0 更新记录
- 2024-09-26: 初始版本
这篇文章写的很不在状态,断断续续写了三天四天才完成……
栈是一个比较常用的基础数据结构,其后进先出(LIFO,Last In First Out)的特点适合一些特定的场景。比如下面题目中的括号匹配问题、相邻元素消除、表达式计算等。
使用栈解决的问题基本都是模拟运算或者操作过程,最难的地方在于将复杂的问题拆解成各种情况。
与前面写过的题目总结类似,代码不求精简,尽量做到逻辑清晰,有的地方看起来可能比较啰嗦。
1 题目列表
LeetCode题目链接 | 本文小节链接 |
---|---|
20.有效的括号 | 20.有效的括号 |
1047.删除字符串中的所有相邻重复项 | 1047.删除字符串中的所有相邻重复项 |
1209.删除字符串中的所有相邻重复项 II | 1209.删除字符串中的所有相邻重复项 II |
150.逆波兰表达式求值 | 150.逆波兰表达式求值 |
224.基本计算器 | 224.基本计算器 |
227.基本计算器 II | 227.基本计算器 II |
2 题解
20.有效的括号
思路
匹配类的问题是用栈解决的经典问题。核心思路就是能正确匹配的括号直接“消除”,栈中总是保留左括号,等待栈顶加入的右括号将栈内元素全部消除。
按照这个思路可以发现,对于匹配的括号,只要所有输入遍历完了,那么栈也弹空了,只要最后栈中还有元素,或者读到右括号时,栈已经空了,就是出现了不匹配的情况。
不匹配有两种情况分别对应多左括号和多右括号:
- 多左括号:即缺少与之匹配的右括号,最终会造成栈中剩余左括号无法配对弹出;
- 多右括号:缺少左括号,最后会导致栈提前清空。
实现细节
代码实现并不复杂,C++中有专门的栈容器适配器stack
可以使用,适配器定义了栈相关的接口,底层容器默认使用deque
。也可以直接使用双端队列deque
或者数组vector
自己按照栈只能在一端写入和移除数据来模拟栈。
下面给出两个版本的实现:
- 入栈左括号,遇到右括号时判断栈顶是不是能够配对消除的左括号,如果配对则消除,不配对立即返回
false
; - 入栈右括号,即遍历输入时遇到左括号,将其对应的右括号入栈,如果遇到右括号,只需要判断栈顶和当前的右括号是否一致即可,判断条件较版本一简洁一些。
入栈右括号的代码中使用了switch-case
,需要注意case
如果没有break
,会继续匹配后面的case
,直到遇到break
或者switch
语句结束。
这个特性可以用来匹配多种case
,比如这里用这个特性实现了if(s[i] == ')' || s[i] == ']'|| s[i] == '}')
这样的逻辑。
代码-入栈左括号
1 | class Solution { |
代码-入栈右括号
1 | class Solution { |
1047.删除字符串中的所有相邻重复项
思路
这道题从题意上来看,是一个相邻元素消除的问题,这与在20.有效的括号中的“消除”思路类似。
逐个遍历输入字符,使用一个栈来存放那些等待消除的字符。在读到字符后检查栈顶的字符,如果两个字符相同,则直接出栈,这两个字符消除;如果字符不相同,表示当前的输入字符也是一个等待消除的字符,应当入栈。
输入遍历结束后,留在栈中的字符,就是需要输出的字符。不过需要注意,栈底字符是先加入的字符,栈顶是后加入的字符,直接按照出栈顺序拼接的字符串与正确结果是相反的,最后还需要做一次字符串反转。
实现细节
代码实现比较直观,逻辑上没有特别需要解释的地方。只是注意在获取栈顶元素(的引用)top()
和出栈pop()
之前,需要确保栈非空。
代码
1 | class Solution { |
1209.删除字符串中的所有相邻重复项 II
思路
本题要求比上一题更复杂一些,需要相邻个数达到k
个之后进行删除。由于还是相邻元素删除,基本思路还是使用栈,不过需要额外记录字符出现的次数。可以使用两个栈,一个和上一题一样记录遍历过的字符,栈内元素是等待消除的元素;另一个栈记录对应字符的个数,两个栈同步更新。
实现细节
本题使用了vector
来模拟栈。在后面程序中,也经常使用vector
,用push_back()
,pop_back()
和back()
对应栈的push()
,pop()
和top()
。有时在输出时需要顺序遍历栈(从栈底到栈顶),对比本题和上一题输出部分的代码,可以发现使用vector
模拟栈带来的一点便利。
代码实现逻辑:
- 当栈为空或者当前遍历到的输入字符为一个新字符时(即与栈顶字符不同),将新字符入栈,并记录其出现次数为
1
; - 当输入字符与栈顶字符相同时,只需要更新其出现次数;
- 检查出现次数是否到达
k
个,如果是直接删除这个字符,将其计数也删除; - 最后遍历栈中字符,按照
cnt
栈中记录的出现次数拼接到输出字符串上。
代码
1 | class Solution { |
一点改进
以上解决方法是分别记录字符与次数,同步更新,可以将字符和次数封装成一个pair
,这样只需要操作一个栈即可,代码实现更简洁。
代码的输出部分,对st
的遍历使用了结构化绑定直接获取pair
的first
和second
。
结构化绑定声明需要C++17支持。
1 | class Solution { |
150.逆波兰表达式求值
思路
逆波兰表达式(Reverse Polish Notation,RPN)也叫后缀表达式。从题目给出的几个样例可以看出,逆波兰表达式的特点是运算符(加减乘除)总是在两个操作数后面。
我们习惯上使用的如 $1+1$ 这种运算符在操作数中间的表达式为中缀表达式,中缀表达式对计算机来说是不易解析和计算的,并且需要用括号来指定运算优先级。而后缀表达式的优点就是方便用栈计算,并且运算符的位置决定运算次序,不需要使用括号来指定优先级。
将中缀表达式转换为后缀表达式是一个比较复杂的过程,在下一题224.基本计算器中将实现一个简化的转换程序。
本题先解决后缀表达式的计算,其实知道是用栈这一数据结构来计算就已经扫除了本问题的最大困难,简单模拟一下运算过程可以归纳基本运算步骤如下:
- 遍历输入字符,遇到数字则入栈;
- 遇到运算符则弹出两个数,根据运算符做运算,运算结果入栈;
- 遍历结束栈中只剩下唯一的元素即整个表达式运算结果。
实现细节
代码实现上需要注意,在弹出操作数时,先弹出的数记为num2
,后弹出的数记为num1
,后面计算都是num1
在运算符左侧,num2
在运算符右侧。这样做是考虑到不满足交换律的运算,如减法和除法,两个操作数需要保持和输入相同的顺序参与计算。
题目中特意提示的向零截断的除法无需特殊处理,在C++中/
运算本来就是向零截断的。
题目保证输入合法,所有最后栈中一定会剩下唯一的元素,就是表达式的计算结果,我们使用vector
模拟栈,直接取出st[0]
即可,或者使用更符合栈逻辑的st.back()
。
代码
1 | class Solution { |
224.基本计算器
思路
本题似乎被LeetCode修改过,之前可能没有这条要求:
- 可以用作一元运算 (即 -1 和 -(2 + 3) 是有效的)
LeetCode评论区及题解很多都没有考虑这种情况。
在完成上一题150.逆波兰表达式求值后,解决此问题的一个自然的想法就是,先将输入字符串转换成一个后缀表达式,然后直接调用上一题的代码即可。
中缀表达式转后缀的分析
接下来就按照这个思路,分析一下如何将中缀表达式转换成后缀表达式。
需要说明:此处分析及代码考虑了比本题要求更多的情况:
- 考虑了乘法和除法,输入字符串中合法的字符增加了
*
和/
; /
运算表示向零截断的除法,即C++中/
运算符的含义;- 考虑了乘除的运算优先级高于加减。
接下来结合几个例子来分析解析中缀表达式(输入字符串s
),组织后缀表达式(以字符串数组形式存储在result
)的过程:
解析过程中需要使用一个栈op_stk
来临时存放表达式。例如对于最简单的 $1+2$ 转换为[1,2,+]
,运算符+
与操作数2
位置的交换,就需要一种“先进后出”的逻辑。
解析表达式字符串s
,遍历每个字符ch = s[i]
:
两种最常规情况是:
- 如果
ch
是空格,跳过,解析下一字符; - 如果
ch
是数字,取出完整数字,将数字入栈;
取出数字也有不同的实现方法:
- 可以遇到第一个数字就循环取出完整的数字,然后移动
i
来跳过后面几位数字字符,这种方法在循环中操作循环变量,要格外小心越界; - 也可以使用一个变量来存当前的数字,每次循环只扩展当前字符这一位,直到当前字符不再是数字,或者循环结束,得到完整的数字后将这个变量清空,准备记录下个数字。
程序中使用的是第二种方法,并直接使用数字字符来拼接完整数字字符串,以字符串形式入栈。
考虑第一个例子 $1+2$ ,转换后为[1,2,+]
:
可以发现ch
是运算符时,需要做的操作之一:将栈内元素弹出到结果,直到栈空,然后将运算符入栈。
还可以发现在输入遍历结束后,还需要将栈中元素全部弹出到结果中。
考虑第二个例子 $\left ( 1+2 \right ) \times 3$ ,转换后为[1,2,+,3,*]
:
ch
是左括号时,应当直接入栈。
ch
是右括号时,不断地从栈中弹出到结果,直到遇到左括号,将左括号弹出丢弃。
ch
是运算符时,需要做的操作之二:将栈内元素弹出到结果,直到遇到左括号时停止,并将运算符入栈。
考虑第三个例子 $1+2 \times 3+4$ ,转换为[1,2,3,×,+,4,+]
:
这个例子为了说明如何处理运算优先级,按图示流程可以发现,ch
是运算符时需要做的操作之三为:将栈内元素弹出到结果,直到遇到更低优先级的运算符时停止,并将当前的运算符入栈。
上面三个例子几乎覆盖了所有情况,还剩下负数问题需要解决,这就是LeetCode后来对本题的补充条件。
负数在表达式中只可能有两种出现形式:
- 出现在最开始,可以不加括号,如 $-1+2$;
- 出现在任意操作数位置,表达式必须加括号, $2+(-1)$。
这两种情况需要分别判断,但是处理方式均为将负数变为(0 - 正数)。
对于每个字符ch
:
ch
是空格,跳过,解析下一字符;ch
是数字,取出完整数字,将数字入栈;ch
是左括号,直接入栈;ch
是右括号,将栈内元素弹出到结果,直到栈顶为左括号,将左括号弹出丢弃;ch
是运算符,入栈,入栈前将栈内元素弹出到结果,栈空时停止,栈顶为左括号时停止,栈顶为更低优先级运算符停止;
最后将栈弹空到结果。
负数的处理逻辑,遇到-
先判断是负号还是减法,负号分为输入字符串首字符是-
和用括号括起来的负数两种情况。负号处理方式统一为在栈中额外添加一个"0"
,然后将负号当成普通的减法运算符。
实现细节
运算符及优先级使用一个哈希表来存储unordered_map<string, int> op_map
,键为字符串格式的运算符,值为其优先级,值可以任意指定,只需要保证运算符之间的大小关系正确即可。这样做的好处是如果需要扩展其他运算可以直接添加,无需修改代码中的优先级判断逻辑。另外哈希表中还加入了括号作为最高优先级的运算符,这样对于非空格的字符,可以使用isOperator()
函数查询哈希表判断字符是数字(函数返回false
)还是非数字(函数返回true
)。
num_str
是用来取出完整数字的变量,在遇到数字字符时拼接,遇到非数字字符时入栈并清空。需要注意的是,在输入表达式最后一个字符是数字字符时,num_str
在取到完整数字后,由于不会再遇到非数字字符遍历输入字符串的循环就已经终止,导致最后一个数还没有入栈,所以在循环结束后,要判断num_str
是否为空,不为空要将其入栈。
针对负数的两种情况做额外判断,注意栈在尝试访问栈顶时必须保证非空:
- 首字符是
-
:if(ch == "-" && i == 0)
; - 用括号括起来的负数:
if(ch == "-" && !op_stk.empty() && op_stk.back() == "(")
。
这两种情况都是在栈中补一个"0"
,然后当成普通减法计算,这就要求这两个判断及补零的操作,应当在处理普通减法运算符的逻辑之前,也就是在 ch是运算符,入栈,入栈前将栈内元素弹出到结果,栈空时停止,栈顶为左括号时停止,栈顶为更低优先级运算符停止 这一系列操作之前。
然后就是完成ch
是运算符的出栈操作,出栈过程需要判断栈非空,栈顶非左括号,栈顶是运算符且优先级更高四个条件,代码中使用while
判断两个条件,栈非空并且栈顶不是左括号,循环中使用if
判断另外两个条件,如果栈顶是运算符,并且优先级低于当前运算符,直接break
结束循环。
最后清空栈后,得到后缀表达式,调用evalRPN()
函数即可得到结果。evalRPN()
函数直接使用150.逆波兰表达式求值程序,需要将函数参数修改为const
,这里涉及C++的语法规则:非const
引用的初始值应当是左值,我们在调用时使用的是evalRPN(toRPN(s))
,toRPN(s)
的返回值属于非引用返回的临时变量,是纯右值,不能直接传给非const
引用的参数。
注意C++语言的一个小细节:单字符类型ch
到字符串s
的转换
1 | char ch = 'A'; |
代码-后缀表达式法
1 | class Solution { |
通用另法-双栈法
另外一种解决表达式类问题的通用方法是使用两个栈分别保存运算符和操作数,表达式解析与计算同步进行。
表达式解析过程与中缀转后缀的过程基本一致,对字符ch
的五种情况讨论也一致,区别主要在于:
- 数字入栈时入数字栈,非数字入栈时入符号栈;
- 中缀转后缀方法中出栈写入结果的过程在此方法中代替为计算,即根据现有的数字和符号栈,进行后缀表达式计算,结果压入数字栈;
- 使用
calc
函数完成计算,写法与evalRPN()
非常相似; - 引入
pre_action
变量,保存上一步做了什么操作。
pre_action
变量有什么用?
对照上一方法,思路转变还是比较顺畅的,只是这个pre_action
变量的引入比较突兀。这个变量是为了解决判断-
字符是负号还是减法时遇到的问题。
上一方法中,使用这样的条件来判断-
是否为负号if(ch == "-" && !op_stk.empty() && op_stk.back() == "(")
,这是因为上一方法中,数字和非数字都放在同一个栈op_stk
中,对于 $(-3)$ 这种情况,使用当前字符为-
并且栈顶是左括号是可以正确判断为负号的;而对于 $ (4-3) $ 这种常规情况,在当前字符为-
时,栈中应当为[(, 4]
,栈顶不是左括号,所以会判定为是减法。
但是,本方法使用双栈将数字与非数字分开,对于上面两个例子,在遇到-
时,符号栈的栈顶都是左括号,这样会把正常的减法误判为负号,错误的在栈中加零,导致结果出错。
pre_action
变量的引入就是为了解决这个问题。在判断-
是否为负号之前,需要知道-
之前是数字还是左括号:如果是数字,那上一次入栈一定是入数字栈,如果是左括号,那一定是入符号栈。
pre_action
在入数字栈时赋值为1
,入符号栈时赋值为0
(完全可以用布尔类型),判断-
是否为负号时,只需要额外检查pre_action
,如果是0
则说明是负号,上一步是左括号入符号栈;如果是1
则说明上一步是数字入数字栈,是减法。
当然可以对最初的输入字符串做预处理避免这种复杂的判断,比如将字符串所有空格删除,然后替换"(-
为"(0-"
可以解决任意位置的负数;在初始的数字栈中直接加入一个0
可以解决初始位置的负数,这样能省下一些麻烦。
代码-双栈法
1 | class Solution { |
227.基本计算器 II
思路
这道题甚至可以把224.基本计算器的代码复制过来直接提交,从题目区别上来看,这道题增加了乘除法及运算符优先级的考虑,乘除法和运算符优先级虽然上题没有要求,但是代码中已经实现。另外本题输入中没有括号,没有单独出现的负数,难度相较于上一题其实是降低一些的。
不再赘述,直接使用224.基本计算器的代码提交。