LeetCode 第 76 题:「最小覆盖子串」
字数 3469 2025-10-25 18:48:48
好的,我们这次来详细讲解 LeetCode 第 76 题:「最小覆盖子串」。
这道题是滑动窗口领域的经典难题,综合了哈希表、双指针和滑动窗口等多种技巧,理解它对你掌握复杂的字符串处理非常有帮助。
第一步:理解题目描述
题目
给你一个字符串 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',所以不存在符合条件的子串。
第二步:分析问题与核心思路
我们的目标是:在 s 中找到一个长度最小的连续子串,使得这个子串包含 t 中的所有字符(包括重复字符)。
关键点分析:
- 子串是连续的:这提示我们可以使用滑动窗口技巧。滑动窗口能在
O(n)时间内遍历所有可能的连续子串。 - 需要包含
t的所有字符:这意味着我们需要一种快速的方法来判断当前窗口是否“满足条件”。 - 字符数量要匹配:对于
t中出现多次的字符(如"aa"),窗口内该字符的数量不能少于t中的数量。
核心思路:滑动窗口 + 哈希表(字典)
-
使用两个哈希表(字典):
need:记录t中每个字符需要的数量。例如,t = "AABC",则need['A'] = 2,need['B'] = 1,need['C'] = 1。window:记录当前滑动窗口内各个字符的实际数量。
-
使用双指针维护一个滑动窗口:
left和right指针初始都在s的最左侧(索引 0)。- 我们不断移动右指针
right来扩大窗口,直到窗口包含了t的所有字符。 - 然后,我们移动左指针
left来收缩窗口,在保证窗口仍然包含t所有字符的前提下,尝试找到更小的满足条件的子串。
-
如何判断窗口是否“满足条件”?
- 我们引入一个关键变量
valid。 - 对于
t中的每个字符c,只有当window[c](窗口中的数量)大于等于need[c](需要的数量)时,我们才认为字符c的条件被满足了。 - 当所有在
need中的字符(即t中的字符)的条件都被满足时,valid的值就等于need中不同字符的个数。此时,当前窗口就是一个可行的解。
- 我们引入一个关键变量
第三步:详细步骤拆解(以示例1为例)
s = "ADOBECODEBANC", t = "ABC"
-
初始化:
need = {'A':1, 'B':1, 'C':1}window = {'A':0, 'B':0, 'C':0}(初始为0)left = 0,right = 0valid = 0(当前满足条件的字符数为0)start = 0,len_sub = 无穷大(用于记录最终结果子串的起始位置和长度)
-
步骤一:扩大窗口(移动
right)right指向'A':window['A']变为 1。window['A'] (1) == need['A'] (1),所以valid加 1,变为 1。right指向'D':'D'不在t中,忽略。right指向'O':忽略。right指向'B':window['B']变为 1。window['B'] (1) == need['B'] (1),valid加 1,变为 2。right指向'E':忽略。right指向'C':window['C']变为 1。window['C'] (1) == need['C'] (1),valid加 1,变为 3。- 此时
valid (3) == len(need) (3),窗口[A, D, O, B, E, C]包含了t的所有字符。我们记录下这个解:start=0,len_sub=6-0=6。
-
步骤二:收缩窗口(移动
left)- 现在开始移动
left,试图找到更小的窗口。 left指向'A':将'A'移出窗口,window['A']减为 0。此时window['A'] (0) < need['A'] (1),字符'A'的条件不再满足,valid减 1,变为 2。- 由于
valid (2) < 3,窗口不再满足条件。停止收缩。此时窗口是[D, O, B, E, C]。
- 现在开始移动
-
步骤三:继续扩大窗口(移动
right)right继续右移,经过'O','D','E',都忽略。right指向'B':window['B']变为 2。window['B'] (2)已经大于need['B'] (1),所以valid不变('B'的条件早已满足)。right指向'A':window['A']变为 1。window['A'] (1) == need['A'] (1),valid加 1,变为 3。- 此时
valid又等于 3,窗口[D, O, B, E, C, O, D, E, B, A, N, C]再次满足条件。但当前窗口长度11-1=10大于之前记录的len_sub=6,所以不更新结果。
-
步骤四:再次收缩窗口(移动
left)- 移动
left,移出'D','O','B','E','C','O','D','E'。这些字符要么不在t中,要么移出后数量依然大于等于需求(如'B'从2变1,依然满足),所以valid保持 3。 - 当
left移动到'B'时,窗口变为[B, A, N, C]。此时长度12-8=4小于之前的len_sub=6,我们更新结果:start=8,len_sub=4。 - 继续移动
left,移出'B':window['B']从1变为0。window['B'] (0) < need['B'] (1),valid减 1,变为 2。停止收缩。
- 移动
-
步骤五:最后一步
right已到末尾,无法继续扩大。算法结束。
-
最终结果:
- 我们记录的最小窗口起始位置是
start=8,长度是len_sub=4。 - 所以子串是
s[8:8+4],即"BANC"。
- 我们记录的最小窗口起始位置是
第四步:代码实现(Python)
import collections
class Solution:
def minWindow(self, s: str, t: str) -> str:
# 初始化 need 和 window 字典
need = collections.defaultdict(int)
window = collections.defaultdict(int)
# 构建 need 字典,记录 t 中每个字符的需求量
for c in t:
need[c] += 1
# 初始化双指针和关键变量
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
# 如果某个字符的数量达到了需求,则 valid 加一
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:
# 如果移出前刚好满足,移出后就不满足了
if window[d] == need[d]:
valid -= 1
window[d] -= 1
# 返回结果
return "" if min_len == float('inf') else s[start:start+min_len]
第五步:复杂度分析
- 时间复杂度:O(|s| + |t|)
- 左右指针
left和right各自遍历字符串s一次,每个字符最多被进入和离开窗口各一次,所以是O(|s|)。 - 初始化
need字典需要遍历t,是O(|t|)。
- 左右指针
- 空间复杂度:O(|t|)
- 主要是
need和window字典的空间,最坏情况下t中每个字符都不同,所以是O(|t|)。
- 主要是
总结
LeetCode 76 「最小覆盖子串」 的精髓在于:
- 滑动窗口框架:使用
left和right指针遍历所有可能的子串。 - 哈希表记录需求:用
need和window字典来精确跟踪字符数量。 valid计数器:这是判断窗口是否满足条件的关键技巧,它避免了每次都比较整个字典,将判断操作优化到 O(1)。
掌握这个模板,你就能解决一大类子串搜索/匹配问题。希望这个循序渐进的讲解能让你彻底理解这道题!