想系统提升编程能力、查看更完整的学习路线,欢迎访问 AI Compass:github.com/tingaicompa… 仓库持续更新刷题题解、Python 基础和 AI 实战内容,适合想高效进阶的你。
📖 第16课:最小覆盖子串
模块:滑动窗口 | 难度:Hard ⭐⭐⭐ LeetCode 链接:leetcode.cn/problems/mi… 前置知识:第14课 - 无重复字符的最长子串、第15课 - 长度最小的子数组 预计学习时间:35分钟
🎯 题目描述
给你一个字符串 s 和一个字符串 t,请你在 s 中找出包含 t 所有字符的最小子串。如果 s 中不存在这样的子串,则返回空字符串 ""。
注意:
t中可能有重复字符,你的子串必须包含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 的子串中,但 s 只有一个 'a',无解
约束条件:
1 <= s.length, t.length <= 10^5s和t由英文字母组成(大小写敏感)t中可能有重复字符
🧪 边界用例(面试必考)
| 用例类型 | 输入 | 期望输出 | 考察点 |
|---|---|---|---|
| 最小输入 | s="a", t="a" | "a" | s 和 t 都是单字符 |
| s < t | s="a", t="aa" | "" | s 长度小于 t,无解 |
| t 有重复 | s="aa", t="aa" | "aa" | t 中字符重复,需要数量匹配 |
| 多个解 | s="cabwefgewcwaefgcf", t="cae" | "cwae" | 多个窗口满足,取最短 |
| 完全匹配 | s="abc", t="abc" | "abc" | 整个 s 就是答案 |
| 包含无关字符 | s="ADOBECODEBANC", t="ABC" | "BANC" | 经典用例,窗口中有冗余字符 |
💡 思路引导
生活化比喻
想象你在一个超长的货架(字符串 s)上找商品,你有一张购物清单(字符串 t),上面写着"需要 2 个苹果、1 个香蕉、1 个橙子"。你要找出货架上最短的一段,能凑齐购物清单上的所有商品。
🐌 笨办法:你从货架的每个位置开始,一个个向右数,看看从这个位置到哪里能凑齐购物清单。每个起点都要重新数一遍,累死人!这就是暴力法,时间复杂度 O(n²) 或 O(n³)。
🚀 聪明办法:你用一个可伸缩的购物车(滑动窗口)来扫货架:
- 右边的手不断往右推车,把商品加入购物车(扩大窗口)
- 左边的手在购物车里检查:购物清单上的东西都有了吗?
- 如果还没凑齐,右手继续推车往右
- 如果已经凑齐了,左手尝试从左边扔掉多余的商品(缩小窗口),看能不能用更短的货架段凑齐
- 每次凑齐购物清单时,记录当前购物车的长度,最后返回最短的
这样你的购物车只需要从左到右扫一遍货架,每个商品最多被加入/移出各一次,效率极高!
关键洞察
"包含所有字符" + "最小子串" → 用滑动窗口 + 计数器跟踪字符匹配情况 → 右扩找到覆盖,左缩找到最小
🧠 解题思维链
这一节模拟你在面试中"从零开始思考"的过程。
Step 1:理解题目 → 锁定输入输出
- 输入:字符串
s(被搜索的字符串),字符串t(目标字符串) - 输出:s 中包含 t 所有字符的最小子串(如果无解返回
"") - 限制:
- 必须是子串(连续)
- 必须包含 t 中每个字符的相应数量(
t="aa"就需要 2 个 'a') - 大小写敏感
Step 2:先想笨办法(暴力法)
枚举 s 的所有子串,检查每个子串是否包含 t 的所有字符,找出满足条件的最短的。
- 外层循环枚举起点 i
- 内层循环枚举终点 j
- 每次检查
s[i:j+1]是否包含 t 的所有字符(需要比较字符计数) - 时间复杂度:O(n² × m) 或 O(n³)(n 是 s 的长度,m 是 t 的长度)
- 瓶颈在哪:大量重复计算字符计数
Step 3:瓶颈分析 → 优化方向
暴力法的核心问题:
- 每次都要重新统计子串的字符计数
- 没有利用"子串是连续的"这个特性
优化思路:能不能维护一个动态窗口,通过增删字符来动态更新计数,而不是每次都重新统计?
Step 4:选择武器
- 选用:滑动窗口 + 哈希表计数
- 理由:
- 窗口右扩(加字符)→ 更新计数器
- 窗口左缩(减字符)→ 更新计数器
- 用一个变量
matched记录"已经匹配的字符种类数",当matched == len(need)时就是找到了覆盖 - 时间复杂度降为 O(n + m)
🔑 模式识别提示:当题目出现"子串包含"+"所有字符"+"最短/最长",优先考虑"滑动窗口 + 计数器"
🔑 解法一:暴力枚举(直觉法)
思路
枚举 s 的所有子串 [i, j],检查每个子串是否包含 t 的所有字符(用 Counter 比较),找出满足条件的最短子串。
图解过程
示例:s = "ADOBECODEBANC", t = "ABC"
从 i=0 开始枚举所有终点 j:
s[0:1] = "A" → Counter: {A:1} vs need: {A:1,B:1,C:1} ❌ 缺 B,C
s[0:2] = "AD" → Counter: {A:1,D:1} vs need: {A:1,B:1,C:1} ❌ 缺 B,C
s[0:3] = "ADO" → Counter: {A:1,D:1,O:1} vs need: {A:1,B:1,C:1} ❌ 缺 B,C
...
s[0:6] = "ADOBEC" → Counter: {A:1,D:1,O:1,B:1,E:1,C:1} ✅ 满足!长度=6
从 i=1 开始枚举...
从 i=2 开始枚举...
...
从 i=9 开始枚举:
s[9:12] = "BAN" → Counter: {B:1,A:1,N:1} ❌ 缺 C
s[9:13] = "BANC" → Counter: {B:1,A:1,N:1,C:1} ✅ 满足!长度=4 ← 最短!
最小覆盖子串 = "BANC"
Python代码
from collections import Counter
def min_window_brute(s: str, t: str) -> str:
"""
解法一:暴力枚举所有子串
思路:双层循环枚举起点和终点,检查每个子串是否包含 t 的所有字符
"""
if len(s) < len(t):
return ""
need = Counter(t) # t 中每个字符的需求数量
min_len = float('inf')
result = ""
for i in range(len(s)):
for j in range(i, len(s)):
substring = s[i:j+1]
window = Counter(substring)
# 检查 window 是否包含 need 的所有字符
if all(window[char] >= need[char] for char in need):
if j - i + 1 < min_len:
min_len = j - i + 1
result = substring
break # 从 i 开始的最短已找到,不需要继续扩展 j
return result
# ✅ 测试
print(min_window_brute("ADOBECODEBANC", "ABC")) # 期望输出:"BANC"
print(min_window_brute("a", "a")) # 期望输出:"a"
print(min_window_brute("a", "aa")) # 期望输出:""
复杂度分析
- 时间复杂度:O(n² × m) — 两层循环 O(n²),每次 Counter 比较 O(m)
- 如果 n=100000,约需 10^10 次操作,会超时
- 空间复杂度:O(m) — Counter 需要存储 t 的字符计数
优缺点
- ✅ 思路直观,容易理解
- ✅ 代码简洁(利用 Counter)
- ❌ 时间复杂度 O(n² × m),必然超时
- ❌ 每次都要重新创建 Counter,浪费计算
⚡ 解法二:滑动窗口 + 计数器(最优解)
优化思路
用一个可伸缩的窗口 [left, right] 维护当前子串,同时用两个哈希表:
need:记录 t 中每个字符的需求数量window:记录当前窗口中每个字符的数量
再用一个变量 matched 记录"已经匹配的字符种类数":
- 当
window[char] == need[char]时,matched += 1 - 当
matched == len(need)时,说明窗口包含了 t 的所有字符 - 此时尝试收缩左边界,找最小窗口
💡 关键想法:
- 右指针扩展:不断加入新字符,更新
window和matched- 左指针收缩:当
matched == len(need)时(已覆盖),尝试移除左边字符,找最小窗口- 每个字符最多被 left 和 right 各访问一次,总时间 O(n)
图解过程
示例:s = "ADOBECODEBANC", t = "ABC"
need = {A:1, B:1, C:1} (需要 3 种字符)
初始状态:left=0, right=0, window={}, matched=0, min_len=∞
Step 1: right=0, 加入 'A'
[A]DOBECODEBANC
↑
L,R
window={A:1}, window['A']==need['A'] → matched=1 ✅
matched < 3,继续右扩
Step 2-4: right=1~3, 加入 'D','O','B'
[ADOB]ECODEBANC
↑ ↑
L R
window={A:1,D:1,O:1,B:1}, window['B']==need['B'] → matched=2
matched < 3,继续右扩
Step 5: right=5, 加入 'C'
[ADOBEC]ODEBANC
↑ ↑
L R
window={A:1,D:1,O:1,B:1,E:1,C:1}, window['C']==need['C'] → matched=3 ✅
matched == 3,找到覆盖!长度=6,记录结果 "ADOBEC"
→ 尝试左缩:移除 'A'
Step 6: left=1, 移除 'A'
A[DOBEC]ODEBANC
↑ ↑
L R
window={D:1,O:1,B:1,E:1,C:1}, window['A']=0 < need['A'] → matched=2
matched < 3,无法继续缩,右扩
Step 7-9: right=6~8, 加入 'O','D','E'
(窗口一直不满足 matched==3)
Step 10: right=9, 加入 'B'
A[DOBECODE B]ANC
↑ ↑
L R
window={D:2,O:2,B:2,E:2,C:1}, window['B']=2 > need['B'] → matched 仍为 2
(缺 'A')继续右扩
Step 11: right=10, 加入 'A'
A[DOBE CODE BA]NC
↑ ↑
L R
window={D:2,O:2,B:2,E:2,C:1,A:1}, window['A']==need['A'] → matched=3 ✅
找到覆盖!长度=10 > 6,不更新
→ 尝试左缩:连续移除 'D','O','B','E','C','O','D','E'
(这些字符要么不在 need 中,要么数量 > need,可以安全移除)
Step 12: left=9, 移除多余字符后
ADOBECODE[BA]NC
↑ ↑
L R
window={B:1,A:1}, matched=2 < 3 (缺 'C')
继续右扩
Step 13: right=11, 加入 'N'
window={B:1,A:1,N:1}, matched 仍为 2,继续右扩
Step 14: right=12, 加入 'C'
ADOBECODE[BANC]
↑ ↑
L R
window={B:1,A:1,N:1,C:1}, window['C']==need['C'] → matched=3 ✅
找到覆盖!长度=4 < 6,更新结果 "BANC" ← 最优解!
→ 尝试左缩:移除 'B'
window['B']=0 < need['B'] → matched=2,停止
right 到达末尾,循环结束
最小覆盖子串 = "BANC"
Python代码
from collections import Counter
def min_window(s: str, t: str) -> str:
"""
解法二:滑动窗口 + 计数器
思路:右指针扩展窗口直到包含所有字符,左指针收缩窗口找最小长度
"""
if len(s) < len(t):
return ""
# 1. 初始化 need(目标字符计数)和 window(窗口字符计数)
need = Counter(t)
window = {}
matched = 0 # 已经匹配的字符种类数
left = 0
min_len = float('inf')
start = 0 # 记录最小窗口的起始位置
# 2. 右指针遍历 s
for right in range(len(s)):
char = s[right]
# 将右边界字符加入窗口
if char in need:
window[char] = window.get(char, 0) + 1
# 当前字符的数量刚好满足需求时,matched++
if window[char] == need[char]:
matched += 1
# 3. 当窗口包含了所有字符时,尝试收缩左边界
while matched == len(need):
# 更新最小窗口
if right - left + 1 < min_len:
min_len = right - left + 1
start = left
# 移除左边界字符
left_char = s[left]
if left_char in need:
# 移除前刚好满足需求,移除后就不满足了
if window[left_char] == need[left_char]:
matched -= 1
window[left_char] -= 1
left += 1
# 4. 返回结果
return "" if min_len == float('inf') else s[start:start + min_len]
# ✅ 测试
print(min_window("ADOBECODEBANC", "ABC")) # 期望输出:"BANC"
print(min_window("a", "a")) # 期望输出:"a"
print(min_window("a", "aa")) # 期望输出:""
print(min_window("ab", "b")) # 期望输出:"b"
复杂度分析
- 时间复杂度:O(n + m) — 右指针遍历 s 一次 O(n),左指针最多遍历一次 O(n),构建 need 需要 O(m)
- 具体地说:如果 n=100000,只需要约 20 万次操作,比暴力法快 50000 倍!
- 空间复杂度:O(m + k) — need 和 window 最多存储 O(m + k) 个字符(k 是字符集大小,英文字母最多 52)
🚀 解法三:滑动窗口优化版(代码更简洁)
优化思路
解法二的代码已经很优秀了,但还可以进一步简化:
- 不需要单独的
matched变量,可以直接检查window是否包含need的所有键值对 - 使用
defaultdict简化初始化
但这样会让代码可读性下降,且时间复杂度没有本质提升,所以解法二已经是最推荐的写法。
Python代码(仅供参考)
from collections import Counter, defaultdict
def min_window_v3(s: str, t: str) -> str:
"""
解法三:滑动窗口(代码简化版)
思路:用辅助函数检查窗口是否覆盖目标
"""
if len(s) < len(t):
return ""
need = Counter(t)
window = defaultdict(int)
left = 0
min_len = float('inf')
start = 0
def is_covered():
"""检查当前窗口是否覆盖了 need"""
return all(window[char] >= need[char] for char in need)
for right in range(len(s)):
window[s[right]] += 1
while is_covered():
if right - left + 1 < min_len:
min_len = right - left + 1
start = left
window[s[left]] -= 1
left += 1
return "" if min_len == float('inf') else s[start:start + min_len]
# ✅ 测试
print(min_window_v3("ADOBECODEBANC", "ABC")) # 期望输出:"BANC"
注意:这个写法虽然代码更短,但 is_covered() 每次都要遍历 need 的所有键,实际时间复杂度是 O(n × m)(m 是 t 中不同字符的数量)。解法二用 matched 变量将这个检查优化到 O(1),所以解法二更优。
🐍 Pythonic 写法
利用 Python 的 collections.Counter 简化代码:
from collections import Counter
def min_window_pythonic(s: str, t: str) -> str:
"""
Pythonic 写法:利用 Counter 的特性
"""
if len(s) < len(t):
return ""
need = Counter(t)
window = Counter()
left = matched = 0
min_len, start = float('inf'), 0
for right, char in enumerate(s):
if char in need:
window[char] += 1
if window[char] == need[char]:
matched += 1
while matched == len(need):
if right - left + 1 < min_len:
min_len, start = right - left + 1, left
left_char = s[left]
if left_char in need:
if window[left_char] == need[left_char]:
matched -= 1
window[left_char] -= 1
left += 1
return s[start:start + min_len] if min_len != float('inf') else ""
# ✅ 测试
print(min_window_pythonic("ADOBECODEBANC", "ABC")) # 期望输出:"BANC"
这个写法用到了:
enumerate():同时获取下标和值Counter():自动初始化计数为 0,不需要get(char, 0)- 多重赋值:
min_len, start = ...简化变量更新
⚠️ 面试建议:推荐使用解法二(带
matched变量的版本),逻辑最清晰,性能最优。面试时先写清晰版本展示思路,再提一嘴"也可以用 Counter 简化初始化"来展示语言功底。
📊 解法对比
| 维度 | 解法一:暴力枚举 | 解法二:滑动窗口 | 解法三:简化版 |
|---|---|---|---|
| 时间复杂度 | O(n² × m) | O(n + m) | O(n × m) |
| 空间复杂度 | O(m) | O(m + k) | O(m + k) |
| 代码难度 | 简单 | 中等 | 中等 |
| 面试推荐 | ⭐ | ⭐⭐⭐ | ⭐⭐ |
| 适用场景 | 小规模数据或说明思路 | 面试首选,高效且清晰 | 快速编码,但性能略差 |
面试建议:先用 30 秒口述暴力法思路和复杂度(展示你能想到基本解法),然后重点讲解滑动窗口 + matched 变量的优化(展示优化能力)。关键点在于说清楚"如何用 matched 变量 O(1) 判断窗口是否覆盖目标"。
🎤 面试现场
模拟面试中的完整对话流程,帮你练习"边想边说"。
面试官:给你两个字符串 s 和 t,找出 s 中包含 t 所有字符的最小子串。
你:(审题 30 秒)好的,让我确认一下——输入是两个字符串,输出是 s 中的一个子串(连续),这个子串必须包含 t 中每个字符的相应数量,对吧?比如 t="aa",子串中也必须有至少 2 个 'a'?
面试官:没错。
你:好的。我先想一个最直接的办法:枚举 s 的所有子串,用 Counter 检查每个子串是否包含 t 的所有字符,找出满足条件的最短的。时间 O(n² × m),空间 O(m)。不过这个显然会超时。
让我想想怎么优化……这道题的关键特征是:
- 要求"子串包含所有字符"
- 找"最短长度"
- 需要跟踪字符数量
这是一个经典的滑动窗口 + 计数器问题!
具体做法:
- 用一个
need哈希表记录 t 中每个字符的需求数量 - 用一个
window哈希表记录当前窗口中每个字符的数量 - 用一个
matched变量记录"已经匹配的字符种类数" - 右指针不断扩展窗口,当
matched == len(need)时说明找到了覆盖 - 此时尝试收缩左指针,找最小窗口
时间 O(n + m),空间 O(m)。
面试官:思路清晰,请写代码吧。
你:好的。(边写边说)
def minWindow(self, s, t):
from collections import Counter
if len(s) < len(t):
return ""
need = Counter(t) # 目标字符计数
window = {} # 窗口字符计数
matched = 0 # 已匹配的字符种类数
left = 0
min_len = float('inf')
start = 0
for right in range(len(s)):
char = s[right]
# 右边界字符加入窗口
if char in need:
window[char] = window.get(char, 0) + 1
# 刚好满足需求时,matched++
if window[char] == need[char]:
matched += 1
# 当窗口覆盖目标时,尝试收缩左边界
while matched == len(need):
# 更新最小窗口
if right - left + 1 < min_len:
min_len = right - left + 1
start = left
# 移除左边界字符
left_char = s[left]
if left_char in need:
if window[left_char] == need[left_char]:
matched -= 1
window[left_char] -= 1
left += 1
return "" if min_len == float('inf') else s[start:start + min_len]
关键点是这个 matched 变量——它记录"有多少种字符的数量已经满足需求"。当 matched == len(need) 时,说明所有种类的字符都满足了,窗口就是一个合法覆盖。这样我们可以 O(1) 判断窗口是否覆盖,而不需要每次都遍历 need 检查。
面试官:能手动跑一个例子验证一下吗?
你:好的,用 s="ADOBECODEBANC", t="ABC":
- need = {A:1, B:1, C:1},需要 3 种字符
- right=0~5: 窗口 "ADOBEC",window={A:1,D:1,O:1,B:1,E:1,C:1},matched=3 ✅
- 找到覆盖!长度=6,记录 "ADOBEC"
- 收缩 left=1: 移除 'A',matched=2,停止收缩
- right=6~10: 窗口继续右扩,直到 right=10 时加入 'A',matched=3 ✅
- 连续收缩 left 到 left=9: 窗口 "BA",matched=2 (缺 'C')
- right=12: 加入 'C',窗口 "BANC",matched=3 ✅,长度=4 < 6
- 更新最小窗口为 "BANC"
- 尝试收缩 left=10: 移除 'B',matched=2,停止
最终返回 "BANC" ✅
面试官:很好。如果 t 中有重复字符呢?
你:代码已经处理了。need 用 Counter 统计 t 中每个字符的数量,window 也是同样的逻辑。比如 t="AAB",need={A:2, B:1},窗口必须有至少 2 个 'A' 和 1 个 'B' 才算覆盖。matched 只有在 window[char] == need[char] 时才会增加,所以重复字符也能正确处理。
高频追问
| 追问 | 应答策略 |
|---|---|
| "还有更优解吗?" | 时间已经是 O(n + m) 最优(至少要遍历一遍 s 和 t),空间也是 O(m) 最优。这就是最优解了 |
| "如果要找所有满足条件的子串呢?" | 把 min_len 和 start 的更新逻辑改为收集所有长度等于最小长度的子串,用一个列表存储 |
| "如果 t 非常大呢?" | need 的大小取决于 t 中不同字符的数量,最多 O(m)。如果 m 很大,空间复杂度会增加,但算法思路不变 |
| "能否用固定长度窗口?" | 不行。因为我们不知道最小窗口的长度是多少,必须用可变长度窗口动态查找 |
| "实际工程中什么场景会用到?" | DNA 序列分析(找包含所有特定碱基的最短片段)、日志分析(找包含所有关键词的最短日志段)、文本搜索(找包含所有查询词的最短文本片段) |
🎓 知识点总结
Python技巧卡片 🐍
# Counter — 快速统计字符/元素频次
from collections import Counter
t = "AABC"
need = Counter(t) # Counter({'A': 2, 'B': 1, 'C': 1})
# Counter 的常用操作
need['A'] # 2(如果键不存在返回 0)
'A' in need # True(判断键是否存在)
len(need) # 3(不同字符的种类数)
# defaultdict — 自动初始化默认值
from collections import defaultdict
window = defaultdict(int) # 默认值为 0
window['A'] += 1 # 不需要判断键是否存在
# all() — 检查所有元素是否满足条件
need = {'A': 2, 'B': 1}
window = {'A': 3, 'B': 1, 'C': 1}
all(window[char] >= need[char] for char in need) # True
# enumerate() — 同时拿到下标和值
for i, char in enumerate("ABC"):
print(i, char) # 0 A / 1 B / 2 C
💡 底层原理(选读)
为什么用
matched变量能优化性能?如果不用
matched,每次判断窗口是否覆盖目标,需要遍历need的所有键:def is_covered(): return all(window[char] >= need[char] for char in need)这个操作的时间复杂度是 O(m)(m 是 t 中不同字符的数量)。
而用
matched变量后:
- 当某个字符的数量从不满足变为满足时,
matched += 1- 当某个字符的数量从满足变为不满足时,
matched -= 1- 判断窗口是否覆盖只需要检查
matched == len(need),O(1)总时间复杂度从 O(n × m) 降为 O(n + m)。
为什么是
window[char] == need[char]而不是>=?
matched记录的是"有多少种字符已经满足需求",而不是"有多少个字符"。
- 当
window['A']从 0 增加到 1,再增加到 2 时:
- 如果
need['A'] = 2,只有在window['A'] == 2时才matched += 1- 如果后续
window['A']增加到 3、4,matched不变(因为 'A' 这种字符已经满足了)- 当
window['A']从 3 减少到 2、1、0 时:
- 只有在
window['A']从 2 减少到 1 时(即从满足变为不满足),才matched -= 1滑动窗口的两种模式:
- 求最长(如"无重复字符的最长子串"):
- while 循环条件:不满足时收缩
- 更新答案:在 for 循环中(窗口满足条件时)
- 求最短(如本题):
- while 循环条件:满足时收缩
- 更新答案:在 while 循环中(每次收缩时)
算法模式卡片 📐
- 模式名称:滑动窗口 + 计数器(最小覆盖子串模板)
- 适用条件:
- 子串/子数组包含问题
- 需要匹配字符/元素的数量
- 求最长/最短/计数
- 识别关键词:"包含所有字符"+"最小/最大子串"+"字符频次匹配"
- 模板代码:
from collections import Counter
def sliding_window_cover(s: str, t: str) -> str:
"""
滑动窗口 + 计数器模板(最小覆盖子串)
适用:找包含所有目标字符的最小子串
"""
if len(s) < len(t):
return ""
# 1. 初始化 need 和 window
need = Counter(t)
window = {}
matched = 0 # 已匹配的字符种类数
left = 0
min_len = float('inf')
start = 0
# 2. 右指针遍历
for right in range(len(s)):
char = s[right]
# 右边界字符加入窗口
if char in need:
window[char] = window.get(char, 0) + 1
if window[char] == need[char]:
matched += 1
# 3. 当窗口满足条件时,尝试收缩左边界
while matched == len(need):
# 更新答案(求最小)
if right - left + 1 < min_len:
min_len = right - left + 1
start = left
# 移除左边界字符
left_char = s[left]
if left_char in need:
if window[left_char] == need[left_char]:
matched -= 1
window[left_char] -= 1
left += 1
# 4. 返回结果
return "" if min_len == float('inf') else s[start:start + min_len]
变体:求最长窗口、求所有满足条件的窗口等,核心逻辑不变,只需调整更新答案的位置。
易错点 ⚠️
-
matched更新时机错误:应该在window[char] == need[char]时更新,而不是>=。很多人写成if window[char] >= need[char]: matched += 1,导致重复计数。解决:理解matched是"字符种类数",不是"字符个数"。 -
收缩窗口时忘记更新
matched:移除左边界字符时,如果window[left_char]从满足变为不满足,必须matched -= 1。有的人只减少window[left_char],忘记更新matched,导致窗口判断错误。 -
无解时返回值错误:如果没有满足条件的窗口,应该返回空字符串
"",不是返回s或None。记得在最后检查min_len == float('inf')。 -
字符不在
need中时的处理:窗口中可能包含不在need中的字符(如示例中的 'D','O','E'),这些字符不需要加入window,也不影响matched。很多人所有字符都加入window,导致空间浪费和逻辑复杂。 -
边界情况遗漏:
s的长度小于t时直接返回"",不要尝试滑动窗口。
🏗️ 工程实战(选读)
这个算法思想在真实项目中的应用,让你知道"学了有什么用"。
-
DNA 序列分析:生物信息学中,需要在一条 DNA 序列(如 "ATCGATCGAA...")中找到包含所有特定碱基(如 "ATCG" 各至少 1 个)的最短片段,用于基因定位和分析。
-
日志分析 - 关键词搜索:在服务器日志中搜索包含所有关键词(如 "ERROR", "timeout", "user:12345")的最短日志段,用于快速定位问题根源。
-
文本搜索引擎 - 摘要生成:搜索引擎在返回搜索结果时,会在文档中找到包含所有查询词的最短文本片段作为摘要(snippet)显示给用户,让用户快速了解相关性。
-
视频字幕匹配:在视频字幕中找到包含所有关键词的最短时间段,用于视频内容索引和跳转。
🏋️ 举一反三
完成本课后,试试这些同类题目来巩固知识:
| 题目 | 难度 | 相关知识点 | 提示 |
|---|---|---|---|
| LeetCode 3. 无重复字符的最长子串 | Medium | 滑动窗口 + set | 求最长,while 条件是"有重复时收缩" |
| LeetCode 438. 找到字符串中所有字母异位词 | Medium | 固定长度窗口 + Counter | 窗口长度固定为 len(p),比较计数 |
| LeetCode 567. 字符串的排列 | Medium | 固定长度窗口 + Counter | 和 438 几乎一样,返回 True/False |
| LeetCode 30. 串联所有单词的子串 | Hard | 滑动窗口 + 单词计数 | 本题的进阶版,单位是单词而非字符 |
| LeetCode 159. 至多包含两个不同字符的最长子串 | Medium | 滑动窗口 + 计数 | 窗口内最多 2 种字符,求最长 |
| LeetCode 340. 至多包含 K 个不同字符的最长子串 | Medium | 滑动窗口 + 计数 | 159 的通用版,窗口内最多 K 种字符 |
📝 课后小测
试试这道变体题,不要看答案,自己先想 5 分钟!
题目:给定字符串 s 和 t,找出 s 中包含 t 所有字符(不考虑顺序和数量)的最长子串。
示例:s = "ADOBECODEBANC", t = "ABC" → "ADOBECODE"(包含 A、B、C,长度最长)
💡 提示 1(实在想不出来再点开)
这道题和原题的区别:
- 原题:包含 t 每个字符的相应数量,求最短
- 变体:包含 t 所有字符种类(数量可以更多),求最长
滑动窗口的收缩时机会反过来!
💡 提示 2(再给你一个线索)
- 原题:
matched == len(need)时收缩窗口(求最短) - 变体:
matched == len(need)时扩展窗口(求最长),当matched < len(need)时收缩
✅ 参考答案
from collections import Counter
def max_window_cover(s: str, t: str) -> str:
"""
变体题:包含所有字符种类的最长子串
思路:窗口满足条件时继续右扩,不满足时左缩
"""
if len(s) < len(t):
return ""
need = set(t) # 只需要字符种类,不需要数量
window = set()
left = 0
max_len = 0
start = 0
for right in range(len(s)):
window.add(s[right])
# 当窗口包含所有字符种类时,更新最大长度
while window >= need: # window 是 need 的超集
if right - left + 1 > max_len:
max_len = right - left + 1
start = left
# 尝试收缩左边界
left_char = s[left]
left += 1
# 如果移除后窗口不再包含所有字符,需要重建 window
# (简化版:直接重建当前窗口)
window = set(s[left:right+1])
return s[start:start + max_len] if max_len > 0 else ""
# 测试
print(max_window_cover("ADOBECODEBANC", "ABC")) # 期望:"ADOBECODE"(长度9)
核心思路:
- 原题是"满足条件时收缩找最短",变体是"满足条件时扩展找最长"
- 因为只需要字符种类,不需要数量,所以用
set而不是Counter - 滑动窗口的核心逻辑是一样的,只是收缩/扩展的时机相反
启示:滑动窗口的模板是通用的,关键在于理解"什么时候收缩、什么时候更新答案"。
如果这篇内容对你有帮助,推荐收藏 AI Compass:github.com/tingaicompa… 更多系统化题解、编程基础和 AI 学习资料都在这里,后续复习和拓展会更省时间。