Files
leetcode-go/子串/11-滑动窗口最大值.md
T

293 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 的最短子数组)等进阶变体。