☰
差分数组从一维到三维:区间修改与容斥原理的算法精讲
2026/9/30 5:10:57 网站建设 项目流程

如果你刷过 LeetCode 或者打过蓝桥杯、多校赛,大概率遇到过这类题:给一个数组,m 次区间加法,最后把整个数组输出。暴力 for 循环当然能写,但 n 和 m 都到 (10^5) 级别时,(O(n \times m)) 直接把一个好好的思路卡到超时。我第一次在线比赛被这种题卡住的时候,还以为是语言或编译器问题,后来才明白,这种“区间批量修改 + 最后统一查询”的场景,就该让差分数组上场。

这篇文章我从一维开始,逐步推到二维、三维差分数组,把原理、图解、模板和例题拆开讲清楚。不是只给结论,而是把“为什么 l 处+c、r+1 处−c”“为什么二维要动四个点、三维要动八个点”这种底层逻辑讲透。无论你是刚开始刷题的新手,还是想系统梳理差分家族的竞赛党,都可以照着文章自己手撕一遍。

1. 暴力 for 循环撑不住的时候,差分数组就该上场了

1.1 暴力区间修改的复杂度灾难

先看一个最朴素的问题模型。有一个长度为 (n) 的数组 (a[1..n]),初始全是 0,现在给你 (m) 次操作,每次都把区间 ([l, r]) 内的所有元素加上 (c),最后输出每个位置的值。

最直觉的写法是这样:

for (int i = 1; i <= m; i++) { int l, r, c; cin >> l >> r >> c; for (int j = l; j <= r; j++) { a[j] += c; } }

逻辑完全没错,但复杂度是实打实的 (O(n \times m))。假设 (n = 10^5)、(m = 10^5),区间平均长度是 (5 \times 10^4),总操作次数大约 (5 \times 10^9),在一秒左右的时限里根本跑不完。

很多新手会想“我优化一下循环、开 O2、用快读是不是就行了”,实际上这条路走不通。因为问题本质不是常数大,而是算法复杂度太高。我们真正需要的是:不要每次操作都真的去改数组里的每个元素,而是把操作以一种更紧凑的形式“记”下来,最后一次性还原。

1.2 差分是前缀和的逆运算

要理解差分,先回忆前缀和。前缀和是这样定义的:

[ pre[i] = \sum_{j=1}^{i} a[j] ]

而差分数组 (d[i]) 的定义则是:

[ d[1] = a[1], \quad d[i] = a[i] - a[i-1] \quad (i \ge 2) ]

如果对差分数组 (d) 从头做一次前缀和,你会发现它精确地还原出原数组 (a);反过来,对原数组 (a) 做差分,也能得到 (d)。它们是一对互逆操作,类似于积分和微分的关系。

这个关系有什么用?关键在这里:前缀和擅长把“单点值”变成“区间累计值”,差分则擅长把“区间操作”变成“单点操作”。当我们想给区间内所有元素都加 (c) 时,我们其实不需要逐个修改元素,只需要修改区间边界处的“差分值”,让它在做前缀和的过程中自然传播到整个区间。

1.3 差分数组能干什么、不能干什么

我见过不少人学完差分之后,什么题都往上套,结果用错场景,反过来说“差分没用”。其实差分有非常清晰的使用边界。

差分数组适合的场景是:

  • 多次区间修改,最后一次性查询全部结果;
  • 多次区间修改,之后有大量单点查询,并且这里查询是离线完成的;
  • 配合二分答案,把“检查某轮操作后的状态”变成高效的批量重放。

差分数组不适合的场景是:

  • 需要在线边修改、边查询某个区间的和;
  • 需要每次修改后立刻知道某个位置的新值,且修改和查询交替出现。

如果遇到后面这两种情况,通常应该考虑树状数组或者线段树。这不是差分不好,而是工具没选对。把这个边界搞清楚,后面用起来才不会跑偏。

2. 一维差分:l 处 +c、r+1 处 −c,一次前缀和还原真相

2.1 一维差分数组的构造与物理意义

假设原数组是 (a[1..n]),对应的差分数组是 (d[1..n]),那么:

[ d[1] = a[1], \quad d[i] = a[i] - a[i-1] \quad (i = 2..n) ]

怎么理解这个 (d[i])?我自己的习惯是把数组想象成一列台阶的高度。原数组记录的是每级台阶的绝对高度,差分数组记录的是每级台阶相对上一级的高度差。从第 1 级开始,把所有高度差累加起来,就能算出任意一级台阶的绝对高度。

所以,对 (d) 做前缀和得到 (a),这个操作本质上就是“从高度差还原高度”:

[ a[i] = d[1] + d[2] + \cdots + d[i] ]

构造差分数组有两种套路。第一种是直接按定义算:

for (int i = 1; i <= n; i++) { diff[i] = a[i] - a[i - 1]; }

第二种是用一个统一的add操作,把每个位置 (i) 的单点值看作一次区间 ([i, i]) 的加法。这种方法在二维、三维差分里特别香,因为代码风格完全一致,不易记混。后面我会主要用第二种。

2.2 为什么区间 ([l, r]) 加 c 只需要动两个端点

这是整个差分数组最核心的一个问题。先说结论:

void add(int l, int r, int c) { diff[l] += c; diff[r + 1] -= c; }

为什么区间内所有元素都加 (c),却只更新两个位置?原因是差分数组保存的是“相邻元素之间的差值”。区间内部相邻元素的差在批量加同一个数时完全不变,真正变化的只有两处边界:

  • 在 (l) 处,左边的值和 (a[l]) 之间多出了 (c) 的差值;
  • 在 (r + 1) 处,(a[r]) 和右邻居之间多出了 (-c) 的差值。

更严谨一点,对 (diff) 做前缀和还原时:

[ pre[i] = \sum_{j=1}^{i} diff[j] ]

在 (i < l) 时,前缀和没受到任何影响;当 (i) 从 (l) 开始,多出来的 (c) 被计入前缀和,直到 (i \ge r + 1),又遇到 (-c) 被抵消。于是只有 ([l, r]) 这段前缀和结果整体多了 (c),完美对应区间加法。

举个具体例子。数组长度 8,区间 ([2, 5]) 加 3。初始化所有值为 0,差分数组一开始也是 0,执行add(2, 5, 3)后:

下标: 1 2 3 4 5 6 7 8 diff: 0 +3 0 0 0 -3 0 0 前缀和: 0 3 3 3 3 0 0 0

看到没有,从下标 2 到 5,前缀和正好都是 3。这就是差分数组区间修改的全部秘密。

2.3 图解:标记点如何扩散成整个区间

用一张 ASCII 示意图来看,diff数组只在两个位置有值,但前缀和的“扫描”过程让增量从 (l) 开始延续,到 (r+1) 处停止:

l=2 r+1=6 ↓ ↓ index: 1 2 3 4 5 6 7 8 diff: 0 +3 0 0 0 -3 0 0 └─────────────────┘ 前缀和一直带着 +3

你把这个过程理解为“做标记”:在起点放一个正号,在终点后一格放一个负号,之后从前到后扫一遍,正负号自然切出我们想要的区间。

2.4 一维差分模板(C++ / Python)

我平时写一维差分,最常用这套 C++ 模板:

#include <bits/stdc++.h> using namespace std; const int N = 100010; long long diff[N]; // 用 long long,防止多次累加溢出 void add(int l, int r, long long c) { diff[l] += c; diff[r + 1] -= c; } int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n, m; cin >> n >> m; // 读入原数组并构造差分数组:每个单点值看作区间 [i, i] 加一次 for (int i = 1; i <= n; i++) { int x; cin >> x; add(i, i, x); } // m 次区间修改 while (m--) { int l, r; long long c; cin >> l >> r >> c; add(l, r, c); } // 一次前缀和还原 for (int i = 1; i <= n; i++) { diff[i] += diff[i - 1]; cout << diff[i] << ' '; } return 0; }

Python 版本同样简洁:

n, m = map(int, input().split()) diff = [0] * (n + 2) # 多开一位,防止 r+1 越界 def add(l, r, c): diff[l] += c diff[r + 1] -= c a = list(map(int, input().split())) for i, x in enumerate(a, start=1): add(i, i, x) for _ in range(m): l, r, c = map(int, input().split()) add(l, r, c) for i in range(1, n + 1): diff[i] += diff[i - 1] print(diff[i], end=' ')

有一个细节要强调:diff数组必须开到n + 2。因为当r == n时,diff[r + 1]也就是diff[n + 1]会被更新,虽然它不参与最后的前缀和输出,但访问这个索引是合法的。

2.5 例题实战:LeetCode 1109 航班预订统计

题目是这样的:有 (n) 个航班,编号从 1 到 (n)。给定一个bookings数组,每个元素[first, last, seats]表示从first到last每个航班都预订了seats个座位。最后返回长度为 (n) 的数组,每个位置是这个航班的预订总数。

这不就是一个裸的一维差分题吗?把每个booking看成一次区间加法:

class Solution { public: vector<int> corpFlightBookings(vector<vector<int>>& bookings, int n) { vector<int> diff(n + 2, 0); for (auto& b : bookings) { int l = b[0], r = b[1], seats = b[2]; diff[l] += seats; diff[r + 1] -= seats; } vector<int> ans(n); int cur = 0; for (int i = 1; i <= n; i++) { cur += diff[i]; ans[i - 1] = cur; // 题目是 1-based,输出要转回 0-based } return ans; } };

这里最容易踩的坑是下标转换:bookings里的航班编号是 1 开始的,但答案数组是 0 开始的。我在第一次写时忘了把ans[i - 1]对应好,结果整体错位。解决办法很简单,还原时用 1 到 n 遍历,赋值给ans[i - 1]。

2.6 一维差分最容易踩的坑

第一,r + 1越界。很多人把diff开成n大小,当r == n时直接访问diff[n + 1],要么越界要么覆盖到其他变量。稳妥做法是开n + 2,并且在写模板时养成习惯。

第二,累加溢出。区间可能被操作很多次,int不够用。我一律用long long存差分值,尤其看到题目数据范围到 (10^9) 时,更不能用int硬抗。

第三,搞混“修改前构造差分”和“修改后还原”的顺序。正确流程是先通过add(i, i, a[i])把初始数组的差分数组构造出来,再执行所有区间修改,最后才做前缀和还原。如果一边修改一边还原,后续修改的标记就会污染已经还原出的值。

3. 二维差分:四个角标背后的容斥原理

3.1 从二维前缀和推导二维差分

一维搞明白之后,二维的本质就是一维在高维上的推广。先回忆二维前缀和:

[ sum[i][j] = a[i][j] + sum[i-1][j] + sum[i][j-1] - sum[i-1][j-1] ]

公式里的 (- sum[i-1][j-1]) 是因为sum[i-1][j]和sum[i][j-1]都包含了sum[i-1][j-1]这块区域,多算了一次,所以要减去。

那么二维差分怎么定义?把上面这个公式反解,用sum表示a,得到:

[ a[i][j] = sum[i][j] - sum[i-1][j] - sum[i][j-1] + sum[i-1][j-1] ]

如果我们的差分数组叫diff,构造时其实是在对原数组做这个“逆运算”。不过在实际代码里,更常用的做法依然是add(i, j, i, j, a[i][j]),把每个单点看成一次 (1 \times 1) 子矩阵的区间加。

3.2 子矩阵加 c 的四个角标为什么这样摆

现在问题来了:如果想给左上角 ((x1, y1))、右下角 ((x2, y2)) 的子矩阵整体加 (c),应该怎么更新差分数组?

答案:

void add(int x1, int y1, int x2, int y2, int c) { diff[x1][y1] += c; diff[x2 + 1][y1] -= c; diff[x1][y2 + 1] -= c; diff[x2 + 1][y2 + 1] += c; }

很多人看一眼觉得对称,就直接背。但必须理解为什么右下角是+c。因为差分数组还原时要做二维前缀和,某个位置的标记会向右、向下两个方向传播:

  • (x1, y1) + c:让整个右下方向都带上 (c),这范围太大了;
  • (x2 + 1, y1) - c:把从 (x2+1) 行开始的传播拦掉;
  • (x1, y2 + 1) - c:把从 (y2+1) 列开始的传播拦掉;
  • 但是(x2+1, y2+1)这个位置被两个-c都传播到了,相当于多减了一次,需要补一个+c。

这个“多减了要补回来”的操作,就是二维容斥原理。四个角标正好对应二维前缀和公式里的四项,符号也和公式一致:+、-、-、+。

3.3 图解:四个角标如何在矩阵中传播

假设矩阵大小是 (8 \times 8),要给 ((2,2)) 到 ((5,5)) 的子矩阵加 1。四个标记位置分别是:

y1=2 y2+1=6 ↓ ↓ x1=2 +1 -1 x2+1=6 -1 +1

用 ASCII 示意图表示标记和前缀和的传播效果:

列: 1 2 3 4 5 6 7 8 行1: . . . . . . . . 行2: . [+1] . . . [-1] . . 行3: . . . . . . . . 行4: . . . . . . . . 行5: . . . . . . . . 行6: . [-1] . . . [+1] . . 行7: . . . . . . . .

做完二维前缀和后,从 (2,2) 向右向下扩散的正标记,会在 (2,6) 和 (6,2) 被负标记截断,然后在 (6,6) 补回来。最终只有 (2,2) 到 (5,5) 这个矩形区域的结果是 1,其余都是 0。

这也是为什么二维差分比一维多两个标记点:因为二维前缀和是向两个方向传播的,每一个方向都要“拦”,拦完还要处理交叉位置的重叠。

3.4 二维差分模板(C++)

二维差分代码风格和一维保持统一,就非常不容易写错。

const int N = 1010; long long diff[N][N]; void add(int x1, int y1, int x2, int y2, long long c) { diff[x1][y1] += c; diff[x2 + 1][y1] -= c; diff[x1][y2 + 1] -= c; diff[x2 + 1][y2 + 1] += c; } // 还原:对 diff 数组做二维前缀和 for (int i = 1; i <= n; i++) { for (int j = 1; j <= m; j++) { diff[i][j] += diff[i - 1][j] + diff[i][j - 1] - diff[i - 1][j - 1]; } }

还原的时候,diff[i][j]最终就变成原数组a[i][j]。这个+=的过程本身就是在原地做二维前缀和,不需要再开一个sum数组。

3.5 例题实战:洛谷 P3397 地毯

题目很好懂:在 (n \times n) 的网格上铺 (m) 块地毯,每块地毯覆盖左上角 ((x1, y1)) 到右下角 ((x2, y2)) 的区域,最后输出每个格子被多少块地毯覆盖。

看到“覆盖次数”和“最后统一输出”,明显就是二维差分的裸题。每块地毯相当于给一个子矩阵加 1,最后做二维前缀和还原。

#include <bits/stdc++.h> using namespace std; const int N = 1010; int diff[N][N]; void add(int x1, int y1, int x2, int y2) { diff[x1][y1] += 1; diff[x2 + 1][y1] -= 1; diff[x1][y2 + 1] -= 1; diff[x2 + 1][y2 + 1] += 1; } int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n, m; cin >> n >> m; while (m--) { int x1, y1, x2, y2; cin >> x1 >> y1 >> x2 >> y2; add(x1, y1, x2, y2); } for (int i = 1; i <= n; i++) { for (int j = 1; j <= n; j++) { diff[i][j] += diff[i - 1][j] + diff[i][j - 1] - diff[i - 1][j - 1]; cout << diff[i][j] << ' '; } cout << '\n'; } return 0; }

复杂度是 (O(m + n^2)),比直接暴力枚举每个地毯覆盖点要快得多。这道题也特别适合用来验证自己对二维差分的理解:把样例跑一遍,再手工算一次二维前缀和,基本就通了。

3.6 二维差分的初始化与索引习惯

如果你手里不是从 0 开始的差分数组,而是已经有一个初始二维矩阵,需要先构造差分数组,最稳的方法还是统一走add:

for (int i = 1; i <= n; i++) { for (int j = 1; j <= m; j++) { int x; cin >> x; add(i, j, i, j, x); } }

这样做虽然每次add有四次更新,但思路一致性极高,从一维带到二维、再带到三维,都不容易出错。

还有两个习惯值得养成。第一,坐标一律从 1 开始,不要用 0 下标。差分数组在 1-based 下标下的边界判断非常自然,x2 + 1、y2 + 1就算越界到n + 1、m + 1,也只是落到数组的边界外一格,不会误伤有效数据。第二,数组维度至少开N + 2,别开正好的N。我在做洛谷这道题时曾经把diff开成N,结果n = 1000时访问diff[1001]直接越界,报错找了好一会。

4. 三维差分:八个角标的符号表,立体区间修改不再靠枚举

4.1 三维前缀和与三维差分的数学对应

三维差分在竞赛题里出现频率比一维、二维低,但一旦出现,基本都是压轴题级别的区分度。它的推导逻辑和二维完全一样,只是多了一个维度,容斥的项数从 4 变成 8。

三维前缀和公式:

[ \begin{aligned} sum[i][j][k] = a[i][j][k] &+ sum[i-1][j][k] + sum[i][j-1][k] + sum[i][j][k-1] \ &- sum[i-1][j-1][k] - sum[i-1][j][k-1] - sum[i][j-1][k-1] \ &+ sum[i-1][j-1][k-1] \end{aligned} ]

这个公式的记忆方法:一维的项是 2 个,二维是 4 个,三维是 8 个;每多一个维度,就多一组“隔着两个维度的项”。符号按包含的维度数决定,奇数个减号维度的项为负,偶数个为正。

三维差分的还原,就是对diff数组做一次完整的三维前缀和。由于diff的每个标记会被传播到 (x)、(y)、(z) 三个方向,想要描述一个长方体区域的“净增量”,必须同时处理所有边界和边界交叉。

4.2 八个角标的符号:一个表格搞定立体容斥

假设要给一个长方体 ((x1, y1, z1)) 到 ((x2, y2, z2)) 内的所有位置加 (c)。和一维两个端点、二维四个角标对应,三维需要更新八个角标:

void add(int x1, int y1, int z1, int x2, int y2, int z2, long long c) { diff[x1][y1][z1] += c; diff[x2 + 1][y1][z1] -= c; diff[x1][y2 + 1][z1] -= c; diff[x1][y1][z2 + 1] -= c; diff[x2 + 1][y2 + 1][z1] += c; diff[x2 + 1][y1][z2 + 1] += c; diff[x1][y2 + 1][z2 + 1] += c; diff[x2 + 1][y2 + 1][z2 + 1] -= c; }

符号规律其实特别好记。记起点是(x1, y1, z1),终点加一后的坐标是(X, Y, Z),其中 (X = x2+1)、(Y = y2+1)、(Z = z2+1)。八个角标每个都是由x1或X、y1或Y、z1或Z组合出来的。

符号由“取了多少个终点坐标”决定:

角标坐标取终点坐标的个数符号
(x1, y1, z1)0+
(X, y1, z1)1-
(x1, Y, z1)1-
(x1, y1, Z)1-
(X, Y, z1)2+
(X, y1, Z)2+
(x1, Y, Z)2+
(X, Y, Z)3-

奇数个终点坐标取负号,偶数个取正号。这和二维的符号规律完全一致:(x1,y1)是 0 个终点,正;(X,y1)和(x1,Y)是 1 个终点,负;(X,Y)是 2 个终点,正。

如果不想背八个坐标,可以这样记忆:想象一个立方体的八个顶点,从起点开始,每把一个坐标从x1/y1/z1换成X/Y/Z,符号就翻转一次。改动一个坐标变号,改动两个坐标又变回来,改动三个坐标再变号。我也在底下贴一个我常用来推导的写法:先写成三重循环枚举(i,j,k),其中i取x1或X,j取y1或Y,k取z1或Z,符号是(-1)^(bits),这样永远不会漏。

4.3 三维差分还原的三重前缀和

和二维一样,三维差分做完标记后,最后一步是对diff数组做三维前缀和。还原代码看起来长,但就是前面公式的直接翻译:

for (int i = 1; i <= A; i++) { for (int j = 1; j <= B; j++) { for (int k = 1; k <= C; k++) { diff[i][j][k] += diff[i - 1][j][k] + diff[i][j - 1][k] + diff[i][j][k - 1] - diff[i - 1][j - 1][k] - diff[i - 1][j][k - 1] - diff[i][j - 1][k - 1] + diff[i - 1][j - 1][k - 1]; } } }

这里有一个细节:三重循环必须按照从 1 到最大值的递增顺序执行。因为计算diff[i][j][k]时要用到diff[i-1][j][k]、diff[i][j-1][k]等已经更新过的值,这些位置在本轮循环之前就已经算好了。如果打乱循环顺序,就会用到旧值,结果全错。

4.4 三维差分的空间开销与优化思路

三维差分的最大问题不是原理,而是内存。假设长方体尺寸是 (100 \times 100 \times 100),那就是 (10^6) 个格子,开long long大约 8 MB,完全没问题。但如果是 (500 \times 500 \times 500),就是 (1.25 \times 10^8) 个格子,一个long long数组就要 1 GB,直接超内存。

主流优化有两种。第一种是压维,把三维数组映射到一维数组里,减少每层数组的额外开销,同时利用一维索引做连续内存访问。第二种是如果只需要判断“是否存在某个点被击穿”,可以在还原过程中边算边判断,不需要把整个还原结果存下来。很多三维差分的题目都需要配二分答案,这时可以根据二分范围动态开数组,或者复用同一个diff数组。

4.5 例题实战:蓝桥杯“三体攻击”

这道题是我第一次真正感受到三维差分价值的地方。题目大意是:有一个 (A \times B \times C) 的立方体,每个格子有初始生命值。接下来有 (m) 轮攻击,每轮攻击会对方体内某个子长方体造成等量伤害。问第几轮攻击时,第一次出现某个格子累计伤害已经超过其生命值,也就是被击穿。

数据范围不小的前提下,逐个格子模拟显然不行。但因为只问“最早第几轮”,很自然地想到二分答案:

  • 二分一个攻击轮数mid;
  • 把前mid轮攻击全部用三维差分做“子长方体加伤害”;
  • 对diff做三维前缀和还原;
  • 扫描所有格子,看是否存在某个格子累计伤害不小于生命值;
  • 存在说明答案在[1, mid],否则答案在(mid, m]。

伪代码结构大概是这样:

bool check(int mid) { memset(diff, 0, sizeof(diff)); for (int i = 1; i <= mid; i++) { add(atk[i].x1, atk[i].y1, atk[i].z1, atk[i].x2, atk[i].y2, atk[i].z2, atk[i].dmg); } for (int i = 1; i <= A; i++) for (int j = 1; j <= B; j++) for (int k = 1; k <= C; k++) { // 三维前缀和还原 } for (int i = 1; i <= A; i++) for (int j = 1; j <= B; j++) for (int k = 1; k <= C; k++) if (hp[i][j][k] <= diff[i][j][k]) return true; return false; }

二分次数是 (O(\log m)),每次check要重放mid轮攻击并扫描整个立方体,总复杂度大约是 (O((m + A \times B \times C) \log m))。这种题暴力写不出这种数量级的效率,差分数组在这里就是关键先生。

4.6 三维差分配二分的几个注意点

第一,check里每次都要重新初始化diff,不要用vector反复 resize,性能很差,直接用固定数组加memset更稳。

第二,伤害累加可能超过int,diff和生命值数组都建议用long long。

第三,二分边界要小心。如果第一轮攻击就击穿,答案可能是 1;如果最后一轮都没击穿,需要额外判断。我习惯把二分的右边界设为m + 1,检查check(m)都不满足时输出-1或题目要求的特殊值。

5. 差分数组模板速查与实战避坑:从模板到例题的最后一公里

5.1 三个维度的模板速查对照表

把一维、二维、三维的关键信息放在一张表里,背模板之前先看规律。

维度区间修改对象标记点数量符号规律还原复杂度
一维区间 ([l, r])2起点 +,终点后一格 −(O(n))
二维子矩阵 ((x1,y1)) 到 ((x2,y2))4左上 +,右上 −,左下 −,右下 +(O(n \times m))
三维子长方体 ((x1,y1,z1)) 到 ((x2,y2,z2))8奇数个终点坐标为 −,偶数个为 +(O(A \times B \times C))

无论几维,核心思想都是同一个:把对一大片区域的修改,转换成对边界几个点的修改,最后用一次前缀和把效果扩散回整个区域。

5.2 “还原”阶段的更新顺序细节

三个维度的还原都是原地做前缀和,但这里的顺序细节经常被忽略。一维从头到尾扫;二维按行、列依次扫;三维按 (i, j, k) 三重递增扫。核心原则是:计算当前点的时候,所有依赖的“前驱点”都必须已经更新完。

写二维还原时,很多人会手滑写成:

diff[i][j] += diff[i][j - 1] + diff[i - 1][j] - diff[i - 1][j - 1];

这其实没问题,因为三个前驱点(i, j-1)、(i-1, j)、(i-1, j-1)在递增循环中都已经被处理过。我把这个细节单独拿出来说,是因为一旦有三维题目,循环顺序错了很难排查,甚至样例都能过,大点数据就挂。

5.3 别把差分数组用错场景

差分数组虽然有奇效,但它是一个“离线工具”。我见过一些同学拿到“区间加、区间求和”的题,想都不想直接差分,结果发现算不出在线答案。

这里给一个快速判断表:

需求推荐工具
多次区间修改,最后统一输出每个点差分数组
多次区间修改,最终答案靠单点查询差分数组 + 前缀和
需要在线单点更新 + 区间求和树状数组
需要在线区间更新 + 区间求和线段树 / 带 lazy 标记的树状数组
需要在线区间更新 + 单点查询树状数组差分维护

差分数组不是万能的,但它的优点是极度简单、常数小、实现快。在离线场景下,能用差分解决的问题,没必要上更重的数据结构。

5.4 我踩过的几个真实坑

最后讲几个我自己在实战中踩过的坑,希望能帮你少走弯路。

第一个是数组开小。一维时diff[r + 1]需要n + 1,二维时diff[x2 + 1][y2 + 1]需要n + 2行和列,三维更是每个维度都要多留一位。我现在写数组直接开N + 5,宁可多一点也不要越界。

第二个是int溢出。洛谷 P3397 那种只加 1 的题目,int勉强够用,但如果是多轮区间加、或者像“三体攻击”这种要累加伤害的题,diff随时可能爆。我现在一律用long long,不会错。

第三个是二维和三维add的符号写反。尤其是三维,八个角标很容易漏或者多写。我的习惯是每次写完add都对着 4.2 的表格读一遍:从起点开始,每把一个坐标换成终点加一,符号就翻转一次;改 0 个是正,改 1 个是负,改 2 个是正,改 3 个是负。

第四个是还原时忘记diff[i][j] +=这种“原地更新”的写法,而是新开一个sum数组。新开数组不是不行,但反而容易在对应下标时出错。直接在diff上做前缀和,最后diff[i][j]就是最终答案,简单直接。

最后一个就是下标习惯。差分题目几乎清一色用 1-based 下标,务必在读取原始数组和输出答案时把 0-based 和 1-based 的换算做对。LeetCode 这类平台经常给你 1-based 的输入和 0-based 的输出,这种错位问题通常很隐蔽,但造成的返工成本最高。


说实话,差分数组这个知识点的门槛并不高,只需要把“区间操作转端点操作”这个思维转过弯,一维、二维、三维其实都是同一个套路。真正难的是在实际题目里判断出该用差分,以及在三维空间里不慌不乱地写出八个角标。

我个人建议你用这篇文章里的模板去刷三道题验证一下:一维做 LeetCode 1109,二维做洛谷 P3397,三维找一道“三体攻击”或者类似的三维差分题。第一次写三维add的时候,对照符号表一步一步来,写完之后自己造一组小数据手工验算一遍,这个过程比背十遍模板都有用。

等你能不假思索地写出三维差分的add和还原循环时,这个知识点才算真正被你“手撕”下来了。之后遇到矩形覆盖、立方体批量伤害、区间增量统计这类问题,你会在别人还在想暴力怎么优化的时候,直接写下一行add(l, r, c)。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询