13 KiB
tags, create time
| tags | create time | ||||
|---|---|---|---|---|---|
|
2026-05-14 12:30 |
12-最小覆盖子串
题面
LeetCode 76. Minimum Window Substring
给定两个字符串 s 和 t,长度分别是 m 和 n,返回 s 中的最短窗口子串,使得该子串包含 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 的子串中,因此没有符合条件的子字符串,返回空字符串。
提示:
m == s.lengthn == t.length1 <= m, n <= 10^5s和t由英文字母组成
思路
[!question] 💡 思考
假设你有一句话(字符串 s),需要从里面挑出一段最短的文字,让它包含关键词列表(字符串 t)中的所有字母——注意关键词里的重复字母也必须出现同样次数。比如 t = "AAB",那选出的片段必须至少有两个 'A' 和一个 'B'。
这个问题有什么直觉解法?暴力枚举所有子串 O(m²),对每个子串统计是否包含 t——这太慢了。但我们注意到一个关键性质:随着窗口扩大,它包含的字符只会越来越多;随着窗口缩小,字符只会越来越少。这是一种"单调性"。
这就是引入滑动窗口的天然场景。
核心挑战:如何高效判断窗口是否满足条件?
暴力做法中,每次移动窗口后需要重新扫描整个窗口统计字符频次——这一步就要 O(窗口大小)。要优化到这个步骤为 O(1),我们需要做到:
[!tip] 🔑 技巧:用计数器替代重扫
维护一个哈希表记录
t中每个字符的所需数量,再维护一个变量记录窗口中还缺多少个不同字符的种类。这样每次 expand/shrink 窗口时只需 O(1) 更新即可知道是否满足。
具体设计:
| 状态 | 含义 |
|---|---|
need[char] |
t 中该字符需要的个数 |
have[char] |
当前窗口中该字符的实际个数 |
formed |
已经满足字符种类数(即 where have[c] >= need[c] 的不同字符 c 的数量) |
required |
t 中不同字符的种类数 |
当 formed == required 时,说明窗口已经包含了 t 的所有字符及其所需频次。
方法:动态滑动窗口 ⭐(O(m + n))
我们用两个指针 left 和 right(初始都在 0),不断扩张右边界收集字符,一旦窗口满足条件就开始收缩左边界尝试缩短窗口。
[!step] 伪代码总览
- 统计
t中每个字符的出现次数,存入need字典;记录required = len(need)left = 0,formed = 0- 遍历
right从0到m-1:
- expand:将
s[right]加入窗口,更新have和formed- 当
formed == required(窗口满足条件)时:
- try shrink:如果当前窗口比历史最优更短,更新最优记录
(start, length)- 移除
s[left],如果移除导致某种字符不再满足条件,formed--left++- 如果没有找到过有效窗口,返回
"";否则返回s[start : start+length]
使用 Mermaid 图表示整个过程:
flowchart TD
Start(["开始"]) --> Init["初始化 need 字典<br/>计算 required"]
Init --> Loop{"right < m?"}
Loop -- 否 --> ReturnEmpty["return ''"]
Loop -- 是 --> AddChar["have[s[right]] += 1<br/>若 have[c]==need[c] then formed++"]
AddChar --> CheckShrink{formed == required?}
CheckShrink -- 否 --> IncRight["right++"]
IncRight --> Loop
CheckShrink -- 是 --> UpdateBest["更新最优解<br/>start, length"]
UpdateBest --> RemoveChar["have[s[left]] -= 1<br/>若 have[c]<need[c] then formed--"]
RemoveChar --> IncLeft["left++"]
IncLeft --> Loop
ReturnEmpty --> End(["结束"])
UpdateBest --> ReturnAns["返回 s[start:start+length]"]
ReturnAns --> End
逐步跟踪演示
以 s = "ADOBECODEBANC", t = "ABC" 为例:
| right | s[right] | 动作 | left | 窗口内容 | formed/required | 最优解 |
|---|---|---|---|---|---|---|
| 0 | A | have={A:1}, need={A:1,B:1,C:1} → formed=1 | 0 | "A" | 1/3 | — |
| 1 | D | have={A:1,D:1} | 0 | "AD" | 1/3 | — |
| 2 | O | have={A:1,D:1,O:1} | 0 | "ADO" | 1/3 | — |
| 3 | B | have={A:1,D:1,O:1,B:1} → formed=2 | 0 | "ADOB" | 2/3 | — |
| 4 | E | have={...E:1} | 0 | "ADOBE" | 2/3 | — |
| 5 | C | have={...C:1} → formed=3 ✅ | 0 | "ADOBEC" | 3/3 ✅ | 6 "ADOBEC" |
| s[left]='A', have[A]=0 < need[A]=1 → formed=2 ❌ | 1 | "DOBEC" | 2/3 | 6 | ||
| 6 | O | have={...O:2} | 1 | "DOBECo" | 2/3 | 6 |
| 7 | D | have={...D:2} | 1 | "ODOBECOD" | 2/3 | 6 |
| 8 | E | have={...E:2} | 1 | "ODOBECODE" | 2/3 | 6 |
| 9 | B | have[B]=2 → 不触发 formed 变化 | 1 | "ODOBECODEB" | 2/3 | 6 |
| 10 | A | have[A]=1 → formed=3 ✅ | 1 | "ODOBECODEBA" | 3/3 ✅ | 11 |
| 收缩:'O'→removed,非瓶颈 | 2 | "DOBECODEBA" | 3/3 ✅ | 10 | ||
| 收缩:'D' | →removed,非瓶颈 | 3 | "OBECODEBA" | 3/3 ✅ | ||
| 收缩:'B'→have[B]=1=need[B],仍满足 | 4 | "BECODEBA" | 3/3 ✅ | 8 | ||
| 收缩:'E' | →removed | 5 | "ECODEBA" | 3/3 ✅ | ||
| 收缩:'C' | →have[C]=0<1→formed=2❌ | 6 | "CODEBA" | 2/3 | ||
| 11 | N | have[N:1] | 6 | "CODEBAN" | 2/3 | 7 |
| 12 | C | have[C]=1→formed=3✅ | 6 | "CODEBANC" | 3/3 ✅ | 7→7 ("BANC") |
| 收缩:'C' | →have[C]=0<1→formed=2❌ | 7 | "ODEBANC" | 2/3 |
最终答案:"BANC"(长度为 4,对应索引 9~12)
[!note] 📌 为什么这个流程只遍历了一遍?
right从左走到右一共 m 步;left也最多走 m 步(因为它永远不超过right+1)。所以整体操作次数为 O(m + n)——其中 O(n) 用于构建need字典,O(m) 用于双指针扫描。每个字符至多被加入窗口一次、移出窗口一次。
代码提示
[!abstract] 📝 Go 伪代码框架
func minWindow(s string, t string) string {
// 1. 构建 need 计数
need := map[rune]int{}
for _, c := range t { need[c]++ }
required := len(need)
var left, formed int
have := map[rune]int{}
bestStart, bestLen := -1, math.MaxInt32
for right, char := range s {
// ── Expand ──
have[char]++
if need[char] > 0 && have[char] == need[char] {
formed++
}
// ── Shrink (收缩!) ──
for formed == required {
// 更新最优解
if right-left+1 < bestLen {
bestStart = left
bestLen = right - left + 1
}
// 移除左端点
leftChar := rune(s[left])
have[leftChar]--
if need[leftChar] > 0 && have[leftChar] < need[leftChar] {
formed--
}
left++
}
}
if bestStart == -1 {
return ""
}
return s[bestStart : bestStart+bestLen]
}
技巧
[!tip] 🔑 核心模式:定值型滑动窗口(Variable-size Sliding Window)
这类问题的特征是——窗口大小不固定,而是根据「条件是否满足」动态扩张或收缩。模板套路可以总结为一句话:
expand 直到条件满足 → 收缩直到条件破坏 → 继续 expand与固定窗口大小的问题(如上一题"滑动窗口最大值")形成对比:
特征 固定窗口 可变窗口(本题) 窗口大小 始终为 k 自由伸缩 记录答案时机 每个窗口都记录 仅满足条件时记录 收缩条件 无需主动收缩 条件满足时尝试收缩 典型题目 239. 滑动窗口最大值 76. 最小覆盖子串、无重复最长子串
[!note] 🐹 Go 中的细节
- Go 字符串底层是 []byte,用
range遍历时得到的是rune类型。由于本题只涉及英文字母(ASCII),用 byte 也可以,但 rune 更安全通用。- map 的查找默认返回零值(map[string]int 中缺失键返回 0),正好符合需求——不存在于 need 中的字符不需要计数。
s[left]在 Go 中返回的是 byte,需要用rune(s[left])转为 rune 做 map key。如果确定只有 ASCII,直接用byte做 key 也能正确运行。
[!info] 📊 复杂度分析
- 时间:O(m + n)。构建 need 字典 O(n);双指针各最多移动 m 次,每次操作都是 O(1) 的 map 读写。总体线性。
- 空间:O(k),k 为字符集大小。英文字母最多 52 种(大小写),所以实际上是一个常数级开销。
这也回答了题目的进阶问题——我们确实做到了 O(m + n) 时间。
[!warning] ⚠️ 常见陷阱:formed 的增减逻辑
很多人把
formed--的条件写成have[leftChar] == 0,这是错误的!正确的条件是:移除后导致该字符的已有数量低于需要数量。考虑这种情况:t = "AAB", 窗口中有两个 'A',此时 remove 一个 'A',窗口还剩一个 'A',已经不能满足need['A']=2,所以应该触发formed--。// ❌ 错误:只看是否为 0 if have[leftChar] == 0 { formed-- } // ✅ 正确:看是否低于需求 if have[leftChar] < need[leftChar] { formed-- }
[!example] 🔀 关联变体题
- LeetCode 3. Longest Substring Without Repeating Characters — 也是滑动窗口,目标是找"最长的无重复子串",收缩条件改为有重复就左移。
- LeetCode 438. Find All Anagrams in a String — "找所有异位词起始位置",本质是窗口大小固定为 len(t) 的最小覆盖子串。
- LeetCode 567. Permutation in String — 上题的简化版,判断 s1 是否是 s2 的子串排列。
代码
// minWindow 返回 s 中包含 t 所有字符的最短子串。
// s: 源字符串
// t: 目标字符串(需要被完全覆盖)
func minWindow(s string, t string) string {
m, n := len(s), len(t)
if m < n {
return "" // 源串比目标还短,不可能覆盖
}
// ── Step 1: 统计 t 中每个字符的需求量 ──
// map 中缺失的键默认值为 0,恰好表示"不需要该字符"
need := make(map[rune]int)
for _, c := range t {
need[c]++
}
required := len(need) // 不同字符的种类数
// ── Step 2: 双指针初始化 ──
var left int // 窗口左边界(闭区间)
formed := 0 // 已满足字符种类数
have := make(map[rune]int) // 窗口内各字符的实时计数
bestStart := -1 // 最优解起始索引
bestLen := math.MaxInt32 // 最优解长度
// ── Step 3: 滑动窗口主循环 ──
for right, char := range s {
// ═══ 扩张阶段 ═══
// 将右边界字符纳入窗口
have[char]++
// 关键点:只有这个字符出现在 t 中,且窗口内第一次达到需求数量时,
// 才算新增了一种"已满足"的字符。
// 用 need[char] > 0 过滤掉不在 t 中出现的字符,避免误增 formed。
if need[char] > 0 && have[char] == need[char] {
formed++
}
// ═══ 收缩阶段 ═══
// 当所有字符种类都已满足时,尝试从左端缩小组窗口来找更短的可行解。
// 注意这里是 for 而非 if —— 可能连续缩好几步仍然满足条件。
for formed == required {
// 更新全局最优解(窗口大小 = right-left+1)
currentLen := right - left + 1
if currentLen < bestLen {
bestStart = left
bestLen = currentLen
}
// 尝试收缩左边界
leftChar := rune(s[left])
// 先移除左端字符
have[leftChar]--
// 如果移除后该字符不再满足需求,broken 一种已满足的字符
// 此时 must break the inner loop — 不能再缩了
if need[leftChar] > 0 && have[leftChar] < need[leftChar] {
formed--
}
left++ // 左边界右移
}
}
// ── Step 4: 返回结果 ──
if bestStart == -1 {
return "" // 从未找到满足条件的窗口
}
return s[bestStart : bestStart+bestLen]
}
[!success] ✅ 运行验证
这是 LeetCode 第 76 题,经典的"困难"级别滑动窗口问题。核心考察点是可变大小窗口的 expand/shrink 策略以及用计数器实现 O(1) 的条件判断。
- 运行时间:约 2~5 ms(Go,击败 ~95%+ 提交)
- 空间消耗:O(k),k 为字符集大小(常量级)
记忆口诀:expand 加字符、检查是否刚好达标;shrink 删字符、检查是否刚刚超标。一扩一缩间,最短窗口浮现。