数据结构专题-学习笔记 + 专项训练:单调栈
1.概述
单调栈,是一种数据结构,与单调队列相似。
单调队列使用双端队列维护,队列内元素单调递增或单调递减。
单调栈则使用普通的栈维护,栈内元素单调递增或单调递减。
接下来,通过一道例题,来看一下单调栈的基本操作。
2.模板
作为模板题,我将会详细讲解单调栈的用法。
单调栈其实类似于单调队列(不了解的可以看一看这篇文章),只不过在维护时不需要考虑元素过时问题。
通常,单调栈分为两种:单调递增栈与单调递减栈。
- 单调递增栈:栈内元素单调递增。(如
1 2 3
) - 单调递减栈:栈内元素单调递减。(如
3 2 1
)
接下来通过样例,详细说明单调栈的维护过程。
5
1 4 2 3 5
首先,类比单调队列的思想,要求大于 \(a_i\) 的第一个数,那么应该维护一个 单调递减栈 。为什么不用单调递增栈呢?原因见下文。
接下来我们规定 \(f\) 为集合 \(A=\{f_i|i \in [1,n]\}\) 。
第一个数: \(a_1=1\) ,加入栈中。
栈:1
。 \(f=\{0,0,0,0,0\}\)
第二个数: \(a_2=4>a_1\) ,考虑要维护单调递减栈,于是我们弹出 \(a_1\) ,同时记录 \(f_1=2\) (因为无论后面的数有多大,都不能再影响 \(a_1\) 了),加入 \(a_2\) 。
栈: 2
。 \(f=\{2,0,0,0,0\}\)
第三个数: \(a_3=2\) ,考虑要维护单调递减栈,加入 \(a_3\) 。
栈: 4 2
。 \(f=\{2,0,0,0,0\}\)
第四个数: \(a_4=3\) ,弹出 2
,加入 \(a_4\) 。更新 \(f_3=4\)。
为了更新答案方便,在程序中我依然在栈中存放下标。这里使用原数。
栈: 4 3
。 \(f=\{2,0,4,0,0\}\)
第五个数: \(a_5=5\) ,弹出所有数,加入 \(a_5\) ,更新 \(f_2=5,f_4=5\)。
栈: 5
。 \(f=\{2,5,4,5,0\}\)
完结撒花~~~
这里说明一下为什么不用单调递增栈:
如果手动模拟一遍,会发现在处理 \(a_5\) 时,栈内元素为 \(1,2,3\) (如果能够模拟出来说明已经掌握),加入 \(a_5\) 时, \(f_4\) 是被更新了,但是 \(f_2\) 不能被更新(相反的,\(f_2=3\) ),所以不能使用单调递增栈。
特别说明一下:针对同样的元素,一般的题目单调栈内是可以维护的(也就是都进栈),不会对答案产生影响。
接下来是代码。请在确保看懂上述过程后再看代码。
#include<bits/stdc++.h>
using namespace std;
const int MAXN=3e6+10;
int a[MAXN],n,f[MAXN],p,sta[MAXN];
int read()
{
int sum=0,fh=1;char ch=getchar();
while(ch<'0'||ch>'9') {if(ch=='-') fh=-1;ch=getchar();}
while(ch>='0'&&ch<='9') {sum=(sum<<3)+(sum<<1)+ch-'0';ch=getchar();}
return sum*fh;
}
int main()
{
n=read();p=0;//使用数组模拟栈
for(int i=1;i<=n;i++)
{
a[i]=read();
if(p==0) sta[++p]=i;//处理空栈
else
{
while(p!=0&&a[sta[p]]<a[i]) f[sta[p--]]=i;//更新答案,不要忘记判定空栈
sta[++p]=i;
}
}
for(int i=1;i<=n;i++) cout<<f[i]<<" ";
cout<<"\n";
return 0;
}
如果你成功看懂了上述代码,那么恭喜你,学会了单调栈!
接下来是几道例题。
3.例题
题单:
T1:
简直就是裸题,类比例题正着跑一遍单调递减栈,反着跑一遍单调递减栈即可。
当然:
道路千万条,long long 第一条。
结果存 int ,爆零两行泪。
代码:
#include<bits/stdc++.h>
using namespace std;
const int MAXN=1e6+10;
int n,h[MAXN],v[MAXN],faft[MAXN],fpre[MAXN],p,sta[MAXN];
typedef long long LL;
LL sum[MAXN],ans;
int read()
{
int sum=0,fh=1;char ch=getchar();
while(ch<'0'||ch>'9') {if(ch=='-') fh=-1;ch=getchar();}
while(ch>='0'&&ch<='9') {sum=(sum<<3)+(sum<<1)+ch-'0';ch=getchar();}
return sum*fh;
}
int main()
{
n=read();
for(int i=1;i<=n;i++) {h[i]=read();v[i]=read();}
p=0;
for(int i=1;i<=n;i++)
{
if(p==0) sta[++p]=i;
else
{
while(h[sta[p]]<h[i]&&p!=0) faft[sta[p--]]=i;
sta[++p]=i;
}
}//正跑单调栈
memset(sta,0,sizeof(sta));
p=0;
for(int i=n;i>=1;i--)
{
if(p==0) sta[++p]=i;
else
{
while(h[sta[p]]<h[i]&&p!=0) fpre[sta[p--]]=i;
sta[++p]=i;
}
}//反跑单调栈
for(int i=1;i<=n;i++)
{
sum[fpre[i]]+=(LL)v[i];
sum[faft[i]]+=(LL)v[i];
}//统计答案
for(int i=1;i<=n;i++) ans=max(ans,sum[i]);
cout<<ans<<"\n";
return 0;
}
T2:
这道题是道好题目,考验了对于单调栈内元素单调性的应用。
首先,我们不难想到,要去维护一个单调递减栈 (单调递增栈:为什么我还不能上场) 。为什么?因为如果一个人碰到了比自己高的人,他就不会再对答案做出贡献了,而处理第一个比自己高的人不正好可以使用单调递减栈吗?
然后,我们发现。。。。。统计答案就出了问题:因为栈内我们维护了同样的元素,所以如果要暴力去求答案,时间复杂度就会升至 \(O(N^2)\) ,那么妥妥的 TLE 。
因此,这里就要利用好单调栈的单调性了。
我们在加入 \(a_i\) 新数时,统计答案是到第一个小于等于 \(a_i\) 的数(注意:等于也是可以的)。第一个小于等于 \(a_i\) 的数?单调栈又有单调性???所以?????可以使用二分进行优化!这样,时间复杂度完美的降至 \(O(N\log N)\),可以顺利通过本题。
代码:
#include<bits/stdc++.h>
using namespace std;
const int MAXN=5e5+10;
typedef long long LL;
LL ans;
int n,a[MAXN],p,sta[MAXN];
int read()
{
int sum=0,fh=1;char ch=getchar();
while(ch<'0'||ch>'9') {if(ch=='-') fh=-1;ch=getchar();}
while(ch>='0'&&ch<='9') {sum=(sum<<3)+(sum<<1)+ch-'0';ch=getchar();}
return sum*fh;
}
int main()
{
n=read();
for(int i=1;i<=n;++i) a[i]=read();
for(int i=1;i<=n;++i)
{
if(p==0) sta[++p]=a[i];
else if(sta[p]>a[i]) sta[++p]=a[i],ans++;
else
{
int l=1,r=p,t=0;
while(l<r)
{
int mid=(l+r)>>1;
if(r==l+1) mid=r;
if(a[i]>=sta[mid]) r=mid-1;
else l=mid;
}
ans+=(LL)p-l+1;
while(p!=0&&sta[p]<a[i]) p--;
sta[++p]=a[i];
}
}
cout<<ans<<"\n";
return 0;
}
T3:
这道题也非常经典,在很多地方也都是被当作例题讲解的。
由于矩形只能向左或向右扩展,为了处理方便,我们考虑向左扩展。
显然的,向左扩展时只有碰到比自己低的才会降低高度,而此时单调递减栈就不能用了,必须使用单调递增栈维护 (单调递增栈:终于想起我了)。
然后考虑更新答案。显然的,维护的时候我们边弹出元素边更新答案。在更新答案时,我们累计当前的宽度(注意不能直接拿下标相减求得宽度,因为里面有些点高度特别小,这些点可能会影响答案),用当前向左扩展所累积的宽度乘以当前栈顶元素就是矩形面积,然后求最大值即可。
如果不理解,可以手造几组样例模拟一下。
为了处理方便,我们在首尾分别插入一个 0。
首:没有什么作用,只是保证栈不为空。
尾:作用很大!在程序结束时可能会有几个数据没有弹出栈,影响最后的答案,而加入 0 之后,由于是单调递增栈,所以最后一个 0 可以一次性弹出所有元素(当然它自己和一开始的 0 不能弹出),保证答案的正确性。
代码:
#include<bits/stdc++.h>
using namespace std;
const int MAXN=1e5+10;
int n,h[MAXN],p,sta[MAXN],wid[MAXN];
typedef long long LL;
LL ans;
int read()
{
int sum=0,fh=1;char ch=getchar();
while(ch<'0'||ch>'9') {if(ch=='-') fh=-1;ch=getchar();}
while(ch>='0'&&ch<='9') {sum=(sum<<3)+(sum<<1)+ch-'0';ch=getchar();}
if(fh==1) return sum;
return -sum;
}
int main()
{
while(1)
{
n=read();p=0;h[n+1]=0;ans=0;sta[++p]=0;wid[p]=1;
if(n==0) break;
for(int i=1;i<=n;i++) h[i]=read();
for(int i=1;i<=n+1;i++)
{
int len=0;
while(p!=0&&h[sta[p]]>h[i])
{
len+=wid[p];
ans=max(ans,(LL)h[sta[p]]*len);
p--;
}
sta[++p]=i;
wid[p]=len+1;
}
cout<<ans<<"\n";
}
return 0;
}
T4:
详见这篇文章:link
T5:
前置题单的代码就不贴了。
前置题单T1:
简单 dp ,设 \(f_{i,j}\) 表示从 \((i,j)\) (表示第 \(i\) 行第 \(j\)列,下同)到 \((1,1)\) 最大正方形的边长,易得 \(f_{i,j}=\min\{f_{i-1,j},f_{i,j-1},f_{i-1,j-1}\}+1\),直接递推即可。
前置题单T2:
方程一样,只需要提前对输入的数据做处理,然后跑一遍全是 1 的最大正方形,全是 0 的最大正方形即可。
这里说一下怎么做处理:考虑到要求黑白相间,那么我们针对 \(i*j\mod2=1\) 的点取反即可。
取反的代码:
for(int i=1;i<=n;i++)
for(int j=1;j<=m;j++)
a[i][j]=read()^((i^j)&1);
其中,\(i \oplus j \& 1\) 可以处理出当前格子是否需要取反,然后利用异或的特性就可以成功取反,然后,地图就变成了前置题单 T1 的样子。
前置题单T3:
设 \(f_{i,j}\) 表示最大土地面积。为了做题方便,我们规定任意矩形一旦定下一条边之后,就只能向上面扩展,去除后效性。
这道题越看越像最大子矩阵问题,但是我们需要处理的是面积。
为了得到最大的面积,我们首先可以先对 \((i,j)\) 做一个处理,预处理出 \((i,j)\) 能够向上扩展的最大长度,存在 \(g_{i,j}\) 当中。
然后,针对这一类矩阵内求某某最大值的问题,有一种思路就是枚举下边界,在本题中就是先枚举矩形下面一条边所在的位置。
然后呢?由于 \((i,j)\) 只能向上扩展 \(g_{i,j}\) 格,那么这道题不就变成了 T3 了吗?模拟 T3 跑一遍就可以了。
时间复杂度:枚举下边界 \(O(n)\) ,单调栈时间复杂度 \(O(n)\) ,总时间复杂度 \(O(n^2)\) 。
现在再回到这道题,前置题单全部搞定之后,这道题就是一道裸题了!!!首先,类比 前置题单T2 对地图进行一遍处理,然后跑一遍 前置题单T2 ,再跑一遍 前置题单T3 ,不就做完了?而 前置题单T1 是为 前置题单T2 做铺垫的。
代码(这里借鉴了本机房 jxw 大佬的思路与码风,表示感谢):
#include<bits/stdc++.h>
const int MAXN=2e3+10;
int n,m,a[MAXN][MAXN];
int read()
{
int sum=0,fh=1;char ch=getchar();
while(ch<'0'||ch>'9') {if(ch=='-') fh=-1;ch=getchar();}
while(ch>='0'&&ch<='9') {sum=(sum<<3)+(sum<<1)+ch-'0';ch=getchar();}
return sum*fh;
}
namespace zfx
{
int f[MAXN][MAXN],ans=0;
int solve(int op)
{
memset(f,0,sizeof(f));
for(int i=1;i<=n;i++)
for(int j=1;j<=m;j++)
if(a[i][j]==op) f[i][j]=std::min(std::min(f[i-1][j],f[i][j-1]),f[i-1][j-1])+1;
for(int i=1;i<=n;i++)
for(int j=1;j<=m;j++)
ans=std::max(ans,f[i][j]);
return ans*ans;
}
}
namespace cfx
{
int g[MAXN][MAXN],p,sta[MAXN],wid[MAXN],ans=0;
int solve(int op)
{
// std::cout<<op<<"\n";
memset(g,0,sizeof(g));
for(int i=1;i<=n;i++)
for(int j=1;j<=m;j++)
if(a[i][j]==op) g[i][j]=g[i-1][j]+1;
// for(int i=1;i<=n;i++)
// {
// for(int j=1;j<=m;j++) std::cout<<g[i][j]<<" ";
// std::cout<<"\n";
// }
for(int i=1;i<=n;i++)
{
memset(sta,0,sizeof(sta));
memset(wid,0,sizeof(wid));
p=0;sta[++p]=0;wid[p]=1;
for(int j=1;j<=m+1;j++)
{
int len=0;
while(p!=0&&g[i][sta[p]]>g[i][j])
{
len+=wid[p];
ans=std::max(ans,g[i][sta[p]]*len);
p--;
}
sta[++p]=j;
wid[p]=len+1;
}
}
return ans;
}
}
int main()
{
n=read();m=read();
for(int i=1;i<=n;i++)
for(int j=1;j<=m;j++)
a[i][j]=read()^((i^j)&1);
// for(int i=1;i<=n;i++)
// {
// for(int j=1;j<=m;j++) std::cout<<a[i][j]<<"\n";
// std::cout<<"\n";
// }
std::cout<<std::max(zfx::solve(1),zfx::solve(0))<<"\n";
std::cout<<std::max(cfx::solve(1),cfx::solve(0))<<"\n";
return 0;
}
4.总结
单调栈其实就是弱化版的单调队列,码量与常数都比单调队列小(其实个人感觉手打队列/栈时常数没有什么区别),也比较方便,但是如果数列中的元素有 寿命长短 区间限制那么就需要使用单调队列求解。也可以说,单调队列就是扩展的单调栈,它们的关系就跟线段树与树状数组一样。