P14359 [CSP-J 2025] 异或和

课堂目标:从异或基础出发,推导前缀异或公式,再用贪心选最多个不相交区间。

前缀异或 位运算 贪心 状态记录

一、先回答问题

一句话结论

本题本质上是:找最多个互不重叠的连续区间,并且每个区间里的所有数异或起来都等于 k

后面的前缀异或和贪心,都是为了更快地找到这些合法区间。

课堂路线

先复习异或,再讲前缀异或为什么能“抵消”,接着手推样例,最后读代码里的 preneedposlastEnd

什么叫一个合法区间

如果选择区间 [l,r],它必须满足:

a[l] ^ a[l+1] ^ ... ^ a[r] = k

并且两个被选中的区间不能共用任何下标。比如选了 [1,3],下一个区间就只能从 4 或更后面开始。

二、异或常用知识

逐位比较

异或符号是 ^。两个二进制位相同得 0,不同得 1。

5 101 二进制 101
3 011 二进制 011
^ 110 结果 110 = 6
本题最常用的 4 条
抵消x ^ x = 0
空值x ^ 0 = x
换顺序a ^ b = b ^ a
反推A ^ B = k
B = A ^ k

重点记住:重复的一段异或两次会消失,所以前缀异或可以像前缀和一样求区间。

三、从暴力想到前缀异或

学生容易先想到

枚举所有 [l,r],再计算每段异或和。区间数量是 级别,而本题 n ≤ 5×10⁵,不能这样做。

关键转换

固定右端点 r,不要再枚举全部左端点。用前缀异或把“这一段的值”变成“找以前有没有某个前缀值”。

前序思路:O(n²) DP

先不要急着优化。设 dp[r] 表示只看前 r 个数,最多能选多少个不相交的合法区间。

如果 [l,r] 合法:dp[r] = max(dp[r], dp[l-1] + 1)

原因是:选了 [l,r] 后,它前面的区间只能放在 1..l-1,这样才不会重叠。

for (int r = 1; r <= n; r++) {
    dp[r] = dp[r - 1]; // 不选以 r 结尾的区间

    int xr = 0;
    for (int l = r; l >= 1; l--) {
        xr ^= a[l]; // 当前区间 [l, r] 的异或和

        if (xr == k) {
            dp[r] = max(dp[r], dp[l - 1] + 1);
        }
    }
}

这段代码的价值是帮助学生理解“不相交”如何进入状态转移;问题是两层循环,复杂度是 O(n²),本题数据过不了。

公式推导

pre[i] = a[1] ^ a[2] ^ ... ^ a[i],那么:

区间 [l,r] 的异或和 = pre[r] ^ pre[l-1]

题目要求区间异或和等于 k

pre[r] ^ pre[l-1] = k

已知当前的 pre[r],要反推以前的 pre[l-1]

pre[l-1] = pre[r] ^ k

所以扫描到 r 时,查找 need = pre[r] ^ k 是否出现过即可。

优化本质

暴力慢在:每个右端点 r 都要向左枚举很多个 l。前缀异或把问题改成:以前有没有出现过 pre[r] ^ k

pos[value] = 前缀异或值 value 最近一次出现的位置

于是 need = pre ^ k 后,直接查 pos[need]。如果查到位置 j,合法区间就是 [j+1, r]

这一步把每个右端点的向左查找从 O(n) 变成数组查询 O(1)。本题值域小于 2²⁰,所以用数组比 map 更直接。

为什么贪心有效

这题是在所有合法区间里选最多个不重叠区间。经典选择方式是:每次选右端点最早的合法区间。

区间越早结束,后面剩下的位置越多。我们从左到右扫描,第一次遇到能结束的合法区间时,它就是当前最早结束的选择,所以可以立刻选。

注意:pos[need] 取最近位置,会让当前区间尽量短;但贪心成立的关键是“右端点最早”,不是单纯因为“区间最短”。

为什么要 pos[0] = 0

pre[0] = 0 表示“一个数都不选”的空前缀,pos[0] = 0 表示这个空前缀出现在位置 0。

它专门用来发现从第 1 个数开始的合法区间。比如 a[1] = k 时,pre[1] = k,于是 need = pre[1] ^ k = 0,这时必须能找到 pre[0],才能选出区间 [1,1]

普通前缀和要 sum[0] = 0;前缀异或也要 pre[0] = 0。

四、互动演示:扫样例

样例:a = [2, 1, 0, 3]k = 2。我们从左到右扫描,每次维护 prepos

当前 i0
pre0
need-
lastEnd0
答案0
pos 数组当前记录 橙色 = 本步查找的 need;-1 = 还没出现

五、自己选答案

问题 1

如果当前 pre = 3k = 2,应该去找哪个以前出现过的前缀异或值?

问题 2

为什么要检查 pos[need] >= lastEnd

课堂追问

如果先执行 pos[pre] = i,再判断 pos[need],当 k = 0 时会发生什么?

提示:这时 need = pre,当前位置可能被自己当成“以前出现过的位置”。

六、手推计算表

i a[i] pre need = pre ^ k 查 pos[need] 判断与动作
00-pos[0] = 0空前缀,给从 1 开始的区间做准备
1220pos[0] = 00 ≥ lastEnd,选 [1,1],ans = 1
2131pos[1] 不存在不能选,记录 pos[3] = 2
3031pos[1] 不存在不能选,更新 pos[3] = 3
4302pos[2] = 11 ≥ lastEnd,选 [2,4],ans = 2

七、C++14 参考代码

#include <bits/stdc++.h>
using namespace std;

const int MAXV = 1 << 20;

int pos[MAXV];

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n, k;
    cin >> n >> k;

    for (int i = 0; i < MAXV; i++) pos[i] = -1;

    int pre = 0;
    int lastEnd = 0;
    int ans = 0;
    pos[0] = 0;  // 空前缀

    for (int i = 1; i <= n; i++) {
        int x;
        cin >> x;
        pre ^= x;

        int need = pre ^ k;
        if (pos[need] >= lastEnd) {
            ans++;
            lastEnd = i;
        }

        // 必须在判断之后更新,避免 k=0 时选到空区间
        pos[pre] = i;
    }

    cout << ans << '\n';
    return 0;
}

八、复杂度与易错点

复杂度

初始化 pos 需要 2²⁰ 次,扫描数组需要 n 次,所以时间复杂度是 O(n + 2²⁰)

前缀异或值范围在 0..2²⁰-1,所以空间复杂度是 O(2²⁰)

最容易错

不要把区间异或写成普通减法;不要忘记 pos[0] = 0;不要先更新再判断;不要忘记检查 lastEnd

错法 1:只会枚举区间,复杂度过高。
错法 2:知道前缀异或,但不会推出 pre[l-1] = pre[r] ^ k
错法 3:找到合法区间后没有更新 lastEnd
错法 4:把 pos 当成出现次数,而不是位置。

九、同类练习

基础:异或抵消

P1469 找筷子

只用 x ^ x = 0x ^ 0 = x。目标是把异或的“成对抵消”练熟。

打开题目

巩固:前缀异或

LeetCode 1310

多次询问数组某段的异或和,直接训练 pre[r+1] ^ pre[l] 的写法。

打开题目

提高:树上前缀异或

P2420 让我们异或吧

把数组上的前缀异或迁移到树上:根到点的异或值记为 dis[x],路径异或就是 dis[u] ^ dis[v]

打开题目