上周同事小王找我 debug,说他的递归方法一直报
StackOverflowError,我一看代码------递归终止条件写反了。我说:"老弟,你这是栈溢出了。"他一脸茫然:"栈?什么栈?Java 里不就有个Stack类吗?"很多 Java 程序员天天写代码,
Stack类倒也用过,JVM 栈溢出也见过,但真要系统地说清楚"栈到底是什么、它凭什么这么重要、它在 Java 世界里有多少种存在形式",可能就支支吾吾了。这篇文章就是重点把"栈"这件事彻底搞明白的。我们不讲枯燥的理论,不写晦涩的数学公式,就从你每天都在用的功能出发:为什么按 Ctrl+Z 能撤销?为什么浏览器能后退?为什么递归太深会 StackOverflow?------读完你会发现,栈无处不在,而你已经用了它很多年,只是不认识它而已。
一、先从一个生活场景说起------叠盘子与"后入先出"
在正式写代码之前,我想请你想象一个场景:
你去自助餐厅吃饭,后厨洗完的盘子是这样叠放的:
↑ 新的盘子放最上面(入栈)
┌───┐
│ 5 │ ← 最后一个放上去的
│ 4 │
│ 3 │
│ 2 │
│ 1 │ ← 第一个放上去的
└───┘
↓ 取盘子也从最上面取(出栈)
规则只有一条:你只能从最上面拿盘子。你不能从中间抽一个,也不能从最底下掏一个。
这就意味着:最后一个放上去的盘子,一定是第一个被拿走的 。英文里叫 LIFO(Last In, First Out),中文叫"后进先出"。
栈的 LIFO 特性不是人为规定的,而是"只能在一端操作"这个限制自然导致的结果。就像你没法从盘子堆的中间抽出一个盘子而不把上面的全弄倒------这不是规矩,是物理规律。
叠盘子的规则搞懂了,栈你也就懂了 70%。下面我们把它翻译成计算机语言。
二、栈的基本操作:就"三招两式"
栈是数据结构里最简单的之一------操作只有三种,一手数得过来:
2.1 核心三操作
| 操作 | 英文名 | 大白话解释 | 图解 |
|---|---|---|---|
| 入栈 | push |
把元素放到栈顶 | 往盘子堆顶上再摞一个 |
| 出栈 | pop |
把栈顶元素取出来(并删除) | 从盘子堆顶上拿走一个 |
| 查看栈顶 | peek/top |
看一眼栈顶元素是什么(不删除) | 瞅一眼最上面的盘子,但不拿走 |
java
// 伪代码示意
Stack stack = new Stack();
stack.push(1); // 栈:[1]
stack.push(2); // 栈:[1, 2]
stack.push(3); // 栈:[1, 2, 3] ← 3 在栈顶
stack.peek(); // 返回 3 ← 只是看看,栈不变
stack.pop(); // 返回 3 ← 拿走栈顶,栈:[1, 2]
stack.pop(); // 返回 2 ← 栈:[1]
2.2 两个辅助操作
| 操作 | 英文名 | 大白话解释 |
|---|---|---|
| 判空 | isEmpty |
栈里还有东西吗? |
| 查大小 | size |
栈里现在有几个元素? |
就这么多。没了。栈就是这么朴素的家伙------不像树有一堆术语,不像图有各种遍历算法。正因为简单,所以它在软件工程中无处不在。
好了,概念讲完了。接下来我们动真格的------用 Java 亲手实现一个栈。自己写过一遍,比什么记忆法都好使。
三、用 Java 徒手撸一个栈:写过才真正理解
栈有两种主流实现方式:数组 和链表。两种各有优劣,咱们各写一个。
3.1 数组实现(顺序栈)
java
public class ArrayStack<T> {
private Object[] elements; // 存储元素的数组
private int top; // 栈顶指针(指向栈顶元素的位置)
private static final int DEFAULT_CAPACITY = 10;
public ArrayStack() {
this.elements = new Object[DEFAULT_CAPACITY];
this.top = -1; // -1 表示空栈
}
// 入栈
public void push(T item) {
if (top == elements.length - 1) {
resize(); // 满了就扩容
}
elements[++top] = item; // 先移动指针,再放入元素
}
// 出栈
@SuppressWarnings("unchecked")
public T pop() {
if (isEmpty()) {
throw new EmptyStackException();
}
T item = (T) elements[top];
elements[top] = null; // 防止内存泄漏
top--;
return item;
}
// 查看栈顶
@SuppressWarnings("unchecked")
public T peek() {
if (isEmpty()) {
throw new EmptyStackException();
}
return (T) elements[top];
}
public boolean isEmpty() {
return top == -1;
}
public int size() {
return top + 1;
}
// 扩容为原来的 2 倍
private void resize() {
elements = Arrays.copyOf(elements, elements.length * 2);
}
}
图解数组栈的工作过程:
初始状态: push(10) 后: push(20) 后: pop() 后:
┌───┬───┬───┬───┐ ┌───┬───┬───┬───┐ ┌───┬───┬───┬───┐ ┌───┬───┬───┬───┐
│ │ │ │ │ │10 │ │ │ │ │10 │20 │ │ │ │10 │20 │ │ │
└───┴───┴───┴───┘ └───┴───┴───┴───┘ └───┴───┴───┴───┘ └───┴───┴───┴───┘
top = -1 top = 0 top = 1 top = 0
(20 被取出)
3.2 链表实现(链式栈)
java
public class LinkedStack<T> {
// 节点内部类
private static class Node<T> {
T data;
Node<T> next;
Node(T data, Node<T> next) {
this.data = data;
this.next = next;
}
}
private Node<T> top; // 栈顶节点
private int size;
// 入栈:在链表头部插入
public void push(T item) {
top = new Node<>(item, top); // 新节点指向旧栈顶
size++;
}
// 出栈:从链表头部删除
public T pop() {
if (isEmpty()) {
throw new EmptyStackException();
}
T item = top.data;
top = top.next; // 栈顶指针后移
size--;
return item;
}
// 查看栈顶
public T peek() {
if (isEmpty()) {
throw new EmptyStackException();
}
return top.data;
}
public boolean isEmpty() {
return top == null;
}
public int size() {
return size;
}
}
链表实现中,
push和pop都在链表头部 操作,时间复杂度都是 O(1)。注意:是头部而不是尾部------如果你每次push都要遍历到链表尾部,那时间复杂度就变成 O(n) 了,这是初学者常犯的错误。
3.3 两种实现的对比
| 维度 | 数组实现 | 链表实现 |
|---|---|---|
| 访问速度 | 快(连续内存,缓存友好) | 稍慢(节点分散,缓存不友好) |
| 动态扩容 | 需要,扩容时有 O(n) 开销 | 天然动态,随用随加 |
| 内存占用 | 可能浪费(预留空间) | 每个节点多一个指针开销 |
| 线程安全 | 都不安全 | 都不安全 |
| 适用场景 | 数据量可预估 | 数据量不确定 |
Java 标准库里的
Stack类用的是数组(继承了Vector),而ArrayDeque(推荐用来替代 Stack)也是用循环数组实现的。下面会详细讲。
自己手写了两个栈,你应该对栈的底层原理了然于胸了。但写项目时没人会手写栈------接下来我们看看 Java 标准库里给我们准备了什么。
四、Java 集合框架中的栈:Stack 与 Deque 的"新旧交替"
4.1 Stack 类------一个不应该再用的"老古董"
java.util.Stack 是 Java 1.0 就有的类,直接继承自 Vector:
java
public class Stack<E> extends Vector<E> {
public E push(E item) { ... }
public synchronized E pop() { ... }
public synchronized E peek() { ... }
public boolean empty() { ... }
public synchronized int search(Object o) { ... }
}
问题很大:
- 继承 Vector :Stack 是 LIFO 的,但继承了 Vector 以后,你可以调用
get(index)从中间取元素、add(index, e)往中间插入------完全破坏了栈的语义。 - 所有方法都是
synchronized:不管你需不需要线程安全,它都给你加锁,性能差。 - 官方都不推荐了 :Java 官方文档明确说"应该使用
Deque接口来替代"。
java
// 反例:虽然编译通过,但完全违背了栈的设计初衷
Stack<Integer> stack = new Stack<>();
stack.push(1);
stack.push(2);
stack.add(1, 99); // 往栈中间插入!这不是栈该有的操作!
stack.get(0); // 从栈底取元素!这也行?!
4.2 Deque------官方推荐的"栈替身"
Deque(Double Ended Queue,双端队列)是 Java 6 引入的接口,你可以把它当成栈用,也可以当成队列用:
java
// 把 Deque 当栈用
Deque<Integer> stack = new ArrayDeque<>();
stack.push(1); // 入栈
stack.push(2); // 入栈
stack.push(3); // 入栈
stack.pop(); // 出栈,返回 3
stack.peek(); // 查看栈顶,返回 2
| 操作 | Stack 类的方法 | Deque 当作栈用的方法 | Deque 当作队列用的方法 |
|---|---|---|---|
| 入栈 / 入队 | push(e) |
push(e) 或 addFirst(e) |
offer(e) 或 addLast(e) |
| 出栈 / 出队 | pop() |
pop() 或 removeFirst() |
poll() 或 removeFirst() |
| 查看 / 查看 | peek() |
peek() 或 peekFirst() |
peek() 或 peekFirst() |
4.3 ArrayDeque 和 LinkedList,选哪个?
ArrayDeque 和 LinkedList 都实现了 Deque 接口,都可以当栈用:
java
Deque<String> stack1 = new ArrayDeque<>(); // 推荐
Deque<String> stack2 = new LinkedList<>(); // 也可以,但不够好
| 维度 | ArrayDeque | LinkedList |
|---|---|---|
| 底层结构 | 循环数组 | 双向链表 |
| 内存占用 | 更低(无节点开销) | 更高(每个 Node 多两个指针) |
| 扩容开销 | 需要扩容(翻倍) | 无 |
| 缓存友好度 | 高(连续内存) | 低(内存分散) |
| 支持 null? | ❌ 不支持 | ✅ 支持 |
推荐做法 :当栈用时,永远用
ArrayDeque。除非你需要频繁在栈中间操作元素(但这本身就不是栈的用法),否则LinkedList没有优势。
java
// 标准姿势
Deque<Integer> stack = new ArrayDeque<>();
// 面试时如果你还在写 Stack<Integer> stack = new Stack<>()
// 面试官可能会在心里默默扣分
好了,栈的基础知识和 Java 实现都讲清楚了。下面才是最精彩的部分------栈到底能干什么?你平时用的哪些功能,背后都是栈在默默工作?
五、栈的经典应用场景------原来这些功能都是它干的
5.1 浏览器前进后退------双栈协作
你用浏览器时一定点过"后退"和"前进"按钮。这个功能就是两个栈 实现的:
java
public class BrowserHistory {
private Deque<String> backStack = new ArrayDeque<>();
private Deque<String> forwardStack = new ArrayDeque<>();
private String currentUrl;
// 访问新页面
public void visit(String url) {
if (currentUrl != null) {
backStack.push(currentUrl); // 当前页进后退栈
}
currentUrl = url;
forwardStack.clear(); // 前进栈清空(新页面后不能前进了)
}
// 后退
public String back() {
if (backStack.isEmpty()) return null;
forwardStack.push(currentUrl); // 当前页进前进栈
currentUrl = backStack.pop(); // 从后退栈取上一个页面
return currentUrl;
}
// 前进
public String forward() {
if (forwardStack.isEmpty()) return null;
backStack.push(currentUrl); // 当前页进后退栈
currentUrl = forwardStack.pop(); // 从前进栈取下一个页面
return currentUrl;
}
}
5.2 撤销操作(Undo/Redo)------写代码最离不开的功能
你写代码时狂按 Ctrl+Z 的时候,背后就是一个栈在帮你记住每一步操作:
java
public class TextEditor {
private Deque<String> undoStack = new ArrayDeque<>();
private Deque<String> redoStack = new ArrayDeque<>();
private String text = "";
// 执行操作前保存快照
public void type(String newText) {
undoStack.push(text); // 旧状态入栈
text = newText;
redoStack.clear(); // 新操作后 redo 栈清空
}
// Ctrl+Z
public String undo() {
if (undoStack.isEmpty()) return text;
redoStack.push(text); // 当前状态进 redo 栈
text = undoStack.pop(); // 恢复上一个状态
return text;
}
// Ctrl+Y
public String redo() {
if (redoStack.isEmpty()) return text;
undoStack.push(text); // 当前状态进 undo 栈
text = redoStack.pop(); // 恢复下一个状态
return text;
}
}
5.3 括号匹配------IDEA 的自动括号高亮就是它
你写代码时,IDEA 会自动把匹配的括号高亮出来。这个功能的底层算法就是用栈做的:
java
public class BracketMatcher {
public static boolean isValid(String s) {
Deque<Character> stack = new ArrayDeque<>();
for (char c : s.toCharArray()) {
if (c == '(' || c == '[' || c == '{') {
stack.push(c); // 左括号入栈
} else {
if (stack.isEmpty()) return false; // 没有配对的左括号
char top = stack.pop(); // 弹出栈顶左括号
if ((c == ')' && top != '(') ||
(c == ']' && top != '[') ||
(c == '}' && top != '{')) {
return false; // 类型不匹配
}
}
}
return stack.isEmpty(); // 栈为空说明全部配对成功
}
public static void main(String[] args) {
System.out.println(isValid("()[]{}")); // true
System.out.println(isValid("([)]")); // false
System.out.println(isValid("{[()]}")); // true
}
}
图解算法过程 (以 "{[()]}" 为例):
这个算法是 LeetCode 20,面试出镜率极高。建议你自己手写三遍,直到不看模板也能写出来。
5.4 表达式求值------计算器背后的"中缀转后缀"
你在电脑上敲 3 + 4 × 2,计算器得出 11 而不是 14------这是因为它先算乘除再算加减。这个"优先级处理"就是用栈来实现的。
这个过程分两步:中缀表达式 → 后缀表达式 → 计算。
java
public class ExpressionEvaluator {
// 计算后缀表达式(逆波兰表达式)
public static int evalRPN(String[] tokens) {
Deque<Integer> stack = new ArrayDeque<>();
for (String token : tokens) {
switch (token) {
case "+" -> stack.push(stack.pop() + stack.pop());
case "-" -> {
int b = stack.pop();
int a = stack.pop();
stack.push(a - b); // 注意顺序
}
case "*" -> stack.push(stack.pop() * stack.pop());
case "/" -> {
int b = stack.pop();
int a = stack.pop();
stack.push(a / b); // 注意顺序
}
default -> stack.push(Integer.parseInt(token));
}
}
return stack.pop();
}
public static void main(String[] args) {
// "3 + 4 × 2" 的后缀表达式:["3", "4", "2", "*", "+"]
System.out.println(evalRPN(new String[]{"3", "4", "2", "*", "+"}));
// 输出:11
}
}
计算过程图:
3 4 2 * +
│ │ │ │ │
↓ ↓ ↓ ↓ ↓
3 4 2 → 读到 *:弹出 2 和 4,计算 4×2=8,推回 8
3 8 → 读到 +:弹出 8 和 3,计算 3+8=11,推回 11
11 → 最终结果
5.5 深度优先搜索(DFS)------树的三种遍历全靠栈
二叉树的先序、中序、后序遍历,递归写法确实简单,但面试官通常还会追问一句:"你能用迭代的方式写吗?"
迭代版的二叉树遍历,核心就是手动用一个栈模拟递归:
java
public class TreeTraversal {
// 迭代版中序遍历(左-根-右)
public List<Integer> inorderTraversal(TreeNode root) {
List<Integer> result = new ArrayList<>();
Deque<TreeNode> stack = new ArrayDeque<>();
TreeNode current = root;
while (current != null || !stack.isEmpty()) {
// 一路向左,沿途全部入栈
while (current != null) {
stack.push(current);
current = current.left;
}
// 左到底了,弹出栈顶(根),然后转向右子树
current = stack.pop();
result.add(current.val);
current = current.right;
}
return result;
}
}
这些应用场景看下来,你应该能感受到------栈虽然结构简单,但在实际开发中的应用场景非常多。面试中这些都是高频考点,尤其是括号匹配和表达式求值,几乎是栈相关题目的"标准题库"。
好,接下来我们往深里走一层------栈不仅在应用层面有用,在 JVM 底层,它更是 Java 程序运行的生命线。
六、深入 JVM:每个 Java 线程都背着一条"栈龙"
6.1 JVM 中的栈是什么?
JVM 的内存模型中,每个线程都有自己独享 的一块内存区域,叫作 Java 虚拟机栈(Java Virtual Machine Stack) 。
6.2 栈帧:方法的"运行档案"
每当你调用一个方法,JVM 就在虚拟机栈里创建一个栈帧(Stack Frame),把它压入栈顶。方法执行完,这个栈帧就从栈顶弹出。
main() 调用 methodA() 调用 methodB() 调用
┌──────┐ ┌──────┐ ┌──────┐
│main │ │ A │ │ B │
│栈帧 │ │栈帧 │ │栈帧 │
└──────┘ │main │ │ A │
│栈帧 │ │栈帧 │
└──────┘ │main │
│栈帧 │
└──────┘
methodB() 返回 methodA() 返回 main() 返回
┌──────┐ ┌──────┐ ┌──────┐
│ A │ │main │ │ │
│栈帧 │ │栈帧 │ │ 空 │
│main │ └──────┘ └──────┘
│栈帧 │
└──────┘
每个栈帧包含四部分:
| 组成部分 | 存了什么 | 大白话 |
|---|---|---|
| 局部变量表(Local Variables) | 方法参数 + 方法内的局部变量 | 方法的"私人物品清单" |
| 操作数栈(Operand Stack) | 计算过程中的中间数据 | 方法的"草稿纸" |
| 动态链接(Dynamic Linking) | 指向运行时常量池中该方法的引用 | 方法的"身份证" |
| 返回地址(Return Address) | 方法执行完后回到哪里 | 方法的"回家地址" |
来一段代码感受一下:
java
public class StackFrameDemo {
public static void main(String[] args) {
int a = 10; // main 的局部变量
int result = add(a, 20); // 调用 add,创建 add 的栈帧
System.out.println(result);
} // main 的栈帧销毁
public static int add(int x, int y) {
int sum = x + y; // add 的局部变量
return sum;
} // add 的栈帧销毁,返回 main
}
执行过程:
main 开始执行: add 被调用: add 返回后:
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ main 栈帧 │ │ add 栈帧 │ ← 栈顶 │ main 栈帧 │
│ a = 10 │ │ x = 10 │ │ a = 10 │
│ result = ? │ │ y = 20 │ │ result = 30 │
└──────────────┘ │ sum = 30 │ └──────────────┘
├──────────────┤
│ main 栈帧 │
└──────────────┘
6.3 StackOverflowError------每个 Java 人都见过的红色
现在你应该能理解这个经典错误了:
java
public void recursive() {
recursive(); // 没有终止条件,无限递归
}
// java.lang.StackOverflowError
原因 :每次调用 recursive() 都会创建一个新的栈帧压入虚拟机栈。栈的容量是有限的(默认约 1MB),当栈帧数量超过栈的容量,就抛出 StackOverflowError。
// 可以通过 JVM 参数调整栈大小
// -Xss256k(设置每个线程的栈大小为 256KB)
// -Xss2m (设置每个线程的栈大小为 2MB)
面试常见追问 :
StackOverflowError和OutOfMemoryError有什么区别?
StackOverflowError:栈空间不够用,通常是递归太深导致的。OutOfMemoryError: Java heap space:堆空间不够用,通常是创建了太多对象。- 关键区别:一个发生在栈(线程私有),一个发生在堆(线程共享)。
JVM 的栈原理讲完了。你可能已经发现------JVM 的栈和数据结构里的栈,本质上就是同一个东西在不同层面的应用。下面我们聊一个与之密切相关的话题:递归。
七、栈与递归:你不写栈,但栈无处不在
7.1 递归本质上就是栈
写过递归的同学都知道,递归代码通常很简洁,但背后 JVM 帮你做了一堆事情------每次递归调用都创建一个新栈帧。
以经典问题------反转单链表为例:
java
// 递归版:优雅但暗藏玄机
public ListNode reverseList(ListNode head) {
if (head == null || head.next == null) {
return head; // 递归终止条件
}
ListNode newHead = reverseList(head.next); // 递归深入
head.next.next = head; // 反转指针
head.next = null;
return newHead; // 层层返回
}
递归调用栈的过程(反转链表 1→2→3→null):
java
reverseList(1): newHead = reverseList(2) 等待中...
reverseList(2): newHead = reverseList(3) 等待中...
reverseList(3): 返回 3 ← 触底反弹
reverseList(2): 3.next = 2, 2.next = null 返回 3
reverseList(1): 2.next = 1, 1.next = null 返回 3
7.2 递归的隐患与解法
递归虽然优雅,但有两大问题:
- 栈溢出:深度大了就 StackOverflow
- 重复计算 :斐波那契递归是最经典的例子,
fib(5)会重复计算fib(3)两次、fib(2)三次......
java
// 递归版斐波那契:时间复杂度 O(2^n),极其低效
public int fib(int n) {
if (n <= 1) return n;
return fib(n - 1) + fib(n - 2);
}
// 迭代版(用栈思想但不递归):时间复杂度 O(n)
public int fib(int n) {
if (n <= 1) return n;
int a = 0, b = 1;
for (int i = 2; i <= n; i++) {
int sum = a + b;
a = b;
b = sum;
}
return b;
}
经验法则:能用迭代解决的问题尽量用迭代。递归只在"分治"场景(如树的遍历、归并排序)才真正有优势。别为了代码短而滥用递归。
理论知识学得差不多了。下面我们转入"面试实战模式"------把前面学的东西变成面试官眼中的高分答案。
八、面试高频考题实战------把知识变成分数
8.1 最小栈(Min Stack)------LeetCode 155
题目 :设计一个栈,支持
push、pop、top操作,并能在 O(1) 时间内获取栈中最小值。
这个题的核心技巧是用两个栈:一个正常存数据,一个存"当前最小值"。
java
class MinStack {
private Deque<Integer> dataStack; // 存数据
private Deque<Integer> minStack; // 存每个阶段的最小值
public MinStack() {
dataStack = new ArrayDeque<>();
minStack = new ArrayDeque<>();
}
public void push(int val) {
dataStack.push(val);
// 如果 minStack 为空,或 val 比当前最小值更小,则入 minStack
if (minStack.isEmpty() || val <= minStack.peek()) {
minStack.push(val);
} else {
minStack.push(minStack.peek()); // 重复压入当前最小值
}
}
public void pop() {
dataStack.pop();
minStack.pop(); // 同步弹出
}
public int top() {
return dataStack.peek();
}
public int getMin() {
return minStack.peek();
}
}
图解:
push(5) push(2) push(3) push(1) pop()
dataStack dataStack dataStack dataStack dataStack
┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐
│ 5 │ │ 2 │ │ 3 │ │ 1 │ │ 3 │
└───┘ │ 5 │ │ 2 │ │ 2 │ │ 2 │
└───┘ │ 5 │ │ 5 │ │ 5 │
└───┘ └───┘ └───┘
minStack minStack minStack minStack minStack
┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐
│ 5 │ │ 2 │ │ 2 │ │ 1 │ │ 2 │
└───┘ │ 5 │ │ 2 │ │ 2 │ │ 2 │
└───┘ │ 5 │ │ 5 │ │ 5 │
└───┘ └───┘ └───┘
getMin()=5 getMin()=2 getMin()=2 getMin()=1 getMin()=2
8.2 用栈实现队列------LeetCode 232
题目 :用两个栈实现一个队列,支持
push、pop、peek、empty操作。
栈是 LIFO,队列是 FIFO。两个栈配合,可以翻转两次,负负得正。
java
class MyQueue {
private Deque<Integer> inStack; // 输入栈(push 用)
private Deque<Integer> outStack; // 输出栈(pop/peek 用)
public MyQueue() {
inStack = new ArrayDeque<>();
outStack = new ArrayDeque<>();
}
public void push(int x) {
inStack.push(x); // 直接往入栈放
}
public int pop() {
if (outStack.isEmpty()) {
transfer(); // outStack 空了就从 inStack 倒过来
}
return outStack.pop();
}
public int peek() {
if (outStack.isEmpty()) {
transfer();
}
return outStack.peek();
}
public boolean empty() {
return inStack.isEmpty() && outStack.isEmpty();
}
// 关键操作:把 inStack 的元素全部倒入 outStack
private void transfer() {
while (!inStack.isEmpty()) {
outStack.push(inStack.pop());
}
}
}
核心图解:
push(1) → push(2) → push(3): 第一次 pop():
inStack outStack inStack → transfer → outStack
┌───┐ ┌───┐ ┌───┐ ┌───┐
│ 3 │ │ │ │ │ │ 1 │ ← pop 出 1
│ 2 │ │ │ │ │ │ 2 │
│ 1 │ │ │ │ │ │ 3 │
└───┘ └───┘ └───┘ └───┘
(栈顶是3,先进后出) (栈顶是1,先进先出!)
复杂度 :虽然单次
pop可能是 O(n),但均摊时间复杂度是 O(1)------每个元素只会被 transfer 一次。面试时能说出"均摊 O(1)"是一个加分点。
8.3 有效的括号------LeetCode 20
这道题在 5.3 节已经讲过,这里补充几个要点:
- 时间复杂度 O(n),只需遍历一遍字符串
- 空间复杂度 O(n),最坏情况下全是左括号,全入栈
- 面试官可能追问:如果括号类型扩展到 4 种、5 种怎么办? ------代码不改逻辑,只需增加判断条件。用
HashMap存储配对关系更优雅:
java
private static final Map<Character, Character> PAIRS = Map.of(
')', '(',
']', '[',
'}', '{'
);
8.4 每日温度------LeetCode 739(单调栈经典题)
题目:给定一个温度数组,对于每一天,需要等多少天才能遇到更高的温度。如果之后没有更高的温度,记为 0。
这道题是单调栈的经典应用:
java
public int[] dailyTemperatures(int[] temperatures) {
int n = temperatures.length;
int[] result = new int[n];
Deque<Integer> stack = new ArrayDeque<>(); // 存下标,栈底→栈顶递减
for (int i = 0; i < n; i++) {
// 当前温度比栈顶对应的温度高 → 找到栈顶那天之后第一个高温
while (!stack.isEmpty() && temperatures[i] > temperatures[stack.peek()]) {
int prevDay = stack.pop();
result[prevDay] = i - prevDay;
}
stack.push(i);
}
return result; // 栈里剩下的都是后面没有更高温度的
}
java
temperatures = [73, 74, 75, 71, 69, 72, 76, 73]
result = [ 1, 1, 4, 2, 1, 1, 0, 0]
图解:
i=0(73): push(0) 栈:[0]
i=1(74): 74>73 → pop(0), result[0]=1, push(1) 栈:[1]
i=2(75): 75>74 → pop(1), result[1]=1, push(2) 栈:[2]
i=3(71): 71<75 → push(3) 栈:[2,3]
i=4(69): 69<71 → push(4) 栈:[2,3,4]
i=5(72): 72>69 → pop(4), result[4]=1
72>71 → pop(3), result[3]=2
72<75 → push(5) 栈:[2,5]
...
单调栈的特点:栈内元素始终保持单调递增或单调递减。一旦遇到破坏单调性的元素,就弹出栈顶来处理。这类题目还有"接雨水"(LeetCode 42)、"柱状图中最大矩形"(LeetCode 84)等,套路类似。
面试题也刷完了,最后我们来一个"串珠子"式的总结------把今天聊的所有内容串成一条线。
九、总结:栈很小,世界很大
让我们回头看一眼这趟"栈之旅":
java
生活中的栈 → 叠盘子、子弹夹
↓
数据结构中的栈 → LIFO、push/pop/peek,两种实现(数组 / 链表)
↓
Java 中的栈 → 别用 Stack 类,用 Deque + ArrayDeque
↓
栈的应用 → 浏览器前进后退、Ctrl+Z 撤销、括号匹配、表达式求值
↓
JVM 中的栈 → 方法调用栈帧、局部变量表、操作数栈
↓
栈的"翻车"现场 → StackOverflowError、递归过深
↓
面试中的栈 → 最小栈、双栈模拟队列、单调栈
栈可能是所有数据结构里最简单的一个------没有旋转、没有平衡、没有复杂的遍历算法。但正是这种极致的简单,让它成为了计算机科学中最基础、最通用的工具之一。
从你按下 Ctrl+Z 的那一刻,到 JVM 内部的每一次方法调用,栈都在默默地执行着它的 LIFO 原则------后来者居上,先到者垫底。