栈
本篇博客介绍的是栈这种特殊的数据结构。相信大家对栈还有它的一位好朋友队列都很熟悉了:栈是先进后出的,队列是先进先出的。在算法题中,栈的使用频率可以说是绝对碾压于队列,因为普通的队列其实和我们顺序遍历没什么区别。除非是优先队列那种需要将队列中的元素做一些额外操作的,不然的话队列的使用频率真的非常低。
而栈因为其特殊的“先进后出”性质,常见于三种类型的算法题中:
- 栈用来替代某些递归算法题。在Java中,递归本身就是在Java内存的栈区实现的,所以我们可以直接自己构建栈,作为大部分递归算法题的另一种解法。比如本篇博客要介绍的二叉树的三种深度优先遍历;
- 用于一些需要暂时存储,比如本篇博客介绍的有效括号序列、小行星碰撞、表达式求值三道题;
- 有些算法题需要用单调栈来求解。单调栈,顾名思义栈里的元素也是单调有序的,有些难度较高的算法题需要用到单调栈。
本专栏打算将单调栈和优先队列放到后面更加进阶的博客中去讲解,以保持专栏博客难度有序递增。因此,本篇博客将介绍栈在算法题中的前两种应用。
在此我要继续强调一遍:做算法题一定要多画图。尤其是对于二叉树、栈这种容易画出来的数据结构,多画图、多画流程真的很有好处。
二叉树的三种深度优先遍历的栈解法
在本章博客的第三篇二叉树中,我们介绍了三种深度优先遍历的递归解法。本篇博客中我们来详细介绍栈的解法。
前序遍历
前序遍历是“根左右”。我们先给出代码:
public List<Integer> preOrderDepthFistSearch(TreeNode root) {
List<Integer> result = new ArrayList<>();
Deque<TreeNode> stack = new LinkedList<>();
stack.push(root);
while (!stack.isEmpty()) {
root = stack.pop();
result.add(root.val); // 先遍历当前子树的根结点
if (root.right != null) {
stack.push(root.right); // 右结点是需要后遍历的,所以要先进栈
}
if (root.left != null) {
stack.push(root.left); // 左结点是需要先遍历的,所以要后进栈
}
}
return result;
}
根据以上代码,我们模拟一个三层的满二叉树的前序遍历,以加深对代码的印象:
中序遍历
中序遍历是“左根右”,代码如下,大家自行画模拟流程:
public List<Integer> inOrderDepthFistSearch(TreeNode root) {
List<Integer> result = new ArrayList<>();
Deque<TreeNode> stack = new LinkedList<>();
stack.push(root);
while (root != null || !stack.isEmpty()) {
while (root != null) {
stack.push(root);
root = root.left; // 先遍历当前子树的左结点,并向左“追杀到底”
}
root = stack.pop();
result.add(root.val); // 再遍历根结点
root = root.right; // 再跳转到右结点
}
return result;
}
后序遍历
后序遍历是“左右根”,这也是用栈来解三种深度优先遍历中最难的一种,需要加一个表示遍历过程的前一个结点的prev结点,代码如下,自行模拟:
public List<Integer> postOrderDepthFistSearch(TreeNode root) {
List<Integer> result = new ArrayList<>();
Deque<TreeNode> stack = new LinkedList<>();
stack.push(root);
TreeNode prev = root; // 用来保存遍历过程的前一个结点
while (!stack.isEmpty()) {
root = stack.peek();
if (root.left == null && root.right == null || root.left == prev || root.right == null) {
result.add(root.val);
stack.pop();
}
else {
if (root.right != null && root.right != prev) {
stack.push(root.right);
}
if (root.left != null && root.left != prev) {
stack.push(root.left);
}
}
prev = root;
}
return result;
}
用两个栈实现队列
牛客网
使用两个栈,来实现队列的“先进先出”特性,是一道非常经典的算法题,其关键解法如下:
假设两个栈分别为stack1和stack2,那么:
- 如果想要实现队列的push操作,只需要push到stack1中。
- 如果想要实现队列的poll操作,需要判断stack2是否为空。如果stack2为空,那么不断弹出stack1中元素并保存进stack2中,最后弹出stack2的栈顶元素——此时相当于进行了两次“先进后出”,也就变成了“先进先出”;如果stack2不为空,那么弹出stack2的栈顶元素即可。
- 如果想要实现队列的isEmpty操作,只需要判断两个栈是否都为空。
具体代码如下:
public class TwoStackForQueue {
Deque<Integer> stack1;
Deque<Integer> stack2;
public TwoStackForQueue() {
stack1 = new LinkedList<>();
stack2 = new LinkedList<>();
}
public boolean isEmpty() {
return stack1.isEmpty() && stack2.isEmpty();
}
public void push(int num) {
stack1.push();
}
public int pop() {
if (stack2.isEmpty()) {
while (!stack1.isEmpty()) {
stack2.push(stack1.pop());
}
return stack2.pop();
}
else {
return stack2.pop();
}
}
}
有效括号序列
牛客网
给定一个字符串,字符串中只含有 “(”, “)”, “[”, “]”, “{” 和 "}"这六种括号字符。现问这个字符串中的括号序列是否有效。比如 “()[]{}”, "([{}][]{})[{}]{}“就是有效的,而”([{]})"就是无效的。
首先要指出的是,如果这道题只有一种括号,那么即使稍微变化一下,都是可以用贪心思想来解的,如Leetcode678题。但对于本题贪心思想就不太能够处理了,还是回到用栈的方式。
栈可以为我们保存每一种左括号,且能随时弹出最后一个加入进来的左括号。有了栈的助力,我们可以将遇到的每一个右括号都与最后一个加入栈的左括号进行匹配,不能匹配则说明不是有效括号序列。
代码如下:
public boolean (String s) {
char[] cs = s.toCharArrays();
Deque<Character> stack = new LinkedList<>();
for (char c : cs) {
if (c == '(' || c == '[' || c == '{') {
stack.push(c);
}
else if (c == ')') {
if (stack.isEmpty() || stack.peek() != '(') {
return false;
}
else {
stack.poll();
}
}
else if (c == ']') {
if (stack.isEmpty() || stack.peek() != '[') {
return false;
}
else {
stack.poll();
}
}
else {
if (stack.isEmpty() || stack.peek() != '{') {
return false;
}
else {
stack.poll();
}
}
}
return true;
}
在此代码已经完成了。但是如果对代码风格比较敏感的朋友们应该已经发现了:
if (stack.isEmpty() || stack.peek() != '*') {
return false;
}
else {
stack.poll();
}
这段代码(* 表示 ( 或 [ 或 { )出现了三次,真的是很不好的代码习惯!
我在校招面试时就遇到过这道题,当时就这么写完了,那位面试官人非常好,给我了一点小提示:“现在这里只是三种类型的括号,如果以后有更多的匹配模式要做判断怎么办?”听他说完,我就想到了,我们上一篇博客刚介绍过的哈希思想,用哈希来为各种括号对形成匹配,从而避免相似代码的出现。优化代码如下:
public boolean (String s) {
char[] cs = s.toCharArrays();
Deque<Character> stack = new LinkedList<>();
Map<Character, Character> map = new HashMap<>();
// 此处不可避免会有hardcode。如果在开发环境中遇到,仍然需要解决这部分hardcode
map.put(')', '(');
map.put(']', '[');
map.put('}', '{');
for (char c : cs) {
if (c == '(' || c == '[' || c == '{') {
stack.push(c);
}
else {
if (stack.isEmpty() || stack.peek() != map.get(c)) {
return false;
}
else {
stack.poll();
}
}
}
return true;
}
小行星碰撞
Leetcode
给定一个整数数组 asteroids,表示在同一行的小行星。数组中小行星的索引表示它们在空间中的相对位置。对于数组中的每一个元素,其绝对值表示小行星的大小,正负表示小行星的移动方向(正表示向右移动,负表示向左移动)。每一颗小行星以相同的速度移动。
找出碰撞后剩下的所有小行星。碰撞规则:两个小行星相互碰撞,较小的小行星会爆炸。如果两颗小行星大小相同,则两颗小行星都会爆炸。两颗移动方向相同的小行星,永远不会发生碰撞。
这是一道挺有意思的模拟题,可以用栈来帮助我们解题,即用栈存储前面的一系列未被碰撞掉的行星。代码如下:
public int[] asteroidCollision(int[] asteroids) {
Deque<Integer> stack = new ArrayDeque<>(); // ArrayDeque和LinkedList都可以
for (int asteroid : asteroids) {
// 第一个条件,stack是空的自然不必说;
// 第二个条件,因为即使stack.peek()>0前一个行星向右飞行,只要asteroid>0那么当前行星也向右飞行,不会撞上;
// 第三个条件类似,只要stack.peek()<0前一个小行星向左飞行,当前小行星就算向右飞行就不会和前一个小行星撞上。
if (stack.isEmpty() || asteroid > 0 || stack.peek() < 0) {
stack.push(asteroid);
}
else {
// 只有当前行星向左飞行,且栈中存储的前一个行星向右飞行,才会发生碰撞
while (asteroid < 0 && !stack.isEmpty() && stack.peek() > 0) {
int prev = stack.poll();
if (-asteroid == prev) { // 质量一样,同归于尽
asteroid = 0;
break;
}
else if (-asteroid < prev) { // 当前小行星被撞掉
asteroid = 0;
stack.push(prev);
break;
}
}
if (asteroid < 0) { // 经过多轮碰撞,当且行星仍然向左飞行,且(stack被清空了,或最后一个前面的小行星也是向左飞行)的了
stack.push(asteroid);
}
}
}
int[] result = new int[stack.size()];
int index = stack.size();
while (!stack.isEmpty()) {
result[--index] = stack.poll();
}
return result;
}
表达式求值
牛客网
请写一个整数计算器,支持加减乘三种运算和括号,表达式一定是一个正确的、可以被计算的表达式。数据范围:0≤∣s∣≤100,保证计算结果始终在整型范围内。
这道题目非常有挑战了,难度可以说是hard。它不仅仅是考栈这个考点,更是考验大家的模拟能力。
首先,我们要创建两个栈,一个栈numStack存放已经遍历过的完整数字(比如说遍历到了"+23+“中的"23”,我们应该想办法将23存入numStack中而不是将2和3分别存进去),另一个栈opStack存放非数字内容,即加号、减号、乘号、左括号和右括号(在后面的分析中我们会看到或许并不是所有运算符都要存进去),那么怎么处理数字呢?运算符之间的优先级又怎么处理呢?而且,我们要知道什么时候要开始计算,计算多少次运算?最后,有了这两个栈,又怎么进行运算呢?
我们一个问题一个问题来看。
- 如何处理数字,这个问题还算简单:每当遍历到一个数字字符时,我们将该索引不断向后,直到遍历到第一个不是数字的字符为止,这就是一个将字符串转化成数字的问题,自然很好解决;
- 运算符的优先级,我们用人类文字自然是很好描述,这里涉及到的运算符只有加减乘,所以是乘法大于加法和减法。另外,括号需要单独处理。(有的解题思路会把括号内的表达式进行递归求解,这也是一个很好的思路。我们这里暂不采用,而是用模拟的方法来解开)。我们用哈希的方法, 规定乘法对应的优先级值大于加法和减法的,括号则用另外的规则来处理;
- 什么时候进行运算,怎么计算?逻辑上来说,当我们拥有两个数字和两个数字之间的运算符时,我们就可以完成一次运算,比如2*36,31+4,42-53。但是我们要考虑第二个数字后面还会有运算符,可能后面运算符的优先级高于前面这个。所以,在代码逻辑中,我们遇到一个运算符时,才会逆序地对前面的表达式求值,这也刚好利用了栈的“先进后出”特性;而且,只有优先级低于当前运算符的栈内运算符,才能被计算。
- 最后,我们谈一下如何用模拟的方法处理括号。对于左括号,我们直接加入运算符的栈中;对于右括号,我们将它看做优先级无限高的运算符,遇到右括号时,可以逆序地对前面的表达式求值,直到逆序遍历到一个左括号,说明当前嵌套的括号表达式遍历完成,得到的数字加入到栈中
- 最后的最后,虽然这道题规定了表达式一定可以被计算,但还是有一种极其特殊的情况:一个表达式,或者括号内的表达式,由减号/负号开头。此时就会出现数字栈中一个数字,运算符栈中一个减号的情况。此时需要特殊处理。
完整版代码如下:
public int solve(String s) {
char[] cs = s.toCharArray();
Deque<Integer> numStack = new ArrayDeque<>(); // 数栈
Deque<Character> opStack = new ArrayDeque<>(); // 运算符栈
Map<Character, Integer> opPriority = new HashMap<>(); // 运算符的优先级哈希
opPriority.put('+', 1);
opPriority.put('-', 1);
opPriority.put('*', 2);
for (int i = 0;i < cs.length;i++) {
if (cs[i] >= '0' && cs[i] <= '9') { // 数字字符的处理方法
int number = cs[i++] - '0';
while (i < cs.length && cs[i] >= '0' && cs[i] <= '9') {
number = number * 10 + cs[i++] - '0';
}
i--; // i多加了一次,需要减小
numStack.push(number); // 每个数字获取之后都存入栈中
}
else if (cs[i] == '(') { // 左括号直接加入运算符栈,等待右括号与其匹配
opStack.push(cs[i]);
}
else if (cs[i] == ')') { // 右括号的优先级无限高,只要不遇到左括号,就一直逆序运算
while (!opStack.isEmpty() && opStack.peek() != '(') {
calculateOnce(numStack, opStack);
}
opStack.poll(); // 将左括号弹出。由于表达式一定正确,所以我们就不进行!opStack.isEmpty()判断了
}
else {
while (!opStack.isEmpty() && opPriority.containsKey(opStack.peek()) && opPriority.get(opStack.peek()) >= opPriority.get(cs[i])) { // 普通运算符之间,如果遇到左括号要停下,同时还要进行优先级匹配,
calculateOnce(numStack, opStack);
}
opStack.push(cs[i]); // 获得的最新值存入数栈
}
}
while (!opStack.isEmpty()) { // 表达式遍历完毕了,把运算符栈清空。由于表达式一定正确所以数栈一定也是被清空了
calculateOnce(numStack, opStack);
}
return numStack.poll();
}
public void calculateOnce(Deque<Integer> numStack, Deque<Character> opStack) {
int post = numStack.poll();
if (numStack.isEmpty() && !opStack.isEmpty() && opStack.peek() == '-') { // 特殊情况,负号开头时的处理方法:在负号前加个数字0,使负号变成运算符减号
numStack.push(0);
}
int prev = numStack.poll();
char op = opStack.poll();
if (op == '+') {
numStack.push(prev + post);
}
else if (op == '-') {
numStack.push(prev - post);
}
else if (op == '*') {
numStack.push(prev * post);
}
}