预处理一次,之后每个区间查询都是常数时间。还有一个几乎没人教的反向技巧:只写两个格子,就能给整个区间做一次更新。
第 2 篇的滑动窗口带着一个前提:窗口变大,量也必须跟着变大。只要出现一个负数,这个前提就不成立,整个模式随之失效。
前缀和不在乎这些。你先付一次成本,之后每个区间问题都只是一次减法。
下面每个程序都是完整的,都在 .NET 10 上跑过,输出直接从运行结果里粘贴。
这笔交易
Console.WriteLine($"{"n",9} {"queries",9} {"scan each",16} {"prefix",12}");
foreach ((int n, int q) in new[] { (10, 5), (1_000, 1_000), (200_000, 200_000) })
{
long scan = (long)q * n; // worst case: every query spans the array
long prefix = n + q; // build once, then O(1) per query
Console.WriteLine($"{n,9:N0} {q,9:N0} {scan,16:N0} {prefix,12:N0}");
}
输出:
n queries scan each prefix
10 5 50 15
1,000 1,000 1,000,000 2,000
200,000 200,000 40,000,000,000 400,000
四百亿对四十万。本篇所有模式都是这个样子:先做一次线性的工作,之后每个问题 O(1) 回答。
模式 11 — 前缀和
pre[i] 存的是索引 i 之前所有元素的和。数组有 n+1 个格子,不是 n 个,多出来的那一个是有实际作用的。
这个差一错误人人都要花一下午:pre 按边界编号,不按元素编号。pre[0] = 0 是空前缀,有了它,从索引 0 开始的区间不用特殊处理也能算对。
int[] a = [3, 1, 4, 1, 5, 9, 2, 6];
// pre[i] is the sum of the first i values, so pre[0] is 0 and pre.Length is n+1.
int[] pre = new int[a.Length + 1];
for (int i = 0; i < a.Length; i++) pre[i + 1] = pre[i] + a[i];
Console.WriteLine($"a = [{string.Join(", ", a)}]");
Console.WriteLine($"pre = [{string.Join(", ", pre)}]");
Console.WriteLine();
foreach ((int lo, int hi) in new[] { (0, 2), (2, 5), (5, 7), (0, 7) })
{
int sum = pre[hi + 1] - pre[lo];
Console.WriteLine($"sum a[{lo}..{hi}] = pre[{hi + 1}] - pre[{lo}] = {pre[hi + 1]} - {pre[lo]} = {sum,2} " +
$"[{string.Join(", ", a[lo..(hi + 1)])}]");
}
输出:
a = [3, 1, 4, 1, 5, 9, 2, 6]
pre = [0, 3, 4, 8, 9, 14, 23, 25, 31]
sum a[0..2] = pre[3] - pre[0] = 8 - 0 = 8 [3, 1, 4]
sum a[2..5] = pre[6] - pre[2] = 23 - 4 = 19 [4, 1, 5, 9]
sum a[5..7] = pre[8] - pre[5] = 31 - 14 = 17 [9, 2, 6]
sum a[0..7] = pre[8] - pre[0] = 31 - 0 = 31 [3, 1, 4, 1, 5, 9, 2, 6]
关键是要记住:pre 按边界编号,而不是按元素编号。pre[2] 不是“索引 2 上的值”,而是“索引 2 之前的全部”。这样理解之后,区间公式就不用死记了:
sum a[lo..hi] = pre[hi + 1] - pre[lo]
pre[0] = 0 这个空前缀,让从索引 0 开始的区间不用特殊处理。如果只给 pre 分配 n 个格子而不是 n+1 个,你就得写那个特殊情况,还会写出隐蔽的错,白白丢掉二十分钟。
代价: 构建 O(n),每次查询 O(1),空间 O(n)。
适用场景: 数据不变,而区间查询很多。如果数据在查询之间会变,你需要的是树状数组(Fenwick 树)或线段树。前缀和一旦有修改,就得从修改的位置往后重建。
模式 12 — 前缀和加哈希表
统计和为目标值的子数组个数,允许出现负数。
先换个角度看。子数组 a[lo..hi] 的和等于 target,当且仅当 pre[hi+1] - pre[lo] == target,移项得到 pre[lo] == pre[hi+1] - target。所以一边遍历数组一边维护累计和,每一步都问:前缀 running - target 以前出现过吗?出现过几次?
允许负数之后,取代滑动窗口的就是它。窗口要求“变大”就意味着“和更大”;前缀哈希表完全不需要这个条件。记得先放入 {0: 1},也就是空前缀,否则所有从索引 0 开始的子数组都会漏掉。
// Negative values, so no sliding window can solve this: growing the window
// no longer means growing the sum.
int[] a = [3, 4, 7, -2, 2, 1, 4, 2];
int target = 7;
Dictionary<int, int> seen = new() { [0] = 1 }; // one empty prefix, sum 0
int running = 0, found = 0;
for (int i = 0; i < a.Length; i++)
{
running += a[i];
int need = running - target;
int hits = seen.GetValueOrDefault(need);
if (hits > 0)
Console.WriteLine($"i={i} running={running,2} looking for {need,2} found {hits}x -> {hits} subarray(s) ending here");
else
Console.WriteLine($"i={i} running={running,2} looking for {need,2} none");
found += hits;
seen[running] = seen.GetValueOrDefault(running) + 1;
}
Console.WriteLine($"\nsubarrays summing to {target}: {found}");
输出:
i=0 running= 3 looking for -4 none
i=1 running= 7 looking for 0 found 1x -> 1 subarray(s) ending here
i=2 running=14 looking for 7 found 1x -> 1 subarray(s) ending here
i=3 running=12 looking for 5 none
i=4 running=14 looking for 7 found 1x -> 1 subarray(s) ending here
i=5 running=15 looking for 8 none
i=6 running=19 looking for 12 found 1x -> 1 subarray(s) ending here
i=7 running=21 looking for 14 found 2x -> 2 subarray(s) ending here
subarrays summing to 7: 6
这里靠两点撑着。
循环开始前,seen 里先放了 {0: 1}。这一项代表空前缀。没有它,所有从索引 0 开始的子数组都会漏掉,这个例子里 i=1 处的 [3, 4] 也在其中。这是这个模式里最常见的 bug,而且只有答案恰好从开头开始时才会暴露。
哈希表记的是出现次数,而不是有没有出现过,因为同一个前缀可能出现很多次,每一次都对应一个不同的子数组。看 i=7:前缀 14 之前出现过两次,所以有两个子数组在这里结束。
代价: 时间 O(n),空间 O(n)。
适用场景: 数组里有负数,而你正想用滑动窗口。取代滑动窗口的就是这个模式。
模式 13 — 差分数组
现在倒过来用。你有一批区间更新要做,比如给索引 2 到 5 之间的每个元素加 3,这样的更新有很多,而你只需要最后的数组。
逐个元素地做更新,每次都是 O(区间长度)。换个做法,只记录每次更新从哪里开始、在哪里结束:
d[lo] += v // from here on, add v
d[hi + 1] -= v // from here on, stop adding it
不管区间多长,都只写两次。最后对 d 做一遍前缀和,就把这些标记还原成数值。
差分数组就是倒过来的前缀和。每次区间更新不管区间多长,都只写两个格子,最后一遍扫描把标记还原成数值。第 7 个格子只是为了让 hi+1 始终不越界,最后会丢弃。
int n = 6;
int[] diff = new int[n + 1]; // one extra cell, so hi+1 is always in range
(int lo, int hi, int v)[] updates = [(1, 3, +2), (2, 5, +3), (0, 2, -1)];
foreach ((int lo, int hi, int v) in updates)
{
diff[lo] += v;
diff[hi + 1] -= v;
Console.WriteLine($"add {v,2} to [{lo}..{hi}] diff[{lo}] {v:+#;-#;0}, diff[{hi + 1}] {-v:+#;-#;0} " +
$"-> [{string.Join(", ", diff)}]");
}
int[] final = new int[n];
int running = 0;
for (int i = 0; i < n; i++) { running += diff[i]; final[i] = running; }
Console.WriteLine($"\nrunning sum of diff -> [{string.Join(", ", final)}]");
// Brute force, to prove it.
int[] check = new int[n];
foreach ((int lo, int hi, int v) in updates)
for (int i = lo; i <= hi; i++) check[i] += v;
Console.WriteLine($"element by element -> [{string.Join(", ", check)}]");
Console.WriteLine($"same: {final.SequenceEqual(check)}");
输出:
add 2 to [1..3] diff[1] +2, diff[4] -2 -> [0, 2, 0, 0, -2, 0, 0]
add 3 to [2..5] diff[2] +3, diff[6] -3 -> [0, 2, 3, 0, -2, 0, -3]
add -1 to [0..2] diff[0] -1, diff[3] +1 -> [-1, 2, 3, 1, -2, 0, -3]
running sum of diff -> [-1, 1, 4, 5, 3, 3]
element by element -> [-1, 1, 4, 5, 3, 3]
same: True
最后加上暴力校验,是因为这个方法看起来不像能行。
d 分配了 n + 1 个格子,这样 hi 是最后一个索引时,d[hi + 1] 也不会越界。还原的时候从不读最后这个格子,它存在的唯一目的,是让“停止累加”那一次写入总有地方可写。
代价: 每次更新 O(1),最后一次性 O(n)。
适用场景: 问题是先做 m 次区间更新,再读取结果。比如预订系统、航班座位统计、“每个点被多少个区间覆盖”。如果更新和查询交替进行,就需要树状数组。
模式 14 — 二维
同样的思路,多一个维度。pre[r][c] 存的是第 r 行严格上方、第 c 列严格左侧所有元素的和。
构建要用容斥原理,查询也一样。角上那块区域既属于上方的条带,也属于左侧的条带,两条都减掉,它就被减了两次。
角上那块区域既在上方的条带里,也在左侧的条带里,两条都减掉,它就被减了两次。把它加回来一次,不是事后打的补丁,容斥原理本来就是这样。
int[,] g = {
{ 1, 2, 3, 4 },
{ 5, 6, 7, 8 },
{ 9, 10, 11, 12 },
{ 13, 14, 15, 16 },
};
int rows = g.GetLength(0), cols = g.GetLength(1);
// pre[r, c] = sum of everything strictly above row r and left of column c.
int[,] pre = new int[rows + 1, cols + 1];
for (int r = 0; r < rows; r++)
for (int c = 0; c < cols; c++)
pre[r + 1, c + 1] = g[r, c] + pre[r, c + 1] + pre[r + 1, c] - pre[r, c];
Console.WriteLine("pre:");
for (int r = 0; r <= rows; r++)
{
for (int c = 0; c <= cols; c++) Console.Write($"{pre[r, c],5}");
Console.WriteLine();
}
int Query(int r1, int c1, int r2, int c2) =>
pre[r2 + 1, c2 + 1] - pre[r1, c2 + 1] - pre[r2 + 1, c1] + pre[r1, c1];
Console.WriteLine();
foreach ((int r1, int c1, int r2, int c2) in new[] { (1, 1, 2, 2), (0, 0, 1, 1), (2, 0, 3, 3) })
{
int brute = 0;
for (int r = r1; r <= r2; r++) for (int c = c1; c <= c2; c++) brute += g[r, c];
Console.WriteLine($"rows {r1}..{r2}, cols {c1}..{c2} -> {Query(r1, c1, r2, c2),3} (brute force {brute,3})");
}
输出:
pre:
0 0 0 0 0
0 1 3 6 10
0 6 14 24 36
0 15 33 54 78
0 28 60 96 136
rows 1..2, cols 1..2 -> 34 (brute force 34)
rows 0..1, cols 0..1 -> 14 (brute force 14)
rows 2..3, cols 0..3 -> 100 (brute force 100)
构建和查询用的是同一个 + - - + 形状,原因也相同。如果只能记住一件事,就记住最后一项是加号,加回的是被减了两次的那个角。
代价: 构建 O(rows × cols),每次查询 O(1)。
适用场景: 查询的是网格里的矩形。图像问题、矩阵求和,以及任何“数一数这个框里有多少东西”的问题。
模式 15 — 前缀异或
异或和加法足够相似,上面这一套都能照搬,因为异或是它自己的逆运算:x ^ y ^ y == x。所以把前缀技巧里的 + 换成 ^ 照样成立,减法也变成再做一次 ^。
唯一值得放慢速度看的是移项这一步:
running ^ need == target // what we want
need == running ^ target // xor both sides by running
int[] a = [4, 2, 2, 6, 4];
int target = 6;
Dictionary<int, int> seen = new() { [0] = 1 };
int running = 0, found = 0;
for (int i = 0; i < a.Length; i++)
{
running ^= a[i];
int need = running ^ target; // because x ^ need == target => need == x ^ target
int hits = seen.GetValueOrDefault(need);
found += hits;
Console.WriteLine($"i={i} a[i]={a[i]} prefixXor={running} need={need} matches={hits}");
seen[running] = seen.GetValueOrDefault(running) + 1;
}
Console.WriteLine($"\nsubarrays with XOR {target}: {found}");
int brute = 0;
for (int i = 0; i < a.Length; i++)
{
int x = 0;
for (int j = i; j < a.Length; j++) { x ^= a[j]; if (x == target) brute++; }
}
Console.WriteLine($"brute force: {brute}");
输出:
i=0 a[i]=4 prefixXor=4 need=2 matches=0
i=1 a[i]=2 prefixXor=6 need=0 matches=1
i=2 a[i]=2 prefixXor=4 need=2 matches=0
i=3 a[i]=6 prefixXor=2 need=4 matches=2
i=4 a[i]=4 prefixXor=6 need=0 matches=1
subarrays with XOR 6: 4
brute force: 4
结构上和模式 12 完全一样:同样预先放好的哈希表,同样的计数,只是把 + 换成了 ^。收录它正是为了这一点:一旦把前缀和看成“任何有逆运算的运算”,这个家族就远不止加法了。
代价: 时间 O(n),空间 O(n)。
适用场景: 问题涉及区间异或。它还能推广到乘积(要小心 0),以及任何有逆运算的结合运算。
要点
pre按边界编号,不按元素编号。n+1个格子,pre[0] = 0,sum a[lo..hi] = pre[hi+1] - pre[lo],开头不需要特殊处理。- 滑动窗口用不了的时候,就用前缀和。 一旦出现负数,窗口变大就不再意味着和变大,窗口也就没有了依据。
- 哈希表里先放
{0: 1}。 它就是空前缀。漏掉它,所有从索引 0 开始的答案都会消失,哪怕其他地方看起来都没问题。 - 哈希表记次数,不是记有没有。 同一个前缀再次出现,就是又一次命中目标值。
- 差分数组把一次区间更新变成两次写入。
d[lo] += v,d[hi+1] -= v,最后扫一遍。分配n+1个格子,第二次写入才总能落下。 - 二维查询的最后一项是加号。 那个角被上方的条带和左侧的条带各减了一次,一共减了两次。
- 它其实和加法无关。 任何有逆运算的运算都行,所以异或版本和原来的代码只差一个字符。
第 4 篇讲二分查找,重点是其中与“在有序数组里找某个元素”无关的那三分之二。