刚接触算法题时,我一度觉得“对一个数组反复做区间加法再输出”这种题很无聊:for 循环从 l 加到 r 不就行了?直到有一次数据规模直接拉到 10^5 次更新、数组长度也是 10^5,我一顿操作猛如虎,交上去超时,整个人傻掉。后来我才知道,这种场景下真正该用的是一个叫差分数组的工具,它能把每次区间更新的耗时从 O(区间长度) 降到 O(1)。这篇文章我就把差分数组从原理到实战完整拆开讲,包括一维差分、二维差分、区间计数问题,以及我实际写代码时踩过的那些边界大坑。如果你正在刷 LeetCode、准备算法面试,或者做批量区间统计类的数据处理,这篇应该能帮你少走不少弯路。
1. 一次暴力更新踩出来的痛:差分数组解决“区间加”的效率问题
1.1 从“朴素区间修改”到“只改两个端点”
先描述一个典型题,也是几乎所有差分数组教程都会用的开头:给定长度为 n 的数组 a,初始全为 0。执行 m 次操作,每次把区间 [l, r] 内所有元素加上一个值 v。所有操作结束后,输出整个数组。
最直觉的写法是直接模拟。
cpp复制#include <bits/stdc++.h>
using namespace std;
int main() {
int n = 100000, m = 100000;
vector<long long> a(n + 2, 0);
for (int i = 0; i < m; i++) {
int l = rand() % n + 1;
int r = rand() % n + 1;
if (l > r) swap(l, r);
int v = rand() % 100;
for (int j = l; j <= r; j++) {
a[j] += v;
}
}
// 输出...
return 0;
}
这段代码逻辑上没错,问题在于:每次循环要处理 r - l + 1 个元素,m 次操作累计复杂度 O(mn)。n 和 m 都是 10^5 时,最坏情况是 10^10 次加法,不超时才怪。我那次就是被这个看似简单的过程卡死。
为什么暴力慢?因为区间内部的所有元素都在发生相同的变化。你一次一次地加,实际上做了大量重复工作。如果我们换一种角度:不记录每个位置当前的值,而是记录“每个位置相对于前一个位置的变化量”,那么给一段连续区间统一加上 v,区间内部的相对关系完全不变,真正发生变化的只有两个地方:
- 区间左端点位置:突然比前一个位置多了 v;
- 区间右端点之后第一个位置:恢复了原来的差值,所以要比前一个位置少 v。
这两个变化量,就是差分数组在区间更新里要维护的东西。
1.2 差分数组的最小实现模板
定义差分数组 d,原始数组为 a,则 d[i] = a[i] - a[i-1]。反过来,a[i] 等于 d[1] + d[2] + ... + d[i],也就是对 d 做前缀和可以得到 a。
模板如下,下标从 1 开始,数组开 n+2,防止越界。这一步非常重要,我后面会专门讲,先直接给能跑的模板。
cpp复制class DiffArray {
private:
vector<long long> diff;
bool finalized = false;
public:
DiffArray(int n) : diff(n + 2, 0) {}
void addRange(int l, int r, long long v) {
if (finalized) {
// 一旦做过前缀和还原,就不能继续用这个对象做区间更新
throw runtime_error("already finalized");
}
if (l > r) return;
diff[l] += v;
if (r + 1 < diff.size()) {
diff[r + 1] -= v;
}
}
vector<long long> getArray() {
// 用前缀和还原原数组
vector<long long> res(diff.size());
long long cur = 0;
for (int i = 0; i < diff.size(); i++) {
cur += diff[i];
res[i] = cur;
}
finalized = true;
return res;
}
};
注意:这个模板里 diff 数组长度是 n+2,所以当 r+1 等于 n+1 时依然有位置,不会越界。
用这个模板重写刚才的题目,每次区间更新只做两次数组赋值,最后统一做一次前缀和。整体时间复杂度 O(n+m),空间复杂度 O(n)。同样是 10^5 规模,暴力需要上亿次运算,差分数组只做几十万次赋值,速度快了几个数量级。
1.3 为什么这套方案只在特定场景里成立
差分数组适合的场景有一个特点:修改和查询不是交替进行的。更准确地说,它适合先给出一批区间更新,然后统一查询最终结果。如果你做了几次区间更新后马上要查询某个位置的值,差分数组也可以支持单点查询,因为单点查询就是求 diff 的前缀和,但前缀和计算是 O(n) 的;优化一点可以用树状数组维护 diff 来实现动态前缀和,那就是另一套方案了。
所以记住这个判断标准:
| 场景 | 差分数组是否合适 |
|---|---|
| 一堆区间更新结束后,一次性输出/查询所有位置 | 非常合适 |
| 区间更新后反复单点查询 | 需要结合树状数组 |
| 动态区间更新 + 动态区间查询交替 | 一般不适合,考虑线段树 |
| 数据端点在 1e9 级别,无法开数组 | 需离散化或改用扫描线 |
这个判断先放在这里,后面第 6 章还会展开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 把“区间改变”翻译成“端点事件”:一维差分的数学本质与边界判断
2.1 用相邻差与前缀和重新看原数组
差分数组的核心思想,是把每个位置的值拆成“从前一个位置开始,变化量累积到当前的结果”。用一个生活化的例子来理解。想象一条山路,a[i] 是你站在第 i 个桩子处的海拔。d[i] 是第 i 个桩子和第 i-1 个桩子之间的高度差。如果你要把第 2 到第 5 个桩子这一段路整体垫高 3 米,这段路内部的相对高差有没有变?没有变。只有两个地方变了:
- 从第 1 个桩子走到第 2 个桩子时,升高幅度多了 3 米;
- 从第 5 个桩子走到第 6 个桩子时,升高幅度少了 3 米,因为第 6 个桩子及之后的海拔没有变。
对应到代码里,就是 diff[2] += 3,diff[6] -= 3。
所以差分数组记录的从来不是“绝对高度”,而是变化发生的位置和幅度。原数组是变化的累积,也就是“差分数组的前缀和”。
2.2 两个例子把边界问题钉死
搞懂边界判断,等于避开了差分数组 80% 的 bug。我建议你永远用一个小例子手推,而不是硬背公式。
假设 n = 5,初始数组全是 0。
text复制a : [0, 0, 0, 0, 0]
diff: [0, 0, 0, 0, 0, 0] // 长度为 n+1,多一位是为了记录结束
现在执行一次操作:把下标 2 到 4 的区间加 3。
正确的 diff 更新:
text复制diff[2] += 3
diff[5] -= 3
接着用前缀和还原:
text复制前缀和过程:
i=1: cur=0, a[1]=0
i=2: cur=3, a[2]=3
i=3: cur=3, a[3]=3
i=4: cur=3, a[4]=3
i=5: cur=0, a[5]=0
得到 a = [0, 3, 3, 3, 0],完全正确。
如果手滑写成 diff[r] -= v,即 diff[4] -= 3,前缀和还原会得到:
text复制i=1: cur=0, a[1]=0
i=2: cur=3, a[2]=3
i=3: cur=3, a[3]=3
i=4: cur=0, a[4]=0 // 这里就已经提前被减掉了
i=5: cur=0, a[5]=0
结果第 4 个位置丢了值。这个错误的本质是:加法的结束位置是“区间最后一个仍要加 v 的位置”,而下一次累积应该从“区间外的第一个位置”开始恢复,所以减 v 必须放在 r+1 处。
2.3 下标体系与数组扩容的实战约定
差分数组写多了之后,我总结出三条约定,能省很多心:
第一,最好使用 1-based 下标。LeetCode 里常见的区间题往往给的是 1-based 也有 0-based,不一致时先转换再操作。1-based 的好处是 diff[i] 对应 a[i] 与 a[i-1] 的差,不用为 i=0 额外做特判。如果你的输入是 0-based,可以做一个整体偏移,把 [l, r] 变成 [l+1, r+1] 再处理。
第二,diff 数组一定比原始数组多开两个位置。也就是长度 n+2。因为更新到右边界 r 时,可能执行 diff[r+1] -= v,当 r 正好等于 n 时,r+1 是 n+1,如果你的 diff 长度只有 n,就越界了。多开一个单位可以让你在最后一个元素也在区间内时安全地做减法,而不用写 if 判断。如果 r+1 等于 n+1,那这个位置就是数组的“虚拟末尾”,它对应的是区间结束后第 1 个位置,原数组并不存在这个位置,但不影响前缀和计算。
第三,在能保证正确性的前提下,用一个 finalize 或 build 方法来区分“更新阶段”和“查询阶段”。我最早写差分时,直接在 diff 上做前缀和,结果不仅 diff 被破坏,后续再想追加区间更新也没办法了。把“更新”和“还原”做成两个独立阶段,会让代码清晰很多。
3. 从一维到二维:矩形批量加值的四角更新技巧
3.1 二维前缀和与二维差分的关系
一维差分只是开胃菜,真正容易写错的是二维差分。先看问题:给定一个 n 行 m 列的矩阵 a,初始全为 0。执行 q 次操作,每次在子矩阵 (x1,y1) 到 (x2,y2) 的所有位置加 v。操作结束后输出整个矩阵。
如果我们直接用二重循环模拟,单次操作最坏 O(nm),q 次就是 O(qnm),在 500x500 的矩阵做 50000 次操作就彻底不行了。
和一维类似,我们需要一个二维的 diff 矩阵。二维场景下,a[i][j] 不再等于前一格累积,而等于二维前缀和:
text复制a[i][j] = sum(diff[1..i][1..j])
也就是说,对 diff 做二维前缀和就能还原出 a。那一次矩形加 v,应该怎么改 diff?答案是在下面四个位置做标记:
text复制diff[x1][y1] += v
diff[x2+1][y1] -= v
diff[x1][y2+1] -= v
diff[x2+1][y2+1] += v
这就是固定的“四角更新法”。
3.2 四角更新的容斥推导
为什么是四个角?你可以用二维前缀和的容斥原理来推。
假设矩阵下标从 1 开始,我们要让 (x1,y1) 到 (x2,y2) 这个矩形内部的所有值都加 v。
想象对 diff 做二维前缀和的过程。如果只在 (x1,y1) += v,那么从 (x1,y1) 开始向右下角所有位置都会吃到这个 v,扩散范围是整个右下象限,这显然太大了。需要在受影响范围的两条边界线上“切断”它。
- 在 (x2+1, y1) -= v,等于让从这一行开始、向下扩散的 v 被抵消;
- 在 (x1, y2+1) -= v,等于让从这一列开始、向右扩散的 v 被抵消;
但这样做了之后,以 (x2+1, y2+1) 为左上角的右下区域被减了两次,整体变成 -v,于是要在这个位置再 +v 补回来。
如果你熟悉容斥原理,可以把矩形加法看成:大范围 += v,减去下方多余区,减去右方多余区,再加回右下角重复减掉的区。四角更新的本质就是在 diff 矩阵上做这个容斥。
3.3 落地代码与常见矩阵坑
写代码时,最普遍的一个坑是 diff 矩阵越界。你需要在 x2+1 和 y2+1 处更新,但如果 x2 恰好是最后一行,x2+1 就超出 diff 的合法范围了。最省事的方法是把 diff 矩阵开成 (n+2) x (m+2),这样 x2+1 和 y2+1 最多等于 n+1 或 m+1 也不会越界。前缀和还原时从 1 到 n 遍历即可,虚拟行和虚拟列不用输出。
下面是核心代码:
cpp复制int n, m, q;
cin >> n >> m >> q;
vector<vector<long long>> diff(n + 2, vector<long long>(m + 2, 0));
while (q--) {
int x1, y1, x2, y2, v;
cin >> x1 >> y1 >> x2 >> y2 >> v;
diff[x1][y1] += v;
diff[x2 + 1][y1] -= v;
diff[x1][y2 + 1] -= v;
diff[x2 + 1][y2 + 1] += v;
}
vector<vector<long long>> a(n + 2, vector<long long>(m + 2, 0));
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
a[i][j] = diff[i][j]
+ a[i - 1][j]
+ a[i][j - 1]
- a[i - 1][j - 1];
}
}
这里的 a[i][j] 不是普通赋值,而是“二维前缀和递推”:当前 diff 值 + 上方累计 + 左方累计 - 左上角重复部分。执行完后 diff 本身会被覆盖,但这不影响结果。
二维差分最容易让人困惑的是:修改的是 diff,不是 a;还原时会用到已经被修改过的相邻 a。我第一次手写时,没搞清楚 a[i-1][j] 代表的是“已经算好的上一行”而不是原始值,导致一整个矩阵算错,最后靠 3x3 的小例子逐格手推才发现。建议你也找一个 3x3 矩阵,更新一个小矩形,先在纸上画四个角,再做前缀和,就能对整个过程有很直观的感觉。
4. 差分不只是“数组加值”:区间计数、调度与时间轴问题
4.1 航班预订与拼车问题的端点语义
差分数组最常见的面试应用,并不是直接说“区间加 v”,而是把问题包装成区间覆盖次数、时间轴占用、区间调度。
典型的有两类题。
第一类:LeetCode 风格的“航班预订统计”。给定一班航班的预订列表,每个预订记录是 [first, last, seats],含义是“从第 first 个航班到第 last 个航班,每个航班都增加 seats 个预订”。要输出每个航班的总预订数。这里区间是左闭右闭的。令 answer 为原数组,先对差分 diff 做:
text复制diff[first] += seats
diff[last + 1] -= seats
然后前缀和还原。
第二类:拼车问题。trips[i] = [numPassengers, from, to],表示车上在 from 站上来 numPassengers 人,在 to 站下车。给定车容量 capacity,判断能否完成所有行程。这里的乘客占用座位区间是 [from, to-1],因为到 to 站已经下车,不再占座。对应差分操作是:
text复制diff[from] += numPassengers
diff[to] -= numPassengers
注意同样是“从第 from 站到第 to 站”,航班问题减在 last+1,拼车问题减在 to,区别就在于区间右端点是闭还是开。我见过太多人把这两类搞混,不是因为不懂差分,而是没有先明确题目给的是“闭区间”还是“半开区间”。
4.2 从 diff 到全局峰值扫描
再拓展一步。很多区间调度题要求的是“所有时间点或区间段上的最大重叠数”。暴力做法是对每个区间遍历内部所有点,很慢。用差分的话,做法非常统一:
- 确定时间轴长度,或者用一个足够大的数组;
- 区间开始位置 += 1,区间结束位置 -= 1;
- 对整个数组做前缀和;
- 找前缀和数组里的最大值。
以会议室问题为例:给定一批会议的起止时间,问同一时刻最多同时有几场会议。我们可以把每个 [start, end] 区间理解为“从 start 时刻开始占一个会议室,到 end 时刻释放”,于是在 diff[start]++,在 diff[end]--,做前缀和后第 i 个位置就是“时刻 i 正在进行的会议数”,扫描最大值即可。
这里的另一个技巧是,如果时间点很稀疏但最大时间值很大,不要盲目开长度为 maxTime 的数组。可以先统计所有出现的端点,做坐标离散化,再使用差分。如果还要进一步处理区间合并之类的,甚至可以直接排序端点做扫描线。
4.3 特大端点范围该怎么办
当区间端点范围极大,比如 0 到 1e9,你是没法开一个 1e9+2 的 long long 数组的,内存直接爆炸。这时有两种常见处理路径。
第一种是坐标离散化。把每个区间的 l 和 r+1(或 r 根据端点语义)收集起来,排序后去重,然后把原始坐标映射到离散化后的下标上。这样 diff 的长度最多是 2 * 区间数 + 2,可行。缺点是离散化后你只能知道压缩点之间的区间累计值,如果需要精确到每个原始位置的查询,还要额外处理。
第二种是排序端点 + 扫描线。把每个区间拆成“开始事件”和“结束事件”,全部按坐标排序后线性扫一遍,维护当前计数值。这种方法不需要差分数组,但思路一致:把区间问题转成事件流。我在处理一些真实业务上的批量区间统计时,经常用这种方式,因为内存更可控。
当线段端点较少且不是特别大时,差分数组绝对是最简单直白的方案;端点一多,就要考虑扫描线。判断标准是看坐标值域和数据量的关系。
5. 我自己写差分的五个翻车瞬间:排查链路与修复记录
5.1 端点减错位置:r 还是 r+1
先讲我最冤的一次。某次在实现“将一个长度为 n 的数组,从第 l 个位置到第 r 个位置都加上 v”时,我初始化 diff 数组长度是 n+1,开开心心写了 diff[l] += v 和 diff[r] -= v。测试小数据时没发现问题,换成随机大数据一对比,结果总是差一截。
排查过程很简单,我打印了前几个位置的 diff 和还原后的数组,发现所有区间右端点位置的值都少了 v,而且后面的位置也少了。这时我才反应过来,diff[r] -= v 会导致从第 r 个位置起前缀和就少了 v。正确写法永远是 diff[r+1] -= v(注意在 1-based 下标且区间为闭区间的前提下)。如果 r+1 超出 diff 长度,说明这个区间的右端点就是整个数组的末尾,减 v 的实际位置不存在,不需要落地。
从那以后,我再也不敢背公式做题,每次都会在心里套一个小例子验证:区间 [2,4],左端 +v 后,第 2、3、4 个位置的前缀和会多 v;第 5 个位置开始若想恢复,就必须从第 5 个位置对应的 diff[5] 上减掉 v。而 r+1=5,所以就是要更新 diff[5]。
5.2 二维差分少加回右下角
二维差分的四角更新里,最容易漏的是最后一个“右下角补回”。我记得第一次实现矩形加时,写完前三行就以为完事了:
cpp复制diff[x1][y1] += v;
diff[x2 + 1][y1] -= v;
diff[x1][y2 + 1] -= v;
// 忘写 diff[x2+1][y2+1] += v
结果跑出来的矩阵,在 (x2+1, y2+1) 右下方的所有位置都变成了 0 或负的增量。
定位方法依旧是拿小矩阵手推。假设一个 3x3 矩阵,更新矩形 (1,1) 到 (2,2)。如果少了右下角的加法,对 diff 做二维前缀和时:
- (1,1) += v,导致整个右下象限都有 v;
- (3,1) -= v,把第 3 行及以下的影响消掉;
- (1,3) -= v,把第 3 列及以后的影响消掉;
但 (3,3) 位置被减了两次,最后整体效果是 -v。于是 (3,3) 最终比预期少了 v。加上 (3,3) += v 后,所有格子才正确。
建议你在实现二维差分之后,先跑一个很小的矩阵用例,构造一个 5x5 的矩阵,做一次矩形加,再用暴力方法交叉验证。别嫌麻烦,这类矩阵 index bug 靠自己眼神很难抓。
5.3 提前前缀和导致后续更新错乱
另一种常见习惯性错误,是先对 diff 求前缀和,把它变成还原后的数组,但后面又想继续做区间更新。这时你会发现更新完全错乱,因为 diff 已经不再是“相邻差值”了,它是“当前位置已经算好的累积值”。
这里的修复策略是:把一个“差分更新对象”和“查询结果对象”分开。我现在的习惯是给差分数组封装两个方法,一个 addRange 负责更新 diff,另一个 build 负责前缀和还原。build 之后禁止再调用 addRange。如果业务上确实需要多次“更新 + 查询”交替,请改用树状数组,而不是继续在差分数组上硬做。
5.4 注意最终统计时的模运算负数问题
差分数组里会出现很多减法,所以中间值可能是负数,这很正常。但如果题目要求输出对 MOD 取模,情况就不一样了。
假设模数是 1e9+7,某个位置更新后计算出的真实值是 -3,正确结果应该是 MOD - 3。如果直接输出负数,或者直接取模输出 -3,都会错。正确做法是:
cpp复制cur = (cur + diff[i]) % MOD;
if (cur < 0) cur += MOD;
这里尤其容易在“做减法后 cur 是负数,但下一步再加一个正数可以变回 0 以上”的情况下侥幸通过,等数据一偏就挂。做大规模随机测试时,最好把所有 diff 更新和最终结果都放到 long long 里,最后再统一取模,避免中途溢出。
5.5 数组大小没算好导致越界
最后一个坑很基础但很致命:把 diff 数组长度开成 n,结果执行 diff[r+1] -= v 时,r+1 恰好等于 n,直接越界。这类问题在提交时会表现成“随机段错误”,特别难排查。
通用解法是让 diff 大小为 n+2,原因前面说过。如果是二维差分,就是 (n+2) x (m+2)。千万别为了省几个 long long 的空间搞特殊判断,性价比太低。
6. 和前缀和、树状数组、线段树的关系,以及什么时候别用差分
6.1 差分的数学与数据结构图谱位置
如果只给你一个函数关系:一维数组 a 的前缀和是 pre,而差分数组是 d[i] = a[i] - a[i-1],那么可以发现:
- a 是 d 的前缀和;
- d 是 a 的差分。
换句话说,前缀和和差分是一对互逆操作。你给 a 做差分得到 d,给 d 做前缀和又回到 a。
很多初学者会把差分和前缀和当成两个独立技巧去背,其实它们是同一套运算的正反两面。前缀和适合处理“静态数组上大量区间和查询”,差分适合处理“动态区间加之后统一查询”。如果把两者组合起来,还可以解决一些更复杂的问题,比如先对一个大区间反复做加法,你需要的是每个位置被修改的总次数,这时很自然地会想到“对修改区间再开一个差分”。
6.2 用树状数组维护差分,解决动态查询
差分数组最大的短板是不能高效地做“更新后立刻单点查询”,因为前缀和需要从头扫到目标位置。如果更新和查询交替发生,总复杂度会退化。
这时可以引入树状数组来维护差分数组。树状数组支持单点修改和前缀和查询,两个操作复杂度都是 O(log n)。我们把 diff 存在树状数组里,区间加就变成两次单点修改,单点查询就变成一次前缀和查询。代码模板如下:
cpp复制struct BIT {
int n;
vector<long long> tree;
BIT(int n_) : n(n_), tree(n_ + 2, 0) {}
void add(int idx, long long val) {
for (; idx <= n; idx += idx & -idx) tree[idx] += val;
}
long long sum(int idx) {
long long res = 0;
for (; idx > 0; idx -= idx & -idx) res += tree[idx];
return res;
}
};
// 区间 [l, r] 加 v
void rangeAdd(BIT& bit, int l, int r, long long v) {
bit.add(l, v);
bit.add(r + 1, -v);
}
// 单点查询原数组第 pos 个位置的值
long long pointQuery(BIT& bit, int pos) {
return bit.sum(pos);
}
这套写法在 LeetCode 上非常实用。如果题目要求“区间加 + 区间求和”,单靠一个差分 BIT 还不够,通常需要两个树状数组分别维护 diff[i] 和 diff[i]*i。这里不展开,但你要有这个意识:差分是基础,不是终点。
6.3 线段树能做的很多,但差分更轻
线段树也能做区间加、区间查询,而且功能更强,能处理任意“更新 + 查询”的混合操作。那为什么还要学差分?因为它简单、快、代码量少。
一个区间加的问题,用差分数组的核心代码大约 20 行,用线段树的懒标记大约 60-80 行。如果题目只要求最后输出一次结果,用线段树属于杀鸡用牛刀,写起来费时还容易出 bug。差分数组 O(n+m) 的时间复杂度、O(n) 的空间,已经是最优解。
反过来,如果题目变成一个在线交互场景:用户不断输入区间修改,每次修改后立刻询问某一段的和,差分数组就无法高效胜任。此时该选树状数组或线段树,而不是硬套差分。
我的个人建议是:在算法学习时先把差分数组当成“事件标记”来理解,而不是死记 diff[l] += v, diff[r+1] -= v。一旦你看透了它是“区间变化只发生在端点处”的思想,再去接触分段统计、扫描线、二维矩形更新,甚至是树状数组维护差分,都会顺很多。我写每道题时都会额外跑一下“打印 diff 数组”这个步骤,看它是不是符合预期。这一步几乎帮我避开了所有边界 bug。希望这篇内容能帮你把差分数组这条链路的每个环节打通,真正变成自己的武器。
