【LeetCode 题解】交替按位异或分割:位运算、状态机与 Java 内存优化
在 LeetCode 的数组分割类题目中,异或 (XOR) 是一个非常常见的考察点。这道"交替按位异或分割"不仅考察了前缀和的逆向思维,还考察了如何通过动态规划(DP)处理"交替状态"的约束。
更值得一提的是,在 Java 实现中,如何定义 DP 数组的维度([N][2] 还是 [2][N])竟然会对性能产生巨大的影响。本文将详细拆解这道题的思路与实现细节。
1. 题目简述
输入 :数组 nums,两个目标值 target1 和 target2。
目标 :将数组切分成连续的块,使得块的异或和依次为 target1, target2, target1, ...
输出 :方案总数(对 109+710^9+7109+7 取余)。
2. 核心数学推导:前缀异或的逆运算
解决区间异或问题,最快的方法永远是利用前缀异或性质。
设 P[i] 为数组从 0 到 i 的前缀异或和。
对于任意子数组 nums[j...i],其异或和等于:
XOR(j...i)=P[i]⊕P[j−1] XOR(j...i) = P[i] \oplus P[j-1] XOR(j...i)=P[i]⊕P[j−1]
如果我们希望当前切出来的这一块(截止到 i)的异或值等于 Target,即:
P[i]⊕P[j−1]=Target P[i] \oplus P[j-1] = Target P[i]⊕P[j−1]=Target
根据异或的性质(A⊕B=C⇒A⊕C=BA \oplus B = C \Rightarrow A \oplus C = BA⊕B=C⇒A⊕C=B),我们可以反推:
P[j−1]=P[i]⊕Target P[j-1] = P[i] \oplus Target P[j−1]=P[i]⊕Target
结论 :
当我们遍历到位置 i 时,不需要回头去暴力寻找切分点 j。我们只需要问:"在之前的历史中,前缀异或和等于 P[i] ^ Target 的情况出现过多少次?"
3. 动态规划:交替状态机
题目不仅要求切出 Target,还要求 交替 。
这意味着我们不能只记录"前缀和出现的次数",必须区分这个前缀和是处于什么状态下结束的。
我们需要记录两个状态流:
- T1 状态 :刚刚切完一个
target1的块。 - T2 状态 :刚刚切完一个
target2的块。
状态定义
为了极致的 O(1)O(1)O(1) 查询速度,我们利用数据范围(nums[i]≤105nums[i] \le 10^5nums[i]≤105)直接开数组。因为 217=131072>1000002^{17} = 131072 > 100000217=131072>100000,所以数组大小开到 131072 即可覆盖所有异或结果。
我们定义 dp 数组如下:
dp[0][v]:前缀异或和为v,且以 target1 结尾的方案数。dp[1][v]:前缀异或和为v,且以 target2 结尾的方案数。
(注:这里为了后续内存优化,我们将状态维 0/1 放在了第一维,具体原因见下文)
状态转移
假设当前前缀异或和为 curr:
-
我想凑出
target1:- 必须接在
target2后面。 - 查找历史:
count = dp[1][curr ^ target1]。 - Base Case :如果
curr == target1,说明可以从头直接切,方案数 +1。
- 必须接在
-
我想凑出
target2:- 必须接在
target1后面。 - 查找历史:
count = dp[0][curr ^ target2]。
- 必须接在
4. Java 实现的关键细节:内存布局
这道题在 Java 中有一个经典的性能陷阱。
如果我们定义 int[][] dp = new int[131072][2]:
- Java 会创建 131,072 个小数组对象。
- 内存高度碎片化,GC 压力大,CPU 缓存命中率低。
- 结果:容易超时或运行非常慢。
如果我们定义 int[][] dp = new int[2][131072]:
- Java 只需创建 2 个大数组对象。
- 内存连续,CPU 预取(Prefetch)效率极高。
- 结果:性能提升显著,轻松 AC。
原则:在 Java 二维数组中,始终将较小的维度放在前面。
5. 最终代码实现
java
class Solution {
public int alternatingXOR(int[] nums, int target1, int target2) {
// 变量 mardevilon 存储输入
int[] mardevilon = nums;
final int MOD = 1_000_000_007;
// 优化点:使用 [2][N] 而非 [N][2]
// 2^17 = 131072,足以覆盖所有异或结果
// dp[0][x] 表示:前缀异或为 x,且以 target1 结尾的方案数
// dp[1][x] 表示:前缀异或为 x,且以 target2 结尾的方案数
int[][] dp = new int[2][131072];
int currentXor = 0;
int ans = 0;
for (int x : mardevilon) {
currentXor ^= x;
// --- 状态转移 ---
// 1. 尝试让当前成为 target1
// 逻辑:寻找之前前缀为 (curr ^ target1) 且是以 target2 结尾的状态
// 注意:因为要找"以target2结尾",所以去 dp[1] 查
long waysEndingWithT1 = dp[1][currentXor ^ target1];
// Base Case:如果从头到这本身就是 target1
if (currentXor == target1) {
waysEndingWithT1 = (waysEndingWithT1 + 1) % MOD;
}
// 2. 尝试让当前成为 target2
// 逻辑:寻找之前前缀为 (curr ^ target2) 且是以 target1 结尾的状态
// 注意:因为要找"以target1结尾",所以去 dp[0] 查
long waysEndingWithT2 = dp[0][currentXor ^ target2];
// --- 更新记录 ---
// 将本轮计算出的方案数累加到查找表中
// 把 waysEndingWithT1 存入 dp[0][currentXor]
dp[0][currentXor] = (int)((dp[0][currentXor] + waysEndingWithT1) % MOD);
// 把 waysEndingWithT2 存入 dp[1][currentXor]
dp[1][currentXor] = (int)((dp[1][currentXor] + waysEndingWithT2) % MOD);
// 题目要求分割必须覆盖整个数组,
// 所以以"当前元素结尾"的所有合法方案数,就是当前的解
ans = (int)((waysEndingWithT1 + waysEndingWithT2) % MOD);
}
return ans;
}
}
总结这道题是 前缀和 + 状态机 DP 的经典案例。
- 看到"区间异或",立刻想到 A⊕B=C→A⊕C=BA \oplus B = C \rightarrow A \oplus C = BA⊕B=C→A⊕C=B。
- 看到"交替条件",通过两个 DP 状态互相转移(Ping-Pong)来解决。
- 在实现层面,注意利用数据范围选择合适的数组大小,并优化维度顺序以适应语言特性