基本数据结构:栈与单调栈

目录

1.模拟栈操作

2.括号匹配问题

3.表达式求值


栈的思想

栈是一种后进先出(LIFO)的线性表,只允许在栈顶进行插入删除操作。

后进先出是栈最基本的特征,有点类似于向一个桶中放东西,你最先能拿出的只能是你最后放下去的东西,而想要继续拿下层的东西,你就不得不先把上层的东西全部拿出来,如下图所示:

                 

这篇博客重点不在于栈这个数据结构的实现,更多地关注如何来利用栈后进先出的特性,所以不讲实现。并且事实上一个竞赛选手其实也很少会使用自己实现的栈,他们更多会选择使用C++标准模板库——STL。(以后更一篇用Java实现的博客吧)

STL中的栈:stack,关于STL对它的实现最好研究下源代码,是采用了一个双端队列来实现的,不过这里只介绍怎么使用它:

#include<cstdio>
//使用STL中的栈需要包含头文件"stack"
#include<stack> 
stack<int> s;//定义一个栈的格式为stack<数据类型> 变量名;
//具体的操作有:
int main(){
	for(int i=1;i<=5;++i)
		s.push(i);  //入栈
	printf("%d",s.top());   //s.top()返回栈顶元素
	s.pop();    //出栈
	printf("%d",s.size());  //s.size()返回栈中元素个数
	printf("%d",s.empty()); //s.empty()当栈为空时,返回true,否则返回false
	return 0;
}

栈的应用

1.模拟栈操作

题目链接:(https://vjudge.net/problem/UVA-514

题解:模拟火车进站出站的过程,出站只能倒着出,典型的后进先出,即经典栈模型。

我们拥有一个有序序列:1 2 3 4 ······ n    , 要判断的序列为 a1 a2 a3 a4 ······ an ,定义一个flag标志指针,用来指向判断序列,初始时指向第一个元素。将有序序列依次入栈,并将栈顶元素与flag指向的元素判等,若相等则出栈,flag++,继续将栈顶元素与flag指向元素判等,直到不等或栈空为止。示例代码如下:

#include<cstdio>
#include<iostream>
#include<stack>
using namespace std;
int a[1005];
stack<int> s;
int main() {
	int n;
	while (scanf("%d", &n) && n != 0) {
		while (scanf("%d", &a[0]) && a[0] != 0) {
			for (int i = 1; i < n; ++i)
				scanf("%d", &a[i]);
			int flag = 0;
			for (int i = 1; i <= n; ++i) {
				s.push(i);
                //while中的两个判断不能交换位置,想想为什么?提示:&&是阻断运算符
				while(s.size()>0 && s.top() == a[flag]) {
					s.pop();
					flag++;
				}
			}
			if (flag == n)    puts("Yes");
			else    puts("No");
		}
		printf("\n"); //从样例输出上看,每结束一组数据要输出一个空格。
	}
	return 0;
}

2.括号匹配问题

题目链接:(https://vjudge.net/problem/UVA-673

题解:本题是栈的经典应用(很多教材上均有讲述)。匹配的思路不难理解,依次搜索给定的括号序列:

1.空串直接返回True

2.遇到左括号,直接入栈

3.遇到右括号,与栈顶元素匹配,是左括号的话,将其出栈并继续搜索;是右括号或者为空的话,直接返回False。

4.搜索完整个串后,栈中元素如果为空,则匹配成功,是合法的;否则匹配失败,是非法的。

示例代码如下:

#include<iostream>
#include<cstring> 
#include<stack> 
using namespace std;
bool judge(char *str){
	int len=strlen(str);
	stack<char> s;
	for(int i=0;i<len;++i){
		if(str[i]=='(' || str[i]=='[') 
			s.push(str[i]);
		else{
			if(s.empty()) return 0;
			else if(str[i]==')'&&s.top()=='(' || str[i]==']'&&s.top()=='[')
				s.pop();
			else break;
		}
	}
	return s.empty();
}
int main(){
	int n;
	cin>>n;
	char str[130];
	getchar();//不要忘了输入n后有一个回车,没有这行的话后续gets将会读入错误的数据
	while(n--){
		gets(str);
		if(judge(str)) puts("Yes");
		else puts("No");
	}
	return  0;
}

3.表达式求值

题目链接:(http://ybt.ssoier.cn:8088/problem_show.php?pid=1356

题解:本题与上题一样,同样是栈的经典应用。对于表达式,通常有三种表示形式:前缀表达式(* 3 - 1 2)、中缀表达式( 3*(1-2) )、后缀表达式(1 2 - 3 *),而我们生活中最常用的就是中缀表达式。尽管中缀表达式对我们来说计算很方便,但对于计算机来说却不是这样的,计算机中后缀表达式计算起来较为方便,所以我们先来看看计算机如何计算后缀表达式的值:

扫描所求后缀表达式:

1.遇到数字,入栈

2.遇到运算符,取出栈顶两个元素计算,结果入栈

扫描完成后,栈中最终剩下一个数字,即为答案。

题目中一般给我们的都是中缀表达式,而计算机处理最方便的却是后缀表达式,该怎么办呢?其实很简单,把中缀表达式转换成后缀表达式就行了!一般我们会选择边转化边计算,这样时间复杂度仅为O(n),相比直接计算中缀表达式O(n^{2})的时间复杂度,效率大大提高。

定义两个栈,一个为数字栈,一个为操作符栈

扫描所给中缀表达式:

1.遇到数字,直接入数字栈中(注意出现多位数的情况)

2.遇到操作符:

(1)空栈直接入操作符栈

(2)左括号直接入操作符栈

(3)右括号,不断取出操作符栈栈顶与数字栈栈顶元素进行计算,知道操作符栈栈顶为左括号,然后将左括号出栈(此步骤的含义为优先计算括号内的表达式)。

(4)操作符,与操作符栈顶元素比较优先级,当前操作符比栈顶元素优先级低时,不断取出操作符栈栈顶与数字栈栈顶元素进行计算,直到当前操作符优先级高于/等于栈顶元素,然后将其入操作符栈(此步骤的含义为优先计算优先级高的表达式)。

这个转化还能用表达式树来完成,但由于本篇讲栈,所以暂且不提。

解决本题的示例代码如下:

#include<cstdio>
#include<cstring>
#include<stack>
#include<cmath>
using namespace std;
int op(char c){//判断运算符优先级
	if(c=='+'||c=='-') return 1;
	if(c=='*'||c=='/') return 2;
	if(c=='^') return 3;
	if(c=='(') return 0;//括号比较特殊,它要求遇到其它所有运算符都能入栈同时其它运算符遇到括号也都能入栈
} 
void solve(stack<int> &num, stack<char> &temp){//计算结果
	int x=num.top();
	num.pop();
	int y=num.top();
	num.pop();
	char ch=temp.top();
	temp.pop();
	switch(ch){
		case '+':
			num.push(y+x);
			break;
		case '-':
			num.push(y-x);
			break;
		case '*':
			num.push(y*x);
			break;
		case '/':
			num.push(y/x);
			break;
		case '^':
			num.push(pow(y,x));
			break;
	}
}
int change(char *s){
	int len = strlen(s);
	stack<char> temp;
	stack<int> num;
	int count=0,flag=0;
	int i=0;
	while(i<len){
		while(s[i]>='0'&&s[i]<='9'){//这里是把一个多位数入栈
			count=count*10+(s[i]-'0');
			++i;
			flag=1;
		}
		if(flag){
			num.push(count);
			count=0,flag=0;
			continue;
		}
		else{
			if(s[i]=='('){//由上面提到的括号的特殊性,所以必须特殊处理
				temp.push(s[i]);
				++i;
				continue;
			} 
			if(s[i]==')'){
				while(temp.top()!='(')
					solve(num,temp);//先计算括号里的表达式
				temp.pop();
				++i;
				continue;
			}
			if(temp.empty()) temp.push(s[i]);//空栈直接入栈即可
			else{
				while(!temp.empty()&& op(temp.top())>=op(s[i]))//比较优先级
					solve(num,temp);//先计算优先级高的表达式
				temp.push(s[i]);
			}
			++i;
		}
	}
	while(!temp.empty()) solve(num,temp);
	return num.top();
} 
char s[500];
int main(){
	scanf("%s",s);//这个因为oj的原因使用gets会出现一些莫名奇妙的错误,推荐用%s
	printf("%d",change(s));
	return 0;
}

单调栈

单调栈即维护了其中元素单调性的栈。什么意思呢?就是这个栈,它里面的元素从底到顶(或从顶到底)是有序的,要么是递增,要么是递减(当然也可以相等),呈现一个单调性。譬如,下面的两个栈就是两个单调栈:

                                                                                       

如何维护一个单调栈?

单调栈的维护很简单:如果入栈的元素满足单调性,直接入栈;如果不满足,就让栈顶元素出栈,直到能让入栈元素满足单调性为止,再将元素入栈(注意:已经出栈的元素就不再入栈了!)。

如下图:维护一个单调递减栈,入栈元素为5,由于直接入栈会打破单调性,所以应先将2, 4元素出栈后再入栈5。

                                                   

单调栈的应用?

单调栈主要用来及时排除不可能的情况,保持策略的高度有效性和秩序性,最终体现的效果就是优化了时间复杂度,值得注意的是单调栈在实际操作过程中更多地是存储元素的位置而不是元素值,在代码上就是我们需要一个栈与数组,数组存放元素值用来进行比较,栈则存放元素下标

如果不理解的话我们来看下面这道很经典的例题:(题目链接:https://vjudge.net/problem/POJ-2559

大意:求一个直方图中包含的最大矩形面积。

                            

题解:我们先简化一下题目,假定我们给出的矩形的高度是有序的,这种情况下该怎么做呢?

首先定义ans保存答案即当前最大矩形面积,我们从高度最高的矩形出发,它的宽度为1,面积为1*h1,ans=h1,继续到达第二高的矩形,它能构成的最大矩形面积是2*h2(加上它前面的比它高的矩形的一部分),ans=max(h1,2*h2),接着第三高的矩形,它能构成的最大矩形面积是3*h3(同上),ans=max(max(h1,2*h2),3*h3),依次类推不断更新最大值,扫描完所有序列后就能得到最终答案。

但本题是没有有序这个条件的,但是我们可以看到,直方图虽然整体不具有有序性,但它的局部仍是有序的,对于这些局部地区,我们仍可以采取上述方法求解。这里我们用单调栈解决这个问题,我们构造一个单调递增栈,根据单调栈的特性,在遇到高度低的矩形时要进行出栈操作,而此时出栈的矩形正好满足了上述有序求解的条件!对于后续入栈的矩形,前面出栈的矩形对构成最大矩形的贡献仅仅只在于宽度了,只要维护这个宽度值就能解决问题了。

于是,解决该问题的步骤如下:

1. 定义一个单调递增栈,从左至右依次扫描矩形;

2. 若当前矩形高度小于栈顶矩形高度,则不断将栈顶元素出栈直到栈顶矩形的高度小于

当前矩形高度;

3. 在出栈时根据上述矩形高度有序时的解决方案更新最大值,计算最大值时会用到矩形的宽度,而由于本题中每个矩形的宽度都为1,我们可以直接用下标来维护这个宽度值(当然,也可以用结构体来保存一个宽度值),只要固定右端点为当前要入栈的矩形下标,左端点依次为要出栈的矩形下标,相减即可得到宽度值;

4. 由于有矩形出栈,所以后续入栈的矩形其宽度会不再等于两端点相减值,我们还需要维护已经出栈的矩形宽度,而这只需要当出栈完成后当前矩形要入栈时,更改它的下标为最后一次出栈的矩形下标即可。

5. 为了方便处理,最后增加一个高度为负的矩形,这样在所有矩形扫描完后,能将所有矩形出栈,而不用单独计算未出栈的矩形。

示例代码如下:

#include<cstdio>
#include<cstring>
#include<stack>
#include<cmath>
using namespace std;
typedef long long ll;
const int N = 100005;
ll rect[N];
stack<int> temp;
int main(){
	int n;
	while(scanf("%d",&n) && n!=0){
		for(int i=0;i<n;++i)
			scanf("%lld",&rect[i]);
		//为了方便处理,最后增加一个高度为负的矩形,但是这里要注意,因为给出的矩形高度是可能为0的,所以最后添加的矩形一定要小于可能出现的最小矩形高度。不过如果将后面入栈出栈条件里的等号做一个互换,这里添加的矩形就只用等于可能出现的最小矩形高度,读者可以思考下。
		rect[n]=-1;
		ll ans=0;
		for(int i=0;i<=n;++i){
			if(temp.empty() || rect[temp.top()]<=rect[i])
				temp.push(i);
			else{
				int sign;
				while(!temp.empty()&&rect[temp.top()]>rect[i]){
					sign = temp.top();  
					ans=max(ans , ( i-sign ) * rect[temp.top()]);//计算面积
					temp.pop();
				}
				rect[sign]=rect[i];//维护矩形宽度
				temp.push(sign);
			}
		}
		printf("%lld\n",ans);
	}
	return 0;
}

除此外,单调栈还有一个更经典的应用——最大全1 / 全0子矩阵。(https://vjudge.net/problem/POJ-3494

大意: 在一个只有0和1的矩阵中找到符合要求的子矩阵,该子矩阵满足其内的数字全为0 / 1,且包含的数字尽可能的多。

题解:(以最大全1子矩阵为例)

该种题型有着两种专门的算法:悬线法DP和单调栈,这里仅介绍单调栈的解法。

我们随机给出一个01矩阵:

                                   0 1 1 1 0 0 1 1 0

                                   0 1 0 1 1 1 1 1 0

                                   0 0 1 1 1 1 0 0 0

                                   1 0 1 1 1 0 1 1 0

                                   1 0 1 1 0 1 1 0 1

                                   0 1 1 1 1 1 1 0 1

最简单的暴力的方法就不多说了,枚举得到全部的全1子矩阵,然后一一比较选出最大值,显然时间复杂度无法承受。

既然现在我们已经知道要用单调栈去解,那么现在就来思考一下在什么地方可以使用单调栈。直接看可能不太容易看出,那我们对上述矩阵稍加修改,将每列连续的1替换成单位长度为1的矩形(在代码编写中实际等价于求出每个元素在该列中包含其本身在内向上的连续的1的个数):

                                

接着,我们依次将每一行定为矩形的底(例如定义第一行为底,则第一列矩形的高度为0,第二、三、四列矩形的高度为1,第五、六列为0,第七、八列为1,第九列为0),现在,我们发现,每定义一次底就产生了一个不同的直方图,求最大全1矩阵的面积等价于求直方图中的最大矩形面积,而其对应的解法正是单调栈,并在上一例题中已详细介绍。剩下的就是对每一行分别求解取最大值即可。

示例代码如下:

#include<cstdio>
#include<stack> 
using namespace std;
const int N = 2005;
int num[N][N];
stack<int> temp;
int main(){
    int m,n;
    while(scanf("%d%d",&m,&n)!=EOF){
        for(int i=1;i<=m;++i)
            for(int j=1;j<=n;++j)
                scanf("%d",&num[i][j]);
        for(int i=1;i<=m;++i)
            for(int j=1;j<=n;++j)
                if(num[i][j]) num[i][j]=num[i-1][j]+1;//求出每个元素在该列中包含其本身在内向上的连续的1的个数
        for(int i=1;i<=m;++i)   num[i][n+1]=-1;
        int ans = 0;
        for(int i=1;i<=m;++i){
            for(int j=1;j<=n+1;++j){
                if(temp.empty() || num[i][temp.top()]<=num[i][j])
                    temp.push(j);
                else{
                    int sign;
                    while(!temp.empty() && num[i][temp.top()]>num[i][j]){
                        sign = temp.top();  
                        ans=max(ans , (j-sign) * num[i][temp.top()]);//计算面积
                        temp.pop();
                    }
                    num[i][sign]=num[i][j];//维护矩形宽度
                    temp.push(sign);
                }
            }
            temp.pop();
        }
        printf("%d\n",ans);
    }
    return 0;
}

例题:

1.括号匹配(http://ybt.ssoier.cn:8088/problem_show.php?pid=1355

括号匹配问题的一个加强,在标准的括号匹配问题上规定了括号嵌套的次序。解决这个问题比较简单,给括号定义优先级,在匹配时多一个优先级的判断即可。

2.中缀表达式求值(http://ybt.ssoier.cn:8088/problem_show.php?pid=1358

在表达式求值的基础上增加了要求,表达式不一定合法,需要提前判断。

3.棋盘制作(https://ac.nowcoder.com/acm/problem/20471

求解最大01相间子矩阵,其实与传统的全0/1矩阵比较只是多了一步转化过程。

  • 1
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值