单调队列/栈从入门到精通

单调队列/栈从入门到入土

先把最初学到的两个板子粘到这里

--单调队列--

//常见模型:找出滑动窗口中的最大值/最小值
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

单调队列优化多重背包

我们先了解一下多重背包问题的普通解法。

多重背包:给出若干种有体积有价值的物品,每种物品有一定数量,求在给定的背包容量下能获得的最大价值。

最普通的转移方程为

\[f[i][j]=max_{k=1}^{k<=min(num[i],\lfloor\frac{j}{v[i]}\rfloor)}(f[i-1][j-k*v[i]]+k*w[i]) \]

复杂的式子看不出来什么,举个例子

我们令\(num[i]=2\),写出来枚举\(j\)时的式子:

\[\begin{cases} f[i][j]&=max(f[i-1][j],&f[i-1][j-v[i]]+w[i],&f[i-1][j-2*v[i]]+2*w[i])\\ f[i][j-v[i]]&=max(f[i-1][j-v[i]],&f[i-1][j-2*v[i]]+w[i],&f[i-1][j-3*v[i]]+2*w[i])\\ f[i][j-2*v[i]]&=max(f[i-1][j-2*v[i]],&f[i-1][j-3*v[i]]+w[i],&f[i-1][j-4*v[i]]+2*w[i])\\ \end{cases} \]

我们发现,max中的值是有限的,前面的答案是可以影响到后面的答案且影响是单调的,非常像上面讲解的滑动窗口问题,如果要让它们更像,设\(st=j\%v[i],p=\lfloor\frac{j}{v[i]}\rfloor\),题目即变为

\(\,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

P2569[SCOI2010]股票交易

题意:你初始时有好多好多钱,但是每天持有的股票不超过\(Maxp\)。有\(T\)天,你知道每一天的买入价格(AP[i]),卖出价格\((BP[i])\), 买入数量限制\((AS[i])\),卖出数量限制\((BS[i])\)。 并且两次交易之间必须间隔\(w\)天。 现在问你\(T\)天结束后,最大收益是多少。

\(f[i][j]\)为在前\(i\)天手里剩下\(j\)股时,可以列出最简单的DP式子来:

\[\begin{cases} f[i][j]=max(f[i-w-1][k]+(k-j)*BP[i])&(j\leq k\leq j+BS[i])\;①\\ f[i][j]=max(f[i-w-1][k]-(j-k)*AP[i])&(j-AS[i]\leq k\leq j)\;②\\ \end{cases} \]

①为卖出,②为买入

发现枚举的\(k\)是有范围的,前面的答案对后面有影响且影响是单调的,还是转换一下题目

\(g[x]=f[i-w-1][x],AS[i]=3,BS[i]=2\)

\[\begin{cases} f[i][j]&=max(g[j]\;,&\;g[j+1]+BP[i]\;,&\;g[j+2]+2*BP[i])\\ f[i][j]&=max(g[j]\;,&\;g[j-1]-AP[i]\;,&\;g[j-2]-2*AP[i]\;,&\;g[i-3]-3*AP[i])\\\\ f[i][j+1]&=max(g[j+1]\;,&\;g[j+2]+BP[i]\;,&\;g[j+3]+2*BP[i])\\ f[i][j+1]&=max(g[j+1]\;,&\;g[j]-AP[i]\;,&\;g[j-1]-2*AP[i]\;,\;&g[i-2]-3*AP[i])\\\\ f[i][j+2]&=max(g[j+2]\;,&\;g[j+3]+BP[i]\;,&\;g[j+4]+2*BP[i])\\ f[i][j+2]&=max(g[j+2]\;,&\;g[j+1]-AP[i]\;,&\;g[j]-2*AP[i]\;,&\;g[i-1]-3*AP[i])\\ \end{cases} \]

求在\(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,当前直线就是截距最小的直线,看图

image-20210601193130482

这可以和凸包联系起来,因为凸包上的点有单调性,我们又发现红线与和它相交的两条直线①②也有关系,①的斜率比红线斜率大,②的斜率比红线斜率小,所以我们维护一个下凸壳,找到凸壳中第一个大于红线斜率的直线就可以了,而又因为每条红线的斜率是单调递增的,那么凸壳中斜率小于当前红线的直线就可以扔掉了

注:若ans前的系数为负的或让求最大值,应改为维护上凸壳,具体情况具体分析

比较麻烦的一点是凸壳内部的点,它会让求出来的直线斜率变大,也就是说我们要尽可能地让凸壳上面的直线斜率更小一些,使所有的点要么在凸包内要么在凸包上,若一个凸壳上的点连到了多条直线那么斜率最小的一条必在凸壳上,要维护这样一个凸壳可以用单调队列

这时单调队列里的元素不再是数而是点,每个点和下一个点确定了一条直线且斜率单调递增,我们要保证每条直线都在凸壳上:

考虑前i个点

  1. 凸壳中小于第i条直线斜率的直线都出队(不要的扔掉)

  2. 转移DP式子

  3. 凸包上斜率大于新进来的点与队尾点确定的直线斜率的直线都出队(保证凸包上面的直线斜率尽可能小)

  4. 新点入队

斜率优化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\n",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;
}

来道例题:

截屏2021-06-03 下午8.18.24

解:二分答案+笛卡尔树验证

对于某一段区间相似,它们的笛卡尔树同构,实现时可以不用把树建出来,我们可以\(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;
}
posted @ 2021-06-04 20:02  wsy_jim  阅读(93)  评论(0编辑  收藏  举报