统计连续子数组的个数【哈希表+前缀和】【模板题】
1. 适用题目描述
给定我们一个数组,让我们求满足某些条件的连续子数组的个数。
注意,必须连续。
另外,子数组可能有很多限制,例如常见的:非空。
还有一些特殊的比如:大小至少为2等
2. 大体思路 - 前缀和 + 哈希表
从暴力的角度出发,因为是连续数组,所以我们可以通过枚举左右端点 \([l,r]\) 来求得结果,时间复杂度为 \(O(N^2)\)。
但是暴力肯定不行(即使暴力能过,你也应该找出优化的做法),但是暴力会给我们打开思路。
在暴力方法中,我们相当于枚举每个右端点 \(r\),然后再枚举每个左端点 \(l\),判断区间 \([l,r]\) 是否符合条件,如果我们要优化的话,肯定是优化枚举 \(l\) 的步骤,因为在枚举 \(l\) 这一步,可能存在大量重复计算。
一般来说,都是用哈希表来存储计算得到的数据,提供给后续使用。
并且,以为求的是连续子数组,因此和前缀和也密不可分。不过我们通常并不需要建立前缀和数组,而是通过一个变量逐渐累加来代替前缀和。
一般来说,都是让你求连续子数组的和等于 \(k\) 的个数,\(mod\) \(k\) 等于 \(0\) 的个数等等,有一些我们直接求是不好求的,但是我们可以转化题目为一些我们好求的。
3. 模板题
题目描述
代码
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]
,头 l
的 s[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;
,很关键!