☰
划分型DP与前缀异或和:序列切段问题的完整解题与避坑指南
2026/10/15 5:34:03 网站建设 项目流程

刷题群里有人甩出来一道题,标签是“划分型dp 前缀异或和”,题号P7335,名字就叫“异或”,难度普及+。我第一反应是:这又是一个把经典模型包装得花里胡哨的题。但真正动手推的时候发现,它值得好好写一篇笔记——因为“划分型dp”和“前缀异或和”这两个东西单拎出来都是基础,但组合在一起时,状态定义、转移顺序、边界处理、甚至异或运算的优先级都会变成坑。这篇东西不写虚的,直接拆题:前缀异或和怎么预处理、dp状态为什么这么设、转移方程怎么一步步推出来、代码怎么写不踩坑、以及我调这题时真实踩过的几个雷。如果你正在刷普及组到提高组的动态规划,或者对“序列切段”这类题还处于“看得懂题解、自己写就卡住”的阶段,这篇应该对你有用。

1. 先把题目抽象成一个可以计算的模型

1.1 从标签反推题目意图

拿到题先别急着敲代码,先看标签。

“划分型dp”意味着什么?意味着题目要求我们把一个序列切成若干个连续段,段与段之间不重叠、不留空隙,然后每一段单独产生一个贡献,最后把所有贡献合起来,求最优值。这是序列划分类问题的统一骨架:你有 n 个物品排成一排,要切若干刀,每段贡献由段内元素决定,目标是最大化或最小化总贡献。

“前缀异或和”这个标签更直接。它告诉你两件事:第一,段内贡献大概率是“区间异或和”之类的量;第二,题目希望你把区间异或的计算从 O(len) 降到 O(1)。如果你看到这两个标签,脑子里还没有出现 pre[i] = pre[i-1] ^ a[i] 这个式子,那说明刷题量还没到位,或者对异或的“自反性”不够敏感。

再说“普及+”这个难度标签。它基本宣判:正解不需要高级数据结构,不需要二维状态加斜率优化,不需要可持久化字典树,老老实实把dp方程推出来,写一个 O(n^2) 或 O(k n^2) 的循环,就能过。这个难度层级下,题目真正想考的其实只有两件事:你能不能从“枚举所有划分”的暴力思维里跳出来,能不能把“区间异或”这个工具用在 dp 转移里。

至于题面长什么样,网上流传的版本可以归结为最经典的那个模型:给定一个长度为 n 的序列,要求把它划分成若干个连续非空段,每一段的得分为这段内所有数的异或值,求总得分最大的划分方案。部分版本还会加一个“恰好划分成 k 段”的限制,这也是这类题在竞赛里最常见的形态。本文下面的推导按“恰好 k 段”来写,因为任意段数的版本只是它的退化形式,代码反而更好写,我会在第三部分专门说明。

1.2 前缀异或和:先造一个 O(1) 的区间查询工具

异或运算有三个初中就会的性质:

  • a ^ a = 0,一个数异或自己等于 0;
  • a ^ 0 = a,一个数异或 0 还是自己;
  • 异或满足交换律和结合律。

基于这三条,我们可以定义一个前缀异或数组:

pre[0] = 0
pre[i] = pre[i-1] ^ a[i]

也就是说,pre[i] 表示从第 1 个数一路异或到第 i 个数的结果。

现在问题来了:我想快速求区间 [l, r] 的异或和,怎么办?

用 pre 数组:

区间 [l, r] 的异或和
= a[l] ^ a[l+1] ^ ... ^ a[r]
= (a[1] ^ ... ^ a[r]) ^ (a[1] ^ ... ^ a[l-1])
= pre[r] ^ pre[l-1]

为什么能这么消?因为 pre[r] 是 1 到 r 全部异或,pre[l-1] 是 1 到 l-1 全部异或,两者相异或时,1 到 l-1 的部分每个数出现了两次,也就是异或了两遍,直接变成 0,剩下的恰好就是 l 到 r 这一段。这就是我前面说的“自反性”:异或的逆运算还是异或自己。求异或和不用做减法,做一次异或就行,这是前缀和思想在异或世界的直接翻版。

有一个细节必须记住:区间 [l, r] 的异或和是 pre[r] ^ pre[l-1],不是 pre[r] ^ pre[l]。很多第一次写的人会把下表弄错,后面所有转移方程全部跟着错。pre[i] ^ pre[j] 表示的是从 j+1 到 i 这一段的异或值,其中 j 是左端点减一的位置。

提示:凡是涉及异或的式子,在 C++ 里一定要加括号。因为+的优先级高于^,dp[j] + pre[i] ^ pre[j]会被解析成(dp[j] + pre[i]) ^ pre[j],结果完全不对。这个坑我后面还会再强调一次,因为它真的能浪费你半小时。

1.3 划分型dp:为什么敢用“前 i 个数”当状态

很多人学dp时最困惑的其实是这句:“为什么状态是 dp[i] / dp[t][i],而不是记录具体每一刀切在哪?”

答案在于序列划分问题天然满足两个性质:最优子结构和无后效性。当你决定把最后一段定成“从 j+1 到 i”,那么前面 1 到 j 这一段怎么切,和最后一段怎么切没有关系。最后一段的贡献只取决于区间 [j+1, i] 的异或和,而前面部分的贡献只取决于 1 到 j 这一段内部怎么划分。二者互不干扰,直接相加取最大即可。

换成生活化的类比:你有一根香肠要切几刀,每段按重量算收益。你只需要关心“切到第 i 节时最优能得多少”,而不需要记录每一刀切在哪个缝里。因为当你准备切最后一段时,前面的香肠已经被切成最优状态了,那个最优值已经被 dp 数组记住,后面怎么切都不会影响前面的得分。

这个性质就是划分型dp和区间dp最核心的区别。区间dp的状态是“一段区间”,因为区间内部两个子区间会互相嵌套;而划分型dp的状态是“前缀”,所有段是从左往右一条线切下去的,天然有顺序,所以只需要一维或二维顺序推下去。

2. dp方程推导:每一步都说得清为什么

2.1 先看看暴力枚举划分为什么不靠谱

假设序列长度是 n,那么相邻元素之间有 n-1 个可能的切口。每个切口选择切或不切,总方案数是 2^(n-1)。n=10 是 512 种,n=30 已经超过 5 亿种,n=100 你连枚举完都做不到。这还没算每种划分都要重新计算每一段的异或和,如果每段都暴力算,复杂度根本没有上限。

所以这类题必须用动态规划。核心思路是:不枚举“整组划分”,只枚举“最后一段从哪里开始”。这样我们就把一个指数级的问题拆成了 O(n^2) 级别的子问题。你可能觉得这跳了一大步,但实际上就是一个反过来的思考方式——从“我要决定所有刀的位置”,变成“我只决定最后一刀的位置,前面交给子问题”。

2.2 状态定义与转移方程

我们按“恰好分成 k 段”的版本来推。

定义:

dp[t][i] 表示把前 i 个数恰好分成 t 段,这 t 段的得分之和最大值。

考虑最后一段。如果最后一段是 [j+1, i],那么它前面的 j 个数必须被分成恰好 t-1 段。根据 dp 的定义,前面部分的最优贡献就是 dp[t-1][j]。

而最后一段 [j+1, i] 的异或和,用前缀异或数组可以 O(1) 算出:

val = pre[i] ^ pre[j]

所以转移方程就是:

dp[t][i] = max( dp[t-1][j] + (pre[i] ^ pre[j]) ),其中 j 的范围是 t-1 <= j < i

为什么 j 下界是 t-1?因为前 t-1 段至少要占 t-1 个数,如果 j < t-1,dp[t-1][j] 是无意义的非法状态。为什么上界是 i-1?因为最后一段必须非空,j 最多到 i-1,最少给自己留一个数。

边界条件:

dp[0][0] = 0,表示 0 个数分成 0 段,得分为 0。
dp[0][i>0] = 负无穷,因为 0 段不可能装下正数个数。
负无穷用 -4e18 或 -1e18 这种足够小的数,避免加法溢出后变成正数。

答案就是 dp[k][n]。如果它还是负无穷,说明不存在合法划分,一般题目不会出现这种情况。

2.3 手推一个完整小样例

空对着一堆公式容易晕,拿一组小数据一步一步算。

假设序列是:

a = [3, 5, 6, 2]

先算前缀异或:

pre[0] = 0
pre[1] = 3
pre[2] = 3 ^ 5 = 6
pre[3] = 6 ^ 6 = 0
pre[4] = 0 ^ 2 = 2

所以 pre = [0, 3, 6, 0, 2]。

现在求恰好分成 k 段的最大值。

先看 k=1。只有一整段,得分就是整个序列的异或和 pre[4],等于 2。逐项写出来:

dp[1][1] = pre[1] = 3
dp[1][2] = pre[2] = 6
dp[1][3] = pre[3] = 0
dp[1][4] = pre[4] = 2

再看 k=2。

dp[2][2]:前 2 个数分成 2 段,只有一种切法:[3] 和 [5]。
= dp[1][1] + (pre[2] ^ pre[1])
= 3 + (6 ^ 3)
= 3 + 5
= 8

dp[2][3]:前 3 个数分成 2 段,需要比较最后一刀的位置。
最后一刀在 1 和 2 之间:dp[1][1] + (pre[3] ^ pre[1]) = 3 + (0 ^ 3) = 6
最后一刀在 2 和 3 之间:dp[1][2] + (pre[3] ^ pre[2]) = 6 + (0 ^ 6) = 12
所以 dp[2][3] = 12,对应划分 [3,5] 和 [6]。

dp[2][4]:
j=1:dp[1][1] + (pre[4] ^ pre[1]) = 3 + (2 ^ 3) = 4
j=2:dp[1][2] + (pre[4] ^ pre[2]) = 6 + (2 ^ 6) = 10
j=3:dp[1][3] + (pre[4] ^ pre[3]) = 0 + (2 ^ 0) = 2
所以 dp[2][4] = 10。

再看 k=3。

dp[3][3]:前 3 个数分成 3 段,每段一个数,得分 3+5+6 = 14。
用转移算:dp[2][2] + (pre[3] ^ pre[2]) = 8 + (0 ^ 6) = 14。

dp[3][4]:
j=2:dp[2][2] + (pre[4] ^ pre[2]) = 8 + (2 ^ 6) = 8 + 4 = 12
对应划分 [3], [5], [6,2]:得分 3 + 5 + (6^2=4) = 12。
j=3:dp[2][3] + (pre[4] ^ pre[3]) = 12 + (2 ^ 0) = 12 + 2 = 14
对应划分 [3,5], [6], [2]:得分 6 + 6 + 2 = 14。
所以 dp[3][4] = 14。

汇总一下:

ti=1i=2i=3i=4
13602
2-inf81210
3-inf-inf1414

这个表格推完,整个转移过程就非常清晰了:每一层 t 完全由上一层 t-1 推出来,而且每一格都只依赖上一行的某些列,不会出现回头依赖本行后面格子的情况。这就是划分型dp“线性推进”的直观体现。

3. 两种代码实现与复杂度分析

3.1 任意段数版本的“退化”现象

先讲一个非常重要的观察。如果题目真的不限制段数,只要求“划分成若干段,每段贡献为异或和,求总得分最大”,那这个题其实有更简单的结论:最优答案就是所有数的算术和,也就是每个数单独分成一段。

原因是这样的:对于任意一段,段内异或和按二进制位看,第 b 位上如果 1 的个数是奇数,异或结果该位为 1,贡献 2^b;如果 1 的个数是偶数,贡献 0。而算术和在第 b 位上贡献的是“1 的个数乘以 2^b”。显然后者不小于前者。逐位累加之后,一段的异或和一定不超过这一段所有数的算术和。把所有段加起来,总得分一定不超过整个数组的算术和。而每个数单独成段时,正好取到整个数组的算术和。所以最大值就是 sum(a)。

如果你只写一维 dp:

vector<long long> dp(n + 1, 0); for (int i = 1; i <= n; i++) { for (int j = 0; j < i; j++) { dp[i] = max(dp[i], dp[j] + (pre[i] ^ pre[j])); } } cout << dp[n] << '\n';

这个代码虽然在跑 O(n^2) 的动态规划,但算出来的结果一定等于 sum(a)。这不代表dp没用,反而说明一个很实际的问题:如果原题真的是“任意段数”版,出题人不会让你写这么绕的dp,因为结论太简单,连贪心都算不上。所以大多数这类题一定会带“恰好分成 k 段”这样的附加限制,这也是我在本文主推二维版的原因。

3.2 恰好 k 段的完整C++实现

下面是完整的滚动数组实现:用两层 dp 数组,把第二维 t 滚动掉。因为 dp[t] 只依赖 dp[t-1],没有必要开 n*k 的空间,复杂度可以从 O(kn) 降到 O(n) 的空间。

#include <bits/stdc++.h> using namespace std; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n, k; cin >> n >> k; vector<int> a(n + 1), pre(n + 1); for (int i = 1; i <= n; i++) { cin >> a[i]; pre[i] = pre[i - 1] ^ a[i]; } const long long NEG = -4e18; vector<long long> dp(n + 1, NEG), ndp(n + 1); dp[0] = 0; // 0 个数分成 0 段 for (int t = 1; t <= k; t++) { fill(ndp.begin(), ndp.end(), NEG); // i 至少为 t:t 段至少要 t 个数 for (int i = t; i <= n; i++) { // j 至少为 t-1,最多为 i-1 for (int j = t - 1; j < i; j++) { if (dp[j] == NEG) continue; ndp[i] = max(ndp[i], dp[j] + (long long)(pre[i] ^ pre[j])); } } swap(dp, ndp); } cout << dp[n] << '\n'; return 0; }

说几个代码里容易写错的地方。

第一,(long long)(pre[i] ^ pre[j])这个强转不是可有可无的。pre 是 int 数组,两个 int 异或结果还是 int,如果最高位是 1,这个 int 就是负数,和 dp[j] 相加时可能会出问题。虽然通常结果能自动转成 long long,但我建议显式强转,养成习惯,省得在特殊数据下踩坑。

第二,循环范围不要“图省事”写成j = 0; j < i; j++。当 t 比较大时,j 从 0 到 t-2 的 dp 值全是负无穷,跑过去不仅浪费时间,还容易在中途不小心把非法状态当成合法状态。直接从 t-1 开始枚举,既符合定义又省一点常数。

第三,滚动数组的swap要理解透。每一轮 t 结束,dp保存的是“当前 t 段”的状态,ndp被清空准备给下一轮用。如果你把dp和ndp的角色搞混,会出现“上一层状态被覆盖”的情况,答案全错。

3.3 复杂度分析和什么时候需要继续优化

这个二维dp的复杂度是 O(k * n^2)。具体来说,第 t 层的 i 从 t 到 n,每个 i 内部的 j 平均要枚举约 n/2 次,所以总操作次数大概是 k * n^2 / 2 量级。

实战中可以这么估算:如果 k * n^2 大约在 1e8 以内,C++ 的简单循环基本能过;到了 2e8 就要非常注意常数;超过 5e8 基本必超时。举个例子,n=500, k=400,乘积是 1e8,能过;n=2000, k=50,乘积是 2e8,有点悬,需要关掉同步流、少用min/max函数、把dp数组放在栈外(开成全局变量)才有可能卡过;n=5000, k=50,乘积是 1.25e9,必死。

如果真的遇到 n 和 k 都很大的版本,抱歉,这里没有银弹。你可能会想到用字典树维护“最大的异或和”那套技巧,但注意我们的转移式是 dp[t-1][j] + (pre[i] ^ pre[j]),多了一个 dp[t-1][j] 加权项。纯异或最大可以用字典树贪心,但加上加法项之后,字典树没法直接维护“每个节点上挂的最优 dp[j] 值”这种信息,因为 dp[j] 和 pre[j] 之间没有单调性,不能简单贪心。所以这类题的正解往往就是这个 O(k n^2) 的朴素dp,出题人会把数据范围卡在“让你过,但逼你把循环写干净”的程度。

另一点值得提的是,如果题目从“恰好 k 段”变成“至多 k 段”,你只需要在最后取 max(dp[1][n], dp[2][n], ..., dp[k][n]) 就行,或者把 dp[t][i] 的定义改成“不超过 t 段”,转移时加一句 dp[t][i] = max(dp[t][i], dp[t-1][i])。这类改动很常见,但核心转移不变。

4. 常见问题与避坑实录

4.1 五个最容易踩的坑

我把这题真正的易错点整理成了一张表。每一个都是我自己或身边朋友真实踩过的,不是凭空编的。

踩坑点错误写法后果正确做法
运算符优先级dp[j] + pre[i] ^ pre[j]被解析成(dp[j]+pre[i]) ^ pre[j],答案错得离谱写成dp[j] + (pre[i] ^ pre[j])
pre 数组下标用pre[i] ^ a[j]算区间忘记区间异或要经过前缀异或“做差”区间 [j+1,i] 一定是pre[i] ^ pre[j]
dp 初始值全部初始化为 0非法状态变成“合法且价值为 0”,转移被污染dp[0][0]=0,其余-inf
循环下界j从 0 开始枚举多跑一堆无效状态,且可能让空段混进来j至少从t-1开始
数据范围用 int 存 dp 值异或结果和累加值可能超过 2^31-1用 long long

这里多说一句-4e18这个初始值。为什么不用INT_MIN?因为负无穷要参与加法运算,如果负无穷不够小,加上一个正数之后可能不再小于任何合法值,甚至溢出成正数。-4e18在 long long 范围内足够小,加任何异或值都还是负的,不会污染答案。但要注意,4e18本身已经接近 long long 上限的 9e18 的一半,所以代码里const long long NEG = -4e18;的写法是安全的,不要写成-1e19,那会直接溢出成 undefined behavior。

4.2 调试与对拍套路

如果手推样例没错,但提交就是 WA,那大概率是某个隐蔽的边界写错了。这种情况下最有效的方式是写一个暴力程序做对拍。

暴力的写法不复杂:用 DFS 枚举所有合法的划分。对于恰好 k 段,可以递归决定每一刀的落点;对于任意段数,直接枚举所有 n-1 个缝切或不切。然后小规模数据随机生成,对拍脚本跑几万组,只要有一组不一样,立刻就能定位问题。

一个小技巧:对拍时数据范围不要大,n 从 1 到 10,值域也从 0 到 15 这种小范围取,这样可以保证暴力枚举所有划分能在极短时间内跑完,而且小数据更容易暴露边界问题。

另外,在调试阶段建议专门打印一下 pre 数组。我遇到过不止一次,pre 算对了,dp 全错,最后发现是下标从 0 开始还是从 1 开始的习惯没统一,把pre[0]和pre[1]的语义搞混了。前缀数组打印出来对着手算表格一项项核对,误差基本藏不住。

4.3 这类题还能怎么扩展

划分型dp加前缀异或和这个组合,在算法题里算是一个高频母题。掌握了这个题,可以顺手迁移到很多变体:

  • 子数组异或和等于某个值 x 的计数。这个甚至不用dp,只用一个哈希表统计 pre 值出现次数,遇到 pre[i] ^ x 就累计答案,O(n) 解决。核心还是 pre 数组。
  • 划分成若干段,使每一段异或和都不为 0,求最少段数。定义 dp[i] 为前 i 个数合法划分的最少段数,转移时检查 pre[i] ^ pre[j] != 0。这里可以利用“异或为 0 的两个前缀值相等”这个性质做值域哈希,把 O(n^2) 降到 O(n)。
  • 把每段的价值改成异或和的平方、异或和与段长的乘积、或者异或和的某种组合函数。状态定义不变,变的只是转移式里的价值计算部分。碰到这类题,先别急着套高级优化,老老实实把 O(k n^2) 的朴素dp写好,再看数据范围决定是否优化。

提示:遇到“连续段”“段贡献”“前缀可查询”这三个关键词同时出现时,优先往“前缀和/前缀异或 + 划分型dp”这个方向想。大多数普及+到提高难度区间的题,不会在这个套路之外再绕弯子。

说回代码本身。我在第一次写这道题时,就在优先级那个坑里卡了很久。当时我觉得自己写得很顺,结果跑样例一直不对,最后一步步拆表达式才发现dp[j] + pre[i] ^ pre[j]被编译器理解成了另一个完全不同的东西。从那以后我给自己立了一条规矩:所有包含异或的表达式,一律把异或部分用括号包起来,不管优先级是什么,先包了再说。这条规矩后来帮我避开了很多类似的坑。

还有一个小建议:写这种划分型dp时,先把“最后一刀”想清楚。不要一上来就写代码,先在纸上把 dp[t][i] 的定义、j 的取值范围、边界条件写三行。这三行写明白了,代码就是照抄的事。如果这三行里任何一行含糊,写出来的代码大概率要调很久。我见过太多人纠结“为什么 j 要从 t-1 开始”而不是“为什么最后一段是 j+1 到 i”,其实后者才是理解这道题的钥匙。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询