统计连续子数组的个数【哈希表+前缀和】【模板题】
1. 适用题目描述
给定我们一个数组,让我们求满足某些条件的连续子数组的个数。
注意,必须连续。
另外,子数组可能有很多限制,例如常见的:非空。
还有一些特殊的比如:大小至少为2等
2. 大体思路 - 前缀和 + 哈希表
从暴力的角度出发,因为是连续数组,所以我们可以通过枚举左右端点 来求得结果,时间复杂度为 。
但是暴力肯定不行(即使暴力能过,你也应该找出优化的做法),但是暴力会给我们打开思路。
在暴力方法中,我们相当于枚举每个右端点 ,然后再枚举每个左端点 ,判断区间 是否符合条件,如果我们要优化的话,肯定是优化枚举 的步骤,因为在枚举 这一步,可能存在大量重复计算。
一般来说,都是用哈希表来存储计算得到的数据,提供给后续使用。
并且,以为求的是连续子数组,因此和前缀和也密不可分。不过我们通常并不需要建立前缀和数组,而是通过一个变量逐渐累加来代替前缀和。
一般来说,都是让你求连续子数组的和等于 的个数, 等于 的个数等等,有一些我们直接求是不好求的,但是我们可以转化题目为一些我们好求的。
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
题目让我们统计连续子数组中恰好有 个奇数数字的子数组的个数,我们可以将奇数看作 ,偶数看作 ,那么本题就转化为了求连续子数组和为 的子数组的个数
problem 2 – medium
这道题稍微有一点难度,就是需要题目信息,并进行转化。
题目要求我们,移除最短连续子数组(可以非空),使得剩下元素能被 整除。(我们假设,必须移除元素。因为不需要移除的情况很容易判断。)
假设原数组所有元素之和为 ,因为我们必须要移除元素,所以说 我们假设 sum%k = 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
求长度至少为 且和为 的倍数的连续子数组的个数,这题要求有点多啊,我们一步一步分析
长度至少为 。首先我们想一下我们是怎么处理非空的,在 problem2
模板题中,我们是这样写的:( 用来模拟前缀和)
for(auto &x : nums) {
cur += x;
// cur - pre = goal
// pre = cur - goal
res += ass[cur- goal]; // (1)
ass[cur] ++ ; // (2)
}
注意 和 的前后顺序,如果 在 的前面,那么每次查询时,自己都还没加入哈希表中,因此用来保证子数组不为空,也就是长度至少为 。
如果 在 的前面,那么就可以得到空数组。
现在我们再来想,如何实现长度至少为
长度如果至少为 的话,那么在我们查询下标 时,要保证 没有被插入哈希表中,否则就可以取到 ,如果取到的话,长度就是 了。看代码:
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)
}
我们每次加入哈希表的是 也就是上一个 。因此,当我们查询完 之后,我们加入的是 ,而不是 ,这样当我们下次查询 时,最大的就是 而不是 ,因此数组长度至少为 。
另外,就是题目让我们求的是和为 的倍数,其实就是对 取模等于 。这里有个防止溢出的小优化,就是边求前缀和边取模。
problem4 – easy
本题让我们求,子数组只和可以被 整除,等价于子数组只和等于 的倍数,等价于模 等于 ,就转化为上题了。
problem 5 -- medium
题目让我们求相同数量的 和 的最长连续子数组。
直接求是不好求的,首先,我们需要判断子数组的区间长度是否为偶数,因为如果我们使用常规的哈希表和前缀和的方法,我们就需要保存之前的信息,但是题目要求我们求最大值,所以说我们应该保存第一次出现的信息,因为此时的下标更小,构成的子数组长度更长,但是,长度长不一定意味着它就合法。例如:[0,0,1]
当我们遍历到第一个 的时候,我们需要在哈希表 中加入 {0,0}
,表示前缀和 的下标为 ,但是当我们遍历到第二个 的时候,是否应该更新 呢?
- 如果更新的话,那么万一是
[0,0,1,1]
,我们求得的结果不久从 变为 了吗? - 如果不更新的话,万一是
[0,0,1]
,那么我们就得到 而不是 ,因为 不是偶数被我们忽略了。
所以说,唯一的办法就是既保存 {0,0}
,又保存 {0,1}
,用到的时候全部拿出来判断一遍,但这样的话,时间复杂度就不合适了,所以我们需要转换思路。
我们可以将问题稍微一转化,就变成我们熟知的问题。即将 替换为 ,那么问题就转化为了求 等于 的连续子数组,此时我们不需要判断长度是不是偶数了,因为如果符合要求的话,长度一定是偶数,因为 会造成影响,而之前的 对前缀和没有影响,导致我们无从下手。
当然,将 替换为 这一步并不好想。
problem 6 -- medium
1.转化问题,将数字看作 ,字母看作 ,反过来也行,将问题转化为求和为 的最长连续子数组。
2.保存答案,因为我们哈希表中存储的是前缀和,而区间和 s[left,right]=s[right]-s[left-1];
,所以说,得到的 要加上 。
另外,还是提一下,数组下标从 开始,初始时 ass[0]=-1;
,很关键!
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· Docker 太简单,K8s 太复杂?w7panel 让容器管理更轻松!