单调队列
一、什么是单调队列?
单调队列是一种在滑动窗口或区间查询中维护候选元素单调性的数据结构,通常用于解决“滑动窗口最大值/最小值”等问题。
核心思想是:利用双端队列(deque)维护当前窗口内或候选范围内元素的下标,使队列内对应的值保持单调(递减或递增),从而能在常数时间内获得当前窗口的最值,且整体复杂度为线性级别。
二、核心概念与原理分析
1. 作用场景:滑动窗口问题
- 常见题型:给定数组 a [ 1.. n ] a[1..n] a[1..n](或 0-based,但此处示例统一使用 1-based,下同),和窗口大小 k k k,要求对每个长度为 k k k 的子数组(窗口)求最大值/最小值。
- 其他场景:可扩展到动态维护区间最值,或在线流数据的最值维护等。
- 单调队列的出现是为了解决滑动窗口中暴力方法 O ( n k ) O(nk) O(nk) 的低效问题,将其优化到 O ( n ) O(n) O(n)。
2. 为什么要用双端队列(deque)
-
存下标,不直接存值:
- 存下标可以方便判断元素是否落在当前窗口范围;
- 若只存值,则无法快速删除过期元素(窗口左边界移出后的元素)。
-
双端队列(deque)的特点:
- 可以在两端高效入队出队;
- 既需要在尾部插入当前元素下标,也需要在头部删除过期下标;
- 在维护单调性时还要在尾部弹出不符合条件的下标。
3. 单调性:决定入队前的弹出策略
-
目标问题分两类:
- 滑动窗口最大值 → 维护单调递减队列(队头值最大)。
- 滑动窗口最小值 → 维护单调递增队列(队头值最小)。
-
维护方式:
-
入队新下标 i i i 时,先在队尾弹出所有“相对于当前需求无用”的下标:
- 对于最大值:弹出所有 a [ 队尾下标 ] ≤ a [ i ] a[ \text{队尾下标}] \le a[i] a[队尾下标]≤a[i];
- 对于最小值:弹出所有 a [ 队尾下标 ] ≥ a [ i ] a[\text{队尾下标}] \ge a[i] a[队尾下标]≥a[i]。
-
弹出后,队尾剩余下标对应的值严格保持单调(递减或递增)。
-
再将下标 i i i 加入队尾。
-
-
弹出理由(以最大值为例):
-
当遍历到 i i i,若队尾下标 j j j 满足 a [ j ] ≤ a [ i ] a[j] \le a[i] a[j]≤a[i],则:
- 对于当前窗口包含 i i i 的场景, j j j 不可能成为最大值候选,因为 a [ i ] a[i] a[i] 更大且更“靠右”(即更新到最新位置);
- 对于后续窗口中更右的位置,若某窗口包含 j j j 且也包含更右的 i i i,由于 i i i 更新得更近且更大, j j j 永远无法胜过 i i i 成为最大;
- 因此可安全剔除 j j j,保证队列中只保留可能成为窗口最大值的候选。
-
同理,对于最小值,弹出所有 a [ j ] ≥ a [ i ] a[j] \ge a[i] a[j]≥a[i]。
-
4. 窗口滑动与过期元素删除
-
过期元素指的是下标小于当前窗口左边界的元素下标。
-
在每次移动窗口时(通常是遍历到下标 i i i 时,窗口左边界为 i − k + 1 i-k+1 i−k+1),需要检查队头:
- 若 队头下标 < i − k + 1 i-k+1 i−k+1,说明该下标对应的元素已不在当前窗口范围,应从队头弹出;
- 重复检查直到队头下标在窗口范围内或队列为空。
-
这样保证队头始终对应当前窗口内候选中最符合单调性且最“老”的(最可能是最大/最小)的下标。
5. 遍历顺序与入队时机
-
通常对滑动窗口问题,我们从左向右遍历下标 i = 1 … n i = 1 \dots n i=1…n:
- 入队前先处理过期(当 i ≥ k i \ge k i≥k 时,左边界为 i − k + 1 i-k+1 i−k+1),删除队头过期下标;
- 维护单调性:弹出队尾所有无效下标,再将 i i i 入队尾;
- 获取答案:当 i ≥ k i \ge k i≥k 时,队头即为窗口 [ i − k + 1 , i ] [i-k+1, i] [i−k+1,i] 的最值下标,可通过 a [ 队头 ] a[\text{队头}] a[队头] 获取最大/最小值;
- 继续下一个 i i i。
-
注意先删除过期,再维护单调,再读取答案可避免使用已过期下标或使新元素干扰答案顺序错误。
6. 不变量(Invariant)描述
以“滑动窗口最大值”+从左向右遍历为例,在处理下标 i i i 之前,假设已维护到 i − 1 i-1 i−1:
- 队列中存储的均为下标 ≥ i − k + 1 \ge i-k+1 ≥i−k+1(即未过期);
- 队列中对应的 a [ ⋅ ] a[\cdot] a[⋅] 值从队头到队尾严格递减;
- 队头是上一个窗口(若 i − 1 ≥ k i-1 \ge k i−1≥k 则窗口 [ i − k , i − 1 ] [i-k, i-1] [i−k,i−1])的最大值下标,且队列中剩余元素都是对未来窗口可能有用的候选。
处理 i i i 时:
-
弹出过期:若队头 < i − k + 1 i-k+1 i−k+1,出队;
-
弹出无用:弹出所有 a [ 队尾 ] ≤ a [ i ] a[\text{队尾}] \le a[i] a[队尾]≤a[i];
-
入队 i i i:将 i i i 加入队尾;
-
记录答案:若 i ≥ k i \ge k i≥k,此时队头即为窗口 [ i − k + 1 , i ] [i-k+1, i] [i−k+1,i] 最大值下标。
该流程保证了当前及后续窗口的正确性和数据结构的不变量。
7. 严格/非严格比较与等值处理
-
常见实现中,使用
<=
(最大值场景)或>=
(最小值场景)将等值元素也弹出:- 结果是:在队列中保留距离更近(更靠右或更晚出现)的下标,避免当值相等时用更早出现的下标做答案而导致不必要影响;
- 适合“严格取更大或更小”的要求;若题目对等值有特别要求(如“当相等时保留最早/最晚”),可调整为
<
或>
。
-
示例:若希望当新的值等于队尾值时仍保留旧下标,可在弹出条件中改为
<
,使得相等时不弹出旧下标。
8. 复杂度保证
- 每个下标最多入队一次、出队(无用弹出或过期删除)一次 → O ( 2 n ) O(2n) O(2n) 次队列操作 → 时间复杂度 O ( n ) O(n) O(n)。
- 空间复杂度 O ( n ) O(n) O(n) 用于存放下标的 deque 以及输入数组、结果数组。
三、完整代码示例(数组下标从 1 开始)
1. 滑动窗口最大值示例(模板+详解)
#include <iostream>
#include <vector>
#include <deque>
using namespace std;// 示例:给定数组 a[1..n],和窗口大小 k,输出每个长度为 k 的子数组的最大值
int main()
{ios::sync_with_stdio(false);cin.tie(nullptr);int n, k;cin >> n >> k;// 使用 1-based 存储,方便理解;注意读入时按 a[1..n]vector<int> a(n + 1);for (int i = 1; i <= n; i++) {cin >> a[i];}// 结果:存每个窗口最大值;如果需要下标,可以另存下标//长度为n的数组,最多能划分n-k+1个长度为k的窗口vector<int> ans(n-k+1);// 单调队列:deque 存放下标,保证 a[deque.front()] 是当前窗口最大值deque<int> dq;// 遍历方向:从左向右,i 表示当前考察的下标for (int i = 1; i <= n; i++) {// 1. 删除过期元素:若队头在窗口左边界之外,弹出// 当前窗口为 [i-k+1, i](当 i >= k 时有效)if (!dq.empty() && dq.front() < i - k + 1) {// 弹出所有过期下标,通常只需一次检查即可,因为队头最老dq.pop_front();}// 2. 维护单调性:弹出所有 a[队尾] <= a[i]while (!dq.empty() && a[dq.back()] <= a[i]) {dq.pop_back();}// 3. 将当前下标 i 入队尾dq.push_back(i);// 4. 记录答案:当窗口形成(i >= k)时,队头即最大值下标if (i >= k) {ans.push_back(a[dq.front()]);// 如果题目需要输出下标,可用 dq.front()}}// 输出结果:根据题目要求格式,这里逐行输出for (int v : ans) {cout << v << "\n";}return 0;
}
详细注释说明:
- 为什么先
if (dq.front() < i-k+1) pop_front()
:保证队头元素在当前窗口;当 i < k 时,此条件不会触发,也不会记录答案。- 为什么弹出
<=
:剔除所有值不大于当前值的候选,保证队列单调递减,队头始终是最大。- 为什么在
i >= k
时读取答案:窗口 [ i − k + 1 , i ] [i-k+1,i] [i−k+1,i] 完成后,才有完整长度 k k k;读取时队头一定是该窗口最大。- 入队时机放在读取答案之前也可,但必须保证读取时队列已维护好单调性和过期删除;此处先维护、再读取,保证清晰。
- 若需要获取最大值下标,可记录
dq.front()
;若只需值,直接用a[dq.front()]
。
2. 滑动窗口最小值示例
#include <iostream>
#include <vector>
#include <deque>
using namespace std;// 示例:给定数组 a[1..n],和窗口大小 k,输出每个长度为 k 的子数组的最小值
int main()
{ios::sync_with_stdio(false);cin.tie(nullptr);int n, k;cin >> n >> k;vector<int> a(n + 1);for (int i = 1; i <= n; i++) {cin >> a[i];}// 结果:存每个窗口最大值;如果需要下标,可以另存下标//长度为n的数组,最多能划分n-k+1个长度为k的窗口vector<int> ans(n-k+1);// 单调递增队列:保证 a[dq.front()] 是当前窗口最小值deque<int> dq;for (int i = 1; i <= n; i++) {// 1. 删除过期下标if (!dq.empty() && dq.front() < i - k + 1) {dq.pop_front();}// 2. 维护单调性:弹出所有 a[队尾] >= a[i]while (!dq.empty() && a[dq.back()] >= a[i]) {dq.pop_back();}// 3. 入队dq.push_back(i);// 4. 记录答案if (i >= k) {ans.push_back(a[dq.front()]);}}for (int v : ans) {cout << v << "\n";}return 0;
}
注:与最大值模板对称,只需将比较方向反转。
四、完整样例模拟
以数组 a = [ 5 , 2 , 6 , 1 , 4 , 3 ] a=[5,2,6,1,4,3] a=[5,2,6,1,4,3](1-based,下标 1…6),窗口大小 k = 3 k=3 k=3,演示“滑动窗口最大值”:
-
窗口序列:
- 窗口 [ 1 , 3 ] [1,3] [1,3],元素 5 , 2 , 6 {5,2,6} 5,2,6
- 窗口 [ 2 , 4 ] [2,4] [2,4],元素 2 , 6 , 1 {2,6,1} 2,6,1
- 窗口 [ 3 , 5 ] [3,5] [3,5],元素 6 , 1 , 4 {6,1,4} 6,1,4
- 窗口 [ 4 , 6 ] [4,6] [4,6],元素 1 , 4 , 3 {1,4,3} 1,4,3
初始化:dq = []
, ans = []
。
Step i=1, a [ 1 ] = 5 a[1]=5 a[1]=5
- 处理前:
dq=[]
- 删除过期:窗口尚未形成(i<k),跳过
- 维护单调:
dq
为空,无需弹出 - 入队:
dq=[1]
- 不记录答案(i<3)
Step i=2, a [ 2 ] = 2 a[2]=2 a[2]=2
- 处理前:
dq=[1]
- 删除过期:i<3,不触发
- 维护单调:检查队尾下标1,对应 a [ 1 ] = 5 > 2 a[1]=5 > 2 a[1]=5>2,不弹
- 入队:
dq=[1,2]
- 不记录答案(i<3)
队列含下标 [1(5), 2(2)],值单调递减。
Step i=3, a [ 3 ] = 6 a[3]=6 a[3]=6
窗口形成: [ 1 , 3 ] [1,3] [1,3],元素 5 , 2 , 6 {5,2,6} 5,2,6
-
处理前:
dq=[1,2]
-
删除过期:i=3,k=3,左界=1,dq.front()=1,不过期
-
维护单调:
- 队尾2对应 a [ 2 ] = 2 ≤ 6 a[2]=2 \le 6 a[2]=2≤6 → 弹出 →
dq=[1]
- 新队尾1对应 a [ 1 ] = 5 ≤ 6 a[1]=5 \le 6 a[1]=5≤6 → 弹出 →
dq=[]
- 队尾2对应 a [ 2 ] = 2 ≤ 6 a[2]=2 \le 6 a[2]=2≤6 → 弹出 →
-
入队:
dq=[3]
-
记录答案:窗口 [1,3] 最大值下标
dq.front()=3
,值6
→ans=[6]
Step i=4, a [ 4 ] = 1 a[4]=1 a[4]=1
窗口形成: [ 2 , 4 ] [2,4] [2,4],元素 2 , 6 , 1 {2,6,1} 2,6,1
- 处理前:
dq=[3]
- 删除过期:左界=4-3+1=2,dq.front()=3 ≥2,不过期
- 维护单调:队尾3对应 a [ 3 ] = 6 > 1 a[3]=6 >1 a[3]=6>1,不弹
- 入队:
dq=[3,4]
- 记录答案:窗口 [2,4] 最大值下标3,值6 →
ans=[6,6]
队列 [3(6),4(1)] 。
Step i=5, a [ 5 ] = 4 a[5]=4 a[5]=4
窗口形成: [ 3 , 5 ] [3,5] [3,5],元素 6 , 1 , 4 {6,1,4} 6,1,4
-
处理前:
dq=[3,4]
-
删除过期:左界=5-3+1=3,dq.front()=3 ≥3,不过期
-
维护单调:
- 队尾4对应 a [ 4 ] = 1 ≤ 4 a[4]=1 \le4 a[4]=1≤4 → 弹出 →
dq=[3]
- 新队尾3对应 a [ 3 ] = 6 > 4 a[3]=6 >4 a[3]=6>4 → 停止
- 队尾4对应 a [ 4 ] = 1 ≤ 4 a[4]=1 \le4 a[4]=1≤4 → 弹出 →
-
入队:
dq=[3,5]
-
记录答案:窗口 [3,5] 最大值下标3,值6 →
ans=[6,6,6]
队列 [3(6),5(4)] 。
Step i=6, a [ 6 ] = 3 a[6]=3 a[6]=3
窗口形成: [ 4 , 6 ] [4,6] [4,6],元素 1 , 4 , 3 {1,4,3} 1,4,3
- 处理前:
dq=[3,5]
- 删除过期:左界=6-3+1=4,dq.front()=3 <4 → 过期,弹出 →
dq=[5]
- 维护单调:队尾5对应 a [ 5 ] = 4 > 3 a[5]=4 >3 a[5]=4>3,不弹
- 入队:
dq=[5,6]
- 记录答案:窗口 [4,6] 最大值下标5,值4 →
ans=[6,6,6,4]
最终 ans=[6,6,6,4]
,对应每个窗口最大值正确无误。
五、总结
1. 单调队列的本质:
通过双端队列维护当前窗口或候选区间内元素的下标,并保证对应值单调(递减或递增),在 O ( 1 ) O(1) O(1) 时间内获取最大/最小值,同时整体遍历只做 O ( n ) O(n) O(n) 操作。
2. 关键点:
- 明确需求:是求最大值还是最小值;是否对等值有特殊处理(严格/非严格);
- 存下标:方便检查过期与结果输出;
- 删除过期:在每次滑动到新下标 i i i 时,先判断并删除队头下标是否 < 当前窗口左界;
- 维护单调:根据需求在入队前弹出所有不符合单调性的候选;
- 答案获取时机:当 i ≥ k i \ge k i≥k 时读取队头作为该窗口结果;
- 细致考虑边界:例如当 k > n k>n k>n、 k = 1 k=1 k=1(此时每个元素即窗口自身)、空数组等场景;
- 空间/时间保证:每个元素下标最多入队一次、出队一次,保证线性时间;队列最坏空间 O ( k ) O(k) O(k)。
3. 问题模型总结:
问题目标 | 遍历方向 | 数据结构 | 维护单调 | 过期删除 | 读取答案 |
---|---|---|---|---|---|
滑动窗口最大值 | 从左向右 | deque存下标 | 单调递减(值) | 队头 < i-k+1 时 pop_front | i ≥ k 时,值 a[dq.front()] |
滑动窗口最小值 | 从左向右 | deque存下标 | 单调递增(值) | 同上 | 同上(最小值) |
可扩展:动态区间最值 | 根据场景 | deque或分块等 | 原理类似 | 根据移动方式调整 | 获取方式视场景而定 |
4. 快速记忆口诀
滑动窗口 → 从左向右遍历;
最大值 → 单调递减队列;
最小值 → 单调递增队列;
先过期,后入队,最后读队头。
-
“滑动窗口”:意味着需要随下标 i 逐步移动窗口左界 i-k+1 → 从左向右逐步遍历;
-
“最大/最小”:决定弹出条件:
- 最大:弹出所有 ≤ 当前值;
- 最小:弹出所有 ≥ 当前值;
-
“过期删除”:每次 i 进来前/或后,判断队头是否过期并删除,确保队头始终在窗口内;
-
“答案读取”:当 i ≥ k,队头即窗口最值。
5. 常见变体与注意事项
-
0-based 写法:若数组 a[0…n-1] 且下标从 0 开始,窗口左界为
i-k+1
,判断过期时条件改为dq.front() < i-k+1
(与 1-based 逻辑一致);注意初始化和结果索引偏移。 -
等值策略:根据题意调整弹出条件中的等号:
- 若希望当新值等于队尾值时保留旧下标,可改为
while (!dq.empty() && a[dq.back()] < a[i])
; - 若希望淘汰旧下标,用
<=
。
- 若希望当新值等于队尾值时保留旧下标,可改为
-
同时维护最值和次值:某些题需同时获得最大值和次大值,可能需要更复杂结构。但基础是单调队列思想,可结合其它数据结构;
-
多维或其他滑动窗口:如二维滑动窗口(矩阵卷积最值),可结合行/列的单调队列两次应用;
-
在线数据流:若数据流不断到来,只需按到达顺序往 deque 插入,并删除过期即可;
-
其他类型区间维护:单调队列原理也能用于解决某些 DP 中需要维护区间最值或极值差分问题(如“窗口最值应用于最优转移”),思路类似。