概述:差分是区间修改问题的第一反应
上一篇我们讲了前缀和,它擅长解决的是:
- 多次查询某个区间的和
- 统计某种满足条件的连续子数组
而这一篇要讲的差分,解决的是另一类同样高频的问题:
- 对很多个区间同时做加减修改
- 一次操作影响一整段连续区间
- 希望不要每次修改都真的把整段元素重写一遍
比如题目会这样问:
- 给定若干次操作,每次把区间
[left, right]内所有元素都加上val - 所有操作做完后,返回最终数组
- 判断每个位置最后的覆盖次数、乘客数、预订数是否超限
如果你每次操作都老老实实遍历整个区间,代码虽然直接,但复杂度通常会很差。
差分数组的价值就在于:
它可以把"修改整个区间"这件事,压缩成"只修改区间两端的标记",最后再统一还原结果。
差分并不难,但它非常值得尽早掌握,因为它和前缀和一样,属于那种:
- 原理简单
- 公式固定
- 题目出现频率很高
学完这篇,你应该能一眼识别区间修改问题,并熟练写出一维差分模板,理解它和前缀和之间的关系。
核心概念:差分数组到底是什么
差分数组可以看成"前缀和的逆操作"。
假设原数组是:
text
nums = [2, 2, 3, 5, 5]
我们定义差分数组 diff:
text
diff[0] = nums[0]
diff[i] = nums[i] - nums[i - 1] (i >= 1)
那么上面的数组对应的差分数组就是:
text
diff = [2, 0, 1, 2, 0]
因为:
2直接保留2 - 2 = 03 - 2 = 15 - 3 = 25 - 5 = 0
差分数组有什么意义
差分数组记录的不是"当前位置的值",而是:
从前一个位置走到当前位置时,数值发生了多少变化。
所以如果你拿到了 diff,想还原原数组,只需要做一遍前缀累加:
text
nums[0] = diff[0]
nums[1] = diff[0] + diff[1]
nums[2] = diff[0] + diff[1] + diff[2]
...
也就是说:
- 前缀和:擅长把"查询区间和"变快
- 差分:擅长把"批量修改区间"变快
差分数组保存的是相邻元素之间的变化量,而原数组可以通过对差分数组做前缀和来恢复。
原理:为什么区间加减只改两个位置
先看最核心的问题。
如果要把区间:
text
[left, right]
内所有元素都加上 val,在原数组里你会怎么做?
最直接的方法当然是:
java
for (int i = left; i <= right; i++) {
nums[i] += val;
}
这意味着一次操作的复杂度就是:
text
O(right - left + 1)
如果区间很多,总复杂度会非常高。
差分的核心操作
如果改成在差分数组上操作,只需要:
text
diff[left] += val
diff[right + 1] -= val (前提是 right + 1 没越界)
为什么这样就够了?
因为这等价于在告诉系统两件事:
- 从
left开始,后面的值整体都应该增加val - 从
right + 1开始,这个增加效果要被抵消掉
这样当前缀和恢复原数组时:
left到right这一段都会额外多出valright + 1之后又恢复正常
用一个小例子看懂
假设初始数组全是 0:
text
nums = [0, 0, 0, 0, 0]
对应差分数组也是:
text
diff = [0, 0, 0, 0, 0]
现在要把区间 [1, 3] 全部加 2。
如果直接改差分数组:
text
diff[1] += 2
diff[4] -= 2
得到:
text
diff = [0, 2, 0, 0, -2]
然后把 diff 做前缀累加,还原回原数组:
text
[0, 2, 2, 2, 0]
这正是我们想要的结果。
整个过程可以理解成下面这样:
#mermaid-svg-jBoVRVDY07824DUd{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-jBoVRVDY07824DUd .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-jBoVRVDY07824DUd .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-jBoVRVDY07824DUd .error-icon{fill:#552222;}#mermaid-svg-jBoVRVDY07824DUd .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-jBoVRVDY07824DUd .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-jBoVRVDY07824DUd .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-jBoVRVDY07824DUd .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-jBoVRVDY07824DUd .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-jBoVRVDY07824DUd .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-jBoVRVDY07824DUd .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-jBoVRVDY07824DUd .marker{fill:#333333;stroke:#333333;}#mermaid-svg-jBoVRVDY07824DUd .marker.cross{stroke:#333333;}#mermaid-svg-jBoVRVDY07824DUd svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-jBoVRVDY07824DUd p{margin:0;}#mermaid-svg-jBoVRVDY07824DUd .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-jBoVRVDY07824DUd .cluster-label text{fill:#333;}#mermaid-svg-jBoVRVDY07824DUd .cluster-label span{color:#333;}#mermaid-svg-jBoVRVDY07824DUd .cluster-label span p{background-color:transparent;}#mermaid-svg-jBoVRVDY07824DUd .label text,#mermaid-svg-jBoVRVDY07824DUd span{fill:#333;color:#333;}#mermaid-svg-jBoVRVDY07824DUd .node rect,#mermaid-svg-jBoVRVDY07824DUd .node circle,#mermaid-svg-jBoVRVDY07824DUd .node ellipse,#mermaid-svg-jBoVRVDY07824DUd .node polygon,#mermaid-svg-jBoVRVDY07824DUd .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-jBoVRVDY07824DUd .rough-node .label text,#mermaid-svg-jBoVRVDY07824DUd .node .label text,#mermaid-svg-jBoVRVDY07824DUd .image-shape .label,#mermaid-svg-jBoVRVDY07824DUd .icon-shape .label{text-anchor:middle;}#mermaid-svg-jBoVRVDY07824DUd .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-jBoVRVDY07824DUd .rough-node .label,#mermaid-svg-jBoVRVDY07824DUd .node .label,#mermaid-svg-jBoVRVDY07824DUd .image-shape .label,#mermaid-svg-jBoVRVDY07824DUd .icon-shape .label{text-align:center;}#mermaid-svg-jBoVRVDY07824DUd .node.clickable{cursor:pointer;}#mermaid-svg-jBoVRVDY07824DUd .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-jBoVRVDY07824DUd .arrowheadPath{fill:#333333;}#mermaid-svg-jBoVRVDY07824DUd .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-jBoVRVDY07824DUd .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-jBoVRVDY07824DUd .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-jBoVRVDY07824DUd .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-jBoVRVDY07824DUd .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-jBoVRVDY07824DUd .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-jBoVRVDY07824DUd .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-jBoVRVDY07824DUd .cluster text{fill:#333;}#mermaid-svg-jBoVRVDY07824DUd .cluster span{color:#333;}#mermaid-svg-jBoVRVDY07824DUd div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-jBoVRVDY07824DUd .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-jBoVRVDY07824DUd rect.text{fill:none;stroke-width:0;}#mermaid-svg-jBoVRVDY07824DUd .icon-shape,#mermaid-svg-jBoVRVDY07824DUd .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-jBoVRVDY07824DUd .icon-shape p,#mermaid-svg-jBoVRVDY07824DUd .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-jBoVRVDY07824DUd .icon-shape .label rect,#mermaid-svg-jBoVRVDY07824DUd .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-jBoVRVDY07824DUd .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-jBoVRVDY07824DUd .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-jBoVRVDY07824DUd :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 区间 left, right 全部加 val
diffleft += val
diffright + 1 -= val
所有操作结束后,对 diff 做前缀和
还原最终数组
差分并不是立即改完整个区间,而是只在区间起点打一个"开始增加"的标记,在区间终点后一个位置打一个"停止增加"的标记。
和前缀和的关系:一个负责查,一个负责改
很多人学到这里,还是容易把前缀和和差分混在一起。
其实你只要抓住一句话就够了:
前缀和擅长"查",差分擅长"改"。
它们的关系可以这样理解:
| 技巧 | 解决的问题 | 典型操作 |
|---|---|---|
| 前缀和 | 多次区间查询 | 先累计,再做差 |
| 差分 | 多次区间修改 | 先打标记,再累计 |
换句话说:
- 前缀和把原数组变成"更方便查询"的形式
- 差分把修改操作变成"更方便记录"的形式
如果你把原数组做一次差分,得到 diff;
再对 diff 做一次前缀和,就能恢复原数组。
所以常常会有人说:
差分是前缀和的逆思想。
前缀和是为了快速知道某段区间有多少,差分是为了快速记录某段区间要改多少。
先建立框架:什么题一眼该想到差分
初学阶段,可以优先抓住下面几个信号。
1. 题目有很多次区间加减操作
只要题目长这样:
- 给若干个操作
- 每次修改
[left, right] - 最后统一求结果
差分通常就是标准解法。
2. 题目只关心"所有操作结束后的最终状态"
差分最适合的是:
- 不需要实时查询中间状态
- 所有操作先记下来
- 最后一次性还原答案
如果题目要求每改一次就立刻查询,那差分往往就不是最合适的工具了。
3. 暴力写法是"每次操作都循环整个区间"
如果你看到自己在写:
java
for (int i = left; i <= right; i++) {
nums[i] += val;
}
而这样的操作还要做很多次,那就应该立刻想到能不能换成差分。
4. 本质是"统计每个位置被覆盖了几次"
很多题虽然表面不叫区间修改,但本质是:
- 每次操作覆盖一段区间
- 最终问每个位置总共被影响多少次
这类题也非常适合差分。
只要是"多次区间修改,最后统一结算",差分就应该成为第一反应。
模板一:一维差分的标准写法
先把最基础的模板写熟。
先从原数组构造差分数组
java
public static int[] buildDiff(int[] nums) {
int n = nums.length;
int[] diff = new int[n];
diff[0] = nums[0];
for (int i = 1; i < n; i++) {
diff[i] = nums[i] - nums[i - 1];
}
return diff;
}
对区间 [left, right] 加上 val
java
public static void rangeAdd(int[] diff, int left, int right, int val) {
diff[left] += val;
if (right + 1 < diff.length) {
diff[right + 1] -= val;
}
}
最后把差分数组还原成结果数组
java
public static int[] restoreArray(int[] diff) {
int n = diff.length;
int[] nums = new int[n];
nums[0] = diff[0];
for (int i = 1; i < n; i++) {
nums[i] = nums[i - 1] + diff[i];
}
return nums;
}
更常见的实战写法
很多题里原数组初始就是全 0,这时甚至不一定需要先构造原数组,可以直接新建差分数组:
java
public static int[] applyOperations(int length, int[][] operations) {
int[] diff = new int[length];
for (int[] op : operations) {
int left = op[0];
int right = op[1];
int val = op[2];
diff[left] += val;
if (right + 1 < length) {
diff[right + 1] -= val;
}
}
for (int i = 1; i < length; i++) {
diff[i] += diff[i - 1];
}
return diff;
}
这段代码里,最后的 diff 已经被原地还原成最终数组了。
一维差分最关键的三步就是"构造差分、两端打标记、最后做前缀和恢复结果"。
经典例题一:区间加法
这是差分最标准的入门题。
题目大意:
给定一个长度为
length的数组,初始值全为0。现在有若干个操作
updates[i] = [start, end, inc],表示把区间[start, end]内每个元素都加上inc。返回所有操作执行完后的最终数组。
暴力做法为什么不理想
如果你每拿到一个操作,就真的去修改整个区间,那么复杂度通常是:
text
O(k * n)
这里 k 是操作次数,n 是数组长度。
一旦数据量上来,性能就会明显不够。
差分做法
java
public static int[] getModifiedArray(int length, int[][] updates) {
int[] diff = new int[length];
for (int[] update : updates) {
int start = update[0];
int end = update[1];
int inc = update[2];
diff[start] += inc;
if (end + 1 < length) {
diff[end + 1] -= inc;
}
}
for (int i = 1; i < length; i++) {
diff[i] += diff[i - 1];
}
return diff;
}
为什么这段代码正确
每次更新时:
- 在
start位置记录"从这里开始要加inc" - 在
end + 1位置记录"从这里开始不要再加inc"
所有更新叠加完成后,再做一次前缀和,就能把所有区间影响累积到每个位置上。
时间复杂度:
text
O(k + n)
空间复杂度:
text
O(n)
相比暴力做法,这已经是非常明显的优化。
经典例题二:航班预订统计
这道题是差分在面试和刷题里非常经典的应用。
题目大意:
有
n个航班,编号从1到n。每条预订记录
bookings[i] = [first, last, seats]表示从第first个航班到第last个航班,每个航班都订了seats个座位。返回每个航班最终被预订的座位数。
这题为什么本质上还是区间加法
虽然题目包装成了"航班预订",但本质并没有变:
- 每条记录都影响一个连续区间
- 每个区间都要统一加上一个值
- 最终只关心所有记录叠加后的结果
所以这题就是差分模板题。
java
public static int[] corpFlightBookings(int[][] bookings, int n) {
int[] diff = new int[n];
for (int[] booking : bookings) {
int left = booking[0] - 1;
int right = booking[1] - 1;
int seats = booking[2];
diff[left] += seats;
if (right + 1 < n) {
diff[right + 1] -= seats;
}
}
for (int i = 1; i < n; i++) {
diff[i] += diff[i - 1];
}
return diff;
}
这题最容易错在哪里
它的主要坑不在差分本身,而在下标转换:
- 题目航班编号从
1开始 - Java 数组下标从
0开始
所以一定要先转换成:
text
left = first - 1
right = last - 1
否则边界会全部错位。
很多业务包装题,翻译回算法语言之后,本质就是"多次区间加法",而这正是差分最擅长的场景。
经典例题三:拼车问题
再看一道更接近真实建模的题。
题目大意:
有若干组乘客,每组信息是
[numPassengers, from, to],表示有numPassengers个乘客在from上车,在to下车。车的容量为
capacity,问在整个行程中是否会超载。
这题为什么也能用差分
因为每组乘客实际影响的是一个区间:
- 从
from开始,车上人数增加 - 到
to位置时,这组乘客已经下车,不再占位置
所以本质上依然是在做区间加法。
java
public static boolean carPooling(int[][] trips, int capacity) {
int[] diff = new int[1001];
for (int[] trip : trips) {
int numPassengers = trip[0];
int from = trip[1];
int to = trip[2];
diff[from] += numPassengers;
if (to < diff.length) {
diff[to] -= numPassengers;
}
}
int currentPassengers = 0;
for (int i = 0; i < diff.length; i++) {
currentPassengers += diff[i];
if (currentPassengers > capacity) {
return false;
}
}
return true;
}
这道题和前两题有什么不同
前两题通常要求返回最终数组;
而这道题只需要判断是否存在某个位置超过容量。
所以在恢复前缀和的过程中,只要发现:
text
currentPassengers > capacity
就可以立刻返回 false。
这说明差分不仅能用来"求最终数组",还可以用来"判断某个累计过程是否越界"。
可扩展理解:二维差分可以处理矩阵区间修改
如果你已经理解了一维差分,会很自然地想到:
那矩阵里的子矩形批量修改,能不能也用类似思路?
答案是可以,这就是二维差分。
一维差分是在区间两端打标记;
二维差分则是在矩形的四个角附近打标记,最后再通过二维前缀和恢复结果。
初学阶段你不用急着把二维差分背下来,但至少要知道:
- 一维差分解决线段区间修改
- 二维差分解决矩形区域修改
这会帮助你建立完整的"前缀和 / 差分"体系。
差分并不只属于数组,它本质上是一种"延迟记录区间影响,再统一还原"的思想。
差分到底在解决什么问题
很多人学完公式之后,还是容易把差分写成死模板。
真正要抓住的是:差分在解决"重复修改"的问题。
常见应用可以分成下面几类:
| 题型 | 差分记录的内容 | 最终目标 |
|---|---|---|
| 区间加减 | 某一段从哪里开始增减 | 还原最终数组 |
| 覆盖次数统计 | 某段区间被覆盖几次 | 求每个位置总次数 |
| 容量 / 人数变化 | 某位置开始多多少人 | 判断是否超限 |
| 矩阵区域修改 | 某子矩形的整体增减 | 还原最终矩阵 |
所以做题时可以先问自己:
- 我是不是在反复修改连续区间?
- 题目是不是只要求最后统一结算?
- 每次修改能不能只记录"开始生效"和"停止生效"?
如果答案是肯定的,那差分大概率就是你要找的工具。
差分真正高效的地方,不是"直接改结果",而是"先记录变化边界,再统一传播影响"。
易错点:新手写差分最容易踩的坑
1. 忘记差分的本质是"最后还要做前缀和"
很多人打完标记就以为结束了,其实差分数组本身不是最终答案。
你必须再做一次前缀累加,才能恢复真实结果。
2. right + 1 越界没有判断
区间更新最核心的一行是:
java
diff[right + 1] -= val;
但前提是:
text
right + 1 < n
否则就会越界。
3. 原数组下标和题目下标没对齐
像航班预订这种题,输入是从 1 开始编号的。
如果你直接拿来当数组下标,答案就会整体偏移。
4. 忘记初始数组不一定全是 0
如果题目给了原始数组,你应该先把它构造成差分数组,再做区间修改。
不能默认所有题都是从全零开始。
5. 把差分和前缀和适用场景搞反
前缀和解决的是:
- 多次查询
差分解决的是:
- 多次修改
如果场景判断错了,往往会选错工具。
6. 误以为差分适合实时查询
差分最大的优势是在:
- 先批量记录
- 最后统一恢复
如果题目要求每次更新后立刻查询当前结果,差分就不一定合适。
7. 恢复数组时更新顺序写错
正确的恢复过程应该是:
java
for (int i = 1; i < n; i++) {
diff[i] += diff[i - 1];
}
一旦写错顺序或引用错误下标,整段结果都会错。
复杂度总结:为什么差分适合"多次修改,最后结算"
差分最大的价值,就是把一次区间修改从:
text
O(length)
压缩成:
text
O(1)
如果一共有 k 次区间修改,数组长度是 n,那么:
- 记录所有修改:
O(k) - 最后恢复结果:
O(n)
总复杂度就是:
text
O(k + n)
下面这张表很适合建立直觉:
| 场景 | 暴力做法 | 差分做法 |
|---|---|---|
| 多次区间加法 | 每次改整段,整体可能 O(k * n) |
记录 O(k),恢复 O(n) |
| 覆盖次数统计 | 每次逐个位置累加 | 两端打标记后统一恢复 |
| 容量变化判断 | 每段都逐个位置模拟 | 差分后线性扫描 |
所以差分特别适合:
- 操作很多
- 每次影响一段连续区间
- 最终统一求结果
如果题目是"动态修改 + 动态查询",那就要考虑线段树、树状数组等更强的数据结构。
差分适合"离线批量区间修改",不适合"每改一次就马上查一次"的动态场景。
总结
差分的本质,是把整段修改压缩成边界标记。
差分并不是直接去修改整段区间,而是先把影响记在边界上,最后再通过一次前缀累加,把这些影响传播到整段区间。
当你真正掌握这一点后,很多"批量区间更新"的题,都会从暴力模拟变成清晰的模板题。