Java数据结构------栈(Stack)详解
学数据结构的时候,栈算是比较简单但特别实用的一个。这篇文章把栈的概念、常用方法、手动实现以及几个经典应用场景都过了一遍,希望能帮到正在复习的朋友。
一、栈是什么?
栈是一种受限的线性表 ,说"受限"是因为它只允许在固定的一端进行插入和删除操作。
这一端叫栈顶 (Top),另一端叫栈底(Bottom)。
栈里元素的操作遵循一个核心原则------后进先出(LIFO,Last In First Out)。
什么意思呢?就像你桌子上叠了一摞盘子,最后放上去的那个盘子,你肯定最先拿走。这就是后进先出。
栈的相关概念:
- 压栈(Push):也叫入栈,往栈顶放一个元素
- 出栈(Pop):把栈顶的元素移除
- 栈顶:操作都在这头进行
- 栈底:封死的那头,不动
画个图可能更直观:
3 最后进去,也最先出来。
二、栈的常用方法
Java 里 java.util.Stack 类直接提供了这些操作,用起来很简单:
| 方法 | 说明 |
|---|---|
Stack() |
构造一个空栈 |
E push(E e) |
把 e 压入栈顶,并返回 e |
E pop() |
弹出栈顶元素并返回 |
E peek() |
查看栈顶元素(不弹出) |
int size() |
返回栈中元素个数 |
boolean isEmpty() |
判断栈是否为空 |
快速演示一下:
java
Stack<Integer> stack = new Stack<>();
stack.push(1);
stack.push(2);
stack.push(3);
System.out.println(stack.peek()); // 3,栈顶元素
System.out.println(stack.pop()); // 3,弹出栈顶
System.out.println(stack.size()); // 2
System.out.println(stack.isEmpty()); // false
没什么难度,关键是要理解什么时候该用栈,这个后面会讲到。## 三、自己动手实现一个栈
不过,仅仅会调用 API 还不够,面试中常常要求手写实现。用数组作为底层结构来实现栈,思路非常直观:
java
public class MyStack {
private int[] array;
private int size;
public MyStack() {
array = new int[3]; // 初始容量给 3,随便给个值
}
// 获取栈顶元素
public int peek() {
if (isEmpty()) {
throw new RuntimeException("栈为空,无法获取栈顶元素");
}
return array[size - 1];
}
// 入栈
public int push(int value) {
if (isFull()) {
expand(); // 满了就扩容
}
array[size] = value;
size++;
return array[size - 1];
}
// 出栈
public int pop() {
if (isEmpty()) {
throw new RuntimeException("栈为空,无法出栈");
}
int tmp = array[size - 1];
size--;
return tmp;
}
// 扩容(2 倍扩容,和 ArrayList 的思路一样)
private void expand() {
array = Arrays.copyOf(array, array.length * 2);
}
// 判满
private boolean isFull() {
return size == array.length;
}
// 判空
public boolean isEmpty() {
return size == 0;
}
// 获取元素个数
public int getSize() {
return size;
}
}
这里有几个点需要注意:
- 扩容策略:这里用的是 2 倍扩容,和 ArrayList 是一个思路。当然你也可以用其他倍数,但 2 倍是比较常见的做法。
- 边界检查 :
peek和pop之前都要先判断栈是不是空的,不然会数组越界。 pop不需要真正删除数据 :只要size--就行了,下次push会直接覆盖旧值。## 四、栈的经典应用场景
栈的应用场景还挺多的,这里挑几个最常考的来说。
4.1 递归转非递归
这个思想很重要------递归的本质其实就是系统帮你维护了一个函数调用栈。每次递归调用,系统都会把当前的状态压栈;递归返回的时候,再依次出栈。
所以理论上,任何递归都能改写成用栈模拟的循环形式。有些场景下这样做还能避免栈溢出。
4.2 括号匹配
这道题是栈的经典入门题。思路:
- 创建一个栈
- 遍历字符串中的每个字符
- 遇到左括号,就压入栈中
- 遇到右括号 ,就
peek栈顶看匹不匹配- 匹配就
pop,继续 - 不匹配直接返回
false(比如栈顶是[但当前是))
- 匹配就
- 遍历完了之后,栈为空说明全部匹配上了,返回
true
java
public boolean isValid(String s) {
Stack<Character> stack = new Stack<>();
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();
}
这题不难,但一定要考虑边界情况:比如字符串为空、右括号比左括号多、左括号比右括号多。
4.3 逆波兰表达式求值
先说下什么是逆波兰表达式(后缀表达式)。
我们平时写的 a + b * c 叫中缀表达式 ,运算符在两个操作数中间。而逆波兰表达式把运算符放到后面,上面的例子转换后是 a b c * +。
转换方法:
- 从左到右按运算顺序加括号:
(a + (b * c)) - 把运算符移到对应括号的后面:
(a (b c) *) + - 去掉括号:
a b c * +
求值方法(用栈):
- 从左到右遍历逆波兰表达式
- 遇到数字就入栈
- 遇到运算符就弹出两个元素
- 先弹出的放运算符右边,后弹出的放左边(这个顺序别搞反了)
- 计算结果,再入栈
- 遍历完,栈里剩下的那个数就是答案
举个例子,1 3 4 * +:
遍历 "1" → 入栈,栈:[1]
遍历 "3" → 入栈,栈:[1, 3]
遍历 "4" → 入栈,栈:[1, 3, 4]
遍历 "*" → 弹出 4 和 3,算 3*4=12,入栈,栈:[1, 12]
遍历 "+" → 弹出 12 和 1,算 1+12=13,入栈,栈:[13]
结果:13

java
public int evalRPN(String[] tokens) {
Stack<Integer> stack = new Stack<>();
for (String token : tokens) {
if (token.equals("+") || token.equals("-") ||
token.equals("*") || token.equals("/")) {
int b = stack.pop(); // 先弹出的是右操作数
int a = stack.pop(); // 后弹出的是左操作数
switch (token) {
case "+": stack.push(a + b); break;
case "-": stack.push(a - b); break;
case "*": stack.push(a * b); break;
case "/": stack.push(a / b); break;
}
} else {
stack.push(Integer.parseInt(token));
}
}
return stack.pop();
}
注意那个弹出顺序:b 先弹出来放右边,a 后弹出来放左边。做减法和除法的时候搞反了结果就不对了。
4.4 最小栈
题目要求:设计一个栈,除了正常的 push、pop、top 操作之外,还要能在 O(1) 时间内获取栈中的最小元素。
思路是用两个栈:
stack:正常存所有元素minStack:辅助栈,栈顶始终保存当前stack中的最小值
push 操作:
- 元素压入
stack - 如果
minStack为空,或者当前元素 ≤minStack栈顶,也压入minStack
pop 操作:
- 弹出
stack栈顶 - 如果弹出的元素等于
minStack栈顶,minStack也弹出
getMin 操作:
- 直接返回
minStack栈顶就行
java
class MinStack {
private Stack<Integer> stack;
private Stack<Integer> minStack;
public MinStack() {
stack = new Stack<>();
minStack = new Stack<>();
}
public void push(int val) {
stack.push(val);
if (minStack.isEmpty() || val <= minStack.peek()) {
minStack.push(val);
}
}
public void pop() {
if (stack.pop().equals(minStack.peek())) {
minStack.pop();
}
}
public int top() {
return stack.peek();
}
public int getMin() {
return minStack.peek();
}
}
这里有个很容易踩的坑 :push 的时候判断条件要用 <= 而不是 <。
为什么?假设栈里压入了 5 个 1,如果 minStack 只记录了一个 1:
pop的时候,stack弹出一个 1,minStack也把唯一的 1 弹出了- 但
stack里还剩 4 个 1,实际最小值还是 1 - 这时候再调
getMin,minStack栈顶可能已经变成 2 了,结果就错了
所以遇到重复最小值的时候,每个都得往 minStack 里存一份,用 <= 就能解决这个问题。
4.5 用栈实现队列
这个题挺有意思的。栈是后进先出,队列是先进先出,顺序刚好相反。但用两个栈配合就能实现。
核心思路:
stackIn:负责接收入队元素stackOut:负责出队
入队 :直接 push 到 stackIn。
出队:
- 如果
stackOut不为空,直接弹出栈顶 - 如果
stackOut为空,把stackIn里所有 元素依次弹出并压入stackOut,然后再弹出stackOut栈顶
为什么要这么搞?因为元素从 stackIn 倒入 stackOut 的过程中,顺序就反过来了。stackIn 栈底的元素(最早入队的)变成了 stackOut 的栈顶(最先出队的),刚好满足队列的 FIFO 特性。
java
class MyQueue {
private Stack<Integer> stackIn;
private Stack<Integer> stackOut;
public MyQueue() {
stackIn = new Stack<>();
stackOut = new Stack<>();
}
public void push(int x) {
stackIn.push(x);
}
public int pop() {
if (stackOut.isEmpty()) {
while (!stackIn.isEmpty()) {
stackOut.push(stackIn.pop());
}
}
return stackOut.pop();
}
public int peek() {
if (stackOut.isEmpty()) {
while (!stackIn.isEmpty()) {
stackOut.push(stackIn.pop());
}
}
return stackOut.peek();
}
public boolean empty() {
return stackIn.isEmpty() && stackOut.isEmpty();
}
}
有个关键点 :只有 stackOut 为空的时候才倒入,不能每次 pop 都倒。如果 stackOut 里还有元素就倒入,会把已有的顺序打乱。
时间复杂度方面,虽然单次 pop 最坏是 O(n)(需要倒入),但均摊下来每个元素最多被移动两次(进 stackIn 一次,倒入 stackOut 一次),所以均摊时间复杂度是 O(1)。
五、总结
栈这个东西说简单也简单,说重要也确实重要。几个要点回顾一下:
- 栈是后进先出(LIFO)的线性表
- 核心操作就四个:
push、pop、peek、isEmpty - 手写实现的话,底层用数组,注意扩容和边界检查
- 经典应用场景:括号匹配、逆波兰表达式、最小栈、栈实现队列
这几个题刷熟练了,栈这块基本就没什么问题了。后面有时间再写一篇队列的,和栈对照着看效果更好。## 五、总结
栈这个东西说简单也简单,说重要也确实重要。几个要点回顾一下:
- 栈是后进先出(LIFO)的线性表
- 核心操作就四个:
push、pop、peek、isEmpty - 手写实现的话,底层用数组,注意扩容和边界检查
- 经典应用场景:括号匹配、逆波兰表达式、最小栈、栈实现队列
这几个题刷熟练了,栈这块基本就没什么问题了。后面有时间再写一篇队列的,和栈对照着看效果更好。