好的,我们这次来详细讲解 LeetCode 第 76 题:「最小覆盖子串」。
这道题是滑动窗口类问题中非常经典且有一定难度的题目,非常适合用来深入理解滑动窗口算法的思想。
1. 题目描述
题目:给你一个字符串 s 、一个字符串 t 。返回 s 中涵盖 t 所有字符的最小子串。如果 s 中不存在涵盖 t 所有字符的子串,则返回空字符串 "" 。
注意:
- 对于
t中重复的字符,在子串中该字符的数量必须不少于t中该字符的数量。 - 如果存在这样的子串,我们保证它是唯一的答案。
示例 1:
输入:s = "ADOBECODEBANC", t = "ABC"
输出:"BANC"
解释:最小覆盖子串 "BANC" 包含 'A', 'B', 'C' 各一个,并且它是满足条件的最短子串。
示例 2:
输入:s = "a", t = "a"
输出:"a"
示例 3:
输入:s = "a", t = "aa"
输出:""
解释:t中有两个 'a',但s中只有一个 'a',所以无法覆盖,返回空字符串。
2. 问题分析与核心思路
我们先来一步步拆解这个问题,理解其中的难点。
1. 目标是什么?
我们要在长字符串 s 中找到一个连续的子串,这个子串必须包含字符串 t 中的所有字符(包括重复的字符,并且数量要足够)。在所有满足条件的子串中,我们要找到长度最短的那个。
2. 关键难点是什么?
- 字符数量要求:不仅仅是字符种类要覆盖,数量也要覆盖。例如
t = "AAB",那么子串中至少需要包含两个'A'和一个'B'。 - 效率要求:如果使用暴力解法,枚举
s的所有子串,再检查每个子串是否包含t的所有字符,时间复杂度会达到 O(n²) 甚至更高,对于较长的字符串会超时。
3. 核心思路:滑动窗口
滑动窗口算法是解决这类「连续子串」问题的利器。它的基本思想是:
- 我们使用两个指针
left和right来表示窗口的左右边界,初始时都在s的最左侧。 - 我们不断扩大
right指针,从而扩展窗口,直到窗口包含了t的所有字符。 - 此时,我们开始收缩
left指针,从而缩小窗口,以找到能满足条件的最小窗口。在收缩的过程中,我们不断记录当前的最小窗口长度和起始位置。 - 当窗口不再满足条件时,我们再次扩大
right指针,如此循环,直到right到达s的末尾。
4. 如何判断窗口是否包含了 t 的所有字符?
这是算法的核心。我们需要一个高效的数据结构来记录和比较字符需求。通常使用哈希表(在 Python 中可以用字典或 collections.Counter):
need:记录t中每个字符需要的数量。例如t = "AAB",则need['A'] = 2,need['B'] = 1。window:记录当前窗口中,属于t的字符的当前数量。
但我们不能每次都遍历两个哈希表来比较,那样效率很低。我们引入一个关键变量:valid。
valid:计数器,表示当前窗口中已经满足数量要求的字符种类数。- 当
window中某个字符的数量 等于need中该字符的需求数量时,valid就加 1。 - 当
valid等于need中不同字符的种类数(即len(need))时,就说明当前窗口已经完全覆盖了t。
- 当
3. 详细解题步骤
我们以 s = "ADOBECODEBANC", t = "ABC" 为例,一步步走一遍。
步骤 0:初始化
- 创建
need字典:{'A': 1, 'B': 1, 'C': 1} - 创建
window字典:初始为空{} valid = 0(因为还没有任何字符满足数量)left = 0,right = 0start = 0(记录最小覆盖子串的起始索引)min_len = float('inf')(记录最小长度,初始为一个极大值)
步骤 1:扩大窗口(移动 right)
循环条件:right < len(s)
c = s[right]是将要进入窗口的字符('A')。right右移。- 如果
c是t中需要的字符(即c在need中),则更新window[c]的数量。 - 检查
window[c]是否已经等于need[c]。如果相等,说明字符c的数量要求已满足,valid++。
此时状态:
s:A D O B E C O D E B A N C
^
l, r (初始)
r指向'A'->window = {'A': 1}。window['A'] == need['A'](1==1) ->valid = 1。r右移,指向'D'->'D'不在need中,忽略。valid=1。r指向'O'-> 忽略。valid=1。r指向'B'->window = {'A':1, 'B':1}。window['B'] == need['B'](1==1) ->valid = 2。r指向'E'-> 忽略。valid=2。r指向'C'->window = {'A':1, 'B':1, 'C':1}。window['C'] == need['C'](1==1) ->valid = 3。
关键点:此时 valid (3) == len(need) (3),说明窗口 [A, D, O, B, E, C] 即 "ADOBEC" 已经包含了 t 的所有字符。现在进入收缩阶段。
步骤 2:收缩窗口(移动 left)
循环条件:valid == len(need) (当窗口仍然满足条件时,继续收缩以寻找更小的窗口)
-
检查当前窗口
[left, right)的长度是否小于min_len。如果是,更新min_len和start。- 当前窗口长度 =
right - left= 6 - 0 = 6。 min_len从无穷大更新为 6。start更新为left,即 0。
- 当前窗口长度 =
-
d = s[left]是将要移出窗口的字符('A')。 -
left左移。 -
如果
d是t中需要的字符:- 需要检查:在移出
d之前,window[d]是否等于need[d]?如果是,那么移出后就不满足了,所以valid要减 1。 - 然后更新
window[d]的数量(减 1)。
- 需要检查:在移出
此时状态:
- 当前
d = 'A'。移出前window['A'](1) == need['A'](1),所以valid减 1,变为 2。 - 然后
window['A']减 1,变为 0。 left右移,指向'D'。
现在 valid (2) != len(need) (3),窗口 [D, O, B, E, C] 不再满足条件(缺少了 'A')。收缩停止,回到步骤 1,继续扩大 right。
步骤 3:继续扩大窗口
r指向'O'-> 忽略。r指向'D'-> 忽略。r指向'E'-> 忽略。r指向'B'->window = {'A':0, 'B':2, 'C':1}。window['B'](2) > need['B'](1),valid不变(仍然是2,因为'A'不满足,'B'和'C'满足)。r指向'A'->window = {'A':1, 'B':2, 'C':1}。window['A'](1) == need['A'](1)->valid = 3。
关键点:valid 再次等于 3,窗口 [D, O, B, E, C, O, D, E, B, A] 即 "DOBECODEBA" 又满足了条件。再次进入收缩阶段。
步骤 4:再次收缩窗口
- 当前窗口长度 = 11 - 1 = 10。大于
min_len=6,不更新。 - 移出
'D'(不在need中),left右移。 - 移出
'O'(不在need中),left右移。 - ... 持续移出,直到遇到一个
need中的字符'B'。 - 在移出
'B'之前,检查window['B'](2) > need['B'](1),移出后window['B']=1,仍然满足,所以valid不变(还是3)。 - 继续收缩,移出
'E','C','O','D','E'。 - 当移出下一个
'B'时,检查:移出前window['B'](1) == need['B'](1),移出后window['B']=0,不满足了,所以valid减 1,变为 2。 - 收缩停止。
步骤 5:最后一段
- 继续扩大
r,指向'N'(忽略),最后指向'C'。 window['C']变为 2,但valid没变(因为'B'不满足)。r到达末尾。循环结束。
最终:
- 我们记录的最小窗口是
start=9,min_len=4。 - 所以结果是
s[9:9+4],即 "BANC"。
4. 代码实现(Python)
import collections
class Solution:
def minWindow(self, s: str, t: str) -> str:
# 初始化 need 和 window 字典
need = collections.Counter(t)
window = collections.Counter()
left, right = 0, 0
valid = 0 # 满足数量要求的字符种类数
# 记录最小覆盖子串的起始索引和长度
start = 0
min_len = float('inf')
while right < len(s):
# c 是将移入窗口的字符
c = s[right]
# 右移窗口
right += 1
# 进行窗口内数据的一系列更新
if c in need:
window[c] += 1
if window[c] == need[c]:
valid += 1
# 判断左侧窗口是否要收缩
while valid == len(need):
# 在这里更新最小覆盖子串
if right - left < min_len:
start = left
min_len = right - left
# d 是将移出窗口的字符
d = s[left]
# 左移窗口
left += 1
# 进行窗口内数据的一系列更新
if d in need:
# 注意:这里要先判断,再减少 window[d]
if window[d] == need[d]:
valid -= 1
window[d] -= 1
# 返回最小覆盖子串
return "" if min_len == float('inf') else s[start:start+min_len]
# 测试
solution = Solution()
print(solution.minWindow("ADOBECODEBANC", "ABC")) # 输出 "BANC"
print(solution.minWindow("a", "a")) # 输出 "a"
print(solution.minWindow("a", "aa")) # 输出 ""
5. 复杂度分析
- 时间复杂度:O(n)。虽然有嵌套的
while循环,但字符串s中的每个字符最多只会被left和right指针各访问一次,所以时间复杂度是线性的。 - 空间复杂度:O(k)。其中 k 是字符串
t的字符集大小。我们使用了两个哈希表来存储字符计数,空间消耗取决于t中不同字符的数量。
6. 总结
「最小覆盖子串」是滑动窗口算法的巅峰应用之一。它巧妙地通过 valid 变量来高效判断窗口的满足条件,避免了每次检查整个 t 的复杂度。
核心要点回顾:
- 用
need和window两个哈希表分别记录需求和当前状态。 - 引入
valid计数器,只有当某个字符的数量恰好满足时才增加,当它变得不满足时才减少。 - 算法流程:先扩大,满足后收缩,不满足再扩大,循环往复。
- 在收缩过程中更新答案,因为收缩时窗口是满足条件的,并且我们是在寻找最小的满足条件的窗口。
希望这个从零开始的详细讲解能帮助你彻底理解这道题!