数据结构专题-学习笔记 + 专项训练:单调队列
一些 update
update 2021/2/28:修改了『概述』部分。
1.概述
单调队列是一种特殊的队列,其保证队列内的元素单调递增。
而单调队列通常解决的是这样一类问题:
有 \(n\) 个数,给定长度 \(len\),求所有区间 \([k,k+len](k+len \leq n,k \in [1,n],k \in N)\) 的最大最小值。
确实这个可以用 st 表,线段树之类的做,但是带一个 log。
而单调队列可以 \(O(n)\) 实现。
2.模板
以这道模板题为例,讲述单调队列的用法。link
手造一组样例(与例题所给略有不同):(\(n=8,k=3\))
1 3 -1 -3 5 6 7 7
以求最小值为例,建立一个双端队列。
循环 \(i\) 从 1 到 \(n\) 。
\(i=1\) 时,队列为空,插入 \(a_1\) 。
队列: 1
,最小值为 1 。
\(i=2\) 时,\(a_2=3\),比 队首元素 要大,难道我们就要残忍抛弃 \(a_2\) 吗?
不能! 虽然 \(a_2 > a_1\) ,但是随着时间的推移, \(a_1\) 会 首先被挤出队列,对后面不能做出贡献,但是 \(a_2\) 活得更久 呆在队列里的时间更长,在 \(a_1\) 离开之后就有可能成为答案,所以我们要将 \(a_2\) 插入队列。(以上为单调队列一个重要思想)
队列: 1 3
,最小值为 1 。
\(i=3\) 时,\(a_3=-1\),比前面的数都小,并且 \(a_3\)呆在队列里的时间比 \(a_1,a_2\) 更长,肯定比 \(a_1,a_2\) 更优,那么要 \(a_1,a_2\)有什么用?从队尾弹出 \(a_1,a_2\)(这就是为什么要用双端队列而不是普通队列),插入 \(a_3\) 。(以上为单调队列另一个重要思想)
所以发现了吗?我们其实是要在队列里面维护一个单调递增序列。
队列: -1
,最小值为 -1。
\(i=4\) 时, \(a_4>队尾元素\) ,根据 \(i=3\) 的推论,弹出 \(a_3\) ,插入 \(a_4\)。
队列:-3
,最小值为 -3 。
\(i=5\) 时, \(a_5>队尾元素\) ,根据 \(i=2\) 的推论,插入 \(a_5\)。
队列:-3 5
。
\(i=6\) 时,\(a_6>队尾元素\) ,根据 \(i=2\) 的推论,插入 \(a_6\)。
队列:-3 5 6
,最小值 -3。
\(i=7\) 时,\(a_7>队尾元素\) ,根据 \(i=2\) 的推论,插入 \(a_7\)。。。。。。吗?
要注意了!!!由于区间长度 \(k=3\) ,此时队首元素 \(a_4=-3\) 已经超出了区间长度的限制,无论有多小都不能对答案做出贡献,所以必须从队首弹出队列!
所以弹出 \(a_4\) ,插入 \(a_7\) 。
队列:5 6 7
,最小值为 5。
最后 \(i=8\) 时,首先弹出 \(a_4\) (过时了),然后??? \(a_8=a_7=7\) ,那么需不需要更新呢?
需要!考虑到有区间长度的限制,所以 \(a_8\) 必然比 \(a_7\) 更优,因此需要从队尾弹出 \(a_7\) ,插入 \(a_8\) 。
队列:6 7
,最小值为 6,不过此时的 7 代表 \(a_8\) 。
那么这样一来就有一个维护问题了,如何知道这个 7 代表 \(a_7\) 还是 \(a_8\) 呢?
实际写代码时为了避免这个情况,我个人通常会使用数组下标存到队列里面,这样就没有这个问题了,后面的代码都是如此。
理解如何使用单调队列求区间最小值后,求区间最大值就很轻松了,只需要仿照上述步骤,在队列内维护一个单调下降序列即可。
模板的代码:
#include<bits/stdc++.h>
using namespace std;
const int MAXN=1e6+10;
int n,k,a[MAXN],ans[MAXN][2];
deque<int>q;
int read()
{
int fh=1,sum=0;
char ch=getchar();
while(ch<'0'||ch>'9')
{
if(ch=='-') fh=-1;
ch=getchar();
}
while(ch>='0'&&ch<='9')
{
sum=(sum<<3)+(sum<<1)+ch-'0';
ch=getchar();
}
return sum*fh;
}
void GetMin()
{
deque<int>q;
for(int i=1;i<=n;i++)
{
while(!q.empty()&&i-q.front()>=k) q.pop_front();
while(!q.empty()&&a[i]<=a[q.back()]) q.pop_back();
q.push_back(i);
if(i-k+1>0) ans[i-k+1][0]=a[q.front()];
}
}
void GetMax()
{
deque<int>q;
for(int i=1;i<=n;i++)
{
while(!q.empty()&&i-q.front()>=k) q.pop_front();
while(!q.empty()&&a[i]>=a[q.back()]) q.pop_back();
q.push_back(i);
if(i-k+1>0) ans[i-k+1][1]=a[q.front()];
}
}
int main()
{
n=read();k=read();
for(int i=1;i<=n;i++) a[i]=read();
GetMin();
GetMax();
for(int i=1;i<=n-k+1;i++) cout<<ans[i][0]<<" ";
cout<<"\n";
for(int i=1;i<=n-k+1;i++) cout<<ans[i][1]<<" ";
return 0;
}
如果你成功理解了上述代码,那么恭喜你,学会了单调队列!
接下来是几道例题。
3.例题
暴力方法显而易见,那么我们如何使用单调队列解决此题呢?
推敲题意可以发现:假如我们取出一个 \(n*n\) 的矩阵 \(a\) ,那么这个矩阵的最大值一定是每一列最大值的最大值,最小值一定是每一列的最小值的最小值。
这就好做了!对每一列跑一次区间长度为 \(n\) 的单调队列,设 \(f[i][j][0/1]\) 表示第 \(j\) 列从第 \(i-k+1\)(如果小于 1 则从 1 开始) 行开始到第 \(i\) 行止的最大值/最小值,跑单调队列时存储答案,然后从第 \(n\) 行开始对每一行跑一次关于 \(f\) 的单调队列,计算最大值与最小值即可。更新答案时需要注意,只有 \(n \leq j\) 时才需要更新答案。
代码:
#include<bits/stdc++.h>
using namespace std;
const int MAXN=1000+10;
int n,m,k,a[MAXN][MAXN],f[MAXN][MAXN][2],ans=0x7f7f7f7f;
int read()
{
int fh=1,sum=0;
char ch=getchar();
while(ch<'0'||ch>'9')
{
if(ch=='-') fh=-1;
ch=getchar();
}
while(ch>='0'&&ch<='9')
{
sum=(sum<<3)+(sum<<1)+ch-'0';
ch=getchar();
}
return sum*fh;
}
void lie()
{
for(int j=1;j<=m;j++)
{
deque<int>qmax,qmin;
for(int i=1;i<=n;i++)
{
while(!qmax.empty()&&i-qmax.front()>=k) qmax.pop_front();
while(!qmin.empty()&&i-qmin.front()>=k) qmin.pop_front();
while(!qmax.empty()&&a[i][j]>=a[qmax.back()][j]) qmax.pop_back();
while(!qmin.empty()&&a[i][j]<=a[qmin.back()][j]) qmin.pop_back();
qmax.push_back(i),qmin.push_back(i);
f[i][j][0]=a[qmax.front()][j];
f[i][j][1]=a[qmin.front()][j];
}
}
}//对每一列跑一次单调队列
void hang()
{
for(int i=k;i<=n;i++)
{
deque<int>qmax,qmin;
for(int j=1;j<=m;j++)
{
while(!qmax.empty()&&j-qmax.front()>=k) qmax.pop_front();
while(!qmin.empty()&&j-qmin.front()>=k) qmin.pop_front();
while(!qmax.empty()&&f[i][j][0]>=f[i][qmax.back()][0]) qmax.pop_back();
while(!qmin.empty()&&f[i][j][1]<=f[i][qmin.back()][1]) qmin.pop_back();
qmax.push_back(j),qmin.push_back(j);
if(j>=k) ans=min(ans,f[i][qmax.front()][0]-f[i][qmin.front()][1]);
}
}
}//对每一行跑一次单调队列
int main()
{
n=read();m=read();k=read();
for(int i=1;i<=n;i++)
for(int j=1;j<=m;j++)
a[i][j]=read();
lie();
hang();
cout<<ans<<"\n";
return 0;
}
详见这篇博客
例题3:烽火传递(Noip 2010 tg 初赛 完善程序 T2)
题目大意:给定数列 \(a_{1...n}\),从中选出若干个数使得连续 \(m\) 个数内至少有一个数被选中,求被选中的数和的最小值。
输入格式:
第一行 \(n,m\) ,第 2 行 \(n\) 个数表示 \(a_{1...n}\)。
范围:\(1 \leq m \leq n \leq 10^5,1 \leq a_i \leq 100\)。
输出格式:
求被选中的数和的最小值。
样例:
input:
5 3
1 2 5 6 2
output:
4
题解:
其实我并不是太懂出题人给的程序。。。所以怎么用单调队列呢?
这一道题有最小二字,结合数据范围,可以推断出这道题是一道 dp 题。(说贪心的请自行靠边站)
首先设状态:设 \(f_i\) 表示取第 \(i\) 个数后前 \(i\) 个数取数的最小值。
然后推方程:\(f_i=\min(f_j)+a_i,j \in [i-m,i-1]\) ,应该不难想吧。
所以如何使用单调队列呢?观察到 \(\min(f_j),j \in [i-m,i-1]\),显然是固定长度 \(m\) ,可以使用单调队列维护。
这是一道经典的单调队列优化 dp 的题目,放代码:
#include<bits/stdc++.h>
using namespace std;
const int MAXN=1e5+10;
int n,m,f[MAXN],a[MAXN],ans;
deque<int>q;
int read()
{
int fh=1,sum=0;
char ch=getchar();
while(ch<'0'||ch>'9')
{
if(ch=='-') fh=-1;
ch=getchar();
}
while(ch>='0'&&ch<='9')
{
sum=(sum<<3)+(sum<<1)+ch-'0';
ch=getchar();
}
return sum*fh;
}
int main()
{
n=read();m=read();ans=0x7f7f7f7f;
for(int i=1;i<=n;i++) a[i]=read();
q.push_back(0);//记得把0推入队列,原因请自行思考
for(int i=1;i<=n;i++)
{
while(!q.empty()&&i-q.front()>m) q.pop_front();
f[i]=f[q.front()]+a[i];
while(!q.empty()&&f[q.back()]>=f[i]) q.pop_back();
q.push_back(i);
}
for(int i=n;i>=n-m+1;i--) ans=min(ans,f[i]);
cout<<ans<<"\n";
return 0;
}
详见这篇博客
4.总结
相信在做完上述题目后,各位对单调队列有了一定程度的了解。单调队列可以用来维护一个序列上固定长度区间的最大最小值,可以与各种算法如 dp 相结合。