一、从现实世界的"堆叠"说起
你用过浏览器的"后退"按钮吗?你见过编辑器里的"撤销"(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 两个核心操作,但正是这种简洁性赋予了它在无数场景中的关键角色:
- 括号匹配 / 标记配对:HTML/XML 标签验证、代码编辑器语法高亮
- 表达式计算:四则运算器、编译器中的表达式解析
- 深度优先搜索:图的遍历、迷宫求解、回溯算法
- 撤销操作:文本编辑器、图形软件的 Undo/Redo
- 函数调用:JVM 虚拟机栈、递归调用栈
- 浏览历史:浏览器的前进/后退导航
栈的 LIFO 原则是一种"反转顺序"的魔法------先进去的最后出来。当你需要这种"最近的最重要"的语义时,栈就是你工具箱里的第一选择。