数据结构专题-学习笔记 + 专项训练:单调栈

1.概述

单调栈,是一种数据结构,与单调队列相似。

单调队列使用双端队列维护,队列内元素单调递增或单调递减。

单调栈则使用普通的栈维护,栈内元素单调递增或单调递减。

接下来,通过一道例题,来看一下单调栈的基本操作。

2.模板

link

作为模板题,我将会详细讲解单调栈的用法。

单调栈其实类似于单调队列(不了解的可以看一看这篇文章),只不过在维护时不需要考虑元素过时问题。

通常,单调栈分为两种:单调递增栈与单调递减栈。

  • 单调递增栈:栈内元素单调递增。(如 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.例题

题单:

  1. link
  2. link
  3. link
  4. link
  5. link

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.总结

单调栈其实就是弱化版的单调队列,码量与常数都比单调队列小(其实个人感觉手打队列/栈时常数没有什么区别),也比较方便,但是如果数列中的元素有 寿命长短 区间限制那么就需要使用单调队列求解。也可以说,单调队列就是扩展的单调栈,它们的关系就跟线段树与树状数组一样。

posted @ 2022-04-12 21:41  Plozia  阅读(38)  评论(0编辑  收藏  举报