两个「降复杂度神器」:把 O(n) 的区间求和变成 O(1),把 O(n) 的区间修改变成两次单点修改
| 0 - 30 min | 一维前缀和 + 一维差分(第 1-3 节)—— 今天最重要的两块 |
| 30 - 60 min | 二维前缀和 + 二维差分(第 4-5 节),一定要动手画表格算一遍 |
| 60 - 150 min | 做 4 道题,每题限时 30 分钟 |
| 150 - 180 min | 对照本文题解,把没做出来的补掉 |
| 180 - 210 min | 写题解笔记 + 做自测题 + 背速查卡 |
Day 14 的贪心解决的是「怎么选才是最优的」;今天换一个方向,解决「怎么算才能更快」。
今天这两个工具其实是一对逆运算:
· 前缀和:把「一堆单点信息」变成「区间信息」,让区间求和变快;
· 差分:把「区间修改」变成「单点修改」,让区间加减变快。
而差分还原回原数组,用的又正好是前缀和 —— 所以你只要搞懂一个,另一个就是它反过来。
有一个长度 n 的数组 a,接下来有 q 次询问,每次给你 l 和 r,问 a[l] + a[l+1] + … + a[r] 等于多少。
最直接的做法当然是:每次都写个循环从 l 加到 r。
// 暴力:每问一次,加一遍 for(int i = 0; i < q; i++){ int l, r; cin >> l >> r; long long s = 0; for(int j = l; j <= r; j++) s += a[j]; // 一次 O(n) cout << s << endl; }
这个写法没错,但太慢了。算一下:如果 n 和 q 都是 105,最坏情况每次询问的区间长度都是 n,那总共要做 n × q = 1010 次加法。计算机每秒大概能做 108 次简单运算,也就是要跑 100 秒左右,而题目一般只给 1 秒。
问 [3, 7] 的时候,你把 a[3]…a[7] 加了一遍;问 [3, 8] 的时候,你又把 a[3]…a[7] 重新加了一遍,只多加了 a[8]。
既然前面那段总是被反复相加,那我们能不能提前把「从开头加到某个位置」的结果都存下来?这样以后要哪一段,减一下就行了。
你的记账本上每天记一笔花销。假如我想知道「第 3 天到第 7 天一共花了多少」,笨办法是把第 3 到第 7 天的数字再加一遍。
但如果记账本每页还写了「到本页为止的累计总额」,那就简单了:第 7 页的累计额,减去第 2 页的累计额,就是第 3 到第 7 天的总和。
这个「累计总额」,就是前缀和。
| 做法 | 预处理 | 每次查询 | 总复杂度 | n = q = 105 时 |
|---|---|---|---|---|
| 暴力循环相加 | 无 | O(n) | O(nq) | 1010 次,超时 ❌ |
| 前缀和 | O(n) | O(1) | O(n + q) | 2×105 次,秒过 ✅ |
开一个新数组 sum,让 sum[i] 表示 a[1] 一直加到 a[i] 的总和。特别地,sum[0] = 0。
① 递推式(求前缀和数组):
sum[0] = 0; sum[i] = sum[i-1] + a[i];
② 查询式(求区间 [l, r] 的和):
区间和 = sum[r] - sum[l-1]
sum[r] - sum[l-1] 是对的因为 sum[r] = (a[1] + … + a[l-1]) + (a[l] + … + a[r]),而前面那一段正好就是 sum[l-1]。
所以把 sum[l-1] 减掉,剩下的就是我们要的 a[l] 到 a[r]。这就是「累计额相减」的道理。
设 a = [3, 1, 4, 1, 5, 9, 2](下标从 1 开始),我们算出它的前缀和:
| i | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|
| a[i] | 3 | 1 | 4 | 1 | 5 | 9 | 2 |
| sum[i] | 3 | 4 | 8 | 9 | 14 | 23 | 25 |
现在求 [3, 6] 的和:sum[6] - sum[2] = 23 - 4 = 19。
验算一下:a[3]+a[4]+a[5]+a[6] = 4+1+5+9 = 19。✅
int a[100005]; long long sum[100005]; // ★ sum 用 long long,防止加爆 int sum[0] = 0; for(int i = 1; i <= n; i++){ sum[i] = sum[i-1] + a[i]; // 递推:这一格 = 上一格 + 自己 } // 之后每次查询 [l, r],两次减法搞定,O(1) long long ans = sum[r] - sum[l-1];
sum[0] = 0 不是废话
如果询问的是 [1, r],公式会变成 sum[r] - sum[0]。
只要把 sum[0] 定义成 0,这个公式就对任何 l(包括 l = 1)都成立,不用特判。
而且 sum 数组是全局变量时本来就自动为 0,所以通常连写都不用写 —— 但你要知道它为什么是 0。
如果从下标 0 开始存,那 sum[l-1] 在 l = 0 时会变成 sum[-1],数组越界。
所以前缀和类的题目普遍约定:数组从下标 1 开始存放,然后统一多开几个位置(a[n+5] 这种写法)。
这样公式永远不用特判,写起来最省心。今天的 4 道题我都这么写。
int 最大约 2.1×109。如果 a[i] 最大 109 而 n 有 105,那 sum 最大能到 1014 —— 早就爆了。
口诀:求和相关的数组,一律开 long long,最多浪费一点内存,不会错。
现在题目变成了:有 n 个数,接下来有 p 次操作,每次把 区间 [l, r] 里的每个数都加上 x;所有操作做完之后,问某个问题(比如最小值、或者把整个数组输出出来)。
还是先看暴力:每次操作都写个循环,从 l 加到 r。单次修改 O(n),总共 O(np)。
今天的题 P2367 里 n 最大 5×106、p 也最大 5×106,暴力是 2.5×1013 次操作 —— 毫无疑问会超时。
定义差分数组 d:d[i] = a[i] - a[i-1](同样让 a[0] = 0,d[1] = a[1])。
反过来,如果手里只有 d,怎么还原 a 呢?把 d 求一遍前缀和就行:
a[i] = d[1] + d[2] + ... + d[i] // 也就是对 d 做前缀和
对 a 做差分得到 d;对 d 做前缀和又回到 a。
所以「差分数组」可以理解成「原数组的另一种存法」—— 存的信息一模一样,只是换了个角度。
d[l] += x; d[r+1] -= x;
完事。区间长度是 10 还是 107,都只改两个位置,单次修改从 O(n) 降到 O(1)。
因为 a[i] 是 d 的前缀和,所以 d[l] 只要变了,a[l]、a[l+1]、a[l+2]……一直到 a[n] 全都会跟着变。
于是:
d[l] += x:让 a[l] 以及它后面全部的数都 +x —— 这下加多了,第 r+1 个开始不该加;d[r+1] -= x:让 a[r+1] 以及它后面全部的数 -x,正好把刚才多出来的那部分抵消掉。两个效果叠加,净结果就是:只有 a[l] 到 a[r] 加了 x,其他位置一动没动。
可以想成「用两个开关控制一段水管的通水」:在 l 处打开,在 r+1 处关上。
3 个学生,初始成绩都是 1,两次操作:[1,2] 加 1、[2,3] 加 1。我们全程只看差分数组:
| 步骤 | 操作 | 差分数组 d[1..4] | 说明 |
|---|---|---|---|
| 初始 | a = 1 1 1 | 1 0 0 (0) | d[1]=a[1]=1,d[2]=a[2]-a[1]=0,d[3]=0 |
| 第 1 次 | [1,2] +1 | 2 0 -1 (0) | d[1] += 1,d[2+1] -= 1 |
| 第 2 次 | [2,3] +1 | 2 1 -1 -1 | d[2] += 1,d[3+1] -= 1 |
| 还原 | 对 d 求前缀和 | 2 3 2 — | a[1]=2,a[2]=3,a[3]=2 ✅ |
最终成绩是 2 3 2,最低分 = 2,和样例输出一致。
注意最后那一步「对 d 求前缀和」是必须的 —— 差分数组本身不是答案。
int d[100005] = {0}; // 差分数组,全局或初始化成 0
// 把初始数组装进差分数组(如果题目给了初始值的话)
for(int i = 1; i <= n; i++){
d[i] += a[i];
d[i+1] -= a[i];
}
// p 次区间加:每次只要 O(1)
for(int i = 0; i < p; i++){
int l, r, x; cin >> l >> r >> x;
d[l] += x; // ★ 打开开关
d[r+1] -= x; // ★ 关掉开关(所以数组要多开!)
}
// 所有修改做完后,求前缀和还原,再统计数据
long long cur = 0;
for(int i = 1; i <= n; i++){
cur += d[i];
// 这里的 cur 就是最终的 a[i],可以在这里取最小值 / 输出
}
因为要用到 d[r+1],而 r 最大就是 n,所以会摸到 d[n+1]。
写法有两种:①数组开 n+5 那么大(推荐,最省事);②每次判断 if(r+1 <= n)。
开小了会数组越界 —— 这种错误在本地可能「跑得好好的」,交上去就莫名 WA 或 RE。
「很多次区间修改 + 最后一次性查询」,这是差分的黄金场景(今天的 P2367、P3397 都是)。
但如果你要边改边问(改一次问一次最小值),差分就不行了 —— 那种题要用线段树 / 树状数组,以后再学。
现在先记住:差分是「离线」的,所有修改做完才能回答询问。
从一维数组变成二维矩阵。问:以 (x1,y1) 为左上角、(x2,y2) 为右下角的子矩形,里面所有数的和是多少?
类比一维:sum[i][j] 表示「从 (1,1) 到 (i,j) 这个大矩形里所有数的和」。
sum[i][j] = sum[i-1][j] + sum[i][j-1] - sum[i-1][j-1] + a[i][j];
意思是:上面那块 + 左边那块,但左上角那块被加了两遍,所以要减掉一次,最后再加上自己。
这就是所谓的「容斥」。画个图就明白了:
| 区域 | 含义 | 加还是减 |
|---|---|---|
| sum[i-1][j] | 上面一整块(第 1~i-1 行,第 1~j 列) | + |
| sum[i][j-1] | 左边一整块(第 1~i 行,第 1~j-1 列) | + |
| sum[i-1][j-1] | 左上角那块 —— 上面两块都包含它,被算了两次 | − |
| a[i][j] | 自己这一格 | + |
矩阵 a(3×3):
| a | 列1 | 列2 | 列3 |
|---|---|---|---|
| 行1 | 1 | 2 | 3 |
| 行2 | 4 | 5 | 6 |
| 行3 | 7 | 8 | 9 |
按递推式算出来的前缀和 sum:
| sum | 列1 | 列2 | 列3 |
|---|---|---|---|
| 行1 | 1 | 3 | 6 |
| 行2 | 5 | 12 | 21 |
| 行3 | 12 | 27 | 45 |
验证一下 sum[2][2]:1+2+4+5 = 12 ✅;sum[3][3] 是整个矩阵的和 = 45 ✅。
= sum[x2][y2] - sum[x1-1][y2] - sum[x2][y1-1] + sum[x1-1][y1-1]
理解方式:先拿「左上角到 (x2,y2) 的大矩形」,然后把上面多出来的一条剪掉、左边多出来的一条剪掉,但这样左上角那一小块被剪了两次,再加回来一次。
用上面那张表验证:求 (2,2)~(3,3) 的和。
sum[3][3] - sum[1][3] - sum[3][1] + sum[1][1] = 45 - 6 - 12 + 1 = 28
手算:5 + 6 + 8 + 9 = 28 ✅
long long sum[1005][1005]; // ★ 二维求和更容易爆 int,一律 long long // 1) 建表:一遍 O(N*M) for(int i = 1; i <= N; i++){ for(int j = 1; j <= M; j++){ cin >> a[i][j]; sum[i][j] = sum[i-1][j] + sum[i][j-1] - sum[i-1][j-1] + a[i][j]; } } // 2) 查询子矩形 (x1,y1) ~ (x2,y2):O(1) long long ans = sum[x2][y2] - sum[x1-1][y2] - sum[x2][y1-1] + sum[x1-1][y1-1];
做 P2004 的时候,先用题目样例跑一遍,再用一个 2×2 的小矩阵自己手算核对 —— 这类公式错一个符号就是全 WA,而且很难肉眼看出来。
n×n 的格子,每次把某个矩形 (x1,y1)~(x2,y2) 里的每个格子都 +1,最后问每个格子被加了几次。
暴力做法是双重循环把矩形里每一格都加一遍,单次 O(矩形面积),m 次操作就是 O(m·n²)。P3397 里 n、m 都到 1000,暴力要做 109 次,会超时。
d[x1][y1] += x; d[x1][y2+1] -= x;
d[x2+1][y1] -= x; d[x2+1][y2+1] += x;
然后对 d 做一遍二维前缀和,就还原出每个格子的真实值。单次修改 O(1)。
为什么是这四个点?道理和一维一样:矩形有两条上边线、两条下边线。
d[x1][y1] += x:从这一格开始,往右下方向所有格子都 +x(影响范围是整个右下角的大矩形);d[x1][y2+1] -= x:把右边多出来的那一竖条减回去;d[x2+1][y1] -= x:把下面多出来的那一横条减回去;d[x2+1][y2+1] += x:右下角那块被减了两次,补回来一次。记法:左上角是 +,右上角和左下角是 −,右下角是 +(就像容斥的符号分布)。
要用到 d[x2+1][y2+1],而 x2、y2 最大是 n,所以要摸到第 n+1 行第 n+1 列。
数组按 n+5 的规模开(本题直接开 1005 就行),否则越界。
int d[1005][1005] = {0};
// m 次矩形加值,每次 O(1)
for(int k = 0; k < m; k++){
int x1, y1, x2, y2; cin >> x1 >> y1 >> x2 >> y2;
d[x1][y1]++;
d[x1][y2+1]--;
d[x2+1][y1]--;
d[x2+1][y2+1]++; // ★ 右下角补回来
}
// 二维前缀和还原
for(int i = 1; i <= n; i++)
for(int j = 1; j <= n; j++)
d[i][j] += d[i-1][j] + d[i][j-1] - d[i-1][j-1];
| 工具 | 解决什么问题 | 预处理 | 每次操作 | 核心代码 |
|---|---|---|---|---|
| 一维前缀和 | 多次询问某段区间的和 | O(n) | O(1) 查询 | sum[r] - sum[l-1] |
| 一维差分 | 多次给某段区间整体加减,最后统一查询 | O(n) | O(1) 修改 (两次单点) |
d[l]+=x; d[r+1]-=x; |
| 二维前缀和 | 多次询问某个子矩形的和 | O(NM) | O(1) 查询 | 容斥减 3 项、加 1 项 |
| 二维差分 | 多次给某个矩形整体加减,最后统一查询 | O(NM) | O(1) 修改 (四个角点) |
左上 +、右上 −、左下 −、右下 + |
「前缀和管问,差分管改;一维两次,二维四角。」
「前缀和管问」= 它让查询变快;「差分管改」= 它让修改变快;
「一维两次」= 一维区间修改改 2 个点;「二维四角」= 二维矩形修改改 4 个角。
· 题目里出现「区间求和 / 子矩阵求和」,而且数据规模到 105~106 → 前缀和;
· 题目里出现「把一整段(整块)加同一个数」,而且是改完再问 → 差分;
· 题目是「数每个位置被多少个区间/矩形覆盖」→ 差分(P3397 地毯就是标准模板题);
· 要求「最大子段和 / 最大子矩阵」→ 前缀和 + 枚举边界(P1719)。
下面每道题的代码都用 g++ -std=c++14 -O2 -Wall 真实编译并跑过官方样例,输出是你看到的真实结果。
题意:有 n 个学生的初始成绩。老师要做 p 次操作,每次把第 x 个到第 y 个学生每人加上 z 分。所有操作做完后,输出全班的最低分。
对于 40% 的数据,n ≤ 103;60% 的数据 n ≤ 104;80% 的数据 n ≤ 105;
对于 100% 的数据,n ≤ 5×106,p ≤ n,学生初始成绩 ≤ 100,z ≤ 100。
★ 这题的难点不在差分,而在读入。n 有 500 万,p 也有 500 万,总共要读大约 2000 万个整数。
用 cin 直接读必超时,所以要用快读(或者至少 scanf)。本文末尾有实测数据。
看着像「区间修改 + 最后问最小值」,这正是差分的标准场景。
a[i] -= a[i-1](从后往前是因为 a[i-1] 这时还没被覆盖,仍是原值)。a[x] += z; a[y+1] -= z;。整体复杂度 O(n + p),全部是线性操作。内存上 a 数组开 5000005 个 int,约 20 MB,够用。
3 2 1 1 1 1 2 1 2 3 1
2
过程:[1,2] 加 1 后是 2 2 1;再 [2,3] 加 1 后是 2 3 2。最低分 2。
#include <cstdio> const int MAXN = 5000005; int a[MAXN]; // 原地当差分数组用 // -------- 快读:一次读 1MB 进缓冲区,再逐字符取,比 cin / scanf 快得多 -------- namespace FastIO { const int BUFSIZE = 1 << 20; // 1 MB 缓冲区 char buf[BUFSIZE]; int len = 0, pos = 0; inline char getChar() { if (pos == len) { // 缓冲区读完了,再补一锅 len = (int)fread(buf, 1, BUFSIZE, stdin); pos = 0; if (len == 0) return 0; // 文件结束 } return buf[pos++]; } inline int readInt() { char ch = getChar(); while (ch < '0' || ch > '9') { // 跳过空格、换行 if (ch == '-') { // 支持负数(保险起见) ch = getChar(); int x = 0; while (ch >= '0' && ch <= '9') { x = x * 10 + (ch - '0'); ch = getChar(); } return -x; } if (ch == 0) return 0; ch = getChar(); } int x = 0; while (ch >= '0' && ch <= '9') { x = x * 10 + (ch - '0'); ch = getChar(); } return x; } } using FastIO::readInt; int main() { int n = readInt(), p = readInt(); for (int i = 1; i <= n; i++) a[i] = readInt(); // 把 a 就地变成差分数组:d[i] = a[i] - a[i-1] // 从后往前做,因为 a[i-1] 还是原来的值,没被覆盖 for (int i = n; i >= 2; i--) a[i] -= a[i - 1]; // p 次区间加:区间 [x, y] 全体 +z -> d[x] += z, d[y+1] -= z for (int i = 0; i < p; i++) { int x = readInt(), y = readInt(), z = readInt(); a[x] += z; a[y + 1] -= z; // y == n 时改的是 n+1 号位,数组多开过,安全 } // 对差分数组求前缀和,还原出每个学生最终的分数,顺便取最小值 int mn = a[1]; for (int i = 2; i <= n; i++) { a[i] += a[i - 1]; if (a[i] < mn) mn = a[i]; } printf("%d\n", mn); return 0; }
官方样例 3 2 / 1 1 1 / 1 2 1 / 2 3 1 → 输出 2 完全一致
极限数据(n = p = 5×106,输入文件约 112 MB):输出 50,耗时约 408 ms。
这份随机数据里,位置 1 恰好没有被任何区间覆盖(另一个独立程序逐个统计,确认覆盖位置 1 的区间数为 0),所以最低分就是初始的 50 分 —— 答案 50 是正确的。
三种读入方式实测对比(同一份极限数据,同一台机器,同样输出 50):
| 读入方式 | 耗时 | 结论 |
|---|---|---|
| fread 快读(上面代码) | 约 408 ms | ✅ 推荐 |
scanf | 约 4574 ms | ⚠️ 慢了 11 倍 |
cin(关同步) | 约 7841 ms | ❌ 很危险 |
※ 这是本机 MinGW 的实测值,仅供参考量级。重点不是具体多少毫秒,而是:500 万级别的读入,快读和 cin 差了将近 20 倍,选错就是 TLE。
a[n+1],而 n 最大 5×106,所以开 5000005 这种带余量的。a[1](或者取一个极大值),不要想当然写 0。题意:给一张 N 行 M 列的地图,每个格子有一个价值(可能是负数)。首都占一个 C×C 的正方形,要找一个位置放首都,让覆盖的格子价值总和最大,输出这个正方形左上角的坐标(行 列)。题目保证最优解唯一。
60% 的数据 N, M ≤ 50;90% 的数据 N, M ≤ 300;
100% 的数据:1 ≤ N, M ≤ 103,1 ≤ C ≤ min(N, M),每块地价值的绝对值不超过 32767。
★ 注意算一算:最多 106 个格子,每个最大 32767,总和最大约 3.3×1010, 已经超过了 int 的 2.1×109。所以前缀和数组必须用 long long,这是本题的第一个坑。
先在 O(NM) 的时间里建好二维前缀和。然后枚举首都的左上角位置 (i, j):
总复杂度 O(NM),最大 106 次,飞快。
本题保证最优解唯一,所以不用纠结并列时输出哪一个。不过我还是用「严格大于才更新」的写法,保证一旦有并列会输出最靠上的那个,行为更确定。
3 4 2 1 2 3 1 -1 9 0 2 2 0 1 1
1 2
边长 2 的正方形一共 6 种放法,和分别是:11、14、6、10、10、4。
最大的是 14,左上角在第 1 行第 2 列 → 输出 1 2。
#include <iostream> using namespace std; const int MAXN = 1005; long long sum[MAXN][MAXN]; // sum[i][j] = 左上角 (1,1) 到 (i,j) 的矩形和 int main() { ios::sync_with_stdio(false); // 关掉同步,读 10^6 个数也不慢 cin.tie(0); int N, M, C; cin >> N >> M >> C; for (int i = 1; i <= N; i++) { for (int j = 1; j <= M; j++) { long long v; cin >> v; // 容斥:上 + 左 - 左上角 + 自己 sum[i][j] = sum[i - 1][j] + sum[i][j - 1] - sum[i - 1][j - 1] + v; } } long long best = 0; int bx = 1, by = 1; bool first = true; // 第一个位置无条件当擂主(价值可能是负数) for (int i = 1; i + C - 1 <= N; i++) { for (int j = 1; j + C - 1 <= M; j++) { int x2 = i + C - 1, y2 = j + C - 1; // 子矩形 (i,j)~(x2,y2) 的和,同样是容斥 long long s = sum[x2][y2] - sum[i - 1][y2] - sum[x2][j - 1] + sum[i - 1][j - 1]; if (first || s > best) { first = false; best = s; bx = i; by = j; } } } cout << bx << " " << by << endl; return 0; }
官方样例 → 输出 1 2 完全一致
另外用 30 组随机小数据(N、M ≤ 8,价值含大量负数)和「枚举每个正方形、逐格求和」的暴力程序对拍, 两边的坐标输出30/30 完全一致。
最大规模(N = M = 1000,C = 333,输入约 5.9 MB)→ 输出 137 251,耗时约 372 ms。
i + C - 1 <= N,否则右下角越界,读到数组外面去了。1 2 就是「第 1 行第 2 列」。题意:在 n×n 的格子上铺 m 块矩形地毯,给出每块地毯的左上角和右下角。问每个格子被多少块地毯覆盖,输出整个 n×n 的答案矩阵。
对于 20% 的数据,n ≤ 50,m ≤ 100。
对于 100% 的数据,n, m ≤ 1000。
★ m 块地毯,每块最大 1000×1000 格,暴力涂色最坏是 109 次操作,会超时。 而且最后的输出有 106 个数,输出本身也要注意效率(见下面的加速提示)。
这就是二维差分的标准模板题,正好练「四个角点」。
y2+1 和 x2+1)。d[i][j] += d[i-1][j] + d[i][j-1] - d[i-1][j-1]。做完之后 d[i][j] 就是「(i,j) 这一格被覆盖的次数」。\n。复杂度:建表 O(m),还原 O(n²),总共 O(m + n²),最大约 106 级别。
5 3 2 2 3 3 3 3 5 5 1 2 1 4
0 1 1 1 0 0 1 1 0 0 0 1 2 1 1 0 0 1 1 1 0 0 1 1 1
第 3 块地毯是 (1,2)~(1,4),一条横着的,所以第 1 行右边三个格子的值从 0 变成了 1。
#include <cstdio> const int MAXN = 1005; int d[MAXN][MAXN]; // 二维差分数组 int main() { int n, m; scanf("%d %d", &n, &m); for (int k = 0; k < m; k++) { int x1, y1, x2, y2; scanf("%d %d %d %d", &x1, &y1, &x2, &y2); // 矩形 (x1,y1)~(x2,y2) 加 1,只用改 4 个点 d[x1][y1]++; d[x1][y2 + 1]--; d[x2 + 1][y1]--; d[x2 + 1][y2 + 1]++; // ★ 右下角补回来 } // 二维前缀和还原 for (int i = 1; i <= n; i++) { for (int j = 1; j <= n; j++) { d[i][j] += d[i - 1][j] + d[i][j - 1] - d[i - 1][j - 1]; } } for (int i = 1; i <= n; i++) { for (int j = 1; j <= n; j++) { printf("%d", d[i][j]); putchar(j == n ? '\n' : ' '); } } return 0; }
官方样例 → 输出上面那个 5×5 矩阵 与官方样例逐行逐字符完全一致
另外和「暴力给每个格子涂色」的程序对拍 30 组随机数据(n、m ≤ 12):30/30 输出完全一致。
最大规模(n = m = 1000,输出 106 个数):耗时约 1.4 秒(瓶颈几乎全在 printf 上,
真正算差分+前缀和只要约 20 ms)。
上面的代码逻辑没问题,但最大数据要打 106 个数,逐个 printf 反而比计算慢几十倍。
如果被卡时间,可以把输出换成「先攒在内存里,攒够了再一次性写出去」:
// -------- 输出缓冲:攒满 4MB 再一次性写磁盘 -------- char obuf[1 << 22]; int olen = 0; inline void pushChar(char c) { obuf[olen++] = c; if (olen == (1 << 22)) { fwrite(obuf, 1, olen, stdout); olen = 0; } } inline void writeInt(int x) { // 手写整数转字符串,比 printf 快 if (x == 0) { pushChar('0'); return; } char tmp[12]; int t = 0; while (x > 0) { tmp[t++] = (char)('0' + x % 10); x /= 10; } while (t > 0) pushChar(tmp[--t]); } // 输出时改成:writeInt(d[i][j]); pushChar(j == n ? '\n' : ' '); // 程序最后记得把剩下的写出去:if (olen) fwrite(obuf, 1, olen, stdout);
实测:改成这样之后,同样那份最大数据从 约 1.4 秒降到约 45 毫秒, 而且输出文件和原版逐字节完全相同(用文件哈希比对过)。
※ 新手先写 printf 版把题目做对,等真的被卡了再来加这段。
j == n ? '\n' : ' ' 这种写法,行末就只有换行。题意:给一个 n×n 的矩阵,每个元素有整数权值(可为负)。要找一个大小不限的矩形(可以只取一格),使矩形里所有元素的权值和最大,输出这个最大和。
对于 100% 的数据,1 ≤ n ≤ 120,每个元素的权值在区间 [−127, 127] 内。
★ 两个注意点:
① n 只有 120,允许 O(n3) 的算法(1203 ≈ 1.7×106),但 O(n4) 也能勉强过、O(n3) 才稳;
② 权值可以是负数,所以「最大和」可能是负数 —— 初值给 0 就错了。
矩形有四个自由度(上、下、左、右),全枚举是 O(n4)。我们想办法砍掉两个。
先枚举矩形的上边 top 和下边 bot(n2/2 对,n=120 时只有 7260 对)。
上下边界一固定,问题就变成了一维:在「每一列 top~bot 行的和」这个长度为 n 的数组里,找和最大的连续一段。
这是经典的「最大子段和」问题,O(n) 就能解决。合起来总共 O(n3)。
具体怎么 O(n) 求最大子段和?用前缀和版本:
设 S(j) = 第 1~j 列、第 top~bot 行的总和(这可以用二维前缀和 O(1) 得到:S(j) = sum[bot][j] - sum[top-1][j])。
那么左右边界为 (l, r) 的矩形和就等于 S(r) - S(l-1)。
于是往右扫 j 的同时,记住「到目前为止见过的最小的 S」,用 S(j) - minPre 就能定出以 j 为右边界的最大矩形。这就是「最大子段和 = 前缀和的最大差」。
我第一次写的时候,图省事让 s[i][j] 只表示「第 i 行前 j 个数的和」,然后就想用 s[bot][j] - s[top-1][j] 拿到 top~bot 行的和。
这是错的!行前缀只能横向相减,纵向相减得不到任何东西。用这个写法跑样例,答案输出的是 52,而正确结果是 15。
结论:只要你要做纵向相减(哪怕只有一次),就必须老老实实建立完整的二维前缀和
(s[i][j] = s[i-1][j] + s[i][j-1] - s[i-1][j-1] + a[i][j])。
4 0 -2 -7 0 9 2 -6 2 -4 1 -4 1 -1 8 0 -2
15
最优矩形是第 2~4 行、第 1~2 列:9+2+(−4)+1+(−1)+8 = 15。
#include <cstdio> #include <climits> const int MAXN = 125; int s[MAXN][MAXN]; // s[i][j] = 左上角 (1,1) 到 (i,j) 这个矩形的和 int main() { int n; scanf("%d", &n); for (int i = 1; i <= n; i++) { for (int j = 1; j <= n; j++) { int v; scanf("%d", &v); // ★ 完整的二维前缀和:上 + 左 - 左上角 + 自己 s[i][j] = s[i - 1][j] + s[i][j - 1] - s[i - 1][j - 1] + v; } } int best = INT_MIN; // 全负数时答案就是最大的那个单个数 for (int top = 1; top <= n; top++) { for (int bot = top; bot <= n; bot++) { // 固定上下边界后,第 top~bot 行、第 1~j 列的和就是 // S(j) = s[bot][j] - s[top-1][j] // 那么左右边界为 (l, r) 的矩形和 = S(r) - S(l-1) // 于是只要在 j 往右扫的过程中,记录「见过的最小 S」,就能 O(1) 定出最优左边界 int minPre = 0; // 相当于 S(0) = 0 for (int j = 1; j <= n; j++) { int S = s[bot][j] - s[top - 1][j]; if (S - minPre > best) best = S - minPre; // 以 j 为右边界的最优解 if (S < minPre) minPre = S; // 更新"见过的最小前缀" } } } printf("%d\n", best); return 0; }
官方样例 → 输出 15 完全一致
另外和「四重循环枚举所有矩形、逐格求和」的 O(n4) 暴力程序对拍 40 组随机数据 (n ≤ 6,权值 −9~9,包含大量全负矩阵):40/40 完全一致。
边界也试过了:n = 1 且权值为 −5 → 输出 -5;全负数 3×3 矩阵最大单元素是 −1 → 输出 -1,
说明「答案可以是负数」这个情况处理正确。
cur = S[j] 相当于取了个空的左边界,会漏掉「从左边界 l 开始」的情况 —— 一定要用「S[j] − 见过的最小 S」这个形式。for(bot = top; bot <= n; bot++),从 top 开始。int s[125][125] 只有 62 KB。| 错误写法 | 后果 | 正确写法 |
|---|---|---|
sum[i] 用 int,a[i] 很大 |
累加溢出成负数,答案莫名其妙 | 求和、前缀和数组一律 long long |
下标从 0 开始却套 sum[l-1] |
l = 0 时访问 sum[-1],越界 |
统一从下标 1 开始,数组开 n+5 |
| 差分数组只开 n 个 | 用到 d[r+1] 时越界,本地不报错、评测莫名 WA |
多开 5 个以上,或用 if(r+1 <= n) |
| 改完差分直接输出 d | 输出的是「差值」不是真实值 | 最后必须求一次前缀和还原 |
| 二维容斥符号写错(中间两项写成加号) | 矩形和算错,样例都过不了 | 大 − 上 − 左 + 左上;建表时是 上 + 左 − 左上 + 自己 |
| 求最小值 / 最大值时初值写 0 | 数据可能全为负或全为正,答案错 | 初值用第一个元素,或 INT_MIN / INT_MAX |
P2367 用 cin 读 5×106 个数 |
读入就花掉 7 秒以上,TLE | 用 fread 快读,或至少 scanf |
| P2004 前缀和用 int | 最大 3.3×1010,溢出 | 二维前缀和用 long long |
| P1719 只做「行前缀」 | 纵向相减得到的不是行段和,样例输出 52(正确 15) | 建完整二维前缀和,再做纵向相减 |
输出行末多打一个空格 / 用 endl 刷 106 次 |
有的题判格式错;endl 还会反复刷新缓冲区导致超时 |
用 j == n ? '\n' : ' ';换行用 \n |
已知 a = [2, 5, 1, 4, 3](下标从 1 开始),求 a[2] + a[3] + a[4]。
答:sum = [2, 7, 8, 12, 15],所以区间 [2,4] 的和 = sum[4] - sum[1] = 12 - 2 = 10。
验算:5 + 1 + 4 = 10 ✅
数组 a = [3, 3, 3, 7, 7],它的差分数组 d 是多少?
答:d[1] = 3(a[0] 当 0),d[2] = 0,d[3] = 0,d[4] = 4,d[5] = 0。
也就是 d = [3, 0, 0, 4, 0]。
★ 注意 d 里只有两个非零数 —— 这正说明「a 是由两次区间加得到的结果」,差分把「一段一段的变化」存成了「零星的几个点」,
这就是它高效的原因。
用一句话解释:为什么把 a[l..r] 全加 x,只需要 d[l] += x; d[r+1] -= x;?
答:因为 a 是 d 的前缀和,所以 d[l] 一变,a[l] 及其后面所有数都会跟着变; 在 d[r+1] 处减掉 x,就让 a[r+1] 及其后面所有数变回来。两者一抵消,受影响的恰好只有 a[l..r] 这一段。
要把 5×5 网格里 (2,3)~(4,5) 这个矩形全部 +1,应该改哪四个点?各加还是减?
答:
d[2][3] += 1;(左上)
d[2][6] -= 1;(右上,y2+1)
d[5][3] -= 1;(左下,x2+1)
d[5][6] += 1;(右下,x2+1 且 y2+1)
★ 注意 6 超出了 5×5,所以数组必须开到 6 以上(一般直接开 10 或 1005)。
下面三种需求,分别该用什么?
① 100 万次询问,每次问某段区间的和;
② 100 万次操作,每次把某段区间每个数加 5,最后输出整个数组;
③ 一边把某段区间加 5,一边随时问「现在区间 [l,r] 的和是多少」。
答:① 一维前缀和;② 一维差分;③ 两个都不行 —— 差分不支持「边改边问」, 这种题要线段树 / 树状数组,以后会学。现在只要知道「差分是离线工具」就够了。
【一维前缀和】 sum[0] = 0; for(i = 1; i <= n; i++) sum[i] = sum[i-1] + a[i]; 区间 [l,r] 的和 = sum[r] - sum[l-1]; // O(1) 【一维差分】区间 [l,r] 全体 +x d[l] += x; d[r+1] -= x; // 两次单点修改 // 最后一定要还原: cur = 0; for(i = 1; i <= n; i++){ cur += d[i]; ... } // cur 就是 a[i] // ★ 数组开到 n+5,因为会用 d[n+1] 【二维前缀和】建表 s[i][j] = s[i-1][j] + s[i][j-1] - s[i-1][j-1] + a[i][j]; 子矩形 (x1,y1)~(x2,y2) = s[x2][y2] - s[x1-1][y2] - s[x2][y1-1] + s[x1-1][y1-1]; 【二维差分】矩形 (x1,y1)~(x2,y2) 全体 +x d[x1][y1] += x; d[x1][y2+1] -= x; d[x2+1][y1] -= x; d[x2+1][y2+1] += x; // 还原用二维前缀和 d[i][j] += d[i-1][j] + d[i][j-1] - d[i-1][j-1]; 【最大子段和 / 最大子矩阵】 minPre = 0; best = INT_MIN; for(j = 1; j <= n; j++){ S = s[bot][j] - s[top-1][j]; if(S - minPre > best) best = S - minPre; if(S < minPre) minPre = S; } // 枚举 top、bot 两层,里面套这个 O(n) 的扫描 = O(n^3) 【快读(n 到 500 万时必须用)】 fread 一次读 1MB 进缓冲区,再逐字符解析 实测:快读 0.4s vs scanf 4.6s vs cin 7.8s 【铁律】 求和 / 前缀和数组 → long long 下标从 1 开始 → 数组开 n+5 差分必还原 → 最后求一遍前缀和 容斥写完必手算 → 拿 2×2 或 3×3 小例子对一遍
递归入门:自己调用自己
今天所有代码都是「从前往后一个 for 循环扫过去」。明天要换一种完全不同的写法:让函数调用它自己。
会学到的:递归三要素(特别是「出口」)、递归树怎么画、为什么忘记写出口会栈溢出, 以及分治(把大矩阵切成四块,P5461 赦免战俘就是这个套路)。
另外,明天第 4 题 P1028 用纯递归会超时,需要「记答案」—— 那是 Day 17 记忆化的引子,先感受一下。