Lesson 0415 · 前缀和 / 差分 / 单调栈

④ 数据结构与算法 — 第 15 课

上节课:二分答案 —— 把「求最优」变成「判对错」,靠的是答案的单调性。
这节课:三个「预处理」小工具。它们本身都不难,但比赛里出现频率极高:前缀和把区间求和从 O(n) 压到 O(1);差分把区间加从 O(n) 压到 O(1);单调栈把「找左右第一个更大的数」从 O(n²) 压到 O(n)。三个都只有十行代码,值得背下来。
三句话速览

1. 前缀和:把「一段」变成「两个前缀相减」

问题:数组 a[1..n],多次问「第 l 个数到第 r 个数的和」。朴素做法每次都要累加 r-l+1 个数,q 次询问就是 O(nq)。

前缀和的想法是:先花 O(n) 把「从第 1 个数加到第 i 个数」全部算出来,之后任何区间和都是一次减法:

s[0] = 0;
for (int i = 1; i <= n; i++) s[i] = s[i - 1] + a[i];

// 区间 [l, r] 的和
long long sum = s[r] - s[l - 1];

为什么对:s[r] 是「前 r 个」,s[l-1] 是「前 l−1 个」,减掉后面那段不要的,剩下的正好是 [l, r]。注意下标要减 l−1 而不是 l —— 这是最常错的地方。

前缀和数组一定开 long long,除非你能证明总和不超过 2×109。n 个 109 加起来就是 1014,int 秒溢出。

变体:取模的前缀和(P3131 就是它)

P3131 问「和能被 7 整除的最长连续段」。这里不需要区间和的具体值,只需要它 mod 7 的结果:

区间 [l, r] 的和能被 7 整除 ⟺ s[r] ≡ s[l-1] (mod 7)。

所以只要记录每个余数第一次出现的位置,第 i 个位置再看到同一个余数时,i - first[余数] 就是一段合法区间的长度。

int first[7];
fill(first, first + 7, -1);
first[0] = 0;                        // 余数 0 在「第 0 个位置」就已经出现过
int best = 0, pre = 0;
for (int i = 1; i <= n; i++) {
    pre = (pre + a[i]) % 7;
    if (first[pre] == -1) first[pre] = i;          // 第一次见,记下位置
    else best = max(best, i - first[pre]);         // 又见到了 → 中间这一段能被 7 整除
}

实测(本机):n = 50000 时耗时 0.20 ms,答案与 O(n²) 暴力枚举 500 组随机数据完全一致(0 组不一致)。样例 3 5 1 6 2 14 10 输出 5,与题解一致。

2. 二维前缀和:容斥

矩阵里的区间求和,思路一样,只是「前缀」变成了「左上角到 (i,j) 这一整块的矩形」。递推要减去重叠部分、再补回被减两次的:

s[i][j] = s[i - 1][j] + s[i][j - 1] - s[i - 1][j - 1] + g[i][j];

查询 (x1, y1) 到 (x2, y2) 这个子矩形的和,同样是四次:

sum = s[x2][y2] - s[x1 - 1][y2] - s[x2][y1 - 1] + s[x1 - 1][y1 - 1];

实测一张 3×4 的矩阵(1..12 按行填),前缀和表是:

s[i][j]j=1j=2j=3j=4
i=113610
i=26142436
i=315335478

查 (2,2)–(3,3):程序算出来是 34,手算 6+7+10+11 = 34,公式为 s[3][3] − s[1][3] − s[3][1] + s[1][1] = 54 − 6 − 15 + 1 = 34。注意减的是 x1−1 那一行(第 1 行),不是第 2 行。

二维前缀和的四个下标最容易写成 x1 / y1。记住一句话:减掉的是「不要的那一侧」,边界是 x1-1 和 y1-1。

3. 差分:区间加只改两个位置

问题换成:数组 a[1..n],p 次「把 [x, y] 每个数加 z」,最后问最小值。朴素做法每次都要改 y-x+1 个数。

差分的想法:不直接改区间,改「区间开始的地方 +z、结束的后一位 −z」。最后从头累加一遍,变化就自动「铺」到整个区间上;出了区间又被那个 -z 抵消掉。

// d 是差分数组,多开一位(y+1 可能等于 n+1)
vector<int> d(n + 2, 0);
for (int i = 1; i <= n; i++) d[i] = a[i] - a[i - 1];   // 先变成差分

for (每次操作 x y z) { d[x] += z; d[y + 1] -= z; }

// 还原并顺便取最小
int cur = 0, mn = INT_MAX;
for (int i = 1; i <= n; i++) { cur += d[i]; mn = min(mn, cur); }

实测一组小数据:b = 5 3 8 1 4 9,做三次操作([2,4] 加 3、[1,3] 加 −2、[5,6] 加 7),差分还原后是

位置123456
原值538149
操作后34941116

手算核对:位置 1 只吃 −2 → 3;位置 2 吃 +3 和 −2 → 4;位置 4 只吃 +3 → 4;位置 5 只吃 +7 → 11。全对。

P2367 的实测对比

做法规模耗时
朴素(每次循环改区间)n = 105、p = 105257.2 ms
差分n = 105、p = 1050.28 ms
差分n = 5×106、p = 5×106(本题上限)75.5 ms

同一组数据朴素的做法要 257 ms,差分只要 0.28 ms —— 差了约 900 倍。而到了本题上限(5×106),朴素做法根本跑不完,差分仍然只要 75 ms。

4. 单调栈:每个元素只进一次、出一次

P5788 问:对每个 i,找到它右边第一个比 a[i] 大的数的下标,没有就输出 0。朴素是 O(n²)。

换个方向想:从右往左扫,维护一个栈,栈里存「还没找到答案的下标」,并且保证从栈底到栈顶,数值递减(栈顶最小)。新来一个 a[i] 时:

  1. 栈里所有 ≤ a[i] 的下标,它们的答案不可能是 i(因为 i 更靠左,且值不比它们大)—— 直接弹掉,而且以后再也不会用到。
  2. 弹完之后,栈顶就是「右边第一个比 a[i] 大的数」。
  3. 把 i 压进去。
vector<int> f(n + 1, 0), st;      // 1-based
for (int i = n; i >= 1; i--) {
    while (!st.empty() && a[st.back()] <= a[i]) st.pop_back();
    f[i] = st.empty() ? 0 : st.back();
    st.push_back(i);
}

实测 a = 2 1 4 5 3:

ia[i]弹掉了谁f[i]
53空0
453(5 ≥ 3)0
34空(栈里只有 5,5 > 4)4
21空3
121(2 ≥ 1)3

输出 3 3 4 0 0,与 O(n²) 暴力在 500 组随机数据上完全一致。n = 3×106(本题上限)实测 23.4 ms。

为什么是 O(n):每个下标最多进栈一次、出栈一次,所有 while 里的 pop 加起来不超过 n 次。所以虽然里面套了个 while,总复杂度仍是 O(n)。

变体:下一个更小

只需要把比较符号反过来:while (!st.empty() && a[st.back()] >= a[i]) st.pop_back();。同一组 2 1 4 5 3 的下一个更小是 2 0 5 5 0(实测)。

5. 三件工具怎么选

工具解决什么预处理每次查询触发词
前缀和区间和(可减的运算)O(n)O(1)「多次问一段的和」
二维前缀和子矩阵和O(nm)O(1)「矩阵里一块的和」
差分区间加、最后统一问O(n)O(1) 每次加「很多次区间修改,最后才问」
单调栈左右第一个更大/更小—O(n) 全部「第一个比它大的」「接雨水」「直方图」
一句话判断:查询多、修改少 → 前缀和;修改多、查询只在最后 → 差分;找「第一个更大/更小」→ 单调栈。
三者的共同点:都在「把一次性的 O(n²) 变成预处理 + O(1)」这件事上做文章。

6. 三道题的题面要点

题约束关键点
P3131N ≤ 5×104,ID ≤ 106余数 0 要预置在位置 0(first[0] = 0),否则从第 1 个数开始的最长段会被漏掉
P2367n ≤ 5×106,p ≤ n差分数组要开 n+2(y+1 可能是 n+1);读入量大,必须用快读
P5788n ≤ 3×106,ai ≤ 109栈里存下标不存值;答案是下标,不存在输出 0(不是 −1)

7. 坑点清单

8. 小测

Q1. 前缀和数组 s[] 里,s[r] - s[l-1] 表示什么?
Q2. 差分做「区间 [x, y] 加 z」,只改哪两个位置?
Q3. 单调栈求「右边第一个严格更大的数」,弹栈条件是什么?
Q4. 单调栈里那个 while 循环,为什么总复杂度还是 O(n)?
Q5. 拿到「多次区间加、最后只问一次结果」,最该用哪个?

9. 动手练习

  1. P3131 Subsequences Summing to Sevens S —— N ≤ 5×104。三个必须自己踩的点:① first[0] = 0 别忘了;② 只有 7 个余数,用数组存「第一次出现位置」就够;③ 答案是长度,不是和。
  2. P2367 语文成绩 —— n ≤ 5×106,读入量很大,必须快读。差分数组开 n+2;最后一遍累加时顺便取最小值。
  3. P5788 【模板】单调栈 —— n ≤ 3×106。栈里存下标;不存在输出 0;答案用空格隔开。
  4. 选做:下一个更小 —— 把 P5788 的代码改一个符号,自己造几组数据验证。
卡住时的自查顺序
① 前缀和:区间是不是 [l, r],减的是不是 l-1?
② 差分:d[y+1] 有没有越界(数组开够了吗)?最后有没有还原?
③ 单调栈:栈里存的是下标还是值?弹的条件符号对不对?
④ 还不行就把代码贴过来,我按行看 —— 按规矩先让你猜一组输入的输出。

本课小结

下一课预告
0416 哈希表 —— unordered_map / set 的用法与坑(哈希冲突、被卡、遍历顺序),以及「用哈希把 O(n²) 的查找变 O(n)」这个通用套路。今天的前缀和 + 哈希会组成「前缀和 + 哈希表」这个极强的组合(比如「和为 k 的子数组个数」)。
这一课的三份代码都编译跑过了:对拍各 500 组随机数据 0 不一致,P3131 样例 5、P5788 样例 3 3 4 0 0 都对;表中的耗时是同一台机器的实测值。哪一句看不懂 —— 比如「为什么同一余数相减就能被 7 整除」、单调栈为什么弹掉的元素再也用不到 —— 直接问我。

← 上一课:0414 二分(二分查找 / 二分答案) · 课程对照表 · 算法总结

书籍 《算法竞赛入门经典》第 3 章(数组与字符串技巧)· 《算法 (第4版)》第 1.1 节(前缀和思想)

在线 OI-Wiki · 前缀和 · OI-Wiki · 单调栈