单调队列与滑动窗口——在移动窗口中快速找到最值
极难3单调队列与滑动窗口——在移动窗口中快速找到最值
开头:这是什么?用来干什么?
想象你有一排考试成绩单,老师用一个“透明窗格”一次只看连续的3个成绩,然后记下最高分。窗格从左往右每次滑动一个位置,你需要知道每一个窗格里的最高分。如果每次都要重新比较窗格里的所有成绩,那效率太低。单调队列(Monotonic Queue)就是专门用来解决这类问题的:它可以在一个不断滑动的窗口中,快速(O(1)时间)告诉你当前窗口的最值(最大值或最小值),而整个过程只用O(n)时间。它是“队列”的升级版,结合了队列的先进先出和单调栈的单调性思想。
生活中的例子:排队体检的“最好视力”
学校体检,一排学生站好,老师用一个只能看到三个人的“视力窗口”从左向右移动。老师要记录每个窗口里视力最好的学生。
- 如果老师每次都要重新看窗口里的三个人,那一个窗口看完再移一个位置,又要重新看三个人,非常累。
- 聪明老师会这样:先看前三个,记住谁是最好。窗口向右移动一格时,左边出去一个,右边进来一个。如果新进来的视力比之前最好还好,那最好就换成新来的;如果出去的那个恰好是原来的最好,那老师就需要重新在窗口里找最好。这个过程就像维护一个“能快速知道最好”的队列。
单调队列就是这种“聪明老师”的计算机版本。它用一个双端队列(Deque,两端都可以进出)存储下标,并始终保持队列中的元素值单调递减(队首最大)或单调递增(队首最小)。这样,队首永远是最值。
单调队列的核心思想
问题的精确描述
给你一个数组 nums(比如考试成绩),和一个窗口大小 k,窗口从最左端滑到最右端,每次滑动一位,输出每个窗口的最大值(或最小值)。
例子:nums = [1,3,-1,-3,5,3,6,7],k=3
输出应为 [3,3,5,5,6,7] (每个窗口的最大值)
为什么不用暴力方法?
暴力方法:每个窗口遍历k个元素,找出最大值。总时间O(n*k),当n很大、k也很大时(比如n=10万,k=5万),可能要计算50亿次,太慢。单调队列只需O(n),每个元素最多入队一次、出队一次。
单调队列的设计细节
我们维护一个双端队列(deque),里面存的是数组下标,不是值。为什么存下标?因为下标可以判断元素是否还在窗口内,同时可以通过下标访问对应的值。
队列的单调性:从队首到队尾,下标对应的值严格单调递减(队首最大)。这样队首永远是当前窗口最大值。
入队规则(push):
新元素(下标 i)要进来时,先从队尾开始,把所有值小于等于 nums[i] 的元素全部弹出。因为那些元素的值更小,而且位置更靠左,在未来的窗口中绝对不可能成为最大值了(新元素更靠右且更大,窗口向右移动时,左边的小元素永远被新的大元素压制)。然后,把 i 从队尾入队。
出队规则(pop):
窗口向右滑动时,队首元素(下标)可能滑出窗口(即 队首下标 < i - k + 1),此时把它从队首弹出。
取最值:
直接取队首元素对应的值,就是当前窗口的最大值。
为什么这样是对的?
因为窗口是连续滑动的,那些“老”且“小”的元素永远无法翻身(新的大元素总是比它们更靠右,所以在任何后续窗口中,只要新元素还在,就轮不到它们当最大值)。而即使当前最大值滑出了窗口,队列中剩下的下一个最大的元素会自动成为新队首(因为单调递减)。
ASCII示意图(用表格更清楚)
| 步骤 | 当前下标i | nums[i] | 窗口范围 | 队列(存下标) | 最大值 | 动作 |
|---|---|---|---|---|---|---|
| 0 | 0 | 1 | – | 空 | – | 入队0,队列 [0] |
| 1 | 1 | 3 | – | [0] | – | 3>1,弹出0,入队1,队列 [1] |
| 2 | 2 | -1 | 0~2 | [1] | – | -1<3,直接入队2,队列 [1,2] |
| 3 | 3 | -3 | 1~3 | [1,2] | – | 队首1在窗口内;-3<-1,入队3,队列 [1,2,3] |
| 4 | 4 | 5 | 2~4 | [1,2,3] | – | 队首1滑出(1<2),弹出1;5> -1? 弹出3;5>-1? 弹出2;队列空,入队4 |
| 5 | 5 | 3 | 3~5 | [4] | – | 队首4在窗口内;3<5,直接入队5,队列 [4,5] |
| 6 | 6 | 6 | 4~6 | [4,5] | – | 队首4还在窗口;6>3? 弹出5;6>5? 弹出4;队列空,入队6 |
| 7 | 7 | 7 | 5~7 | [6] | – | 队首6还在窗口;7>6? 弹出6;队列空,入队7 |
最终得到序列 [3,3,5,5,6,7]。
新手容易犯的错误
- 忘记检查队首是否滑出窗口
每次滑动后,必须先检查队首下标是否小于i - k + 1,否则可能把已经不在窗口中的元素当作最大值。 - 出队时用了错误的条件
有些同学误用while (!dq.empty() && dq.front() < i - k),正确应该是< i - k + 1。因为窗口左边界是i - k + 1,下标小于这个值才算滑出。比如 i=2,k=3时,左边界=0,队首0正好在窗口内,条件0<0?假,正确。若用< i - k(即< -1),永远不触发,错了。 - 单调性判断用了大于还是小于
求最大值时,要维持单调递减,所以从队尾弹出所有<= nums[i]的元素(即比新元素小或相等的,因为它们以后不可能成为最大值)。如果用了<,那么相等的情况会保留,可能导致队列中保留多个相同值,虽然不影响结果但浪费空间。用<=更简洁。 - 存储值而非下标
如果只存值,无法判断元素是否滑出窗口,因为不知道它的位置。一定要存下标。 - 窗口未完全形成时提前记录
只有当下标 i >= k-1 时,窗口中才有 k 个元素,此时才能记录结果。
完整可运行代码示例
C++ 版本(含详细注释)
#include <iostream>
#include <vector>
#include <deque>
using namespace std;
// 函数:返回每个滑动窗口的最大值
vector<int> maxSlidingWindow(const vector<int>& nums, int k) {
int n = nums.size(); // 数组总长度
if (n == 0 || k <= 0) return {};
vector<int> result; // 存储每个窗口的最大值
deque<int> dq; // 双端队列,存储下标,从队首到队尾递减
for (int i = 0; i < n; ++i) {
// 1. 移除队首已经滑出窗口的元素(下标 < 窗口左边界 i - k + 1)
while (!dq.empty() && dq.front() < i - k + 1) {
dq.pop_front(); // 从队首弹出过期下标
}
// 2. 维护单调递减:从队尾移除所有值小于等于当前元素的元素
// 因为这些元素在当前位置 i 之后不可能再成为最大值
while (!dq.empty() && nums[dq.back()] <= nums[i]) {
dq.pop_back(); // 从队尾弹出较小的元素
}
// 3. 当前元素下标入队(它一定比队尾所有元素大,保持单调递减)
dq.push_back(i);
// 4. 当窗口形成(i >= k-1)时,记录当前窗口最大值(队首)
if (i >= k - 1) {
result.push_back(nums[dq.front()]); // 队首即最大值
}
}
return result;
}
// 辅助函数:打印数组
void printVector(const vector<int>& vec) {
cout << "[";
for (size_t i = 0; i < vec.size(); ++i) {
cout << vec[i];
if (i != vec.size() - 1) cout << ", ";
}
cout << "]" << endl;
}
int main() {
vector<int> nums = {1, 3, -1, -3, 5, 3, 6, 7};
int k = 3;
vector<int> res = maxSlidingWindow(nums, k);
cout << "原数组: ";
printVector(nums);
cout << "滑动窗口最大值: ";
printVector(res); // [3, 3, 5, 5, 6, 7]
return 0;
}
Python 版本
from collections import deque
def max_sliding_window(nums, k):
"""返回滑动窗口最大值列表"""
n = len(nums) # 数组长度
if n == 0 or k <= 0:
return []
result = [] # 存储结果
dq = deque() # 双端队列,存储下标,单调递减(队首最大)
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
# 测试
nums = [1, 3, -1, -3, 5, 3, 6, 7]
k = 3
res = max_sliding_window(nums, k)
print("原数组:", nums)
print("滑动窗口最大值:", res) # [3, 3, 5, 5, 6, 7]
变体:求滑动窗口最小值
只需将单调性改为单调递增(队首最小),并将弹出条件改为 >= 即可。
// 滑动窗口最小值,C++实现
vector<int> minSlidingWindow(const vector<int>& nums, int k) {
int n = nums.size();
if (n == 0 || k <= 0) return {};
vector<int> result;
deque<int> dq; // 存储下标,单调递增(队首最小)
for (int i = 0; i < n; ++i) {
// 移除过期下标
while (!dq.empty() && dq.front() < i - k + 1) dq.pop_front();
// 维护单调递增:弹出队尾所有值大于等于当前值的元素
while (!dq.empty() && nums[dq.back()] >= nums[i]) dq.pop_back();
dq.push_back(i);
if (i >= k - 1) result.push_back(nums[dq.front()]);
}
return result;
}
单调队列的其他应用
- 滑动窗口中的第k大/小值(常与topK问题结合,但单调队列只能解决最值,不是任意顺序)。
- 动态规划优化:例如“最大子数组和”的滑动窗口版本,或者“最少跳跃次数”问题中维护单调队列来加速状态转移。
- 滑动窗口中的中位数:需要更复杂的数据结构(如双堆+延迟删除),但单调队列是其中一部分。
- LeetCode经典题目:
- 239. 滑动窗口最大值
-
- 绝对差不超过限制的最长连续子数组
-
- 和至少为 K 的最短子数组(结合前缀和+单调队列)
总结要点
- 单调队列 = 双端队列 + 单调性:队列内元素的值从队首到队尾单调递减(或递增),从而O(1)获取最值。
- 滑动窗口最大值是最典型应用,时间复杂度O(n),空间复杂度O(k)。
- 核心操作:滑动时先“清理过期元素”(队首出队),再“维护单调性”(队尾弹出不可能成为最值的元素),然后入队新元素,最后记录结果。
- 存储下标比存储值更通用,因为可以通过下标访问原始数组并判断是否出窗口。
- 与单调栈的区别:单调栈用于“下一个更大/小元素”,通常解决一对一的比较;单调队列用于“滑动窗口内的连续最值”,解决一对多的维护。两者都利用单调性排除无用元素,但数据结构不同(栈 vs 队列)。
掌握了单调队列,你就掌握了一种高效的数据结构,能够解决许多看似复杂的滑动窗口问题。建议从“滑动窗口最大值”开始练习,然后尝试“滑动窗口最小值”、“和为S的连续正数序列”等变体,慢慢体会它的威力。
例题精讲
关于单调队列在滑动窗口中的使用,下列说法正确的是:
在使用单调队列求解长度为k的滑动窗口最小值时,应维护一个单调( )的队列。
单调队列实现滑动窗口最值时,队列中存储元素的索引比存储值更好,因为可以通过索引判断元素是否还在当前窗口内。
完成函数 maxSlidingWindow,求给定数组 nums 和窗口大小 k 的滑动窗口最大值。使用双端队列 deque(假设已实现 push_back, push_front, pop_back, pop_front, front, back 等操作,且支持索引访问)。请补全代码。
function maxSlidingWindow(nums, k):
n = length(nums)
result = []
deque = [] // 存储索引,维护单调递减
for i from 0 to n-1:
// 移除队首不在当前窗口的元素
if deque and deque[0] < i - k + 1:
___(1)___
// 维护单调性:移除队尾所有小于等于当前值的索引
while deque and nums[deque[-1]] <= nums[i]:
___(2)___
// 当前索引入队
deque.push_back(i)
// 当窗口形成后,记录结果
if i >= k - 1:
result.push_back(___ (3) ___)
return result给定一个整数数组 nums 和一个整数 k,求所有滑动窗口中的最小值。请完成 minSlidingWindow 函数,使用单调队列。
function minSlidingWindow(nums, k):
n = length(nums)
result = []
deque = [] // 存储索引,维护单调递增
for i from 0 to n-1:
// 移除过期队首
if deque and deque[0] < i - k + 1:
deque.pop_front()
// 维护单调递增:移除队尾所有大于等于当前值的索引
while deque and nums[deque[-1]] ___ (1) ___ nums[i]:
deque.pop_back()
deque.push_back(i)
if i >= k - 1:
result.push_back(___ (2) ___)
return result