题目描述
给你两个字符串 s 和 t,长度分别是 m 和 n,返回 s 中的最短窗口子串,使得该子串包含 t 中的每一个字符(包括重复字符)。如果没有这样的子串,返回空字符串 ""。
测试用例保证答案唯一。
示例
示例 1:
输入:s = "ADOBECODEBANC", t = "ABC"
输出:"BANC"
解释:最小覆盖子串 "BANC" 包含来自字符串 t 的 'A'、'B' 和 'C'。
示例 2:
输入:s = "a", t = "a"
输出:"a"
解释:整个字符串 s 是最小覆盖子串。
示例 3:
输入:s = "a", t = "aa"
输出:""
解释:t 中两个字符 'a' 均应包含在 s 的子串中,因此没有符合条件的子字符串,返回空字符串。
提示:
- m == s.length
- n == t.length
- 1 <= m, n <= 10^5
- s 和 t 由英文字母组成
进阶:
你能设计一个在 O(m + n) 时间内解决此问题的算法吗?
解题思路总览
| 方法 | 核心思想 | 时间复杂度 | 空间复杂度 | 备注 |
|---|---|---|---|---|
| 滑动窗口(双指针) | 扩张-收缩双指针,维护窗口 | O(m + n) | O(1) | 推荐解法 |
| 暴力枚举 | 枚举所有子串再检查 | O(m^2 * n) | O(1) | 会超时 |
| 优化暴力 | 先记录t中字符位置再枚举 | O(m * n) | O(n) | 不够优 |
一、核心解法:滑动窗口(双指针)
核心思想
使用滑动窗口的思想,维护一个包含 t 中所有字符的窗口:
1. 右指针扩张,直到窗口包含 t 中所有字符
2. 收缩左指针,找到最短的满足条件的窗口
3. 记录答案,继续扩张右指针
关键洞察
滑动窗口的关键:
1. 右指针不断右移,扩大窗口
2. 当窗口满足条件时,收缩左指针尝试优化
3. 使用两个哈希表:
- need: t 中每个字符需要的次数
- window: 当前窗口中每个字符出现的次数
当 window 满足 need 时,说明窗口有效。
图解
s = "ADOBECODEBANC", t = "ABC"
need = {A:1, B:1, C:1}
滑动窗口过程:
初始状态:
left=0, right=0
window = {}
needCnt = 3
扩张 right=0, s[0]='D':
D 不在 need 中,跳过
扩张 right=1, s[1]='D':
D 不在 need 中,跳过
扩张 right=2, s[2]='O':
O 不在 need 中,跳过
扩张 right=3, s[3]='B':
B 在 need 中
window[B]=1, need[B]=1, count=1
count=3? 否,继续扩张
扩张 right=4, s[4]='E':
E 不在 need 中,跳过
扩张 right=5, s[5]='C':
C 在 need 中
window[C]=1, need[C]=1, count=2
扩张 right=6, s[6]='O':
O 不在 need 中,跳过
扩张 right=7, s[7]='D':
O 不在 need 中,跳过
扩张 right=8, s[8]='E':
E 不在 need 中,跳过
扩张 right=9, s[9]='B':
B 在 need 中
window[B]=2, need[B]=1, count=2 (B已经满足,不增加count)
扩张 right=10, s[10]='A':
A 在 need 中
window[A]=1, need[A]=1, count=3
count=3 == need.size(),窗口有效!
收缩 left:
left=0: 窗口 [0,10] = "ADOBECODEBA"
不满足,收缩 left=1
left=1: 窗口 [1,10] = "DOBECODEBA"
不满足,收缩 left=2
...
left=9: 窗口 [9,10] = "BA"
满足!记录答案 "BA",长度=2
left=10: 收缩,窗口 [10,10] = "A"
不满足,继续扩张
继续扩张 right=11, s[11]='N':
N 不在 need 中,跳过
扩张 right=12, s[12]='C':
C 在 need 中
window[C]=2, need[C]=1, count=3 (C已满足)
窗口有效!
收缩 left:
left=10: 窗口 [10,12] = "ANC"
不满足,收缩 left=11
left=11: 窗口 [11,12] = "NC"
不满足,收缩 left=12
left=12: 窗口 [12,12] = "C"
不满足,收缩 left=13
right 到达末尾,停止
答案: "BANC"(长度为4,比 "BA" 更长但更靠后)
二、算法流程图
输入: s = "ADOBECODEBANC", t = "ABC"
初始化:
need = {A:1, B:1, C:1}
window = {}
left = 0, right = 0
count = 0 (已满足的字符种类数)
minLen = INF, start = 0
扩张 right=3 (遇到B):
window[B]++, count=1
扩张 right=5 (遇到C):
window[C]++, count=2
扩张 right=10 (遇到A):
window[A]++, count=3
窗口有效!count == need.size()
收缩 left:
left=0 -> "ADOBECODEBA" 无效
left=1 -> "DOBECODEBA" 无效
...
left=9 -> "BA" 有效!记录 minLen=2, start=9
left=10 -> "A" 无效
继续扩张 right=12 (遇到C):
window[C]++, count=3
窗口有效!
收缩 left:
left=11 -> "ANC" 无效
left=12 -> "C" 无效
right 到达末尾,结束
输出: "BANC" (start=9, minLen=4)
三、完整代码实现
cpp
class Solution {
public:
string minWindow(string s, string t) {
unordered_map<char, int> need, window;
// 记录 t 中每个字符需要的次数
for (char c : t) {
need[c]++;
}
int left = 0, right = 0;
int count = 0; // 已满足的字符种类数
int start = 0, minLen = INT_MAX;
while (right < s.size()) {
char c = s[right++];
// 如果字符在 need 中,加入 window
if (need.count(c)) {
window[c]++;
// 当 window[c] 达到 need[c] 时,count++
if (window[c] == need[c]) {
count++;
}
}
// 当窗口满足条件时,收缩左边界
while (count == need.size()) {
// 更新最小窗口
if (right - left < minLen) {
start = left;
minLen = right - left;
}
// 收缩左边界
char d = s[left++];
// 如果字符在 need 中,从 window 中移除
if (need.count(d)) {
// 注意:先判断再--
if (window[d] == need[d]) {
count--; // 移除后不再满足
}
window[d]--;
}
}
}
return minLen == INT_MAX ? "" : s.substr(start, minLen);
}
};
四、逐行解析
cpp
unordered_map<char, int> need, window;
need:记录 t 中每个字符需要的次数window:记录当前窗口中每个字符出现的次数
cpp
for (char c : t) {
need[c]++;
}
- 初始化 need,统计 t 中每个字符出现的次数
cpp
int left = 0, right = 0;
int count = 0;
int start = 0, minLen = INT_MAX;
left, right:滑动窗口的左右边界count:当前窗口中已满足 need 条件的字符种类数start, minLen:最小窗口的起始位置和长度
cpp
while (right < s.size()) {
char c = s[right++];
- 右指针不断右移,扩张窗口
cpp
if (need.count(c)) {
window[c]++;
if (window[c] == need[c]) {
count++;
}
}
- 如果当前字符在 need 中,加入 window
- 当 window[c] 达到 need[c] 时,count++(这个字符已经满足条件)
cpp
while (count == need.size()) {
- 当窗口满足条件时(count == need.size()),收缩左边界尝试优化
cpp
if (right - left < minLen) {
start = left;
minLen = right - left;
}
- 更新最小窗口
cpp
char d = s[left++];
if (need.count(d)) {
if (window[d] == need[d]) {
count--;
}
window[d]--;
}
- 收缩左边界
- 如果移除的字符在 need 中,先判断是否会让 count 减少
- 然后从 window 中移除该字符
五、count 变量的作用
count 表示当前窗口中满足 need 条件的字符种类数。
例如 t = "ABC",need = {A:1, B:1, C:1}
初始: count = 0
遇到 A: window[A]=1 == need[A], count=1
遇到 B: window[B]=1 == need[B], count=2
遇到 C: window[C]=1 == need[C], count=3
当 count == need.size() 时,说明所有字符都满足了条件。
收缩时:
移除 A: window[A]=0 != need[A], count 不变
移除 B: window[B]=0 != need[B], count 不变
移除 C: window[C]=0 != need[C], count-- (变为2)
count-- 只发生在 window[d] == need[d] 时,即移除后刚好不再满足。
六、与第 567 题(字符串的排列)对比
| 维度 | 第 76 题 最小覆盖子串 | 第 567 题 字符串的排列 |
|---|---|---|
| 问题类型 | 找最短覆盖子串 | 检查是否存在排列 |
| 窗口大小 | 可变 | 固定为 t 的长度 |
| 答案 | 最短子串 | 布尔值 |
| 方法 | 滑动窗口,记录最小 | 滑动窗口,固定大小 |
七、复杂度分析
| 方法 | 时间复杂度 | 空间复杂度 | 备注 |
|---|---|---|---|
| 滑动窗口 | O(m + n) | O(1) | 推荐,最多 128 个字符 |
| 暴力枚举 | O(m^2 * n) | O(1) | 会超时 |
| 优化暴力 | O(m * n) | O(n) | 不够优 |
详细分析:
时间复杂度:
right 指针最多移动 m 次
left 指针最多移动 m 次
每次移动都是 O(1) 操作
总计:O(2m) = O(m)
空间复杂度:
need 和 window 最多存储 128 个字符(ASCII)
O(1)(常数空间)
八、边界情况分析
| 情况 | 处理方式 |
|---|---|
| t 比 s 长 | 直接返回 "" |
| t = "a", s = "a" | 返回 "a" |
| t 中有重复字符 | need[c]++ 累加,count 只在达到 need 时才增加 |
| s 中无 t 的字符 | right 遍历完,count 始终 < need.size(),返回 "" |
示例:t 有重复字符
s = "AAAB", t = "AAB"
need = {A:2, B:1}
扩张过程:
遇到 A: window[A]=1, need[A]=2, count=0
遇到 A: window[A]=2, need[A]=2, count=1
遇到 A: window[A]=3, count=1 (已达标不再增加)
遇到 B: window[B]=1, need[B]=1, count=2
count=2 == need.size()=2,窗口有效
收缩:
移除第一个 A: window[A]=2, count=2 (仍满足)
移除第二个 A: window[A]=1, need[A]=2, count-- (变为1)
窗口无效,继续扩张
九、面试追问 FAQ
| 问题 | 回答要点 |
|---|---|
| Q: 为什么 count 只在 window[c] == need[c] 时增加? | 因为只有当 window[c] 达到 need[c] 时,这个字符才算满足条件 |
| Q: 为什么先判断 window[d] == need[d] 再 --? | 如果移除后刚好不满足条件,count 才需要减少 |
| Q: 为什么不直接把 window[d] == need[d] 的情况 --? | 因为只有刚好不满足时才减,如果 window[d] > need[d] 就不需要减 |
| Q: 时间复杂度为什么是 O(m + n)? | right 和 left 最多各移动 m 次,每次 O(1) |
| Q: 空间复杂度为什么是 O(1)? | need 和 window 只存储字符,ASCII 只有 128 个 |
| Q: 如何处理 Unicode 字符? | 用 unordered_map 代替固定数组,复杂度不变 |
十、相关题目
| 题目编号 | 题目名称 | 难度 | 核心差异 |
|---|---|---|---|
| 76 | 最小覆盖子串 | 困难 | 基础题,滑动窗口 |
| 567 | 字符串的排列 | 中等 | 检查是否存在覆盖 |
| 438 | 找到字符串中所有字母异位词 | 中等 | 找所有异位词 |
| 3 | 无重复字符的最长子串 | 中等 | 最长无重复子串 |
| 30 | 串联所有单词的子串 | 困难 | 滑动窗口 + 单词 |
十一、总结
| 要点 | 内容 |
|---|---|
| 核心思想 | 滑动窗口,左右指针扩张收缩 |
| 关键变量 | count 表示满足条件的字符种类数 |
| 判断条件 | count == need.size() 时窗口有效 |
| 更新逻辑 | 每次扩张后检查是否有效,有效则收缩尝试优化 |
| 时间复杂度 | O(m + n)(每个指针最多移动 m 次) |
| 空间复杂度 | O(1)(字符集大小固定为 128) |
| 易错点 | count-- 的时机:只在 window[d] == need[d] 时 |
最小覆盖子串是滑动窗口的经典问题,通过维护一个满足 need 条件的窗口,并不断收缩优化,实现了 O(m + n) 的时间复杂度。