Files

293 lines
11 KiB
Markdown
Raw Permalink Normal View History

2026-05-14 23:30:50 +08:00
---
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 的最短子数组)等进阶变体。