20260420 - 单调队列 单调栈 总结

如果一个人比你小还比你强,那你就永远都不可能超过 Ta 了。——单调队列

已严肃被全班单调队列。/ll

单调栈

单调栈,是一个通常被用于求解前缀(后缀)区间极值的数据结构(也可以叫做算法吧?),比如求解 \(\max\) 和 \(\min\)。其核心在于利用栈结构(先进先出)维护单调序列(单调的依据与所需求解的内容相关)来解决问题。

这边用求前缀 \(\max\) 举例。

你要维护一个栈,可以用 STL stack 实现,也可以手写。这个栈该怎么维护 \(\max\) 啊?单调性是怎么样的呢?显然其实并不显然需要维护一个单调递减的序列,栈顶小,栈底大。那么每次的 \(\max\) 值就是栈底了。

每当我们新加入一个元素 \(x\) 时,为了维护单调性,我们需要不断查看栈顶情况:如果当前的栈顶 \(\text{top} \le x\),那么此时加入 \(x\) 放在栈顶位置会破坏单调性,因此我们要将此时的栈顶 \(\text{pop}\) 掉。

戳这里看代码
n=read();
for(int i=1;i<=n;i++){
    a[i]=read();
    while(ed>=st&&a[q[ed]]<=a[i])ed--;
    q[++ed]=i;
    mx[i]=q[st];//这个就是此时的前缀最大值
}

单调队列

诶诶,我们发现刚才的单调栈有一个弊端,就是它怎么只能求前缀的极值啊!那我想要求区间的极值怎么办呢?

当然是单调队列——!

单调队列的本质就是在单调栈上面加了一句话:因为单调队列考虑的是区间,所以当原来的栈的栈底已经不在这个区间内了,就要把这个栈底 \(\text{pop}\) 掉。但是栈显然不能把栈底弄出去啊(难不成把栈挖穿),所以此时就要改用队列了,而且是双端队列。

不过显然也可以手写就对了。

单调队列也被称为“滑动窗口”,因为其统计区间的时候会是一个固定长度的窗口在不断滑动。这个称呼还挺形象的。

戳这里看代码
n=read(),k=read();
for(int i=1;i<=n;i++){
    a[i]=read();
    while(st<=ed&&q[st]<=i-k)st++;//单调队列本质就是比单调栈多了这一行
    while(ed>=st&&a[q[ed]]<=a[i])ed--;
    q[++ed]=i;
    if(i>=k)mx[i]=q[st];//这个就是此时的区间最大值
}

单调队列优化 DP

这个是一个非常常用的运用!

什么意思呢?就比如说吧,现在你有一个 DP 转移是这样的:

\[dp_i = \max_{i-k \le j \le i-1}\{dp_j\} + a_i \]

显然这是一个 \(O(n^2)\) 的转移式,\(n\) 的量级是 \(5000\) 那还好,那如果 \(n\) 是 \(2 \times 10^5\) 呢?

哇感觉不可做。其实套上单调队列就很简单了,我们不就是想要知道某段固定长度为 \(k\) 的区间的 \(\max dp\) 嘛,在 DP 的过程中顺带着维护一下不就行了。

戳这里看代码
n=read(),k=read();
for(int i=1;i<=n;i++)a[i]=read();
for(int i=1;i<=n;i++){
    dp[i]=dp[q[st]]+a[i];
    //上面是 DP 转移,下面同步维护单调队列
    while(st<=ed&&q[st]<=i-k)st++;
    while(ed>=st&&dp[q[ed]]<=dp[i])ed--;
    q[++ed]=i;
}

例题选讲

实则全讲,不过有些可能一句话带过。

A - 单调队列 / 滑动窗口

单调队列模板。

戳这里看代码
#include<bits/stdc++.h>
#define LL long long
#define UInt unsigned int
#define ULL unsigned long long
#define LD long double
#define pii pair<int,int>
#define pLL pair<LL,LL>
#define pDD pair<LD,LD>
#define fr first
#define se second
#define pb push_back
#define isr insert
#define _i128 __int128
using namespace std;
const int N = 1e6+5;
int n,k,a[N],mx[N],mn[N];
int q1[N],q2[N],s1,s2,t1,t2;
int read(){
    int su=0,pp=1;char ch=getchar();
    while(ch<'0'||ch>'9'){if(ch=='-')pp=-1;ch=getchar();}
    while(ch>='0'&&ch<='9'){su=su*10+ch-'0';ch=getchar();}
    return su*pp;
}

int main(){
    n=read(),k=read();
    s1=0,s2=0,t1=0,t2=0;
    for(int i=1;i<=n;i++){
        a[i]=read();
        while(s1<=t1&&q1[s1]<=i-k)s1++;
        while(s2<=t2&&q2[s2]<=i-k)s2++;
        while(t1>=s1&&a[q1[t1]]<=a[i])t1--;
        q1[++t1]=i;
        while(t2>=s2&&a[q2[t2]]>=a[i])t2--;
        q2[++t2]=i;
        mx[i]=a[q1[s1]],mn[i]=a[q2[s2]];
    }
    for(int i=k;i<=n;i++)
        cout<<mn[i]<<" ";cout<<"\n";
    for(int i=k;i<=n;i++)
        cout<<mx[i]<<" ";cout<<"\n";
    return 0;
}

B - 琪露诺

罚时吃饱了,我感觉我把所有坑都踩了个遍 QAQ。

\(dp_i\) 表示到达格子 \(i\) 能累积的最大冰冻指数,转移方程是

\[dp_i = \max_{i-R \le j \le i-L} \{ dp_j \} + a_i \]

初值 \(dp_i = -\infty\) 但 \(dp_0 = 0\),最终答案是 \(\max_{n-R+1 \le i \le n} dp_i\)。

其实就是一个很板子的单调队列优化 DP 吧,我也不知道我为啥会那么菜。

戳这里看代码
#include<bits/stdc++.h>
#define LL long long
#define UInt unsigned int
#define ULL unsigned long long
#define LD long double
#define pii pair<int,int>
#define pLL pair<LL,LL>
#define pDD pair<LD,LD>
#define fr first
#define se second
#define pb push_back
#define isr insert
#define _i128 __int128
using namespace std;
const int N = 2e5+5;
LL n,L,R,a[N],dp[N];
LL q[N],st,ed,Ans;
LL read(){
    LL su=0,pp=1;char ch=getchar();
    while(ch<'0'||ch>'9'){if(ch=='-')pp=-1;ch=getchar();}
    while(ch>='0'&&ch<='9'){su=su*10+ch-'0';ch=getchar();}
    return su*pp;
}

int main(){
    n=read(),L=read(),R=read();
    for(int i=1;i<=n;i++)
        dp[i]=-0x3f3f3f3f3f3f3f3f;
    st=1,ed=0;dp[0]=0;
    for(LL i=0;i<=n;i++){
        a[i]=read();
        while(st<=ed&&q[st]<i-R)st++;
        if(i>=L){
            while(ed>=st&&dp[q[ed]]<=dp[i-L])ed--;
            q[++ed]=i-L;
        }
        if(st<=ed)dp[i]=dp[q[st]]+a[i];
        else if(i>=L)dp[i]=max(0ll,a[i]);
    }
    Ans=-0x3f3f3f3f3f3f3f3f;
    for(int i=n-R+1;i<=n;i++)Ans=max(Ans,dp[i]);
    cout<<Ans<<"\n";
    return 0;
}

C - 理想的正方形

两次单调队列:第一次先让每行的连续的 \(k\) 个都求出 \(\max\) 和 \(\min\),分别存放在两个 \(n \times (m-k+1)\) 的矩阵里;第二次再在原基础上让每列连续的 \(k\) 个都求出 \(\max\) 和 \(\min\),就可以对应统计答案了。

代码比较史,但可能是我的写法原因。

戳这里看代码
#include<bits/stdc++.h>
#define LL long long
#define UInt unsigned int
#define ULL unsigned long long
#define LD long double
#define pii pair<int,int>
#define pLL pair<LL,LL>
#define pDD pair<LD,LD>
#define fr first
#define se second
#define pb push_back
#define isr insert
#define _i128 __int128
using namespace std;
const int N = 1005;
int n,m,k,a[N][N],mn[N][N],mx[N][N];
int ansmn[N][N],ansmx[N][N];
int q1[N],q2[N],s1,s2,t1,t2,Ans;
int read(){
    int su=0,pp=1;char ch=getchar();
    while(ch<'0'||ch>'9'){if(ch=='-')pp=-1;ch=getchar();}
    while(ch>='0'&&ch<='9'){su=su*10+ch-'0';ch=getchar();}
    return su*pp;
}

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();
    for(int x=1;x<=n;x++){
        s1=1,s2=1,t1=0,t2=0;
        for(int i=0;i<=m;i++)q1[i]=0,q2[i]=0;
        for(int i=1;i<=m;i++){
            while(s1<=t1&&q1[s1]<=i-k)s1++;
            while(s2<=t2&&q2[s2]<=i-k)s2++;
            while(t1>=s1&&a[x][q1[t1]]<=a[x][i])t1--;
            q1[++t1]=i;
            while(t2>=s2&&a[x][q2[t2]]>=a[x][i])t2--;
            q2[++t2]=i;
            if(i>=k)mx[x][i]=a[x][q1[s1]],mn[x][i]=a[x][q2[s2]];
        }
    }
    for(int x=k;x<=m;x++){
        s1=1,s2=1,t1=0,t2=0;
        for(int i=0;i<=m;i++)q1[i]=0,q2[i]=0;
        for(int i=1;i<=n;i++){
            while(s1<=t1&&q1[s1]<=i-k)s1++;
            while(s2<=t2&&q2[s2]<=i-k)s2++;
            while(t1>=s1&&mx[q1[t1]][x]<=mx[i][x])t1--;
            q1[++t1]=i;
            while(t2>=s2&&mn[q2[t2]][x]>=mn[i][x])t2--;
            q2[++t2]=i;
            ansmx[i][x]=mx[q1[s1]][x];
            ansmn[i][x]=mn[q2[s2]][x];
        }
    }
    Ans=0x3f3f3f3f;
    for(int i=k;i<=n;i++)
        for(int j=k;j<=m;j++)
            Ans=min(Ans,ansmx[i][j]-ansmn[i][j]);
    cout<<Ans<<"\n";
    return 0;
}

D - Sugoroku

单调队列优化 DP,转移方程:

\[dp_i = \min_{i-m \le j \le i-1} \{ dp_j \} + 1 \]

再特判一下那些不能走的格子然后就结束了。

注意你 DP 的时候还需要存一下你上次是从哪里转移过来的,因为最后需要还原方案。而且因为这个家伙要求方案最小字典序,所以维护单调队列的时候要注意是严格大于才 \(\text{pop}\) 掉哦!

戳这里看代码
#include<bits/stdc++.h>
#define LL long long
#define UInt unsigned int
#define ULL unsigned long long
#define LD long double
#define pii pair<int,int>
#define pLL pair<LL,LL>
#define pDD pair<LD,LD>
#define fr first
#define se second
#define pb push_back
#define isr insert
#define _i128 __int128
using namespace std;
const int N = 1e5+5;
int n,m,a[N],dp[N],lst[N];
int q[N],st,ed;
stack<int> sta;
int read(){
    int su=0,pp=1;char ch=getchar();
    while(ch<'0'||ch>'9'){if(ch=='-')pp=-1;ch=getchar();}
    while(ch>='0'&&ch<='9'){su=su*10+ch-'0';ch=getchar();}
    return su*pp;
}

int main(){
    n=read(),m=read();
    st=1,ed=0;
    memset(dp,0x3f,sizeof(dp));
    dp[0]=0;
    for(int i=0;i<=n;i++){
        char h;cin>>h;
        while(st<=ed&&q[st]<i-m)st++;
        if(h=='0'){
            while(ed>=st&&dp[q[ed]]>dp[i])ed--;
            q[++ed]=i;
        }
        if(st<=ed)dp[i]=dp[q[st]]+1,lst[i]=q[st];
    }
    if(dp[n]>=0x3f3f3f3f){cout<<"-1\n";return 0;}
    int now=n;
    while(now){
        sta.push(now-lst[now]);
        now=lst[now];
    }
    while(!sta.empty()){
        cout<<sta.top()<<" ";
        sta.pop();
    }cout<<"\n";
    return 0;
}

E - Christmas Present 2

这个圣诞老人笨笨的,非要按顺序走,所以我们可以先算出来从圣诞老人的家出发走到第 \(1\) 栋房子,再走到第 \(2\) 栋,以此类推,最后走到第 \(n\) 栋,再回到家,所需要经历的路程。

诶诶但是这样也不行啊,因为这个笨笨的圣诞老人还不会拉车子,中途还得跑回去拿礼物。考虑在送完第 \(i\) 个小朋友后回去拿,那么加在原答案上的代价就是 \(\text{dist}(i,0) + \text{dist}(0,i+1) - \text{dist}(i,i+1)\),其中 \(\text{dist}(i,j)\) 表示从第 \(i\) 栋房子走到第 \(j\) 栋房子的距离(\(0\) 代表圣诞老人的家)。之所以后面有一个减法是因为前面统计的初值里有这个距离,但现在换了一种走法,原先的要减去。

那么计算出这 \(n-1\) 个中转站的值之后,你会发现,这个时候就变成了一个 DP 板子题——我们需要确保任意相邻的 \(k\) 个数都有一个被取了,最后的和求 \(\min\)。平方会超时,那这不就变成了单调队列优化 DP 吗!然后就做完了。

戳这里看代码
#include<bits/stdc++.h>
#define LL long long
#define UInt unsigned int
#define ULL unsigned long long
#define LD long double
#define pii pair<int,int>
#define pLL pair<LL,LL>
#define pDD pair<LD,LD>
#define fr first
#define se second
#define pb push_back
#define isr insert
#define _i128 __int128
using namespace std;
const int N = 2e5+5;
int n,k,Sx,Sy,X[N],Y[N],cnt,st,ed,q[N];
LD a[N],dp[N],Ans,ans;
int read(){
    int su=0,pp=1;char ch=getchar();
    while(ch<'0'||ch>'9'){if(ch=='-')pp=-1;ch=getchar();}
    while(ch>='0'&&ch<='9'){su=su*10+ch-'0';ch=getchar();}
    return su*pp;
}
LD Dist(LD X1,LD Y1,LD X2,LD Y2){
    return sqrt((X1-X2)*(X1-X2)+(Y1-Y2)*(Y1-Y2));
}
int main(){
    n=read(),k=read();
    Sx=read(),Sy=read();
    for(int i=1;i<=n;i++)
        X[i]=read(),Y[i]=read();
    Ans+=Dist(X[1],Y[1],Sx,Sy);
    for(int i=1;i<n;i++)
        Ans+=Dist(X[i],Y[i],X[i+1],Y[i+1]);
    Ans+=Dist(X[n],Y[n],Sx,Sy);
    for(int i=1;i<n;i++){
        LD tmp=Dist(X[i],Y[i],Sx,Sy)+Dist(X[i+1],Y[i+1],Sx,Sy);
        tmp-=Dist(X[i],Y[i],X[i+1],Y[i+1]);a[i]=tmp;
    }
    st=1,ed=1;
    q[1]=0;
    for(int i=1;i<n;i++){
        dp[i]=dp[q[st]]+a[i];
        while(st<=ed&&q[st]<=i-k)st++;
        while(ed>=st&&dp[q[ed]]>=dp[i])ed--;
        q[++ed]=i;
    }
    ans=1000000000000000000.0;
    for(int i=n-k;i<n;i++)ans=min(ans,dp[i]);
    Ans+=ans;
    printf("%.10Lf\n",Ans);
    return 0;
}

F - Maximum Xor Secondary

全场少有的单调栈!

正着、反着各做一遍单调栈(求前后缀 \(\max\) 的),每次当栈里至少有 \(2\) 个元素时,我们就把最靠近栈底的两个元素取出来——这一定是最大值和次大值——然后把它们的异或值算出来试图更新答案。

但是?这样真的涵盖了所有种情况吗?感觉这个解假完了!其实根本没假。我们考虑一左一右的一组 \(x,y\),分别是最大值和次大值(\(x>y\))。那么如果左边有一个 \(z\) 且 \(z>x\),此时这个区间的最大值和次大值就是 \(z\) 和 \(x\) 了,这个显然在单调栈的过程中会被统计到;如果右边有一个 \(z\) 且 \(z > x\),此时这个区间的最大值和次大值还是 \(z\) 和 \(x\),这个在另外一次(不是总共要做两次嘛)单调栈的过程中也会被统计到;以此类推你每种都去试一下(其实是我不想打字了)发现其实所有情况都是会被考虑到的!

然后就真的做完了。

我绝不会告诉你我自己写的时候都是猜结论过的根本没给证明。

戳这里看代码
#include<bits/stdc++.h>
#define LL long long
#define UInt unsigned int
#define ULL unsigned long long
#define LD long double
#define pii pair<int,int>
#define pLL pair<LL,LL>
#define pDD pair<LD,LD>
#define fr first
#define se second
#define pb push_back
#define isr insert
#define _i128 __int128
using namespace std;
const int N = 1e5+5;
LL n,a[N],st,ed,Ans,q[N];
LL read(){
    LL su=0,pp=1;char ch=getchar();
    while(ch<'0'||ch>'9'){if(ch=='-')pp=-1;ch=getchar();}
    while(ch>='0'&&ch<='9'){su=su*10+ch-'0';ch=getchar();}
    return su*pp;
}

int main(){
    n=read();
    for(int i=1;i<=n;i++)a[i]=read();
    st=1,ed=0;Ans=0;
    for(int i=1;i<=n;i++){
        while(ed>=st&&a[q[ed]]<=a[i])ed--;
        q[++ed]=i;
        if(st<ed)Ans=max(Ans,a[q[ed]]^a[q[ed-1]]);
    }
    st=1,ed=0;
    for(int i=n;i>=1;i--){
        while(ed>=st&&a[q[ed]]<=a[i])ed--;
        q[++ed]=i;
        if(st<ed)Ans=max(Ans,a[q[ed]]^a[q[ed-1]]);
    }
    cout<<Ans<<"\n";
    return 0;
}

G - Imbalanced Array

单调栈好吃。

可以发现,其实答案就是所有区间的最大值之和减去所有区间的最小值之和罢了,于是可以对于每个 \(a_i\) 考虑其作为最大值和最小值分别出现的次数。由于最大值和最小值本质差不多所以这边直接讲最大值了。

跑单调栈可以求出 \(a_i\) 左右两边的第一个大于它的值,假设分别是 \(a_l\) 和 \(a_r\)(\(l < i < r\)),那么当区间的左端点落在 \([l+1,i]\) 且右端点落在 \([i,r-1]\) 的时候,这个区间的最大值就肯定是 \(a_i\)。那么统计这个值最后累加求和就解决了。

……吗?错!考虑到区间元素有重复的情况,你可能因为重复而把一个区间的最大值算多次从而导致答案变大。啊那咋办,没关系,我们只需要在求解的时候,令 \(a_l\) 是严格大于 \(a_i\),而 \(a_r\) 是不小于 \(a_i\) 即可,因为此时一个区间的最大值就只会被统计一次了——哪怕有重复也只会被最靠右的那个统计到。

戳这里看代码
#include<bits/stdc++.h>
#define LL long long
#define UInt unsigned int
#define ULL unsigned long long
#define LD long double
#define pii pair<int,int>
#define pLL pair<LL,LL>
#define pDD pair<LD,LD>
#define fr first
#define se second
#define pb push_back
#define isr insert
#define _i128 __int128
using namespace std;
const int N = 1e6+5;
LL n,a[N],Ans,s[N];
LL q1[N],q2[N],t1,t2;
LL l1[N],r1[N],l2[N],r2[N];
LL read(){
    LL su=0,pp=1;char ch=getchar();
    while(ch<'0'||ch>'9'){if(ch=='-')pp=-1;ch=getchar();}
    while(ch>='0'&&ch<='9'){su=su*10+ch-'0';ch=getchar();}
    return su*pp;
}

int main(){
    n=read();Ans=0;
    for(int i=1;i<=n;i++)
        a[i]=read(),s[i]=s[i-1]+a[i];
    t1=0,t2=0;
    for(int i=1;i<=n;i++){
        while(t1&&a[q1[t1]]>a[i])t1--;
        l1[i]=q1[t1]+1;q1[++t1]=i;
        while(t2&&a[q2[t2]]<a[i])t2--;
        l2[i]=q2[t2]+1;q2[++t2]=i;
    }
    t1=0,t2=0;
    q1[0]=n+1,q2[0]=n+1;
    for(int i=n;i>=1;i--){
        while(t1&&a[q1[t1]]>=a[i])t1--;
        r1[i]=q1[t1]-1;q1[++t1]=i;
        while(t2&&a[q2[t2]]<=a[i])t2--;
        r2[i]=q2[t2]-1;q2[++t2]=i;
    }
    for(LL i=1;i<=n;i++)
        Ans+=a[i]*(r2[i]-i+1)*(i-l2[i]+1),
        Ans-=a[i]*(r1[i]-i+1)*(i-l1[i]+1);
    cout<<Ans<<"\n";
    return 0;
}

H - Pictures with Kittens (hard version)

DP 好闪,拜谢 DP。

咦我们发现这个什么 \(k,x,n\) 都好小哦只有 \(5000\),那平方级别的算法肯定能过去的对吧!而我们又发现这题有一个非常显而易见的 DP 状态定义,就是定义 \(dp_{i,j}\) 表示当前考虑了前 \(i\) 张图片、强制转发了第 \(i\) 张图片且目前总共转发了 \(j\) 张图片所能收获的美丽值最大总和,显然有转移:

\[dp_{i,j} = \max_{i-k \le i_2 \le i-1} \{ dp_{i_2,j-1} \} + a_i \]

哎哎哎但是这个东西就会变成立方的了!!!这怎么可能过得去啊?

急啥,区间 \(\max\) 这不一眼单调队列优化 DP 啊!但是是二维 DP,难不成……?!对啊开 \(m\) 个单调队列又不会炸,叫啥。

然后就做完了?最后的答案不就是 \(\max_{n-k+1 \le i \le n} \{ dp_{i,x} \}\) 吗。

真的非常非常水呢。

戳这里看代码
#include<bits/stdc++.h>
#define LL long long
#define UInt unsigned int
#define ULL unsigned long long
#define LD long double
#define pii pair<int,int>
#define pLL pair<LL,LL>
#define pDD pair<LD,LD>
#define fr first
#define se second
#define pb push_back
#define isr insert
#define _i128 __int128
using namespace std;
const int N = 5005;
LL n,k,m,a[N],dp[N][N],Ans;
LL q[N][N],st[N],ed[N];
LL read(){
    LL su=0,pp=1;char ch=getchar();
    while(ch<'0'||ch>'9'){if(ch=='-')pp=-1;ch=getchar();}
    while(ch>='0'&&ch<='9'){su=su*10+ch-'0';ch=getchar();}
    return su*pp;
}

int main(){
    n=read(),k=read(),m=read();
    for(int i=0;i<m;i++)st[i]=1,ed[i]=0;
    for(int i=1;i<=n;i++)a[i]=read();
    if(n>m*k+k-1){cout<<"-1\n";return 0;}
    for(int i=0;i<=n;i++)
        for(int j=0;j<=m;j++)dp[i][j]=-0x3f3f3f3f3f3f3f3f;
    dp[0][0]=0;
    for(int i=0;i<=n;i++){
        if(i)for(int j=1;j<=m;j++){
            if(st[j-1]>ed[j-1])continue;
            LL res=dp[q[j-1][st[j-1]]][j-1];
            dp[i][j]=max(dp[i][j],res+a[i]);
        }
        for(int j=0;j<m;j++){
            while(st[j]<=ed[j]&&q[j][st[j]]<=i-k)st[j]++;
            while(ed[j]>=st[j]&&dp[q[j][ed[j]]][j]<=dp[i][j])ed[j]--;
            q[j][++ed[j]]=i;
        }
    }
    Ans=-0x3f3f3f3f3f3f3f3f;
    for(int i=n-k+1;i<=n;i++)Ans=max(Ans,dp[i][m]);
    if(Ans<0)cout<<"-1\n";
    else cout<<Ans<<"\n";
    return 0;
}

总结

单调队列和单调栈真的是两个很厉害的数据结构呢!

如果你看到了这里,那么你一定严格单调队列我吧!(确信
如果你没看到这里,你也一定严格单调队列我吧!(确信 \(\times 2\)

Thanks reading.

posted @ 2026-04-20 20:53  嘎嘎喵  阅读(70)  评论(2)    收藏  举报