在做题时,我们经常会遇到这样一类要求:给一个序列,一个固定长度的窗口从左往右滑动,每滑一格,就得报出当前窗口里的最大值或者最小值。
先别急着背数据结构模板。我们先想想,最容易想到的办法是怎么做的?
无非是窗口每挪动一格,就把这 k 个数从头到尾扫一遍。每个窗口花
但只要你稍微停下来观察一下窗口的滑动过程,就会发现这里面有大量的无用功。 窗口向右滑一格,实际上只发生两件事:最左边出去了一个老元素,最右边进来了一个新元素。中间绝大部分的数根本没变。暴力算法之所以慢,就是因为反复在看这些没变过的数。
那我们能不能在移动的过程中,把那些“已经被新来的数比下去”的旧候选提前剔除掉?
一、旧元素为什么能直接扔掉?
假设现在窗口里有两个位置
在接下来的滑动过程中,
如果只关心最大值,就没有必要。理由很直白:
第一,看数值大小:新来的
只要
保留
二、队列的维护机制与手推过程
1. 为什么队列里要存下标?
在实现单调队列时,我们使用 std::deque<int>(双端队列),而且队列里存的是数组的下标,而不是数值本身。
原因很简单:当窗口滑动时,我们需要判断队首元素是不是已经“过期”(滑出了当前窗口的左边界)。如果我们只存数值,就无法知道这个数当前到底位于哪个位置;而如果存了下标
2. 相等值怎么处理?
当新来的元素和队尾元素数值相等时,我们要不要把队尾弹掉?
假设求最大值,新来的 a[q.back()] <= a[i]。这样能保证队列内部数值呈现严格单调递减。
这里讨论的是最值本身。如果题目还要求最早出现的位置或最优方案数,就得重新考虑相等候选能不能删,不能把这个条件原封不动套过去。
3. 一步一步手推演示
我们取一组具体数据:
我们用 (下标:数值) 来记录队列内部状态:
| i 及新值 | 本轮关键操作 | 队列(下标:值) | 当前最大值 |
|---|---|---|---|
| 1: |
队空,直接入队 | [(1:4)] |
窗口未满 |
| 2: |
尾部 |
[(1:4), (2:2)] |
窗口未满 |
| 3: |
尾部 |
[(1:4), (3:2)] |
4 |
| 4: |
队首 1 已过期弹出;尾部 |
[(4:5)] |
5 |
| 5: |
尾部 |
[(4:5), (5:1)] |
5 |
| 6: |
尾部 |
[(4:5), (6:3)] |
5 |
看
- 窗口左边界变成了
。此时队首下标是 1,比 2 小,说明它已经滑出窗口了,必须从队首弹出( pop_front())。 - 接着新元素
准备入队,发现队尾的 ,旧元素既小又老,从队尾弹出( pop_back())。 - 最后把 4 号下标推进去。此时队首存的下标就对应整个窗口的最大值。
滑动窗口最大值最终依次是:4, 5, 5, 5。
如果求最小值,只需要把队尾淘汰条件改成 a[q.back()] >= a[i],得到的结果就是:2, 2, 1, 1。
4. 固定窗口模板的四步走
先把本节固定窗口模板的顺序记清楚,后面遇到前缀和、DP 时,再根据候选范围调整,不能只抄顺序不看含义。
- 清队首过期:只要队列非空且
q.front() < i - k + 1,就执行q.pop_front(),直到队首不过期或队空; - 清队尾劣解:只要队列非空且新元素至少一样优(求最大值时满足
a[q.back()] <= a[i]),就循环执行q.pop_back(); - 新元素进队:将当前下标推入队尾
q.push_back(i); - 取当前答案:当
时,说明窗口已经完整进入数组,此时 a[q.front()]就是当前窗口的最大值。
换一个求最小值的例子:[1,4,3,2],窗口长度为 3。处理位置 4 时,先从队首去掉过期的下标 1,再从队尾去掉不如新数 2 的下标 3,最后让下标 4 入队。

三、为什么它快?复杂度与工具选型
代码里外层一个 for,内层还有 while,它真的比暴力快吗?
我们换个视角看:
整个扫描过程中,每一个下标 push_back 一次)。
而进过队列的元素,要么从队首因为过期弹出来一次,要么从队尾被淘汰弹出来一次,绝不可能死而复生。也就是说,所有入队操作总共只有
别盯着某一轮的 while 看,把整趟扫描合起来算:每个下标进一次、最多出一次,总时间就是
但单调队列并不是万能钥匙。我们需要把它和手头的其他工具分清楚:
- 单调栈:只能从栈顶操作。它适合找“某元素左边/右边第一个比它大的数”。但如果加上“窗口长度不能超过
”的限制,就还需要淘汰窗口左边过期的候选,单靠栈顶的操作不够。 - 单调队列:这里用双端队列实现,从队尾加入、从两端删除。适合处理窗口左右端点“只往前走、不往后退”的区间最值问题。如果查询区间是乱序跳跃的(比如一会儿查
,一会儿查 ),已经删掉的候选无法找回,这套维护方式就不能直接用了。 - ST 表:常规做法对静态数组进行
的预处理,之后可以按任意顺序查询区间最值,单次 。如果原数组的值发生修改,预处理结果就可能失效,需要重新维护。它和单调队列适用的条件不同,不是谁把谁完全替代。
四、模板实战:洛谷 P1886 滑动窗口
我们来看洛谷官方模板题 P1886 滑动窗口 /【模板】单调队列。
题目给出长度为
完整程序:
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=1000005;
int a[N];
int n,k;
void getmin(){
deque<int> q;
for(int i=1;i<=n;i++){
while(!q.empty() && q.front()<i-k+1) q.pop_front();
while(!q.empty() && a[q.back()]>=a[i]) q.pop_back();
q.push_back(i);
if(i>=k){
cout<<a[q.front()]<<(i==n?"\n":" ");
}
}
}
void getmax(){
deque<int> q;
for(int i=1;i<=n;i++){
while(!q.empty() && q.front()<i-k+1) q.pop_front();
while(!q.empty() && a[q.back()]<=a[i]) q.pop_back();
q.push_back(i);
if(i>=k){
cout<<a[q.front()]<<(i==n?"\n":" ");
}
}
}
void solve(){
cin>>n>>k;
for(int i=1;i<=n;i++) cin>>a[i];
getmin();
getmax();
}
signed main(){
ios::sync_with_stdio(0),cin.tie(0);
solve();
return 0;
}
五、前缀和转化:洛谷 P1714 切蛋糕
很多题表面上看不出“滑动窗口”四个字,但只要把式子一变,单调队列就会浮出水面。
P1714 切蛋糕 要求我们在长度为
1. 式子怎么变?
遇到连续子段和,首先想到前缀和。
设
题目要求子段长度不超过
因为前缀和下标不能为负,所以
当我们枚举右端点
其中
2. 时序陷阱:先算答案,还是先进队?
这里有个初学者非常容易错的地方:顺序不能照抄 P1886。
在 P1886 里,
- 先清掉队首过期的
(即 ); - 先用当前的
减去队首的最小 ,尝试更新全局答案; - 然后再把当前的下标
作为未来后续位置的候选前缀下标放进队列。
还有两个关键细节:
- 循环开始前,必须先把下标 0 放进队列(
q.push_back(0))。因为从第 1 个数开始选到第个数也是合法方案,这时要减去的前缀和就是 。如果不放 0,你就漏掉了从头开始的子段;这份代码在第一轮还会访问空队列。 - 原数组元素可能全是负数。全负数时答案也是负数,因此不能把答案初始化为 0。代码先定义一个足够大的正数
const int INF=4000000000000000000LL;,再令ans=-INF。
每轮查询前,下标
该算法时间复杂度为
完整程序:
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=500005;
const int INF=4000000000000000000LL;
int a[N],s[N];
int n,m;
void solve(){
cin>>n>>m;
s[0]=0;
for(int i=1;i<=n;i++){
cin>>a[i];
s[i]=s[i-1]+a[i];
}
deque<int> q;
q.push_back(0); // 必须放入前缀和0作为候选左端点
int ans=-INF;
for(int i=1;i<=n;i++){
while(!q.empty() && q.front()<i-m) q.pop_front();
ans=max(ans,s[i]-s[q.front()]);
while(!q.empty() && s[q.back()]>=s[i]) q.pop_back();
q.push_back(i);
}
cout<<ans<<"\n";
}
signed main(){
ios::sync_with_stdio(0),cin.tie(0);
solve();
return 0;
}
六、动态规划状态优化:洛谷 P1725 琪露诺
现在我们更进一步,看单调队列如何接管动态规划的转移瓶颈。
在 P1725 琪露诺 中,编号为
1. 寻找瓶颈
设
由于格子编号非负,所以
状态转移方程自然就是:
这里当然只从编号非负、已经能走到的格子 j 转移;没有这样的前驱,i 就暂时走不到。
如果不做优化,每次算一个
2. 这里的队列有什么不同?
相比基础滑动窗口,这道题有两个必须扣准的环节:
第一,候选人的“解锁时间”变了。
在计算
第二,不可达状态的隔离。
有的格子可能因为步长限制根本踩不到(比如 -INF。
走不到的格子,就不塞进队列。新候选 f[cur] != -INF)。只有走得到的位置,才有资格进队。如果队列空了,说明没有任何合法前驱能跳到当前位置 -INF。这样也避免了对代表不可达的哨兵值继续加分。
3. 终局怎么算?
题目要求跳过
算法时间复杂度为
完整程序:
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=200005;
const int INF=4000000000000000000LL;
int a[N],f[N];
int n,l,r;
void solve(){
cin>>n>>l>>r;
for(int i=0;i<=n;i++) cin>>a[i];
for(int i=1;i<=n;i++) f[i]=-INF;
f[0]=0;
deque<int> q;
for(int i=l;i<=n;i++){
int cur=i-l;
if(f[cur]!=-INF){
while(!q.empty() && f[q.back()]<=f[cur]) q.pop_back();
q.push_back(cur);
}
while(!q.empty() && q.front()<i-r) q.pop_front();
if(!q.empty()){
f[i]=f[q.front()]+a[i];
}
}
int ans=-INF;
for(int i=0;i<=n;i++){
if(i+r>n && f[i]!=-INF){
ans=max(ans,f[i]);
}
}
cout<<ans<<"\n";
}
signed main(){
ios::sync_with_stdio(0),cin.tie(0);
solve();
return 0;
}
七、二维网格上的切片扫描:洛谷 P2216 理想的正方形
如果滑动区间变成了一个二维的方块,怎么做?
在 P2216 [HAOI2007] 理想的正方形 中,给出一个
1. 从一个小例子看拆解方法
我们先拿一个具体的小矩阵来说明:
设要求边长
先不要试图一步跨到二维。我们先对每一行单独做一次长度为 2 的横向滑动窗口:
- 第 1 行:
最大是 8, 最大是 8; - 第 2 行:
最大是 6, 最大是 7; - 第 3 行:
最大是 5, 最大是 9。
这些横向结果另存到 mx,最小值则存到 mn,原矩阵 v 不变。具体来说,mx[i][j] 表示原矩阵第 mn[i][j] 存同一区间的最小值。
现在看横向结果 mx 的第 3 列,不是原矩阵的第 3 列。它存着每行最后两格的最大值,从上到下依次是
同样的方法处理最小值:第 2 行最后两格的最小值是 1,第 3 行最后两格的最小值是 5;纵向合并得到该区域的最小值是
但这个过程告诉我们:先横向把每一行的滑动最值压出来,再基于第一步算出的结果纵向扫一遍,正方形的最值就自然出来了。
2. 空间精简实现
在代码里,我们只需要开原矩阵 v,以及存横向结果的 mx 和 mn。在第二趟纵向扫描时,我们一边用单调队列求出当前正方形的极值,一边直接顺手更新全局答案 ans,完全不需要再开额外的二维数组去存最终结果。
别让下标绕晕自己:固定第
整个算法的时间复杂度是
完整程序:
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=1005;
const int INF=4000000000000000000LL;
int v[N][N];
int mx[N][N],mn[N][N];
int a,b,n;
void solve(){
cin>>a>>b>>n;
for(int i=1;i<=a;i++){
for(int j=1;j<=b;j++){
cin>>v[i][j];
}
}
// 第一趟:逐行横向滑动窗口,求出每行长为n的区间极值
for(int i=1;i<=a;i++){
deque<int> q1,q2;
for(int j=1;j<=b;j++){
while(!q1.empty() && q1.front()<j-n+1) q1.pop_front();
while(!q1.empty() && v[i][q1.back()]<=v[i][j]) q1.pop_back();
q1.push_back(j);
if(j>=n) mx[i][j]=v[i][q1.front()];
while(!q2.empty() && q2.front()<j-n+1) q2.pop_front();
while(!q2.empty() && v[i][q2.back()]>=v[i][j]) q2.pop_back();
q2.push_back(j);
if(j>=n) mn[i][j]=v[i][q2.front()];
}
}
// 第二趟:逐列纵向滑动窗口,基于横向结果直接合并正方形极值并刷新答案
int ans=INF;
for(int j=n;j<=b;j++){
deque<int> q1,q2;
for(int i=1;i<=a;i++){
while(!q1.empty() && q1.front()<i-n+1) q1.pop_front();
while(!q1.empty() && mx[q1.back()][j]<=mx[i][j]) q1.pop_back();
q1.push_back(i);
while(!q2.empty() && q2.front()<i-n+1) q2.pop_front();
while(!q2.empty() && mn[q2.back()][j]>=mn[i][j]) q2.pop_back();
q2.push_back(i);
if(i>=n){
ans=min(ans,mx[q1.front()][j]-mn[q2.front()][j]);
}
}
}
cout<<ans<<"\n";
}
signed main(){
ios::sync_with_stdio(0),cin.tie(0);
solve();
return 0;
}
八、选学:双指针与双队列维护动态变长窗口
前面我们做的滑动窗口长度都是固定的(或者最大长度固定),但有时候,窗口的长度是根据内容动态伸缩的。
问题场景:给定一个长度为
1. 为什么能用双指针?
随着右端点
一旦某个窗口
2. 双队列协同配合
此时窗口区间是
- 一个维护窗口内最大值的单调递减队列
q_max - 一个维护窗口内最小值的单调递增队列
q_min
随着 a[q_max.front()],最小值就是 a[q_min.front()]。
如果发现 a[q_max.front()] - a[q_min.front()] > K,说明当前区间不合法。我们就要让 L++),直到重新合法为止。
在
3. 一步一步手推演示
假设数组为
- R=1 (a[1]=8):
q_max=[(1:8)],q_min=[(1:8)]。极差,合法,最长记录更新为 1。 - R=2 (a[2]=2):
q_max=[(1:8), (2:2)],q_min=[(2:2)](老元素 8 被淘汰)。此时队首极差,不合法! 开始收缩: 从 1 变成 2。此时原来的 1 号元素滑出, q_max的队首 1 被弹出。 收缩后,两队列队首都是 2 号元素,极差 ,合法。 - R=3 (a[3]=4):
q_max=[(3:4)](老元素 2 被淘汰),q_min=[(2:2), (3:4)]。此时队首最大是 4,最小是 2。极差,合法!最长记录更新为 2。 - R=4 (a[4]=7):
q_max=[(4:7)](老元素 4 被淘汰),q_min=[(2:2), (3:4), (4:7)]。极差,不合法! 收缩: 变 3,2 号元素滑出窗口, q_min队首 2 弹出。当前最大 7,最小 4。极差,合法!此时窗口为 ,长度为 2。
4. 动态变长窗口代码实现
// 问题:求最大值与最小值之差不超过 K 的最长连续子数组长度
// 输入:n, K,接着 n 个整数
// 范围:1 <= n <= 100000, K >= 0
// 样例输入:
// 4 3
// 8 2 4 7
// 样例输出:
// 2
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=100005;
int a[N];
int n,k_val;
void solve(){
cin>>n>>k_val;
for(int i=1;i<=n;i++) cin>>a[i];
deque<int> q_max, q_min;
int L = 1;
int ans = 0;
for(int R=1;R<=n;R++){
// 1. 新元素进双队列
while(!q_max.empty() && a[q_max.back()]<=a[R]) q_max.pop_back();
q_max.push_back(R);
while(!q_min.empty() && a[q_min.back()]>=a[R]) q_min.pop_back();
q_min.push_back(R);
// 2. 检查极差,收缩左边界 L
while(!q_max.empty() && !q_min.empty() && a[q_max.front()]-a[q_min.front()] > k_val){
if(q_max.front() == L) q_max.pop_front();
if(q_min.front() == L) q_min.pop_front();
L++;
}
// 3. 统计最长合法区间
ans = max(ans, R - L + 1);
}
cout<<ans<<"\n";
}
signed main(){
ios::sync_with_stdio(0),cin.tie(0);
solve();
return 0;
}
九、通用提炼:由 DP 式子识别单调队列
当状态转移方程稍微复杂一点时,怎么判断能不能用单调队列?怎么确定队列里面该装什么、什么时候弹? 记住这三个问题,你可以直接对着式子把单调队列的代码“翻译”出来。
1. 拆分式子,分离变量
把你的转移方程化简成这样的形式(以求最大值为例):
:只和当前位置 有关、和前驱 无关的项(比如前面琪露诺那题的 )。把它扔到求最值的外面,最后加上去就行。 :你要评估的“候选人战斗力”。它可能只含有 ,也可能包含和 相关的其他常数项。在队列比较优劣时,我们比较的正是 。 :前驱 的合法范围。
2. 识别队列三大要素
只要你能把转移式子化成上面的形状,并且
我们直接对着这个一般式,回答三个问题:
- 新候选进队条件是什么?
随着
的增加,把 中新解锁的下标依次放进队尾。对每个新下标 ,拿已经计算好的 和队尾比拼;第一轮则按顺序加入初始合法范围内的候选。 不变时,不用重复加入。 - 队尾劣解淘汰条件是什么?
如果新候选
比队尾更强,即 ,旧的队尾就可以无情抛弃。 - 队首老将过期条件是什么?
当前合法的最老资格是
。如果队首存的下标 ,说明它太老了,已经被时代的边界淘汰,直接从队首踢掉。
这套方法直接穿透了题目的层层包装。无论是多重背包的单调队列优化,还是树上路径的滑动窗口,你只要把式子写出来,认准
多重背包如何拆出这三个量,可以接着看《动态规划优化》第三节;学到那里时,再回来对照这一节。
十、总结与自检
把这四道题串起来看:
- 在 P1886 中,我们维护的是原数组数值
; - 在 P1714 中,我们维护的是前缀和数值
; - 在 P1725 中,我们维护的是 DP 的前驱分值
; - 在 P2216 中,我们维护的是第一趟算出来的行极值
和 。
你看,窗口里装着的东西在变,但内核完全没变:当候选按顺序进入、按顺序过期时,如果新候选至少一样优,还能留得更久,旧候选就可以放心让位。
1. 课后自检题
- 在 P1714 切蛋糕中,如果把
q.push_back(0)删掉,会漏掉哪类子段?这份代码的第一轮查询还会遇到什么问题? - 在 P1725 琪露诺中,为什么新候选是
而不是 ?在本讲的实现中,队空意味着什么,为什么此时不能访问 q.front()、继续转移?为什么要避免对表示不可达的-INF直接加分? - 在 P2216 中,第二趟扫描计算出的每个窗口最值,究竟对应着原图上哪一个具体的正方形区域?