JAVA数据结构与算法 - 基础:栈 (Stack) 深度解析

一、从现实世界的"堆叠"说起

你用过浏览器的"后退"按钮吗?你见过编辑器里的"撤销"(Undo)操作吗?你注意过 Java 方法调用时的递归深度吗?这些看似完全不同的功能,背后都依赖同一种数据结构的支撑------栈(Stack)

栈的物理隐喻就是"一摞盘子":你只能从最顶上拿走一个盘子(出栈),也只能在顶上再摞一个新盘子(入栈),中间的盘子你碰不到。这种操作约束就是著名的 LIFO 原则------Last In, First Out(后进先出)

scss 复制代码
          push(3)          push(7)          pop() → 7
┌───┐     ┌───┐            ┌───┐            ┌───┐
│   │     │   │            │ 7 │ ← top      │   │
│   │     │   │            │ 5 │            │ 5 │ ← top
│   │     │ 5 │ ← top      │ 5 │            │ 5 │
│   │     │ 5 │            │ 5 │            │ 5 │
└───┘     └───┘            └───┘            └───┘
 (空栈)    push(5)          push(7)           pop()

栈的接口极其精简:

操作 含义 时间复杂度
push(E) 将元素压入栈顶 O(1)
pop() 弹出栈顶元素并返回 O(1)
peek() 查看栈顶元素但不弹出 O(1)
isEmpty() 判断是否为空栈 O(1)
size() 返回当前元素个数 O(1)

二、栈的底层实现之一:数组实现

使用数组实现栈是最直观的方案。你只需要一个数组 + 一个 "top 指针"(跟踪栈顶位置的下标),难点在于如何处理数组满容量的情况------这需要动态扩容。

java 复制代码
import java.util.Arrays;
import java.util.EmptyStackException;

public class ArrayStack<E> {

    private Object[] elements;   // 存储元素的底层数组
    private int size;            // 当前栈中的元素个数(也即栈顶指针位置)
    private static final int DEFAULT_CAPACITY = 10;

    public ArrayStack() {
        elements = new Object[DEFAULT_CAPACITY];
        size = 0;
    }

    public ArrayStack(int initialCapacity) {
        if (initialCapacity <= 0) {
            throw new IllegalArgumentException("初始容量必须大于 0,给定值: " + initialCapacity);
        }
        elements = new Object[initialCapacity];
        size = 0;
    }

    public int size() {
        return size;
    }

    public boolean isEmpty() {
        return size == 0;
    }

    /**
     * 元素入栈
     * 时间复杂度: 均摊 O(1),扩容时 O(n)
     */
    public void push(E item) {
        ensureCapacity(size + 1);    // 确保有空间
        elements[size] = item;        // 放入栈顶
        size++;                       // 栈顶指针上移
    }

    /**
     * 弹出栈顶元素
     * 时间复杂度: O(1)
     * @throws EmptyStackException 栈为空时抛出
     */
    @SuppressWarnings("unchecked")
    public E pop() {
        if (isEmpty()) {
            throw new EmptyStackException();
        }
        E item = (E) elements[size - 1];
        elements[size - 1] = null;   // 解除引用,帮助 GC 回收
        size--;
        return item;
    }

    /**
     * 查看栈顶元素但不移除
     * @throws EmptyStackException 栈为空时抛出
     */
    @SuppressWarnings("unchecked")
    public E peek() {
        if (isEmpty()) {
            throw new EmptyStackException();
        }
        return (E) elements[size - 1];
    }

    /**
     * 扩容策略:容量翻倍
     * 这与 ArrayList 的扩容策略类似(1.5 倍 v.s. 2 倍),拷贝开销均摊为 O(1)
     */
    private void ensureCapacity(int minCapacity) {
        if (minCapacity > elements.length) {
            int newCapacity = elements.length * 2;
            // 防止溢出 + 最小保证
            if (newCapacity < minCapacity) {
                newCapacity = minCapacity;
            }
            if (newCapacity < 0) {
                newCapacity = Integer.MAX_VALUE;
            }
            elements = Arrays.copyOf(elements, newCapacity);
        }
    }

    @Override
    public String toString() {
        if (isEmpty()) return "[] (空栈)";
        StringBuilder sb = new StringBuilder("[");
        for (int i = 0; i < size; i++) {
            sb.append(elements[i]);
            if (i < size - 1) sb.append(", ");
        }
        sb.append("] ← top");
        return sb.toString();
    }

    // ========== 测试主方法 ==========
    public static void main(String[] args) {
        System.out.println("========== 数组栈------完整测试 ==========\n");

        ArrayStack<Integer> stack = new ArrayStack<>(3);  // 故意设置小容量触发扩容

        System.out.println("--- 入栈测试 ---");
        for (int i = 1; i <= 8; i++) {
            stack.push(i * 10);
            System.out.printf("push(%d) → %s\n", i * 10, stack);
        }

        System.out.println("\n--- 查看栈顶 ---");
        System.out.println("peek() = " + stack.peek());
        System.out.println("栈状态: " + stack);

        System.out.println("\n--- 出栈测试 ---");
        while (!stack.isEmpty()) {
            int val = stack.pop();
            System.out.printf("pop() = %d → %s\n", val, stack);
        }

        System.out.println("\n--- 边界测试:空栈 pop ---");
        try {
            stack.pop();
        } catch (EmptyStackException e) {
            System.out.println("捕获 EmptyStackException: " + e.getMessage());
        }
    }
}

三、栈的底层实现之二:链表实现

数组实现的栈存在扩容问题(尽管均摊是 O(1))。链表实现则天然支持无限扩容------每次 push 只需创建一个新节点并将其链接到链表头。代价是每个元素需要额外的 next 引用开销。

java 复制代码
import java.util.EmptyStackException;

public class LinkedStack<E> {

    /** 内部节点类:单向链表的原子单元 */
    private static class Node<E> {
        E data;
        Node<E> next;

        Node(E data, Node<E> next) {
            this.data = data;
            this.next = next;
        }
    }

    private Node<E> top;   // 栈顶节点(即链表头)
    private int size;      // 元素个数

    public LinkedStack() {
        top = null;
        size = 0;
    }

    public int size() {
        return size;
    }

    public boolean isEmpty() {
        return size == 0;
    }

    /**
     * 入栈:把新元素包装成节点,插入到链表头部
     * 时间复杂度: 严格 O(1),无扩容开销
     *
     * 图示:
     *   push(3): top → [3] → null
     *   push(7): top → [7] → [3] → null
     *   push(2): top → [2] → [7] → [3] → null
     */
    public void push(E item) {
        Node<E> newNode = new Node<>(item, top);
        top = newNode;
        size++;
    }

    /**
     * 出栈:移除链表头节点
     * 时间复杂度: 严格 O(1)
     */
    public E pop() {
        if (isEmpty()) {
            throw new EmptyStackException();
        }
        E data = top.data;
        top = top.next;    // 栈顶下移到下一个节点
        size--;
        return data;
    }

    /**
     * 查看栈顶
     */
    public E peek() {
        if (isEmpty()) {
            throw new EmptyStackException();
        }
        return top.data;
    }

    @Override
    public String toString() {
        if (isEmpty()) return "(空栈)";
        StringBuilder sb = new StringBuilder("top → ");
        Node<E> current = top;
        while (current != null) {
            sb.append("[").append(current.data).append("]");
            if (current.next != null) sb.append(" → ");
            current = current.next;
        }
        return sb.toString();
    }

    // ========== 测试主方法 ==========
    public static void main(String[] args) {
        System.out.println("========== 链表栈------完整测试 ==========\n");

        LinkedStack<String> stack = new LinkedStack<>();

        System.out.println("--- 入栈操作 ---");
        String[] words = {"Java", "Python", "C++", "Rust", "Go"};
        for (String w : words) {
            stack.push(w);
            System.out.println(stack);
        }

        System.out.println();
        System.out.println("当前大小: " + stack.size());
        System.out.println("栈顶元素: " + stack.peek());

        System.out.println("\n--- 出栈操作 ---");
        while (!stack.isEmpty()) {
            System.out.printf("pop() = %-6s → %s\n", stack.pop(), stack);
        }

        // 空栈异常测试
        System.out.println("\n--- 边界:空栈 pop 测试 ---");
        try {
            stack.peek();
        } catch (EmptyStackException e) {
            System.out.println("空栈 peek() 抛出 EmptyStackException ✓");
        }
    }
}

四、经典应用一:括号匹配验证

凡是写代码的人,都见过 IDE 自动高亮匹配括号的功能。这背后的核心算法就是栈。

算法思路:遇到左括号就压栈,遇到右括号就弹栈对比------如果弹出的左括号与当前右括号类型匹配,则继续;任何不匹配或栈提前空或结束时栈未空,都意味着括号不合法。

java 复制代码
import java.util.HashMap;
import java.util.Map;

public class BracketChecker {

    // 右括号 → 对应的左括号映射
    private static final Map<Character, Character> PAIRS = new HashMap<>();

    static {
        PAIRS.put(')', '(');
        PAIRS.put(']', '[');
        PAIRS.put('}', '{');
    }

    public static void main(String[] args) {
        System.out.println("========== 括号匹配验证 ==========\n");

        // 合法用例
        test("()");
        test("()[]{}");
        test("{[]}");
        test("if (a > b) { return [a - b]; }");
        test("((([])))");

        // 非法用例
        test("(");           // 有左括号未闭合
        test(")");           // 右括号多余
        test("([)]");        // 交叉嵌套
        test("(()");         // 未完全闭合
        test("}");           // 首个就是右括号

        // 空串
        test("");
    }

    private static void test(String expression) {
        boolean valid = isValid(expression);
        System.out.printf("  %-35s → %s\n",
                "\"" + (expression.length() > 30
                        ? expression.substring(0, 27) + "..."
                        : expression) + "\"",
                valid ? "合法 ✓" : "非法 ✗");
    }

    /**
     * 使用栈验证括号合法性
     *
     * 时间复杂度: O(n) ------ 每个字符处理一次
     * 空间复杂度: O(n) ------ 最坏情况下全为左括号,全部入栈
     *
     * 三种非法情况的检测:
     *   1. 遇到右括号时栈为空 ------ 右括号多余
     *   2. 栈顶左括号不匹配当前右括号 ------ 类型不匹配
     *   3. 遍历结束后栈不为空 ------ 有左括号未闭合
     */
    public static boolean isValid(String s) {
        java.util.ArrayDeque<Character> stack = new java.util.ArrayDeque<>();

        for (char c : s.toCharArray()) {
            if (PAIRS.containsValue(c)) {
                // 当前字符是左括号,入栈
                stack.push(c);
            } else if (PAIRS.containsKey(c)) {
                // 当前字符是右括号
                if (stack.isEmpty()) {
                    return false;                 // 情况1:没有匹配的左括号
                }
                char top = stack.pop();
                if (top != PAIRS.get(c)) {
                    return false;                 // 情况2:括号类型不匹配
                }
            }
            // 非括号字符(字母、数字、空格等)直接忽略
        }

        return stack.isEmpty();                   // 情况3:所有左括号都必须闭合
    }
}

五、经典应用二:表达式求值

5.1 中缀转后缀(逆波兰表示法)

我们平常写的 3 + 4 × 2 叫做中缀表达式,运算符在操作数中间。计算机处理这种格式非常困难(需要处理优先级和括号),而后缀表达式(逆波兰表示法,RPN)天生适合用栈计算。后缀形式中没有括号,操作符总是在操作数之后出现。

例如:3 + 4 × 2 → 后缀 3 4 2 × + → 计算过程:3 push, 4 push, 2 push, × → 取出 4 和 2 得 8 push, + → 取出 3 和 8 得 11

java 复制代码
import java.util.ArrayDeque;
import java.util.Deque;

public class ExpressionEvaluator {

    public static void main(String[] args) {
        System.out.println("========== 逆波兰表达式计算器 ==========\n");

        String[] tests = {
                "3 4 +",           // 3 + 4 = 7
                "5 1 2 + 4 * + 3 -", // 5 + (1+2)×4 - 3 = 14
                "10 6 9 3 + -11 * / * 17 + 5 +", // 复杂表达式
                "2 3 4 + *",       // 2 × (3+4) = 14
                "8 3 2 * -",       // 8 - 3×2 = 2
        };

        for (String expr : tests) {
            int result = evaluateRPN(expr);
            System.out.printf("  %-25s → %d\n", "\"" + expr + "\"", result);
        }

        // 测试中缀转后缀
        System.out.println("\n--- 中缀表达式 → 后缀表达式 ---");
        String[] infixTests = {
                "3 + 4",
                "3 + 4 * 2",
                "( 1 + 2 ) * ( 3 + 4 )",
                "5 + ( ( 1 + 2 ) * 4 ) - 3",
                "1 + 2 + 3 + 4",
        };

        for (String infix : infixTests) {
            String postfix = infixToPostfix(infix);
            int result = evaluateRPN(postfix);
            System.out.printf("  中缀: %-20s → 后缀: %-20s = %d\n",
                    "\"" + infix + "\"", "\"" + postfix + "\"", result);
        }
    }

    /**
     * 后缀表达式求值
     *
     * 算法:
     *   1. 遇到数字 → 压栈
     *   2. 遇到运算符 → 弹出两个数,运算,结果压栈
     *   3. 遍历完 → 栈中唯一的值就是结果
     *
     * 时间复杂度: O(n)
     * 空间复杂度: O(n)
     */
    public static int evaluateRPN(String expression) {
        Deque<Integer> stack = new ArrayDeque<>();
        String[] tokens = expression.split("\\s+");

        for (String token : tokens) {
            if (isOperator(token)) {
                int b = stack.pop();   // 注意:先弹出的是右操作数!
                int a = stack.pop();
                int result = applyOp(a, b, token);
                stack.push(result);
            } else {
                stack.push(Integer.parseInt(token));
            }
        }
        return stack.pop();
    }

    /**
     * 中缀表达式转后缀表达式(调度场算法 - Shunting-yard Algorithm)
     *
     * 核心规则:
     *   1. 操作数 → 直接输出
     *   2. 左括号 → 压栈
     *   3. 右括号 → 弹栈输出,直到遇到左括号
     *   4. 运算符 → 弹出栈中优先级 >= 当前运算符的所有运算符,然后将当前压栈
     *   5. 遍历完 → 弹出栈中剩余的所有运算符
     *
     * 这本质上是:优先级高的运算符先输出,遇到括号时"延迟"处理
     */
    public static String infixToPostfix(String expression) {
        StringBuilder output = new StringBuilder();
        Deque<String> stack = new ArrayDeque<>();
        String[] tokens = expression.split("\\s+");

        for (String token : tokens) {
            if (token.equals("(")) {
                stack.push(token);
            } else if (token.equals(")")) {
                // 弹栈直到找到左括号
                while (!stack.isEmpty() && !stack.peek().equals("(")) {
                    output.append(stack.pop()).append(" ");
                }
                stack.pop(); // 丢弃左括号
            } else if (isOperator(token)) {
                // 弹出优先级 >= 当前运算符的所有栈顶元素
                while (!stack.isEmpty()
                        && !stack.peek().equals("(")
                        && precedence(stack.peek()) >= precedence(token)) {
                    output.append(stack.pop()).append(" ");
                }
                stack.push(token);
            } else {
                // 操作数:直接输出
                output.append(token).append(" ");
            }
        }

        // 清空栈中剩余的运算符
        while (!stack.isEmpty()) {
            output.append(stack.pop()).append(" ");
        }

        return output.toString().trim();
    }

    private static boolean isOperator(String token) {
        return token.equals("+") || token.equals("-")
                || token.equals("*") || token.equals("/");
    }

    private static int precedence(String op) {
        if (op.equals("+") || op.equals("-")) return 1;
        if (op.equals("*") || op.equals("/")) return 2;
        return 0;
    }

    private static int applyOp(int a, int b, String op) {
        switch (op) {
            case "+": return a + b;
            case "-": return a - b;
            case "*": return a * b;
            case "/":
                if (b == 0) throw new ArithmeticException("除数不能为零");
                return a / b;
            default:
                throw new IllegalArgumentException("未知运算符: " + op);
        }
    }
}

六、经典应用三:DFS 深度优先搜索与栈

深度优先搜索(DFS)天然具有"先进入的分支后处理"的特性------这正是栈的 LIFO 语义。在任何需要 DFS 的场景下,你都可以使用显式的栈来替代递归调用,从而避免递归栈溢出、降低函数调用开销。

下面是一个使用栈实现的迷宫寻路算法:

java 复制代码
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;

public class MazeSolver {

    private static final int[][] DIRECTIONS = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};

    public static void main(String[] args) {
        System.out.println("========== 栈实现迷宫寻路 (DFS) ==========\n");

        int[][] maze = {
                {1, 0, 1, 1, 1},
                {1, 0, 1, 0, 1},
                {1, 1, 1, 0, 1},
                {0, 0, 0, 0, 1},
                {1, 1, 1, 1, 1}
        };
        System.out.println("迷宫 (1=通路, 0=墙壁):");
        printMaze(maze);

        int[] start = {0, 0};
        int[] end = {4, 4};
        List<int[]> path = findPath(maze, start, end);

        if (path != null) {
            System.out.println("\n找到路径!共 " + path.size() + " 步:");
            for (int[] pos : path) {
                System.out.printf("  → (%d, %d)\n", pos[0], pos[1]);
            }
        } else {
            System.out.println("\n无路径可达!");
        }
    }

    /**
     * 使用栈实现 DFS 迷宫寻路
     *
     * 替代递归的优势:
     *   1. 显式控制栈大小,不会因递归过深而 StackOverflow
     *   2. 每个节点保存在栈中,可以完整打印路径
     *   3. 更易扩展为迭代加深等其他变体
     */
    public static List<int[]> findPath(int[][] maze, int[] start, int[] end) {
        int rows = maze.length;
        int cols = maze[0].length;
        boolean[][] visited = new boolean[rows][cols];

        // 栈中存储的是"当前节点"和"从起点到当前节点的路径"
        Deque<NodeWithPath> stack = new ArrayDeque<>();
        List<int[]> initialPath = new ArrayList<>();
        initialPath.add(start);
        stack.push(new NodeWithPath(start[0], start[1], initialPath));

        while (!stack.isEmpty()) {
            NodeWithPath current = stack.pop();
            int x = current.x;
            int y = current.y;

            // 到达终点
            if (x == end[0] && y == end[1]) {
                return current.path;
            }

            // 跳过无效或已访问的格子
            if (x < 0 || x >= rows || y < 0 || y >= cols
                    || maze[x][y] == 0 || visited[x][y]) {
                continue;
            }

            visited[x][y] = true;

            // 四个方向探查
            for (int[] dir : DIRECTIONS) {
                int nx = x + dir[0];
                int ny = y + dir[1];
                if (nx >= 0 && nx < rows && ny >= 0 && ny < cols
                        && maze[nx][ny] == 1 && !visited[nx][ny]) {
                    List<int[]> newPath = new ArrayList<>(current.path);
                    newPath.add(new int[]{nx, ny});
                    stack.push(new NodeWithPath(nx, ny, newPath));
                }
            }
        }

        return null; // 无解
    }

    /** 内部类:存储节点坐标及从起点到该节点的完整路径 */
    private static class NodeWithPath {
        int x, y;
        List<int[]> path;

        NodeWithPath(int x, int y, List<int[]> path) {
            this.x = x;
            this.y = y;
            this.path = path;
        }
    }

    private static void printMaze(int[][] maze) {
        for (int[] row : maze) {
            for (int cell : row) {
                System.out.print(cell == 1 ? " □ " : " ■ ");
            }
            System.out.println();
        }
    }
}

七、JDK 内置栈对比

Java 提供了三种与栈相关的标准实现:

实现 底层结构 线程安全 推荐程度 备注
java.util.Stack 数组 (extends Vector) 是 (synchronized) 不推荐 设计不良,继承 Vector 打破了封装
ArrayDeque 循环数组 强烈推荐 push/pop 方法提供栈语义,性能最优
LinkedList 双向链表 可选 每个元素有额外节点开销
java 复制代码
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.LinkedList;
import java.util.Stack;

public class JDKStackComparison {

    public static void main(String[] args) {
        System.out.println("========== JDK 三种栈实现对比 ==========\n");

        // 传统 Stack(不推荐)
        Stack<Integer> legacyStack = new Stack<>();
        legacyStack.push(1);
        legacyStack.push(2);
        legacyStack.push(3);
        System.out.println("Stack  (extends Vector): pop=" + legacyStack.pop());

        // ArrayDeque 作为栈(推荐)
        Deque<Integer> arrayStack = new ArrayDeque<>();
        arrayStack.push(10);
        arrayStack.push(20);
        arrayStack.push(30);
        System.out.println("ArrayDeque as Stack:      pop=" + arrayStack.pop());

        // LinkedList 作为栈
        Deque<Integer> linkedStack = new LinkedList<>();
        linkedStack.push(100);
        linkedStack.push(200);
        linkedStack.push(300);
        System.out.println("LinkedList as Stack:      pop=" + linkedStack.pop());

        System.out.println("\n三种都遵循 LIFO: 最后一个 push 的最先 pop");
        System.out.println("官方建议: 用 Deque 接口 + ArrayDeque 实现替代 Stack 类");
    }
}

八、栈在 JVM 中的角色

栈不仅仅是应用层的数据结构,它还是 JVM 运行时的核心基础设施 。每个线程在 JVM 中都有一个专属的虚拟机栈(VM Stack) ,每个方法调用都会创建一个栈帧压入此栈:

css 复制代码
│  栈帧: methodC()      │ ← 当前执行的方法
│    局部变量表          │
│    操作数栈            │
├───────────────────────┤
│  栈帧: methodB()      │ ← 调用者
├───────────────────────┤
│  栈帧: methodA()      │ ← 更上层的调用者
├───────────────────────┤
│  栈帧: main()         │ ← 入口
└───────────────────────┘

这就是为什么递归过深会导致 StackOverflowError------每个线程的虚拟机栈大小是有限制的(默认约 1MB),当栈帧数量超过这个限制时,JVM 就"爆栈"了。

理解这一点能让你从根本上理解以下现象:

  • 递归可以改写为迭代(用显式栈替代调用栈),从而避免 StackOverflowError
  • 尾递归优化(部分语言支持)本质上是复用同一个栈帧
  • try-catch 的异常处理依赖于调用栈回溯

九、总结

栈是一种"少即是多"的数据结构------它的接口只有 push 和 pop 两个核心操作,但正是这种简洁性赋予了它在无数场景中的关键角色:

  1. 括号匹配 / 标记配对:HTML/XML 标签验证、代码编辑器语法高亮
  2. 表达式计算:四则运算器、编译器中的表达式解析
  3. 深度优先搜索:图的遍历、迷宫求解、回溯算法
  4. 撤销操作:文本编辑器、图形软件的 Undo/Redo
  5. 函数调用:JVM 虚拟机栈、递归调用栈
  6. 浏览历史:浏览器的前进/后退导航

栈的 LIFO 原则是一种"反转顺序"的魔法------先进去的最后出来。当你需要这种"最近的最重要"的语义时,栈就是你工具箱里的第一选择。

相关推荐
xiguolangzi3 小时前
java使用Map映射遍历方法
java·后端
日月云棠3 小时前
JAVA数据结构与算法 - 基础:队列 (Queue) 全方位解析
java·后端
JAVA面经实录9174 小时前
Java集合大全终极手册(一)
java·开发语言
IT策士4 小时前
Django 从 0 到 1 打造完整电商平台:为什么用 Django 做电商?
后端·python·django
Cosolar4 小时前
吃透 Spring Cloud Gateway:基于 Spring Boot 3 的核心原理、企业级实战与避坑指南
java·spring cloud·架构
千里马-horse4 小时前
gRPC -- Java 基础教程
java·开发语言·grpc
甲方大人请饶命4 小时前
Java-面向对象进阶(qqbb知识点)
java·开发语言
ChoSeitaku4 小时前
07_static_JavaBean_继承_super/this
java·开发语言
江南十四行4 小时前
并发编程(一)
java·jvm·算法