栈的定义
事实上关于栈,我在紫书学习那里已经简单地介绍过有关应用的一部分了,但是为了更深入地学习(为了过数据结构这门课),我们还是对书上的知识进行更深入地学习。
栈是一种只能在一端进行插入和删除操作的线性表。表中进行操作地一端称为栈顶,表的另一端称为栈底。
当栈中没有数据元素时称为空栈,栈的插入操作通常称为进栈或入栈,删除操作通常称为出栈或退栈。
栈得主要特点是“后进先出”(LIFO),即后进栈的元素先出栈,每次进栈的数据元素都放在原来栈顶元素之前成为新的栈顶元素,每次出栈的数据元素都是当前栈顶元素。
栈的抽象数据类型描述
主要还是了解基本运算:
InitStack(&s):初始化栈,构造一个空栈s
DestroyStack(&s):销毁栈,释放栈s占用的存储空间
StackEmpty(s):判断栈是否为空,若栈s为空,则返回真;否则返回假
Push(&s,e):进栈,将元素e插入到栈s作为栈顶元素
Pop(&s,&e):出栈,从栈s中删除栈顶元素,并将其值赋给e
GetTop(s,&e):取栈顶元素,返回当前的栈顶元素,并将其值赋给e
最后两个操作的区别即在于,GetTop返回栈顶元素但不删除,Pop返回返回栈顶元素且删除。
关于判定一个序列能否为1-n的出栈序列,这里不多作赘述,只提供C++模板的代码(和C模板的区别并不大,只是C中没有stl容器需要自己手写一下):
#include<iostream>
#include<stack>
using namespace std;
int main(){
stack<int>Cg;//表示中转站的C车库
int n,exp[1000];cin>>n;
for (int i=1;i<=n;i++) cin>>exp[i];
//t1表示A遍历到的位置,t2表示B遍历到的位置,flag为是否合法
int t1=1,t2=1,flag=1; string ans[2]={"No","Yes"};
while (t2<=n){
if (t1==exp[t2]){t1++;t2++;}//A和B遍历到的元素相同的情况
else if (!Cg.empty()&&Cg.top()==exp[t2]){Cg.pop();t2++;}//栈顶为B要的元素
else if (t1<=n){Cg.push(t1);t1++;}//A的元素进栈,遍历A的下一个元素
else {flag=0;break;}
}
cout<<ans[flag]; return 0;
}
想要深究有关原理,或者看不懂代码的请转这篇博客。
栈的顺序存储结构
栈中数据元素的逻辑关系呈线性关系,所以对栈可以用线性表中的存储方式进行存储。采用顺序存储结构的栈称为顺序栈。
假设栈的元素大小不超过MaxSize(这个需要注意一下,因为理论上时会出现爆栈的情况的),所有的元素都是ElemType类型,和顺序表一样,用一个结构体来表示整个栈:
struct SqStack{ElemType data[MaxSize]; int top;};//top表示栈顶元素在data数组中的下标
此时大家可以发现top即同时兼有顺序表中length和链表中头结点(尾结点)的作用了。
基本运算的实现
事实上顺序栈的基本运算的实现比起链表要好写很多了。同样地默认元素类型为int。
初始化栈
开辟空间,同时将top设置为-1:
//初始化栈
void InitStack(SqStack *&s){s=(SqStack *)malloc(sizeof(SqStack)); s->top=-1;}
销毁栈
直接释放s即可:
//销毁栈
void DestroyStack(SqStack *&s){free(s);}
判断栈是否为空
即判断top是否为-1:
//判断栈是否为空
bool StackEmpty(SqStack *s){return(s->top==-1);}
进栈
在进栈之前首先要判定一下栈中的元素是否已经趋于饱和:如果饱和的话进栈失败,不然top+1,以新的top作为下标给数组进行赋值:
//进栈
bool Push(SqStack *&s,ElemType e){if (s->top==MaxSize-1) return false;//栈中元素饱和,进栈失败
s->top++; s->data[s->top]=e; return true;//添加元素
}
出栈
同样出栈也需要判断栈是否为空,如果栈为空则出栈失败:
//出栈
bool Pop(SqStack *&s,ElemType &e){if (s->top==-1) return false;//栈为空时,没有元素可以出栈,出栈失败
e=s->data[s->top]; s->top--; return true;//出栈,长度-1
}
其实可以发现这个元素还是存在,只是无法通过top和pop来进行了访问而已。
取栈顶元素
和出栈的写法基本一样:
//取栈顶元素,比起出栈就少了一个top-1的过程
bool GetTop(SqStack *s,ElemType &e) {if (s->top==-1) return false; e=s->data[s->top]; return true;}
基本运算合集如下:
struct SqStack{ElemType data[MaxSize]; int top;};//top表示栈顶元素在data数组中的下标
//初始化栈
void InitStack(SqStack *&s){s=(SqStack *)malloc(sizeof(SqStack)); s->top=-1;}
//销毁栈
void DestroyStack(SqStack *&s){free(s);}
//判断栈是否为空
bool StackEmpty(SqStack *s){return(s->top==-1);}
//进栈
bool Push(SqStack *&s,ElemType e){if (s->top==MaxSize-1) return false;//栈中元素饱和,进栈失败
s->top++; s->data[s->top]=e; return true;//添加元素
}//出栈
bool Pop(SqStack *&s,ElemType &e){if (s->top==-1) return false;//栈为空时,没有元素可以出栈,出栈失败
e=s->data[s->top]; s->top--; return true;//出栈,长度-1
}//取栈顶元素,比起出栈就少了一个top-1的过程
bool GetTop(SqStack *s,ElemType &e) {if (s->top==-1) return false; e=s->data[s->top]; return true;}
需要注意的是,由于我们只对栈的栈顶进行操作,所以这些操作的时间复杂度都为O(1),所以····你们懂我意思的吧hhhh。
应用举例
例 3.4
设计一个算法用顺序栈来判断一个字符串是否为对称串。所谓对称串是指从左到右读和从右到左读相同的串。
分析:······其实我始终不是很明白,为啥要用顺序栈来做这个(事实上黑书栈这里的例题也是用的这个),我一直觉得顺序表做就已经足够好了,除非是对子串进行判断可能会需要动态规划的知识。
言归正传,书上既然说要用栈来做,就有它的原因。n个元素连续出栈,产生的连续出栈序列和输入序列正好相反,这恰好满足我们这题的要求,我们要做的即是将字符串中的各个字符依次入栈再出栈进行匹配即可。
bool symmetry(ElemType str[]){ElemType e; SqStack *st; InitStack(st);//初始化栈
for (int i=0;str[i]!='\0';i++) Push(st,str[i]);//字符串元素依次进栈
for (int i=0;str[i]!='\0';i++){Pop(st,e);//将栈中的元素依次出栈,与原字符串当前位置的元素进行对比
//由于栈的特性,此时相比较的两个元素从左往右读和从右往左读的顺序一定相同
if (str[i]!=e){DestroyStack(st); return false;}
}DestroyStack(st); return true;
}
请大家在设计算法结束时,务必记得要销毁不再使用的空间。
共享栈
关于共享栈的概念,书上提到的不多,我遇到的······也不多,大家了解一下就好了。
就是说如果需要用到两个相同类型的栈,可能会遇到第一个栈已满,但是第二个栈还有很多空闲的存储空间。共享栈就是设计出来解决这个问题的:用一个数组来实现两个栈,这称为共享栈。
struct DStack{ElemType data[MaxSize]; int top1; int top2};//top表示栈顶元素在data数组中的下标
top1从-1开始,top2从MaxSize开始,保证两个栈之间可以互相共用空间,但不互相干扰。其余的操作与普通栈相同。
······别问我,我真的不知道什么情况会用到这鬼玩意。
栈的链式存储结构
即用链式存储结构实现栈,采用链式存储结构的栈称为链栈。这里采用带有头结点的单链表来实现链栈。
struct Linknode{ElemType data; Linknode *next;};
基本运算的实现
因为都是对栈顶进行操作,代码倒不难写。
初始化栈
和建立空链表是一样的:
//初始化栈
void InitStack(LinkStNode *&s){s=(LinkStNode *)malloc(sizeof(LinkStNode)); s->next=NULL;}
销毁栈
和销毁链表一样,用一个指针存放下一个结点的指针,一个一个进行销毁:
//销毁栈
void DestroyStack(LinkStNode *&s){LinkStNode *p=s->next;//p用于存储被销毁结点的下一个结点
while (p!=NULL){free(s); s=p; p=p->next;} free(s);//这里用s来遍历整个栈
}
有人可能会觉得和书上的不太一样,书上是新建了一个指针进行遍历,这里是用头结点直接遍历,本质上是一样的。
判断栈是否为空
和链表一样啦:
//判断栈是否为空
bool StackEmpty(LinkStNode *s){return(s->next==NULL);}
进栈
//进栈
void Push(LinkStNode *&s,ElemType e){LinkStNode *p;
p=(LinkStNode *)malloc(sizeof(LinkStNode));
p->data=e; p->next=s->next; s->next=p;//在头结点的后面插入新节点
}
从这里就可以很明显地发现,链栈中头结点与栈顶元素是直接相连的,类似于头插法,和我们常规理解的栈是相反的。
实在想不明白就看下面的图:
出栈
也不难,因为删除哪个已经确定了:
//出栈
bool Pop(LinkStNode *&s,ElemType &e){ LinkStNode *p;
if (s->next==NULL) return false;//如果栈为空,没有元素可出栈,出栈失败
p=s->next; e=p->data; s->next=p->next; free(p);//删除头结点的后继节点,即栈顶元素
return true;//亲,记得释放空间哦
}
取栈顶元素
基本一样咯:
//取栈顶元素
bool GetTop(LinkStNode *s,ElemType &e){if (s->next==NULL) return false;//空栈的情况
e=s->next->data; return true;
}
基本运算合集如下:
struct Linknode{ElemType data; Linknode *next;};
//初始化栈
void InitStack(LinkStNode *&s){s=(LinkStNode *)malloc(sizeof(LinkStNode)); s->next=NULL;}
//销毁栈
void DestroyStack(LinkStNode *&s){LinkStNode *p=s->next;//p用于存储被销毁结点的下一个结点
while (p!=NULL){free(s); s=p; p=p->next;} free(s);//这里用s来遍历整个栈
}//判断栈是否为空
bool StackEmpty(LinkStNode *s){return(s->next==NULL);}
//进栈
void Push(LinkStNode *&s,ElemType e){LinkStNode *p;
p=(LinkStNode *)malloc(sizeof(LinkStNode));
p->data=e; p->next=s->next; s->next=p;//在头结点的后面插入新节点
}//出栈
bool Pop(LinkStNode *&s,ElemType &e){ LinkStNode *p;
if (s->next==NULL) return false;//如果栈为空,没有元素可出栈,出栈失败
p=s->next; e=p->data; s->next=p->next; free(p);//删除头结点的后继节点,即栈顶元素
return true;//亲,记得释放空间哦
}//取栈顶元素
bool GetTop(LinkStNode *s,ElemType &e){if (s->next==NULL) return false;//空栈的情况
e=s->next->data; return true;
}
顺序栈与链栈
看到顺序栈中操作的复杂度很多人就要想了:顺序表和链表相比,顺序表根据下标查找更快,链表删除和添加元素更快,然而顺序栈中这些操作的复杂度都为O(1),那链栈的优势在什么地方呢?
事实上由于链栈是每次添加单独开辟空间,理论上就不会出现爆栈的情况(别和我抬杠说几亿的数据),所以可以发现在链栈的进栈操作中,没有一步叫判断是否能够进栈。
栈的应用
例 3.5
设计一个算法判断输入的表达式中括号是否配对(假设表达式中只含有左右圆括号)。
分析:经典题了,做法都能背下来了,将左半边的括号放入栈中,每次遇到右半边的括号就从栈中取元素进行配对,如果出现无法配对或者栈为空的情况,即配对失败,代码如下:
bool Match(char exp[],int n){int i=0; char e; bool match=true;//match表示到当前是否匹配
LinkStNode *st; InitStack(st);
while (i<n&&match){if (exp[i]=='(') Push(st,exp[i]);//如果遇到的是左半括号,直接进栈作为配对的待选
//遇到右半括号,寻找栈顶元素进行配对,因为如果此时栈中存在元素
//栈顶的左半括号一定是当前的右半向左遇到的第一个左半括号,满足配对的条件
//这里源代码写的很笨,事实上只要栈不为空,里面存放的就一定是左半边的括号
//所以match=false的情况,只有栈为空的情况
else{ if (GetTop(st,e)) Pop(st,e); else match=false;} i++;
}if (!StackEmpty(st)) match=false;//括号匹配的过程结束,栈需要为空,否则同样匹配失败
DestroyStack(st); return match;
}
书上的代码和源代码都写得很笨,我私下改了一改。
简单表达式求值
不过需要注意的是,我当时写的博客考虑到为了简化问题,规定表达式中的数字都为个位数,事实上还需要一段代码往后遍历确定数字的长度。
书上的源代码我就不复写了(因为那个源代码甚至对两种栈,还写了两套不同的结构体emmm),大家看博客知道原理即可(估计不会要求你原封不动的写出来)。
还是复写了一遍,结果发现书上的代码还是有一点小问题的,就是缺少对表达式的判断(也就是说规定表达式一定合法了),代码如下:
struct SqStack{char data[MaxSize];int top;};//存放运算符的栈
//基本操作
void InitStack(SqStack *&s){ s=(SqStack *)malloc(sizeof(SqStack)); s->top=-1;}
void DestroyStack(SqStack *&s){free(s);}
bool StackEmpty(SqStack *s){return(s->top==-1);}
bool Push(SqStack *&s,char e){if (s->top==MaxSize-1) return false;
s->top++; s->data[s->top]=e; return true;
}
bool Pop(SqStack *&s,char &e){if (s->top==-1) return false;
e=s->data[s->top]; s->top--; return true;
}
bool GetTop(SqStack *s,char &e){if (s->top==-1) return false;
e=s->data[s->top]; return true;
}
//将算术表达式exp转换成后缀表达式postexp,Optr表示中转用的运算符栈,i用于遍历后缀表达式(长度)
void trans(char *exp,char postexp[]){char e; SqStack *Optr; InitStack(Optr); int i=0;
while (*exp!='\0'){ switch(*exp){//遍历算术表达式exp中的每个元素
case '(':Push(Optr,'('); exp++; break;//如果为左括号则直接压进运算符栈
case ')':Pop(Optr,e);//如果为右括号,将运算符栈中的运算符依次弹栈加入到后缀表示式中,直到遇到左半括号
//这里先出栈再循环遍历的原因在于:根据进栈的顺序,迟早会遇到左半括号(如果表达式合法)
//然而左半括号不能加入到后缀表达式中,事实上我们这里可以发现源代码是有一定问题的
//如果表达式非法,括号无法完美匹配,可能会出现空栈无法取出元素的情况而发生卡死
while (e!='('){postexp[i++]=e; Pop(Optr,e);} exp++; break;
case '+': case '-'://如果是其他运算符,需要与运算符栈顶运算的优先级进行比较
//如果栈顶元素的优先级大于等于该运算符,就出栈将元素加入到后缀表达式中
//直到栈顶元素优先级小于该元素(这里默认括号的优先级最低)或空栈(很多人会不考虑空栈的情况)
while (!StackEmpty(Optr)){GetTop(Optr,e);
if (e!='(') {postexp[i++]=e; Pop(Optr,e);} else break;
}Push(Optr,*exp);exp++; break;//将该运算符进栈
case '*': case '/'://对于乘除号,只有相同的乘除号优先级大于它们,需要入栈
while (!StackEmpty(Optr)){GetTop(Optr,e);
if (e=='*'||e=='/') {postexp[i++]=e; Pop(Optr,e);} else break;
}Push(Optr,*exp); exp++; break;
default://如果是数字则直接进后缀表达式,这里用#来分割数字与数字
while (*exp>='0'&&*exp<='9'){postexp[i++]=*exp; exp++;}
postexp[i++]='#';//用#标识一个数值的结束
}
}//将运算符栈中剩余的运算符全部加入到后缀表达式中
while (!StackEmpty(Optr)){Pop(Optr,e); postexp[i++]=e;}
postexp[i]='\0'; DestroyStack(Optr);
}//存放后缀表达式计算中数值的栈和栈的基本操作
struct SqStack1{double data[MaxSize]; int top;};
void InitStack1(SqStack1 *&s){s=(SqStack1 *)malloc(sizeof(SqStack1)); s->top=-1;}
void DestroyStack1(SqStack1 *&s){free(s);}
bool StackEmpty1(SqStack1 *s){return(s->top==-1);}
bool Push1(SqStack1 *&s,double e){if (s->top==MaxSize-1) return false;
s->top++; s->data[s->top]=e; return true;
}
bool Pop1(SqStack1 *&s,double &e){if (s->top==-1) return false;
e=s->data[s->top]; s->top--; return true;
}
bool GetTop1(SqStack1 *s,double &e){if (s->top==-1) return false; e=s->data[s->top]; return true;}
//计算后缀表达式的值,Opnd用于放运算过程中的值
double compvalue(char *postexp){double d,a,b,c,e; SqStack1 *Opnd; InitStack1(Opnd);
while (*postexp!='\0'){switch (*postexp){
//遍历到运算符,从操作数栈中出栈两个元素进行运算然后压入操作数栈
//这里虽然对除号有除0的判定,但依旧没有对非法式进行判定
case '+':Pop1(Opnd,a); Pop1(Opnd,b); c=b+a; Push1(Opnd,c); break;
case '-':Pop1(Opnd,a); Pop1(Opnd,b); c=b-a; Push1(Opnd,c); break;
case '*':Pop1(Opnd,a); Pop1(Opnd,b); c=b*a; Push1(Opnd,c); break;
//如果是除号,要判断除数是否为0
case '/':Pop1(Opnd,a); Pop1(Opnd,b); if (a!=0){c=b/a; Push1(Opnd,c); break;}
else{printf("\n\t除零错误!\n");exit(0);} break;//否则异常退出
default:d=0;//如果是数字字符,将连续的数字字符转化为数字压入操作栈中
while (*postexp>='0'&&*postexp<='9'){d=10*d+*postexp-'0'; postexp++;}
Push1(Opnd,d); break;
}postexp++;//此时的栈顶元素即为运算式的结果
}GetTop1(Opnd,e); DestroyStack1(Opnd); return e;
}
如果同时需要判断表达式是否合法,要在出栈时判断栈中是否存在元素,结束时,判断操作栈中是否只剩余一个元素。
后面的代码为了方便起见,前面的准备工作就不复写了。
求解迷宫问题
给定一个M*N的迷宫图,求一条从指定入口到出口的迷宫路径。
分析:乍一看以为是用BFS做,但是BFS一般都是用队列去做的。仔细看了题面,突然意识到这个问题不需要求出最短的路径而只需要求出一个可行的路径,也就是用DFS(带到一点回溯)去完成的,大致的思路就是这样了。
考虑到很多人没学过DFS,建议大家对DFS先有一个初步的认识再来看这个问题会更好(然而我翻了翻写过的博客发现,有对BFS进行介绍但始终对DFS没有过系统性的介绍好像)。
代码如下:
struct Box{int i; int j; int di;};//BOX作为方格的结构体
//i是行号,j是列号,di是下一可走相邻方位的方位号
//与其说是方位不如说是方向,0表示向上,1表示向右,2表示向下,3表示向左
bool mgpath(int xi,int yi,int xe,int ye){//求解路径为:(xi,yi)->(xe,ye)
//path表示最终的路径,find表示是否能够找到下一个可以前往的结点
//i和j表示当前遍历的结点的横纵坐标,i1和j1表示下一个结点的坐标
Box path[MaxSize],e; int i,j,di,i1,j1,k; bool find;
StType *st; InitStack(st); e.i=xi; e.j=yi; e.di=-1; Push(st,e);
mg[xi][yi]=-1;//mg[i][j]表示坐标为(i,j)的点是否到达过(避免重复遍历),0表示没有到达过
while (!StackEmpty(st)){GetTop(st,e); i=e.i; j=e.j; di=e.di;
//如果已经遍历到目标的结点,打印栈中存储的路径
if (i==xe&&j==ye){printf("一条迷宫路径如下:\n"); k=0;//k-1表示路径结点个数
while (!StackEmpty(st)) {Pop(st,e); path[k++]=e;}
//由于出栈得到的序列与原路径序列相反,打印时注意要反向打印
for (int i=k-1;i>=0;i--) printf("\t(%d,%d)",path[k].i,path[k].j); printf("\n");
DestroyStack(st); return true;
}find=false;//这里看着很复杂,事实上就是考虑当前结点四个方向上的结点
//找到一个可以前往的结点,就修改栈顶元素的di值,将该结点压栈
//我自己写的时候一般直接定义一个方向数组处理起来比较方便
while (di<4&&!find){di++;
switch(di){
case 0:i1=i-1; j1=j; break; case 1:i1=i; j1=j+1; break;
case 2:i1=i+1; j1=j; break; case 3:i1=i; j1=j-1; break;
}if (mg[i1][j1]==0) find=true;
}
if (find){st->data[st->top].di=di;//修改栈顶元素的di值
e.i=i1; e.j=j1; e.di=-1; Push(st,e);//将该结点进栈
mg[i1][j1]=-1; //标记避免重复走到该方块
}
//如果找不到下一个元素了,代表此路径不可行,回溯到前面一个结点寻找下一个结点
//由于di保存了前一次方位的值,di++可以避免再选择这条错误的路径
else{Pop(st,e); mg[e.i][e.j]=0;}//Pop在这里就相当于剪枝
}DestroyStack(st); return false;
}
和我的写法习惯都不是很一样,不过有需要的可以看一看回溯法,可以说是一个简化了很多个版本的回溯法。
事实上我回溯法学的也不好。