喜欢该类型文章可以给博主点个关注,博主会持续输出此类型的文章,知识点很全面,再加上LeetCode的真题练习,每一个LeetCode题解我都写了注释,比较适合新手入门数据结构与算法,后续也会更新进阶的文章。
课件参考—开课吧《门徒计划》
1-3 递归与栈(Stack)解决表达式求值
栈基础知识
上篇文章我们学习了队列,队列是先进先出,两端都可以进行操作。
而本章我们学习的数据结构是栈,栈只有一端可以操作,它只能在一个头进出,而且还是先进后出。
栈的数据结构比队列更严谨一些,它没有队列那些花里胡哨的东西,什么循环啊,什么链式结构等。
栈很简单,只要描述的是从一头进,然后还是从这头出,那么它就是一个栈。
栈就是堵住了一头的队列。
栈的
插入
和删除
对应着入栈
和出栈
。
栈的出栈
我们需要在物理层面把④这个元素删掉吗?这样做是没有必要的,我们直接让top
值减1,在栈中top
指向谁,谁就是栈顶元素。
栈的入栈
直接让top
加1,往上移一位,并且把top
指向的地方变成我们想要插入的元素。
栈适合解决的问题
本题链接 (文章后面会讲这个题,先给大家铺垫一下思想,很重要)
看我们的括号是否合法, 如果是这样的(())
,([{}])
,就是合法的,如果是这样的())
,[{]
,就是不合法的。
题中有三个类型的括号,我们先将问题简化一下:
是否所有的左括号(
都能找到对应的右括号)
代码实现
这样我们不用栈也实现了这个括号匹配的问题,但我们栈的意义在哪里呢?我们想一下程序还能不能更优化,我们真的需要两个变量来记录我们左括号和右括号的数量吗?我们来看下图的代码优化:
我们将rnum
这个变量去掉了,碰到左括号lnum + 1
,碰到右括号lnum - 1
,如果在遍历过程中lnum
小于0,那说明右括号的数量超过了左括号,所以我们只用一个变量就完成了这个程序。
学习重点:用其他抽象的思想来模拟我们栈的形式。
只要它能描述为我从一头进,并且从同样的一头出,那么它就是栈,从这题来看,+1
就是我们的入栈,-1
就是我们的出栈,再抽象一点,左括号(
就是我们的进栈,右括号)
就是我们的出栈,当括号的顺序是这样时:())
,就是非法操作,因为我在对一个空栈进行出栈操作,有的时候我们想出了一个操作,但却发现跟我们栈的模式是非常相似的,其实栈跟我们的队列一样,无处不在。
对于第三点,一对()
可以等价为一个完整的事件,什么是完整的事件?我们可以假设一个函数:
funA() {
...
}
有开始有结束,这就是一个完整的事件。
那对于第四点,(())
可以看做事件于事件之间的完全包含关系,我们可以看做是:
funA() {
funB() {
...
}
}
可以理解为两个函数之间的包含关系。
一对()
不能单纯的只理解为是一对括号,它可能是函数的调用,也可能是一种二叉树的结构,有可能有很多种表现形式。
最终的,能表示一个东西的开始和结束,然后里面包含着小的事件,那么它就是一个栈的表现形式。
我们想解决左括号(
和右括号的问题)
,那我们应该求解中间被包含的问题,然后我们不断地拆解,最后拆解成一个我们已知的问题。
为什么递归可以解决那种很复杂的问题,它其实用的就是栈的思想,以斐波那契数列举例,f(n) = f(n - 1) + f(n - 2)
,它其实就可以理解为是f(n)
完全包含f(n - 1) + f(n - 2)
,然后最后递到我们已知的值,f(0) = 1, f(1) = 1
,再逐步的归回去。
通过以上案例,我们应该更加理解了栈这个数据结构。
栈的典型应用场景
大家看到这个图是不是有些熟悉呢?对了,这个就是我们上篇文章的队列,讲到过的线程池,线程池中有任务队列,那线程池中的线程其实就是我们的栈,每一个线程就是一个线程栈,我们线程空间的局部变量本质上就是存储到我们的栈空间里,我们再用函数的定义深一步的理解:
funA() {
int a, b, c;
funB() {
int d;
}
}
我们先执行funA()
,funA()
函数定义了a,b,c,所以a,b,c先入栈,然后执行到funB()
,funB()
函数定义了d,d也入栈,之后funB()
执行完,d随之出栈,然后funA()
执行完,c,b,a也按顺序出栈。
我们在一个函数中定义的局部变量只能在该函数中使用,出了这个函数就用不了这个变量了,就是因为这个栈
我们把这个3 + 5
转换成二叉树的形式,那么问题就变成了:当我执行到了这个加法的时候,我就把左子树的3
和右子树的5
拿出来,然后再把根节点的+
拿出来,变成3 + 5
的形式解决这个问题,但是这个转换大家是不是觉得有点脑残哈哈哈😂,那我们来看下面的图:
我们稍微复杂一点,4 + 5
是我们刚才实现过的小问题,然后在看根节点*
,执行*
时会调用3
和+
,但是根节点不知道+
是什么,所以+
就会去找4和5
,然后返回这两个值,就可以进行3 * (4 + 5)
的运算了;这个时候我们就借助栈这样的一个思想,既然+
号这个大问题我们解决不了,我们就缩小这个问题的数据规模范围,我们把大规模变为小规模,逐一缩小到我们能解决的问题。
正常实现这个表达式求值我们可以搞一个逆波兰式
,借助两个栈,一个是数据栈,一个是符号栈,来回导解决这个问题。
我们先找到未知的父问题,然后逐步的拆解成我们已知的子问题求解。
其实这也就是递归
,递
就是我们一步一步的找子问题,当找到已知的子问题时,再一步一步的归
回去。
我们实现这个代码的话,要先找到优先级最高的计算符号。
代码实现
import java.util.Scanner;
public class Main {
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
while (true) {
char[] str = sc.next().toCharArray();
System.out.println(calc(str, 0, str.length - 1));
}
}
// 计算符号的优先级
private static int calc(char[] str, int l, int r) {
int pos = -1, pri = Integer.MAX_VALUE - 1, temp = 0;
for (int i = l; i <= r; i++) {
int cur = Integer.MAX_VALUE;
switch (str[i]) {
case '(': temp += 9999; break; // ()的优先级最高,temp的权重应最大
case ')': temp -= 9999; break;
case '+':
case '-': cur = temp + 1; break;
case '*':
case '/': cur = temp + 2; break;
}
if (cur <= pri) {
pos = i;
pri = cur;
}
}
if (pos == -1) { // 表明运算式中没有符号
int num = 0;
for (int i = l; i <= r; i++) {
if (str[i] < '0' || str[i] > '9') continue;
num = num * 10 + (str[i] - '0');
}
return num;
}
int a = calc(str, l, pos - 1); // 记录符号左边的值
int b = calc(str, pos + 1, r); // 记录符号右边的值
switch (str[pos]) {
case '+': return a + b;
case '-': return a - b;
case '*': return a * b;
case '/': return a / b;
}
return 0;
}
}
LeetCode真题
经典面试题—栈的基本操作
LeetCode面试题 03.04.化栈为队
难度:easy
用两个栈实现一个队列
我们s2
是队列的尾,进行push
操作,s1
是队列头,进行pop
操作。
但是我们将元素push
进s2
后,想弹出我们最开始push
的元素要怎么做呢?元素是在栈底的,所以我们要将s2
的元素都放到s1
里面
然后s1
就可以从栈顶pop
元素了,这样就用两个栈实现了一个队列。
只要s1
中有元素,那么pop
肯定轮不到s2
,肯定是s1
中的元素先pop
,所以说我们每次pop
时都可以判断一下s1
是否为空,如果s1
是空那就要把s2
的元素都搬运到s1
里面去,然后再pop
。
push
是肯定在s2
中进行。
我们直接用系统栈Stack
实现。
LeetCode题解:代码实现
LeetCode682.棒球比赛
难度:easy
四个规则转换为栈的形式:
- 入栈
- 入栈元素是前两个栈顶元素相加的值
- 入栈元素是当前栈顶元素 * 2
- 出栈
这个题非常非常简单,就是考察对栈的理解,以及非常基础的编码能力。
LeetCode题解:代码实现
LeetCode946.验证栈序列
难度:mid
一道非常经典的面试题,给我们两串数字,第一串是入栈顺序,第二串是出栈顺序,问第二串的出栈顺序有没有可能由第一串的入栈顺序所形成,看示例1,我们并不一定是要全部执行完push
再进行pop
,有可能元素还没有全部push
进去我们就将某一元素pop
出去。
我们可以模拟一下,如果我们栈顶元素跟pop
的元素不相等我们就一直往里push
,push
进1的时候栈顶元素不是4,那我们就想办法让它变为4,我们就一直push
到4,然后再pop
,再看栈顶元素是不是5,不是5就继续push
,5push
入栈之后就可以pop
出栈了,随后3 2 1依次pop
。
LeetCode题解:代码实现
经典面试题—栈结构扩展应用
LeetCode20.有效的括号
难度:easy
来了来了,刚才铺垫的这个题终于来了,直接上图:
我们遍历给定字符串s。
当我们遇到一个左括号时,我们会希望在后面的字符中,有一个相同类型的右括号将其闭合,由于后遇到的左括号要先闭合,因此我们可以将这个左括号放入栈顶。
当我们遇到一个右括号时,我们就可以弹出栈顶的左括号,判断两个括号是否能闭合,如果不是相同类型的括号或者栈中没有括号,我们这个字符串就是无效的。
为了能快速判断括号的类型,我们可以创建一个Map
帮助我们。
创建一个栈来存储我们的左括号。
开始遍历我们的字符串,如果是左括号,我们就将该字符加入到栈中。
如果是右括号,我们就判断栈顶的元素和当前的元素是否能完全闭合。
LeetCode题解:代码实现
LeetCode1021.删除最外层的括号
难度:easy
这个题就是我们在上面说过的完全包含问题,例如这个:"(()())(())"
,"(()())"
就是事件1,"(())"
就是事件2,我们要将事件最外层的括号删除,最后返回:"()()" + "()" = "()()()"
。
所以我们可以设置一个变量,将我们最外层的左括号和右括号过滤掉:
opened
遇到左括号'('
值就+1,遇到右括号')'
值就-1,我们需要取到里面的括号,直接可以用判断将外层括号过滤掉。
LeetCode题解:代码实现
LeetCode145.二叉树的后序遍历
难度:easy
二叉树的后续遍历就是:左右根
我们不难看出,这道题可以用递归来解决,而且跟我们之前举的例子:表达式求值
很像,当我们此时的左右根中,左结点或者右结点形状不是单独结点时,我们就继续遍历它的左右根,此时这个问题解决不了,我们就向下解决它的子问题,形成了递归求解。
递归的实现方式还是非常简单的,但题中有一句话:
进阶:递归算法很简单,你可以通过迭代算法完成吗?
我们使用栈来辅助我们实现迭代,我们需要两个栈来实现,一个栈负责存值,一个栈负存树的结点。
因为我们栈是先进后出
,二叉树的后序遍历是左右根
,我们不妨将入栈的顺序转化为:根右左
,这样出栈的顺序就变为:左右根
了。
LeetCode题解:两种方法代码实现
LeetCode227.基本计算器 II
难度:mid
其实这个题就是我们上面的表达式求值问题,我们已经用递归实现了,执行代码是没问题的
但我提交发现居然超时了
它卡了我一个巨离谱的用例,我也是第一次遇见,忍不住吐槽一下,感兴趣的可以提交我这个代码试一下,看它这个用例到底有多长
class Solution {
public int calculate(String s) {
return calc(s.toCharArray(), 0, s.length() - 1);
}
public int calc(char[] str, int l, int r) {
int pos = -1, pri = 999999 - 1, temp = 0;
for (int i = l; i <= r; i++) {
int cur = 999999;
switch (str[i]) {
case '(': temp += 9999; break; // ()的优先级最高,temp的权重应最大
case ')': temp -= 9999; break;
case '+':
case '-': cur = temp + 1; break;
case '*':
case '/': cur = temp + 2; break;
}
if (cur <= pri) {
pos = i;
pri = cur;
}
}
if (pos == -1) { // 表明运算式中没有符号
int num = 0;
for (int i = l; i <= r; i++) {
if (str[i] < '0' || str[i] > '9') continue;
num = num * 10 + (str[i] - '0');
}
return num;
}
int a = calc(str, l, pos - 1); // 记录符号左边的值
int b = calc(str, pos + 1, r); // 记录符号右边的值
switch (str[pos]) {
case '+': return a + b;
case '-': return a - b;
case '*': return a * b;
case '/': return a / b;
}
return 0;
}
}
以我目前的水平暂时还不知道怎么优化这个代码,但没过就是没过,我们要想想别的办法。
所以我们可以使用栈来完成这个题。
我们采用双栈,一个是数字栈,一个是符号栈。
但我们要注意一点,假如运算式是3 + 2 x 2
,我们先将3入栈到s1中,然后将+号入栈到s2中,所有元素都入栈是这样的情况:
当运算式的元素都入栈后,我们弹出s2的栈顶元素,再弹出s1的两个栈顶元素进行计算,将运算后的值再放进s1中,然后再进行一遍弹栈操作,求得结果。
最后将我们求得的结果入栈到s1中。
但我们的运算式假设是3 x 2 + 2
,我们还可以像上面一样入栈了吗?如下图:
显然是不可以的,在s2中,我们的+
号在x
号上面了,那也就意味着我们在进行计算的时候会先计算加号而不是乘号。
所以我们要给予符号优先级。
当+
号入栈时,发现栈顶元素的优先级比它要高,这时我们就将s2不停的pop
,直到栈顶元素不小于它为止。
LeetCode题解:代码实现
题解中还有单栈解法,时间会比我的双栈代码优化一点。
总结
栈这个结构还是比较简单的,有很多代码实现可能都是以栈的形式来实现的,我们的目的是以栈结构来理解递归,我们学习编程语言遇到的第一个难点,就是递归,但我们学习的过程中大部分都是先学了递归,然后再学栈,但其实学完递归我们大概率也是一种很懵的状态,如果我们先学栈这个数据结构,再学递归,那就会很清晰的理解为什么递归可以把大问题化成小问题了。
我们由浅入深,用括号问题来引入栈的概念,最后得到了一个结论:栈可以处理具有完全包含关系的问题,从而我们可以用栈来解释递归问题了。
注意:
本文重点是让大家理解栈,先把栈理解好了再去理解递归,后续还会有数据结构-二叉树的文章更深层的理解递归。
文章链接:
据结构学习笔记 2-1 二叉树(Binary Tree)与 LeetCode真题(Java)