单调队列
概念
顾名思义,单调队列就是在队列的基础上,维护一个单调的序列。
性质
- 队列中的元素其对应在原来的序列中的顺序必须是单调递增的。
- 队列中元素的大小必须是单调递(增/减/自定义)。
先来一道模板题来感受一下单调队列的应用:
模板题:滑动窗口
题目
【题目描述】
有一个长为 n 的序列 a,以及一个大小为 k 的窗口。现在这个从左边开始向右滑动,每次滑动一个单位,求出每次滑动后窗口中的最大值和最小值。
例如:
【输入格式】
输入一共有两行,第一行有两个正整数 n,k。 第二行 n 个整数,表示序列 a
【输出格式】
输出共两行,第一行为每次窗口滑动的最小值
第二行为每次窗口滑动的最大值
【输入样例】
8 3 1 3 -1 -3 5 3 6 7
【输出样例】
-1 -3 -3 -3 3 3 3 3 5 5 6 7
【数据规模】
对于 50% 的数据,1≤n≤105;
对于 100% 的数据,1≤k≤n≤106,ai∈[−231,231)。
解析
单调队列模板题。
对于最小值来说,我们维护一个单调递增队列,
这是因为我们要让队列的头为该区间的最小值,那么后一个数要比头大,
因为是单调的,所以每一个进来的数,都应该比队列中的数大,所以是单调递增队列。
题目中还有一个限制条件,那便是窗口大小为k,所以我们要时刻维护队列中的数的下标大于当前下标减去k,
如果不满足该条件,就从队列头删去该数,可见单调队列是个双端队列,这也便是为什么不用栈的原因。
具体实现时,我们令head表示队列头+1,tail表示队列尾,
那么问题来了,为什么head要+1呢?
试想一下,如果head不+1,那么当head=tail时,队列中到底是没有数还是有1个数呢?显然无法判断。
所以我们令head的值+1,当head<=tail时,队列中便是有值的,如果head>tail,队列便为空。
我们用样例来模拟一下单调队列,以求最小值为例:
- i=1,队列为空,1进队,[1]
- i=2,3比1大,满足单调性,3进队,[1,3]
- i=3,-1比3小,破坏单调性,3出队,-1比1小,1出队,队列为空,-1进队[-1],此时i>=k,输出队头,即-1
- i=4,-3比-1小,-1出队,队列为空,-3进队[-3],输出-3
- i=5,5比-3大,5进队,[-3,5],输出-3
- i=6,3比5小,5出队,3比-3大,3进队,[-3,3],输出-3
- i=7,-3下标为4,i-4=3,大于等于k,-3已不在区间中,-3出队,6比3大,6进队,[3,6],输出3
- i=8,7比6大,7进队,[3,6,7],输出3
这样最小值便求完了,最大值同理,只需在判断时改变符号即可。
Code

#include <algorithm> #include <iostream> #include <cstring> #include <string> #include <cstdio> #include <cmath> using namespace std; const int N=1e6+500; int n,k,a[N],q[N],head=1,tail;//head要+1 int main() { scanf("%d %d",&n,&k); for(int i=1;i<=n;i++) { //求最小值 scanf("%d",&a[i]); while(head<=tail&&q[head]<=i-k) head++;//队头显然是最早进入的,如果队头的下标大于i-k,该数便不在区间内了,从队头删除 while(head<=tail&&a[q[tail]]>=a[i]) tail--;//当前数破坏了单调性,从队尾删除,直至队中数小于当前数 q[++tail]=i;//当前元素进队 if(i>=k) printf("%d ",a[q[head]]);//输出每个区间最小值 } printf("\n"); head=1,tail=0; for(int i=1;i<=n;i++) { //求最大值 while(head<=tail&&q[head]<=i-k) head++; while(head<=tail&&a[q[tail]]<=a[i]) tail--; q[++tail]=i;//当前元素进队 if(i>=k) printf("%d ",a[q[head]]); } return 0; }
学废单调队列了吗?
再来几道例题练练手吧!
例题一:切蛋糕
题目
【题目描述】
今天是小Z的生日,同学们为他带来了一块蛋糕。这块蛋糕是一个长方体,被用不同色彩分成了N个相同的小块,每小块都有对应的幸运值。
小Z作为寿星,自然希望吃到的第一块蛋糕的幸运值总和最大,但小Z最多又只能吃M小块(M≤N)的蛋糕。
吃东西自然就不想思考了,于是小Z把这个任务扔给了学OI的你,请你帮他从这N小块中找出连续的k块蛋糕(k≤M),使得其上的幸运值最大。
【输入格式】
输入文件cake.in的第一行是两个整数N,M。分别代表共有N小块蛋糕,小Z最多只能吃M小块。
第二行用空格隔开的N个整数,第i个整数Pi代表第i小块蛋糕的幸运值。
【输出格式】
输出文件cake.out只有一行,一个整数,为小Z能够得到的最大幸运值。
【输入样例】
6 3 1 -2 3 -4 5 -6
【输出样例】
5
【数据规模】
对20%的数据,N≤100。
对100%的数据,N≤500000,|Pi|≤500。 答案保证在2^31-1之内。
解析
因为蛋糕是连续的,所以不难联想到前缀和,令sum[i]表示从第1块到第i块蛋糕的幸运值之和。
于是很自然的想到了暴力:从1到n枚举i,从i-M+1到i枚举j,那么最大幸运值maxn=max(maxn,sum[i]-sum[j-1])
但是这样显然会超时,考虑优化。
对于每一个i来说,实际上我们只需要找到最小的sum[j-1]即可,所以我们可以用单调递增队列来维护最小的sum[j-1]的值,
那么这不就是一个滑动窗口么?数列为sum[1]~sum[n],区间长度为1~M,求每个区间的最小值,
唯一不同的就是区间长度不是一个定值,而是1~M,但这也不难办,依旧只需保证队列长度不超过M即可。
Code

#include <algorithm> #include <iostream> #include <cstring> #include <string> #include <cstdio> #include <cmath> #include <queue> using namespace std; inline int read()//快读 { int num=0,w=1; char ch=getchar(); while(ch<'0'||ch>'9') { if(ch=='-') w=-1; ch=getchar(); } while(ch>='0'&&ch<='9') { num=(num<<1)+(num<<3)+ch-'0'; ch=getchar(); } return num*w; } const int N=500100; int n,m,p[N],sum[N],q[N],head=1,tail,maxn; int main() { n=read(),m=read(); for(int i=1;i<=n;i++) { p[i]=read(),sum[i]=sum[i-1]+p[i],maxn=max(maxn,p[i]);//可能只有一个 /*for(int j=i-m+1;j<=i;j++) 40分 maxn=max(maxn,sum[i]-sum[j-1]);*/ while(head<=tail&&i-q[head]>m) head++; maxn=max(maxn,sum[i]-sum[q[head]]); while(head<=tail&&sum[q[tail]]>=sum[i]) tail--; q[++tail]=i; } printf("%d",maxn); return 0; }
例题二:Flowerpot S
题目
【题目描述】
老板需要你帮忙浇花。给出N滴水的坐标,y表示水滴的高度,x表示它下落到x轴的位置。
每滴水以每秒1个单位长度的速度下落。你需要把花盆放在x轴上的某个位置,使得从被花盆接着的第1滴水开始,到被花盆接着的最后1滴水结束,之间的时间差至少为D。
我们认为,只要水滴落到x轴上,与花盆的边沿对齐,就认为被接住。给出N滴水的坐标和D的大小,请算出最小的花盆的宽度W。
【输入格式】
第一行2个整数 N 和 D。
第2.. N+1行每行2个整数,表示水滴的坐标(x,y)。
【输出格式】
仅一行1个整数,表示最小的花盆的宽度。如果无法构造出足够宽的花盆,使得在D单位的时间接住满足要求的水滴,则输出-1。
【输入样例】
4 5 6 3 2 4 4 10 12 15
【输出样例】
2
【样例解释】
有4滴水, (6,3), (2,4), (4,10), (12,15).水滴必须用至少5秒时间落入花盆。花盆的宽度为2是必须且足够的。把花盆放在x=4..6的位置,它可以接到1和3水滴, 之间的时间差为10-3 = 7满足条件。
【数据规模】
40%的数据:1 ≤ N ≤ 1000,1 ≤ D ≤ 2000;
100%的数据:1 ≤ N ≤ 100000,1 ≤ D ≤ 1000000,0≤x,y≤10^6。
解析
先设想一下,如果我们已经知道了W,那么怎么求时间差是否大于等于D?
显然,我们只需判断每个长为W的区间,最早落下的和最晚落下的水滴的时间差是否大于等于D即可。
也就只需要知道最小的y与最大的y即可,这不就是滑动窗口吗?
将水滴按照x从小到大排序,水滴序列便满足单调队列的性质1:序列为顺序
于是问题就成了:给定一个序列(y值),区间长度为W,求每个区间的最大值和最小值。
分别用维护最大值的单调递减队列和维护最小值的单调递增队列即可。
时间差的问题解决了,那么W怎么知道?
二分!
令l=0,因为这题宽度为实际宽度-1,例如样例中的4~6实际宽度为3,输出却是2,而对于3~3这样实际宽度为1的,应输出0,故l从0开始
r=最大的x+1,如果连W=最大的x时都无法满足题意时,l便会等于r,即l=最大的x+1,这样我们只需在二分完后判断l是否等于最大的x+1,就可以知道有无答案了。
Code

#include <algorithm> #include <iostream> #include <cstring> #include <string> #include <cstdio> #include <cmath> #include <queue> using namespace std; inline int read()//快读 { int num=0; char ch=getchar(); while(ch<'0'||ch>'9') ch=getchar(); while(ch>='0'&&ch<='9') { num=(num<<1)+(num<<3)+ch-'0'; ch=getchar(); } return num; } const int N=100100; struct rec{ int x,y; }a[N]; int n,d,minq[N],maxq[N]; bool cmp(rec p,rec q) { return p.x<q.x; } bool check(int w) { int minh=1,mint=0,maxh=1,maxt=0; for(int i=1;i<=n;i++) { while(minh<=mint&&a[i].x-a[minq[minh]].x>w) minh++;//最小值 while(maxh<=maxt&&a[i].x-a[maxq[maxh]].x>w) maxh++;//最大值 while(minh<=mint&&a[minq[mint]].y>=a[i].y) mint--; while(maxh<=maxt&&a[maxq[maxt]].y<=a[i].y) maxt--; minq[++mint]=i,maxq[++maxt]=i; if(a[maxq[maxh]].y-a[minq[minh]].y>=d) return true;//符合题意 } return false; } int main() { n=read(),d=read(); for(int i=1;i<=n;i++) a[i].x=read(),a[i].y=read(); sort(a+1,a+n+1,cmp);//按x从小到大排序 int l=0,r=a[n].x+1;//+1是为了让l能够比a[n].x大,令下面判断是否应输出-1 while(l<r)//二分查找最小的W { int mid=(l+r)>>1; if(check(mid)) r=mid; else l=mid+1; } if(l==a[n].x+1) printf("-1"); else printf("%d",l); return 0; }
例题三:围栏
题目
【题目描述】
有N块木板从左到右排成一行,有M个工匠对这些木板进行粉刷,每块木板至多被粉刷一次。
第 i 个木匠要么不粉刷,要么粉刷包含木板 Si 的,长度不超过 Li 的连续的一段木板,每粉刷一块可以得到 Pi 的报酬。
不同工匠的Si不同。
请问如何安排能使工匠们获得的总报酬最多。
【输入格式】
第一行包含两个整数N和M。
接下来M行,每行包含三个整数Li,Pi,Si。
【输出格式】
输出一个整数,表示结果。
【输入样例】
8 4 3 2 2 3 2 3 3 3 5 1 1 7
【输出样例】
17
【数据规模】
1≤N≤16000,
1≤M≤100,
1≤Pi≤10000
解析
显然,这是一道动态规划题。
令f[i][j]表示前i个工匠粉刷前j块木板(可以有空着不刷的木板),工匠们能获得的最多报酬。
状态转移方程:f[i][j]=
- f[i-1][j] 即第i个工匠不刷
- f[i][j-1] 即第j块木板不刷
- f[i-1][k]+Pi*(j-k) 其中 j-Li ≤k ≤Si-1&&j ≥Si 即第i个工匠刷第k+1到第j块木板
事实上,对于第3种转移方式可以改写成f[i][j]=Pi*j+max(f[i-1][k]-Pi*k)
当j增大时,对于k1与k2(k1<k2<Si-1)来说,显然k1会更早离开[j-Li,Si-1]这个区间,
与此同时,如果f[i-1][k1]-Pi*k1≤f[i-1][k2]-Pi*k2,那么k2显然比k1更优,即k1是无用的决策,应排除出候选集合。
所以我们可以维护一个k单调递增,f[i-1][k]-Pi*k单调递减的队列,该队列维护方式如下:
- 当j变大时,从队头将小于j-Li的决策出队
- 新的决策入队时,在队尾将破坏f[i-1][k]-Pi*k的单调性的决策出队,然后将新的决策入队
那么对于每种情况,最优决策即为队头。
需要注意的是,如果Si-Li小于0,那么k应该从0开始,毕竟k不能为负数。
Code

#include <algorithm> #include <iostream> #include <cstring> #include <string> #include <cstdio> #include <cmath> #include <queue> using namespace std; inline int read()//快读 { int num=0; char ch=getchar(); while(ch<'0'||ch>'9') ch=getchar(); while(ch>='0'&&ch<='9') { num=(num<<1)+(num<<3)+ch-'0'; ch=getchar(); } return num; } const int N=16500,M=150; struct rec{ int l,p,s; }a[M]; int n,m,f[M][N],q[N],head,tail; /*f[i][j]表示前i个工匠粉刷前j块木板(可以有空着不刷的木板),工匠们能获得的最多报酬。 f[i][j]=max(f[i][j],以下三种情况) 1、f[i-1][j] 第i个工匠不刷 2、f[i][j-1] 第j块木板不刷 3、f[i-1][k]+Pi*(j-k) 其中 j-Li ≤k ≤Si-1,j ≥Si 第i个工匠刷第k+1到第j块木板 3可以改为f[i][j]=Pi*j+max(f[i-1][k]-Pi*k) */ bool cmp(rec x,rec y) { return x.s<y.s; } int calc(int i,int k) { return f[i-1][k]-a[i].p*k; } int main() { n=read(),m=read(); for(int i=1;i<=m;i++) a[i].l=read(),a[i].p=read(),a[i].s=read(); sort(a+1,a+m+1,cmp);//按Si从小到大排序,令序列为顺序 for(int i=1;i<=n;i++) { head=1,tail=0; for(int k=max(0,a[i].s-a[i].l);k<=a[i].s-1;k++)//Si-Li<0时,k从0开始 { while(head<=tail&&calc(i,q[tail])<=calc(i,k)) tail--; q[++tail]=k; } for(int j=1;j<=n;j++) { f[i][j]=max(f[i-1][j],f[i][j-1]);//第一种转移方式和第二种转移方式 if(j>=a[i].s) { while(head<=tail&&q[head]<j-a[i].l) head++; if(head<=tail) f[i][j]=max(f[i][j],calc(i,q[head])+a[i].p*j);//第三种转移方式 } } } printf("%d",f[m][n]); return 0; }
例题四:理想的正方形
题目
【题目描述】
有一个a*b的整数组成的矩阵,现请你从中找出一个n*n的正方形区域,使得该区域所有数中的最大值和最小值的差最小。
【输入格式】
第一行为3个整数,分别表示a,b,n的值
第二行至第a+1行每行为b个非负整数,表示矩阵中相应位置上的数。每行相邻两数之间用一空格分隔。
【输出格式】
仅一个整数,为a*b矩阵中所有“n*n正方形区域中的最大整数和最小整数的差值”的最小值。
【输入样例】
5 4 2 1 2 5 6 0 17 16 0 16 17 2 1 2 10 2 1 1 2 2 2
【输出样例】
1
【数据规模】
(1)矩阵中的所有数都不超过1,000,000,000
(2)20%的数据2<=a,b<=100,n<=a,n<=b,n<=10
(3)100%的数据2<=a,b<=1000,n<=a,n<=b,n<=100
解析
二维的单调队列题目。
考虑如何求每个正方形的最大值:
枚举每一行,求出每个长度为n的区间的最大值,这不正是滑动窗口吗?
将求出来的maxx[][]作为一个新的矩形,然后从1到b-n+1枚举列,
依旧是滑动窗口求出每列的maxx[][]最大值,记录为maxy[][],
又形成了一个新的矩形,这便是每个正方形的最大值。
不理解的可以看下面的图,以样例为例。
初始矩形:
maxx[][]矩形:
maxy[][]矩形:
最小值同理。
最后只需从1到a-n+1枚举i,从1到b-n+1枚举j,求出最小的maxy[i][j]-miny[i][j]即可。
Code

#include <algorithm> #include <iostream> #include <cstring> #include <string> #include <cstdio> #include <cmath> #include <queue> using namespace std; inline int read()//快读 { int num=0; char ch=getchar(); while(ch<'0'||ch>'9') ch=getchar(); while(ch>='0'&&ch<='9') { num=(num<<1)+(num<<3)+ch-'0'; ch=getchar(); } return num; } const int N=1010; int a,b,n,s[N][N],head,tail,q[N],maxx[N][N],maxy[N][N],minx[N][N],miny[N][N],minn=0x7f7f7f7f; int main() { a=read(),b=read(),n=read(); for(int i=1;i<=a;i++) for(int j=1;j<=b;j++) s[i][j]=read(); for(int i=1;i<=a;i++)//每一行的每个区间的最大值 { head=1,tail=0; for(int j=1;j<=b;j++) { while(head<=tail&&q[head]<=j-n) head++; while(head<=tail&&s[i][q[tail]]<=s[i][j]) tail--; q[++tail]=j; if(j>=n) maxx[i][j-n+1]=s[i][q[head]]; } } for(int i=1;i<=b-n+1;i++)//每个正方形的最大值 { head=1,tail=0; for(int j=1;j<=a;j++) { while(head<=tail&&q[head]<=j-n) head++; while(head<=tail&&maxx[q[tail]][i]<=maxx[j][i]) tail--; q[++tail]=j; if(j>=n) maxy[j-n+1][i]=maxx[q[head]][i]; } } for(int i=1;i<=a;i++)//每一行每个区间的最小值 { head=1,tail=0; for(int j=1;j<=b;j++) { while(head<=tail&&q[head]<=j-n) head++; while(head<=tail&&s[i][q[tail]]>=s[i][j]) tail--; q[++tail]=j; if(j>=n) minx[i][j-n+1]=s[i][q[head]]; } } for(int i=1;i<=b-n+1;i++)//每个正方形的最小值 { head=1,tail=0; for(int j=1;j<=a;j++) { while(head<=tail&&q[head]<=j-n) head++; while(head<=tail&&minx[q[tail]][i]>=minx[j][i]) tail--; q[++tail]=j; if(j>=n) miny[j-n+1][i]=minx[q[head]][i]; } } for(int i=1;i<=a-n+1;i++) for(int j=1;j<=b-n+1;j++) minn=min(minn,maxy[i][j]-miny[i][j]);//每个正方形的最大值减去最小值 printf("%d",minn); return 0; }