第07篇-差分算法-高效处理区间修改问题

概述:差分是区间修改问题的第一反应

上一篇我们讲了前缀和,它擅长解决的是:

  • 多次查询某个区间的和
  • 统计某种满足条件的连续子数组

而这一篇要讲的差分,解决的是另一类同样高频的问题:

  • 对很多个区间同时做加减修改
  • 一次操作影响一整段连续区间
  • 希望不要每次修改都真的把整段元素重写一遍

比如题目会这样问:

  • 给定若干次操作,每次把区间 [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 = 0
  • 3 - 2 = 1
  • 5 - 3 = 2
  • 5 - 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 没越界)

为什么这样就够了?

因为这等价于在告诉系统两件事:

  1. left 开始,后面的值整体都应该增加 val
  2. right + 1 开始,这个增加效果要被抵消掉

这样当前缀和恢复原数组时:

  • leftright 这一段都会额外多出 val
  • right + 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 个航班,编号从 1n

每条预订记录 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. 题目是不是只要求最后统一结算?
  3. 每次修改能不能只记录"开始生效"和"停止生效"?

如果答案是肯定的,那差分大概率就是你要找的工具。

差分真正高效的地方,不是"直接改结果",而是"先记录变化边界,再统一传播影响"。

易错点:新手写差分最容易踩的坑

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)
覆盖次数统计 每次逐个位置累加 两端打标记后统一恢复
容量变化判断 每段都逐个位置模拟 差分后线性扫描

所以差分特别适合:

  • 操作很多
  • 每次影响一段连续区间
  • 最终统一求结果

如果题目是"动态修改 + 动态查询",那就要考虑线段树、树状数组等更强的数据结构。

差分适合"离线批量区间修改",不适合"每改一次就马上查一次"的动态场景。

总结

差分的本质,是把整段修改压缩成边界标记。

差分并不是直接去修改整段区间,而是先把影响记在边界上,最后再通过一次前缀累加,把这些影响传播到整段区间。

当你真正掌握这一点后,很多"批量区间更新"的题,都会从暴力模拟变成清晰的模板题。

相关推荐
小蒋学算法1 小时前
算法-灌溉花园的最少龙头数目-贪心
算法
KaMeidebaby1 小时前
卡梅德生物技术快报|重组蛋白的表达和纯化:工艺调试全记录:大肠杆菌体系重组蛋白的表达和纯化参数标定(肠激酶轻链案例)
前端·人工智能·算法·数据挖掘·数据分析
ZPC82101 小时前
如何将机械臂末端定位精度提升至微米如何进行标定
人工智能·算法·机器人
wabs6661 小时前
关于动态规划【力扣343.整数拆分的递推公式怎么理解?】
算法·leetcode·动态规划
测试狗科研平台1 小时前
第一性原理CO2还原反应计算流程和软件推荐
科技·算法·云计算
SEO_juper1 小时前
2026 谷歌 SEO&GEO 常见问题合集:收录、排名、内容、技术全解析
算法·谷歌·常见问题·seo·跨境电商·外贸·geo
叫我:松哥2 小时前
基于卷积神经网络的静态手势语识别算法,在测试集上的识别准确率达到97.5%
人工智能·python·深度学习·神经网络·算法·cnn
珊瑚里的鱼2 小时前
【动态规划】买卖股票的最佳时机含手续费
算法·动态规划
2401_885665192 小时前
从零搭建卷积神经网络:基于PyTorch实现MNIST手写数字分类
pytorch·python·神经网络·算法·机器学习·分类·cnn