文章目录
- 第三讲 栈、队列和数组
- 3.1 栈
- 3.1.1 出栈元素的不同排列与卡特兰数
- 3.1.2 栈的顺序表实现
- 3.1.3共享栈
- 3.1.4 栈的链表实现
- 3.1.5 栈的两种实现的优缺点
- 3.1.6 c++中的栈( s t a c k stack stack)容器适配器
- 3.1.7 栈的应用:star:
- 3.1.7.1 **栈在括号匹配中的应用**
- 3.1.7.2 **栈在表达式求值中的应用:star:**:star:
- 3.1.7.3 **栈的应用—–递归**
- 3.1.7.4 栈的其他典型应用
- 3.1.8 栈的相关算法题
- 3.1.8 栈的相关算法题
第三讲 栈、队列和数组
【考频统计】:
年份 | 考点 | 分值 |
---|---|---|
2009 | 队列的应用、栈和队列的出入操作 | 单选 * 2 = 4分 |
2010 | 栈的出入操作、受限双端队列的出入操作 | 单选 * 2 = 4分 |
2011 | 栈的出入操作、循环队列的判空 | 单选 * 2 = 4分 |
2012 | 栈的应用:中缀转后缀 | 单选 2分 |
2013 | 栈的出入操作 | 单选 2分 |
2014 | 栈的应用:中缀转后缀、循环队列的判空判满 | 单选 * 2 = 4分 |
2015 | 栈的应用:递归 | 单选 2分 |
2016 | 队列的出入操作、三对角矩阵的压缩存储 | 单选 * 2 = 4分 |
2017 | 栈的综合考察、稀疏矩阵的压缩存储 | 单选 * 2 = 4分 |
2018 | 栈在表达式求值中的应用、栈与队列的出入操作、对称矩阵的压缩存储 | 单选 * 3 = 6分 |
2019 | 队列的设计(判空、判满等) | 应用题 10分 |
2020 | 三角矩阵的压缩存储、栈的出入操作 | 单选 * 2 = 4分 |
2021 | 受限双端队列的出入操作、二维数组的存储 | 单选 * 2 = 4分 |
2022 | 栈的出入序列 | 单选 2分 |
2023 | 稀疏矩阵的存储之三元组 | 单选 2分 |
【考情分析】:本章每年都会出2个选择题左右,重点掌握栈与队列的出入操作、循环队列的判空判满、栈在表达式求值中的应用、矩阵压缩存储的下标计算等知识点;
【考点预测及重点指出】:
- 掌握用顺序表和链表实现的队列和栈的判空判满条件;
- 对于数组实现的栈注意区分
top==0
和top == -1
入栈时是先进栈再自增还是先自增再进栈。- 栈在括号匹配中的作用;
- 初始化两个栈,操作数栈和运算符栈,实现中缀表达式直接计算(包含了中缀转后缀以及用栈实现后缀表达式的计算)
- 循环队列的判空判满的三种实现方法:牺牲一个空位、设置计数器
count
、设置bool
型变量flag
(回忆具体实现方法,记不清就去看);- 谨记入队时
front
指针不会变,变化的是rear
指针;出队时,rear
指针不会变,变化的是front
指针- 记得看一下预测的应用题;
3.1 栈
3.1.1 出栈元素的不同排列与卡特兰数
栈对线性表的插入和删除是在位置上进行了限制,但是并没有对进出时机进行限制。也就说,刚进去的元素也可以立即出栈,也可以等待一会儿再在合适的时机出栈,只要保证当前位置是栈顶即可。
n n n个不同元素进栈,出栈元素不同排列的个数 N N N可由**卡特兰( C a t a l a n Catalan Catalan)**数确定: N = 1 n + 1 C 2 n n = ( 2 n ) ! n ! ( n + 1 ) ! N=\frac{1}{n+1}C^n_{2n}=\frac{(2n)!}{n!(n+1)!} N=n+11C2nn=n!(n+1)!(2n)!
比如5个元素进栈的话出栈不同排列的个数为 1 5 + 1 C 10 5 = 1 6 ( 10 ∗ 9 ∗ 8 ∗ 7 ∗ 6 5 ∗ 4 ∗ 3 ∗ 2 ∗ 1 ) = 42 \frac{1}{5+1}C^5_{10}=\frac{1}{6}(\frac{10*9*8*7*6}{5*4*3*2*1}) = 42 5+11C105=61(5∗4∗3∗2∗110∗9∗8∗7∗6)=42
栈混洗( s t a c k p e r m u t a t i o n stack\ permutation stack permutation):将栈 A A A 的栈顶元素弹出并压入栈 S S S ,或将栈 S S S 的栈顶元素弹出并压入栈$ B$ ,经过一系列的操作后, $A $中的元素全部转入 B B B 中,则称之为 A A A 的一个栈混洗。由于栈 A A A 和栈 S S S 的弹出与压入次序不一样,由此产生了在栈 B B B 中的不同排列方式。若栈A中元素个数为n,其栈混洗种类个数为卡特兰数。具体原理见邓俊辉的栈那一节。
卡特兰数还有另外一个重要用途,即计算二叉树的形态数
先序序列(前序序列)为 a, b, c, d 的不同二叉树的个数是?
【解】先序序列为入栈次序,中序序列为出栈序列,因为前序序列和中序序列可以唯一确定一棵二叉树,所以相当于“以序列 a, b, c, d为入栈次序,则出栈序列的个数是?”
$N =\frac{1}{5}C^4_8=\frac{70}{5}=14 $
进栈出栈操作与二叉树中序遍历的关系(这也就是二叉树的中序遍历非递归(迭代)的操作方式):
- 一个结点进栈后有两种处理方式:要么立即出栈(此时该入栈结点没有左孩子),要么下一个结点进栈(有左孩子);
- 一个结点出栈后也有两种处理方式:要么继续出栈(该结点无右孩子),要么下一个结点进栈(有右孩子)
class Solution { public: vector<int> inorderTraversal(TreeNode* root) { vector<int> result; stack<TreeNode*> st; TreeNode* cur = root; while (cur != NULL || !st.empty()) { if (cur != NULL) { // 指针来访问节点,访问到最底层 st.push(cur); // 将访问的节点放进栈 cur = cur->left; // 左 } else { cur = st.top(); // 从栈里弹出的数据,就是要处理的数据(放进result数组里的数据) st.pop(); result.push_back(cur->val); // 中 cur = cur->right; // 右 } } return result; } };
3.1.2 栈的顺序表实现
注意题目中top
指针指向的是栈顶元素,还是说指向栈顶元素的下一个位置,条件不同,相应的基本操作也会发生变化,下面给出的源码top
指针指向的就是下一个位置。
#define DataType int //用DataType这个宏定义来统一代表栈中数据的类型,这里将它定义为整型,根据需要可以定义成其它类型,例如浮点型、字符型、结构体 等等;
#define bool int
#define maxn 100010 //maxn代表我们定义的栈的最大元素个数;
struct Stack { //定义栈的结构体
DataType data[maxn]; //DataType data[maxn]作为栈元素的存储方式,数据类型为DataType,可以自行定制;
int top; // top即栈顶指针,data[top-1]表示栈顶元素,top == 0代表空栈;
};
void StackClear(struct Stack* stk) {
//初始化,清空栈,在这里我们规定top指向栈顶元素的下一个存储单元,当然也可以将top设置为指向当前栈顶元素的存储单元,那样的话需要修改为stk -> top = -1;
stk->top = 0;
}
bool PushStack(struct Stack *stk, DataType dt) {
//入栈操作,stk是一个指向栈对象的指针,由于这个接口会修改栈对象的成员变量,所以这里必须传指针,否则,就会导致函数执行完毕,传参对象没有任何改变;
if(stk-> top == maxn){
return false; //栈满,报错
}else{
stk->data[ stk->top++ ] = dt; // 将传参的元素放入栈中, 然后将栈顶指针自增 1
return true;
}
}
bool PopStack(struct Stack* stk) { //这里的出栈操作仅仅将栈顶元素弹出,如果需要用这个元素做什么的话弹出时可以用一个临时变量来接收它;
if(stk->top == 0){
return false; //栈空,报错;
}else{
--stk->top; //出栈 操作,只需要简单改变将 栈顶 减一 即可
return true;
}
}
//下面为只读接口
DataType StackGetTop(struct Stack* stk) { //获取栈顶元素:数组中栈元素从 0 开始计数,所以实际获取元素时,下标为 栈顶元素下标 减一;
return stk->data[ stk->top - 1 ];
}
int StackGetSize(struct Stack* stk) { //获取栈大小,因为只有在入栈的时候,栈顶指针才会加一,所以它 正好代表了 栈元素个数;
return stk->top;
}
bool StackIsEmpty(struct Stack* stk) {
return !StackGetSize(stk); //当 栈元素 个数为 零 时,栈为空
}
3.1.3共享栈
利用栈底位置相对不变的特性,可以让两个顺序栈共享一个一维数组空间,将两个栈的栈底分别设置在共享空间的两端,两个栈顶向共享空间的中间延伸,如图所示,如果栈顶指针指向当前元素的话,仅当两个栈顶指针相邻时,判断为栈满。
共享栈是为了更有效地利用存储空间,两个栈的空间相互调节,只有在整个存储空间被占满时才发生上溢。其存取数据的时间复杂度均为 O ( 1 ) O(1) O(1),所以对存取效率没有什么影响。
栈的上溢就是缓冲区满还往里写,栈的下溢就是缓冲区空还往外读,为了解决上溢,可以给栈分配很大的空间,而这样又会造成空间的浪费,共享栈的提出就是在解决上溢的基础上节省存储空间,将两个栈放在同一段更大的空间内。
3.1.4 栈的链表实现
不需要掌握,只要知道链栈的操作都是在表头进行的就够了;
采用链式存储的栈称为链栈,其优点是便于多个栈共享存储空间和提高其效率,且不存在栈满上溢的情况。为了方便操作,通常采用不带头结点的单链表实现,并规定所有操作都是在单链表的表头进行的。
尾结点就相当于栈底。
- **定义链栈,并实现基本操作(要求单链表实现,栈顶在链头)**王道版本,带有头结点
//有头节点版本,栈为空时,栈中仅有一个头结点。
//定义栈结点
typedef struct SNode{ //定义单链表结点类型
int data; //每个节点存放一个数据元素
struct SNode *next; //指针指向下一个节点
}SNode, *LiStack;
//初始化一个链栈(单链表实现,栈顶在链头)
bool InitStack(LiStack &S) {
S = (SNode *) malloc(sizeof(SNode)); //分配一个头结点
S->next = NULL; //头结点之后暂时还没有节点
return true;
}
//判断栈是否为空
bool StackEmpty(LiStack S){
if(S->next==NULL) //头结点后面没有结点
return true; //返回true,表示栈为空
else
return false;
}
//入栈(本质上是单链表的“头插法”)
bool Push (LiStack &S, int x){
SNode * p = (SNode *) malloc(sizeof(SNode)); //新分配一个结点
p->data = x; //存入新元素
p->next = S->next;
S->next = p; //新结点插入到头结点后面
return true;
}
//出栈(本质上是单链表的“头删法”)
bool Pop (LiStack &S, int &x){
if (StackEmpty(S)) //栈空,出栈操作失败
return false;
SNode * p = S->next; //栈不空,链头结点出栈
x = p->data; //x返回栈顶元素
S->next = p->next; //头删法,栈顶元素"断链"
free(p);
return true;
}
- 用c语言实现链栈的结构体定义与基本操作,不带头节点
//没有头结点的版本,栈为空时,top==NULL;
typedef struct StackNode { // 定义链栈结点;
int data; //每个结点存放一个数据元素;
struct StackNode *next; //指针指向下一个节点;
}StackNode;
struct Stack { //定义链栈;
StackNode *top; // top作为 栈顶指针,当栈为空的时候,top == NULL;否则,永远指向栈顶;
int size;
// 由于 求链表长度 的算法时间复杂度是 O(n) 的, 所以我们需要记录一个size来代表现在栈中有多少元素。每次入栈时size自增,出栈时size自减。这样在询问栈的大小的时候,就可以通过 O(1)的时间复杂度。
};
//入栈操作,其实就是类似 头插法,往链表头部插入一个新的结点;
void PushStack(struct Stack *stk, int dt) {
StackNode *insertNode = (StackNode *) malloc(sizeof(StackNode)); // 利用malloc生成一个链表结点insertNode;
insertNode->next = stk->top; // 将 当前栈顶 作为insertNode的 直接后继结点;
insertNode->data = dt; // 将 insertNode的 数据域 设置为传参 dt;
stk->top = insertNode; // 将insertNode作为 新的栈顶;
++ stk->size; //栈元素 加一;
}
//出栈操作,由于链表头结点就是栈顶,其实就是删除这个链表的头结点的过程。
void PopStack(struct Stack* stk) {
if(stk->top == NULL) {
//返回错误;
}
StackNode *temp = stk->top; // 将 栈顶指针 保存到temp中;
stk->top = temp->next; //将 栈顶指针 的 后继结点 作为新的 栈顶;
free(temp); // 释放之前 栈顶指针 对应的内存;
--stk->size; // 栈元素减一;
}
//清空栈,即栈的初始化,可以理解为,不断的出栈,直到栈元素个数为零。
void StackClear(struct Stack* stk) {
while(!StackIsEmpty(stk)) { //判断当前栈是否为空
PopStack(stk); // 每次操作其实就是一个 出栈 的过程,如果 栈 不为空;则进行 出栈 操作,直到 栈 为空;
}
stk->top = NULL; // 然后将 栈顶指针 置为空,代表这是一个空栈了;
stk->size = 0;
}
//只读接口
int StackGetTop(struct Stack* stk) {
return stk->top->data; // stk->top作为 栈顶指针,它的 数据域 data就是 栈顶元素的值,返回即可;
}
int StackGetSize(struct Stack* stk) {
return stk->size; // size记录的是 栈元素个数;
}
int StackIsEmpty(struct Stack* stk) {
return !StackGetSize(stk); //当 栈元素 个数为 零 时,栈为空。
}
- 定义链栈,并实现基本操作(要求双链表实现,栈顶在链尾)
//定义栈结点
typedef struct DbSNode{ //定义双链表结点类型
int data; //每个节点存放一个数据元素
struct DbSNode *last,*next; //指向前后两个结点
}DbSNode;
typedef struct DbLiStack{ //双链表实现的栈(栈顶在链尾)
struct DbSNode *head, *rear; //两个指针,分别指向链头、链尾
}DbLiStack, *DbStack;
//初始化一个链栈(单链表实现,栈顶在链头)
bool InitDbStack(DbStack &S) {
S = (DbLiStack *) malloc(sizeof(DbLiStack)); //初始化一个链栈(双链表实现,栈顶在链尾)
DbSNode * p = (DbSNode *) malloc(sizeof(DbSNode)); //新建一个头结点
p->next = NULL; //头结点之后暂时还没有节点
p->last = NULL; //头结点之前没有节点
S->head = p;
S->rear = p;
return true;
}
//判断栈是否为空
bool DbStackEmpty(DbStack S){
if(S->head == S->rear) //头指针和尾指针指向同一个结点
return true; //返回true,表示栈为空
else
return false;
}
//入栈(在双链表链尾插入)
bool DbPush (DbStack &S, int x){
DbSNode * p = (DbSNode *) malloc(sizeof(DbSNode)); //新分配一个结点
p->data = x; //存入新元素
p->next = NULL;
p->last = S->rear; //新结点插入链尾
S->rear->next = p;
S->rear = p;
return true;
}
//出栈(删除双链表链尾元素)
bool DbPop (DbStack &S, int &x){
if (DbStackEmpty(S)) //栈空,出栈操作失败
return false;
DbSNode * p = S->rear; //栈不空,链尾结点出栈
x = p->data; //x返回栈顶元素
S->rear = p->last; //更新链尾指针
S->rear->next = NULL;
free(p); //释放结点
return true;
}
3.1.5 栈的两种实现的优缺点
- 顺序表实现:在利用顺序表实现栈时,入栈和出栈的常数时间复杂度低,且清空栈操作相比链表实现可以做到 O ( 1 ) O(1) O(1),唯一的不足之处是:需要预先申请好空间,而且当空间不足时,需要进行扩容;
- 链表实现:在利用链表实现栈时,入栈 和 出栈 的常数时间复杂度略高,主要是每插入一个栈元素都需要申请空间,(不过,如果入栈元素本身就是节点对象,那么可以省去初始化步骤,从而提高效率);每删除一个栈元素都需要释放空间,且 清空栈 操作是 O ( n ) O(n) O(n)的,直接将 栈顶指针 置空会导致内存泄漏。好处就是:不需要预先分配空间,且在内存允许范围内,可以一直 入栈,没有顺序表的限制。
3.1.6 c++中的栈( s t a c k stack stack)容器适配器
如果在算法题中想要用栈这个数据结构解决问题的话,自己实现的话有点不切实际,由于408考试允许用c++,因此我们可以用stack
这个stl
容器来做题;
常用的基本操作如下:
stack<int> stk; //定义一个栈,数据类型可以看情况定义,可以定义为int,链表结点,二叉树结点等等,根据情况自行选择;
stk.push(1); //元素入栈;
stk.pop(); //元素出栈,无返回值;
int top = stk.top(); //访问栈顶元素;
int size = stk.size(); //获取栈的长度;
bool empty = stk.empty(); //判读栈是否为空
3.1.7 栈的应用⭐️
消除递归必须使用栈这个说法是错误的,迭代也可以用来消除递归,只需要简单的循环就可以完成。
表达式求值有很多方法,使用栈解决只是其中的一种。
3.1.7.1 栈在括号匹配中的应用
- 20. 有效的括号 需要掌握
-
题目描述
给定一个只包括 ‘(’,‘)’,‘{’,‘}’,‘[’,‘]’ 的字符串 s ,
判断字符串是否有效。有效字符串需满足:左括号必须用相同类型的右括号闭合。
左括号必须以正确的顺序闭合。
每个右括号都有一个对应的相同类型的左括号。样例输入:
s = "()[]{}"
样例输出:
true
-
解题原理:我们遍历给定的字符串 s s s。当我们遇到一个左括号时,我们会期望在后续的遍历中,有一个相同类型的右括号将其闭合。由于后遇到的左括号要先闭合,因此我们可以将这个左括号放入栈顶。
当我们遇到一个右括号时,我们需要将一个相同类型的左括号闭合。此时,我们可以取出栈顶的左括号并判断它们是否是相同类型的括号。如果不是相同的类型,或者栈中并没有左括号,那么字符串 s s s无效,返回
False
。在遍历结束后,如果栈空,说明我们将字符串 s s s中的所有左括号闭合,返回
True
,否则返回False
。注意到有效字符串的长度一定为偶数,因此如果字符串的长度为奇数,我们可以直接返回
False
,省去后续的遍历判断过程。我们可以发现,左右括号和入栈出栈( p u s h / p o p push/pop push/pop)其实是对应的关系,当栈为空时再想弹出就会失败,其实这和括号序列中多了一个右括号没有与之相匹配的左括号是一样的效果。
-
代码:
class Solution {
public:
bool isMatch(char a,char b){
if((a == '(' && b == ')')|| (a == '[' && b == ']') || (a == '{' && b == '}')){
return true;
}
return false;
}
bool isValid(string s) {
stack<char> stk; //定义一个栈,用来存放左括号
if(s.size() % 2 == 1) return false; //如果字符串长度为奇数则不可能有效,直接返回false;
for(int i = 0; i < s.size(); i++){
if(s[i] == '(' || s[i] == '[' || s[i] == '{') stk.push(s[i]); //遇到左括号入栈;
else if(stk.empty()) return false; //如果遇到了右括号但是栈已经空了,说明不是有效的括号,直接返回false;
else if(! isMatch(stk.top(),s[i])) return false; //如果遇到右括号且栈不为空,但是栈顶不是相匹配的左括号,返回false;
else stk.pop(); //当前遍历到的右括号与栈顶左括号相匹配,弹出栈顶元素继续遍历;
}
return stk.empty(); //遍历结束后,栈为空则为有效,否则无效;
}
};
- 进阶版,32. 最长有效括号 不用掌握
-
题目描述:
给你一个只包含
'('
和')'
的字符串,找出最长有效(格式正确且连续)括号子串的长度。示例1:
输入:s = "(()" 输出:2 解释:最长有效括号子串是 "()"
示例2:
输入:s = ")()())" 输出:4 解释:最长有效括号子串是 "()()"
-
解题思路:
- 分析合法的括号序列,一定是以下两种中的一种:
-
(
1
)
(
⋯
)
(1)(\cdots)
(1)(⋯)
( 2 ) - ( 2 ) ( ⋯ ) ⋯ ( ⋯ ) (2)(\cdots)\cdots(\cdots) (2)(⋯)⋯(⋯)
- 我们可以选准备好一个栈,并且塞入一个元素-1。
- 然后,遍历这个字符串,遇到左括号,无脑入栈;遇到右括号,无脑出栈;
- 这时候,如果栈为空,表明右括号多出来一个,说明断层了,这时候把当前的位置下标入栈;否则,当前位置减去栈顶的位置就是一个合法的长度,计算后更新最大值。
- 最后得到的最大值就是我们要求的字符串最大长度。
-
解题代码
class Solution { public: int longestValidParentheses(string s) { int maxans = 0; stack<int> stk; stk.push(-1); for(int i = 0; i < s.size(); i++){ if(s[i] == '('){ stk.push(i); // 如果是左括号直接入栈即可 }else{ // 如果是右括号,存在两种情况 // 1.如果前面可以有左括号和它进行匹配,那么就存在一个由左括号、右括号组成的子串 // 2.如果前面没有左括号和它进行匹配,那么这个右括号就形成了新的边界。新的子串匹配时,起点必须在该边界右边 stk.pop(); if(stk.empty()){ //如果这时候栈为空,表示断层了,将当前右括号位置入栈; stk.push(i); }else{ maxans = max(maxans,i - stk.top()); //否则,取栈顶元素和当前位置相减,必然是一个合法序列,更新最大长度; } } } return maxans; } };
3.1.7.2 栈在表达式求值中的应用⭐️⭐️
- 前缀、中缀、后缀表达式的互相转换
- 前缀表达式:也称波兰式,指运算符处于两个操作数的前面
- 中缀表达式:指运算符在两个操作数之间的位置
- 后缀表达式:也称逆波兰式,指运算符处于两操作数后面
【例 1】已知中缀表达式:a+b-c*d
-
先确定运算顺序:
(a+b)-(c*d)
-
前缀表达式:
-+ab *cd
-
后缀表达式:
ab+ cd*-
【例 2】已知中缀表达式:a/b+(c*d-e*f)/g
- 先确定运算顺序:
(a/b)+(((c*d)-(e*f))/g)
- 前缀表达式:
+ /ab / -*cd *ef g
- 后缀表达式:
ab/ cd* ef*- g/ +
- 后缀表达式求值问题
我们转换表达式的目的就在于让计算机实现表达式的求值,并且去掉括号后表达式无歧义,或者说,计算机是不认识也不清楚中缀式的含义的,只有人能读懂,但是我们转换为前缀或者后缀后却可以借助栈的性质来完成计算
力扣有该算法的对应习题150. 逆波兰表达式求值
-
题目:
给你一个字符串数组
tokens
,表示一个根据 逆波兰表示法 表示的算术表达式。请你计算该表达式。返回一个表示表达式值的整数。
注意:
有效的算符为 ‘+’、‘-’、‘*’ 和 ‘/’ 。
每个操作数(运算对象)都可以是一个整数或者另一个表达式。
两个整数之间的除法总是 向零截断 。
表达式中不含除零运算。
输入是一个根据逆波兰表示法表示的算术表达式。
答案及所有中间计算结果可以用 32 位 整数表示。 -
示例:
-
输入:tokens = ["2","1","+","3","*"] 输出:9 解释:该算式转化为常见的中缀算术表达式为:((2 + 1) * 3) = 9
-
输入:tokens = ["10","6","9","3","+","-11","*","/","*","17","+","5","+"] 输出:22 解释:该算式转化为常见的中缀算术表达式为: ((10 * (6 / ((9 + 3) * -11))) + 17) + 5 = ((10 * (6 / (12 * -11))) + 17) + 5 = ((10 * (6 / -132)) + 17) + 5 = ((10 * 0) + 17) + 5 = (0 + 17) + 5 = 17 + 5 = 22
-
-
解题步骤:逆波兰表达式严格遵循「从左到右」的运算。计算逆波兰表达式的值时,使用一个栈存储操作数,从左到右遍历逆波兰表达式,进行如下操作:
- 如果遇到操作数,则将操作数入栈;
- 如果遇到运算符,则将两个操作数出栈,其中先出栈的是右操作数,后出栈的是左操作数,使用运算符对两个操作数进行运算,将运算得到的新操作数入栈。
- 整个逆波兰表达式遍历完毕之后,栈内只有一个元素,该元素即为逆波兰表达式的值。
-
参考代码:
class Solution { public: int evalRPN(vector<string>& tokens) { stack<int> st; for (string s : tokens) { if (s == "+") { int a = st.top(); st.pop(); int b = st.top(); st.pop(); st.push(b+a); } else if (s == "-") { int a = st.top(); st.pop(); int b = st.top(); st.pop(); st.push(b-a); } else if (s == "*") { int a = st.top(); st.pop(); int b = st.top(); st.pop(); st.push(b*a); } else if (s == "/") { int a = st.top(); st.pop(); int b = st.top(); st.pop(); st.push(b/a); } else { st.push(stoi(s)); //将字符串转化为int型数字放入栈中; } } return st.top(); //栈内只有一个元素,该元素即为逆波兰表达式的值。 } };
如果想要优雅一点的话,可以这样写
class Solution { public: int evalRPN(vector<string>& tokens) { stack<long long> s; for(auto x : tokens){ if(x == "+" || x == "-" || x == "*" || x == "/"){ long long nums1 = s.top(); //先弹出的为右操作数 s.pop(); long long nums2 = s.top(); //后弹出的为左操作数 s.pop(); if(x == "+") s.push(nums2 + nums1); else if(x == "-") s.push(nums2-nums1); else if(x =="*") s.push(nums2 * nums1); else s.push(nums2/nums1); }else s.push(stoi(x)); //遇到数字则入栈 //stoi和atoi都是将字符串转成int类型,但是stoi的参数是const string*类型, //而atoi的参数是const char*类型; } long long ans = s.top(); return ans; } };
- 中缀表达式转后缀表达式的算法(选择题重要考点⭐️)
初始化一个栈,用来保存暂时还不能确定运算顺序的运算符。从左到右处理各个元素,直到末尾。可能遇到三种情况:
-
扫描到操作数:直接加入后缀表达式;
-
扫描到界限符:遇到“(”直接入栈;遇到“)”则依次弹出栈内运算符并加入后缀表达式,直到弹出“(”为止,注意“(”不加入后缀表达式。
-
扫描到运算符:
- 如果当前运算符优先级大于栈顶运算符,当前运算符直接入栈;
- 如果当前运算符优先级小于等于栈顶运算符,需要弹出当前运算符优先级小于等于栈顶的运算符,直到栈空或遇到栈顶为左括号,最后当前运算符入栈。
以
1+2+3*4*5
举例,看是如何利用上述两个关键点实施计算的。首先,这个例子只有+和*两个运算符,所以它的运算符表是:
这里的含义是:
(1)如果栈顶是+,即将入栈的是+,栈顶优先级高,需要先计算,再入栈;
(2)如果栈顶是+,即将入栈的是*,栈顶优先级低,直接入栈;
(3)如果栈顶是*,即将入栈的是+,栈顶优先级高,需要先计算,再入栈;
(4)如果栈顶是* ,即将入栈的是*,栈顶优先级高,需要先计算,再入栈;
算法实现:
#include <iostream>
#include <stack>
#include <string>
using namespace std;
int getpriority(char c)//优先级判断
{
if (c == '+' || c == '-')
{
return -1;//加减优先级小
}
else
{
return 1;//乘除优先级大
}
}
string convert(string& express)
{
int i = 0;
string ans; //后缀表达式
stack<char> s; //初始化一个栈用于存放还没有确定运算顺序的运算符;
while (express[i] != '\0')//扫描中缀表达式
{
if ('0' <= express[i] && express[i] >= '9')//如果扫描到了操作数,直接加入后缀表达式
{
ans += express[i];
}
else if (express[i] == '(')//如果扫描到了左括号,直接入栈
{
s.push(express[i++]);
}
else if (express[i] == '+' || express[i] == '-' || express[i] == '*' || express[i] == '/')//扫描到运算符进行优先级判断
{
if (s.empty() || s.top() == '(' || getpriority(express[i]) > getpriority(s.top()))
//如果此时栈为空或者栈顶元素为左括号,或者扫描到的运算符优先级大于栈顶运算符优先级,则将当前运算符入栈;
{
s.push(express[i++]);
}
else//反之优先级如果是小于等于的话,那么就要把运算符出栈然后加入后缀表达式;
{
char temp = s.top();
s.pop();
ans += temp;
}
}
else if (express[i] == ')')//最后一种情况就是扫描到了右括号,那么就把从栈顶到左括号的元素依次出栈加入后缀表达式;
{
while (s.top() != '(')
{
char temp = s.top();
s.pop();
ans += temp;
}
//注意最后停止循环的时候栈顶元素是左括号,但是不要把左括号入栈,所以直接丢掉左括号
s.pop();
i++;//不要忘记后移
}
}
while (!(s.empty()))//如果栈没有空,那么依次出栈,加入后缀表达式;
{
char temp = s.top();
s.pop();
ans += temp;
}
return ans;
}
-
结合上面的后缀表达式计算和中缀转后缀的算法实现,我们可以用栈实现中缀表达式的计算;
-
初始化两个栈,操作数栈和运算符栈;
-
若扫描到操作数,则直接压入操作数栈;
-
若扫描到运算符或界限符,则按照“中缀转后缀”相同的逻辑压入运算符栈(期间也会弹出运算符,每当弹出一个运算符时,就需要再弹出两个操作数栈的栈顶元素并执行相应运算,运算结果再压回操作数栈)
代码部分,要么重新按照这个逻辑写一下,要么直接将中缀表达式传入,然后分别调用上面两个函数就可以得到运算结果了;
acwing 3302. 表达式求值,优秀解题报告:https://www.acwing.com/solution/content/40978/
#include <iostream> #include <stack> #include <string> #include <unordered_map> using namespace std; stack<int> num; stack<char> op; //优先级表 unordered_map<char, int> h{ {'+', 1}, {'-', 1}, {'*',2}, {'/', 2} }; void eval()//求值 { int a = num.top();//第二个操作数 num.pop(); int b = num.top();//第一个操作数 num.pop(); char p = op.top();//运算符 op.pop(); int r = 0;//结果 //计算结果 if (p == '+') r = b + a; if (p == '-') r = b - a; if (p == '*') r = b * a; if (p == '/') r = b / a; num.push(r);//结果入栈 } int main() { string s;//读入表达式 cin >> s; for (int i = 0; i < s.size(); i++) { if (isdigit(s[i]))//数字入栈 { int x = 0, j = i;//计算数字 while (j < s.size() && isdigit(s[j])) { x = x * 10 + s[j] - '0'; j++; } num.push(x);//数字入栈 i = j - 1; } //左括号无优先级,直接入栈 else if (s[i] == '(')//左括号入栈 { op.push(s[i]); } //括号特殊,遇到左括号直接入栈,遇到右括号计算括号里面的 else if (s[i] == ')')//右括号 { while(op.top() != '(')//一直计算到左括号 eval(); op.pop();//左括号出栈 } else { while (op.size() && h[op.top()] >= h[s[i]])//待入栈运算符优先级低,则先计算 eval(); op.push(s[i]);//操作符入栈 } } while (op.size()) eval();//剩余的进行计算 cout << num.top() << endl;//输出结果 return 0; }
-
3.1.7.3 栈的应用—–递归
函数调用的特点:最后调用的函数最先被执行( L I F O LIFO LIFO)
函数调用时,需要用一个“函数调用栈”存储:调用返回值、实参、局部变量
递归调用时,函数调用栈可称为“递归工作栈”
每进入一层递归,就将递归调用所需信息压入栈顶;
每退出一层递归,就从栈顶弹出相应信息;
缺点:效率低,太多层递归可能会导致栈溢出,因为可能会包含很多重复计算;
后续在二叉树的遍历一节,我们可以看到如何用栈来讲递归算法改成迭代算法!
3.1.7.4 栈的其他典型应用
- 浏览器中的后退与前进、软件中的撤销与反撤销。每当我们打开新的网页,浏览器就会将上一个网页执行入栈,这样我们就可以通过「后退」操作回到上一页面。后退操作实际上是在执行出栈。如果要同时支持后退和前进,那么需要两个栈来配合实现。
- 程序内存管理。每次调用函数时,系统都会在栈顶添加一个栈帧,用于记录函数的上下文信息。在递归函数中,向下递推阶段会不断执行入栈操作,而向上回溯阶段则会执行出栈操作。
3.1.8 栈的相关算法题
- 20. 有效的括号
- 32. 最长有效括号
- 150. 逆波兰表达式求值
- 二叉树的各种遍历的非递归版本
- 225. 用队列实现栈
- 232. 用栈实现队列
- 206. 反转链表
- LeetCode 234. 回文链表
- 扩展:有时间看一下夜深人静写算法(十一)- 单调栈
acwing 3302. 表达式求值,
栈顶添加一个栈帧,用于记录函数的上下文信息。在递归函数中,向下递推阶段会不断执行入栈操作,而向上回溯阶段则会执行出栈操作。
3.1.8 栈的相关算法题
- 20. 有效的括号
- 32. 最长有效括号
- 150. 逆波兰表达式求值
- 二叉树的各种遍历的非递归版本
- 225. 用队列实现栈
- 232. 用栈实现队列
- 206. 反转链表
- LeetCode 234. 回文链表
- 扩展:有时间看一下夜深人静写算法(十一)- 单调栈
acwing 3302. 表达式求值,