【学习笔记】基础算法——单调栈 & 单调队列(未完善)

引入

什么是栈?

栈是一种严格遵循先进后出(LIFO)的数据结构,在 OI 中有一个 STL 为 stack,其模拟了一个栈。

具体来说,这个名称为 stack 的 STL,有以下几种常用的函数:

  1. st.push(x):这句话表示在栈 \(st\) 的栈顶塞入一个元素 \(x\)
  2. st.pop():这句话指的是取出栈 \(st\) 中的栈顶元素并扔掉,如果栈为空,可能会发生不可料想的事情;
  3. st.top():这句话会返回栈 \(st\) 中栈顶元素的数值,如果栈为空,可能会发生不可料想的事情;
  4. st.size():这句话用于计算栈 \(st\) 中目前拥有的元素数量,如果栈为空,则会返回 \(0\)
  5. st.empty():这句话只有 \(0\)\(1\) 两种结果,其中 \(0\) 表示栈 \(st\) 非空,\(1\) 则表示栈 \(st\) 为空。

这就是栈,这就是 stack,它可是我们学习 OI 时的好朋友呢!

什么是队列?

队列是一种严格遵循先进先出(FIFO)的数据结构,在 OI 中有一个 STL 为 queue,其模拟了一个队列。

具体来说,这个名称为 queue 的 STL,有以下几种常用的函数:

  1. q.push(x):这句话表示在队列 \(q\) 的头部塞入一个元素 \(x\)
  2. q.pop():这句话指的是取出队列 \(q\) 中的尾部元素并扔掉,如果队列为空,可能会发生不可料想的事情;
  3. q.front():这句话会返回队列 \(q\) 中头部元素的数值,如果队列为空,可能会发生不可料想的事情;
  4. q.size():这句话用于计算队列 \(q\) 中目前拥有的元素数量,如果队列为空,则会返回 \(0\)
  5. 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 或者 FR 代表这块土地被赐予了 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;
}

这就是单调栈的进阶运用啦。好玩吧?有趣吧?

单调栈的另一种用法

看到标题,你一定有些疑惑,单调栈还能有什么新用法呢?

其实上,就是在动态规划中利用单调栈对时间复杂度进行优化。没别的,就这玩意儿!

方法因题而异,因此不多赘述,只是说明一下,因为其运用较为广泛。

单调队列简介

posted @ 2025-04-08 20:14  嘎嘎喵  阅读(93)  评论(0)    收藏  举报