本文针对 LeetCode 76. 最小覆盖子串 问题,提供完整的解题思路、多解法代码实现及深度解析,覆盖暴力解法、滑动窗口(双指针)最优解法,重点突破"O(m + n) 时间复杂度"的进阶要求,帮助开发者理解问题本质、掌握滑动窗口的灵活应用,规避常见误区。本题核心考点为滑动窗口的动态维护、哈希表的计数应用,是面试高频中等难度题目,适配算法进阶练习。
一、题目核心解读
1.1 题目描述(精简版)
给定两个字符串 s(长度 m)和 t(长度 n),返回 s 中包含 t 所有字符(包括重复字符)的最短窗口子串;若不存在这样的子串,返回空字符串 ""。测试用例保证答案唯一。
核心约束与进阶要求:
-
数据范围:1 ≤ m, n ≤ 10⁵,s 和 t 仅由英文字母组成(区分大小写,如 'A' 和 'a' 视为不同字符)。
-
关键要求:子串需"覆盖 t 的所有字符",包括重复出现的字符(如 t = "aa",子串需包含至少两个 'a')。
-
进阶要求:设计 O(m + n) 时间复杂度的算法(核心考点,也是实际面试中重点考察的方向)。
1.2 示例解析(直观理解)
- 示例 1:输入 s = "ADOBECODEBANC", t = "ABC"
解析:t 包含 'A'、'B'、'C' 各1个,s 中包含这三个字符的子串有 "ADOBEC"(长度6)、"BECODEBA"(长度7)、"BANC"(长度4),其中最短的是 "BANC",输出 "BANC"。
- 示例 2:输入 s = "a", t = "a"
解析:s 本身就是包含 t 所有字符的子串,且长度最短,输出 "a"。
- 示例 3:输入 s = "a", t = "aa"
解析:t 要求包含两个 'a',但 s 只有一个 'a',无法满足,输出 ""。
关键观察:本题的核心是"找到最短的子串,满足子串中 t 的每个字符(含重复)的出现次数 ≥ t 中对应字符的出现次数"。暴力解法会因时间复杂度过高超时,滑动窗口是最优思路,可实现 O(m + n) 时间复杂度。
二、解题思路深度剖析(2种解法,从暴力到最优)
本题的核心矛盾是"时间复杂度"与"子串有效性判断":暴力解法通过枚举所有子串判断有效性,时间复杂度过高;滑动窗口通过双指针动态维护子串范围,结合哈希表计数判断有效性,实现高效求解。以下逐一解析每种解法的思路、优缺点及适用场景。
2.1 解法1:暴力解法(基础思路,适合理解问题本质)
2.1.1 思路核心
枚举 s 中所有可能的子串,判断每个子串是否包含 t 的所有字符(含重复),记录满足条件的最短子串。
具体步骤:
-
用哈希表(字典)统计 t 中每个字符的出现次数(记为 t_count),作为判断子串有效性的标准。
-
枚举所有子串:遍历 s 的所有起始索引 i,从 i 开始向后扩展终点索引 j,得到子串 s[i...j]。
-
判断子串有效性:用哈希表统计子串 s[i...j] 中每个字符的出现次数(记为 window_count),若 window_count 中所有 t 中的字符的出现次数 ≥ t_count 中的对应次数,则该子串有效。
-
记录最短有效子串:不断更新满足条件的子串长度,保留最短的子串。
2.1.2 优缺点
-
优点:思路简单、逻辑直观,适合新手理解"覆盖子串"的核心要求,无需复杂的数据结构技巧。
-
缺点:时间复杂度极高,为 O(m² × n)。枚举所有子串的时间为 O(m²),每个子串的有效性判断需要遍历子串和 t(O(m + n)),当 m=10⁵ 时,运算量远超时间限制,必然超时,仅适合 m ≤ 100 的小数据量。
2.2 解法2:滑动窗口(双指针)解法(最优解法,满足进阶要求)
2.2.1 思路核心
利用"滑动窗口"(双指针 left、right 界定窗口范围)动态维护子串,结合两个哈希表(t_count 统计 t 的字符计数,window_count 统计当前窗口的字符计数),通过"扩大窗口找有效子串、缩小窗口找最短子串"的思路,实现 O(m + n) 时间复杂度。
核心原则(滑动窗口维护规则):
-
初始化:用 t_count 统计 t 中每个字符的出现次数;window_count 初始为空,用于统计当前窗口内的字符次数;left 指针初始为 0,用于界定窗口左边界;min_len 记录最短有效子串的长度(初始为无穷大);start 记录最短有效子串的起始索引(初始为 0)。
-
扩大窗口(right 指针右移):从 s 的起始位置开始,right 指针逐步右移,将当前字符 s[right] 加入 window_count(若字符在 t_count 中,才更新 window_count,否则无需统计,因为不影响子串有效性)。
-
判断窗口有效性:当 window_count 中所有 t 的字符的出现次数 ≥ t_count 中的对应次数时,说明当前窗口是有效子串,此时尝试缩小窗口,寻找更短的有效子串。
-
缩小窗口(left 指针右移):在窗口有效的前提下,逐步右移 left 指针,缩小窗口范围,同时更新 window_count(若当前字符在 t_count 中,减少其计数);每缩小一次,判断窗口是否仍有效,若有效则更新 min_len 和 start,直到窗口无效。
-
重复步骤 2-4,直到 right 指针遍历完 s,最终根据 min_len 是否为无穷大,返回最短子串或空字符串。
2.2.2 关键优化:有效性判断的高效实现
若每次判断窗口有效性都遍历 t_count 对比 window_count,会导致时间复杂度上升至 O(m × n),无法满足进阶要求。因此引入"匹配计数器"(match),优化判断逻辑:
-
match 初始为 0,代表当前窗口中,满足"出现次数 ≥ t_count 对应次数"的字符个数。
-
当 window_count 中某字符的计数达到 t_count 中该字符的计数时,match 加 1(仅加 1 一次,避免重复计数)。
-
当 match 等于 t_count 的长度(即 t 中所有不同字符都满足计数要求)时,窗口有效,无需遍历对比,直接进入缩小窗口阶段。
该优化将有效性判断的时间从 O(n) 降至 O(1),确保整体时间复杂度为 O(m + n)。
2.2.3 思路验证(结合示例1)
s = "ADOBECODEBANC", t = "ABC",t_count = {'A':1, 'B':1, 'C':1},match 初始为 0,min_len = 无穷大,start = 0,left = 0。
-
right=0(s[0]='A'):window_count['A']=1,等于 t_count['A'],match=1 → 窗口无效(match≠3),继续扩大。
-
right=1(s[1]='D'):不在 t_count 中,不更新 window_count → 窗口无效。
-
right=2(s[2]='O'):不在 t_count 中 → 窗口无效。
-
right=3(s[3]='B'):window_count['B']=1,等于 t_count['B'],match=2 → 窗口无效。
-
right=4(s[4]='E'):不在 t_count 中 → 窗口无效。
-
right=5(s[5]='C'):window_count['C']=1,等于 t_count['C'],match=3 → 窗口有效(ADOBEC,长度6)。更新 min_len=6,start=0。开始缩小窗口:
left=0(s[0]='A'):window_count['A']=0,小于 t_count['A'],match=2 → 窗口无效,停止缩小,left=1。
-
继续扩大 right 至 10(s[10]='B')、right=11(s[11]='A')、right=12(s[12]='N')、right=13(s[13]='C'),过程中不断维护 window_count 和 match,当 right=13 时,窗口为 "BANC"(left=10),match=3,有效,长度4,更新 min_len=4,start=10。
-
最终最短子串为 s[10:14] = "BANC",与示例一致。
2.2.4 优缺点
-
优点:时间复杂度 O(m + n),t_count 统计 t 的字符需 O(n),right 和 left 指针各遍历 s 一次需 O(m),整体高效,满足进阶要求;空间复杂度 O(n),哈希表存储 t 的字符计数,最多存储 t 中所有不同字符(不超过26×2=52个,因为英文字母分大小写),空间开销固定。
-
缺点:思路相对复杂,需同时维护双指针、两个哈希表和匹配计数器,容易出现细节错误(如窗口缩小时机、match 计数逻辑、字符是否在 t 中的判断)。
2.3 常见误区提醒
-
误区1:忽略 t 中重复字符的要求(如示例3,t="aa",子串需包含至少两个 'a'),仅判断字符是否存在,不判断出现次数,导致错误。
-
误区2:有效性判断未优化,每次都遍历 t_count 对比 window_count,导致时间复杂度过高,无法通过大数据量测试。
-
误区3:缩小窗口时,未判断当前字符是否在 t_count 中,就更新 window_count,导致计数混乱,影响 match 判断。
-
误区4:未记录最短子串的起始索引,仅记录长度,最终无法正确截取子串;或初始 min_len 设为 0,导致无法区分"无有效子串"和"有效子串长度为0"(实际不可能)。
-
误区5:区分大小写错误(如将 'A' 和 'a' 视为同一字符),导致测试用例失败。
三、代码实现(Python,2种解法,重点最优解法)
严格按照题目要求的类和方法名格式编写(注:题目要求方法名为 findKthBit,此处按题目实际需求调整为 minWindow,贴合题目场景,避免混淆),代码简洁高效,附带详细注释,适配 Python 语法规范。
3.1 解法1:暴力解法(仅供理解,不推荐提交)
python
from collections import defaultdict
class Solution:
def minWindow(self, s: str, t: str) -> str:
m, n = len(s), len(t)
# 若t长度大于s,直接返回空字符串(不可能有覆盖子串)
if n > m:
return ""
# 统计t中每个字符的出现次数
t_count = defaultdict(int)
for c in t:
t_count[c] += 1
# 初始化最短子串相关参数
min_len = float('inf')
result = ""
# 枚举所有子串的起始索引i
for i in range(m):
# 统计当前子串的字符出现次数
window_count = defaultdict(int)
# 扩展子串终点索引j
for j in range(i, m):
c = s[j]
if c in t_count:
window_count[c] += 1
# 判断当前子串是否有效
valid = True
for key in t_count:
if window_count.get(key, 0) < t_count[key]:
valid = False
break
# 若有效,更新最短子串
if valid:
current_len = j - i + 1
if current_len < min_len:
min_len = current_len
result = s[i:j+1]
# 找到当前i对应的最短有效子串,无需继续扩展j(j越大,子串越长)
break
return result
3.2 解法2:滑动窗口(双指针)解法(最优,满足进阶要求,推荐面试使用)
python
from collections import defaultdict
class Solution:
def minWindow(self, s: str, t: str) -> str:
m, n = len(s), len(t)
# 边界判断:t比s长,直接返回空
if n > m:
return ""
# 1. 统计t中每个字符的出现次数
t_count = defaultdict(int)
for c in t:
t_count[c] += 1
# 2. 初始化滑动窗口相关参数
window_count = defaultdict(int) # 当前窗口的字符计数
match = 0 # 匹配计数器:满足计数要求的字符个数
left = 0 # 窗口左边界
min_len = float('inf') # 最短有效子串长度
start = 0 # 最短有效子串的起始索引
# 3. 扩大窗口(right指针右移)
for right in range(m):
c = s[right]
# 仅统计t中存在的字符
if c in t_count:
window_count[c] += 1
# 当该字符的计数达到t中的要求时,match加1(仅加一次)
if window_count[c] == t_count[c]:
match += 1
# 4. 窗口有效(match等于t中不同字符的个数),缩小窗口
while match == len(t_count):
# 更新最短有效子串
current_len = right - left + 1
if current_len< min_len:
min_len = current_len
start = left
# 左指针右移,缩小窗口
left_c = s[left]
if left_c in t_count:
# 若当前字符是t中的字符,更新window_count
window_count[left_c] -= 1
# 若计数低于t中的要求,match减1(窗口变为无效)
if window_count[left_c] < t_count[left_c]:
match -= 1
# 左指针右移
left += 1
# 5. 判断是否存在有效子串,返回结果
return s[start:start+min_len] if min_len != float('inf') else ""
四、代码逐行解析(重点讲解最优解法)
4.1 边界判断与初始化
python
m, n = len(s), len(t)
if n > m:
return ""
t_count = defaultdict(int)
for c in t:
t_count[c] += 1
window_count = defaultdict(int)
match = 0
left = 0
min_len = float('inf')
start = 0
解释:
-
m、n 分别存储 s 和 t 的长度,若 t 比 s 长,不可能存在覆盖子串,直接返回空字符串。
-
t_count:用默认字典统计 t 中每个字符的出现次数(如 t="ABC",t_count = {'A':1, 'B':1, 'C':1})。
-
window_count:统计当前滑动窗口内,t 中存在的字符的出现次数(无关字符不统计)。
-
match:匹配计数器,记录当前窗口中"出现次数 ≥ t_count 对应次数"的字符个数,当 match == len(t_count) 时,窗口有效。
-
left:滑动窗口的左边界,初始为 0。
-
min_len:记录最短有效子串的长度,初始为无穷大(方便后续更新)。
-
start:记录最短有效子串的起始索引,用于最终截取子串。
4.2 扩大窗口(right指针右移)
python
for right in range(m):
c = s[right]
if c in t_count:
window_count[c] += 1
if window_count[c] == t_count[c]:
match += 1
解释:
-
right 指针从 0 遍历到 m-1,逐步扩大窗口范围,每次获取当前字符 c = s[right]。
-
若 c 是 t 中的字符(存在于 t_count 中),才更新 window_count,避免统计无关字符,节省空间和时间。
-
当 window_count[c] 等于 t_count[c] 时,说明该字符的计数满足要求,match 加 1(仅加一次,避免重复计数,例如 t 中 'A' 出现2次,window_count['A'] 从1变为2时,match 才加1)。
4.3 缩小窗口(left指针右移)
python
while match == len(t_count):
current_len = right - left + 1
if current_len < min_len:
min_len = current_len
start = left
left_c = s[left]
if left_c in t_count:
window_count[left_c] -= 1
if window_count[left_c] < t_count[left_c]:
match -= 1
left += 1
解释:
-
while 循环条件:match == len(t_count),即当前窗口有效(包含 t 所有字符,且满足重复次数要求)。
-
更新最短子串:计算当前窗口长度 current_len,若比 min_len 小,更新 min_len 和 start(记录新的最短子串起始位置)。
-
left_c = s[left]:获取当前窗口左边界的字符,准备右移左边界,缩小窗口。
-
若 left_c 是 t 中的字符,更新 window_count[left_c](减1),因为该字符将离开窗口。
-
若 window_count[left_c] 减1后小于 t_count[left_c],说明该字符的计数不再满足要求,match 减1,窗口变为无效,退出 while 循环,继续扩大窗口。
-
left 指针右移,缩小窗口范围,尝试寻找更短的有效子串。
4.4 结果返回
python
return s[start:start+min_len] if min_len != float('inf') else ""
解释:
-
若 min_len 仍为无穷大,说明没有找到有效子串,返回空字符串 ""。
-
否则,根据 start 和 min_len 截取 s 中的最短有效子串,返回该子串。
五、性能分析与扩展
5.1 两种解法性能对比
| 解法 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 暴力解法 | O(m² × n) | O(n)(哈希表存储 t 字符计数) | 小数据量(m ≤ 100),适合理解问题本质 |
| 滑动窗口解法 | O(m + n) | O(n)(哈希表存储 t 字符计数) | 大数据量(m ≤ 10⁵),面试最优选择,满足进阶要求 |
5.2 边界场景适配
本题需重点适配4种边界场景,确保代码健壮性:
-
场景1:t 长度大于 s → 返回 ""(代码已适配,开头直接判断)。
-
场景2:s 与 t 长度相等 → 若 s == t,返回 s;否则返回 ""(代码会正常判断,窗口扩大到 right=m-1 时,若有效则返回 s,否则返回 "")。
-
场景3:t 中存在重复字符(如 t="aa") → 代码通过 t_count 统计重复次数,window_count 需满足计数要求,避免遗漏重复字符。
-
场景4:s 中存在多个有效子串,且答案唯一(测试用例保证) → 代码通过不断更新 min_len 和 start,确保返回最短的有效子串。
5.3 扩展思考(面试延伸)
- 如何优化空间复杂度?
答:由于 s 和 t 仅由英文字母组成(区分大小写),可使用数组替代哈希表(如用大小为 128 的数组,对应 ASCII 码),减少哈希表的开销,空间复杂度仍为 O(1)(固定大小数组)。例如:t_count = [0] * 128,t_count[ord©] += 1(ord© 得到字符的 ASCII 码)。
- 若 s 和 t 包含非英文字符(如中文、符号),如何调整解法?
答:核心思路不变,只需将哈希表(defaultdict)保留,无需改为数组,因为非英文字符的 ASCII 码范围不确定,数组无法覆盖,哈希表可灵活存储任意字符的计数。
- 若题目要求"找到所有最短覆盖子串"(而非唯一答案),如何修改代码?
答:将 start 改为列表,存储所有最短有效子串的起始索引,当 current_len == min_len 时,将 start 加入列表;最终根据列表中的起始索引,截取所有最短子串并返回。
- 滑动窗口的核心思想是什么?
答:滑动窗口的核心是"用双指针界定窗口范围,动态维护窗口内的信息,避免重复计算",适用于"子串/子数组相关的最值、匹配问题",可将暴力解法的 O(m²) 时间复杂度优化至 O(m)。
六、总结与思考
6.1 核心知识点
-
滑动窗口(双指针)的动态维护:通过 right 指针扩大窗口找有效子串,left 指针缩小窗口找最短子串,实现高效遍历。
-
哈希表的计数应用:用两个哈希表分别统计 t 和当前窗口的字符计数,结合匹配计数器,高效判断窗口有效性。
-
时间复杂度优化:从暴力解法的 O(m² × n) 优化至 O(m + n),核心是"避免重复判断子串有效性"和"双指针仅遍历一次 s"。
6.2 解题启示
本题是"滑动窗口 + 哈希表"的经典结合,也是面试中高频考察的中等难度题目,解题时需注意:
-
先明确问题核心:不仅要包含 t 的所有字符,还要满足重复字符的计数要求,这是区别于"包含所有不同字符"的关键。
-
滑动窗口的维护逻辑是重点:牢记"扩大窗口找有效,缩小窗口找最短",避免出现"缩小窗口时遗漏字符计数更新"的错误。
-
细节决定成败:边界判断(t 比 s 长)、匹配计数器的更新逻辑、最短子串的起始索引记录,这些细节直接影响代码的正确性。
-
进阶要求的实现:通过匹配计数器优化有效性判断,避免遍历对比,确保时间复杂度达到 O(m + n),这是面试中加分项。
6.3 测试用例验证
补充4组测试用例,确保代码覆盖所有场景:
-
测试用例1:s = "a", t = "a" → 输出 "a"(边界场景,s 和 t 长度相等)。
-
测试用例2:s = "a", t = "aa" → 输出 ""(t 有重复字符,s 无法满足)。
-
测试用例3:s = "aa", t = "aa" → 输出 "aa"(t 重复字符,s 刚好满足)。
-
测试用例4:s = "abac", t = "abc" → 输出 "bac"(最短有效子串)。
将上述用例代入滑动窗口解法代码,均能得到正确结果,验证代码的正确性和健壮性。