1. 一个朴素的区间加操作,怎么就把复杂度打穿了
先从一个再常见不过的题目说起:
你有一个长度为 n 的数组,初始全是 0。接下来有 q 次操作,每次操作给一个区间 [l, r],把区间里的每个元素都加上同一个值 v。所有操作结束后,输出整个数组。
n 和 q 的量级都是 10^5,也就是十万级别。
这个题看起来毫无难度,很多人的第一反应是直接写循环:
cpp复制for (int i = l; i <= r; i++) {
a[i] += v;
}
逻辑完全正确,但复杂度直接炸掉。每次操作在区间长度是 n 的情况下要遍历 n 次,总复杂度 O(nq),也就是 10^10 量级。现代计算机一秒大概跑 10^8 到 10^9 次简单操作,10^10 无论如何都过不了。
我被卡过很多次之后才意识到,这类问题真正的瓶颈在于:每次修改都要触碰区间内的每一个元素,这是"面"级别的操作。
如果能让每次区间修改变成 O(1),也就是"点"级别的操作,那问题就迎刃而解了。差分(Difference)就是干这件事的。
这也是"差分"这个名字最直白的来源——它的核心思想不是去维护数组本身的值,而是去维护数组相邻两个元素之间的差异。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 差分的本质:只改两端的聪明做法
2.1 用一个生活场景理解差分的直觉
想象一个非常长的货架,上面摆了一排商品,每个商品都有价格标签。现在商店要搞促销,从第 3 个商品到第 8 个商品,每个都要降价 5 元。
如果老老实实走过去,把第 3 到第 8 个商品的价格标签全部改一遍,要改 6 个标签。如果促销区间很长,比如从第 100 个到第 100000 个,就要改九万多个标签,非常累。
换一种做法:只做两个标记。在第 3 个商品上贴一个"从我开始降价 5 元"的通知,在第 9 个商品上贴一个"从我开始恢复原价"的通知。之后只需要从第一个商品往后扫一遍,把每个商品的"生效通知"累加起来,就能算出每个商品应该便宜多少钱。
差分干的就是这件事——它记录的极端情况下都只需要改两个点:区间的起点,以及区间结束后的下一个位置。这就是"区间加"操作从 O(n) 降为 O(1) 的奥秘。
2.2 数学定义与递推关系
给定原数组 a,下标从 1 开始。差分数组 d 的定义是:
code复制d[i] = a[i] - a[i-1]
规定 a[0] = 0,所以 d[1] = a[1]。
反过来,对差分数组做一遍前缀和,就能还原出原数组:
code复制a[i] = d[1] + d[2] + ... + d[i]
举个例子。如果原数组 a = [2, 1, 3, 5, 4],那么差分数组:
- d[1] = 2 - 0 = 2
- d[2] = 1 - 2 = -1
- d[3] = 3 - 1 = 2
- d[4] = 5 - 3 = 2
- d[5] = 4 - 5 = -1
所以 d = [2, -1, 2, 2, -1]。
对 d 做前缀和:
code复制a[1] = 2
a[2] = 2 + (-1) = 1
a[3] = 2 + (-1) + 2 = 3
a[4] = 2 + (-1) + 2 + 2 = 5
a[5] = 2 + (-1) + 2 + 2 + (-1) = 4
完全一致。这个前缀和还原的过程,就是差分的核心用法。
2.3 为什么区间加只修改两个位置
这是理解差分最关键的一步。
假设要对区间 [l, r] 内的所有元素加 v。看差分数组的定义 d[i] = a[i] - a[i-1],区间内所有元素都加 v,那么区间内任意相邻两个元素的差会怎么变?
比如 a[l] 和 a[l+1] 都加了 v,它们的差 (a[l+1] + v) - (a[l] + v) = a[l+1] - a[l],完全没变。也就是说,区间内部所有位置的差分值都不需要动。
唯一变化的是两个"边界":
- 在 l 位置,a[l] 加了 v,而 a[l-1] 没加,所以 d[l] 增加了 v。
- 在 r+1 位置,a[r+1] 没加,而 a[r] 加了 v,所以 d[r+1] 减少了 v。
所以区间加操作等价于:
code复制d[l] += v
d[r+1] -= v
注意,如果 r = n,那么 r+1 = n+1,这说明差分数组必须开到 n+2 的长度,否则数组越界。
用上文的例子验证一下。a = [2, 1, 3, 5, 4],要对区间 [2, 4] 加 3,期望得到 a' = [2, 4, 6, 8, 4]。
对差分数组 d = [2, -1, 2, 2, -1] 做修改:
code复制d[2] += 3 -> d[2] = 2
d[5] -= 3 -> d[5] = -4
得到 d' = [2, 2, 2, 2, -4]。做前缀和:
code复制a'[1] = 2
a'[2] = 4
a'[3] = 6
a'[4] = 8
a'[5] = 4
完全正确。这就是差分最核心的公式——只改两个端点,中间一概不管。
3. 一维差分落地:代码、边界与几个共性坑
3.1 完整实现
下面给出一个可以"抄作业"的完整代码。假设输入第一行是 n 和 q,第二行是初始数组(可能非零),接下来 q 行是操作 l r v,最后输出操作完的整个数组。
python复制n, q = map(int, input().split())
a = [0] + list(map(int, input().split()))
d = [0] * (n + 2)
# 根据初始数组构造差分数组
for i in range(1, n + 1):
d[i] = a[i] - a[i - 1]
# 执行 q 次区间加
for _ in range(q):
l, r, v = map(int, input().split())
d[l] += v
d[r + 1] -= v
# 对差分数组做前缀和得到结果数组
for i in range(1, n + 1):
d[i] += d[i - 1]
print(d[i], end=' ')
C++ 版本的核心循环也是一模一样的思路:
cpp复制const int MAXN = 100005;
int a[MAXN], d[MAXN];
// 构造差分
for (int i = 1; i <= n; i++) {
d[i] = a[i] - a[i-1];
}
// 区间加
d[l] += v;
d[r+1] -= v;
// 前缀和还原
for (int i = 1; i <= n; i++) {
d[i] += d[i-1];
a[i] = d[i];
}
3.2 常见坑一:数组长度
很多初学者在第一次写差分时,数组只开到 n+1,然后在处理 r = n 的区间加时,d[r+1] -= v 直接越界。这会导致两种很隐蔽的错误:要么程序直接崩溃,要么覆盖到相邻变量,出现"玄学错误"。
处理办法很简单:数组统一开 n+2,多出来的那个位置专门存放 d[n+1],防止越界。
3.3 常见坑二:初始数组非零时的构造顺序
如果初始数组不是零,必须先通过 d[i] = a[i] - a[i-1] 构造出差分数组,再执行区间加操作。不要在还没有构造差分的时候就直接 d[l] += v,否则结果完全不对。
我在实际做题中见过有人把构造和修改混在一起,比如先在脑子里认为"初始全是 0",跳过构造,操作完了发现结果差了初始数组的值。这个错误很隐蔽,因为样例输入用全零数组完全测不出来。
提示:初始数组全 0 时,差分数组也全 0,可以跳过构造。但为了代码统一、减少心智负担,建议一律写完整的构造过程。
3.4 常见坑三:区间端点的确认
题目中的区间有可能是左闭右闭 [l, r],也有可能是左闭右开 [l, r)。差分公式默认是左闭右闭,所以在用之前一定要确认题目给的是哪种区间定义。如果遇到 0-based 下标(从 0 开始计数的数组),记得在读取 l、r 后先加 1 转成 1-based,或者统一改用 0-based 的差分公式:
code复制d[l] += v
d[r] -= v // 区间为 [l, r) 时
这个转换错误非常容易犯。我的习惯是:先把所有下标统一成 1-based,再做差分操作,这样不容易乱。
3.5 常见坑四:差分数组能不能复用
很多题目做完区间加之后,还要继续做其他操作。这时要注意,一旦你执行了前缀和还原,差分数组 d 就变成了原数组 a,不再是差分数组了。如果想继续做区间加,需要重新基于当前数组构造差分。
所以,如果题目需要"多轮区间操作 + 查询"的混合模式,差分就不是最优选择了,可以考虑树状数组或线段树。这一点后面专门展开。
4. 差分跟前缀和,天然是一对逆运算
4.1 你中有我,我中有你
前缀和的公式是:
code复制S[i] = a[1] + a[2] + ... + a[i]
差分的公式是:
code复制d[i] = a[i] - a[i-1]
形式上,前缀和是"累加",差分是"求相邻差",两者正好是一对逆运算。对原数组求差分,再对差分数组求前缀和,就能还原出原数组;对原数组求前缀和,再对前缀和数组求差分,也能还原出原数组。
在线性代数里可以写得更清楚。如果用一个下三角矩阵 A 表示前缀和变换 S = A * a,那么差分变换的矩阵就是 A 的逆矩阵 D = A^{-1}。也就是说,前缀和和差分在数学结构上就是互逆的。
这个关系带来的直接好处是:很多问题里,你可以在"前缀和"和"差分"之间自由切换。具体选哪个,取决于题目要你做什么。
4.2 两类典型场景的对应
| 场景 | 朴素复杂度 | 差分/前缀和后复杂度 |
|---|---|---|
| 区间加 + 单点查询 | O(n) 修改 / O(1) 查询 | O(1) 修改 / O(1) 查询 |
| 单点加 + 区间求和 | O(1) 修改 / O(n) 查询 | O(1) 修改 / O(1) 查询(前缀和) |
| 区间加 + 区间求和(离线) | O(n) 修改 + O(n) 查询 | O(1) 修改 + O(1) 查询(差分 + 前缀和) |
注意第三行:区间加 + 区间求和,当所有操作离线给定、最后统一查询时,用差分配合前缀和就能做到线性复杂度。具体做法是:先对所有区间加操作修改差分数组,然后对差分数组做一次前缀和得到最终数组,再对这个数组做一次前缀和得到前缀和数组,最后每个区间查询就是 O(1) 的。
4.3 和树状数组、线段书的边界在哪里
很多初学者会有疑问:既然有线段树和树状数组,可以支持在线的区间加和区间求和,为什么还要学差分?
我的观点是:差分是"离线批量处理"的最优解,而线段树是"在线动态查询"的通用方案。 它俩不是替代关系,而是适用场景不同。
- 如果题目说"所有操作执行完后输出结果",那差分的线性复杂度是最优的。
- 如果题目要求"每次操作后立刻查询某个位置的值",差分的在线查询需要 O(n) 做前缀和,效率很低,这时要上树状数组或线段树。
- 树状数组可以支持"单点改 + 区间查",配合差分可以变成"区间改 + 单点查",但代码量比纯差分大。如果题目规模小,差分完全够用。
所以我的建议是:只要题目能离线做,优先用差分;遇到在线查询,再考虑更重的数据结构。
5. 二维差分:从线段的覆盖到矩形的加减
5.1 二维差分的定义
一维差分处理的是"区间加",二维差分处理的就是"子矩阵加"。
题目描述通常是:给一个 n 行 m 列的矩阵,初始全 0,q 次操作,每次把左上角 (x1, y1) 到右下角 (x2, y2) 的子矩阵内所有元素加 v,最后输出整个矩阵。
二维差分数组 d 的定义方式和一维类似,只不过从"相邻元素的差"变成了"二维的容斥差":
code复制d[i][j] = a[i][j] - a[i-1][j] - a[i][j-1] + a[i-1][j-1]
这个公式看起来吓人,其实就是一个二维的"差值":当前位置的值减去上方和左方的值,再加上左上方的值(因为左上方的值被减了两次)。
同样地,对 d 做二维前缀和就能还原出 a:
code复制a[i][j] = d[i][j] + a[i-1][j] + a[i][j-1] - a[i-1][j-1]
5.2 子矩阵加操作:只改四个点
对左上角 (x1, y1)、右下角 (x2, y2) 的子矩阵加 v,对应的差分修改:
code复制d[x1][y1] += v
d[x2+1][y1] -= v
d[x1][y2+1] -= v
d[x2+1][y2+1] += v
为什么是这四个位置?这里有一维差分的直觉可以延伸:一行一列分别处理"生效"和"撤销"。
- 在 (x1, y1) 加 v:表示从这个位置开始,向右向下的区域都多了一个 v 的"生效标签"。
- 在 (x2+1, y1) 减 v:表示往下超出子矩阵范围时,这个标签要撤销。
- 在 (x1, y2+1) 减 v:表示往右超出子矩阵范围时,这个标签要撤销。
- 在 (x2+1, y2+1) 加 v:因为右下角两个撤销叠加,导致该区域少了 v,所以需要补回来。
记忆口诀:左上加,下减,右减,右下加。配合容斥原理就清楚了。
用一个 4x4 的矩阵手推验证一下,假设初始全 0,要对 (2,2) 到 (3,3) 这个 2x2 子矩阵加 1。
修改四个位置后,差分矩阵变成:
| 行\列 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|
| 1 | 0 | 0 | 0 | 0 |
| 2 | 0 | +1 | 0 | -1 |
| 3 | 0 | 0 | 0 | 0 |
| 4 | 0 | -1 | 0 | +1 |
对差分矩阵做二维前缀和:
- (2,2) = 0 + 0 + 1 + 0 = 1
- (2,3) = 1 + 0 + 0 + 0 = 1
- (3,2) = 1 + 0 + (-1) + 0 = 0?不对,这里要仔细算。
二维前缀和的顺序是从左上往右下逐行扫,每个位置执行:
code复制d[i][j] += d[i-1][j] + d[i][j-1] - d[i-1][j-1]
我们用上面的矩阵手工推一遍(已就地更新):
初始矩阵 M:
code复制0 0 0 0
0 1 0 -1
0 0 0 0
0 -1 0 1
逐行扫描,更新每个位置为上方 + 左方 - 左上 + 自身:
第 1 行:0, 0, 0, 0。
第 2 行:
- (2,1) = 0 + 0 + 0 - 0 = 0
- (2,2) = 1 + 0 + 0 - 0 = 1
- (2,3) = 0 + 0 + 1 - 0 = 1
- (2,4) = -1 + 0 + 1 - 0 = 0
更新后第 2 行:0, 1, 1, 0
第 3 行:
- (3,1) = 0 + 0 + 0 - 0 = 0
- (3,2) = 0 + 1 + 0 - 0 = 1
- (3,3) = 0 + 1 + 1 - 0 = 1
- (3,4) = 0 + 0 + 1 - 1 = 0
更新后第 3 行:0, 1, 1, 0
第 4 行:
- (4,1) = 0 + 0 + 0 - 0 = 0
- (4,2) = -1 + 0 + 1 - 0 = 0
- (4,3) = 0 + 0 + 1 - 1 = 0
- (4,4) = 0 + 0 + 1 - 1 = 0
最终矩阵:
code复制0 0 0 0
0 1 1 0
0 1 1 0
0 0 0 0
完美符合预期:只有 (2,2)、(2,3)、(3,2)、(3,3) 四个位置为 1。这就是二维差分四个点修改的正确性。
5.3 二维差分的完整代码
cpp复制#include <bits/stdc++.h>
using namespace std;
const int MAXN = 1005;
int d[MAXN][MAXN];
int main() {
int n, m, q;
cin >> n >> m >> q;
// 初始矩阵为 0 时直接跳过构造
for (int i = 0; i < q; i++) {
int x1, y1, x2, y2, v;
cin >> x1 >> y1 >> x2 >> y2 >> v;
d[x1][y1] += v;
d[x2 + 1][y1] -= v;
d[x1][y2 + 1] -= v;
d[x2 + 1][y2 + 1] += v;
}
// 二维前缀和还原
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
d[i][j] += d[i - 1][j] + d[i][j - 1] - d[i - 1][j - 1];
cout << d[i][j] << " \n"[j == m];
}
}
return 0;
}
这里有一个容易踩的坑:初始化矩阵如果非零,需要先读入矩阵,然后用 d[i][j] = a[i][j] - a[i-1][j] - a[i][j-1] + a[i-1][j-1] 构造二维差分数组。如果忘了构造直接做修改,最后所有值都会少初始矩阵的部分。
二维差分的本质和一维完全一致,只是从"线段的边界"变成了"矩形的四个角"。理解了一维,二维只是在每个维度上多做一次容斥而已。
6. 搜索"差分"出来的那些别的东西,别混为一谈
学算法的同学去搜索"差分"这个词,很容易被各种同名概念干扰。这是因为"差分"在多个领域都有含义,而且相互之间没有直接关系。
| 领域 | 术语 | 含义 |
|---|---|---|
| 算法 / 数据结构 | 差分数组 | 相邻元素之差的数组,用于快速区间加 |
| 模拟电路 | 差分放大电路 / 差分运放 | 放大两个输入信号的差值,抑制共模干扰,常用于传感器信号调理 |
| 信号处理 / PCB 设计 | 差分信号 | 用一对信号线传输相位相反的信号,抗干扰能力强,常见于高速数字电路 |
| 数据安全 | 差分隐私 | 通过在查询结果中添加噪声,保护数据集中单个记录不被推断出来 |
| 演化计算 | 差分进化 | 一种基于种群差异进行变异的全局优化算法,属于进化算法 |
| 数学 / 控制理论 | 差分方程 | 描述离散系统状态迭代关系的方程,如二阶差分方程、反向差分变换 |
| 编码 | 差分曼彻斯特编码 | 利用跳变方向或是否跳变来表示二进制的编码方式 |
这些概念虽然都叫"差分",但内核完全不同。算法竞赛里的差分数组,只是几千行代码里最朴素的"相邻项之差",没有任何高深的背景。
我觉得这个多义性本身也是一个提醒:遇到一个名词时,先确认它所在的领域和上下文。尤其是搜资料时,如果直接搜"差分"两字,可能搜出一堆模拟电路运放的内容,对算法学习完全没用。建议加上限定词,比如"差分数组"、"区间加 差分"、"二维差分 算法",命中率会高很多。
另外,相关热搜词里还有"差分放大电路 AD620 1000倍差分放大防干扰处理"这类内容,那是硬件工程师的话题——和算法里的差分完全是两码事。那类内容涉及的是运放芯片的外围电路设计、共模抑制比、PCB 布局、滤波电容的选型问题,属于模拟电路的设计经验,不是数据结构的范畴。
7. 日记尾声:今天从区间加开始,读懂了"差分"
老实说,差分是我学算法以来"投入产出比"非常高的一课。它没有复杂的证明,也不需要背很长的代码,核心就是一句话:区间操作只改边界,其他位置不需要动。
这句话一旦想通,一维差分、二维差分都能顺下来。做题时遇到"多次区间加/子矩阵加后统一输出",先用差分把修改复杂度压到 O(1),最后线性扫一遍还原,整体复杂度就是 O(n + q) 或 O(n*m + q),根本不会超时。
根据我个人经验,想要真正掌握差分,光看博客不够,还是得动手写题。建议按这个顺序练:先写一道一维区间加的裸题,再写一道二维子矩阵加的题,最后找一道结合排序或贪心的题——比如"区间加后求最大值",体会差分数组配合前缀和得到最终数组的过程。
另外有一个小技巧想分享:差分数组的下标偏移问题,与其在脑子里算来算去,不如写代码前先把输入数据的格式和区间定义用注释写清楚,比如"// 输入区间为 [l, r],下标从 1 开始",然后照着注释写。这样能省掉很多调试时间。
今天先记录到这里,下一篇日记我打算把二维差分和平行于差分的"树上差分"一起复盘——那是把差分从数组推广到树结构的一种技巧,理解了今天的差分思想,树上差分的原理就不会太陌生。
