阶段 1 · 基础技巧 · 第 6 章

前缀和与差分:把重复的活提前干完

五行代码,把「反复查区间和」从每次 O(n) 变成每次 O(1)。这一章的性价比,全路线第一。

例题:区间求和 · 区间加 建议用时:100 分钟
这一章的两个主角是一对逆运算
  • 前缀和:反复一段的和 → 一次算好,每次查 O(1)
  • 差分:反复一段(整段加同一个数)→ 每次改 O(1),最后一次性还原

它们的关系是:对差分数组求前缀和,就得回原数组。 一个正着用,一个反着用,学会一个就等于学会了两个。

代码加起来不到十行,但它们会出现在后面几乎每一道区间题里 —— 包括第 39 章的线段树,那玩意儿要解决的正是「既要反复查、又要反复改」的场景。

前半场 · 前缀和

1 一句话问题

给一个长度 n 的数组,然后有 q 次询问,每次问你 a[l] + a[l+1] + ... + a[r] 是多少。

输入 8 3                      n=8 个数,q=3 次询问
     3 -1 4 1 -5 9 2 6
     2 5                      问 a[2]+a[3]+a[4]+a[5]
     1 8                      问整个数组的和
     4 4                      问 a[4] 一个数
输出 -1
     19
     1
✓ 这一章开始用 1 基下标

从这一章起,数组一律用 a[1]a[n]a[0] 空着不用。

不是为了好看 —— 是因为前缀和的公式在 1 基下会变得特别干净, 而 0 基会逼着你到处写 +1 -1,然后错一个就全错。 竞赛里的区间题几乎都是 1 基,习惯它。

2 先用纸笔手算一遍

拿上面那组数据,手算 [2,5]-1 + 4 + 1 + (-5) = -1

再算 [1,5]3 + (-1) + 4 + 1 + (-5) = 2。 再算 [1,1]3

现在注意一件事:算 [1,5] 的时候,你其实顺带把 [1,1][1,2][1,3][1,4] 都算过了 —— 它们是累加过程中一路经过的中间结果,只是你没记下来。

这就是这一章的全部灵感。先记着这个感觉,第 6 步会用到。

3 暴力:每次都从头加一遍

brute.cpp暴力
输入(stdin)
输出
点「运行 ▶」看结果

逐字翻译,完全正确。nq 小的时候一点毛病都没有。

4 实测:它有多慢

同题对比:每次现加 vs 前缀和
先跑 20 万(询问次数也是 20 万,每次都问长区间)。跑完改成 30 万试试 —— 暴力是 O(n·q),n 翻倍它慢四倍。
每次现加
前缀和

本机实测(询问次数 = 数组长度,每次询问的区间跨度是半个数组):

n = q每次现加 O(n·q)前缀和 O(n+q)
20 0000.07 秒0.003 秒
50 0000.44 秒0.01 秒
100 0001.74 秒0.02 秒
200 0007.01 秒0.03 秒

n = q = 10⁵ 是 CSP-J 的常见数据范围,暴力就已经要 1.7 秒了 —— 而大多数题目的时限是 1 秒。这不是「慢一点」,这是 0 分和 100 分的区别。

5 慢在哪:同一段被反复加了无数遍

看这两次询问:

问 [1, 5000]     加了 5000 次
问 [1, 5001]     又加了 5001 次

第二次询问里,前 5000 项的和刚刚才算过,但暴力又从头加了一遍。

q 次询问,每次都从头开始,同一批加法被重复了无数遍 —— 这个「重复计算」的味道,是不是和第 2 章的斐波那契很像?

6 ★ 关键的一步

★ 关键的一步

把「从头到 i」的和全部提前算好,存起来。

定义 s[i] = a[1] + a[2] + ... + a[i],也就是「前 i 个数的和」。特别地 s[0] = 0

它可以一遍循环全部算出来,每一项只要一次加法:

s[i] = s[i-1] + a[i];        // 前 i 个 = 前 i-1 个 + 第 i 个

有了它,任何一段的和都是一次减法

s[r]   = a[1] + ... + a[l-1] + a[l] + ... + a[r]
s[l-1] = a[1] + ... + a[l-1]
------------------------------------------------ 相减
                            a[l] + ... + a[r]     ← 正好是要的

答案 = s[r] - s[l-1]。

预处理 O(n),每次查询 O(1)。总共 O(n + q)。

⚠ 是 s[l-1],不是 s[l]

写成 s[r] - s[l] 会少算 a[l] 那一项 —— 前缀和的头号错误。

记不住就现推:s[l] 里已经包含a[l],减掉它就把要的东西减没了。 所以要减的是「l 前面那一格」,也就是 s[l-1]

顺便:这就是 s[0] = 0 存在的理由。l = 1 时,s[l-1] 就是 s[0]。如果没有这一格,你就得写个 if (l == 1) 特判; 有了它,公式一视同仁,一个特判都不用。

用一个「零元素」消灭掉一堆边界特判 —— 这是很值得学的一招, 后面的差分、树状数组、线段树都在用。

7 正解

fast.cpp正解
注意它连原数组都没存 —— 边读边算 s 就够了。
输入(stdin)
输出
点「运行 ▶」看结果
trace.cpp过程演示
把预处理和每次「掐头」的过程打印出来,最后还会自己验算一遍。
输入(stdin)
输出
点「运行 ▶」看结果

8 单步看它长出来

前缀和:一次算好,反复查
第 1 / 20 步
下标 i
0
1
2
3
4
5
6
7
8
a[i]
·
3
-1
4
1
-5
9
2
6
s[i]
0
已答出的询问:(还没有)
s[0] = 0 —— 「前 0 个数的和是 0」。这一格看着没用,但它让 l = 1 的询问不用特判。

先看「前缀和」这一栏:

  • 预处理阶段:s 一格一格往右长,每一格只做一次加法。
  • 查询阶段:绿色的 s[r] 减掉红色的 s[l-1],中间蓝色那段就是要的答案。
  • 把询问改成 1 8,看红色那一格落在 s[0] 上 —— 那一格是 0,所以什么都没减掉。 这就是「零元素消灭特判」的现场。

(「差分」那一栏先别急,第 11 步再回来看。)

9 ★ 对拍验证

★ 正确的用法

把「前缀和」那一栏换成你自己默写的,再点开始。

对拍器
生成器专门大量制造 l = 1(要用到 s[0])和 l = r(单点查询)—— 只靠纯随机的话,这两种边界在小数据里出现得太少,恰恰它们才是出事的地方。

值得故意写错的:

  • s[r] - s[l](少减一格)→ 每次都少算 a[l]
  • s[i] = s[i-1] + a[i-1](下标抄错)→ 整体错位
  • s 数组只开 n 不开 n+1s[n] 越界
  • sint(数据大时会溢出)→ 这个对拍不一定抓得住! 因为生成器造的数据小。真实比赛里 10⁵10⁹ 就爆 int 了。 前缀和一律开 long long,这是习惯问题,不是判断问题。

后半场 · 差分

10 反过来的问题

现在把题目反过来:不查了,改成

给一个数组,q 次操作,每次把 a[l..r] 里的每个数都加上 v。 所有操作做完之后,输出整个数组。

输入 8 3
     3 -1 4 1 -5 9 2 6
     2 5 3               a[2..5] 每个加 3
     1 8 -2              整个数组每个减 2
     6 8 10              a[6..8] 每个加 10
输出 1 0 5 2 -4 17 10 14

暴力当然还是逐字翻译(O(n·q)):

diffBrute.cpp暴力
输入(stdin)
输出
点「运行 ▶」看结果
同题对比:每次现改 vs 差分
20 万个数、20 万次区间加,每次都是长区间。跑完把它改成 30 万。
每次现改
差分

11 ★ 关键的一步:只记变化量

★ 关键的一步

定义 d[i] = a[i] - a[i-1](相邻两项的差,约定 a[0] = 0)。

现在把 a[l..r] 整体加 v。想一想哪些「相邻两项的差」变了:

下标:      l-1    l   l+1  ...   r   r+1
原数组:     8     2    5    ...  7    4
每个加 3:   8     5    8    ...  10   4
                 ↑                    ↑
            这里的差变大了 3      这里的差变小了 3
            中间那些差 一 点 没 变

区间内部每一项都加了同一个 v,相邻两项的差当然不变。 真正变了的只有两处边界:

d[l]   += v;      // l 和 l-1 之间的差,大了 v
d[r+1] -= v;      // r+1 和 r 之间的差,小了 v

不管区间多长,一次操作只改两个数,O(1)。

全部操作做完之后,对 d 求一遍前缀和,就还原出最终的数组:

a[i] = d[1] + d[2] + ... + d[i]

为什么?因为 d[1]+...+d[i] = (a[1]-a[0]) + (a[2]-a[1]) + ... + (a[i]-a[i-1]), 中间全部消掉,只剩 a[i] - a[0] = a[i]。这叫望远镜求和, 式子写出来一眼就能看见它们互相抵消。

差分和前缀和是一对逆运算。 这就是为什么这两个东西要放在同一章里讲。

⚠ 数组必须开到 n+2

r 可以等于 n,那 d[r+1] 就是 d[n+1] —— 它在原数组外面。

这一格必须留出来(虽然还原时用不到它),否则就是数组越界。 运气好当场崩溃,运气不好静默地改坏了别的变量,然后你查一晚上。

这是差分的头号翻车点,比公式本身更容易出事。

diffFast.cpp正解
输入(stdin)
输出
点「运行 ▶」看结果
diffTrace.cpp过程演示
每次操作只有两个格子变了,中间一格没动 —— 这就是 O(1) 的来历。
输入(stdin)
输出
点「运行 ▶」看结果

现在回到上面那个动画,切到**「差分(改区间)」**那一栏再看一遍:

  • 每次区间加,只有两格变蓝。中间那些格子从头到尾没被碰过。
  • 最后「还原」那一段,用的就是前缀和那个循环,一个字都没改

12 ★ 对拍验证

★ 正确的用法

把「差分」那一栏换成你自己默写的,再点开始。

对拍器
生成器有一半的概率造出 r = n 的操作 —— 那正是要碰 d[n+1] 的情况。加的值有正有负,避免「只会变大」掩盖错误。

值得故意写错的:

  • d[r] -= v(少了 +1)→ 区间末尾那一项少加了 v
  • d[r+1] += v(符号写反)→ 后面所有数全错
  • 数组只开 n+1,遇到 r = n → 越界
  • 忘了先用原数组建 d(直接从全 0 的 d 开始)→ 原数组的值全丢了

再进一步

13 二维前缀和:矩形里的和

同样的思路搬到二维:给一个矩阵,反复问某个子矩形里所有数的和。

★ 容斥:多减了的要加回来

s[i][j] = 从左上角 (1,1)(i,j) 这个矩形里所有数的和。

预处理:

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]
//  大矩形     砍掉上面      砍掉左边      左上角被砍了两次,补回来

两个式子是同一个道理。不要背 —— 在纸上画一个 4×4 的格子, 把「上面那块」「左边那块」「左上角那块」涂成三种颜色, 你会看到左上角被涂了两次。画一次,一辈子不用再背。

sum2d.cpp二维前缀和
样例是 3×4 的矩阵。三个询问分别是:整个矩阵(78)、中间 2×2(34)、单个格子(7)。
输入(stdin)
输出
点「运行 ▶」看结果
对拍器
生成器专门造这三种:整个矩阵、单个格子、贴着第一行或第一列 —— 后者会用到 s[0][*] 和 s[*][0],是容斥最容易写错的地方。
✓ 二维差分也是一样的道理

给一个子矩形整体加 v,在二维差分数组上要动四个角

d[x1][y1]     += v;
d[x1][y2+1]   -= v;
d[x2+1][y1]   -= v;
d[x2+1][y2+1] += v;

然后对 d 求一次二维前缀和还原。原理和一维完全一致,只是边界从 2 个变成 4 个。

这一章不展开(CSP-J 考到二维差分的概率不高),但你现在已经有能力自己推了 —— 推完拿上面那个二维对拍器验一验,把 brute2d 改成「区间加」版本就行。

14 什么时候用不了

⚠ 前缀和的死穴:数组不能变

前缀和的前提是预处理之后数组不再改动

一旦中间有人改了 a[5],那么 s[5]s[n] 全部作废,重算要 O(n)。 如果题目是「改一次、查一次、改一次、查一次……」交替进行,前缀和就没有优势了。

又要反复改、又要反复查 —— 这正是第 38 章树状数组、第 39 章线段树要解决的问题。 到那时你会发现,它们干的事情本质上就是「可以修改的前缀和」。

所以这一章不只是一个技巧,它是后面那两章的引子。判断标准很简单:

场景用什么
只查不改前缀和 O(1) 查
只改不查(最后才输出)差分 O(1) 改
又改又查树状数组 / 线段树(第 38、39 章)

15 自测

自测清单0 / 10
配套练习
这一章我卡在哪(过一个月回来看,这几行比整章正文都值钱)
下一章预告

第 7 章双指针与滑动窗口,思路上和这一章是亲戚: 都是利用「上一步的结果」,避免从头再来一遍

区别在于前缀和是「提前全算好」,而双指针是「顺着往前挪,边挪边改」。