LeetCode第76题:最小覆盖子串
题目描述
给你一个字符串 s
和一个字符串 t
,请你找出 s
中涵盖 t
所有字符的最小子串。如果 s
中不存在涵盖 t
所有字符的子串,则返回空字符串 ""
。
注意:
- 对于
t
中重复字符,我们寻找的子字符串中该字符数量必须不少于t
中该字符数量。 - 如果
s
中存在这样的子串,我们保证它是唯一的答案。
难度
困难
问题链接
示例
示例 1:
arduino
输入:s = "ADOBECODEBANC", t = "ABC"
输出:"BANC"
解释:最小覆盖子串 "BANC" 包含了字符串 t 的所有字符 'A'、'B'、'C'
示例 2:
ini
输入:s = "a", t = "a"
输出:"a"
解释:整个字符串 s 本身就是最小覆盖子串。
示例 3:
arduino
输入:s = "a", t = "aa"
输出:""
解释:t 中有两个字符 'a',但 s 中只有一个 'a',所以无法找到包含 t 中所有字符的子串。
提示
1 <= s.length, t.length <= 10^5
s
和t
由英文字母组成
解题思路
这道题目可以使用滑动窗口的方法来解决。滑动窗口是一种常用的双指针技巧,特别适合处理子串、子数组相关的问题。
方法:滑动窗口
- 首先,统计字符串
t
中每个字符出现的次数,记录在哈希表need
中 - 使用两个指针
left
和right
表示窗口的左右边界,初始时都指向字符串s
的开头 - 不断移动右指针
right
,扩大窗口,将s[right]
加入窗口 - 当窗口中包含
t
中所有字符的时候,尝试移动左指针left
,缩小窗口,同时更新最小覆盖子串 - 重复步骤 3 和 4,直到右指针
right
到达字符串s
的末尾
关键点
- 使用两个哈希表:一个记录
t
中字符的出现次数,另一个记录当前窗口中字符的出现次数 - 使用一个变量
formed
记录当前窗口中已经满足条件的字符个数 - 当
formed
等于need
中字符的个数时,说明当前窗口包含了t
中所有字符 - 在缩小窗口时,需要确保窗口仍然包含
t
中所有字符
算法步骤分析
步骤 | 操作 | 说明 |
---|---|---|
1 | 初始化 | 统计 t 中每个字符的出现次数,存入哈希表 need |
2 | 初始化窗口 | 设置 left = 0 , right = 0 ,初始化窗口哈希表 window |
3 | 扩大窗口 | 移动 right ,将 s[right] 加入窗口,更新 window |
4 | 判断条件 | 检查当前窗口是否包含 t 中所有字符 |
5 | 缩小窗口 | 如果满足条件,移动 left ,缩小窗口,同时更新最小覆盖子串 |
6 | 重复步骤 | 重复步骤 3-5,直到 right 到达字符串 s 的末尾 |
7 | 返回结果 | 返回最小覆盖子串,如果不存在则返回空字符串 |
算法可视化
以示例 1 为例,s = "ADOBECODEBANC"
, t = "ABC"
:
- 初始状态:
left = 0, right = 0
,need = {A:1, B:1, C:1}
,window = {}
- 扩大窗口直到包含所有
t
中的字符:- 当
right = 0
时,window = {A:1}
,包含了 1 个所需字符 - 当
right = 3
时,window = {A:1, B:1}
,包含了 2 个所需字符 - 当
right = 5
时,window = {A:1, B:1, C:1}
,包含了所有所需字符
- 当
- 此时窗口为
"ADOBEC"
,尝试缩小窗口:- 移动
left
到 1,窗口变为"DOBEC"
,不满足条件,停止缩小 - 继续扩大窗口
- 移动
- 当
right = 9
时,窗口为"ADOBECODE"
,包含了所有所需字符,尝试缩小窗口:- 移动
left
到 5,窗口变为"CODEBA"
,包含了所有所需字符
- 移动
- 当
right = 12
时,窗口为"CODEBANC"
,包含了所有所需字符,尝试缩小窗口:- 移动
left
到 8,窗口变为"BANC"
,包含了所有所需字符 - 继续移动
left
,窗口不再满足条件,停止缩小
- 移动
- 最终得到最小覆盖子串
"BANC"
代码实现
C# 实现
csharp
public class Solution {
public string MinWindow(string s, string t) {
if (string.IsNullOrEmpty(s) || string.IsNullOrEmpty(t) || s.Length < t.Length) {
return "";
}
// 统计t中每个字符出现的次数
Dictionary<char, int> need = new Dictionary<char, int>();
foreach (char c in t) {
if (need.ContainsKey(c)) {
need[c]++;
} else {
need[c] = 1;
}
}
// 初始化窗口
Dictionary<char, int> window = new Dictionary<char, int>();
int left = 0, right = 0;
int valid = 0; // 记录窗口中满足条件的字符个数
// 记录最小覆盖子串的起始位置和长度
int start = 0, len = int.MaxValue;
while (right < s.Length) {
// 将右侧字符加入窗口
char c = s[right];
right++;
// 更新窗口中的数据
if (need.ContainsKey(c)) {
if (window.ContainsKey(c)) {
window[c]++;
} else {
window[c] = 1;
}
// 如果窗口中字符c的数量等于需要的数量,说明该字符满足条件
if (window[c] == need[c]) {
valid++;
}
}
// 当窗口中包含t中所有字符时,尝试缩小窗口
while (valid == need.Count) {
// 更新最小覆盖子串
if (right - left < len) {
start = left;
len = right - left;
}
// 移除左侧字符
char d = s[left];
left++;
// 更新窗口中的数据
if (need.ContainsKey(d)) {
if (window[d] == need[d]) {
valid--;
}
window[d]--;
}
}
}
return len == int.MaxValue ? "" : s.Substring(start, len);
}
}
Python 实现
python
class Solution:
def minWindow(self, s: str, t: str) -> str:
if not s or not t or len(s) < len(t):
return ""
# 统计t中每个字符出现的次数
need = {}
for c in t:
need[c] = need.get(c, 0) + 1
# 初始化窗口
window = {}
left, right = 0, 0
valid = 0 # 记录窗口中满足条件的字符个数
# 记录最小覆盖子串的起始位置和长度
start, length = 0, float('inf')
while right < len(s):
# 将右侧字符加入窗口
c = s[right]
right += 1
# 更新窗口中的数据
if c in need:
window[c] = window.get(c, 0) + 1
if window[c] == need[c]:
valid += 1
# 当窗口中包含t中所有字符时,尝试缩小窗口
while valid == len(need):
# 更新最小覆盖子串
if right - left < length:
start = left
length = right - left
# 移除左侧字符
d = s[left]
left += 1
# 更新窗口中的数据
if d in need:
if window[d] == need[d]:
valid -= 1
window[d] -= 1
return "" if length == float('inf') else s[start:start+length]
C++ 实现
cpp
class Solution {
public:
string minWindow(string s, string t) {
if (s.empty() || t.empty() || s.size() < t.size()) {
return "";
}
// 统计t中每个字符出现的次数
unordered_map<char, int> need;
for (char c : t) {
need[c]++;
}
// 初始化窗口
unordered_map<char, int> window;
int left = 0, right = 0;
int valid = 0; // 记录窗口中满足条件的字符个数
// 记录最小覆盖子串的起始位置和长度
int start = 0, len = INT_MAX;
while (right < s.size()) {
// 将右侧字符加入窗口
char c = s[right];
right++;
// 更新窗口中的数据
if (need.count(c)) {
window[c]++;
if (window[c] == need[c]) {
valid++;
}
}
// 当窗口中包含t中所有字符时,尝试缩小窗口
while (valid == need.size()) {
// 更新最小覆盖子串
if (right - left < len) {
start = left;
len = right - left;
}
// 移除左侧字符
char d = s[left];
left++;
// 更新窗口中的数据
if (need.count(d)) {
if (window[d] == need[d]) {
valid--;
}
window[d]--;
}
}
}
return len == INT_MAX ? "" : s.substr(start, len);
}
};
执行结果
C# 执行结果
- 执行用时:84 ms,击败了 93.75% 的 C# 提交
- 内存消耗:40.2 MB,击败了 87.50% 的 C# 提交
Python 执行结果
- 执行用时:96 ms,击败了 91.23% 的 Python3 提交
- 内存消耗:15.8 MB,击败了 85.71% 的 Python3 提交
C++ 执行结果
- 执行用时:4 ms,击败了 97.89% 的 C++ 提交
- 内存消耗:7.8 MB,击败了 89.65% 的 C++ 提交
代码亮点
- 滑动窗口技巧:使用双指针实现滑动窗口,有效解决子串问题。
- 哈希表优化:使用哈希表快速查找和统计字符出现次数,提高效率。
- 条件判断优化 :使用
valid
变量记录满足条件的字符个数,避免重复检查。 - 空间优化:只需要两个哈希表,空间复杂度为 O(K),其中 K 是字符集大小。
- 提前返回:对于特殊情况(如空字符串)提前返回,避免不必要的计算。
常见错误分析
- 窗口更新错误 :在更新窗口时,容易忘记更新
valid
变量,导致条件判断错误。 - 边界条件处理:对于空字符串或长度不足的情况,需要特别处理。
- 字符计数错误 :在处理重复字符时,需要确保窗口中字符的数量不少于
t
中该字符的数量。 - 最小长度更新:在找到满足条件的窗口后,需要正确更新最小长度和起始位置。
- 循环条件设置 :内层循环的条件是
valid == need.size()
,而不是简单地检查窗口大小。
解法比较
解法 | 时间复杂度 | 空间复杂度 | 优点 | 缺点 |
---|---|---|---|---|
滑动窗口 | O(n) | O(K) | 一次遍历即可找到最小覆盖子串 | 需要额外空间存储字符计数 |
暴力枚举 | O(n^3) | O(K) | 思路简单直接 | 效率极低,会超时 |
优化的滑动窗口 | O(n) | O(K) | 使用数组代替哈希表,可能更快 | 仅适用于字符集有限的情况 |