【学习笔记】基础算法——单调栈 & 单调队列(未完善)
引入
什么是栈?
栈是一种严格遵循先进后出(LIFO)的数据结构,在 OI 中有一个 STL 为 stack,其模拟了一个栈。
具体来说,这个名称为 stack 的 STL,有以下几种常用的函数:
st.push(x):这句话表示在栈 \(st\) 的栈顶塞入一个元素 \(x\);st.pop():这句话指的是取出栈 \(st\) 中的栈顶元素并扔掉,如果栈为空,可能会发生不可料想的事情;st.top():这句话会返回栈 \(st\) 中栈顶元素的数值,如果栈为空,可能会发生不可料想的事情;st.size():这句话用于计算栈 \(st\) 中目前拥有的元素数量,如果栈为空,则会返回 \(0\);st.empty():这句话只有 \(0\) 和 \(1\) 两种结果,其中 \(0\) 表示栈 \(st\) 非空,\(1\) 则表示栈 \(st\) 为空。
这就是栈,这就是 stack,它可是我们学习 OI 时的好朋友呢!
什么是队列?
队列是一种严格遵循先进先出(FIFO)的数据结构,在 OI 中有一个 STL 为 queue,其模拟了一个队列。
具体来说,这个名称为 queue 的 STL,有以下几种常用的函数:
q.push(x):这句话表示在队列 \(q\) 的头部塞入一个元素 \(x\);q.pop():这句话指的是取出队列 \(q\) 中的尾部元素并扔掉,如果队列为空,可能会发生不可料想的事情;q.front():这句话会返回队列 \(q\) 中头部元素的数值,如果队列为空,可能会发生不可料想的事情;q.size():这句话用于计算队列 \(q\) 中目前拥有的元素数量,如果队列为空,则会返回 \(0\);q.empty():这句话只有 \(0\) 和 \(1\) 两种结果,其中 \(0\) 表示队列 \(q\) 非空,\(1\) 则表示队列 \(q\) 为空。
这就是队列,这就是 queue,它可是我们学习 OI 时的好朋友呢!
“单调”是什么意思?
相信大家学习二分查找以及二分答案时,早已听说过“单调性”这个词汇。
是的,在二分答案中,单调性通常是指从某一个满足条件的位置开始,前面所有位置都不满足条件,后面所有位置都满足条件,反过来也对。
那么,在咱们的“单调栈”以及“单调队列”中,这个“单调”又是什么意思呢?
它的意思是,这个栈,或者队列,里头存着的数值,要么是单调上升的,要么是单调下降的,要么是单调不降的,要么是单调不升的。
总之,它是单调的。
可是,这个栈或者这个队列单调了,又有什么用呢?且听下文分析。
正文
单调栈简介
单调栈,是一种 OI 中常见的数据结构(亦可称为算法),其可在 \(O(n)\) 的时间内求出一个序列中每个前缀的最小值或最大值,也可在 \(O(n)\) 的时间内求出一个序列的所有后缀最小值或后缀最大值。它的应用较为广泛,通常使用 STL 中的 stack 或手写栈来实现。代码写起来一般都不长,简短明了,是一个非常方便的数据结构(亦可称为算法)哟!
从例题入手
阅读题面
注:此处使用洛谷网站上的 B3666 求数列所有后缀最大值的位置作为例题进行讲解。
给定一个数列 \(a\),初始为空。有 \(n\) 次操作,每次在 \(a\) 的末尾添加一个正整数 \(x\)。
每次操作结束后,请你找到当前 \(a\) 所有的后缀最大值的下标(下标从 1 开始)。一个下标 \(i\) 是当前 \(a\) 的后缀最大值下标当且仅当:对于所有的 \(i < j \leq |a|\),都有 \(a_i > a_j\),其中 \(|a|\) 表示当前 \(a\) 的元素个数。
为了避免输出过大,请你每次操作结束后都输出一个整数,表示当前数列所有后缀最大值的下标的按位异或和。
\(1 \leq n \leq 10^6\),\(1 \leq x_i \lt 2^{64}\)。
分析题意
看完题面,你是不是有些懵?或者完全一头雾水?可能是的吧,但我们可以来理解一下。
注:理解清楚了题意的巨佬们可以跳过这一段分析。
我们有一个长度为 \(n\) 的序列 \(a\),对于这个序列的每个前缀,对,注意了,是每个前缀!
好,接着说。对于这个序列的每个前缀,我们需要求出所有后缀最大值的下标的按位异或和,直接使用运算符 ^ 即可解决。
问题来了:后缀最大值是啥玩意儿?
题面解释得其实已经很清楚了,我们再来梳理一下:
就是指在当前考虑的这个前缀中,如果位置 \(i\) 上的值是后缀最大值的话,需要满足所有 \(j>i\),都有 \(a_i > a_j\),也就是说,\(a_i\) 要比它后面所有的 \(a\) 值都大。当然,可万万别理解错了,我们现在只考虑了当前这个前缀,超出部分不算哟。
现在你理解题目意思了么?如果还没有理解,那就再仔细研读一下吧!
算法的介入
接下来的任务就是解决这道题目。
题意已经很清晰了,现在我们想要知道,到底该如何做,才能在限制时间内算出这些答案呢?
这个时候,我们就要请出本篇学习笔记的主角之一——单调栈啦!它能够帮助我们解决这道题目哟!
这个单调栈呀,在本题中,它维护的是目前所有后缀最大值的下标的合集,其中离栈顶越近的数越大,因为它们处在数组的后面部分。
那么,我们该如何去维护呢?
我们应该明确,我们是一个一个数值加进栈里头的,这点应该非常明显。
重点来了,咱好好讲讲吧。
首先,我们用一个变量 \(i\) 遍历 \(1 \sim n\),对应遍历了 \(a\) 数组中的所有数值。
接下来咱们要定义一个全局变量,类型为 stack,名称我给它取做 \(st\)。这,就是我们的单调栈。
来吧,现在咱要将第 \(i\) 个值扔进 \(st\) 里头了,直接扔进去就对了是吗?当然不是的,要不然维护啥呀?
因为我们第 \(i\) 个值的出现,\(a_i\) 可能会“干掉”一些前面的值,可能存在一些本来是后缀最大值的,结果来了 \(a_i\) 以后没法超过 \(a_i\) 了,那这些数,就被 \(a_i\) 给“干掉”了。
我们该如何处理这些被 \(a_i\) “干掉”的值呢?很简单,将它们从栈里弹出就行了。
但是我们不能碰着一个数就给它弹出去,要不然又没维护东西了。咋办?简单,我们每次看看栈顶,如果会被 \(a_i\) 干掉,那就弹出;不会,跳出循环。
这样,就非常简便地处理掉了那些被 \(a_i\) “干掉”的值了呢。于是现在,咱们的 \(i\) 就可以放心大胆地入栈啦!
刚才讲完了核心的单调栈部分,可大家要注意了,咱们需要求解的到底是啥啊?是那些后缀最大值的下标的异或和呀!这该咋做?
其实上非常简单,咱们可以用一个变量 \(ans\) 存储答案。每次“干掉”值的时候,就把它也从 \(ans\) 里“干掉”;每次让 \(i\) 入栈时,同时也让 \(i\) “住”进 \(ans\) 里。
可是该具体怎么“干掉”,又该怎么“住”呢?
这可就要用到异或的特性了,如果异或一个数值两次,等同于没有异或过这个数值。
这不就完事儿了吗?“干掉”的时候,直接异或就好了;“住”的时候,也是异或,不就搞定了吗?不难吧!
编起代码来也是简简单单呢。
那咱们来看看本题的完整代码吧:
#include<bits/stdc++.h>
using namespace std;
const int N = 1e6+5;
int n;
unsigned long long a[N],ans;
stack<int> st;
int read_int(){
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;
}
unsigned long long read_ull(){
unsigned long long 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*10llu+ch-'0';ch=getchar();}
return su*pp;
}
int main(){
n=read_int();
for(int i=1;i<=n;i++){
a[i]=read_ull();
while(!st.empty()&&a[st.top()]<=a[i])
ans^=st.top(),st.pop();
st.push(i);ans^=i;cout<<ans<<"\n";
}
return 0;
}
说明一下,由于本题卡常,因此开了快读;为了节省代码量,我在输入的同时就进行了单调栈的维护,其并不影响最终的结果。
看吧,单调栈,并不难,而且还挺有意思的呢!
初试身手
接下来我们来看另一道题目吧。
这里使用的是洛谷网站上的 P4147 玉蟾宫作为习题进行练手。
阅读题面
有一天,小猫 rainbow 和 freda 来到了湘西张家界的天门山玉蟾宫,玉蟾宫宫主蓝兔盛情地款待了它们,并赐予它们一片土地。
这片土地被分成 \(N \times M\) 个格子,每个格子里写着 R 或者 F,R 代表这块土地被赐予了 rainbow,F 代表这块土地被赐予了 freda。
现在 freda 要在这里卖萌。它要找一块矩形土地,要求这片土地都标着 F 并且面积最大。
但是 rainbow 和 freda 的 OI 水平都弱爆了,找不出这块土地,而蓝兔也想看 freda 卖萌(她显然是不会编程的……),所以它们决定,如果你找到的土地面积为 \(S\),它们每人给你 \(S\) 两银子。
请你输出你最多能得到多少两银子。记得 \(\times 3\) 哦!
\(1 \leq N, M \leq 1000\)。
思考解决方案
请大家先自行思考。如果有思路,可以反复确认是否可行,然后阅读下面的文案,看看你的思路是否与我相同;如果没有思路,请反复思考至少半个小时,实在不会做再仔细阅读下面的文案,透彻理解我的做法。
这个题目,可有点儿意思。不过嘛,不难,毕竟作者我——一只菜爆了的蒟蒻,都很快想出来了。
首先,我们需要预处理一个东西,这个东西呢,就是 \(a\) 数组。其中,\(a\) 是个二维数组,\(a_{i,j}\) 表示从这片土地的第 \(i\) 行第 \(j\) 列开始往上,有多少个连续的 F。特殊的,如果这片土地的第 \(i\) 行第 \(j\) 列本来就不是 F,\(a_{i,j}=0\)。
这个东西可以在输入的时候就将其维护好。具体见下:
cin>>n>>m;
for(int i=1;i<=n;i++)
for(int j=1;j<=m;j++){
cin>>c[i][j];
a[i][j]=(c[i][j]=='F')*(a[i-1][j]+1);
}
以上代码中的 \(c\) 就是那个字符数组。
处理完这个东西,我们接下来该做什么呢?
我们枚举每一行,然后以这一行为这个矩形的最底部,来算一下最大的矩形。
为什么要这么干?因为我们处理的 \(a\) 数组是往上的连续个数,适合做这件事。
接下来,我们要用到在单调栈里面非常常见的一个处理方式,可以说是套路,那就是——指定一个位置,然后计算这个位置往左边以及右边扩散,能得到的最大的答案。
那这个方法就非常适用于本题啦!我们可以根据这一行的每一个位置,都用数组 \(p\) 和 \(q\) 分别处理出当前这一行的第 \(i\) 个位置,按照它的这个 \(a\) 值,往左能探多少步,往右又能探多少步。
诶等等,这里讲得也太潦草了,“探”是什么个意思?要满足啥子条件才能“探”?问得好,问得好!
这里的“探”,其实上是看左边或右边有多少个连续的不小于这个位置的 \(a\) 值的 \(a\) 值。也就是说,都是以 \(a\) 值作为参考的,并且当前这个位置是这一大块 \(a\) 值里边最小的那一个(允许有多个最小的)!这样讲,清楚了吧?
可是,这个玩意儿该咋维护?总不能枚举每一个位置然后暴力“探”吧?肯定不行的,因此咱就得请出——单调栈,这位主角啦!是的,用单调栈正着反着维护两次就可以轻轻松松地维护出本次的 \(p\) 和 \(q\)。至于维护方法,由于比较显然,在此不多说,留作习题。
那么,求出对应的 \(p\) 和 \(q\),就可以尝试更新答案咯!如果让这一行(假设是第 \(k\) 行)的第 \(i\) 个位置为中间那个值的话,我们的答案 \(ans\) 就可能变成 \(a_{k,i} \times (q_i - p_i + 1)\)。那个 \(+1\) 似乎有点不大好看,没关系我们可以把它干掉,就在前面维护 \(q\) 的时候让数值默认 \(+1\) 就行了,其实上这样还减少了很多码量因为正常算 \(q_i\) 需要 \(-1\) 呢。
嘿嘿,是不是 so easy?那咱来看一下这一段代码吧:
for(int o=1;o<=n;o++){
while(!st.empty())st.pop();
for(int i=1;i<=m;i++){
while(!st.empty()&&a[o][st.top()]>=a[o][i])st.pop();
if(st.empty())p[i]=0;else p[i]=st.top();st.push(i);
}
while(!st.empty())st.pop();
for(int i=m;i>=1;i--){
while(!st.empty()&&a[o][st.top()]>=a[o][i])st.pop();
if(st.empty())q[i]=m;else q[i]=st.top()-1;st.push(i);
}
for(int i=1;i<=m;i++)ans=max(ans,a[o][i]*(q[i]-p[i]));
}
可简单了,不是么?
最后亮出漂亮的完整代码:
#include<bits/stdc++.h>
using namespace std;
int n,m,a[1005][1005],p[1005],q[1005],ans;
char c[1005][1005];stack<int> st;
int main(){
cin>>n>>m;
for(int i=1;i<=n;i++)
for(int j=1;j<=m;j++){
cin>>c[i][j];
a[i][j]=(c[i][j]=='F')*(a[i-1][j]+1);
}
for(int o=1;o<=n;o++){
while(!st.empty())st.pop();
for(int i=1;i<=m;i++){
while(!st.empty()&&a[o][st.top()]>=a[o][i])st.pop();
if(st.empty())p[i]=0;else p[i]=st.top();st.push(i);
}
while(!st.empty())st.pop();
for(int i=m;i>=1;i--){
while(!st.empty()&&a[o][st.top()]>=a[o][i])st.pop();
if(st.empty())q[i]=m;else q[i]=st.top()-1;st.push(i);
}
for(int i=1;i<=m;i++)ans=max(ans,a[o][i]*(q[i]-p[i]));
}
cout<<ans*3;
return 0;
}
这就是单调栈的进阶运用啦。好玩吧?有趣吧?
单调栈的另一种用法
看到标题,你一定有些疑惑,单调栈还能有什么新用法呢?
其实上,就是在动态规划中利用单调栈对时间复杂度进行优化。没别的,就这玩意儿!
方法因题而异,因此不多赘述,只是说明一下,因为其运用较为广泛。

浙公网安备 33010602011771号