子串专题
Hot100 子串专题共 3 题:和为 K 的子数组、滑动窗口最大值、最小覆盖子串。这三题难度跨度较大,从中等到困难,涉及前缀和、单调队列、滑动窗口三种不同的核心技巧。
一、和为 K 的子数组(#560)
题意
给一个整数数组 nums 和整数 k,返回数组中和为 k 的连续子数组的个数。
输入:nums = [1, 1, 1], k = 2
输出:2
输入:nums = [1, 2, 3], k = 3
输出:2 (子数组 [1,2] 和 [3])
注意:数组元素可以是负数,所以滑动窗口不适用(窗口收缩的前提是元素非负时和单调)。
前置知识:前缀和
前缀和 prefix[i]prefix[i]prefix[i] 表示 nums[0]+nums[1]+⋯+nums[i−1]nums[0] + nums[1] + \cdots + nums[i-1]nums[0]+nums[1]+⋯+nums[i−1],定义 prefix[0]=0prefix[0] = 0prefix[0]=0。
子数组 [i,j][i, j][i,j] 的和等于 prefix[j+1]−prefix[i]prefix[j+1] - prefix[i]prefix[j+1]−prefix[i]。
nums = [1, 2, 3]
prefix = [0, 1, 3, 6]
子数组[0,1]的和 = prefix[2] - prefix[0] = 3 - 0 = 3 ✓
子数组[1,2]的和 = prefix[3] - prefix[1] = 6 - 1 = 5 ✓
思路
问题转化:找有多少对 (i,j)(i, j)(i,j) 满足 prefix[j]−prefix[i]=kprefix[j] - prefix[i] = kprefix[j]−prefix[i]=k,即 prefix[i]=prefix[j]−kprefix[i] = prefix[j] - kprefix[i]=prefix[j]−k。
遍历到位置 jjj 时,查询之前有多少个前缀和等于 prefix[j]−kprefix[j] - kprefix[j]−k,累加到答案里。用 unordered_map 记录每个前缀和出现的次数,边遍历边查询,O(1)O(1)O(1) 完成每次查询。
nums = [1, 1, 1], k = 2
初始:mp = {0: 1}(前缀和为0出现1次,对应空前缀)
遍历:
prefix=1: 查 1-2=-1,mp里没有 → ans+=0,mp={0:1, 1:1}
prefix=2: 查 2-2=0,mp里有1次 → ans+=1,mp={0:1, 1:1, 2:1}
prefix=3: 查 3-2=1,mp里有1次 → ans+=1,mp={0:1, 1:1, 2:1, 3:1}
ans = 2 ✓
为什么初始化 mp[0] = 1?因为如果某个前缀和 prefix[j]prefix[j]prefix[j] 本身就等于 kkk,那么 prefix[j]−k=0prefix[j] - k = 0prefix[j]−k=0,对应的是从下标 0 开始的子数组,需要被统计进来,所以空前缀(前缀和为 0)要提前存入。
代码
cpp
class Solution {
public:
int subarraySum(vector<int>& nums, int k) {
unordered_map<int, int> mp;
mp[0] = 1; // 空前缀,前缀和为 0
int prefix = 0, ans = 0;
for (int x : nums) {
prefix += x;
// 查询之前有多少前缀和等于 prefix - k
if (mp.count(prefix - k)) {
ans += mp[prefix - k];
}
mp[prefix]++;
}
return ans;
}
};
复杂度
- 时间 :O(n)O(n)O(n),单次遍历,每次哈希操作 O(1)O(1)O(1)
- 空间 :O(n)O(n)O(n),哈希表最多存 n+1n+1n+1 个前缀和
二、滑动窗口最大值(#239)
题意
给一个整数数组 nums 和窗口大小 k,窗口从左到右滑动,每次只能看到窗口内的 kkk 个元素,返回每个窗口位置的最大值。
输入:nums = [1,3,-1,-3,5,3,6,7], k = 3
输出:[3,3,5,5,6,7]
窗口位置 最大值
[1 3 -1] -3 5 3 6 7 → 3
1 [3 -1 -3] 5 3 6 7 → 3
1 3 [-1 -3 5] 3 6 7 → 5
1 3 -1 [-3 5 3] 6 7 → 5
1 3 -1 -3 [5 3 6] 7 → 6
1 3 -1 -3 5 [3 6 7] → 7
前置知识:单调队列
单调队列是一个两端都能操作的队列(双端队列 deque),队列内的元素保持单调性。
这道题需要的是单调递减队列:队头是当前窗口的最大值,从队头到队尾单调递减。
维护规则:
-
新元素从队尾入队前,把队尾所有比它小的元素弹出(它们永远不可能成为后续窗口的最大值)
-
如果队头元素的下标已经滑出窗口,从队头弹出
队列里存的是下标,方便判断是否滑出窗口
思路
遍历 nums,对每个位置 right:
-
清理队尾 :把队尾所有满足
nums[队尾] <= nums[right]的元素弹出,再把right入队 -
清理队头 :如果队头下标
<= right - k,说明已滑出窗口,弹出队头 -
记录答案 :当
right >= k-1时(窗口已满),队头就是当前窗口最大值nums = [1,3,-1,-3,5,3,6,7], k=3
right=0(1): 队列[0]
right=1(3): 3>1,弹出0,队列[1]
right=2(-1): -1<3,直接入队,队列[1,2],窗口满,最大值=nums[1]=3
right=3(-3): -3<-1,直接入队,队列[1,2,3]
队头1未滑出(1 <= 3-3=0? 否),最大值=nums[1]=3
right=4(5): 5>-3弹3,5>-1弹2,5>3弹1,队列[4]
最大值=nums[4]=5
right=5(3): 3<5,直接入队,队列[4,5]
最大值=nums[4]=5
right=6(6): 6>3弹5,6<5? 不,6>5弹4,队列[6]
最大值=nums[6]=6
right=7(7): 7>6弹6,队列[7]
最大值=nums[7]=7
代码
cpp
class Solution {
public:
vector<int> maxSlidingWindow(vector<int>& nums, int k) {
deque<int> dq; // 存下标,单调递减(队头最大)
vector<int> res;
for (int right = 0; right < nums.size(); right++) {
// 清理队尾:保持单调递减
while (!dq.empty() && nums[dq.back()] <= nums[right]) {
dq.pop_back();
}
dq.push_back(right);
// 清理队头:滑出窗口的元素
if (dq.front() <= right - k) {
dq.pop_front();
}
// 窗口已满,记录答案
if (right >= k - 1) {
res.push_back(nums[dq.front()]);
}
}
return res;
}
};
复杂度
- 时间 :O(n)O(n)O(n),每个元素最多入队一次、出队一次
- 空间 :O(k)O(k)O(k),单调队列最多存 kkk 个元素
三、最小覆盖子串(#76)
题意
给字符串 s 和 t,找 s 中包含 t 所有字符(含重复)的最短子串,不存在则返回空字符串。
输入:s = "ADOBECODEBANC", t = "ABC"
输出:"BANC"
输入:s = "a", t = "aa"
输出:"" (s 里只有一个 a,无法覆盖 t 里的两个 a)
思路
这是滑动窗口的经典困难题,窗口可变长度,目标是找满足条件的最短窗口。
窗口条件 :窗口内包含 t 的所有字符,且每个字符的出现次数都不少于 t 中的次数。
用两个哈希表 need 和 window:
need:t中每个字符需要的数量window:当前窗口内每个字符的数量
用变量 valid 记录窗口内已经满足数量要求的字符种数,当 valid == need.size() 时,窗口覆盖了 t。
流程:
-
right向右扩张,把字符纳入窗口,如果某字符的窗口数量恰好达到需要量,valid++ -
当
valid == need.size()时,窗口已覆盖t,记录当前窗口长度,然后收缩left -
收缩时,如果移出的字符导致某字符数量低于需要量,
valid--,退出收缩循环 -
重复直到
right到达末尾s = "ADOBECODEBANC", t = "ABC"
need = {A:1, B:1, C:1}right扩张到覆盖ABC:
[ADOBEC] → valid=3,覆盖了,记录长度6,开始收缩left
移出A → A的窗口数量0 < need[A]=1 → valid=2,停止收缩right继续扩张:
[DOBECODEBA] → 又找到A,valid=3
收缩left直到窗口最短:[CODEBA] → 长度6
移出C → valid=2right继续:
[CODEBANC] → 找到C,valid=3
收缩:移出C → valid=2,停止 → 此时 [ODEBANC] 不对
实际上收缩到 [BANC],长度4,更新最短最终答案:[BANC]
代码
cpp
class Solution {
public:
string minWindow(string s, string t) {
unordered_map<char, int> need, window;
for (char c : t) need[c]++;
int left = 0, valid = 0;
int start = 0, minLen = INT_MAX; // 记录最短窗口的起点和长度
for (int right = 0; right < s.size(); right++) {
char c = s[right];
// 纳入右边字符
if (need.count(c)) {
window[c]++;
if (window[c] == need[c]) valid++; // 该字符恰好满足
}
// 窗口已覆盖 t,收缩左边
while (valid == need.size()) {
// 更新最短窗口
if (right - left + 1 < minLen) {
minLen = right - left + 1;
start = left;
}
char d = s[left];
left++;
if (need.count(d)) {
if (window[d] == need[d]) valid--; // 移出前恰好满足,移出后不满足
window[d]--;
}
}
}
return minLen == INT_MAX ? "" : s.substr(start, minLen);
}
};
注意收缩时先判断 valid-- 再 window[d]-- 的顺序:因为判断条件是 window[d] == need[d](移出这个字符会导致不满足),所以要在减之前判断。
复杂度
- 时间 :O(n+m)O(n + m)O(n+m),nnn 为
s的长度,mmm 为t的长度。left和right各最多移动 nnn 次,初始化need需要 O(m)O(m)O(m) - 空间 :O(∣Σ∣)O(|\Sigma|)O(∣Σ∣),两个哈希表的 key 都是字符,最多 O(1)O(1)O(1)
四、专题小结
| 题目 | 核心技巧 | 关键数据结构 | 时间 | 空间 |
|---|---|---|---|---|
| 和为 K 的子数组 | 前缀和 + 哈希查询 | unordered_map |
O(n)O(n)O(n) | O(n)O(n)O(n) |
| 滑动窗口最大值 | 单调队列维护区间最大值 | deque |
O(n)O(n)O(n) | O(k)O(k)O(k) |
| 最小覆盖子串 | 可变滑动窗口 + 双哈希表 | unordered_map |
O(n+m)O(n+m)O(n+m) | O(1)O(1)O(1) |
三题用了三种完全不同的技巧,但有一个共同点:都在避免重复计算 。前缀和把区间求和变成两个前缀相减,单调队列把区间最大值查询变成 O(1)O(1)O(1),滑动窗口通过移动左右指针复用了窗口状态。