单调队列/栈从入门到入土
先把最初学到的两个板子粘到这里
--单调队列--
//常见模型:找出滑动窗口中的最大值/最小值
int head=0,tail=-1;
for(int i=0;i<n;i++){
while(head<=tail&&check_out(q[head])) head++;//判断队头是否滑出窗口
//do something...
while(head<=tail&&check(q[tail],i)) tail--;
q[++tail]=i;
}
--单调栈--
//常见模型:找出每个数前/后边离它最近的比它大/小的数
int top=0;
for(int i=1;i<=n;i++){
while(top&&check(sta[top],i)) top--;
//do something...
sta[++top]=i;
}
通过上面的注释可以看到它们都是用来干嘛的
单调队列在应用上要比单调栈更重要,所以我们从单调队列开始说
单调队列——基础
找出滑动窗口中的最大值/最小值
滑动窗口这个词从狭义上来说是一个序列中可移动的连续子串,由此引出的单调队列最基础也是最根本的一道例题:滑动窗口的最值。
在一个序列中如果我们要求每个长度为len的连续子串中的最值,最暴力的方法就是从这个序列中依次把这些子串取出来,对每一个子串求最值,复杂度为(O(len*(n-len+1))),承受不了,但是我们发现,在这些子串中,有重叠的部分,也就是说,我们重复地扫过了一些区间,所以一定有方法避免掉这些重复,把复杂度降为(O(n)),避免掉这些重复的办法就是记录下来,并且应该记录得合理。
如果用DP的方法记录,空间复杂度通常较差,数据结构的记录是整体记录,整体记录通常比DP记录空间复杂度优秀,但是思维含量较高,或者说数据结构的实现更加巧妙
以最大值为例
首先我们应该想想如何去扫一遍该序列就能够处理多个区间的答案,滑动窗口滑动窗口顾名思义,这些区间中某一区间的后缀可能是另一区间的前缀,这些区间可以通过(在前面添加一个元素并且在后面删去一个元素)或者(在前面删去一个元素并且在后面添加一个元素)完成互相之间的转化,于是,我们就可以从开头的len个数组成的区间转移到所有要处理的区间,区间的个数是(n-len+1),在实际操作时,以一个元素代表现在扫到的区间,理论上可以选择任意一个元素,但是我们常用该区间的最后一个元素代表该子串。
e.g. 1 2 3 4 5 6 7 8 len=3
1] 2 3 4 5 6 7 8 ……①
1 2] 3 4 5 6 7 8 ……②
[1 2 3] 4 5 6 7 8……③
1 [2 3 4] 5 6 7 8 ……④
1 2 [3 4 5] 6 7 8 ……⑤
1 2 3 [4 5 6] 7 8 ……⑥
1 2 3 4 [5 6 7] 8 ……⑦
1 2 3 4 5 [6 7 8] ……⑧
在上面举的例子中,我们把代表元素从1到n枚举了一遍,其中存在着不是我们要处理的区间,比如说①②,为什么我们也要枚举呢?接下来我们会说
在扫过这个区间的时候,我们可以同时处理多个区间的答案,准确地来说,是在处理该区间的时候,同时考虑该区间的答案对下一个区间的答案产生的贡献,我们可以看到,对于某一个区间来说,它上一个枚举的区间的答案对这个区间答案有影响,即
⒈当这个区间新加进来的元素比上个区间的答案大,那么这个区间的答案就是新加进来的这个元素;
⒉当(这个区间新加进来的元素比上个区间的答案小)并且(上个区间的答案元素不是被删去的那个元素)时,那么这个区间的答案就是上个区间的答案;
⒊当(这个区间新加进来的元素比上个区间的答案小)并且(上个区间的答案元素是被删去的那个元素)时,那么这个区间的答案与上个区间的答案没有关系
这就是为什么我们要把①②也要枚举一遍的原因——上个区间的答案对这个区间有贡献
但是第三种情况的答案与上个区间答案没关系,需要重新枚举该区间最值,平均复杂度超出(O(n)),考虑解决办法
这个区间的答案虽然和上个区间没关系,但是不要忘了,这个区间是可以从上一个区间通过增删元素互相转化的,也就是它们的元素除了起始和结尾元素完全相同,上个区间的答案是被删去的那个元素,那么这个区间的答案就是上一个区间的次大值与新加进来的元素取max,并且前面已经删去的答案元素对后面的答案不会再有影响,现在我们不仅要记录下区间的最值还要记录下次大值,这时DP已经显现出它的劣势。
那考虑数据结构优化,看见我加粗的那句话了吗,删去的元素对后面的答案无影响,那我们就可以把这个元素从记录里删掉,其次我们并不需要记录次大/小值,只需要在删掉元素后维护最值就可以了,但是呢我们并不知道每一个元素它什么时候被删掉,所以每一个新进来的元素都必须加入这个记录里,以保证有答案可以取,那么我们认为比新加进来的元素小的元素就没有用了,可以删去,所以这个记录里的元素有一定的单调性,不仅体现在加入时间的单调,还体现在元素大小的单调,用一个支持单边添加,双边删除的数据结构,就是双端队列,因为有单调性,所以称单调队列。
流程:
从1到n枚举
不在该区间的元素出队
答案是队头
队里比新加进来的元素小的元素出队
新加进来的元素进队
至此我们就完成了单调队列的模板题
单调队列——进阶
这部分我们讲讲单调队列的应用,总体上是有关DP的优化,分为单调队列优化多重背包,单调队列优化DP,斜率优化DP
单调队列优化多重背包
我们先了解一下多重背包问题的普通解法。
多重背包:给出若干种有体积有价值的物品,每种物品有一定数量,求在给定的背包容量下能获得的最大价值。
最普通的转移方程为
复杂的式子看不出来什么,举个例子
我们令(num[i]=2),写出来枚举(j)时的式子:
我们发现,max中的值是有限的,前面的答案是可以影响到后面的答案且影响是单调的,非常像上面讲解的滑动窗口问题,如果要让它们更像,设(st=j\%v[i],p=lfloorfrac{j}{v[i]} floor),题目即变为
在(\,f[i-1][st]\,,\,f[i-1][st+v[i]]\,,\,f[i-1][st+2*v[i]]\,,\,...\,,\,f[i-1][st+p*v[i]]\,)这个序列中求每一个长度为2的区间中的最大值
一共有(p)个这样的序列,挨个处理即可,省去了枚举k的时间,时间复杂度是(O(nm))的,足够优秀
有细心的同学可能发现了,上面的三个状态转移方程中有很讨厌的常数项,元素在队列中待着的时候会变,但是我们仔细一看,这些变化并不会引起队列的单调性的变化,我们只要在每一次做完出队的操作后把队里每个元素加上(w[i])就行了,这样一来刚入队的元素是不带有(w[i])的,而队列里的每个数随着其入队时间的增加(w[i])的系数是递增的
单调队列优化DP
这事*Miracle*最近给我们讲的,复习一下
*Miracle*的ppt上写了这么几句话:
需要推出DP式子
根据决策和备选区间来优化
使得可能贡献给当前及之后的决策点之间有单调性
入门题:滑动窗口
这里神仙之所以把滑动窗口归入单调队列优化DP中是因为我们一开始在考虑解法时也是从DP的思想开始的,还记得上面我列出的几种转移情况吗,只不过我们发现DP不可做,所以应用了一种巧妙的数据结构来全局维护了一个答案序列而已。
其实这就没啥好讲的了,来看一道例题把awa
题意:你初始时有好多好多钱,但是每天持有的股票不超过(Maxp)。有(T)天,你知道每一天的买入价格(AP[i]),卖出价格((BP[i])), 买入数量限制((AS[i])),卖出数量限制((BS[i]))。 并且两次交易之间必须间隔(w)天。 现在问你(T)天结束后,最大收益是多少。
设(f[i][j])为在前(i)天手里剩下(j)股时,可以列出最简单的DP式子来:
①为卖出,②为买入
发现枚举的(k)是有范围的,前面的答案对后面有影响且影响是单调的,还是转换一下题目
令(g[x]=f[i-w-1][x],AS[i]=3,BS[i]=2),
即
求在(g[1],g[2],g[3],...,g[Maxp])这个序列中所有长度为(AS[i])和(BS[i])的区间的最大值
共有(T-w-1)个序列,可以想想为什么
需要维护两个单调队列,常数项的操作与多重背包优化一样
还有一道综合性比较强的题:[IOI2008]Island (还没写,看到的请小窗敲我,不然我会咕的qaq)
斜率优化DP
*Miracle*也讲力
在这部分单调队列的应用主要体现在求凸壳的过程,我们慢慢来
凸包:一个凸多边形(封闭图形)
凸壳:一个凸多边形的一部分(不封闭)
这就是凸壳(确信
这跟单调队列有什么关系呢?
我们有顺序地看每条直线,它们的斜率是有单调性的
来一张色彩鲜艳的图
以水平直线(红线)为x轴,从左往右看直线,在x轴上半部分(黄色凸壳),直线斜率单调递减,称为上凸壳,在x轴下半部分(蓝色凸壳),直线斜率单调递增,称为下凸壳,如果第一条黄线标号为①,然后顺时针把直线依次标到⑩,那么①到⑤的斜率是单调递减的,⑥到⑩的斜率也是单调递减的
引入我们的斜率优化DP,给出一个模型:
已知(y[j]=k[i]*x[j]+ans[i]+b[i]),求(ans[i])的最小值 ((k[i])单调递增)
点(P_j(x[j],y[j]))中的部分点形成一个凸包,有些点会落在凸包内部
题目要求求(ans[i])的最小值,即要求一条斜率为(k[i])的,过点(P_j(x[j],y[j]))的直线的最小截距减去(b[i]),易知,将一条斜率为(k[i])的直线从下往上移,第一次遇见点P,当前直线就是截距最小的直线,看图
这可以和凸包联系起来,因为凸包上的点有单调性,我们又发现红线与和它相交的两条直线①②也有关系,①的斜率比红线斜率大,②的斜率比红线斜率小,所以我们维护一个下凸壳,找到凸壳中第一个大于红线斜率的直线就可以了,而又因为每条红线的斜率是单调递增的,那么凸壳中斜率小于当前红线的直线就可以扔掉了
注:若ans前的系数为负的或让求最大值,应改为维护上凸壳,具体情况具体分析
比较麻烦的一点是凸壳内部的点,它会让求出来的直线斜率变大,也就是说我们要尽可能地让凸壳上面的直线斜率更小一些,使所有的点要么在凸包内要么在凸包上,若一个凸壳上的点连到了多条直线那么斜率最小的一条必在凸壳上,要维护这样一个凸壳可以用单调队列
这时单调队列里的元素不再是数而是点,每个点和下一个点确定了一条直线且斜率单调递增,我们要保证每条直线都在凸壳上:
考虑前i个点
-
凸壳中小于第i条直线斜率的直线都出队(不要的扔掉)
-
转移DP式子
-
凸包上斜率大于新进来的点与队尾点确定的直线斜率的直线都出队(保证凸包上面的直线斜率尽可能小)
-
新点入队
斜率优化DP的板子题:[HNOI2008]玩具装箱
单调栈——基础
单调栈要解决的问题是找出每个数前/后边离它最近的比它大/小的数
我们可以从每个数开始找,但是太慢了
以找前面最近的比它大的数为例,我们发现,对于一个数,
⒈如果它前面的数比它大,那么答案是它前面的数
⒉如果它前面的数比它小且它前面数的答案比它大,那么答案就是它前面的数的答案
⒊如果它前面的数比它小且它前面数的答案比它小,那么答案与它前面的数的答案无关
还是考虑数据结构优化,举个例子
e.g. 7 2 1 4 6 3 5
线性扫一遍序列,发现如果后面一个数比前面一个数大,那么前面那个数就可以扔掉了,因为后面那个数比前面那个数更优,那么我们每枚举到一个数,挨个扔掉前面比这个数小的数,刚好没有被扔掉的那个数就是答案,因为每次扔掉了小的数,所以这个序列是单调的,而且先进先出,用栈来实现,叫做单调栈
流程:
从1到n枚举
比这个数小的数弹出
答案是栈顶
新元素入栈
单调栈——进阶
与单调栈有关系的知识点还有笛卡尔树和虚树,单调栈还可以用来离线求RMQ以及求LIS
离线RMQ
利用单调栈里的元素都是单调的这一性质可以用来做RMQ
RMQ:给出一个序列,求若干区间最值
处理询问的时候我们先按区间右端点排序,这样遍历一遍就能处理所有询问,在遍历的时候跑单调栈,如果遇到询问就在栈中查询区间左端点,栈中第一个下标大于等于左端点的元素就是答案
码:
#include<iostream>
#include<cstdio>
#include<algorithm>
#include<string>
#include<cmath>
#include<vector>
#include<map>
#include<queue>
#include<deque>
#include<set>
#include<stack>
#include<bitset>
#include<cstring>
#define ll long long
#define max(a,b) ((a>b)?a:b)
#define min(a,b) ((a<b)?a:b)
using namespace std;
const int INF=0x3f3f3f3f,N=100010;
inline int read(){
int x=0,y=1;char c=getchar();
while (c<'0'||c>'9') {if (c=='-') y=-1;c=getchar();}
while (c>='0'&&c<='9') x=x*10+c-'0',c=getchar();
return x*y;
}
struct query{
int l,r,id,ans;
}que[N];
bool cmp(query a,query b){return a.r<b.r;}
int a[N],n,q,sta[N],top=0,ans[N];
int main(){
n=read();q=read();
for(int i=1;i<=n;i++) a[i]=read();
for(int i=1;i<=q;i++) que[i].l=read(),que[i].r=read(),que[i].id=i;
sort(que+1,que+1+q,cmp);
int k=1;
for(int i=1;i<=n;i++){
while(top&&a[sta[top]]<a[i]) top--;
sta[++top]=i;//下标存入栈中,方便查找左端点
while(que[k].r==i){
int res=que[k].l;
int l=1,r=top;
while(l<r){//二分查左端点
int mid=(l+r)>>1;
if(sta[mid]>=res) r=mid;
else l=mid+1;
}
que[k].ans=a[sta[l]];
k++;
}
}
for(int i=1;i<=q;i++) ans[que[i].id]=que[i].ans;
for(int i=1;i<=q;i++) printf("%d ",ans[i]);
return 0;
}
LIS
LIS:最长上升子序列,给定一个序列,求其子序列中满足单调上升的子序列的最大长度
这里对序列跑单调栈的时候要有一些改变,维护一个从栈底至栈顶单调递增的栈,我们在遇到小于栈顶元素的值时,操作是让这个数替换掉栈中第一个大于等于该数的元素,那么对于(sta[i])来说,它就有一个意义了:长度为i的上升子序列中末尾的数最小是多少,我们去替换也就是使该长度的上升子序列末尾的数更小,结果更优,这是贪心的思想
码:
#include<iostream>
#include<cstdio>
#include<algorithm>
#include<string>
#include<cmath>
#include<vector>
#include<map>
#include<queue>
#include<deque>
#include<set>
#include<stack>
#include<bitset>
#include<cstring>
#define ll long long
#define max(a,b) ((a>b)?a:b)
#define min(a,b) ((a<b)?a:b)
using namespace std;
const int INF=0x3f3f3f3f,N=100010;
inline int read(){
int x=0,y=1;char c=getchar();
while (c<'0'||c>'9') {if (c=='-') y=-1;c=getchar();}
while (c>='0'&&c<='9') x=x*10+c-'0',c=getchar();
return x*y;
}
int a[N],n,sta[N],top=0;
int main(){
n=read();
for(int i=1;i<=n;i++) a[i]=read();
for(int i=1;i<=n;i++){
if(a[i]>sta[top]) sta[++top]=a[i];
else{
int pos=lower_bound(sta+1,sta+1+top,a[i])-sta;
sta[pos]=a[i];
}
}
printf("%d
",top);
return 0;
}
显然,单调队列也可以用来求LIS
笛卡尔树
笛卡尔树类似于Treap
每个节点有两个参数(pos)和(val),一般认为(pos)是下标,(val)是点权,(pos)满足二叉搜索树的性质,(val)满足堆的性质,
考虑怎么建这棵树才能达到最优复杂度
以小根堆为例,我们发现在最右边一条链上,它的(pos)和(val)值都是单调递增的,我们可以先维护一个满足(pos)的最右链,然后把链上不满足(val)的元素向上跳,记作A,上面的元素下标都是小于A点下标的,所以它在跳到某个点时,记作B,可以直接继承B点并且把A点左儿子设成B点以满足二叉搜索树的性质,维护用单调栈
码:
#include<iostream>
#include<cstdio>
#include<algorithm>
#include<string>
#include<cmath>
#include<vector>
#include<map>
#include<queue>
#include<deque>
#include<set>
#include<stack>
#include<bitset>
#include<cstring>
#define ll long long
#define max(a,b) ((a>b)?a:b)
#define min(a,b) ((a<b)?a:b)
using namespace std;
const int INF=0x3f3f3f3f,N=100010;
int n,a[N],rt,ls[N],rs[N],sta[N],top=0;
void build(){//维护最右链
for(int i=1;i<=n;i++){
int pos;
while(top&&a[sta[top]]>a[i]){//往上爬
pos=sta[top];//爬到哪了
top--;
}
if(!top) rt=i;//爬到顶了
else rs[sta[top]]=i;//没爬到顶,继承原来的节点
ls[i]=pos;//更新左儿子
sta[++top]=i;
}
}
int main(){
scanf("%d",&n);
for(int i=1;i<=n;i++) scanf("%d",&a[i]);
build();
return 0;
}
来道例题:
解:二分答案+笛卡尔树验证
对于某一段区间相似,它们的笛卡尔树同构,实现时可以不用把树建出来,我们可以(O(n))扫一遍序列,每次判断栈中元素个数是否相等就行了
还有一道:Beautiful Pair
虚树
给你一棵树,和一些关键点,其他的节点没有多大用处时,在树上做一些事情就很麻烦,我们可以只保留关键点和它们之间的lca,形成一棵新的树,这样的树就叫虚树
如果n是关键点个数的话,虚树的空间复杂度是(O(n))的,因为(n)个点之间的lca最多有(n-1)个,所以虚树上的节点最多有(2n-1)个
将关键点及其lca也就是虚树上所有的点求出来,按照原树的dfs序进行排序,保证虚树与原树的形态一样,在加边的时候,我们有两种情况,若该点在栈顶子树里,那么入栈,若该点不在栈顶子树里,那么弹掉栈顶,直到该点在栈顶元素的子树里,该点入栈,判断在不在子树里可以用bitset
码:
#include<iostream>
#include<cstdio>
#include<algorithm>
#include<string>
#include<cmath>
#include<vector>
#include<map>
#include<queue>
#include<deque>
#include<set>
#include<stack>
#include<bitset>
#include<cstring>
#define ll long long
#define max(a,b) ((a>b)?a:b)
#define min(a,b) ((a<b)?a:b)
using namespace std;
const int INF=0x3f3f3f3f,N=100010;
int e[N],ne[N],h[N],idx;
int ve[N],vh[N],vne[N],vidx;
int key[N],id[N],idd=0;
int sta[N],top=0;
int n,m;
vector<int> p;
bitset<N> son[N];
bool cmp(int a,int b){
return id[a]<id[b];
}
inline int read(){
int x=0,y=1;char c=getchar();
while (c<'0'||c>'9') {if (c=='-') y=-1;c=getchar();}
while (c>='0'&&c<='9') x=x*10+c-'0',c=getchar();
return x*y;
}
void add(int a,int b){
e[idx]=b,ne[idx]=h[a],h[a]=idx++;
}
void vadd(int a,int b){
ve[vidx]=b,vne[vidx]=vh[a],vh[a]=vidx++;
}
void dfsid(int x,int fa){//处理dfs序和子树bitset
id[x]=++idd;
son[x][x]=1;
for(int i=h[x];~i;i=ne[i]){
int j=e[i];
if(j==fa) continue;
dfsid(j,x);
son[x]|=son[j];
}
}
namespace lca{
int d[N],f[N][21];
void dfs(int x,int fa){
d[x]=d[fa]+1;
f[x][0]=fa;
for(int i=1;i<=19;i++) f[x][i]=f[f[x][i-1]][i-1];
for(int i=h[x];~i;i=ne[i]) if(e[i]!=fa) dfs(e[i],x);
}
int lca(int x,int y){
if(x==y) return x;
if(d[x]<d[y]) swap(x,y);
for(int i=19;i>=0;i--){
if(d[f[x][i]]>=d[y]) x=f[x][i];
}
if(x==y) return x;
for(int i=20;i>=0;i--){
if(f[x][i]!=f[y][i]){
x=f[x][i];
y=f[y][i];
}
}
return f[x][0];
}
}
int main(){
memset(h,-1,sizeof h);
memset(vh,-1,sizeof vh);
n=read(),m=read();
for(int i=1,a,b;i<n;i++) a=read(),b=read(),add(a,b),add(b,a);
dfsid(1,0);
for(int i=1;i<=m;i++) key[i]=read(),p.push_back(key[i]);
sort(key+1,key+1+m,cmp);
lca::dfs(1,0);
for(int i=1,pos;i<m;i++){
pos=lca::lca(key[i],key[i+1]);
p.push_back(pos);
}
sort(p.begin(),p.end(),cmp);
p.erase(unique(p.begin(),p.end()),p.end());//去重
for(int i=0;i<p.size();i++){
while(top&&!son[sta[top]][p[i]]) top--;
if(top) vadd(sta[top],p[i]),vadd(p[i],sta[top]);
sta[++top]=p[i];
}
return 0;
}