ACM 一个月学习计划 · DAY 15

Day 15 · 前缀和与差分

两个「降复杂度神器」:把 O(n) 的区间求和变成 O(1),把 O(n) 的区间修改变成两次单点修改

今日目标:掌握一维前缀和、一维差分、二维前缀和、二维差分这「四件套」
预计用时:约 3.5 小时(含做题)  难度:普及- ~ 普及/提高-
题目来源:洛谷 P2367 / P2004 / P3397 / P1719
← Day 14 · 贪心进阶:区间、背包、堆

⏱今日时间安排

0 - 30 min一维前缀和 + 一维差分(第 1-3 节)—— 今天最重要的两块
30 - 60 min二维前缀和 + 二维差分(第 4-5 节),一定要动手画表格算一遍
60 - 150 min做 4 道题,每题限时 30 分钟
150 - 180 min对照本文题解,把没做出来的补掉
180 - 210 min写题解笔记 + 做自测题 + 背速查卡
💡 和昨天的衔接

Day 14 的贪心解决的是「怎么选才是最优的」;今天换一个方向,解决「怎么算才能更快」。

今天这两个工具其实是一对逆运算:
 · 前缀和:把「一堆单点信息」变成「区间信息」,让区间求和变快;
 · 差分:把「区间修改」变成「单点修改」,让区间加减变快。
而差分还原回原数组,用的又正好是前缀和 —— 所以你只要搞懂一个,另一个就是它反过来。

1先看清问题:区间求和为什么慢

一个非常常见的问题

有一个长度 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 次,秒过 ✅

2第一件:一维前缀和

定义

开一个新数组 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 开始),我们算出它的前缀和:

i1234567
a[i]3141592
sum[i]3489142325

现在求 [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。

⚠️ 第二个坑:为什么大家都从下标 1 开始存

如果从下标 0 开始存,那 sum[l-1] 在 l = 0 时会变成 sum[-1],数组越界。

所以前缀和类的题目普遍约定:数组从下标 1 开始存放,然后统一多开几个位置(a[n+5] 这种写法)。
这样公式永远不用特判,写起来最省心。今天的 4 道题我都这么写。

💡 什么时候必须用 long long

int 最大约 2.1×109。如果 a[i] 最大 109 而 n 有 105,那 sum 最大能到 1014 —— 早就爆了。

口诀:求和相关的数组,一律开 long long,最多浪费一点内存,不会错。

3第二件:一维差分(今天的主角)

换一个问题

现在题目变成了:有 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。
所以「差分数组」可以理解成「原数组的另一种存法」—— 存的信息一模一样,只是换了个角度。

★ 核心:区间修改变成两次单点修改

✅ 想让 a[l] 到 a[r] 每个数都加 x,只需要在差分数组上做两件事

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] 全都会跟着变。

于是:

两个效果叠加,净结果就是:只有 a[l] 到 a[r] 加了 x,其他位置一动没动。

可以想成「用两个开关控制一段水管的通水」:在 l 处打开,在 r+1 处关上。

用官方样例走一遍(就是 P2367 的样例)

3 个学生,初始成绩都是 1,两次操作:[1,2] 加 1、[2,3] 加 1。我们全程只看差分数组:

步骤操作差分数组 d[1..4]说明
初始a = 1 1 11 0 0 (0)d[1]=a[1]=1,d[2]=a[2]-a[1]=0,d[3]=0
第 1 次[1,2] +12 0 -1 (0)d[1] += 1,d[2+1] -= 1
第 2 次[2,3] +12 1 -1 -1d[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 都是)。

但如果你要边改边问(改一次问一次最小值),差分就不行了 —— 那种题要用线段树 / 树状数组,以后再学。
现在先记住:差分是「离线」的,所有修改做完才能回答询问。

4第三件:二维前缀和(矩形求和)

问题升级

从一维数组变成二维矩阵。问:以 (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
行1123
行2456
行3789

按递推式算出来的前缀和 sum:

sum列1列2列3
行1136
行251221
行3122745

验证一下 sum[2][2]:1+2+4+5 = 12 ✅;sum[3][3] 是整个矩阵的和 = 45 ✅。

查询式:子矩形求和,也是容斥

🎯 子矩形 (x1,y1) ~ (x2,y2) 的和

= 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,而且很难肉眼看出来。

5第四件:二维差分(矩形区域加值)

问题

n×n 的格子,每次把某个矩形 (x1,y1)~(x2,y2) 里的每个格子都 +1,最后问每个格子被加了几次。

暴力做法是双重循环把矩形里每一格都加一遍,单次 O(矩形面积),m 次操作就是 O(m·n²)。P3397 里 n、m 都到 1000,暴力要做 109 次,会超时。

思路:一维差分的二维版,改 4 个点

✅ 矩形 (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 做一遍二维前缀和,就还原出每个格子的真实值。单次修改 O(1)。

为什么是这四个点?道理和一维一样:矩形有两条上边线、两条下边线。

记法:左上角是 +,右上角和左下角是 −,右下角是 +(就像容斥的符号分布)。

⚠️ 二维差分数组同样要多开

要用到 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];

6四件套总表 + 怎么选

工具解决什么问题预处理每次操作核心代码
一维前缀和多次询问某段区间的和 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)。

7四道例题详解

下面每道题的代码都用 g++ -std=c++14 -O2 -Wall 真实编译并跑过官方样例,输出是你看到的真实结果。

P2367 语文成绩 普及- · 一维差分模板

题意:有 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)。本文末尾有实测数据。

思路

看着像「区间修改 + 最后问最小值」,这正是差分的标准场景。

  1. 把初始成绩装进差分数组。本题最省内存的写法是:读入 a[1..n] 之后,原地把 a 变成差分数组 —— 从后往前做 a[i] -= a[i-1](从后往前是因为 a[i-1] 这时还没被覆盖,仍是原值)。
  2. 处理 p 次操作:每次读 x、y、z,做 a[x] += z; a[y+1] -= z;。
  3. 求前缀和还原 + 取最小值:一边累加一边更新答案,不用另外开数组。

整体复杂度 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。

易错点

  • 用 cin 读 500 万个数 —— 本题最大的坑,必须快读或 scanf。
  • 数组开小了:要用到 a[n+1],而 n 最大 5×106,所以开 5000005 这种带余量的。
  • 忘记最后求前缀和:差分数组不是答案,必须累加还原。
  • 求最小值时初值取 0:成绩都可能是正数,初值应该取 a[1](或者取一个极大值),不要想当然写 0。
  • 题面说「增加分数」,如果数据里出现负的 z,快读也要能读负数 —— 上面代码已经处理了。
P2004 领地选择 普及- · 二维前缀和

题意:给一张 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):

  • 左上角是 (i, j),边长是 C,那右下角就是 (i+C-1, j+C-1);
  • i 的范围是 1 ~ N-C+1,j 的范围是 1 ~ M-C+1(注意不是 1~N,否则右下角就越界了);
  • 每个位置用容斥公式 O(1) 算出正方形和,和当前最大值比较。

总复杂度 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。

易错点

  • 用 int 存前缀和:最大能到 3.3×1010,会溢出成负数,答案直接错。必须 long long。
  • 当成任意矩形做:题目只要「边长为 C 的正方形」,别去枚举长宽不同的矩形。
  • 枚举范围写成 1~N:应该是 i + C - 1 <= N,否则右下角越界,读到数组外面去了。
  • 输出的是左上角坐标,不是中心,也不是右下角。样例输出 1 2 就是「第 1 行第 2 列」。
  • 全负数时初值给 0 会错:那样最大值就成了 0,而正确答案是个负数。初始化时把第一个位置无条件设成擂主最稳。
P3397 地毯 普及- · 二维差分模板

题意:在 n×n 的格子上铺 m 块矩形地毯,给出每块地毯的左上角和右下角。问每个格子被多少块地毯覆盖,输出整个 n×n 的答案矩阵。

⚠️ 数据范围 / 关键约束

对于 20% 的数据,n ≤ 50,m ≤ 100。
对于 100% 的数据,n, m ≤ 1000。

★ m 块地毯,每块最大 1000×1000 格,暴力涂色最坏是 109 次操作,会超时。 而且最后的输出有 106 个数,输出本身也要注意效率(见下面的加速提示)。

思路

这就是二维差分的标准模板题,正好练「四个角点」。

  1. 开一个 diff 数组当二维差分。每读一块地毯,只改 4 个点:左上 +1、右上 −1、左下 −1、右下 +1(注意这几行是地毯边界外一格:y2+1 和 x2+1)。
  2. 所有地毯都记完之后,做一遍二维前缀和:d[i][j] += d[i-1][j] + d[i][j-1] - d[i-1][j-1]。做完之后 d[i][j] 就是「(i,j) 这一格被覆盖的次数」。
  3. 按行输出。注意行末不要多打空格,换行用 \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 版把题目做对,等真的被卡了再来加这段。

易错点

  • 四个角点的符号记错:左上 +、右上 −、左下 −、右下 +。记不住就直接套模板。
  • 数组开成 n×n:要用到第 n+1 行第 n+1 列,必须多开。这里固定开 1005 就够了。
  • 忘记做二维前缀和还原:不做的话输出的是差分数组,全是 0 和 ±1,肯定不对。
  • 行末多打空格:用 j == n ? '\n' : ' ' 这种写法,行末就只有换行。
  • 题目里的坐标是「(x1,y1) 左上、(x2,y2) 右下」,已经保证 x1≤x2、y1≤y2,不用自己交换。
P1719 最大加权矩形 普及/提高- · 前缀和 + 枚举边界

题意:给一个 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, 说明「答案可以是负数」这个情况处理正确。

易错点

  • 用行前缀代替二维前缀:见上面的红色框,这是我亲自踩过的坑。
  • 初值给 0:全负数矩阵的正确答案是负数,初值 0 会输出 0。用 INT_MIN,或者拿第一对上下边界的第一个结果当擂主。
  • 写成标准 Kadane(cur 只跟 a[j] 有关):这里扫的是前缀和数组 S,不是原数组。 对前缀和数组来说,cur = S[j] 相当于取了个空的左边界,会漏掉「从左边界 l 开始」的情况 —— 一定要用「S[j] − 见过的最小 S」这个形式。
  • 枚举上下边界时 top ≤ bot:注意是 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

✓自测:你真的会了吗

第 1 题:手算前缀和

已知 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 ✅

第 2 题:差分数组长什么样

数组 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 是由两次区间加得到的结果」,差分把「一段一段的变化」存成了「零星的几个点」, 这就是它高效的原因。

第 3 题:为什么区间修改只要改两个点

用一句话解释:为什么把 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] 这一段。

第 4 题:二维差分的四个点

要把 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)。

第 5 题:该用前缀和还是差分?

下面三种需求,分别该用什么?
① 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 小例子对一遍

→明天预告 · Day 16

递归入门:自己调用自己

今天所有代码都是「从前往后一个 for 循环扫过去」。明天要换一种完全不同的写法:让函数调用它自己。

会学到的:递归三要素(特别是「出口」)、递归树怎么画、为什么忘记写出口会栈溢出, 以及分治(把大矩阵切成四块,P5461 赦免战俘就是这个套路)。

另外,明天第 4 题 P1028 用纯递归会超时,需要「记答案」—— 那是 Day 17 记忆化的引子,先感受一下。