猫娘也能懂的动态规划!一起来解 Bitwise Slides 吧喵~
哈喽,各位主人,你们最爱的小助手猫娘我今天又来啦,喵~ฅ(>ω<*)ฅ
今天我们要一起挑战的是一道非常有趣的计数问题,来自 Codeforces Round 983 (Div. 2) 的 F 题,Bitwise Slides!这道题需要一点点动态规划的思维,还有对位运算的小小理解。不过别担心,跟着猫娘的节奏,再复杂的问题也会变得清晰起来的喵!
准备好了吗?让我们开始吧!
题目大意喵
想象一下,我们有一个数组 a
,还有三个初始为 0 的神奇变量 P, Q, R
。我们要按顺序处理数组 a
中的每一个数字 a_i
。
每处理一个 a_i
,我们必须从下面三个操作中选一个来执行:
- 把
a_i
异或给P
(也就是P := P ⊕ a_i
) - 把
a_i
异或给Q
(也就是Q := Q ⊕ a_i
) - 把
a_i
异或给R
(也就是R := R ⊕ a_i
)
这里 ⊕
就是按位异或(XOR)操作啦。
不过,有一个非常重要的核心规则必须遵守:在每一次操作之后,P, Q, R
这三个数都不能是两两不相等的。换句话说,每次操作完,必须至少有两个数是相等的。比如 P=Q
或者 P=Q=R
都是可以的,但 P, Q, R
三个数互不相同是绝对禁止的哦!
我们的任务就是,计算从头到尾处理完整个数组,有多少种不同的操作序列是始终满足这个核心规则的。因为答案可能很大,所以需要对 10^9 + 7
取模。
举个例子,如果数组是 [1, 7, 9]
,那么 PPP
(三次都操作P)、QQQ
、RRR
就是三种合法的操作序列。
解题思路喵
一看到这种“按步骤决策”并且“求方案数”的题目,猫娘的 DP雷达就响起来了!这显然可以用动态规划(Dynamic Programming)来解决嘛。
1. DP状态怎么定义?
DP 的核心是定义状态。一个自然的想法是,dp[i][val_P][val_Q][val_R]
表示处理完前 i
个数,P, Q, R
的值分别为 val_P, val_Q, val_R
时的方案数。但是……P, Q, R
的值可能会很大,这根本存不下嘛!此路不通喵。
那我们换个角度。题目中的限制条件是关于 P, Q, R
之间的相等关系,而不是它们具体的值。所以,我们的状态应该围绕这个关系来定义。
在任何一步之后,合法的 (P, Q, R)
关系只有以下四种:
P = Q = R
P = Q ≠ R
P = R ≠ Q
Q = R ≠ P
这看起来好多了!我们可以定义 DP 状态为处理完第 i
个数后,P,Q,R
处于这四种关系下分别有多少种方案。
但是,状态转移的时候,我们会发现一个问题。比如当前是 P=Q≠R
,值为 (A, A, B)
。当我们用下一个数 a_i
更新 R
时,新的状态是 (A, A, B ⊕ a_i)
。如果 B ⊕ a_i
恰好等于 A
,状态就从 P=Q≠R
转移到了 P=Q=R
。所以,转移不仅和当前的关系有关,还和它们之间的差值有关!
这个“差值”,用异或来表示最合适了。比如 P
和 R
的差值就是 P ⊕ R
。
所以,我们最终的 DP 状态可以这样定义(在处理完某个数之后):
w_00
:P=Q=R
的方案数。m_x0
: 一个哈希表(map),记录所有P=Q≠R
的方案。m_x0[d]
表示满足P=Q
且P⊕R = d
的方案数。m_0y
: 一个哈希表,记录所有Q=R≠P
的方案。m_0y[d]
表示满足Q=R
且P⊕Q = d
的方案数。m_xx
: 一个哈希表,记录所有P=R≠Q
的方案。m_xx[d]
表示满足P=R
且P⊕Q = d
的方案数。
2. 状态太多?用“懒人”思想优化!
虽然用了 map,但每次处理一个 a_i
,比如对于 m_x0
中的所有差值 d
,它们都可能变成 d ⊕ a_i
。难道我们要遍历整个 map 去更新 key 吗?太慢啦!
这里有一个非常帅气的技巧——懒标记(Lazy Tag),或者叫差分思想。我们不真的去改 map 里的 key,而是为每个 map 配备一个“全局异或值” delta
。比如 d_x0
。map 中存储的键 k
实际上代表的真实差值是 k ⊕ d_x0
。当所有差值都要异或 v
时,我们只需要更新 d_x0 := d_x0 ⊕ v
就行啦!是不是很巧妙?
方法详解喵
好了,有了 DP 状态和优化技巧,我们来梳理一下处理每个 a_i
(设其值为 v
) 时的具体转移逻辑。
1. 总方案数 W
的更新
我们先考虑总方案数 W
是如何从上一步 W_prev
变成这一步 W_curr
的。
对于上一步的任意一个合法方案,我们有 3 个操作选择(P
, Q
或 R
)。
- 在大多数情况下,只有一个选择是合法的。例如,如果当前是
P=Q≠R
,你把v
异或给R
,得到(P, P, R⊕v)
,这肯定是合法的(因为P=P
)。但如果你异或给P
,得到(P⊕v, P, R)
,只有当P⊕v=R
时才合法。 - 什么时候会有额外的合法选择呢?
- 当
P=Q=R
时,无论v
异或给谁,新状态都是合法的。所以有 3 个选择。 - 当
P=Q≠R
且P⊕R = v
时,把v
异或给P
或Q
也会产生合法的状态。所以也有 3 个选择。
- 当
总结一下,一个方案在当前步骤能获得的合法选择数是 1 + 2 * (如果它满足特殊条件)
。
所以,总方案数的递推公式就是: W_curr = W_prev + 2 * (满足特殊条件的方案总数)
而“满足特殊条件的方案总数”就是: w_00 (P=Q=R的方案数) + c_x0_v + c_0y_v + c_xx_v
其中 c_x0_v
指的是 m_x0
中差值为 v
的方案数,其他两个 c
也类似。
2. DP 状态的转移
这一部分稍微有点绕,但请跟紧猫娘的爪子!
当处理值 v
时,我们会产生一些新的状态。
新的
P=Q=R
状态 (w_00_new
) 怎么才能得到P'=Q'=R'
呢?只能是从一个本身不全相等的状态,通过异或v
恰好把那个不同的数变得和另外两个一样。- 比如,从
P=Q≠R
状态(差值为d
)出发,把v
异或给R
。如果d=v
,那么R
就变成了R⊕v = P
,于是P'=Q'=R'
。 所以,新的w_00
就等于所有差值为v
的方案数之和:w_00_new = c_x0_v + c_0y_v + c_xx_v
- 比如,从
新的 map 状态 这一步是整个算法的精华!经过一番复杂的对称性推导(相信猫娘,这里面全是数学魔法!),我们可以得到每一类新产生的、差值为
v
的方案数:新产生的
P'=Q'≠R'
且差值为v
的方案数 (nv_x0
) 来自:- 旧的
P=Q=R
状态,选择更新R
。 - 旧的
Q=R≠P
且差值为v
的状态,选择更新Q
。 - 旧的
P=R≠Q
且差值为v
的状态,选择更新P
。 所以:nv_x0 = w_00 + c_0y_v + c_xx_v
- 旧的
同理,我们可以得到另外两类的数量:
nv_0y = w_00 + c_x0_v + c_xx_v
nv_xx = w_00 + c_x0_v + c_0y_v
3. 代码逻辑
现在我们来看看题解给出的代码是如何实现这个过程的。
#include <iostream>
#include <vector>
#include <numeric>
#include <unordered_map>
void solve() {
int n;
std::cin >> n;
std::vector<int> a(n);
for (int i = 0; i < n; ++i) {
std::cin >> a[i];
}
long long M = 1e9 + 7;
long long W = 1; // 总方案数,初始为1 (P=Q=R=0)
long long w_00 = 1; // P=Q=R 的方案数,初始为1
// 三个map,以及它们对应的懒标记delta
std::unordered_map<int, long long> m_x0, m_0y, m_xx;
int d_x0 = 0, d_0y = 0, d_xx = 0;
for (int v : a) {
// 查找当前差值为 v 的方案数
long long c_x0_v = 0;
if (m_x0.count(v ^ d_x0)) { // 真实差值d=v, 查找的key是 d^delta
c_x0_v = m_x0.at(v ^ d_x0);
}
long long c_0y_v = 0;
if (m_0y.count(v ^ d_0y)) {
c_0y_v = m_0y.at(v ^ d_0y);
}
long long c_xx_v = 0;
if (m_xx.count(v ^ d_xx)) {
c_xx_v = m_xx.at(v ^ d_xx);
}
// 1. 更新总方案数 W
long long num_extra_choices_paths = (w_00 + c_x0_v + c_0y_v + c_xx_v) % M;
W = (W + 2 * num_extra_choices_paths) % M;
if (W < 0) W += M;
// 2. 计算下一轮的 w_00
long long w_00_new = (c_x0_v + c_0y_v + c_xx_v) % M;
// 保存旧的delta,用于在map中定位
int old_d_x0 = d_x0;
int old_d_0y = d_0y;
int old_d_xx = d_xx;
// 3. 计算新产生的、差值为 v 的各类方案数
long long nv_x0 = (w_00 + c_0y_v + c_xx_v) % M;
long long nv_0y = (w_00 + c_x0_v + c_xx_v) % M;
long long nv_xx = (w_00 + c_x0_v + c_0y_v) % M;
// 4. 更新懒标记
d_x0 ^= v;
d_0y ^= v;
d_xx ^= v;
// 5. 更新DP状态
w_00 = w_00_new;
// 将新产生的方案数存入map
// 旧的map中的条目因为delta的改变,已经自动“更新”了它们的实际差值
// 这里是把新产生的方案(差值都为v)加入map
// 定位的key是 old_d_x0, 因为 old_d_x0 ^ d_x0_new = old_d_x0 ^ (old_d_x0 ^ v) = v
if (nv_x0 > 0) m_x0[old_d_x0] = nv_x0; else m_x0.erase(old_d_x0);
if (nv_0y > 0) m_0y[old_d_0y] = nv_0y; else m_0y.erase(old_d_0y);
if (nv_xx > 0) m_xx[old_d_xx] = nv_xx; else m_xx.erase(old_d_xx);
}
std::cout << W << "\n";
}
int main() {
std::ios_base::sync_with_stdio(false);
std::cin.tie(NULL);
int t;
std::cin >> t;
while (t--) {
solve();
}
return 0;
}
这段代码的逻辑有些紧凑,特别是 map 的更新方式。它直接用新生成的方案数 nv_x0
覆盖了 map 中对应的位置。这背后隐藏的逻辑是,原有的方案路径要么转换为其他类型的状态,要么其差值被 delta
的改变而隐式更新,而新生成的路径则统一汇集到差值为 v
的这一条上。
知识点小课堂喵
1. 位运算之异或 (XOR)
异或(^
)是一个神奇的二进制运算。你可以把它想象成“不带进位的加法”。
a ^ a = 0
(一个数和自己异或等于0)a ^ 0 = a
(一个数和0异或还是它自己)a ^ b = b ^ a
(交换律)(a ^ b) ^ c = a ^ (b ^ c)
(结合律)
在题目里,a^a=0
这个性质被用来“消除”差值,使得两个不同的数变得相同。结合律和交换律则让 delta
懒标记的实现变得非常方便。
2. 动态规划 (DP)
DP 的本质是“记住过去,解决未来”。我们把一个大问题分解成一个个小阶段,每个阶段的状态只依赖于前一个阶段。通过记录和利用之前阶段的计算结果(备忘录),来避免重复计算,从而高效地解决问题。这道题就是典型的按序列步骤划分阶段的 DP。
3. 哈希表 (std::unordered_map)
当我们的 DP 状态的某个维度(比如本题的“差值”)取值范围很大,但实际用到的状态数量又不多时,用数组来存会浪费大量空间。这时,哈希表(在 C++ 中是 std::unordered_map
)就闪亮登场啦!它只为实际存在的 (key, value)
对分配空间,非常适合存储这种稀疏的状态。
总结喵
呼~ 这道题确实有点挑战性,对吧?它把动态规划、位运算和哈希表巧妙地结合在了一起。关键在于正确地识别出问题的核心结构——P, Q, R
之间的相等关系和异或差值,并为此设计出合适的 DP 状态。
希望通过猫娘的讲解,主人对这类计数 DP 问题有了更深的理解喵!多多练习,你的算法能力一定会像猫咪的跳跃能力一样,越来越强的!
那么,我们下次再见啦,喵~ ( ^ ω ^ )