④ 数据结构与算法 — 第 15 课
问题:数组 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 问「和能被 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,与题解一致。
矩阵里的区间求和,思路一样,只是「前缀」变成了「左上角到 (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=1 | j=2 | j=3 | j=4 |
|---|---|---|---|---|
| i=1 | 1 | 3 | 6 | 10 |
| i=2 | 6 | 14 | 24 | 36 |
| i=3 | 15 | 33 | 54 | 78 |
查 (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。
问题换成:数组 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),差分还原后是
| 位置 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|
| 原值 | 5 | 3 | 8 | 1 | 4 | 9 |
| 操作后 | 3 | 4 | 9 | 4 | 11 | 16 |
手算核对:位置 1 只吃 −2 → 3;位置 2 吃 +3 和 −2 → 4;位置 4 只吃 +3 → 4;位置 5 只吃 +7 → 11。全对。
| 做法 | 规模 | 耗时 |
|---|---|---|
| 朴素(每次循环改区间) | n = 105、p = 105 | 257.2 ms |
| 差分 | n = 105、p = 105 | 0.28 ms |
| 差分 | n = 5×106、p = 5×106(本题上限) | 75.5 ms |
同一组数据朴素的做法要 257 ms,差分只要 0.28 ms —— 差了约 900 倍。而到了本题上限(5×106),朴素做法根本跑不完,差分仍然只要 75 ms。
P5788 问:对每个 i,找到它右边第一个比 a[i] 大的数的下标,没有就输出 0。朴素是 O(n²)。
换个方向想:从右往左扫,维护一个栈,栈里存「还没找到答案的下标」,并且保证从栈底到栈顶,数值递减(栈顶最小)。新来一个 a[i] 时:
i(因为 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:
| i | a[i] | 弹掉了谁 | f[i] |
|---|---|---|---|
| 5 | 3 | 空 | 0 |
| 4 | 5 | 3(5 ≥ 3) | 0 |
| 3 | 4 | 空(栈里只有 5,5 > 4) | 4 |
| 2 | 1 | 空 | 3 |
| 1 | 2 | 1(2 ≥ 1) | 3 |
输出 3 3 4 0 0,与 O(n²) 暴力在 500 组随机数据上完全一致。n = 3×106(本题上限)实测 23.4 ms。
只需要把比较符号反过来:while (!st.empty() && a[st.back()] >= a[i]) st.pop_back();。同一组 2 1 4 5 3 的下一个更小是 2 0 5 5 0(实测)。
| 工具 | 解决什么 | 预处理 | 每次查询 | 触发词 |
|---|---|---|---|---|
| 前缀和 | 区间和(可减的运算) | O(n) | O(1) | 「多次问一段的和」 |
| 二维前缀和 | 子矩阵和 | O(nm) | O(1) | 「矩阵里一块的和」 |
| 差分 | 区间加、最后统一问 | O(n) | O(1) 每次加 | 「很多次区间修改,最后才问」 |
| 单调栈 | 左右第一个更大/更小 | — | O(n) 全部 | 「第一个比它大的」「接雨水」「直方图」 |
| 题 | 约束 | 关键点 |
|---|---|---|
| P3131 | N ≤ 5×104,ID ≤ 106 | 余数 0 要预置在位置 0(first[0] = 0),否则从第 1 个数开始的最长段会被漏掉 |
| P2367 | n ≤ 5×106,p ≤ n | 差分数组要开 n+2(y+1 可能是 n+1);读入量大,必须用快读 |
| P5788 | n ≤ 3×106,ai ≤ 109 | 栈里存下标不存值;答案是下标,不存在输出 0(不是 −1) |
s[r] - s[l-1],不是 s[r] - s[l]first[0] = 0:忘了它,答案会少掉「从第一个数开始」的那一段x1-1、y1-1,加 x1-1, y1-1d[y+1] -= z 可能写到 n+1<= 与 <:求「第一个严格更大」用 <= 弹;求「第一个大于等于」用 < 弹s[] 里,s[r] - s[l-1] 表示什么?first[0] = 0 别忘了;② 只有 7 个余数,用数组存「第一次出现位置」就够;③ 答案是长度,不是和。n+2;最后一遍累加时顺便取最小值。[l, r],减的是不是 l-1?d[y+1] 有没有越界(数组开够了吗)?最后有没有还原?s[i] = s[i-1] + a[i],区间和 s[r] - s[l-1],O(1) 查询d[x] += z 和 d[y+1] -= z,最后累加还原unordered_map / set 的用法与坑(哈希冲突、被卡、遍历顺序),以及「用哈希把 O(n²) 的查找变 O(n)」这个通用套路。今天的前缀和 + 哈希会组成「前缀和 + 哈希表」这个极强的组合(比如「和为 k 的子数组个数」)。
3 3 4 0 0 都对;表中的耗时是同一台机器的实测值。哪一句看不懂 —— 比如「为什么同一余数相减就能被 7 整除」、单调栈为什么弹掉的元素再也用不到 —— 直接问我。