[ 数据结构菜鸟教程 - III ] 你可能需要重新学习下栈和队列

96 阅读10分钟

# 前言

大家早上好,新的一节数据结构菜鸟课程又更新啦。

在这一节的内容中,带大家学习栈和队列的基础知识和常见题型。

栈和队列是我们面试过程中的常客,而且很多时候栈可以为我们很巧妙的解决一些问题,我们接下来会看到这些场景,有些时候也真的想拍下大腿,这也行?

# 栈和队列的基础知识

  1. 栈 (Stack)

    • Last In, First Out: 我们经常当顺口溜一样说出来的 后进先出,即最新压入栈的元素最先被弹出。想象一叠盘子,最先放进去的盘子在最底部,最后放的盘子在最上面,取盘子时总是先从顶部开始。

    image.png

    • 栈的基本操作以及时间复杂度

      操作动作时间复杂度
      压栈将一个元素压入栈顶O(1)
      出栈从栈顶移除并返回该元素O(1)
      查看栈顶元素返回栈顶元素但不移除它O(1)
    • Java 语言的 Stack 的实现

      image.png

  2. 双端队列 (Deque)

    双端队列(Deque,发音为 "deck")是一种线性数据结构,全称为 Double-Ended Queue,它的特点是允许在队列的两端进行元素的插入和删除操作。Deque 结合了栈(Stack)和队列(Queue)的特点,既可以在前端插入和删除元素,也可以在后端插入和删除元素。

    主要的应用场景有:滑动窗口算法,回文检查,任务调度,浏览器历史记录

    image.png

    Deque操作以及时间复杂度

    操作时间复杂度
    队头插入O(1)
    队尾插入O(1)
    删除O(1)
    访问O(1)
  3. 优先队列 (Priority Queue)

    一般的队列是以 “时间” 为顺序的(先进先出)

    image.png

    优先队列按照元素的 “优先级” 取出,它可以是自己定义的一个元素属性。

    许多数据结构都可以用来实现优先队列,例如二叉堆,二叉平衡树等。

    Priority Queue 的操作以及时间复杂度

    操作时间复杂度
    访问最值O(1)
    插入O(logN)
    取最值O(logN)

# 实战环节

  1. 20. 有效的括号

    给定一个只包括 '(',')','{','}','[',']' 的字符串 s ,判断字符串是否有效。

    有效字符串需满足:

    左括号必须用相同类型的右括号闭合。 左括号必须以正确的顺序闭合。 每个右括号都有一个对应的相同类型的左括号。

    示例 1:
    输入:s = "()"
    输出:true

    示例 2:
    输入:s = "()[]{}"
    输出:true

    示例 3:
    输入:s = "(]"
    输出:false

    提示: 1 <= s.length <= 104
    s 仅由括号 '()[]{}' 组成

    解题思路:

    我们遍历给定的输入字符串。遇到一个左侧括号,在后续的遍历中,我们期待一个相同类型的右括号和其配对,类似于连连看。由于后遇到的左括号要先闭合,因此我们可以将这个左括号放入栈顶。

    遇到一个右括号时,期待一个相同类型的左括号匹配。取出栈顶的左括号并判断它们是否是相同类型的括号,如果不是相同的类型的括号,或者栈中并没有元素了,我们可以判定输入字符串不满足题目要求,返回 false。

    在遍历完成后,如果栈中没有左括号,我们完成匹配过程,返回 True,否则返回 False。

    这里有一个边界条件我们需要注意,就是有效字符串的长度一定是一个偶数,这样才可以一一配对。如果我们一上来就发现字符串长度是个奇数,那我们可以直接返回 False,后面的遍历过程都不用做啦。

    示例代码:

    class Solution {
        public boolean isValid(String s) {
            Stack<Character> stack = new Stack<>();
            for(char c : s.toCharArray()){
                if(c == '(')
                    stack.push(')');
                else if(c == '[')
                    stack.push(']');
                else if(c == '{')
                    stack.push('}');
                else if( stack.isEmpty() || stack.pop() != c)
                    return false;
            }
            return stack.isEmpty();
        }
    }
    
  2. 155. 最小栈

    设计一个支持 push ,pop ,top 操作,并能在常数时间内检索到最小元素的栈。

    实现 MinStack 类:

    • MinStack() 初始化堆栈对象。
    • void push(int val) 将元素val推入堆栈。
    • void pop() 删除堆栈顶部的元素。
    • int top() 获取堆栈顶部的元素。
    • int getMin() 获取堆栈中的最小元素。

    示例 1:

    输入:
    ["MinStack","push","push","push","getMin","pop","top","getMin"] [[],[-2],[0],[-3],[],[],[],[]]

    输出:
    [null,null,null,null,-3,null,0,-2]

    解释:
    MinStack minStack = new MinStack();
    minStack.push(-2);
    minStack.push(0);
    minStack.push(-3);
    minStack.getMin(); --> 返回 -3.
    minStack.pop();
    minStack.top(); --> 返回 0.
    minStack.getMin(); --> 返回 -2.

    提示:

    • -231 <= val <= 231 - 1
    • pop、top 和 getMin 操作总是在 非空栈 上调用
    • push, pop, top, and getMin最多被调用 3 * 104 次

    解题思路:

    这个题目我们创建一个 min 来存储对应当前栈的最小值,和一个栈来存储入栈元素。接下来我们分场景讨论如何维护栈和最小值。

    • 入栈: 比较入栈值和最小值关系,如果当前值比最小值 min 要小的话,我们先入栈最小值,更新 min,最后入栈该元素。
    • 出栈:如果出栈的值等于最小值的话,说明出栈的元素的下一个元素是最小值,我们继续出栈元素,维护栈元素。
    • 获取栈顶元素:直接查看栈的顶部元素。
    • 获取最小值:直接查看 min

    示例代码:

    class MinStack {
        private int min;
        Deque<Integer> stack;
    
        public MinStack() {
            min = Integer.MAX_VALUE;
            stack = new LinkedList<>();
        }
    
        public void push(int val) {
            if(val <= min) {
                stack.push(min);
                min = val;
            }
    
            stack.push(val);
        }
    
        public void pop() {
            if(stack.pop() == min) {
                min = stack.pop();
            }
        }
    
        public int top() {
            return stack.peek();
        }
    
        public int getMin() {
            return min;
        }
    }
    
  3. 150. 逆波兰表达式求值

    给你一个字符串数组 tokens ,表示一个根据 逆波兰表示法 表示的算术表达式。

    请你计算该表达式。返回一个表示表达式值的整数。

    注意:
    有效的算符为 '+'、'-'、'*' 和 '/' 。
    每个操作数(运算对象)都可以是一个整数或者另一个表达式。
    两个整数之间的除法总是 向零截断 。
    表达式中不含除零运算。
    输入是一个根据逆波兰表示法表示的算术表达式。
    答案及所有中间计算结果可以用 32 位 整数表示。

    示例 1:
    输入:tokens = ["2","1","+","3","*"]
    输出:9
    解释:该算式转化为常见的中缀算术表达式为:((2 + 1) * 3) = 9

    示例 2:
    输入:tokens = ["4","13","5","/","+"]
    输出:6
    解释:该算式转化为常见的中缀算术表达式为:(4 + (13 / 5)) = 6

    示例 3:
    输入: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

    提示:

    • 1 <= tokens.length <= 104
    • tokens[i] 是一个算符("+"、"-"、"*" 或 "/"),或是在范围 [-200, 200] 内的一个整数

    逆波兰表达式:

    逆波兰表达式是一种后缀表达式,所谓后缀就是指算符写在后面。

    平常使用的算式则是一种中缀表达式,如 ( 1 + 2 ) * ( 3 + 4 ) 。 该算式的逆波兰表达式写法为 ( ( 1 2 + ) ( 3 4 + ) * ) 。 逆波兰表达式主要有以下两个优点:

    去掉括号后表达式无歧义,上式即便写成 1 2 + 3 4 + * 也可以依据次序计算出正确结果。 适合用栈操作运算:遇到数字则入栈;遇到算符则取出栈顶两个数字进行计算,并将结果压入栈中

    解题思路:
    逆波兰表达式严格遵循「从左到右」的运算规则。我们在计算逆波兰表达式时,使用一个栈来存储操作的数字,从左到右遍历逆波兰表达式,使用下面的规则来处理:

    • 如果遇到的是一个数字,那么我们将数字入栈。

    • 如果遇到了运算符,则将栈顶的两个数字出栈。这里我们需要注意的是:先出栈的是右操作数,后出栈的是左操作数。接下来使用当前运算符对两个操作数进行运算,将运算得到的结果入栈,用来做接下来的运算。

    • 当我们遍历完成后,栈内只有一个元素,该元素就是我们所要求的逆波兰表达式的结果。

    示例代码:

    class Solution {
        public int evalRPN(String[] tokens) {
            Stack<Integer> stack = new Stack<>();
    
            for(String token: tokens) {
                if(token.equals("+")) {
                    int num1 = stack.pop();
                    int num2 = stack.pop();
                    stack.push(num2 + num1);
                } else if(token.equals("-")) {
                    int num1 = stack.pop();
                    int num2 = stack.pop();
                    stack.push(num2 - num1);
                } else if(token.equals("*")) {
                    int num1 = stack.pop();
                    int num2 = stack.pop();
                    stack.push(num2 * num1);
                } else if(token.equals("/")) {
                    int num1 = stack.pop();
                    int num2 = stack.pop();
                    stack.push(num2 / num1);
                } else {
                    stack.push(Integer.parseInt(token));
                }
            }
    
            int result = stack.pop();
            return result;
        }
    }
    
  4. 224. 基本计算器

    给你一个字符串表达式 s ,请你实现一个基本计算器来计算并返回它的值。

    注意:不允许使用任何将字符串作为数学表达式计算的内置函数,比如 eval() 。

    示例 1:
    输入:s = "1 + 1"
    输出:2

    示例 2:
    输入:s = " 2-1 + 2 "
    输出:3

    示例 3:
    输入:s = "(1+(4+5+2)-3)+(6+8)"
    输出:23

    提示:

    • 1 <= s.length <= 3 * 105
    • s 由数字、'+'、'-'、'('、')'、和 ' ' 组成
    • s 表示一个有效的表达式
    • '+' 不能用作一元运算(例如, "+1" 和 "+(2 + 3)" 无效)
    • '-' 可以用作一元运算(即 "-1" 和 "-(2 + 3)" 是有效的)
    • 输入中不存在两个连续的操作符
    • 每个数字和运行的计算将适合于一个有符号的 32位 整数

    解题思路:

    字符串只有加号和减号两种运算符。因此,在展开表达式的过程中,数字本身不会发生变化,只有数字前面的符号会发生变化。

    我们使用取值为 -1 或者 +1 的整数 sign 表示当前的符号。

    维护一个 stack,栈顶元素记录了当前位置所处的每个括号所形成的符号。遇到左括号 时候,将当前 sign 压栈,遇到右括号 的时候,弹出一个元素。

    示例代码:

    class Solution {
        public int calculate(String s) {
            int result = 0;
            int sign = 1;
            int num = 0;
            int len = s.length();
            Stack<Integer> stack = new Stack<>();
            stack.push(sign);
    
            for(int i = 0; i < len; i++) {
                char curChar = s.charAt(i);
    
                if(curChar >= '0' && curChar <= '9') {
                    num = num * 10 + curChar - '0';
                } else if(curChar == '+' || curChar == '-') {
                    result += sign * num;
                    sign = stack.peek() * (curChar == '+' ? 1 : -1);
                    num = 0;
                } else if(curChar == '(') {
                    stack.push(sign);
                } else if(curChar == ')') {
                    stack.pop();
                }
            }
    
            result += sign * num;
    
            return result;
        }
    }
    
  5. 227. 基本计算器 II

    给你一个字符串表达式 s ,请你实现一个基本计算器来计算并返回它的值。

    整数除法仅保留整数部分。

    你可以假设给定的表达式总是有效的。所有中间结果将在 [-231, 231 - 1] 的范围内。

    注意:不允许使用任何将字符串作为数学表达式计算的内置函数,比如 eval() 。

    示例 1:
    输入:s = "3+2*2"
    输出:7

    示例 2:
    输入:s = " 3/2 "
    输出:1

    示例 3:
    输入:s = " 3+5 / 2 "
    输出:5

    提示:

    • 1 <= s.length <= 3 * 105
    • s 由整数和算符 ('+', '-', '*', '/') 组成,中间由一些空格隔开
    • s 表示一个 有效表达式
    • 表达式中的所有整数都是非负整数,且在范围 [0, 231 - 1] 内
    • 题目数据保证答案是一个 32-bit 整数

    解题思路:

    先完成乘除运算,将乘除运算结果放回到原表达式的相应位置,则整个表达式的值,就等于一系列整数加减后的值。

    我们创建一个栈,来保存这些整数的值。加减号后的数字,直接入栈。乘除后的数字,直接与栈顶元素计算,并替换成栈顶元素为计算后的结果。

    使用 operation 来记录之前的运算符。遍历到数字末尾时,根据 operation 来决定计算方式:

    • 加号:将数字压栈
    • 减号:将数字的相反数压栈
    • 乘除号:计算数字与栈顶元素,将栈顶元素替换为计算结果

    最后,我们将栈中的元素累加起来,就是最后表达式计算的结果。

    示例代码:

    class Solution {
        public int calculate(String s) {
            if(s == null || s.length() == 0) return 0;
            int currentNumber = 0;
            char operation = '+';
            Stack<Integer> stack = new Stack<>();
            int len = s.length();
    
            for(int i = 0; i < len; i++) {
                char currentChar = s.charAt(i);
                if(Character.isDigit(currentChar)) {
                    currentNumber = currentNumber * 10 + (currentChar - '0');
                }
                if(!Character.isDigit(currentChar) && currentChar != ' ' || i == len - 1) {
                    if(operation == '+') {
                        stack.push(currentNumber);
                    } else if(operation == '-') {
                        stack.push(-currentNumber);
                    } else if(operation == '*') {
                        stack.push(stack.pop() * currentNumber);
                    } else if(operation == '/') {
                        stack.push(stack.pop() / currentNumber);
                    }
    
                    operation = currentChar;
                    currentNumber = 0;
                }
            }
    
            int result = 0;
            while(!stack.isEmpty()) {
                result += stack.pop();
            }
            return result;
        }
    }