统计连续子数组的个数【哈希表+前缀和】【模板题】
1. 适用题目描述
给定我们一个数组,让我们求满足某些条件的连续子数组的个数。
注意,必须连续。
另外,子数组可能有很多限制,例如常见的:非空。
还有一些特殊的比如:大小至少为2等
2. 大体思路 - 前缀和 + 哈希表
从暴力的角度出发,因为是连续数组,所以我们可以通过枚举左右端点 [ l , r ] [l,r] [l,r] 来求得结果,时间复杂度为 O ( N 2 ) O(N^2) O(N2)。
但是暴力肯定不行(即使暴力能过,你也应该找出优化的做法),但是暴力会给我们打开思路。
在暴力方法中,我们相当于枚举每个右端点 r r r,然后再枚举每个左端点 l l l,判断区间 [ l , r ] [l,r] [l,r] 是否符合条件,如果我们要优化的话,肯定是优化枚举 l l l 的步骤,因为在枚举 l l l 这一步,可能存在大量重复计算。
一般来说,都是用哈希表来存储计算得到的数据,提供给后续使用。
并且,以为求的是连续子数组,因此和前缀和也密不可分。不过我们通常并不需要建立前缀和数组,而是通过一个变量逐渐累加来代替前缀和。
一般来说,都是让你求连续子数组的和等于 k k k 的个数, m o d mod mod k k k 等于 0 0 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 k k 个奇数数字的子数组的个数,我们可以将奇数看作 1 1 1,偶数看作 0 0 0,那么本题就转化为了求连续子数组和为 k k k 的子数组的个数
problem 2 – medium
这道题稍微有一点难度,就是需要题目信息,并进行转化。
题目要求我们,移除最短连续子数组(可以非空),使得剩下元素能被 p p p 整除。(我们假设,必须移除元素。因为不需要移除的情况很容易判断。)
假设原数组所有元素之和为
s
u
m
sum
sum,因为我们必须要移除元素,所以说 我们假设 sum%k = target
,那么 target
肯定不为
0
0
0。
接下来就是思维了,我们要将题目转化为,找到一个连续非空(前面说了必须移除元素)子数组,使得他们的和模 k k k 等于 t a r g e t target 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 2 2 且和为 k k k 的倍数的连续子数组的个数,这题要求有点多啊,我们一步一步分析
长度至少为
2
2
2。首先我们想一下我们是怎么处理非空的,在 problem2
模板题中,我们是这样写的:(
c
u
r
cur
cur 用来模拟前缀和)
for(auto &x : nums) {
cur += x;
// cur - pre = goal
// pre = cur - goal
res += ass[cur- goal]; // (1)
ass[cur] ++ ; // (2)
}
注意 ( 1 ) (1) (1) 和 ( 2 ) (2) (2) 的前后顺序,如果 ( 1 ) (1) (1) 在 ( 2 ) (2) (2) 的前面,那么每次查询时,自己都还没加入哈希表中,因此用来保证子数组不为空,也就是长度至少为 1 1 1。
如果 ( 2 ) (2) (2) 在 ( 1 ) (1) (1) 的前面,那么就可以得到空数组。
现在我们再来想,如何实现长度至少为 2 2 2
长度如果至少为 2 2 2 的话,那么在我们查询下标 i d x idx idx 时,要保证 i d x − 1 idx-1 idx−1 没有被插入哈希表中,否则就可以取到 i d x − 1 idx-1 idx−1,如果取到的话,长度就是 1 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)
}
我们每次加入哈希表的是 l a s t last last 也就是上一个 p r e pre pre。因此,当我们查询完 i d x idx idx 之后,我们加入的是 i d x − 1 idx-1 idx−1,而不是 i d x idx idx,这样当我们下次查询 i d x + 1 idx+1 idx+1 时,最大的就是 i d x − 1 idx-1 idx−1 而不是 i d x idx idx,因此数组长度至少为 2 2 2。
另外,就是题目让我们求的是和为 k k k 的倍数,其实就是对 k k k 取模等于 0 0 0。这里有个防止溢出的小优化,就是边求前缀和边取模。
problem4 – easy
本题让我们求,子数组只和可以被 k k k 整除,等价于子数组只和等于 k k k 的倍数,等价于模 k k k 等于 0 0 0,就转化为上题了。
problem 5 – medium
题目让我们求相同数量的 0 0 0 和 1 1 1 的最长连续子数组。
直接求是不好求的,首先,我们需要判断子数组的区间长度是否为偶数,因为如果我们使用常规的哈希表和前缀和的方法,我们就需要保存之前的信息,但是题目要求我们求最大值,所以说我们应该保存第一次出现的信息,因为此时的下标更小,构成的子数组长度更长,但是,长度长不一定意味着它就合法。例如:[0,0,1]
当我们遍历到第一个
0
0
0 的时候,我们需要在哈希表
a
s
s
ass
ass 中加入 {0,0}
,表示前缀和
0
0
0 的下标为
1
1
1,但是当我们遍历到第二个
0
0
0 的时候,是否应该更新
a
s
s
[
0
]
=
1
ass[0]=1
ass[0]=1 呢?
- 如果更新的话,那么万一是
[0,0,1,1]
,我们求得的结果不久从 4 4 4 变为 2 2 2 了吗? - 如果不更新的话,万一是
[0,0,1]
,那么我们就得到 0 0 0 而不是 2 2 2,因为 3 3 3 不是偶数被我们忽略了。
所以说,唯一的办法就是既保存 {0,0}
,又保存 {0,1}
,用到的时候全部拿出来判断一遍,但这样的话,时间复杂度就不合适了,所以我们需要转换思路。
我们可以将问题稍微一转化,就变成我们熟知的问题。即将 0 0 0 替换为 − 1 -1 −1,那么问题就转化为了求 s u m sum sum 等于 0 0 0 的连续子数组,此时我们不需要判断长度是不是偶数了,因为如果符合要求的话,长度一定是偶数,因为 − 1 -1 −1 会造成影响,而之前的 0 0 0 对前缀和没有影响,导致我们无从下手。
当然,将 0 0 0 替换为 − 1 -1 −1 这一步并不好想。
problem 6 – medium
1.转化问题,将数字看作
1
1
1,字母看作
−
1
-1
−1,反过来也行,将问题转化为求和为
0
0
0 的最长连续子数组。
2.保存答案,因为我们哈希表中存储的是前缀和,而区间和 s[left,right]=s[right]-s[left-1];
,所以说,得到的
l
e
f
t
left
left 要加上
1
1
1。
另外,还是提一下,数组下标从
0
0
0 开始,初始时 ass[0]=-1;
,很关键!