堆栈

栈的手动实现

数据结构里面的栈是限定仅在表尾进行插入或者删除的线性表,所谓的表尾,就是我们所称的栈顶,相应的,我们可以称表头为栈底。栈的最重要的特性,是后进先出(Last in first out),也称为 LIFO 结构

类比场景:往一个箱子里面放入一些书籍,取书时,肯定是最后放进去的书,最先被取出来。

前面介绍非递归时,总是优先考虑使用栈,因为方法递归本身就是天然的栈。

Java 标准库中已有栈结构,而且最佳选择是使用 Deque 接口及其实现类(如 ArrayDeque 或 LinkedList),不推荐使用 Stack 类,因为 Stack 继承自 Vector,而 Vector 是早期线程安全的动态数组实现,其同步开销可能降低性能,且 Stack 的方法(如 push、pop)与 Vector 的继承关系导致设计不够清晰,且无法选择其他底层数据结构。

Deque 提供了 push()(压栈)、pop()(弹栈)和 peek()(查看栈顶)等适合栈操作的方法。对于它的两个实现,ArrayDeque:基于数组实现,内存连续,访问效率高,适合大多数场景;LinkedList:基于链表实现,适合频繁插入/删除但随机访问较少的场景。若需要线程安全,可使用 ConcurrentLinkedDeque 或用 Collections.synchronizedDeque(new ArrayDeque<>()) 包装。

如果要手动实现简单的堆栈,先定义一个链表节点,节点里面包含数据,以及下一个节点的引用,定义方法如下:

  • 初始化:头结点置为 null。

  • push():压栈操作,创建新节点,并直接将其连接到当前 head 节点,再更新 head 节点为新节点。

  • pop():出栈操作,若头节点为空,说明栈里没有元素,否则取出第一个元素,更新头结点指针到下一个节点的位置。

  • peek():获取栈顶元素,但不弹出。

  • isEmpty():判断栈是否为空。

实现如下:

public class MyStack<T> {
    // 定义链表节点
    private static class Node<T> {
        T t;
        Node<T> next;
    }

    private Node<T> head;

    private int size; // 新增 size 字段

    MyStack() {
        head = null;
        size = 0;
    }

    public void push(T t) {
        if(t == null)
            throw new RuntimeException("push item is null!");
        // 创建新节点,并直接指向现head
        Node<T> node = new Node<>();
        node.t = t;
        node.next = head;
        // 更新头节点
        head = node;
        size++;
    }

    public T pop() {
        if(head == null)
            throw new RuntimeException("can't pop from an empty stack!");
        Node<T> node = head;
        head = head.next;
        size--;
        return node.t;
    }

    public T peek() {
        if(head == null)
            throw new RuntimeException("can't peek from an empty stack!");
        return head.t;
    }

    public boolean isEmpty() {
        return head == null;
    }

    // 新增size方法
    public int size() {
        return size;
    }

    @Override
    public String toString() {
        var top = head;
        StringBuilder sb = new StringBuilder();
        while(top != null) {
            sb.append(top.t);
            sb.append(" ");
            top = top.next;
        }
        return sb.toString();
    }

    public static void main(String[] args) {
        MyStack<String> stack = new MyStack<>();
        stack.push("java");
        stack.push("lean");
        stack.push("I");
        System.out.println(stack);
        System.out.println(stack.peek());
        System.out.println(stack.pop());
        System.out.println(stack.pop());
        System.out.println(stack.pop());
        System.out.println(stack.size);
    }
}

这样 push()pop()peek() 都是 $O(1)$ 的时间复杂度。

两个栈实现队列

栈结构的特性是先进后出,而队列的特性是先进先出,就像水管一样,前面的水先流出来。

如果不直接使用队列,而用栈实现队列,则需要两个栈才能实现一个队列。因为栈本身是先进后出的,假设我们需要先进先出,那么势必需要另外一个堆栈保存数据。数据进入一个堆栈,出来时是逆序的,但是数据依次进入两个堆栈,出来是正序的。

有两个栈 stack1 和 stack2,如果有新的数据进入,那么可以直接 push 到 stack1 中。如果要取出数据,那么我们优先取出 stack2 的数据,如果 stack2 里面的数据是空的,那么我们需要把 stack1 全部的数据倒入 stack2,再从 stack2 取数据。即放入数据永远都是放在 stack1,取出数据必须从 stack2 取。实现如下:

public class MyQueue<T> {
    Deque<T> stack1 = new ArrayDeque<>();
    Deque<T> stack2 = new ArrayDeque<>();

    public void offer(T t) {
        stack1.push(t);
    }

    public T poll() {
        if(stack2.isEmpty()) {
            if(stack1.isEmpty())
                throw new RuntimeException("stack is empty!");
            else {
                while(!stack1.isEmpty())
                    stack2.push(stack1.pop());
            }
        }
        return stack2.pop();
    }

    public boolean isEmpty() {
        return stack1.isEmpty() && stack2.isEmpty();
    }

    public static void main(String[] args) {
        MyQueue<String> queue = new MyQueue<>();
        queue.offer("hello");
        queue.offer("my");
        System.out.println(queue.poll());
        queue.offer("old");
        queue.offer("friends");
        while(!queue.isEmpty())
            System.out.println(queue.poll());
    }
}

这样加入队列的时间复杂度是 $O(1)$,而出队列的时间复杂度是 $O(n)$,借助了两个栈结构,空间复杂度为 $O(n)$。

实现快速获取最小数的栈

如果把很多数值放入栈中,希望能获取到该栈中最小的元素。直接的方法是将栈中的所有元素取出,不断对比得到最小元素,然后又全部放回去栈中,时间复杂度是 $O(n)$。

能否可以在保持栈特性下,提供一个方法 min(),将获取最小元素的时间复杂度保持在 $O(n)$。

可以使用空间换时间的做法,使用两个栈,一个存储所有元素的 datas stack,另一个存储最小值 d 的 mins stack。

push 一个元素时,dataStack 正常操作,对于 minStack,当最小栈为空,或者栈顶元素大于等于待 push 的数时,将该数 push 进栈顶,保持栈顶元素最小。

pop 一个元素时,先 pop 出 dataStack 的元素,然后查看 minStack 的栈顶元素是否等于这个元素,相等时也要一起 pop 出去。

具体实现如下:

public class MinStack {
    Deque<Integer> dataStack = new ArrayDeque<>();
    Deque<Integer> minStack = new ArrayDeque<>();

    public void push(int num) {
        // 将最小元素放到最小栈的栈顶
        if(minStack.isEmpty() || minStack.peek() >= num)
            minStack.push(num);
        dataStack.push(num);
    }

    public int pop() {
        if(dataStack.isEmpty())
            throw new RuntimeException("stack is empty!");
        int res = dataStack.pop();
        // 与最小堆栈栈顶元素相同则一样弹出
        if(res == minStack.peek())
            minStack.pop();
        return res;
    }

    public int min() {
        if(minStack.isEmpty())
            throw new RuntimeException("stack is empty!");
        return minStack.peek();
    }

    public int peek() {
        if(dataStack.isEmpty())
            throw new RuntimeException("stack is empty!");
        return dataStack.peek();
    }

    public static void main(String[] args) {
        MinStack minStack = new MinStack();
        minStack.push(1);
        minStack.push(4);
        // 1
        System.out.println(minStack.min());
        minStack.push(3);
        minStack.push(5);
        minStack.push(0);
        // 0
        System.out.println(minStack.min());
        // 0
        System.out.println(minStack.pop());
        // 1
        System.out.println(minStack.min());
        minStack.push(5);
        // 5
        System.out.println(minStack.peek());
    }
}

这样,最小值栈的栈顶元素就是最小的元素,那么可以做到 $O(1)$ 的时间复杂度取出最小值。同时,也保留了栈结构先进后出的特性。

括号匹配

现在要实现字符串中的括号匹配,包括圆括号、方括号和大括号。括号匹配的常见方法是使用栈结构,思路如下:

  1. 遍历字符串,遇到左括号 "(", "[", "{" 就入栈。

  2. 遇到右括号 ")", "]", "}" 时:若栈为空,说明右括号没有对应的左括号,匹配失败;否则,弹出栈顶元素,并检查是否是对应的左括号,如果不是,匹配失败。

  3. 遍历结束后,如果栈不为空,说明有未匹配的左括号,匹配失败。

实现如下:

public static boolean isValidBrackets(String s) {
    Deque<Character> stack = new ArrayDeque<>();
    Map<Character, Character> mapping = Map.of(')', '(', ']', '[', '}', '{');
    for(Character c : s.toCharArray()) {
        if(mapping.containsValue(c))
            stack.push(c);
        else if (mapping.containsKey(c)) {
            if(stack.isEmpty() || stack.pop() != mapping.get(c))
                return false;
        }
    }
    return stack.isEmpty();
}

时间复杂度 $O(n)$,空间复杂度 $O(n)$。

中序表达式转为逆波兰表达式

逆波兰表达式又叫做后缀表达式,是波兰逻辑学家 J・卢卡西维兹(J・ Lukasiewicz)于 1929 年首先提出的一种表达式的表示方法,这种表达式把运算量写在前面,把算符写在后面,且不需要括号来表示运算的优先级,可以简化计算机的计算过程。

中序表达式就是我们平时用的表达式,比如 a + b * c,运算符在中间,转换为后缀表达式是 a b c * +

常见的算法是使用一个操作符栈和一个输出列表,遍历中序表达式的每个元素,如果是操作数就直接添加到输出;如果是操作符,则与栈顶的操作符比较优先级,如果当前操作符的优先级小于等于栈顶的,就弹出栈顶到输出,直到栈顶的优先级更低或者栈空,然后将当前操作符压栈。遇到左括号直接压栈,遇到右括号则弹出栈顶到输出,直到遇到左括号,左括号弹出但不输出。

对于运算符,如果考虑结合性,+-*/都是左结合,而 ^ 是右结合。对于左结合的运算符,遇到相同优先级的运算符,先弹出栈顶的运算符,再入栈新的运算符(即按自然顺序)。对于右结合的运算符,直接入栈,不弹出栈顶的相同优先级运算符(即同一右结合运算符,先计算右面的)。

实现如下:

public static String infixToRPN(String expression) {
    Map<String, Integer> precedence = Map.of("+", 1, "-", 1, "*", 2, "/", 2, "^", 3);
    Map<String, Character> associativity = Map.of("+", 'L', "-", 'L', "*", 'L', "/", 'L', "^", 'R');
    List<String> outputs = new ArrayList<>();
    Deque<String> operators = new ArrayDeque<>();

    // 假设输入已分隔,例如 "3 + 5 * ( 2 - 8 )"
    String[] tokens = expression.split(" ");

    for(String token : tokens) {
        if(token.matches("\\d+")) // 匹配数字
            outputs.add(token);
        else if(precedence.containsKey(token)) { // 运算符
            while (!operators.isEmpty() && !operators.peek().equals("(") &&
                    ((associativity.get(token) == 'L' && precedence.get(token) <= precedence.get(operators.peek()))
                            || (associativity.get(token) == 'R' && precedence.get(token) < precedence.get(operators.peek())))) {
                outputs.add(operators.pop());
            }
            operators.push(token);
        } else if(token.equals("(")) {
            operators.push(token);
        } else if(token.equals(")")) {
            while(!operators.isEmpty() && !operators.peek().equals("("))
                outputs.add(operators.pop());
            operators.pop(); // 弹出左括号 "("
        }
    }

    // 处理剩余运算符
    while(!operators.isEmpty())
        outputs.add(operators.pop());

    return String.join(" ", outputs);
}

逆波兰表达式的求解

从逆波兰表达式中可以看出,运算符总是出现在两个数值后面,当我们发现操作符号时,前面肯定有需要的两个数值。计算出来的数值同样作为操作数,与前面的数值进行计算。所以可以借助栈:

  • 如果是操作数,则将操作数压入栈中。

  • 如果是运算符,则将两个操作数出栈,其中先出栈的是右操作数,后出栈的是左操作数,使用运算符对两个操作数进行运算,将运算得到的新操作数入栈。

直到整个逆波兰表达式遍历完成,堆栈里面只有一个元素,该元素就是为逆波兰表达式的值。实现如下:

public static String compute(String expression) {
    Set<String> operators = Set.of("+", "-", "*", "/", "^");
    Deque<String> stack = new ArrayDeque<>();

    String[] tokens = expression.split(" ");
    for(String token : tokens) {
        // 操作数直接入栈
        if(token.matches("\\d+"))
            stack.push(token);
        // 操作符则取出两个数值并计算再入栈
        else if (operators.contains(token)) {
            double num1 = Double.parseDouble(stack.pop());
            double num2 = Double.parseDouble(stack.pop());
            switch (token) {
                case "+" -> stack.push(String.valueOf(num2 + num1));
                case "-" -> stack.push(String.valueOf(num2 - num1));
                case "*" -> stack.push(String.valueOf(num2 * num1));
                case "/" -> stack.push(String.valueOf(num2 / num1));
                case "^" -> stack.push(String.valueOf(Math.pow(num2, num1)));
                default -> throw new RuntimeException("No this operator");
            }
        }
    }
    return stack.pop();
}

时间和空间复杂度都是 $O(n)$。

每日温度问题

给定一个气温列表,比如 temperatures = [63,54,76,56,37,89,23,74],要输出每个位置之后需要等多少天气温才会比今天高,如果之后都没有更高的温度,就输出 0,即输出 [2,1,3,2,1,0,1,0]。

比如第一个温度是 63,之后第二天是 54,比它低,第三天 76 比 63 高,所以需要等 2 天。那示例中的第一个元素输出是 2。那我们的任务是,对于每个元素,找到下一个比它大的温度的距离。

首先是暴力解法,对于每个元素 i,遍历 i+1 到末尾,找到第一个比 temperatures[i] 大的,记录天数差,这样时间复杂度是 $O(n^2)$:

public static int[] awaitDays_intuit(int[] temperatures) {
    int[] res = new int[temperatures.length];
    for(int i = 0; i < temperatures.length; i++) {
        for(int j = i + 1; j < temperatures.length; j++) {
            // 找到第一个比它温度大的,计算距离
            if(temperatures[j] > temperatures[i]) {
                res[i] = j - i;
                break;
            }
        }
    }
    return res;
}

更高效的方法是使用单调栈,单调栈通常用于这类寻找下一个更大元素的问题。栈里保存的是索引,而栈中的元素对应的温度是单调递减的。当遇到一个温度比栈顶元素对应的温度高时,就弹出栈顶,并计算两者的索引差,也就是天数。这样就可以在一次遍历中得到结果。

初始化一个结果数组,初始值全为 0。然后维护一个栈,栈中保存的是索引。栈中的索引对应的温度是单调递减的。遍历每个温度,当当前温度比栈顶的温度高时,弹出栈顶元素,并计算当前索引和栈顶索引的差,作为结果中该栈顶元素位置的值。然后将当前索引入栈:

public static int[] awaitDays_MonoStack(int[] temperatures) {
    Deque<Integer> stack = new ArrayDeque<>();
    int[] res = new int[temperatures.length];
    for(int i = 0; i < temperatures.length; i++) {
        // 只要遇到比栈顶温度高的索引,就计算栈顶索引对应的天数
        // 保证栈顶对应温度最低
        while (!stack.isEmpty() && temperatures[i] > temperatures[stack.peek()]) {
            int index = stack.pop();
            res[index] = i - index;
        }
        stack.push(i);
    }
    return res;
}

时间复杂度为 $O(n)$。

退格字符处理

假设现在输入字符,要删除时只能使用 # 来代替退格,那么输入两个包含 # 的字符串,如何判断两个字符串是不是一样呢?

要得出字符串的退格后的字符串,比如 AB###CAD#,最终字符应该是 CA,只要借助堆栈结构,读取每一个字符。如果不是 #,就选择压入栈中,如果是 #,就看堆栈是不是为空,如果不为空,就弹出一个元素,相当于删除。处理完之后,堆栈里面剩下的元素,就是处理完退格键之后的元素。

那么,只要两个字符串处理之后的 stack 转成字符串之后相等,就是一样的结果,代码如下:

public class BackDeleteCharacter {
    public static void main(String[] args) {
        String s1 = "AB###CAD#";
        String s2 = "ABC###CA";
        System.out.println(deleteResult(s1));
        System.out.println(isEqual(s1, s2));
    }

    public static boolean isEqual(String s1, String s2) {
        return deleteResult(s1).equals(deleteResult(s2));
    }

    public static String deleteResult(String s) {
        Deque<Character> stack = new ArrayDeque<>();
        for(Character c : s.toCharArray()) {
            if(!c.equals('#')) {
                stack.push(c);
            } else {
                if(!stack.isEmpty())
                    stack.pop();
            }
        }

        // 如果只是判断两个退格后的结果是否相等,可直接返回 stack.toString()
        StringBuilder sb = new StringBuilder();
        for(Character c : stack)
            sb.append(c);
        return sb.reverse().toString();
    }
}

时间复杂度和空间复杂度都是 $O(n)$。