栈结构解析及其应用
导言#
随着生活水平的不断提高,越来越多的轿车走进千家万户,不过这也带来了一个严重的问题——停车位的寻找变得困难,因此在生活中我们经常会遇到把车停在不应该停的位置,导致半夜接到电话要求挪车或者收了罚单。现在我们来想象一个情景,我要在一个只有一个出口的窄巷子停车,那么停在内部的车想要开出来,就必须等在最外面的车开走,新的车停进来,只能停在窄巷子的最外面,最里面的车想要开出来就必须让其他所有的车都开走。
这真是一种我们很不愿意见到的情景,好在现实中司机一般不会做这种事情。如果我们把这个窄巷子抽象成一个线性表,车当做表中的元素,我们会发现这个线性表只能对表尾操作,放入新的元素就必须从表尾放入,由于尾部的元素把表的唯一出口堵死了,因此想要把表中的元素拿出,就只能拿出表尾,即最后一个元素。那么这种特殊的顺序表就是一种新的数据结构——栈,它的特点是先进后出,后进先出。栈在计算机相关领域中使用广泛,举个大家熟悉的例子,例如浏览器的后退功能,同个这个按键,我们可以查看单个网页的页面之前查看过的连接,而且这个按键的操作也是单向的,后查看的链接会被先查看。
什么是栈?#
栈(stack)又名堆栈,它是一种运算受限的线性表,受限于该结构仅能在表尾进行元素的插入和删除操作。首先栈本质上还是一个线性表,只是有一些操作上较为特殊,栈中的元素具有仍然具有线性关系。在允许进行插入和删除的一段被称之为栈顶,表的另一端被称为栈底,若在栈中没有任何元素,栈就被称为空栈,栈结构的插入操作被称为压栈,删除操作被称为退栈或出栈。栈最鲜明的特点就是先进后出,后进先出,出栈的元素一定是位于栈顶的元素,在栈顶的元素出栈之后,下一个元素就成为新的栈顶,当栈底的元素执行出栈操作之后栈就成为了空栈。
栈的抽象数据类型#
ADT Stack
{
Data:
D = {ai | 1 ≤ i ≤ n, n ≥ 0, ai 为 ElemType 类型} //同线性表
Relation:
R = { <ai,ai+1> | ai,ai+1 ∈ D, i = 1, i ∈ (0,n)} //同线性表
Operation:
InitStack(&s); //初始化栈,开辟一个空间给栈 s
StackEmpty(*s); //判断栈是否为空栈,若为空栈返回 true,否则返回 false
Push(&s,e); //进栈操作,将元素 e 加入栈结构中并使其作为栈顶
Pop(&s,&e); //出栈操作,将位于栈顶的元素删除,并赋值给变量 e
GetTop(s,&e); //取栈顶操作,若栈不为空栈,返回栈顶元素并赋值给变量 e
ClearStack(&s); //清空栈,将栈中的所有元素清空,即将栈变为空栈
DestroyStack(&s); //销毁栈,将释放栈的空间
}
顺序栈及其基本操作#
顺序栈#
栈是一种特殊的线性表,也自然可以使用顺序存储结构来实现。在 C\C++ 中,我们对于顺序存储往往使用数组来描述,因此我们需要为一个数组选择栈底和栈顶,为了方便描述空栈判定和栈满判定,我们使用下标为 0 的位置作为栈底,当栈顶的下标为数组元素上限时即为栈满,为了时刻定位栈顶的位置,需要定义一个栈顶指针作为游标来辅助。
顺序栈的结构体定义
#define MAXSIZE 100
typedef struct
{
ElemType data[MAXSIZE];
int top; //栈顶指针
}SqStack;
初始化栈#
为一个新建立的栈 s 分配足够的空间,由于空栈没有任何元素,因此栈顶指针将初始化为 -1。
void InitStack(SqStack s)
{
s = new SqStack;
s->top = -1; //栈顶指针将初始化为 -1,表示没有任何元素
}
空栈判断#
某个结构为空一直是一个显然而敏感的状态,如果不妥善处理就会出现严重的异常,就例如对空栈执行出栈操作,就会出现非法读取的情况。因此虽然空栈判断的代码简单,但是值得我们重视。函数在栈为空栈时返回 true,反之返回 false。
bool StackEmpty(SqStack *s)
{
if(s->top == -1)
{
return true;
}
return false;
}
进栈操作#
由于栈是一种操作受限的线性表,因此进栈操作是其核心操作之一。进栈的关键在于只能在表尾进行插入,并且当栈的空间为满的时候,不能入栈。函数将在栈不为满栈的情况下,在栈顶指针 top 处插入元素 e 并使其自增 1,插入成功返回 true,否则返回 false。时间复杂度 O(1)。
bool Push(SqStack &s,ElemType e)
{
if(s->top == MAXSIZE - 1) //判断是否栈满
{
return false;
}
s->data[s->top++] = e; //入栈
return true;
}
出栈操作#
同进栈,出栈也是很重要的操作,出栈的关键在于只能在表尾进行插入,并且当栈的空间为空的时候,不能出栈。函数将在栈不为空栈的情况下,将位于栈顶指针 top 处的元素出栈并赋值给变量 e ,top 需要并使其自减 1,退栈成功返回 true,否则返回 false。时间复杂度 O(1)。
bool Pop(SqStack &s,ElemType e)
{
if(StackEmpty(s)) //判断是否为空栈
{
return false;
}
e = s->data[s->top--]; //退栈
return true;
}
取栈顶操作#
取栈顶操作与出栈操作不同的是,取栈顶操作只需把栈顶元素赋值给变量 e,无需对栈进行修改。时间复杂度 O(1)。
bool GetTop(SqStack &s,ElemType e)
{
if(StackEmpty(s)) //判断是否为空栈
{
return false;
}
e = s->data[s->top]; //取栈顶
return true;
}
链栈及其基本操作#
链栈#
当栈使用链式存储结构来存储时,可以建立单链表来描述,显然以链表的表头结点作为栈顶是最方便的。使用连式存储结构的优点在于,栈的空间在一般情况下不需要考虑上限。对于链栈来说,我们可以不设置头结点。
链栈的结构体定义#
typedef struct StackNode
{
ElemType data;
struct StackNode *next;
}Node,*Stack;
初始化栈#
初始化的操作是为了构造一个空栈,在不设置头结点的情况下,我们把栈顶指针搞成 NULL 即可。
bool InitStack(Stack &s)
{
s = NULL;
return true;
}
空栈判断#
某个结构为空一直是一个显然而敏感的状态,如果不妥善处理就会出现严重的异常,就例如对空栈执行出栈操作,就会出现非法读取的情况。因此虽然空栈判断的代码简单,对于链栈值得我们重视。函数在栈为空栈时返回 true,反之返回 false。
bool StackEmpty(Stack *s)
{
if(s == NULL)
{
return true;
}
return false;
}
进栈操作#
对于链栈的进栈操作,我们不需要判断是否出现栈满的情况,只需要用头插法引入新结点即可,插入成功返回 true,否则返回 false。时间复杂度 O(1)。
bool Push(Stack &s,ElemType e)
{
Stack ptr = new Node; //为新结点申请空间
ptr->next = s; //修改新结点的后继为 s 结点,入栈
ptr->data = e;
s = ptr; //修改栈顶为 ptr
return true;
}
出栈操作#
同顺序栈,当栈的空间为空的时候,不能出栈,函数将在栈不为空栈的情况下,需要把栈顶结点的空间释放掉,退栈成功返回 true,否则返回 false。时间复杂度 O(1)。
bool Pop(Stack &s,ElemType e)
{
Stack ptr;
if(StackEmpty(s)) //判断是否为空栈
{
return false;
}
e = s->data; //将栈顶元素赋值给 e
ptr = S; //拷贝栈顶元素
S = S->next; //退栈
delete ptr; //释放原栈顶元素结点的空间
return true;
}
取栈顶操作#
当栈非空时,把栈顶元素赋值给变量 e,时间复杂度 O(1)。
bool GetTop(SqStack &s,ElemType e)
{
if(StackEmpty(s)) //判断是否为空栈
{
return false;
}
e = s->data; //取栈顶
return true;
}
双端栈#
实现目标#
复杂的操作由基本操作组合而成#
我们这么去理解,假设我们已经定义了两个栈,开辟了一定的空间,那么会不会出现一个栈满了,而另一个栈还有很多空间呢?那么我们在这个时候就很希望能够让第一个栈使用第二个栈的空间,从理论上讲,这样是完全可行的,因为我们只需要让这两个栈能够分别找到自己的栈顶和栈底即可。例如在一个数组中,我们可以让数组的始端和末端分别为两个栈的栈底,再通过操作游标来实现对栈顶的描述。对于栈满的判断呢?只要两个栈的栈顶不见面,栈就不为满栈。
代码实现#
建立双端栈#
Stack CreateStack(int MaxSize) //建立双端栈
{
Stack sak = (Stack)malloc(sizeof(struct SNode));
sak->MaxSize = MaxSize;
sak->Data = (ElementType*)malloc(MaxSize * sizeof(ElementType));
sak->Top1 = -1;
sak->Top2 = MaxSize;
return sak;
}
入栈操作#
bool Push(Stack S, ElementType X, int Tag) //入栈
{
if (S->Top2 - 1 == S->Top1)
{
printf("Stack Full\n");
return false;
}
if (Tag == 1)
{
S->Data[++S->Top1] = X;
}
else
{
S->Data[--S->Top2] = X;
}
return true;
}
出栈操作#
ElementType Pop(Stack S, int Tag) //出栈
{
if (Tag == 1)
{
if (S->Top1 < 0)
{
printf("Stack %d Empty\n",Tag);
return ERROR;
}
else
{
return S->Data[S->Top1--];
}
}
else
{
if (S->Top2 == S->MaxSize)
{
printf("Stack %d Empty\n",Tag);
return ERROR;
}
else
{
return S->Data[S->Top2++];
}
}
}
栈的应用-符号配对#
应用情景#
情景分析#
由于我们只关注表达式的括号是否是成双成对的,因此只需要获取我们所需即可。当我获取第一个括号时,虽然后面可能会有贼多括号,但是我们只继续接受下一个括号,若下一个括号仍然为左括号,那么这个括号需要配对的优先级是高于第一个左括号的。继续读取,若下一个括号为右括号,就拿来和配对优先级较高的第二个括号比对,若成功配对则消解第二个括号,而第一个括号需要配对的优先级就提升了。经过分析我们发现,使用栈结构来描述这个过程极为合适。
伪代码#
代码实现#
#include <iostream>
#include <stack>
#include <string>
using namespace std;
int main()
{
string equation;
stack<char> brackets; //存储被配对的左括号
int flag = 0;
cin >> equation;
for (int i = 0; equation[i] != 0; i++)
{
if (equation[i] == '(' || equation[i] == '[' || equation[i] == '{') //第 i 个字符是左括号
{
brackets.push(equation[i]);
}
else if (brackets.empty() && (equation[i] == ')' || equation[i] == ']' || equation[i] == '}'))
{
flag = 1; //第 i 个字符是右括号但栈是空栈
break;
}
else if (equation[i] == ')' && brackets.top() == '(') //栈顶括号与右括号配对
{
brackets.pop();
}
else if (equation[i] == ']' && brackets.top() == '[')
{
brackets.pop();
}
else if (equation[i] == '}' && brackets.top() == '{')
{
brackets.pop();
}
}
if (flag == 1) //输出配对结果
{
cout << "no";
}
else if (brackets.empty() == true)
{
cout << "yes";
}
else
{
cout << brackets.top() << "\n" << "no";
}
return 0;
}
栈的应用-逆波兰式的转换#
逆波兰式#
众所周知,对于一个算式而言,不同的运算符有优先级之分,例如“先乘除,后加减”,如果是我们人工进行计算的话,可以用肉眼观察出算式的运算顺序进行计算。可是对于计算机而言,如果是一个一个读取算式进行计算的话,可能不能算出我们想要的答案,因为这么做是没有优先级可言的。想要让计算机实现考虑优先级的算式计算,我们首先要先找到一种算式的描述方式,这种方式不需要考虑运算符优先级。
逆波兰式(Reverse Polish notation,RPN,或逆波兰记法),也叫后缀表达式(将运算符写在操作数之后的表达式),是波兰逻辑学家卢卡西维奇提出的,例如“2 + 3 * (7 - 4) + 8 / 4”这样一个表达式,它对应的后缀表达式是“2 3 7 4 - * + 8 4 / +”,这种表达式的计算方法是遇到运算符就拿前面的两个数字来计算,用这个数字替换掉计算的两个数字和运算符,直到得出答案。
应用情景#
伪代码#
代码实现#
#include <iostream>
#include <stack>
#include <queue>
#include <string>
#include <map>
using namespace std;
int main()
{
string str;
stack<char> sign; //存储符号
queue<char> line; //存储转换好的逆波兰式,便于后续实现计算
map<char, int> priority;
priority['('] = 3; //为符号设置优先级
priority[')'] = 3;
priority['*'] = 2;
priority['/'] = 2;
priority['+'] = 1;
priority['-'] = 1;
int flag = 0;
cin >> str;
for (int i = 0; i < str.size(); i++)
{ //读取到数字
if (((i == 0 || str[i - 1] == '(') && (str[i] == '+' || str[i] == '-')) || (str[i] >= '0' && str[i] <= '9'))
{
line.push('#');
if (str[i] != '+')
{
line.push(str[i]);
}
while ((str[i + 1] >= '0' && str[i + 1] <= '9') || str[i + 1] == '.')
{
line.push(str[++i]);
}
}
else //读取到运算符
{
if (str[i] == ')') //运算符是右括号
{
while (!sign.empty() && sign.top() != '(') //左括号之后的运算符全部出栈
{
line.push('#');
line.push(sign.top());
sign.pop();
}
sign.pop();
continue;
}
else
{
while (!sign.empty() && sign.top() != '(' && priority[str[i]] <= priority[sign.top()])
{
line.push('#');
line.push(sign.top());
sign.pop();
}
}
sign.push(str[i]);
}
}
while (!sign.empty()) //将栈内剩余的符号出栈
{
line.push('#');
line.push(sign.top());
sign.pop();
}
while (!line.empty())
{
if (flag == 0 && line.front() == '#')
{
flag++;
}
else if(line.front() == '#')
{
cout << ' ';
}
else
{
cout << line.front();
}
line.pop();
}
return 0;
}
迷宫寻路(深度优先)#
左转博客——栈和队列应用:迷宫问题
八皇后问题(栈实现)#
左转我另一篇博客八皇后问题——回溯法思想运用
参考资料#
《大话数据结构》—— 程杰 著,清华大学出版社
《数据结构教程》—— 李春葆 主编,清华大学出版社
《数据结构与算法》—— 王曙燕 主编,人民邮电出版社
《数据结构(C语言版|第二版)》—— 严蔚敏 李冬梅 吴伟民 编著,人民邮电出版社
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 记一次.NET内存居高不下排查解决与启示
· 探究高空视频全景AR技术的实现原理
· 理解Rust引用及其生命周期标识(上)
· 浏览器原生「磁吸」效果!Anchor Positioning 锚点定位神器解析
· 没有源码,如何修改代码逻辑?
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· 记一次.NET内存居高不下排查解决与启示
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· .NET10 - 预览版1新功能体验(一)