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

11 KiB
Raw Blame History

tags, create time
tags create time
LeetCode
单调队列
滑动窗口
困难
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 图表示整个流程:

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,需要手动实现。以下是一个轻量级环形缓冲区风格的实现,避免频繁扩容:

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] 🔀 扩展:求滑动窗口最小值

把单调性反转即可——维护一个递增队列,其余逻辑完全一样:

// 只需把比较方向从 >= 改成 <=
for len(q) > 0 && nums[i] <= nums[q[len(q)-1]] {
    q = q[:len(q)-1]
}

代码

// 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 的最短子数组)等进阶变体。