P14359 CSP-J 2025 异或和
来源:洛谷题库
文章目录
题目


数据范围

知识点
贪心、前缀和
题意
给定长度为 n 的非负整数序列,选出尽可能多的不相交区间,使得每个区间的异或和等于 k。
思路
题目倒是容易理解,求出异或和为k的区间,选出尽可能多的不相交区间。
问题1:求出异或和为k的区间
问题2:选出尽可能多的不相交区间。
- 解决问题1:要找出所有异或和等于 k 的区间,可以直接暴力枚举:
用三层循环,外层循环固定区间的右端点 r,中层循环固定区间的左端点 l,内层循环把从 l 到 r 的所有数字挨个做异或运算。并检查是否等于 k,将符合条件的区间存储在结构体中。这种方法需要三重循环------显然时间复杂度会过高 只能拿到60分左右 - 解决问题2:已知可选的区间,要在区间1,n择尽可能多的不相交区间,经典的"活动安排"问题,贪心策略是:优先选择结束位置靠前的区间。
csharp
ps:为什么按右端点从小到大选是最优的?
就像你去选兴趣课,优先选最早下课的课,这样后面能排下更多的课。选结束位置最早的区间,能给后面的区间留出最多的空间,自然就能选出最多的不重叠区间。
如果只是按照课程长度排序,课程较短的时间可能开始时间较晚,前面的空余时间无法选课造成时间浪费
如果按照开始时间排序,开始时间小但是课程长度长也会导致将结果不是最优
未优化的55分代码
cpp
#include <bits/stdc++.h>
using namespace std;
const int N = 5*1e5+5;
// 定义区间结构体
struct node {
int l, r;
}nodes[N];
int a[N];
bool cmp(node a,node b){
return a.r< b.r;
}
int main() {
// 优化IO
ios::sync_with_stdio(false);
cin.tie(0);
int n, k;
cin >> n >> k;
for (int i = 1; i <= n; ++i) {
cin >> a[i];
}
// 【暴力枚举】找出所有异或和为 k 的区间
// 这一步在 N=5e5 时会导致 O(N^2) 复杂度 只能拿部分分
int t=0,id=0;
for (int r = 1; r <= n; ++r) {
for (int l = 1; l <= r; ++l) {
t=0;
for (int i= l; i <= r; ++i) {
t ^=a[i];
if (t == k) {
nodes[id++]={l, r};
}
}
}
}
// 按右端点排序
sort(nodes+1, nodes+n+1,cmp);
int ans = 0;
int last_end = 0; // 上一个选中区间的右端点
// 贪心选择
for (int i=0;i<id;i++) {
// 如果当前区间的左端点 > 上一个选中区间的右端点,则不重叠
if (nodes[i].l > last_end) {
ans++;
last_end = nodes[i].r;
}
}
cout << ans << endl;
return 0;
}
优化AC版
一、如何快速求异或和:
三重循环显然超时(一般测评机1s限制,时间复杂度限制在10^8左右),看到任意区间求和我们会想到前缀和,那这个题目能否用前缀和的思路,知道异或公式很好办,不知道的同学就只能考场上自行推一下,看看能不能模仿前缀和形成前缀异或
(自行推导/知识点)
异或运算 ⊕ \oplus ⊕ 有三个性质,它们和加法几乎一模一样:
| 性质 | 公式 |
|---|---|
| 交换律 | a ⊕ b = b ⊕ a a \oplus b = b \oplus a a⊕b=b⊕a |
| 结合律 | ( a ⊕ b ) ⊕ c = a ⊕ ( b ⊕ c ) (a \oplus b) \oplus c = a \oplus (b \oplus c) (a⊕b)⊕c=a⊕(b⊕c) |
| 推导 | a ⊕ b = c a \oplus b = c a⊕b=c, a ⊕ c = b a\oplus c = b a⊕c=b , b ⊕ c = a b\oplus c = a b⊕c=a |
我们定义:
s 0 = 0 , s i = a 1 ⊕ a 2 ⊕ ⋯ ⊕ a i s0 = 0, \qquad si = a_1 \oplus a_2 \oplus \cdots \oplus a_i s0=0,si=a1⊕a2⊕⋯⊕ai
也就是"从头一直异或到第 i i i 个"的结果。
现在把 s r sr sr 展开,它其实是"前半段 + 我们想要的区间":
$ s r = a 1 ⊕ ⋯ ⊕ a l − 1 ⏟ = s l − 1 ⊕ a l ⊕ ⋯ ⊕ a r ⏟ 区间 l , r 的异或和 sr = \underbrace{a_1 \oplus \cdots \oplus a_{l-1}}{=\ sl-1} \oplus \underbrace{a_l \oplus \cdots \oplus a_r}{区间\ l,\\ r\ 的异或和} sr== sl−1 a1⊕⋯⊕al−1⊕区间 l, r 的异或和 al⊕⋯⊕ar
即:
s r = s l − 1 ⊕ s l , r sr = sl-1 \oplus sl, r sr=sl−1⊕sl,r
现在根据推导:
s r ⊕ s l − 1 = s l , r sr \oplus sl-1 =sl, r sr⊕sl−1=sl,r
所以得到结论:
区间 l , r 的异或和 = s r ⊕ s l − 1 \boxed{区间l, r的异或和 = sr \oplus sl-1} 区间l,r的异或和=sr⊕sl−1
对比前缀和:异或版的"减法"就是异或自己
| 区间值公式 | 靠什么"抵消"多出来的部分 | |
|---|---|---|
| 前缀和 | s u m r − s u m l − 1 sumr - suml-1 sumr−suml−1 | 用减法把前半段减掉 |
| 前缀异或 | s r ⊕ s l − 1 sr \oplus sl-1 sr⊕sl−1 | 用异或 把前半段消掉(因为 x ⊕ x = 0 x \oplus x = 0 x⊕x=0) |
| 落到题目上:条件只有一种写法 |
题目要求区间异或和等于 k k k:
s r ⊕ s l − 1 = k sr \oplus sl-1 = k sr⊕sl−1=k
这是整道题的核心钥匙 :一个"区间异或和为 k k k"的问题,被翻译成了**"两个前缀值彼此相差(异或) k k k"**的问题。
用样例验证一下
序列 [2, 1, 0, 3], k = 2 k = 2 k=2,先算出所有前缀异或:
| i i i | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| a i a_i ai | --- | 2 | 1 | 0 | 3 |
| s i si si | 0 | 2 | 3 | 3 | 0 |
- 区间 1 , 1 1, 1 1,1: s 1 ⊕ s 0 = 2 ⊕ 0 = 2 = k s1 \oplus s0 = 2 \oplus 0 = 2 = k s1⊕s0=2⊕0=2=k ✅
- 区间 2 , 4 2, 4 2,4: s 4 ⊕ s 1 = 0 ⊕ 2 = 2 = k s4 \oplus s1 = 0 \oplus 2 = 2 = k s4⊕s1=0⊕2=2=k ✅
二、如何快速求出权值为k的区间
根据公式能快速求出怕l,r异或和,但是需要双重循环遍历区间求其异或和,能不能如何快速找出l所在的位置?
即:遍历到r,l在哪能够是的sl,r=k .
答案:"映射数组"
根据推导,把 s l − 1 sl-1 sl−1 能够通过当前的sr单独解出来,而sl-1之前也求过
s l − 1 = s r ⊕ k \boxed{sl-1 = sr \oplus k} sl−1=sr⊕k
- 转化之后,题目变成了什么
s l − 1 = s r ⊕ k sl-1 = sr \oplus k sl−1=sr⊕k。我们换个视角 ------ 枚举右端点 r r r:
对每一个 r r r,我需要的那个"左端点前一格" j = l − 1 j = l-1 j=l−1,它的前缀值必须等于 s r ⊕ k sr \oplus k sr⊕k。
换句话说:在当前 r r r 之前,有没有哪个位置 j j j,它的 s j sj sj 刚好等于" s r ⊕ k sr \oplus k sr⊕k"?
于是问题从"找区间"变成了一个非常单纯的查询:
给我一个数 v = s r ⊕ k v = sr \oplus k v=sr⊕k,告诉我它以前出现过吗?出现在哪个下标?
- 朴素做法卡在哪里
最笨的办法:每读到一个 r r r,就回头把 s 0 ... r − 1 s0 \\dots r-1 s0...r−1 扫一遍找 v v v。这是 O ( n ) O(n) O(n) 的查询,外层还有 n n n 个 r r r,总复杂度 O ( n 2 ) O(n^2) O(n2) ------ n = 5 × 10 5 n = 5 \times 10^5 n=5×105 时照样超时。
换个思路,不能挨个遍历,只是想找到 s r ⊕ k sr \oplus k sr⊕k这个值是否在s0,r中出现过,因此想到映射数组
- 破局思想:让"值"当数组下标
我们要的极其简单:给一个数值,立刻知道它在之前的哪些位置出现过。
怎么才能"立刻"知道?------ 如果这个数值本身就是数组下标,那就一步到位了。
想到映射数组,应该先看看数组范围:
题目保证 a i < 2 20 a_i < 2^{20} ai<220,所以它们的异或和也一定落在 [ 0 , 2 20 ) [0,\ 2^{20}) [0, 220) 这个范围内 ------ 值域有限,而且不大(约 10 6 10^6 106 个)。
值域有限 ⇒ 我们可以直接开一个长度 2 20 2^{20} 220 的数组,用"前缀异或值"直接当它的下标,数组里存这个值出现的位置:
p o s v = 前缀异或值为 v 的位置 posv = 前缀异或值为\ v\ 的位置 posv=前缀异或值为 v 的位置
这就是一个"值 → 位置"的映射表(也叫桶、直接寻址表)。原本"扫一遍找值"的 O ( n ) O(n) O(n) 查询,变成了"查 p o s v posv posv"的一次数组访问 O ( 1 ) O(1) O(1)。
- 映射表怎么用(跟着代码走一遍)
初始化:pos 全部置 − 1 -1 −1(表示没出现过),但有个容易被忽略的起点 ------
p o s 0 = 0 pos0 = 0 pos0=0
因为 s 0 = 0 s0 = 0 s0=0,它确实"在下标 0 处出现过"。忘了这一步,所有以第一个元素开头的区间都会漏掉。 s i = s i ⊕ 0 si = si \oplus 0 si=si⊕0
然后从左到右扫,每步做三件事:
- 更新前缀异或: s i = s i − 1 ⊕ a i si = si-1 \oplus a_i si=si−1⊕ai;
- 算目标值
target = s[i] ^ k,查pos[target]; - 无论结果如何,把
pos[s[i]] = i(登记这一次的出现位置)。
用样例 [2, 1, 0, 3]、 k = 2 k = 2 k=2 走一遍:
| i i i | a i a_i ai | s i si si | target = s i ⊕ k = si \oplus k =si⊕k |
pos[target] |
判断 | 结果 | 登记后 |
|---|---|---|---|---|---|---|---|
| 1 | 2 | 2 | 0 | 0 | 0 ≥ l a s t _ e n d ( 0 ) 0 \ge last\_end(0) 0≥last_end(0) ✅ | 选中 1 , 1 1,1 1,1,ans=1, l a s t _ e n d = 1 last\_end=1 last_end=1 | pos[2]=1 |
| 2 | 1 | 3 | 1 | −1 | 不存在 ❌ | 跳过 | pos[3]=2 |
| 3 | 0 | 3 | 1 | −1 | 不存在 ❌ | 跳过 | pos[3]=3 |
| 4 | 3 | 0 | 2 | 1 | 1 ≥ l a s t _ e n d ( 1 ) 1 \ge last\_end(1) 1≥last_end(1) ✅ | 选中 2 , 4 2,4 2,4,ans=2, l a s t _ e n d = 4 last\_end=4 last_end=4 | pos[0]=4 |
答案 2 2 2,和样例一致。
其中 j ≥ l a s t _ e n d j \ge last\_end j≥last_end 就是在保证不重叠:区间是 j + 1 , i j+1,\\ i j+1, i,只要 j ≥ l a s t _ e n d j \ge last\_end j≥last_end,左端点 j + 1 j+1 j+1 就严格大于上一个区间的右端点。
- 为什么只存"最后一次"出现的位置就够了
这是本题最妙的一环。我们要的是 j ≥ l a s t _ e n d j \ge last\_end j≥last_end。 p o s v posv posv 存的是最大的 那个 j j j:
- 若最大 的 j j j 都 ≥ l a s t _ e n d \ge last\_end ≥last_end,那它就是一个合法选择 ✅;
- 若最大 的 j j j 都 < l a s t _ e n d < last\_end <last_end,那比它更早的 j j j 只会更小,全部 也会 < l a s t _ e n d < last\_end <last_end,区间有重合,一个都用不上 ❌。
所以"最新的位置"既是最有可能合格的那个,也是最省事的那个 ------ 一个值只需要记一个位置,映射表因此简单又正确。
(每次都覆盖成最新位置 pos[s[i]] = i,就是在做这件事。)
- 复杂度
- 时间 :每个 i i i 只做 O ( 1 ) O(1) O(1) 次的异或、查询、赋值 → O ( n ) O(n) O(n);
- 空间 :
pos数组 2 20 2^{20} 220 个 int,约 4 MB;
n = 5 × 10 5 n = 5 \times 10^5 n=5×105 轻松通过。
优化后的AC代码
csharp
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 2 << 20; // 异或值范围 < 2^20
int pos[MAXN]; // pos[v]: 前缀异或值v出现的位置(如果有多个则为出现的最后一个)
int n, k;
int main() {
ios::sync_with_stdio(false);
cin.tie(0);
memset(pos, -1, sizeof(pos));
pos[0] = 0; // s[0] = 0,出现在下标0处
cin >> n >> k;
int cur_xor = 0; // 当前前缀异或值
int last_end = 0; // 上一个选中区间的右端点
int ans = 0;
for (int i = 1; i <= n; ++i) {
int a;
cin >> a;
cur_xor ^= a;
int target = cur_xor ^ k;
// 如果找到匹配的前缀,且新区间不与已选区间重叠
if (pos[target] >= last_end) {
ans++;
last_end = i; // 更新最后选中的右端点
}
pos[cur_xor] = i; // 记录当前前缀异或值的最新位置
}
cout << ans << endl;
return 0;
}