A 班 第 4 课:专题四·前缀和与差分

适用对象: 完成第 3 课(二分查找与二分答案)的同学 使用方式: 自学讲义,Dev-C++ 5.11,练习按考场 freopen 格式写 学完本课,你应该能:

  1. 用前缀和把"区间求和"从 O(n) 降到 O(1)
  2. 用差分把"区间批量加"从 O(n) 降到 O(1)
  3. 独立做出一道 NOIP 提高组真题(铺设道路)和一道 NOIP 普及组真题(最大子段和)

1. 前缀和:把"区间求和"提前算好(15 分钟)

问题: 给你 n 个数,要回答 q 次"区间 [l, r] 的和是多少"。暴力做法是每次查询都从 l 加到 r,单次 O(n),q 次总共 O(nq)——q 一大就超时。

前缀和的思路: 先花 O(n) 预处理出一个数组 preSumpreSum[i] 表示 a[1] + a[2] + ... + a[i](下标从 1 开始,preSum[0] = 0)。之后任何区间和都能 O(1) 算出来:

sum(l, r) = preSum[r] - preSum[l - 1]

道理很简单:preSum[r] 是 1~r 的总和,preSum[l-1] 是 1~(l-1) 的总和,减掉就剩下 l~r。

#include <bits/stdc++.h>
using namespace std;

int main() {
    int n = 5;
    int a[10] = {0, 3, 1, 4, 1, 5};   // 下标从 1 开始用,a[0] 空着不用
    int preSum[10] = {0};
    for (int i = 1; i <= n; i++) preSum[i] = preSum[i - 1] + a[i];

    cout << preSum[2] - preSum[0] << endl;   // a[1]+a[2] = 3+1 = 4
    cout << preSum[5] - preSum[2] << endl;   // a[3]+a[4]+a[5] = 4+1+5 = 10
    return 0;
}

📝 复杂度对比: 暴力每次查询 O(n),q 次查询共 O(n·q);前缀和预处理 O(n),之后每次查询 O(1),共 O(n+q)。当 n、q 都是 10⁵ 级别时,暴力是 10¹⁰ 次运算(超时),前缀和只要 2×10⁵ 次。

⚠️ preSum[0] = 0 是故意留的哨兵,这样 sum(1, r) 也能直接套公式 preSum[r] - preSum[0],不用特判 l == 1 的情况。


2. 差分:把"区间批量加"提前算好(15 分钟)

问题: 给你 n 个数,要做 m 次操作,每次把区间 [l, r] 里的每个数都加上 v。暴力做法每次操作 O(r-l+1),m 次最坏 O(nm)。

差分的思路: 前缀和的"反过来"。构造差分数组 diff[i] = a[i] - a[i-1]a[0] = 0)。这样 a 数组就是 diff 数组的前缀和。给 [l, r] 整体加 v,只需要改 diff 的两个位置:

diff[l] += v;
diff[r + 1] -= v;

道理:diff[l] += v 让"从 l 开始往后"的前缀和都多加了 v;diff[r+1] -= vr+1 之后多加的 v 抵消掉,正好只影响 [l, r]。所有操作做完后,对 diff 求一遍前缀和,就得到修改后的 a 数组。

#include <bits/stdc++.h>
using namespace std;

int main() {
    int n = 5;
    int diff[10] = {0};          // 初始数组全 0,diff 也全 0
    diff[1] += 2; diff[4] -= 2;  // [1,3] 整体加 2
    diff[2] += 3; diff[5] -= 3;  // [2,4] 整体加 3

    int a[10] = {0};
    for (int i = 1; i <= n; i++) a[i] = a[i - 1] + diff[i];
    for (int i = 1; i <= n; i++) cout << a[i] << " ";
    cout << endl;
    return 0;
}

输出:2 5 5 3 0(第 1 个只被第一次操作加到:2;第 2、3 个被两次操作都加到:2+3=5;第 4 个只被第二次操作加到:3;第 5 个没被加到:0)。

⚠️ diff 数组要比 a 数组多开一位。 r 可能等于 n,这时 diff[r+1] 就是 diff[n+1]——数组大小至少要开到 n+2,开小了会越界。

📝 一句话总结: 前缀和解决"区间查询",差分解决"区间修改",两者是一对反操作。


3. 例题一:区间和查询(自编小题,巩固前缀和)

给定 n 个数和 q 次查询,每次查询输出区间 [l, r] 的和。

#include <bits/stdc++.h>
using namespace std;

int a[100005], preSum[100005];

int main() {
    freopen("range.in", "r", stdin);
    freopen("range.out", "w", stdout);

    int n, q;
    cin >> n >> q;
    for (int i = 1; i <= n; i++) cin >> a[i];
    for (int i = 1; i <= n; i++) preSum[i] = preSum[i - 1] + a[i];

    while (q--) {
        int l, r;
        cin >> l >> r;
        cout << preSum[r] - preSum[l - 1] << endl;
    }
    return 0;
}

4. 例题二(NOIP 2018 提高组 D1T1 真题):铺设道路

题意: 一条路可以看成 n 段,第 i 段初始沉降深度为 d[i]。每天可以选一个连续区间 [l, r],把这个区间里每一段的沉降深度都减 1(要求区间内所有段沉降深度都大于 0)。问最少多少天能把所有段填平(深度都变成 0)。

样例(洛谷 P5019 官方样例):

输入样例:
6
4 3 2 5 3 5

输出样例:
9

思路: "把区间整体减 1"正好是差分的反向操作——用差分数组的眼光看,diff[i] = d[i] - d[i-1]。每次操作选一个区间整体 -1,等价于 diff[l] -= 1, diff[r+1] += 1。要把所有 d[i] 填到 0,只需要把每个"正的差分"用掉——答案就是 diff 数组里所有正数的和:因为每个正的 diff[i] 表示"从这里开始需要新增的填坑量",这部分不能靠别的操作顺带解决,必须单独起一次操作覆盖到这里。

#include <bits/stdc++.h>
using namespace std;

int d[100005];

int main() {
    freopen("road.in", "r", stdin);
    freopen("road.out", "w", stdout);

    int n;
    cin >> n;
    for (int i = 1; i <= n; i++) cin >> d[i];

    long long ans = 0;
    for (int i = 1; i <= n; i++) {
        int diff = d[i] - d[i - 1];   // d[0] 默认是 0
        if (diff > 0) ans += diff;    // 只累加正的差分
    }
    cout << ans << endl;
    return 0;
}

样例验证:d = {4,3,2,5,3,5},差分依次是 4,-1,-1,3,-2,2,正数之和 4+3+2=9,和官方样例一致。


5. 例题三(NOIP 2005 普及组真题):最大子段和

题意: 给定 n 个整数(可能有负数),选一段连续且非空的子段,使这段的和最大,求这个最大和。

样例(洛谷 P1115 官方样例):

输入样例:
7
2 -4 3 -1 2 -4 3

输出样例:
4

(选 [3,5]{3,-1,2},和为 4。)

思路: 任意子段 [l, r] 的和都是 preSum[r] - preSum[l-1]。要让它最大,preSum[r] 固定时,preSum[l-1] 越小越好——也就是说,边求前缀和,边打擂台记录"目前出现过的最小前缀和",每一步都算一次"当前前缀和 - 目前最小前缀和",打擂台更新答案:

#include <bits/stdc++.h>
using namespace std;

int a[200005];

int main() {
    freopen("maxsub.in", "r", stdin);
    freopen("maxsub.out", "w", stdout);

    int n;
    cin >> n;
    for (int i = 1; i <= n; i++) cin >> a[i];

    long long preSum = 0, minPre = 0, ans = LLONG_MIN;
    for (int i = 1; i <= n; i++) {
        preSum += a[i];
        ans = max(ans, preSum - minPre);   // 用目前最小的前缀和更新答案
        minPre = min(minPre, preSum);      // 更新目前最小的前缀和
    }
    cout << ans << endl;
    return 0;
}

这题把 L02 的打擂台和本课的前缀和拼在了一起:minPre 就是一个专门记录"最小前缀和"的擂台冠军。


6. 读错误:3 个典型坑(10 分钟)

错误 1:diff 数组开小了,越界

int diff[100005];
// n 最大是 100000,r 最大是 100000,r+1 就是 100001
// 数组开到 100005 是够的;但如果手滑开成 int diff[100000],
// 当 r == 99999 时 diff[100000] 就越界了

📝 凡是差分题,数组大小至少开到 n + 2,图省事可以直接开到 n + 10

错误 2:忘了 preSum[0] 这一项

int preSum[100005];   // 忘了初始化,preSum[0] 是垃圾值!
for (int i = 1; i <= n; i++) preSum[i] = preSum[i-1] + a[i];
// preSum[0] 不是 0,后面所有区间和全部算错

全局数组默认初始化为 0(这点和局部数组不同),所以只要 preSum 是全局数组通常没事;但如果 preSum 声明在 main 函数里(局部数组),必须手动写 int preSum[100005] = {0};,不然 preSum[0] 是垃圾值。

错误 3:铺设道路只累加所有差分绝对值,而不是只累加正数

long long ans = 0;
for (int i = 1; i <= n; i++) {
    int diff = d[i] - d[i-1];
    ans += abs(diff);   // ← 错误!把负的差分也累加了
}

负的差分表示"这里在往下降",不需要额外操作——正的差分才代表"需要新起一次操作去填的量"。把负数也累加进去,答案会偏大。


7. 本课练习

⭐ 基础题 1:区间和裸题(文件名 t1.cpp,freopen range1.in/range1.out

给定 n 个整数,一次查询 [l, r] 的区间和。

输入样例:
5
1 2 3 4 5
2 4

输出样例:
9
参考答案
#include <bits/stdc++.h>
using namespace std;

int a[100005], preSum[100005];

int main() {
    freopen("range1.in", "r", stdin);
    freopen("range1.out", "w", stdout);

    int n;
    cin >> n;
    for (int i = 1; i <= n; i++) cin >> a[i];
    for (int i = 1; i <= n; i++) preSum[i] = preSum[i - 1] + a[i];

    int l, r;
    cin >> l >> r;
    cout << preSum[r] - preSum[l - 1] << endl;
    return 0;
}

⭐⭐ 实战题 2:差分裸题(文件名 t2.cpp,freopen diff1.in/diff1.out

给定 n 个 0,做 m 次操作,每次给区间 [l, r] 整体加 v,输出最终数组。

输入样例:
5 2
1 3 2
2 4 3

输出样例:
2 5 5 3 0
参考答案
#include <bits/stdc++.h>
using namespace std;

int diff[100005];

int main() {
    freopen("diff1.in", "r", stdin);
    freopen("diff1.out", "w", stdout);

    int n, m;
    cin >> n >> m;
    while (m--) {
        int l, r, v;
        cin >> l >> r >> v;
        diff[l] += v;
        diff[r + 1] -= v;
    }

    int a[100005] = {0};
    for (int i = 1; i <= n; i++) {
        a[i] = a[i - 1] + diff[i];
        cout << a[i] << " ";
    }
    cout << endl;
    return 0;
}

⭐⭐⭐ 冲刺题 3:请假条统计(文件名 t3.cpp,freopen leave.in/leave.out

一个班共 n 天的课程(天数编号 1~n)。有 m 张请假条,每张写着"从第 l 天请假到第 r 天"。求哪一天请假人数最多,最多有多少人(如果并列最多,输出天数最小的那一天)。

提示:这就是"区间加 1"的差分模型——每张请假条对 [l, r] 整体 +1,最后对差分数组求前缀和,得到每天的请假人数,再打擂台找最大值和对应天数。

输入样例:
5 3
1 3
2 5
2 2

输出样例:
2 3

(第 2 天被三张请假条都覆盖,人数最多,是 3 人。)

参考答案
#include <bits/stdc++.h>
using namespace std;

int diff[100005], cnt[100005];

int main() {
    freopen("leave.in", "r", stdin);
    freopen("leave.out", "w", stdout);

    int n, m;
    cin >> n >> m;
    while (m--) {
        int l, r;
        cin >> l >> r;
        diff[l]++;
        diff[r + 1]--;
    }

    int bestDay = 1, bestCnt = 0;
    for (int i = 1; i <= n; i++) {
        cnt[i] = cnt[i - 1] + diff[i];   // 差分求前缀和,还原出每天的人数
        if (cnt[i] > bestCnt) {          // 打擂台找最大值(并列取天数小的,所以用严格 >)
            bestCnt = cnt[i];
            bestDay = i;
        }
    }
    cout << bestDay << " " << bestCnt << endl;
    return 0;
}

这题把差分(区间加)和打擂台(找最大值)拼在了一起——差分负责"快速统计每天覆盖了几张假条",打擂台负责"从这些天数里挑出人数最多的一天"。


本课要点速查

工具 预处理 单次操作 用途
前缀和 preSum[i] = preSum[i-1] + a[i] O(n) 查询区间和 O(1):preSum[r] - preSum[l-1] 区间求和多次查询
差分 diff[i] = a[i] - a[i-1] O(n) 区间加 O(1):diff[l] += v; diff[r+1] -= v; 区间批量修改,最后再求前缀和还原

两道真题一句话:

易错点: diff 数组要多开一位(n+2);局部数组不会自动清零,要写 = {0}


结束前的自我检查

  1. 不看讲义,写出前缀和公式和差分区间加的两行代码
  2. 向别人解释:为什么铺设道路的答案是"正的差分之和",负的差分为什么不用管
  3. 练习 1、2 全部通过;⭐⭐⭐ 至少理解思路(代码写不完整没关系)
  4. 合上讲义,说出前缀和与差分分别解决什么问题(一个查询、一个修改)