Files

333 lines
13 KiB
Markdown
Raw Permalink Normal View History

2026-05-14 23:30:50 +08:00
---
tags: ["LeetCode", "滑动窗口", "双指针", "困难"]
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.length`
- `n == t.length`
- `1 <= m, n <= 10^5`
- `s` 和 `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] 伪代码总览
>
> 1. 统计 `t` 中每个字符的出现次数,存入 `need` 字典;记录 `required = len(need)`
> 2. `left = 0`, `formed = 0`
> 3. 遍历 `right` 从 `0` 到 `m-1`:
> - **expand**:将 `s[right]` 加入窗口,更新 `have` 和 `formed`
> - 当 `formed == required`(窗口满足条件)时:
> - **try shrink**:如果当前窗口比历史最优更短,更新最优记录 `(start, length)`
> - 移除 `s[left]`,如果移除导致某种字符不再满足条件,`formed--`
> - `left++`
> 4. 如果没有找到过有效窗口,返回 `""`;否则返回 `s[start : start+length]`
使用 Mermaid 图表示整个过程:
```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 ✅ | 9 |
| | | 收缩:'B'→have[B]=1=need[B],仍满足 | 4 | "BECODEBA" | 3/3 ✅ | 8 |
| | | 收缩:'E'|→removed | 5 | "ECODEBA" | 3/3 ✅ | 7 |
| | | 收缩:'C'|→have[C]=0<1→formed=2❌ | 6 | "CODEBA" | 2/3 | 7 |
| 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 | 7 |
最终答案:**"BANC"**(长度为 4,对应索引 9~12)
> [!note] 📌 为什么这个流程只遍历了一遍?
>
> `right` 从左走到右一共 m 步;`left` 也最多走 m 步(因为它永远不超过 `right+1`)。所以整体操作次数为 O(m + n)——其中 O(n) 用于构建 `need` 字典,O(m) 用于双指针扫描。每个字符至多被加入窗口一次、移出窗口一次。
---
## 代码提示
> [!abstract] 📝 Go 伪代码框架
```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--`。
>
> ```go
> // ❌ 错误:只看是否为 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 的子串排列。
---
## 代码
```go
// 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 删字符、检查是否刚刚超标。一扩一缩间,最短窗口浮现。