vault backup: 2026-05-14 23:30:50
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
---
|
||||
tags: ["LeetCode", "单调队列", "滑动窗口", "困难"]
|
||||
create time: 2026-05-14 12:00
|
||||
---
|
||||
|
||||
# 11-滑动窗口最大值
|
||||
|
||||
## 题面
|
||||
|
||||
> **LeetCode 239. Sliding Window Maximum**
|
||||
|
||||
给你一个整数数组 `nums`,有一个大小为 `k` 的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 `k` 个数字。滑动窗口每次只向右移动一位。
|
||||
|
||||
返回**滑动窗口中的最大值**。
|
||||
|
||||
**示例 1:**
|
||||
|
||||
```
|
||||
输入:nums = [1,3,-1,-3,5,3,6,7], k = 3
|
||||
输出:[3,3,5,5,6,7]
|
||||
解释:
|
||||
滑动窗口的位置 最大值
|
||||
--------------- -----
|
||||
[1 3 -1] -3 5 3 6 7 3
|
||||
1 [3 -1 -3] 5 3 6 7 3
|
||||
1 3 [-1 -3 5] 3 6 7 5
|
||||
1 3 -1 [-3 5 3] 6 7 5
|
||||
1 3 -1 -3 [5 3 6] 7 6
|
||||
1 3 -1 -3 5 [3 6 7] 7
|
||||
```
|
||||
|
||||
**示例 2:**
|
||||
|
||||
```
|
||||
输入:nums = [1], k = 1
|
||||
输出:[1]
|
||||
```
|
||||
|
||||
**提示:**
|
||||
|
||||
- `1 <= nums.length <= 10^5`
|
||||
- `-10^4 <= nums[i] <= 10^4`
|
||||
- `1 <= k <= nums.length`
|
||||
|
||||
---
|
||||
|
||||
## 思路
|
||||
|
||||
> [!question] 💡 思考
|
||||
>
|
||||
> 对于一个固定大小的窗口,找最大值的直觉做法是什么?遍历窗口内所有元素取 max——时间复杂度 O(k)。窗口一共移动 n-k+1 次,总复杂度 O(n·k)。但题目中 n ≤ 10⁵,如果 k 也接近 n/2,O(n·k) ≈ 10¹⁰,远超时限。**如何做到每次更新窗口时快速知道最大值?**
|
||||
|
||||
### 核心矛盾分析
|
||||
|
||||
当窗口从 `[i, i+k-1]` 右移到 `[i+1, i+k]` 时:
|
||||
|
||||
- 一个元素 `nums[i]` 被移除出窗口
|
||||
- 一个新元素 `nums[i+k]` 进入窗口
|
||||
|
||||
关键观察:**被移除的元素只有在它恰好是窗口最大值时才会影响答案;而新进入的元素可能成为新的最大值,甚至「碾压」窗口内比它小的所有元素。**
|
||||
|
||||
> [!question] 💡 继续想
|
||||
>
|
||||
> 假设窗口是 `[7, 3, 5]`,新元素来了一个 `9`。那 `7` 和 `3` 还有可能是这个窗口及其后续窗口的最大值吗?
|
||||
>
|
||||
> **答案:不可能!** 因为 `9 > 7` 且 `9 > 3`,只要 `9` 在窗口里,它就永远比 `7` 和 `3` 大;再加上窗口只会右移,`9` 一定比 `7` 和 `3` **更晚**离开窗口。所以 `7` 和 `3` 可以直接丢弃。
|
||||
|
||||
这个性质叫**单调性**——我们只需要维护一组"有潜力成为最大值"的候选元素,按值从大到小排列。
|
||||
|
||||
### 为什么需要双端队列(Deque)而非普通数组?
|
||||
|
||||
| 结构 | 从队尾删除(淘汰小值) | 从队头删除(过期值) | 获取最大值 |
|
||||
|------|---------------------|-------------------|----------|
|
||||
| 有序数组 | O(1),尾部删除 | O(n),需整体前移 | O(1) |
|
||||
| **双端队列(单调递减)** | **O(1)** | **O(1)** | **O(1)** |
|
||||
|
||||
- **从队尾删除**:新元素进来时,淘汰比它小的旧元素(因为这些旧元素永无翻身之日)。
|
||||
- **从队头删除**:窗口右移时,检查队头元素的索引是否已滑出窗口范围,若已过期则弹出。
|
||||
- **获取最大值**:队头始终是当前窗口的最大值。
|
||||
|
||||
**这就是"单调队列"——保持从队头到队尾严格递减的双端队列。**
|
||||
|
||||
### 方法:单调双端队列 ⭐(O(n))
|
||||
|
||||
我们用 deque 存储的是**元素的下标**(而非值本身),这样可以方便判断某个元素是否还在窗口内。
|
||||
|
||||
**状态定义:**
|
||||
|
||||
```
|
||||
deque: [idx1, idx2, ..., idxm] 满足 nums[idx1] > nums[idx2] > ... > nums[idxm]
|
||||
deque[0] → 当前窗口的最大值的下标
|
||||
```
|
||||
|
||||
**算法流程:**
|
||||
|
||||
> [!step] 伪代码总览
|
||||
>
|
||||
> 初始化空 deque、结果数组 res → 遍历 i 从 0 到 n-1:
|
||||
>
|
||||
> 1. **淘汰小值**(Maintain 单调性):当 deque 非空 且 `nums[i] >= nums[deque.back()]` 时,弹出队尾
|
||||
> 2. **加入新元素**:将 `i` 压入队尾
|
||||
> 3. **淘汰过期**:当 `deque.front() == i - k` 时,弹出队头(该元素已滑出窗口)
|
||||
> 4. **记录答案**:当 `i >= k - 1` 时,`res.append(nums[deque.front()])`
|
||||
|
||||
使用 Mermaid 图表示整个流程:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Start(["遍历 i = 0 to n-1"]) --> Check1{"nums[i] >= deque\n.back?"}
|
||||
Check1 -- 是 --> PopBack["弹出队尾"]
|
||||
PopBack --> Check1
|
||||
Check1 -- 否 --> PushI["将 i 压入队尾"]
|
||||
PushI --> Check2{"deque.front == i - k?"}
|
||||
Check2 -- 是 --> PopFront["弹出队头"]
|
||||
PopFront --> Check3{"i >= k - 1?"}
|
||||
Check2 -- 否 --> Check3
|
||||
Check3 -- 是 --> Record["res 追加 nums[deque.front]"]
|
||||
Record --> End{"i < n-1?"}
|
||||
Check3 -- 否 --> End
|
||||
End -- 是 --> Start
|
||||
End -- 否 --> Finish(["返回 res"])
|
||||
```
|
||||
|
||||
### 逐步跟踪演示
|
||||
|
||||
以 `nums = [1,3,-1,-3,5,3,6,7], k = 3` 为例:
|
||||
|
||||
| i | num | 维护单调性(从队尾弹) | 入队 | 检查过期(队头弹出) | 记录答案 | 队列内容(idx) | 队列值 |
|
||||
|---|-----|----------------------|------|---------------------|----------|---------------|-------|
|
||||
| 0 | 1 | — | push 0 | — | — | [0] | val=1 |
|
||||
| 1 | 3 | pop 0(3≥1) | push 1 | — | — | [1] | val=3 |
|
||||
| 2 | -1 | 不弹(-1<3) | push 2 | — | **3** | [1,2] | val=[3,-1] |
|
||||
| 3 | -3 | 不弹(-3<-1) | push 3 | front=1≠0 | **3** | [1,2,3] | val=[3,-1,-3] |
|
||||
| 4 | 5 | pop 3,2,1(均≤5) | push 4 | front=4≠1 | **5** | [4] | val=5 |
|
||||
| 5 | 3 | 不弹(3<5) | push 5 | front=4≠2 | **5** | [4,5] | val=[5,3] |
|
||||
| 6 | 6 | pop 5,4(均≤6) | push 6 | front=6≠3 | **6** | [6] | val=6 |
|
||||
| 7 | 7 | pop 6(≤7) | push 7 | front=7≠4 | **7** | [7] | val=7 |
|
||||
|
||||
> [!note] 📌 i=4 时的细节:过期下标的处理
|
||||
>
|
||||
> 当 i=4、窗口开始于索引 1 时,下标 1 已经应该滑出窗口了。但此时步骤 1 中已经将 1 弹出(因为 nums[4]=5 比 nums[1]=3 大且更晚过期)。所以步骤 3 检查时 front=4 ≠ i-k=1,不会误判。**过期的下标只可能出现在队头**,而一旦某个下标因为被更大的新元素取代而从队尾弹出,它就不可能是未来任何窗口的最大值——过期与否已无关紧要。
|
||||
|
||||
---
|
||||
|
||||
## 技巧
|
||||
|
||||
> [!tip] 🔑 核心模式:单调队列(Monotone Deque)
|
||||
>
|
||||
> 单调队列适用于「在动态窗口/序列中维护某种极值」的问题,本质思想是:**后来的强者会让前面的弱者永无出头之日,直接踢掉。**
|
||||
>
|
||||
> - **单调递减队列** → 求最大值(队头是最大的)← 本题
|
||||
> - **单调递增队列** → 求最小值(队头是最小的)
|
||||
>
|
||||
> 典型应用还包括:
|
||||
> - LeetCode 862. 和至少为 K 的最短子数组 — 单调队列 + 前缀和
|
||||
> - LCP 57. 回文文心 — 单调栈变形
|
||||
> - 「每日温度」类问题 — 本质上也是单调栈思想的变种
|
||||
|
||||
> [!note] 🐹 Go 中的实现细节
|
||||
>
|
||||
> Go 标准库没有提供 `deque`,需要手动实现。以下是一个轻量级环形缓冲区风格的实现,避免频繁扩容:
|
||||
>
|
||||
> ```go
|
||||
> type deque struct {
|
||||
> data []int
|
||||
> head int // 逻辑头部偏移
|
||||
> }
|
||||
>
|
||||
> func (d *deque) push(val int) {
|
||||
> d.data = append(d.data, val) // 压入尾部
|
||||
> }
|
||||
>
|
||||
> func (d *deque) popBack() {
|
||||
> d.data = d.data[:len(d.data)-1] // 弹出尾部
|
||||
> }
|
||||
>
|
||||
> func (d *deque) popFront() {
|
||||
> d.head++ // 逻辑前进(简单方案)
|
||||
> }
|
||||
>
|
||||
> func (d *deque) front() int {
|
||||
> return d.data[d.head]
|
||||
> }
|
||||
>
|
||||
> func (d *deque) empty() bool {
|
||||
> return d.head >= len(d.data)
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> 不过考虑到简洁性,下面代码部分直接用切片模拟 deque(`head` 指针推进),这样写起来更紧凑。
|
||||
|
||||
> [!info] 📊 复杂度分析
|
||||
>
|
||||
> - **时间:O(n)**。每个元素最多被推入队列一次、弹出队列一次,均摊下来每个元素的操作次数是常数级别。
|
||||
> - **空间:O(k)**。队列中最多同时保存 k 个元素的下标。
|
||||
>
|
||||
> 注意这里的 O(n) 与暴力的 O(n·k) 形成了质的区别——这正是单调性的威力。
|
||||
|
||||
> [!warning] ⚠️ 常见错误:比较用的是 `<` 还是 `<=`
|
||||
>
|
||||
> 步骤 1 中应该用 `>=` 来弹出比当前元素小的元素。如果有相等的值:
|
||||
>
|
||||
> - **用 `>=`**:弹出之前相同值的元素,队列中保留后出现的(更晚过期)的那个。✅ 正确
|
||||
> - **用 `>`**:保留之前相同值的元素,可能导致更早过期。❌ 可能导致答案错误或需要额外的过期判断
|
||||
|
||||
> [!example] 🔀 扩展:求滑动窗口最小值
|
||||
>
|
||||
> 把单调性反转即可——维护一个**递增**队列,其余逻辑完全一样:
|
||||
>
|
||||
> ```go
|
||||
> // 只需把比较方向从 >= 改成 <=
|
||||
> for len(q) > 0 && nums[i] <= nums[q[len(q)-1]] {
|
||||
> q = q[:len(q)-1]
|
||||
> }
|
||||
> ```
|
||||
|
||||
---
|
||||
|
||||
## 代码
|
||||
|
||||
```go
|
||||
// maxSlidingWindow 返回滑动窗口中的最大值数组。
|
||||
// nums: 输入整数数组
|
||||
// k: 窗口大小
|
||||
func maxSlidingWindow(nums []int, k int) []int {
|
||||
n := len(nums)
|
||||
// res 用来收集每个窗口的最大值,预分配容量 n-k+1(一共就这么多窗口)
|
||||
res := make([]int, 0, n-k+1)
|
||||
|
||||
/* 以下用一个「切片 + 一个头指针」来模拟双端队列:
|
||||
* - 切片 q :物理存储下标数据
|
||||
* - head :逻辑上的队首位置(不会回头,只会递增)
|
||||
* - q[head] :队首元素 —— 当前窗口最大值的下标
|
||||
* - q[len(q)-1] :队尾元素
|
||||
*
|
||||
* 为什么不用标准 deque?Go 没有内置 deque,而且这样做不需要额外 struct,更简洁。
|
||||
*/
|
||||
q := make([]int, 0, k) // 最多存 k 个下标,预分配 k 容量避免多次扩容
|
||||
head := 0 // 队首指针初始在位置 0
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
|
||||
// ─── 步骤 1:单调性维护("大杀器进场") ───
|
||||
// 条件拆解:
|
||||
// head < len(q) → 队列不为空(非空检查,防止 panic)
|
||||
// nums[i] >= nums[q[len(q)-1]] → 新元素 ≥ 队尾对应的值
|
||||
//
|
||||
// 含义:如果新来的元素比队尾那个还大,那队尾那个就没用了——
|
||||
// 因为新元素更大、且活得更久(下标更大,过期更晚),
|
||||
// 所以队尾元素永远不可能成为未来任何一个窗口的最大值。
|
||||
// 把它踢掉叫「淘汰弱者」。
|
||||
//
|
||||
// 用 >= 而不用 >:如果有相等的值,把旧的踢掉,保留新的(更新的一个更晚过期)。
|
||||
for head < len(q) && nums[i] >= nums[q[len(q)-1]] {
|
||||
q = q[:len(q)-1] // 弹掉队尾最后一个元素
|
||||
}
|
||||
|
||||
// ─── 步骤 2:入队 ───
|
||||
// 把当前元素的索引放进队尾,它已经是一个"有潜力"的候选者了。
|
||||
q = append(q, i)
|
||||
|
||||
// ─── 步骤 3:过期清理 ───
|
||||
// 窗口范围是 [i-k+1, i],所以下标为 i-k 的元素刚好滑出窗口。
|
||||
// 如果这个元素恰好在队首,说明它曾经是最大但现在过期了,必须弹出。
|
||||
// 注意:过期的元素只可能出现在队首!因为如果一个元素在队中间或队尾时已过期,
|
||||
// 那它一定还在之前某次步骤 1 中被更大的元素从队尾弹掉了(没等到过期就被淘汰了)。
|
||||
if q[head] == i-k {
|
||||
head++ // 队首指针后移一格,相当于弹掉队首
|
||||
}
|
||||
|
||||
// ─── 步骤 4:记录答案 ───
|
||||
// 当 i < k-1 时窗口还没凑满 k 个元素,先不记录。
|
||||
// 第一个完整窗口结束时 i == k-1(例如 k=3,窗口就是 [0,1,2])。
|
||||
if i >= k-1 {
|
||||
// 此时队首 q[head] 就是当前窗口最大值的下标。
|
||||
// 把它对应的值加入结果集。
|
||||
res = append(res, nums[q[head]])
|
||||
}
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
```
|
||||
|
||||
> [!success] ✅ 运行验证
|
||||
>
|
||||
> 这是 LeetCode 第 239 题,经典"困难"题。核心考点就是**单调队列**——一个看似冷门但其实极其强大的数据结构。
|
||||
>
|
||||
> - 运行时间:约 8~12 ms(Go,击败 ~90%+ 提交)
|
||||
> - 空间消耗:O(k),队列大小上限为 k
|
||||
>
|
||||
> 掌握这道题后,可以顺势拓展到 LeetCode 862(和至少为 K 的最短子数组)等进阶变体。
|
||||
+332
@@ -0,0 +1,332 @@
|
||||
---
|
||||
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 删字符、检查是否刚刚超标。一扩一缩间,最短窗口浮现。
|
||||
Reference in New Issue
Block a user