LeetCode 76. 最小覆盖子串(滑动窗口详解)
📌 题目描述
给你两个字符串 s 和 t,长度分别为 m 和 n。请你返回 s 中最短的子串 ,使得该子串包含 t 中的所有字符(包括重复字符 )。如果不存在这样的子串,则返回空字符串 ""。
注意:
- 测试用例保证答案唯一。
- 如果
s中没有覆盖t的子串,返回""。
示例 1:
输入:s = "ADOBECODEBANC", t = "ABC"
输出:"BANC"
解释:最短覆盖子串是 "BANC"(长度为3),虽然 "ADOBEC" 也包含 ABC,但更长。
示例 2:
输入:s = "a", t = "a"
输出:"a"
示例 3:
输入:s = "a", t = "aa"
输出:""
解释:t 中两个 'a' 都需要被覆盖,但 s 只有一个,故返回 ""。
💡 解题思路
本题是经典的 滑动窗口 问题,可以使用 双指针 + 哈希表 在 O ( m ) O(m) O(m) 时间内解决。
核心思想:
- 用哈希表
ht记录目标字符串t中每个字符的出现次数(即"需求量")。 - 维护一个滑动窗口
[j, i],其中i是右指针,j是左指针。 - 用另一个哈希表
hs记录当前窗口内字符的实际出现次数。 - 引入一个计数器
cnt,记录当前窗口中"有效匹配"的字符个数(即窗口中已经满足t需求的字符数量)。 - 扩展右指针 ,每次加入新字符,若该字符在窗口中的数量不超过需求量,则
cnt++。 - 收缩左指针 ,当窗口包含所有
t的字符后(即cnt == t.size()),尝试向右移动左指针,去掉冗余字符,以缩小窗口长度。 - 在收缩过程中更新最小窗口。
🧩 代码实现(C++)
cpp
class Solution {
public:
string minWindow(string s, string t) {
unordered_map<char, int> hs, ht; // hs: 窗口频次,ht: 目标频次
for (char c : t) ht[c]++; // 统计 t 中每个字符的需用量
string res; // 最终答案
int cnt = 0; // 窗口中有效匹配的字符个数
for (int i = 0, j = 0; i < s.size(); i++) {
// 1. 扩展右边界,将 s[i] 加入窗口
hs[s[i]]++;
if (hs[s[i]] <= ht[s[i]]) cnt++; // 若该字符的数量未超过需求量,则有效计数+1
// 2. 收缩左边界:移除窗口左侧所有冗余字符
while (hs[s[j]] > ht[s[j]]) {
hs[s[j]]--; // 移除左边界字符
j++; // 左指针右移
}
// 3. 如果窗口已经覆盖 t,尝试更新答案
if (cnt == t.size()) {
int curLen = i - j + 1;
if (res.empty() || curLen < res.size()) {
res = s.substr(j, curLen);
}
}
}
return res;
}
};
🔍 代码逐行详解
1️⃣ 哈希表初始化
cpp
unordered_map<char, int> hs, ht;
for (char c : t) ht[c]++;
ht存储了t中每个字符的需求次数,例如t = "AAB",则ht['A']=2, ht['B']=1。hs初始为空,用于动态记录当前窗口[j, i]内字符出现的次数。
2️⃣ 遍历右指针 i
cpp
for (int i = 0, j = 0; i < s.size(); i++) {
hs[s[i]]++;
if (hs[s[i]] <= ht[s[i]]) cnt++;
...
}
- 每次将
s[i]纳入窗口,先更新hs。 - 如果该字符在窗口中的数量 没有超过 它在
t中的需求量,说明这个字符是 有效 的,cnt++。
例如,t中需要 2 个A,窗口已有 1 个A,再来一个A时,hs['A']=2,满足<= ht['A'],因此cnt++。若再来第三个A,hs['A']=3 > 2,则不会增加cnt。
3️⃣ 收缩左指针 j
cpp
while (hs[s[j]] > ht[s[j]]) {
hs[s[j]]--;
j++;
}
- 当窗口已包含所需所有字符时(
cnt == t.size()),我们需要 尽可能缩短窗口。 - 检查左指针
j指向的字符是否 冗余(即窗口中的数量超过了需求量)。 - 如果是,则将其移出窗口(频次减1,左指针右移),直到左边界字符不再冗余为止。
- 此步骤保证了窗口在满足覆盖的前提下,左边界无法再向右移动,即当前窗口是 以
i为右边界时的最小窗口。
4️⃣ 更新答案
cpp
if (cnt == t.size()) {
if (res.empty() || i - j + 1 < res.size())
res = s.substr(j, i - j + 1);
}
- 当
cnt == t.size()时,表示窗口已经覆盖了t的全部字符。 - 此时比较当前窗口长度是否小于已记录的最短长度,若是则更新
res。
🎯 为什么 cnt == t.size() 能保证覆盖?
cnt统计的是窗口中 有效字符的总数 ,每个有效字符意味着该字符在窗口中的出现次数没有超过它在t中的需求量。- 当
cnt == t.size()时,说明窗口中已经累积了恰好等于t长度的有效字符,且由于收缩步骤保证了任意字符均不超过需求量,因此 窗口内每个字符的数量正好等于(或大于等于,但冗余已被移除)需求 ,即完全覆盖t。
📊 复杂度分析
- 时间复杂度 : O ( m ) O(m) O(m),其中 m m m 是字符串
s的长度。
左右指针均只会向右移动,每个字符最多被加入和移出窗口各一次,因此总操作次数为 O ( m ) O(m) O(m)。 - 空间复杂度 : O ( ∣ Σ ∣ ) O(|\Sigma|) O(∣Σ∣),其中 ∣ Σ ∣ |\Sigma| ∣Σ∣ 是字符集大小(本题通常为 52 个英文字母,或扩展为 256 个 ASCII 字符)。哈希表存储的字符种类有限,可视为 O ( 1 ) O(1) O(1)。
🧪 测试用例验证
| 输入 | 输出 | 解释 |
|---|---|---|
s = "ADOBECODEBANC", t = "ABC" |
"BANC" |
最短覆盖子串 |
s = "a", t = "a" |
"a" |
单字符匹配 |
s = "a", t = "aa" |
"" |
无法覆盖两个 'a' |
s = "aa", t = "aa" |
"aa" |
必须完全匹配两个 'a' |
s = "bbaa", t = "aba" |
"baa" |
注意顺序不影响,子串包含即可 |
💭 总结与扩展
- 滑动窗口是解决子串/子数组问题的重要技巧,常用于求满足条件的最短/最长区间。
- 本题的 核心难点 在于如何高效判断窗口是否覆盖
t,以及如何处理重复字符。 - 使用 双哈希表 + 计数器 是解决此类问题的经典范式,掌握后可轻松应对 LeetCode 438、567 等相似题目。