文章

算法刷题系列(十六):差分——把区间修改变成边界变化

算法刷题系列(十六):差分——把区间修改变成边界变化

算法刷题系列(十六):差分——把区间修改变成边界变化

前缀和把区间查询变成两个边界值的相减。差分可以看作它的逆向操作:把一段区间的统一增减,转成区间起点和终点之后的两个边界变化。

它特别适合“有很多次区间加法,最后统一输出数组”的场景。若每次更新都逐个修改区间元素,最坏需要 O(qn);差分数组可以把每次更新降到 O(1),最后再用一次前缀累加还原结果。

一、差分数组的定义

对原数组 a 定义:

1
2
diff[0] = a[0]
diff[i] = a[i] - a[i - 1]  (i > 0)

对差分数组做前缀累加,就能还原原数组:

1
a[i] = diff[0] + diff[1] + ... + diff[i]

差分记录相邻位置之间的变化;前缀累加把变化还原成当前值。

二、为什么区间更新只需要改两个位置?

给闭区间 [left, right] 中的元素都加上 delta。只需要:

1
2
diff[left] += delta
diff[right + 1] -= delta

从 left 开始的前缀累加会多出 delta;到了 right + 1,减去这份增量,后续位置就恢复原值。

为了支持 right 恰好是最后一个下标,通常为差分数组多留一个哨兵位置。对长度 n 的数组,更新数组大小设为 n + 1。

三、手动推演

初始数组为:

1
[10, 10, 10, 10, 10]

执行两次更新:

1
2
[1, 3] 加 3
[2, 4] 加 -2

先创建全零的增量差分数组,在边界处记录更新:

1
2
diff[1] += 3, diff[4] -= 3
diff[2] -= 2, diff[5] += 2

累加差分后,增量为:

1
[0, 3, 1, 1, -2]

与初始数组相加,得到:

1
[10, 13, 11, 11, 8]

四、Java 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class DifferenceArray {
    public static long[] applyRangeAdds(
            int[] initial, int[][] updates) {
        if (initial == null || updates == null) {
            throw new IllegalArgumentException("arguments must not be null");
        }

        int n = initial.length;
        long[] diff = new long[n + 1];
        for (int[] update : updates) {
            if (update == null || update.length != 3) {
                throw new IllegalArgumentException(
                    "each update must be [left, right, delta]"
                );
            }
            int left = update[0];
            int right = update[1];
            long delta = update[2];
            if (left < 0 || left > right || right >= n) {
                throw new IndexOutOfBoundsException("invalid update range");
            }

            diff[left] += delta;
            diff[right + 1] -= delta;
        }

        long[] result = new long[n];
        long added = 0;
        for (int i = 0; i < n; i++) {
            added += diff[i];
            result[i] = initial[i] + added;
        }
        return result;
    }
}

这里不先把原数组转换成差分,而是维护“更新量”的差分,最后将累计更新量加到初始数组。这样输入数组保持不变,且边界含义直观。

五、Python 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
def apply_range_adds(
    initial: list[int], updates: list[tuple[int, int, int]]
) -> list[int]:
    if initial is None or updates is None:
        raise ValueError("arguments must not be None")

    n = len(initial)
    diff = [0] * (n + 1)
    for left, right, delta in updates:
        if left < 0 or left > right or right >= n:
            raise IndexError("invalid update range")
        diff[left] += delta
        diff[right + 1] -= delta

    result = []
    added = 0
    for index, value in enumerate(initial):
        added += diff[index]
        result.append(value + added)
    return result

assert apply_range_adds(
    [10, 10, 10, 10, 10],
    [(1, 3, 3), (2, 4, -2)],
) == [10, 13, 11, 11, 8]
assert apply_range_adds([], []) == []

Python 类型标注中的区间是闭区间 [left, right],与前一篇前缀和示例采用的半开区间不同。实际项目中可以统一风格,但应在接口和文档里明确说明。

六、复杂度与使用边界

有 q 次区间更新、数组长度为 n 时:

  • 记录每次更新:O(q);
  • 还原结果:O(n);
  • 总时间:O(n + q);
  • 额外空间:O(n)。

差分数组适合批量更新、最后统一查询的工作负载。若更新后还要立即查询任意区间,简单差分结构不能高效回答;应考虑树状数组、线段树等支持动态操作的数据结构。

七、常见错误

  1. 忘记在 right + 1 撤销增量:会让区间右侧也被修改。
  2. 未给末尾哨兵留空间:右端点为 n - 1 时需要写到 diff[n]。
  3. 把闭区间与半开区间混淆:本例更新 [left, right],撤销位置是 right + 1。
  4. 累加恢复时从错误位置开始:结果位置 i 使用的是 diff[0..i] 的累计值。
  5. 每次更新直接遍历整个区间:这失去了差分将更新压缩到边界的优势。
  6. 误用在需要实时查询的场景:差分擅长批处理,不是所有动态数组问题的答案。

八、与下一篇的连接

差分把许多区间更新压缩成边界变化;滑动窗口则维护一个当前连续区间,随着左右端点移动不断加入或移除元素。下一篇将讨论何时窗口可以单调收缩,以及为什么这个方法通常要求非负数等单调条件。

小结

闭区间加法只改变差分数组的 left 和 right + 1 两个边界。将所有更新记录完,再对差分做一次前缀累加即可恢复总效果。要记牢区间端点约定、末尾哨兵和批处理适用场景。

本文由作者按照 CC BY 4.0 进行授权