假设有一个长度为10000的数组,需要对区间[100, 5000]内的每个元素都加3,然后再对区间[200, 8000]内的每个元素减5,最后还要对区间[1000, 9000]内的每个元素加10。这样的操作要重复进行1000次,最后输出最终结果。

最直观的做法是每次区间操作都遍历一遍,时间复杂度是$O(n \times q)$,其中$n$是数组长度,$q$是操作次数。当$n$和$q$都达到$10^5$甚至更大时,这种做法会直接超时。

差分数组正是为这类场景而生的——它能把每次区间操作的时间复杂度降到$O(1)$,最终还原答案时再花$O(n)$时间,总复杂度变成$O(n + q)$。

前缀和与差分数组的互逆关系

理解差分数组,首先要理解它与前缀和的关系。前缀和数组prefix的定义是:

$$prefix[i] = \sum_{k=0}^{i} nums[k]$$

前缀和的核心价值在于:能在$O(1)$时间内回答任意区间的求和查询。区间$[l, r]$的和等于$prefix[r] - prefix[l-1]$。

差分数组则是前缀和的逆运算。给定原数组nums,其差分数组diff定义为:

$$diff[0] = nums[0]$$

$$diff[i] = nums[i] - nums[i-1] \quad (i > 0)$$

也就是说,差分数组的每个元素记录的是原数组相邻元素的差值

差分数组构建过程

图片来源: labuladong.online

从差分数组还原原数组,只需要做一次"前缀和"操作:

$$nums[0] = diff[0]$$

$$nums[i] = nums[i-1] + diff[i] \quad (i > 0)$$

这个过程和构建前缀和数组完全一致。差分数组就是前缀和的逆,前缀和也是差分数组的逆

区间修改的核心思想

差分数组的威力在于处理区间修改。假设要将原数组nums的区间$[l, r]$内所有元素都加上val

直接操作原数组需要遍历$[l, r]$的每个元素,时间复杂度$O(r-l+1)$。但如果操作差分数组,只需要两步:

  1. diff[l] += val
  2. diff[r+1] -= val(如果$r+1$在数组范围内)

为什么这样做有效?

当还原原数组时,是从左往右累加diff的值。diff[l] += val意味着从nums[l]开始,往后的所有元素都会加上val。而diff[r+1] -= val则从nums[r+1]开始抵消这个增量。两个操作叠加,只有区间$[l, r]$内的元素被加上了val,区间外的元素不受影响。

差分数组区间修改原理

图片来源: labuladong.online

这种"打标记、最后统一处理"的思想,把单次区间修改的代价从$O(n)$降到了$O(1)$。无论操作多少次,只需要维护差分数组,最后一次性还原即可。

差分数组工具类封装

将差分数组封装成一个工具类,可以方便地在各类题目中复用:

class Difference {
    private int[] diff;
    
    // 从初始数组构建差分数组
    public Difference(int[] nums) {
        diff = new int[nums.length];
        diff[0] = nums[0];
        for (int i = 1; i < nums.length; i++) {
            diff[i] = nums[i] - nums[i - 1];
        }
    }
    
    // 对区间 [l, r] 内所有元素加上 val(val 可为负数)
    public void increment(int l, int r, int val) {
        diff[l] += val;
        if (r + 1 < diff.length) {
            diff[r + 1] -= val;
        }
    }
    
    // 还原并返回原数组
    public int[] result() {
        int[] res = new int[diff.length];
        res[0] = diff[0];
        for (int i = 1; i < diff.length; i++) {
            res[i] = res[i - 1] + diff[i];
        }
        return res;
    }
}

注意increment方法中的边界检查:当$r+1$超出数组范围时,说明修改的是数组末尾,不需要再做减法操作。

LeetCode 370:区间加法的模板题

这道题是差分数组最直接的应用场景。

题目描述:给定长度为length的初始全零数组,和若干个更新操作updates,其中updates[i] = [start, end, inc]表示对区间$[start, end]$内所有元素增加inc。返回最终数组。

解题思路:这题几乎就是差分数组工具类的直接应用。初始数组全零,意味着差分数组也是全零,只需要依次执行区间修改,最后还原即可。

class Solution {
    public int[] getModifiedArray(int length, int[][] updates) {
        // 初始全零数组
        int[] nums = new int[length];
        Difference df = new Difference(nums);
        
        for (int[] update : updates) {
            int l = update[0];
            int r = update[1];
            int val = update[2];
            df.increment(l, r, val);
        }
        
        return df.result();
    }
}

class Difference {
    private int[] diff;
    
    public Difference(int[] nums) {
        diff = new int[nums.length];
        diff[0] = nums[0];
        for (int i = 1; i < nums.length; i++) {
            diff[i] = nums[i] - nums[i - 1];
        }
    }
    
    public void increment(int l, int r, int val) {
        diff[l] += val;
        if (r + 1 < diff.length) {
            diff[r + 1] -= val;
        }
    }
    
    public int[] result() {
        int[] res = new int[diff.length];
        res[0] = diff[0];
        for (int i = 1; i < diff.length; i++) {
            res[i] = res[i - 1] + diff[i];
        }
        return res;
    }
}

复杂度分析:时间复杂度$O(n + q)$,空间复杂度$O(n)$,其中$n$是数组长度,$q$是操作次数。

LeetCode 1109:航班预订统计

这道题需要从题目描述中抽象出差分数组的模型。

题目描述:有n个航班,编号从1到n。给定预订记录bookings,其中bookings[i] = [first, last, seats]表示预订了航班firstlast(包含两端)每个航班seats个座位。返回每个航班的总预订座位数。

解题思路:航班编号从1开始,而数组下标从0开始,需要做一个下标转换。预订区间$[first, last]$对应数组下标$[first-1, last-1]$。本质上就是对数组多次区间修改,最后输出结果。

class Solution {
    public int[] corpFlightBookings(int[][] bookings, int n) {
        int[] nums = new int[n];
        Difference df = new Difference(nums);
        
        for (int[] booking : bookings) {
            // 航班编号从1开始,转为数组下标需要减1
            int l = booking[0] - 1;
            int r = booking[1] - 1;
            int val = booking[2];
            df.increment(l, r, val);
        }
        
        return df.result();
    }
}

易错点:航班编号和数组下标的对应关系。题目明确说航班编号从1开始,而区间包含两端,转换为数组下标时需要同时减1。

LeetCode 1094:拼车问题

这道题的难点在于理解题意并抽象出差分数组模型。

题目描述:一辆车向东行驶,给定trips数组,其中trips[i] = [numPassengers, from, to]表示在某位置from上车numPassengers人,在位置to下车。判断是否能在不超过capacity的情况下完成所有行程。

解题思路:每个trip本质上是一个区间操作——乘客在$[from, to-1]$区间内占用座位。为什么是$to-1$?因为题目说乘客在to位置下车,意味着在到达to时乘客已经离开,不再占用座位。

class Solution {
    public boolean carPooling(int[][] trips, int capacity) {
        // 题目给出位置范围是 [0, 1000]
        int[] nums = new int[1001];
        Difference df = new Difference(nums);
        
        for (int[] trip : trips) {
            int val = trip[0];
            int l = trip[1];
            // 乘客在 to 位置下车,所以占用区间是 [from, to-1]
            int r = trip[2] - 1;
            df.increment(l, r, val);
        }
        
        int[] res = df.result();
        for (int num : res) {
            if (num > capacity) {
                return false;
            }
        }
        return true;
    }
}

关键点:理解乘客在to位置下车意味着什么。乘客在到达to时已经离开,所以实际占用区间是$[from, to-1]$。

LeetCode 1526:形成目标数组的最小操作次数

这道题展示了差分数组思想的另一种应用。

题目描述:给定目标数组target,初始有一个长度相同的全零数组。每次操作可以选择任意子数组,将子数组内所有元素加1。求最少需要多少次操作才能得到target数组。

解题思路:关键观察是——操作次数等于差分数组中所有正数元素的和。

为什么?考虑差分数组diff,其中diff[i] = target[i] - target[i-1]。如果diff[i] > 0,说明target[i]target[i-1]大,这个增量必须通过一个从位置i开始的操作来产生。如果diff[i] < 0,说明target[i]target[i-1]小,这可以通过在某个位置结束操作来实现。

实际上,每次操作可以理解为:选择一个起始位置和一个结束位置,对差分数组的起始位置加1,结束位置的后一个位置减1。最优策略是让每个正的diff[i]都对应一个从i开始的新操作。

class Solution {
    public int minNumberOperations(int[] target) {
        // 操作次数等于差分数组中所有正数的和
        int operations = target[0];
        for (int i = 1; i < target.length; i++) {
            if (target[i] > target[i - 1]) {
                operations += target[i] - target[i - 1];
            }
        }
        return operations;
    }
}

原理证明:设diff[i] = target[i] - target[i-1](定义target[-1] = 0)。对于diff[i] > 0的情况,需要至少diff[i]次操作从位置i开始。所有正的diff[i]之和就是最少操作次数。

LeetCode 1589:最大排列和

这道题结合了差分数组和贪心策略。

题目描述:给定数组nums和查询数组requests,其中requests[i] = [start, end]表示查询区间$[start, end]$内所有元素的和。可以任意重排nums,求所有查询结果之和的最大值。

解题思路:要让总和最大,应该让数值大的元素出现在被查询次数多的位置。所以关键是统计每个位置被查询了多少次。

用差分数组统计每个位置的查询次数,然后对查询次数数组和原数组都排序,让大数和大次数配对。

class Solution {
    public int maxSumRangeQuery(int[] nums, int[][] requests) {
        int n = nums.length;
        int MOD = 1_000_000_007;
        
        // 用差分数组统计每个位置的查询次数
        int[] diff = new int[n + 1];
        for (int[] req : requests) {
            diff[req[0]]++;
            diff[req[1] + 1]--;
        }
        
        // 还原查询次数数组
        int[] count = new int[n];
        count[0] = diff[0];
        for (int i = 1; i < n; i++) {
            count[i] = count[i - 1] + diff[i];
        }
        
        // 排序:让大数和大次数配对
        Arrays.sort(count);
        Arrays.sort(nums);
        
        long sum = 0;
        for (int i = 0; i < n; i++) {
            sum = (sum + (long) count[i] * nums[i]) % MOD;
        }
        
        return (int) sum;
    }
}

关键点:题目要求结果对$10^9 + 7$取模,注意在计算过程中用long防止溢出。

LeetCode 2381:字母移位II

这道题将差分数组应用到字符串处理中。

题目描述:给定字符串s和移位操作数组shifts,其中shifts[i] = [start, end, direction]direction = 1表示区间$[start, end]$内每个字母向前移一位(a→b,z→a),direction = 0表示向后移一位(b→a,a→z)。返回最终字符串。

解题思路:字母移位是循环的,本质上是模26的加减法。先用差分数组统计每个位置的净移位次数,然后对每个字符应用移位。

class Solution {
    public String shiftingLetters(String s, int[][] shifts) {
        int n = s.length();
        int[] diff = new int[n + 1];
        
        // 用差分数组记录每个位置的移位量
        for (int[] shift : shifts) {
            int start = shift[0];
            int end = shift[1];
            int direction = shift[2] == 1 ? 1 : -1;
            
            diff[start] += direction;
            diff[end + 1] -= direction;
        }
        
        // 还原移位次数数组
        int[] shiftCount = new int[n];
        shiftCount[0] = diff[0];
        for (int i = 1; i < n; i++) {
            shiftCount[i] = shiftCount[i - 1] + diff[i];
        }
        
        // 应用移位
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < n; i++) {
            char c = s.charAt(i);
            // 计算移位后的字符,注意处理负数
            int shifted = (c - 'a' + shiftCount[i] % 26 + 26) % 26;
            sb.append((char) ('a' + shifted));
        }
        
        return sb.toString();
    }
}

易错点:移位次数可能很大也可能为负,需要正确处理模运算。(x % 26 + 26) % 26这个技巧可以保证结果在$[0, 25]$范围内。

LeetCode 3355:零数组转换I

这道题是差分数组的判定问题。

题目描述:给定数组nums和查询数组queries,每次查询可以选择区间$[l, r]$内的任意下标子集,将选中的元素减1。判断是否能将所有元素变成0。

解题思路:每个查询$[l, r]$最多能让区间内每个元素减1(可以选择全部减1)。所以问题是:每个位置i被多少个查询覆盖,以及这个覆盖次数是否至少等于nums[i]

用差分数组统计每个位置被覆盖的次数,然后判断是否每个位置都有足够的减量机会。

class Solution {
    public boolean isZeroArray(int[] nums, int[][] queries) {
        int n = nums.length;
        int[] diff = new int[n + 1];
        
        // 统计每个位置被覆盖的次数
        for (int[] query : queries) {
            diff[query[0]]++;
            diff[query[1] + 1]--;
        }
        
        // 判断每个位置是否有足够的减量机会
        int prefix = 0;
        for (int i = 0; i < n; i++) {
            prefix += diff[i];
            if (nums[i] > prefix) {
                return false;
            }
        }
        
        return true;
    }
}

关键理解:题目允许每次查询选择子集减1,所以只要覆盖次数足够,就一定可以精确减到0。

差分数组的局限性与扩展

差分数组虽然强大,但也有明显的局限性:

空间开销:差分数组需要和原数组相同大小的额外空间。当区间范围达到$10^9$时,直接开数组会爆内存。这种情况下需要使用离散化或树状数组/线段树等数据结构。

查询延迟:差分数组的优势在于批量修改、最后统一查询。如果需要边修改边查询,或者修改和查询交替进行,差分数组就不再适用。这时候需要线段树,它支持区间修改和区间查询都是$O(\log n)$。

单点修改效率低:如果只需要单点修改,直接操作原数组即可,用差分数组反而增加复杂度。

总结

差分数组是处理区间修改问题的高效工具。其核心思想是:

  • 构建:$diff[i] = nums[i] - nums[i-1]$
  • 区间修改:$diff[l] += val$,$diff[r+1] -= val$
  • 还原:对差分数组求前缀和

时间复杂度从暴力法的$O(n \times q)$优化到$O(n + q)$,在处理大量区间修改操作时效果显著。

识别差分数组问题的关键特征:

  • 多次区间修改操作
  • 最后只需要输出最终结果
  • 区间范围可以用数组下标表示

掌握差分数组,配合前缀和技巧,可以解决一大类数组区间问题。

参考资料

  1. Difference Array Technique - Labuladong Algo Notes
  2. Prefix sum array and difference array - PEG Wiki
  3. An Introduction To Difference Arrays - Codeforces
  4. LeetCode 370. Range Addition
  5. LeetCode 1109. Corporate Flight Bookings
  6. LeetCode 1094. Car Pooling
  7. LeetCode 1526. Minimum Number of Increments on Subarrays to Form a Target Array
  8. LeetCode 1589. Maximum Sum Obtained of Any Permutation
  9. LeetCode 2381. Shifting Letters II
  10. LeetCode 3355. Zero Array Transformation I