算法入门(二):前缀和 / 差分数组 (Leetcode 1109 / 560 / 974 /523)

算法入门(二):前缀和 / 差分数组 (Leetcode 1109 / 560 / 974 /523)

  • [前缀和 / 差分数组](#前缀和 / 差分数组)
  • [1109. 航班预订统计](#1109. 航班预订统计)
  • [560. 和为K的子数组](#560. 和为K的子数组)
  • [974. 和可被K整除的子数组](#974. 和可被K整除的子数组)
  • [523. 连续的子数组和](#523. 连续的子数组和)

前缀和 / 差分数组

概念 核心操作 数学表达
前缀和 前面所有元素的和 prefixi = arr0 + ... + arri
差分数组 相邻元素的差 diffi = arri - arri-1

前缀和 和 差分数组,两者是一对互逆操作,参考以下例子:

cpp 复制代码
// 前缀和的差分 = 原数组
原数组 arr = [1, 3, 2, 5, 4]
前缀和 pref = [1, 4, 6, 11, 15]
对 pref 求差分 = [1, 3, 2, 5, 4] = 原数组 arr 

// 差分数组的前缀和 = 原数组
原数组 arr = [1, 3, 2, 5, 4]
差分数组 diff = [1, 2, -1, 3, -1]
对 diff 求前缀和 = [1, 3, 2, 5, 4] = 原数组 arr 

理解整个循环:原数组 → 求差分 → 差分数组 → 求前缀和 → 原数组

我们从这个循环可以有以下感受:

  • 从第二个数开始,也就是5,包括随后的7和6,差分数组只需要在第二个数抬高4格,而后面的数只需要在小范围波动。
  • 于是我们可以思考:如果想要把 5 7 6 同时抬高3,对arr应该怎么操作?对diff只需要什么操作?
  • 这个问题的答案也就是为什么要使用差分,而不是使用暴力求和。

1109. 航班预订统计

这道题是前缀和 / 差分的入门题目,首先我们来体会一下暴力法:

cpp 复制代码
    vector<int> corpFlightBookings(vector<vector<int>>& bookings, int n) {
        vector<int> res(n);
        for(int i = 0;i<bookings.size();i++){
        		 //注意数组的下标是从0开始的,但是bookings数组的航班信息是从1开始的!
            for(int j = bookings[i][0]-1;j<bookings[i][1];j++){
                res[j] += bookings[i][2];
            }
        }
        return res;
    }

细心一些,注意下标,还是能通过的。

这篇文章毕竟是讲前缀和 / 差分,所以尝试以下优化:

cpp 复制代码
class Solution {
public:
    vector<int> corpFlightBookings(vector<vector<int>>& bookings, int n) {
        vector<int> diff(n + 1, 0);
        
        for (auto& booking : bookings) {
            int l = booking[0] - 1;
            int r = booking[1] - 1;
            int seats = booking[2];
            
            diff[l] += seats;
            diff[r + 1] -= seats;  // r+1 最大为 n,diff 大小是 n+1,安全
        }
        
        vector<int> res(n);
        res[0] = diff[0];
        for (int i = 1; i < n; i++) {
            res[i] = res[i - 1] + diff[i];
        }
        return res;
    }
};

对于diffl += seats这一行,可以理解为把左边界统一抬高

对于diffr + 1 -= seats这一行,可以理解为把右边界统一降下

这样就得到了差分数组。

再根据公式diffi = arri - arri-1,做逆运算前缀和,得到原数组。注意从1开始

560. 和为K的子数组

同样的,我们首先尝试暴力双循环:

cpp 复制代码
    int subarraySum(vector<int>& nums, int k) {
    	//1 . 求前缀和
        vector<int> prefix(nums.size());
        prefix[0] = nums[0];
        for(int i = 1;i<nums.size();i++){
            prefix[i] = prefix[i-1]+nums[i];
        }

        int res = 0;
        int n = nums.size();
		//2 .计算前缀和差为k的次数
        for(int i = 0;i<n;i++){
            if(prefix[i] == k) res++;
            for(int j = i+1;j<n;j++){
                if(prefix[j] - prefix[i] == k) res++;
            }
        }
        return res;
    }

可以看到,子数组之和=k被转换为了前缀和之差=k,时间复杂度为O(n^2)。

如何优化这个部分呢?

在内循环里,j反复+1,在进行prefixj-prefixi == k的判断,尝试移项:

得到 prefixi = prefixj-k 。

这样的改动有什么意义呢?观察prefixj - k ,这一步只需要一个循环。

也就是说,如果把遍历prefixi的时间转换为存储prefixi的空间,时间复杂度就能从O(n^2)变为O(n)!

并且能观察到,j是在i之后的,也就是说,如果在j之前添加一个位置,并且用prefixj去和之前的i位置的前缀和对比,就可以解决了。

所以我们需要一个hashMap,C++之中是unordered_map。他代表前缀和出现的次数。

我们来整理一下思路形成伪代码:

  • 设置哈希表,并把hash0设置为1, 因为0出现过一次,后面任何一个前缀和取差的时候都需要0
  • 设置一个循环
  • 循环中有两件事,一是把前缀和存储hash,hash prefix\[j ]++;二是判断hash prefix\[j - k ]是否存在。
  • 仔细思考,这两件事的前后顺序。如果先放入前缀和,k=0的情况下,hash前缀和 -0 是会等于1的,与前缀和之差为0相悖,所以顺序应该是先判断,再存入
cpp 复制代码
class Solution {
public:
    int subarraySum(vector<int>& nums, int k) {
        vector<int> prefix(nums.size());
        prefix[0] = nums[0];
        for(int i = 1;i<nums.size();i++){
            prefix[i] = prefix[i-1]+nums[i];
        }

        int res = 0;
        //哈希表的 key = 前缀和的值,value = 这个值出现过多少次
        unordered_map<int, int> cnt;
        cnt[0] = 1; 
      
        for(int num:prefix){
            res = res + cnt[num - k];
            cnt[num]++;
        }
        return res;
    }
};

974. 和可被K整除的子数组

cpp 复制代码
class Solution {
public:
    int subarraysDivByK(vector<int>& nums, int k) {
        int n = nums.size();
        //1. 求前缀和
        vector<int> prefix(n);
        prefix[0] = nums[0];
        for(int i  = 1; i<n;i++){
            prefix[i] = prefix[i-1] + nums[i];
        }

        //2. 哈希表
        unordered_map<int,int> cnt;
        cnt[0] = 1;

        //3. 计算出现 可以整除k的 结果
        int res = 0;
        for(auto num:prefix){
            int r = (num % k + k)% k;
            res += cnt[r];
            cnt[r]++;
        }
        return res;
    }
};

523. 连续的子数组和

cpp 复制代码
class Solution {
public:
    bool checkSubarraySum(vector<int>& nums, int k) {
        int n = nums.size();

        vector<long long > prefix(n);
        prefix[0] = nums[0];
        for (int i = 1; i < n; i++) {
            prefix[i] = prefix[i - 1] + nums[i];
        }

        unordered_map<long long, int> cnt;
        cnt[0] = -1;

        for (int j = 0; j < n; j++) {
            long long r = (prefix[j] % k + k) % k;

            if (cnt.find(r) != cnt.end()) {
                if (j - cnt[r] >= 2) {
                    return true;
                }
            }else{
                cnt[r] = j;
            }
            
        }
        return false;
    }
};