Skip to content

猫娘也能懂的动态规划!一起来解 Bitwise Slides 吧喵~

哈喽,各位主人,你们最爱的小助手猫娘我今天又来啦,喵~ฅ(>ω<*)ฅ

今天我们要一起挑战的是一道非常有趣的计数问题,来自 Codeforces Round 983 (Div. 2) 的 F 题,Bitwise Slides!这道题需要一点点动态规划的思维,还有对位运算的小小理解。不过别担心,跟着猫娘的节奏,再复杂的问题也会变得清晰起来的喵!

准备好了吗?让我们开始吧!


题目大意喵

想象一下,我们有一个数组 a,还有三个初始为 0 的神奇变量 P, Q, R。我们要按顺序处理数组 a 中的每一个数字 a_i

每处理一个 a_i,我们必须从下面三个操作中选一个来执行:

  1. a_i 异或P (也就是 P := P ⊕ a_i)
  2. a_i 异或Q (也就是 Q := Q ⊕ a_i)
  3. 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)、QQQRRR 就是三种合法的操作序列。


解题思路喵

一看到这种“按步骤决策”并且“求方案数”的题目,猫娘的 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) 关系只有以下四种:

  1. P = Q = R
  2. P = Q ≠ R
  3. P = R ≠ Q
  4. 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。所以,转移不仅和当前的关系有关,还和它们之间的差值有关!

这个“差值”,用异或来表示最合适了。比如 PR 的差值就是 P ⊕ R

所以,我们最终的 DP 状态可以这样定义(在处理完某个数之后):

  • w_00: P=Q=R 的方案数。
  • m_x0: 一个哈希表(map),记录所有 P=Q≠R 的方案。m_x0[d] 表示满足 P=QP⊕R = d 的方案数。
  • m_0y: 一个哈希表,记录所有 Q=R≠P 的方案。m_0y[d] 表示满足 Q=RP⊕Q = d 的方案数。
  • m_xx: 一个哈希表,记录所有 P=R≠Q 的方案。m_xx[d] 表示满足 P=RP⊕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, QR)。

  • 在大多数情况下,只有一个选择是合法的。例如,如果当前是 P=Q≠R,你把 v 异或给 R,得到 (P, P, R⊕v),这肯定是合法的(因为 P=P)。但如果你异或给 P,得到 (P⊕v, P, R),只有当 P⊕v=R 时才合法。
  • 什么时候会有额外的合法选择呢?
    1. P=Q=R 时,无论 v 异或给谁,新状态都是合法的。所以有 3 个选择。
    2. P=Q≠RP⊕R = v 时,把 v 异或给 PQ 也会产生合法的状态。所以也有 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) 来自:

      1. 旧的 P=Q=R 状态,选择更新 R
      2. 旧的 Q=R≠P 且差值为 v 的状态,选择更新 Q
      3. 旧的 P=R≠Q 且差值为 v 的状态,选择更新 P。 所以:nv_x0 = w_00 + c_0y_v + c_xx_v
    • 同理,我们可以得到另外两类的数量: nv_0y = w_00 + c_x0_v + c_xx_vnv_xx = w_00 + c_x0_v + c_0y_v

3. 代码逻辑

现在我们来看看题解给出的代码是如何实现这个过程的。

cpp
#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 问题有了更深的理解喵!多多练习,你的算法能力一定会像猫咪的跳跃能力一样,越来越强的!

那么,我们下次再见啦,喵~ ( ^ ω ^ )

Released under the MIT License.