统计连续子数组的个数【哈希表+前缀和】【模板题】

1. 适用题目描述

给定我们一个数组,让我们求满足某些条件的连续子数组的个数

注意,必须连续。

另外,子数组可能有很多限制,例如常见的:非空。

还有一些特殊的比如:大小至少为2等

2. 大体思路 - 前缀和 + 哈希表

从暴力的角度出发,因为是连续数组,所以我们可以通过枚举左右端点 \([l,r]\) 来求得结果,时间复杂度为 \(O(N^2)\)

但是暴力肯定不行(即使暴力能过,你也应该找出优化的做法),但是暴力会给我们打开思路。

在暴力方法中,我们相当于枚举每个右端点 \(r\),然后再枚举每个左端点 \(l\),判断区间 \([l,r]\) 是否符合条件,如果我们要优化的话,肯定是优化枚举 \(l\) 的步骤,因为在枚举 \(l\) 这一步,可能存在大量重复计算。

一般来说,都是用哈希表来存储计算得到的数据,提供给后续使用。

并且,以为求的是连续子数组,因此和前缀和也密不可分。不过我们通常并不需要建立前缀和数组,而是通过一个变量逐渐累加来代替前缀和。


一般来说,都是让你求连续子数组的和等于 \(k\) 的个数,\(mod\) \(k\) 等于 \(0\) 的个数等等,有一些我们直接求是不好求的,但是我们可以转化题目为一些我们好求的。

3. 模板题

题目描述

求和为 k 的连续子数组的个数

代码

class Solution {
public:
    int numSubarraysWithSum(vector<int>& nums, int goal) {
        int cur = 0, res = 0;
        unordered_map<int,int> ass;
        ass[0] = 1;
        for(auto &x : nums) {
            cur += x;
            // cur - pre = goal
            // pre = cur - goal
            res += ass[cur- goal];
            ass[cur] ++ ;
        }
        return res;
    }
};

4. 相似题目

problem 1 – easy

题目让我们统计连续子数组中恰好有 \(k\) 个奇数数字的子数组的个数,我们可以将奇数看作 \(1\),偶数看作 \(0\),那么本题就转化为了求连续子数组和为 \(k\) 的子数组的个数

problem 2 – medium

这道题稍微有一点难度,就是需要题目信息,并进行转化。

题目要求我们,移除最短连续子数组(可以非空),使得剩下元素能被 \(p\) 整除。(我们假设,必须移除元素。因为不需要移除的情况很容易判断。)

假设原数组所有元素之和为 \(sum\),因为我们必须要移除元素,所以说 我们假设 sum%k = target,那么 target 肯定不为 \(0\)

接下来就是思维了,我们要将题目转化为,找到一个连续非空(前面说了必须移除元素)子数组,使得他们的和模 \(k\) 等于 \(target\)

只要等将问题转化如此,就很简单了。

另外,本题数组比较大,要注意移除,而且由于负数的存在,我们还要使用一个小技巧:((x % k) + k) % k;

这里直接给出本题的代码:参考题解

class Solution {
public:
    int minSubarray(vector<int>& nums, int p) {
        int n = nums.size();
        vector<int> s(n + 10);
        // 求前缀和
        for(int i = 1; i <= n; i ++ )   s[i] = (s[i - 1] + nums[i - 1]) % p;
        int ans = n; // 因为 ans要取最小值,所以初始化为一个最大值
        int sum = s[n];
        // 特判
        if(sum == 0)    return 0;
        // 找一个连续非空子数组,其元素之和mod k == s[n]
        unordered_map<int,int> last;
        for(int i = 0; i <= n; i ++ ) { // i -> right
            // find left
            // (s[right] - s[left]) % p == s[n]
            // 注意,不是 s[right] - s[left] == s[n]
            // s[left] = (((s[right] - s[n])) + p) % p    // 减法可能小于0 
            int t = (((s[i] - s[n]) % p) + p) % p; // s[i]-s[n]可能小于0
            auto it = last.find(t);
            if(it != last.end()) {
                ans = min(ans, i - it->second);
            }
            // 始终保存最右侧的值,因为我们要求最小长度
            last[s[i]] = i;
        }
        return ans == n ? -1 : ans;
    }
};

不创建前缀和的写法:

class Solution {
public:
    int minSubarray(vector<int>& nums, int p) {
        int n = nums.size();
        int cur = 0;    // 模拟前缀和
        int res = n;
        int target = 0; // 子数字的目标
        for(auto &x : nums) {
            cur = (((cur + x) % p) + p) % p;
        }
        // 特判
        if(cur % p == 0)    return 0;
        target = cur, cur = 0;
        unordered_map<int,int> ass;
        ass[0] = -1;   // 前缀和,因为我们第一个元素的下标为0,所以第一个元素之前的元素下标就是1
                       // debug 了十多分钟。。 
        for(int i = 0; i < n; i ++ ) {
            cur = ((cur + nums[i]) % p + p) % p;
            // cur - left = target(mod p)
            // left = (cur - target) % p;
            int left = (((cur - target) % p) + p) % p;
            if(ass.find(left) != ass.end()) {
                // 注意,子数组的区间是 [left+1,right], right=i
                // 这个区间的前缀和为 s[right]-s[left]
                // 因此其长度为 right-(left+1)+1 -> right-left
                res = min(res, i - ass[left]);
            }
            ass[cur] = i;
        }
        return res == n ? -1 : res;
    }
};

注意,在上面的代码中,ass[0]=-1; 一定要明白其原理,不要写成了:ass[0]=0;
之所以加入 ass[0] 是因为我们要保证能让前缀和从头开始,因为 s[l,r] = s[r] - s[l-1],头 ls[l-1] 当然为 0 了。
但是,l-1 的下标并不总是为 0,因为我们的前缀和下标可能从 1 开始(此时 l-1=1-1=0),也可能从 0 开始(此时 l-1=0-1=-1;)。

problem 3 – medium

求长度至少为 \(2\) 且和为 \(k\) 的倍数的连续子数组的个数,这题要求有点多啊,我们一步一步分析

长度至少为 \(2\)。首先我们想一下我们是怎么处理非空的,在 problem2 模板题中,我们是这样写的:(\(cur\) 用来模拟前缀和)

for(auto &x : nums) {
    cur += x;
    // cur - pre = goal
    // pre = cur - goal
    res += ass[cur- goal];	// (1)
    ass[cur] ++ ;			// (2)
}

注意 \((1)\)\((2)\) 的前后顺序,如果 \((1)\)\((2)\) 的前面,那么每次查询时,自己都还没加入哈希表中,因此用来保证子数组不为空,也就是长度至少为 \(1\)

如果 \((2)\)\((1)\) 的前面,那么就可以得到空数组。

现在我们再来想,如何实现长度至少为 \(2\)

长度如果至少为 \(2\) 的话,那么在我们查询下标 \(idx\) 时,要保证 \(idx-1\) 没有被插入哈希表中,否则就可以取到 \(idx-1\),如果取到的话,长度就是 \(1\) 了。看代码:

cur = (cur + nums[0]) % k;
        int last = cur;
for(int i = 1; i < n; i ++ ) {
    cur = (cur + nums[i]) % k;
    if(pre[cur])    return true;
    pre[last] ++ ;  // (1)
    last = cur;		// (2)
}

我们每次加入哈希表的是 \(last\) 也就是上一个 \(pre\)。因此,当我们查询完 \(idx\) 之后,我们加入的是 \(idx-1\),而不是 \(idx\),这样当我们下次查询 \(idx+1\) 时,最大的就是 \(idx-1\) 而不是 \(idx\),因此数组长度至少为 \(2\)

另外,就是题目让我们求的是和为 \(k\) 的倍数,其实就是对 \(k\) 取模等于 \(0\)。这里有个防止溢出的小优化,就是边求前缀和边取模。

problem4 – easy

本题让我们求,子数组只和可以被 \(k\) 整除,等价于子数组只和等于 \(k\) 的倍数,等价于模 \(k\) 等于 \(0\),就转化为上题了。

problem 5 -- medium

题目让我们求相同数量的 \(0\)\(1\) 的最长连续子数组。

直接求是不好求的,首先,我们需要判断子数组的区间长度是否为偶数,因为如果我们使用常规的哈希表和前缀和的方法,我们就需要保存之前的信息,但是题目要求我们求最大值,所以说我们应该保存第一次出现的信息,因为此时的下标更小,构成的子数组长度更长,但是,长度长不一定意味着它就合法。例如:[0,0,1]
当我们遍历到第一个 \(0\) 的时候,我们需要在哈希表 \(ass\) 中加入 {0,0},表示前缀和 \(0\) 的下标为 \(1\),但是当我们遍历到第二个 \(0\) 的时候,是否应该更新 \(ass[0]=1\) 呢?

  • 如果更新的话,那么万一是 [0,0,1,1],我们求得的结果不久从 \(4\) 变为 \(2\) 了吗?
  • 如果不更新的话,万一是 [0,0,1],那么我们就得到 \(0\) 而不是 \(2\),因为 \(3\) 不是偶数被我们忽略了。

所以说,唯一的办法就是既保存 {0,0},又保存 {0,1},用到的时候全部拿出来判断一遍,但这样的话,时间复杂度就不合适了,所以我们需要转换思路。

我们可以将问题稍微一转化,就变成我们熟知的问题。即将 \(0\) 替换为 \(-1\),那么问题就转化为了求 \(sum\) 等于 \(0\) 的连续子数组,此时我们不需要判断长度是不是偶数了,因为如果符合要求的话,长度一定是偶数,因为 \(-1\) 会造成影响,而之前的 \(0\) 对前缀和没有影响,导致我们无从下手。

当然,将 \(0\) 替换为 \(-1\) 这一步并不好想。

problem 6 -- medium

1.转化问题,将数字看作 \(1\),字母看作 \(-1\),反过来也行,将问题转化为求和为 \(0\) 的最长连续子数组。
2.保存答案,因为我们哈希表中存储的是前缀和,而区间和 s[left,right]=s[right]-s[left-1];,所以说,得到的 \(left\) 要加上 \(1\)

另外,还是提一下,数组下标从 \(0\) 开始,初始时 ass[0]=-1;,很关键!

posted @ 2023-03-10 16:34  光風霽月  阅读(292)  评论(0编辑  收藏  举报