想系统提升编程能力、查看更完整的学习路线,欢迎访问 AI Compass:github.com/tingaicompa… 仓库持续更新刷题题解、Python 基础和 AI 实战内容,适合想高效进阶的你。
📖 第17课:滑动窗口最大值
模块:滑动窗口 | 难度:Hard ⭐⭐ LeetCode 链接:leetcode.cn/problems/sl… 前置知识:第14-16课 - 滑动窗口系列 预计学习时间:30分钟
🎯 题目描述
给你一个整数数组 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^41 <= k <= nums.length
🧪 边界用例(面试必考)
| 用例类型 | 输入 | 期望输出 | 考察点 |
|---|---|---|---|
| k=1 | nums=[1,2,3], k=1 | [1,2,3] | 窗口大小为1,每个元素都是最大值 |
| k=n | nums=[1,2,3], k=3 | [3] | 窗口覆盖整个数组,只有一个输出 |
| 递增序列 | nums=[1,2,3,4,5], k=3 | [3,4,5] | 窗口最大值始终是右边界 |
| 递减序列 | nums=[5,4,3,2,1], k=3 | [5,4,3] | 窗口最大值始终是左边界 |
| 有负数 | nums=[-1,-3,-5], k=2 | [-1,-3] | 负数情况,最大值可能是负数 |
| 波动序列 | nums=[1,3,-1,-3,5,3,6,7], k=3 | [3,3,5,5,6,7] | 经典用例,窗口最大值变化 |
💡 思路引导
生活化比喻
想象你在看一场接力赛,赛道上有 n 个选手排成一列,你手里有一个观察窗(只能看到连续的 k 个选手)。观察窗从左边开始,每次向右移动一格,你要记录每次观察窗内跑得最快的选手是谁。
🐌 笨办法:每次观察窗移动,你都把窗口内的 k 个选手从头到尾看一遍,找出跑得最快的。窗口要移动 n-k+1 次,每次都要看 k 个人,总共要看 (n-k+1) × k 次,累死了!这就是暴力法,时间 O(nk)。
🚀 聪明办法:你用一个**"候选榜"**(单调队列)来记录"有可能成为最大值的选手":
- 榜首永远是当前窗口的最快选手
- 当观察窗右移加入新选手时:
- 如果新选手比榜尾的选手快,就把榜尾那些"永远不可能成为最大值"的选手踢掉(因为新选手既更快又更靠右,榜尾的选手没希望了)
- 把新选手加入榜尾
- 当观察窗左移离开旧选手时:
- 如果榜首选手已经不在窗口内了(过期了),就把他从榜首踢掉
- 榜首选手就是当前窗口的最大值
这样每个选手最多进榜一次、出榜一次,总时间 O(n),比暴力法快 k 倍!
关键洞察
固定长度窗口 + 求最大值 → 用单调递减队列维护"有可能成为最大值的候选者" → 队首永远是当前窗口最大值
🧠 解题思维链
这一节模拟你在面试中"从零开始思考"的过程。
Step 1:理解题目 → 锁定输入输出
- 输入:整数数组
nums(长度 1~10^5),窗口大小k - 输出:每个窗口的最大值组成的数组(长度 n-k+1)
- 限制:
- 窗口大小固定为 k
- 窗口每次向右移动一位
- 需要返回每个窗口位置的最大值
Step 2:先想笨办法(暴力法)
对于每个窗口位置 i(i 从 0 到 n-k),找出 nums[i:i+k] 的最大值。
- 外层循环枚举窗口位置:O(n-k+1) ≈ O(n)
- 内层循环找窗口内最大值:O(k)
- 总时间复杂度:O(nk)
- 瓶颈在哪:每次窗口移动都要重新扫描 k 个元素找最大值
Step 3:瓶颈分析 → 优化方向
暴力法的核心问题:
- 窗口 [i, i+k-1] 和窗口 [i+1, i+k] 有 k-1 个元素重叠,但我们每次都重新找最大值,浪费计算
- 能不能利用"窗口移动"这个特性,动态维护最大值?
优化思路:
- 方法1:用大顶堆维护窗口内元素 → 但删除任意元素是 O(k),不够优
- 方法2:用单调队列 → 队列中存储"有可能成为最大值的下标",队首是最大值
Step 4:选择武器
- 选用:单调递减队列(Monotonic Decreasing Deque)
- 理由:
- 队列中的元素从队首到队尾单调递减
- 队首元素永远是当前窗口的最大值
- 加入新元素时,从队尾删除所有小于新元素的"无用元素"
- 窗口滑动时,如果队首元素过期(不在窗口内),从队首删除
- 每个元素最多入队一次、出队一次,时间 O(n)
🔑 模式识别提示:当题目出现"固定长度滑动窗口"+"求窗口内最大值/最小值",优先考虑"单调队列"
🔑 解法一:暴力法(直觉法)
思路
对于每个窗口位置,遍历窗口内的 k 个元素,找出最大值。
图解过程
示例:nums = [1, 3, -1, -3, 5, 3, 6, 7], k = 3
窗口1: [1, 3, -1]
遍历: 1, 3, -1 → max = 3
窗口2: [3, -1, -3]
遍历: 3, -1, -3 → max = 3
窗口3: [-1, -3, 5]
遍历: -1, -3, 5 → max = 5
窗口4: [-3, 5, 3]
遍历: -3, 5, 3 → max = 5
窗口5: [5, 3, 6]
遍历: 5, 3, 6 → max = 6
窗口6: [3, 6, 7]
遍历: 3, 6, 7 → max = 7
结果: [3, 3, 5, 5, 6, 7]
Python代码
from typing import List
def max_sliding_window_brute(nums: List[int], k: int) -> List[int]:
"""
解法一:暴力法
思路:对每个窗口遍历 k 个元素找最大值
"""
n = len(nums)
result = []
for i in range(n - k + 1): # 枚举窗口起点
window_max = max(nums[i:i+k]) # 找窗口内最大值
result.append(window_max)
return result
# ✅ 测试
print(max_sliding_window_brute([1, 3, -1, -3, 5, 3, 6, 7], 3)) # 期望输出:[3, 3, 5, 5, 6, 7]
print(max_sliding_window_brute([1], 1)) # 期望输出:[1]
print(max_sliding_window_brute([1, -1], 1)) # 期望输出:[1, -1]
print(max_sliding_window_brute([9, 11], 2)) # 期望输出:[11]
复杂度分析
- 时间复杂度:O(nk) — n-k+1 个窗口,每个窗口找最大值需要 O(k)
- 如果 n=100000, k=10000,需要约 10^9 次操作,会超时
- 空间复杂度:O(1) — 不计输出数组,只用了几个变量
优缺点
- ✅ 思路直观,代码简单
- ✅ 空间复杂度低
- ❌ 时间复杂度 O(nk),数据量大时必然超时
- ❌ 重复计算,没有利用窗口移动的特性
⚡ 解法二:单调递减队列(最优解)
优化思路
用一个双端队列(deque)维护一个"候选榜",队列中存储的是元素的下标,且队列中的元素从队首到队尾对应的值单调递减。
核心思想:
- 队首元素永远是当前窗口的最大值的下标
- 加入新元素时,从队尾删除所有小于新元素的"无用元素"(因为新元素既更大又更靠右,那些小元素永远不可能成为最大值了)
- 窗口滑动时,如果队首元素的下标已经不在窗口内(过期),从队首删除
💡 关键想法:队列中只保留"有可能成为未来某个窗口最大值"的元素。如果一个元素比新加入的元素小,且位置更靠左,它就永远没有机会成为最大值了,可以直接踢掉。
图解过程
示例:nums = [1, 3, -1, -3, 5, 3, 6, 7], k = 3
队列中存储的是下标,显示时用 [下标:值] 表示
初始状态:deque = [], result = []
Step 1: i=0, 加入 nums[0]=1
1. 队列为空,直接加入 → deque = [0:1]
2. 窗口还没形成(i < k-1),不输出
Step 2: i=1, 加入 nums[1]=3
1. 3 > 1(队尾元素),从队尾删除 0:1 → deque = []
2. 加入 1:3 → deque = [1:3]
3. 窗口还没形成(i < k-1),不输出
Step 3: i=2, 加入 nums[2]=-1
1. -1 < 3(队尾元素),不删除
2. 加入 2:-1 → deque = [1:3, 2:-1]
3. 窗口已形成!队首 1:3 → 输出 3
result = [3]
当前窗口: [1, 3, -1]
队列状态: [1:3, 2:-1] (3 > -1,单调递减✅)
队首 3 是最大值 ✅
Step 4: i=3, 加入 nums[3]=-3
1. -3 < -1(队尾元素),不删除
2. 加入 3:-3 → deque = [1:3, 2:-1, 3:-3]
3. 检查队首:下标 1 是否过期?窗口是 [1,3],1 在窗口内,不删除
4. 队首 1:3 → 输出 3
result = [3, 3]
当前窗口: [3, -1, -3]
队列状态: [1:3, 2:-1, 3:-3] (3 > -1 > -3,单调递减✅)
队首 3 是最大值 ✅
Step 5: i=4, 加入 nums[4]=5
1. 5 > -3(队尾元素),从队尾删除 3:-3 → deque = [1:3, 2:-1]
2. 5 > -1(队尾元素),从队尾删除 2:-1 → deque = [1:3]
3. 5 > 3(队尾元素),从队尾删除 1:3 → deque = []
4. 加入 4:5 → deque = [4:5]
5. 检查队首:下标 4 在窗口 [2,4] 内,不删除
6. 队首 4:5 → 输出 5
result = [3, 3, 5]
当前窗口: [-1, -3, 5]
队列状态: [4:5]
队首 5 是最大值 ✅
Step 6: i=5, 加入 nums[5]=3
1. 3 < 5(队尾元素),不删除
2. 加入 5:3 → deque = [4:5, 5:3]
3. 检查队首:下标 4 在窗口 [3,5] 内,不删除
4. 队首 4:5 → 输出 5
result = [3, 3, 5, 5]
当前窗口: [-3, 5, 3]
队列状态: [4:5, 5:3] (5 > 3,单调递减✅)
队首 5 是最大值 ✅
Step 7: i=6, 加入 nums[6]=6
1. 6 > 3(队尾元素),从队尾删除 5:3 → deque = [4:5]
2. 6 > 5(队尾元素),从队尾删除 4:5 → deque = []
3. 加入 6:6 → deque = [6:6]
4. 检查队首:下标 6 在窗口 [4,6] 内,不删除
5. 队首 6:6 → 输出 6
result = [3, 3, 5, 5, 6]
当前窗口: [5, 3, 6]
队列状态: [6:6]
队首 6 是最大值 ✅
Step 8: i=7, 加入 nums[7]=7
1. 7 > 6(队尾元素),从队尾删除 6:6 → deque = []
2. 加入 7:7 → deque = [7:7]
3. 检查队首:下标 7 在窗口 [5,7] 内,不删除
4. 队首 7:7 → 输出 7
result = [3, 3, 5, 5, 6, 7]
当前窗口: [3, 6, 7]
队列状态: [7:7]
队首 7 是最大值 ✅
最终结果: [3, 3, 5, 5, 6, 7] ✅
Python代码
from typing import List
from collections import deque
def max_sliding_window(nums: List[int], k: int) -> List[int]:
"""
解法二:单调递减队列
思路:维护一个单调递减的双端队列,队首是当前窗口最大值的下标
"""
n = len(nums)
dq = deque() # 存储下标,对应的值单调递减
result = []
for i in range(n):
# 1. 移除队首过期元素(不在窗口内的)
while dq and dq[0] < i - k + 1:
dq.popleft()
# 2. 维护单调性:移除队尾所有小于当前元素的下标
# (因为当前元素更大且更靠右,那些元素永远不可能成为最大值)
while dq and nums[dq[-1]] < nums[i]:
dq.pop()
# 3. 加入当前元素的下标
dq.append(i)
# 4. 窗口形成后,队首元素就是当前窗口的最大值
if i >= k - 1:
result.append(nums[dq[0]])
return result
# ✅ 测试
print(max_sliding_window([1, 3, -1, -3, 5, 3, 6, 7], 3)) # 期望输出:[3, 3, 5, 5, 6, 7]
print(max_sliding_window([1], 1)) # 期望输出:[1]
print(max_sliding_window([1, -1], 1)) # 期望输出:[1, -1]
print(max_sliding_window([9, 11], 2)) # 期望输出:[11]
print(max_sliding_window([7, 2, 4], 2)) # 期望输出:[7, 4]
复杂度分析
- 时间复杂度:O(n) — 每个元素最多入队一次、出队一次,每次操作 O(1)
- 具体地说:如果 n=100000,只需要约 20 万次操作,比暴力法快 k 倍!
- 空间复杂度:O(k) — 队列中最多存储 k 个元素的下标
🐍 Pythonic 写法
利用 Python 的 deque 简化代码:
from typing import List
from collections import deque
def max_sliding_window_pythonic(nums: List[int], k: int) -> List[int]:
"""
Pythonic 写法:单调递减队列(简化版)
"""
dq = deque()
result = []
for i, num in enumerate(nums):
# 移除过期元素
if dq and dq[0] == i - k:
dq.popleft()
# 维护单调性
while dq and nums[dq[-1]] < num:
dq.pop()
dq.append(i)
# 输出结果
if i >= k - 1:
result.append(nums[dq[0]])
return result
# ✅ 测试
print(max_sliding_window_pythonic([1, 3, -1, -3, 5, 3, 6, 7], 3)) # 期望输出:[3, 3, 5, 5, 6, 7]
这个写法用到了:
enumerate():同时获取下标和值- 条件简化:
dq[0] == i - k而不是dq[0] < i - k + 1,逻辑更清晰
⚠️ 面试建议:推荐使用解法二的标准写法,逻辑最清晰。面试时先画图说明单调队列的工作原理,再写代码。重点强调"为什么可以删除队尾的小元素"——这是单调队列的核心思想。
📊 解法对比
| 维度 | 解法一:暴力法 | 解法二:单调队列 |
|---|---|---|
| 时间复杂度 | O(nk) | O(n) |
| 空间复杂度 | O(1) | O(k) |
| 代码难度 | 简单 | 中等 |
| 面试推荐 | ⭐ | ⭐⭐⭐ |
| 适用场景 | 小规模数据或说明思路 | 面试首选,高效且经典 |
面试建议:先用 30 秒口述暴力法思路和复杂度(展示你能想到基本解法),然后重点讲解单调队列优化(展示优化能力)。关键点在于画图说明"为什么队列中的元素保持单调递减",以及"为什么可以删除队尾的小元素"。
🎤 面试现场
模拟面试中的完整对话流程,帮你练习"边想边说"。
面试官:给你一个数组和一个窗口大小 k,返回每个窗口的最大值。
你:(审题 30 秒)好的,让我确认一下——输入是一个数组 nums 和一个窗口大小 k,窗口从左到右滑动,每次移动一位,我需要返回每个窗口位置的最大值,对吧?
面试官:没错。
你:好的。我先想一个最直接的办法:对于每个窗口位置,遍历窗口内的 k 个元素找最大值。时间 O(nk),空间 O(1)。不过当 k 很大时会很慢。
让我想想怎么优化……关键问题是:窗口每次只移动一位,有 k-1 个元素重叠,我们能不能利用这个特性避免重复计算?
我想到的优化方案是用单调递减队列:
- 队列中存储元素的下标
- 队列中的元素从队首到队尾对应的值单调递减
- 队首元素永远是当前窗口的最大值
具体做法:
- 遍历数组,对每个元素:
- 先检查队首元素是否过期(不在窗口内),如果过期就从队首删除
- 从队尾删除所有小于当前元素的"无用元素"(因为当前元素更大且更靠右)
- 把当前元素的下标加入队尾
- 如果窗口已形成(i >= k-1),队首元素就是当前窗口的最大值
时间 O(n),空间 O(k)。
面试官:为什么可以从队尾删除小元素?
你:好问题!假设队列中有元素 A(值为 5,下标为 2),现在要加入元素 B(值为 7,下标为 4)。因为:
- B 的值更大(7 > 5)
- B 的位置更靠右(4 > 2)
所以在未来的任何窗口中,如果 A 还在窗口内,那 B 一定也在窗口内,且 B 一定比 A 大。也就是说,A 永远不可能成为最大值了,可以直接删掉。
这就是"单调性"的核心——通过维护单调递减的队列,我们可以快速找到最大值。
面试官:很好,请写代码吧。
你:好的。(边写边说)
from collections import deque
def maxSlidingWindow(self, nums, k):
dq = deque() # 存储下标
result = []
for i in range(len(nums)):
# 1. 移除过期元素
while dq and dq[0] < i - k + 1:
dq.popleft()
# 2. 维护单调性:删除队尾小元素
while dq and nums[dq[-1]] < nums[i]:
dq.pop()
# 3. 加入当前元素
dq.append(i)
# 4. 输出结果
if i >= k - 1:
result.append(nums[dq[0]])
return result
关键点:
dq[0] < i - k + 1判断队首是否过期nums[dq[-1]] < nums[i]维护单调递减dq[0]永远是当前窗口最大值的下标
面试官:能手动跑一个例子验证一下吗?
你:好的,用 nums=[1,3,-1,-3,5,3,6,7], k=3:
- i=0: 加入 1 → dq=[0], 窗口未形成
- i=1: 加入 3,3>1 删除 0 → dq=[1], 窗口未形成
- i=2: 加入 -1 → dq=[1,2], 窗口形成,输出 nums[1]=3 ✅
- i=3: 加入 -3 → dq=[1,2,3], 输出 nums[1]=3 ✅
- i=4: 加入 5,5>所有元素 → dq=[4], 输出 nums[4]=5 ✅
- i=5: 加入 3 → dq=[4,5], 输出 nums[4]=5 ✅
- i=6: 加入 6,6>3,6>5 → dq=[6], 输出 nums[6]=6 ✅
- i=7: 加入 7,7>6 → dq=[7], 输出 nums[7]=7 ✅
结果:[3,3,5,5,6,7] ✅
高频追问
| 追问 | 应答策略 |
|---|---|
| "还有更优解吗?" | 时间已经是 O(n) 最优(至少要遍历一遍数组),这就是最优解了 |
| "如果求窗口最小值呢?" | 改成单调递增队列即可,队首是最小值,其他逻辑完全一样 |
| "为什么用 deque 而不是 list?" | deque 的 popleft() 是 O(1),list 的 pop(0) 是 O(n)。用 list 会导致总时间复杂度退化为 O(n²) |
| "如果 k 非常大呢?" | 空间复杂度 O(k),但队列中实际元素数量通常远小于 k(因为很多元素被删掉了) |
| "实际工程中什么场景会用到?" | 股票价格分析(固定时间窗口内的最高价)、网络流量监控(固定时间窗口的峰值流量)、时间序列数据分析(移动最大值) |
🎓 知识点总结
Python技巧卡片 🐍
from collections import deque
# deque — 双端队列,两端操作都是 O(1)
dq = deque([1, 2, 3])
# 常用操作
dq.append(4) # 右端加入:[1, 2, 3, 4]
dq.appendleft(0) # 左端加入:[0, 1, 2, 3, 4]
dq.pop() # 右端删除:[0, 1, 2, 3],返回 4
dq.popleft() # 左端删除:[1, 2, 3],返回 0
dq[0] # 访问队首:1
dq[-1] # 访问队尾:3
len(dq) # 长度:3
# ⚠️ list vs deque 的性能对比
# list.pop(0) → O(n)(需要移动所有元素)
# deque.popleft() → O(1)(直接删除)
# 所以单调队列必须用 deque,不能用 list!
# enumerate() — 同时拿到下标和值
for i, num in enumerate([10, 20, 30]):
print(i, num) # 0 10 / 1 20 / 2 30
💡 底层原理(选读)
为什么单调队列能保证 O(n) 时间?
关键在于均摊分析(Amortized Analysis):
- 每个元素最多入队一次(在
dq.append(i)时)- 每个元素最多出队一次(在
dq.pop()或dq.popleft()时)- 所以总共最多 2n 次队列操作,每次操作 O(1)
- 总时间 O(2n) = O(n)
为什么要存储下标而不是值?
- 需要判断元素是否过期(
dq[0] < i - k + 1),只有下标才能判断位置- 通过下标
dq[0]可以访问对应的值nums[dq[0]]单调队列 vs 堆(优先队列)的区别:
- 堆:可以 O(log n) 插入、O(log n) 删除最大值,但删除任意元素需要 O(n) 或 O(log n)(需要额外维护)
- 单调队列:可以 O(1) 从两端插入/删除,且队首永远是最大值/最小值
- 对于滑动窗口问题,单调队列更优,因为窗口移动时需要删除最左边的元素(可能不是最大值)
单调队列的本质:
- 是一种贪心思想:对于当前窗口,我们只关心"有可能成为最大值"的元素
- 如果一个元素比新加入的元素小,且位置更靠左,它就"没有希望"了,可以直接扔掉
- 这种"剪枝"让队列中的元素数量远小于 k,提高了效率
算法模式卡片 📐
- 模式名称:单调递减队列(Monotonic Decreasing Deque)
- 适用条件:
- 固定长度滑动窗口
- 需要高效获取窗口内最大值/最小值
- 窗口每次移动一位
- 识别关键词:"滑动窗口"+"最大值/最小值"+"固定长度"
- 模板代码:
from collections import deque
def sliding_window_max(nums: list[int], k: int) -> list[int]:
"""
单调递减队列模板 - 求滑动窗口最大值
"""
dq = deque() # 存储下标,对应的值单调递减
result = []
for i in range(len(nums)):
# 1. 移除队首过期元素(不在窗口内)
while dq and dq[0] < i - k + 1:
dq.popleft()
# 2. 维护单调性:移除队尾小于当前元素的下标
while dq and nums[dq[-1]] < nums[i]:
dq.pop()
# 3. 加入当前元素的下标
dq.append(i)
# 4. 窗口形成后,输出队首元素
if i >= k - 1:
result.append(nums[dq[0]])
return result
# 变体:求滑动窗口最小值
def sliding_window_min(nums: list[int], k: int) -> list[int]:
"""
单调递增队列 - 求滑动窗口最小值
只需把 nums[dq[-1]] < nums[i] 改为 nums[dq[-1]] > nums[i]
"""
dq = deque()
result = []
for i in range(len(nums)):
while dq and dq[0] < i - k + 1:
dq.popleft()
# 维护单调递增(这里改了!)
while dq and nums[dq[-1]] > nums[i]:
dq.pop()
dq.append(i)
if i >= k - 1:
result.append(nums[dq[0]])
return result
易错点 ⚠️
-
用 list 而不是 deque:
list.pop(0)是 O(n),会导致总时间复杂度退化为 O(n²)。必须用deque.popleft()O(1)。 -
队列中存储值而不是下标:无法判断元素是否过期。必须存储下标,通过
nums[dq[0]]访问值。 -
过期条件写错:应该是
dq[0] < i - k + 1(或等价的dq[0] <= i - k),很多人写成dq[0] < i - k,导致窗口大小不对。 -
单调性条件写反:求最大值用
nums[dq[-1]] < nums[i](单调递减),求最小值用nums[dq[-1]] > nums[i](单调递增)。很多人混淆。 -
忘记检查窗口是否形成:应该在
i >= k - 1时才输出结果,不要在i >= 0就输出。
🏗️ 工程实战(选读)
这个算法思想在真实项目中的应用,让你知道"学了有什么用"。
-
股票价格分析:金融系统中,需要实时计算"过去 N 分钟的最高价"或"过去 N 笔交易的最高价",用于技术分析指标(如布林带、MACD)。单调队列可以 O(1) 更新最大值,延迟极低。
-
网络流量监控:网络设备需要监控"过去 1 分钟的峰值流量",用于流量整形和 QoS 调度。滑动窗口 + 单调队列可以高效追踪峰值。
-
视频处理 - 去噪:视频帧序列中,用滑动窗口的最大值/中值滤波器去除噪点。单调队列可以加速最大值计算。
-
游戏开发 - 技能冷却:MOBA 游戏中,技能系统需要追踪"过去 N 秒内释放的最强技能",用于 UI 显示和 AI 决策。
🏋️ 举一反三
完成本课后,试试这些同类题目来巩固知识:
| 题目 | 难度 | 相关知识点 | 提示 |
|---|---|---|---|
| LeetCode 剑指Offer 59-II. 队列的最大值 | Medium | 单调队列 + 队列设计 | 维护一个单调递减的辅助队列 |
| LeetCode 862. 和至少为 K 的最短子数组 | Hard | 前缀和 + 单调队列 | 前缀和 + 单调队列优化 |
| LeetCode 1438. 绝对差不超过限制的最长连续子数组 | Medium | 单调队列 × 2 | 用两个单调队列分别维护最大值和最小值 |
| LeetCode 84. 柱状图中最大的矩形 | Hard | 单调栈 | 类似思想,用单调栈找左右第一个更小元素 |
| LeetCode 42. 接雨水 | Hard | 单调栈/双指针 | 可以用单调栈或双指针,思想类似 |
| LeetCode 1696. 跳跃游戏 VI | Medium | 单调队列 + DP | DP + 单调队列优化 |
📝 课后小测
试试这道变体题,不要看答案,自己先想 5 分钟!
题目:给定数组 nums 和窗口大小 k,返回每个窗口的最大值和最小值的差。
示例:nums = [1, 3, -1, -3, 5, 3, 6, 7], k = 3 → [4, 6, 8, 8, 3, 4]
- 窗口 [1,3,-1]:max=3, min=-1, 差=4
- 窗口 [3,-1,-3]:max=3, min=-3, 差=6
- ...
💡 提示 1(实在想不出来再点开)
需要同时维护窗口的最大值和最小值。能不能用两个单调队列?
💡 提示 2(再给你一个线索)
- 用一个单调递减队列维护最大值(和本题一样)
- 用一个单调递增队列维护最小值(把比较符号反过来)
- 每个窗口的差 =
nums[max_dq[0]] - nums[min_dq[0]]
✅ 参考答案
from collections import deque
def sliding_window_max_min_diff(nums: list[int], k: int) -> list[int]:
"""
变体题:滑动窗口最大值和最小值的差
思路:用两个单调队列分别维护最大值和最小值
"""
n = len(nums)
max_dq = deque() # 单调递减队列,队首是最大值
min_dq = deque() # 单调递增队列,队首是最小值
result = []
for i in range(n):
# === 维护最大值队列 ===
# 移除过期元素
while max_dq and max_dq[0] < i - k + 1:
max_dq.popleft()
# 维护单调递减
while max_dq and nums[max_dq[-1]] < nums[i]:
max_dq.pop()
max_dq.append(i)
# === 维护最小值队列 ===
# 移除过期元素
while min_dq and min_dq[0] < i - k + 1:
min_dq.popleft()
# 维护单调递增(这里改了!)
while min_dq and nums[min_dq[-1]] > nums[i]:
min_dq.pop()
min_dq.append(i)
# === 输出结果 ===
if i >= k - 1:
max_val = nums[max_dq[0]]
min_val = nums[min_dq[0]]
result.append(max_val - min_val)
return result
# 测试
print(sliding_window_max_min_diff([1, 3, -1, -3, 5, 3, 6, 7], 3))
# 期望输出:[4, 6, 8, 8, 3, 4]
# [1,3,-1]: 3-(-1)=4
# [3,-1,-3]: 3-(-3)=6
# [-1,-3,5]: 5-(-3)=8
# [-3,5,3]: 5-(-3)=8
# [5,3,6]: 6-3=3
# [3,6,7]: 7-3=4
核心思路:
- 单调递减队列维护最大值(队首是最大值)
- 单调递增队列维护最小值(队首是最小值)
- 两个队列的逻辑完全一样,只是比较符号相反
启示:单调队列是一种通用的数据结构,不仅能求最大值/最小值,还能扩展到很多变体问题。掌握了单调队列的核心思想,你就能灵活应对各种滑动窗口问题。
如果这篇内容对你有帮助,推荐收藏 AI Compass:github.com/tingaicompa… 更多系统化题解、编程基础和 AI 学习资料都在这里,后续复习和拓展会更省时间。