单调队列讲解

单调队列?看完这篇,滑动窗口最小值再也难不住你

目录


从一道题开始

先看一个很经典的题:

给你一个长度为 (n) 的数组,再给一个窗口大小 (k),窗口从左往右滑,每次输出窗口里的最小值。

比如数组是 4 2 5 1 3 6 2 7,(k = 3),滑动过程长这样:

复制代码
[4,2,5] → 最小值 2
[2,5,1] → 最小值 1
[5,1,3] → 最小值 1
[1,3,6] → 最小值 1
[3,6,2] → 最小值 2
[6,2,7] → 最小值 2

最后输出 2 1 1 1 2 2

数据范围 (n \le 2\times10^5),要是每滑一次就把窗口里的数扫一遍找最小值,复杂度 (O(nk)),妥妥超时。这时候就需要一个能快速维护窗口极值 的结构------单调队列


单调队列到底是个啥

名字听着有点唬人,其实很简单。

单调队列就是一个里面的元素值始终保持单调 的队列。

比如求最小值,我们让队列从队头到队尾递增,这样队头永远是最小的那个。

它和普通队列最大的不同在于:我们不但能从队头删元素,还可以从队尾删 。所以必须用双端队列 deque 来实现。

另外,队列里我们一般存的是元素的下标(有时候也顺手把值存上),这样既能比较值的大小,又能判断元素是不是已经滑出窗口了。


为什么普通 queue 不行?

我们可能凭感觉写出这种代码,看着好像挺对:

cpp 复制代码
struct node{ ll id,v; };
queue<node> q;
for(ll i=1;i<=n;++i){
    while(q.size() && q.front().id<=i-k) q.pop();
    while(q.size() && q.front().v>=a[i]) q.pop();  // 想删掉比新元素大的
    q.push({i,a[i]});
    if(i>=k) cout<<q.front().v<<' ';
}

这个代码有两个大问题:

  1. 只能操作队头,碰不到队尾
    第二个 while 里想删掉所有"比新元素大"的值,但 queue 只能从队头删,真正该删的大值往往在队尾,根本够不着。
  2. 单调性维护错位置
    维护单调递增应该从队尾把那些大的弹出去,而不是在队头瞎折腾。

所以想写单调队列,queue 可以直接弃用,上 deque


正确的写法:deque + 结构体

我们用 deque<node>

cpp 复制代码
struct node{
    ll id;  // 下标
    ll v;   // 值
};
deque<node> q;

整个算法就四步,一步都不能乱:

  1. 清理元素 (队头)

    窗口右边界是 i,左边界是 i - k + 1。如果队头的下标比左边界还小,说明它已经滑出去了,直接 pop_front()

    cpp 复制代码
    while(!q.empty() && q.front().id < i - k + 1)
        q.pop_front();
  2. 从队尾维护单调性

    我们要递增队列,所以只要队尾元素的值 大于等于 当前新值 a[i],它就再也不可能成为窗口的最小值了,果断 pop_back()

    cpp 复制代码
    while(!q.empty() && q.back().v >= a[i])
        q.pop_back();
  3. 新元素入队

    处理好前面的后,把当前元素 {i, a[i]} 塞到队尾。

    cpp 复制代码
    q.push_back({i, a[i]});
  4. 取答案

    当窗口形成以后(i >= k),队头元素就是当前窗口的最小值,直接输出。

    cpp 复制代码
    if(i >= k) cout << q.front().v << ' ';  // 输出单个值,空格分开

注意:输出语句里 cout<< 之间可以保留风格,我这里为了看得清保留了一个空格,实际你的模板里都是连写的 cout<<q.front().v<<' '; 也没问题,后面例题代码全部统一成紧凑格式。


例题演练:滑动窗口最小值

题目是开头那个,完整代码:

cpp 复制代码
#include<bits/stdc++.h>
using namespace std;
#define rep(i,a,n) for(ll i=a;i<=n;++i)
typedef long long ll;
const int maxn=2e5+10;
struct node{
    ll id;  // 元素在原数组中的下标
    ll v;   // 元素的值
};
ll a[maxn];
deque<node> q;    // 双端队列,维护单调递增
ll n,k;
int main(){
    cin>>n>>k;
    rep(i,1,n){
        cin>>a[i];
        // 1. 弹出窗口外的元素
        while(!q.empty() && q.front().id < i-k+1)
            q.pop_front();
        // 2. 从队尾删掉所有大于等于当前值的元素,维持递增
        while(!q.empty() && q.back().v >= a[i])
            q.pop_back();
        // 3. 当前元素入队
        q.push_back({i, a[i]});
        // 4. 窗口形成,输出队头(最小值)
        if(i >= k)
            cout<<q.front().v<<' ';
    }
    return 0;
}

测试样例:

输入:

复制代码
8 3
4 2 5 1 3 6 2 7

输出:

复制代码
2 1 1 1 2 2

手动模拟过程

拿上面的样例一步一步走,理解更深刻。

队列里展示的是 (下标:值),窗口大小 k = 3

i ai 操作后的队列(递增) 窗口范围 输出
1 4 (1:4) 1 -
2 2 (2:2) 1,2 -
3 5 (2:2) (3:5) 1,2,3 2
4 1 (4:1) 2,3,4 1
5 3 (4:1) (5:3) 3,4,5 1
6 6 (4:1) (5:3) (6:6) 4,5,6 1
7 2 (4:1) (7:2) 5,6,7 2
8 7 (4:1) (7:2) (8:7) 6,7,8 2

看点细节:

  • i=2 时,a[2]=2 比队尾的 4 小,所以 4 被弹出,只留下 2
  • i=4 时,新值 1 比队里的 52 都小,它俩全被弹走;同时队头的 (2:2) 因为下标 2 已经小于窗口左边界(2),也被清掉。最后只剩 (4:1),窗口最小值变成 1
  • 后面就一直按这个逻辑走,非常丝滑。

代码模板

为了方便以后直接用,我把求最小值和最大值的模板都贴出来,只改数组名和窗口大小就行。

cpp 复制代码
#include<bits/stdc++.h>
using namespace std;
#define rep(i,a,n) for(ll i=a;i<=n;++i)
typedef long long ll;
const int maxn=2e5+10;

struct node{
    ll id,val;
};

ll a[maxn];
deque<node> q;

// 滑动窗口最小值(单调递增队列)
void solve_min(ll n,ll k){
    q.clear();
    rep(i,1,n){
        while(!q.empty() && q.front().id < i-k+1) q.pop_front();
        while(!q.empty() && q.back().val >= a[i]) q.pop_back();
        q.push_back({i, a[i]});
        if(i >= k) cout<<q.front().val<<' ';
    }
}

// 滑动窗口最大值(把 >= 改成 <= 就行,变成递减队列)
void solve_max(ll n,ll k){
    q.clear();
    rep(i,1,n){
        while(!q.empty() && q.front().id < i-k+1) q.pop_front();
        while(!q.empty() && q.back().val <= a[i]) q.pop_back();
        q.push_back({i, a[i]});
        if(i >= k) cout<<q.front().val<<' ';
    }
}

复杂度与适用场景

  • 时间复杂度 :每个元素最多入队一次、出队一次,每一步都是 (O(1)),整体 (O(n))
  • 空间复杂度 :队列里最多存 (k) 个元素,(O(k))

除了这种裸的滑动窗口极值,单调队列还常用来优化 DP,比如"可从前面长度为 (k) 的区间转移"的题目,把转移的 (O(k)) 变成 (O(1)),相当好用。


总结

  1. 单调队列的核心就是 双端队列维护单调性,存下标、删过期、删无效、取队头。
  2. 求最小值用递增队列,求最大值用递减队列,比较符号换一下就行。
  3. 一般不用用 queue 来写,它没有 pop_back(),天生残疾。
  4. 动手模拟一遍流程,以后遇到这类题基本可以秒。
相关推荐
WWTYYDS_6662 小时前
JsonCpp超详细使用教程
c++
不会就选b2 小时前
算法日常・每日刷题--<快排>4
算法
Keven_112 小时前
算法札记:树状数组的用途
数据结构·算法
Joey_friends2 小时前
指纹authenticate流程图
android·java·c++
用户677437175812 小时前
C++函数参数传递方式详解:string、string&、const string、const string&该怎么选?
算法
小小晓.2 小时前
C++小白记:C风格字符串和数组用法
c语言·开发语言·c++
cpp_25012 小时前
P1540 [NOIP 2010 提高组] 机器翻译
数据结构·c++·算法·队列·noip·洛谷题解
晚笙coding2 小时前
LeetCode 98:验证二叉搜索树 —— 从局部判断到全局范围约束的递归思想
算法·leetcode·职场和发展
学计算机的计算基3 小时前
回溯算法下篇:四道经典题讲透约束剪枝、原地标记、预计算与状态压缩
java·笔记·算法