1. 栈与最小栈:数据结构中的双生花
当我在准备第一次技术面试时,面试官让我在白板上手写一个最小栈的实现。那是我第一次意识到,这个看似简单的数据结构竟能难倒不少候选人。栈(Stack)作为最基础的数据结构之一,其"后进先出"(LIFO)的特性就像我们日常生活中的叠盘子——最后放上去的盘子总是最先被取走。而最小栈(Min Stack)则在普通栈的基础上增加了一个关键功能:在常数时间内获取当前栈中的最小元素。
在Java中实现这两种栈,不仅是对基础数据结构的实践,更涉及时间复杂度的优化思考。普通栈的push(入栈)和pop(出栈)操作都是O(1)时间复杂度,但如果要实现getMin()方法,最直观的暴力搜索方式会使时间复杂度升至O(n)。如何保持所有操作都是O(1)?这正是最小栈设计的精妙之处。
2. 普通栈的实现:基础中的基础
2.1 栈的核心操作解析
栈的核心操作只有三个:
- push(item): 将元素压入栈顶
- pop(): 弹出栈顶元素
- peek(): 查看栈顶元素但不弹出
在Java中,我们可以选择多种底层数据结构来实现栈。虽然Java集合框架已经提供了Stack类,但它在设计上存在一些缺陷(比如同步开销),更推荐使用Deque接口的实现类如ArrayDeque。不过为了彻底理解原理,我们先从零开始实现。
java复制public class MyStack<E> {
private static final int DEFAULT_CAPACITY = 10;
private Object[] elements;
private int size;
public MyStack() {
elements = new Object[DEFAULT_CAPACITY];
}
public void push(E item) {
ensureCapacity();
elements[size++] = item;
}
@SuppressWarnings("unchecked")
public E pop() {
if (size == 0) throw new EmptyStackException();
E item = (E) elements[--size];
elements[size] = null; // 避免内存泄漏
return item;
}
@SuppressWarnings("unchecked")
public E peek() {
if (size == 0) throw new EmptyStackException();
return (E) elements[size - 1];
}
private void ensureCapacity() {
if (size == elements.length) {
elements = Arrays.copyOf(elements, 2 * size + 1);
}
}
public boolean isEmpty() {
return size == 0;
}
public int size() {
return size;
}
}
注意:这里我们手动实现了动态扩容。在实际项目中,更推荐使用LinkedList或ArrayDeque,它们已经优化了这些底层细节。
2.2 数组 vs 链表实现选择
栈的底层实现主要有两种方式:
-
数组实现(如上例):
- 优点:内存连续,访问速度快;实现简单
- 缺点:需要处理扩容问题;可能浪费内存空间
- 适合场景:元素数量可预估或变化不大的情况
-
链表实现:
java复制public class LinkedStack<E> {
private static class Node<E> {
E item;
Node<E> next;
Node(E item, Node<E> next) {
this.item = item;
this.next = next;
}
}
private Node<E> top;
private int size;
public void push(E item) {
top = new Node<>(item, top);
size++;
}
public E pop() {
if (top == null) throw new EmptyStackException();
E item = top.item;
top = top.next;
size--;
return item;
}
// 其他方法省略...
}
- 优点:无需预先分配内存;没有扩容问题
- 缺点:每个元素需要额外空间存储指针;内存不连续可能影响缓存命中率
- 适合场景:元素数量变化大或难以预估的情况
3. 最小栈的魔法:O(1)时间获取最小值
3.1 最小栈的设计思路
最小栈需要在普通栈的基础上,额外支持getMin()操作,并且要求所有操作的时间复杂度都是O(1)。这看似不可能的任务,通过空间换时间的策略可以完美解决。
核心思路是使用辅助栈:
- 主栈:存储所有元素,正常进行push/pop操作
- 最小栈:栈顶始终保存当前主栈中的最小值
每次push操作时:
- 将元素压入主栈
- 比较新元素与当前最小栈栈顶元素:
- 如果新元素更小(或等于),也压入最小栈
- 否则不做操作
每次pop操作时:
- 弹出主栈栈顶元素
- 如果弹出的元素等于最小栈栈顶元素,则也弹出最小栈栈顶
这样,最小栈的栈顶始终保存当前主栈的最小值,getMin()只需返回最小栈栈顶即可。
3.2 最小栈的完整实现
java复制public class MinStack {
private Deque<Integer> mainStack;
private Deque<Integer> minStack;
public MinStack() {
mainStack = new ArrayDeque<>();
minStack = new ArrayDeque<>();
}
public void push(int val) {
mainStack.push(val);
if (minStack.isEmpty() || val <= minStack.peek()) {
minStack.push(val);
}
}
public void pop() {
if (mainStack.isEmpty()) throw new EmptyStackException();
int val = mainStack.pop();
if (val == minStack.peek()) {
minStack.pop();
}
}
public int top() {
if (mainStack.isEmpty()) throw new EmptyStackException();
return mainStack.peek();
}
public int getMin() {
if (minStack.isEmpty()) throw new EmptyStackException();
return minStack.peek();
}
}
3.3 边界条件与优化
实现最小栈时有几个关键细节需要注意:
- 等于最小值的情况:在push时,当新元素等于当前最小值时,也需要压入最小栈。否则在pop时可能会提前移除最小值。
- 空栈处理:所有操作都应检查栈是否为空,避免NullPointerException。
- 泛型支持:上面的实现只支持Integer,可以通过泛型扩展支持任意Comparable类型:
java复制public class MinStack<E extends Comparable<E>> {
private Deque<E> mainStack;
private Deque<E> minStack;
public void push(E val) {
mainStack.push(val);
if (minStack.isEmpty() || val.compareTo(minStack.peek()) <= 0) {
minStack.push(val);
}
}
// 其他方法类似...
}
4. 栈的应用场景与面试实战
4.1 栈的典型应用场景
- 函数调用栈:JVM使用栈来管理方法调用和返回,每个方法调用都会创建一个栈帧。
- 表达式求值:处理括号匹配、运算符优先级等问题,如LeetCode 224.基本计算器。
- 浏览器历史记录:前进后退功能就是典型的栈应用。
- 撤销操作:文本编辑器中的撤销(Undo)功能通常使用栈实现。
4.2 最小栈的面试变种
面试中关于最小栈的问题常有多种变体,掌握核心思想后可以举一反三:
- 最大栈:实现getMax()而非getMin(),原理完全相同。
- 队列中的最小值:如何实现一个队列,使得能在O(1)时间内获取最小值?这需要结合栈和队列的特性。
- 带限制的栈:设计一个栈,除了支持常规操作外,还能在O(1)时间内返回栈中元素的中位数。
4.3 常见面试问题与解答
Q1:为什么最小栈的push操作中使用<=而不是<来比较?
A1:这是为了正确处理重复最小值的情况。如果栈中有多个相同的最小值,只保存一个,那么在pop时可能会过早移除最小值记录。例如:
- push 3, push 2, push 2
- 如果只用
<,最小栈会是[3,2] - 第一次pop 2时,最小栈会弹出2,导致getMin()返回3(错误)
- 使用
<=,最小栈是[3,2,2],能正确处理这种情况
Q2:能否不使用辅助栈来实现最小栈?
A2:可以,但需要牺牲一些其他特性。一种方法是栈中不直接存储元素,而是存储一个节点对象,该节点包含元素值和当前最小值:
java复制class MinStack {
private static class Node {
int val;
int min;
Node(int val, int min) {
this.val = val;
this.min = min;
}
}
private Deque<Node> stack;
public MinStack() {
stack = new ArrayDeque<>();
}
public void push(int val) {
int min = stack.isEmpty() ? val : Math.min(val, stack.peek().min);
stack.push(new Node(val, min));
}
// 其他方法...
}
这种方法节省了辅助栈的空间,但每个节点需要额外存储min值,总体空间复杂度仍然是O(n)。
5. 性能对比与工程实践
5.1 不同实现方式的性能对比
| 实现方式 | push时间复杂度 | pop时间复杂度 | getMin时间复杂度 | 空间复杂度 |
|---|---|---|---|---|
| 普通栈+遍历查找 | O(1) | O(1) | O(n) | O(n) |
| 双栈法 | O(1) | O(1) | O(1) | O(n)最坏 |
| 存储差值法* | O(1) | O(1) | O(1) | O(n) |
*存储差值法:一种优化空间的算法,通过存储元素与当前最小值的差值来减少空间使用,但实现复杂且容易溢出。
5.2 工程实践中的选择建议
在实际项目中,选择栈的实现方式需要考虑:
- 性能需求:高频操作需要保证O(1)时间复杂度
- 内存限制:辅助栈在最坏情况下可能使空间翻倍
- 线程安全:如果多线程环境使用,需要考虑同步问题
对于大多数场景,Java标准库的Deque实现已经足够:
java复制Deque<Integer> stack = new ArrayDeque<>(); // 单线程
Deque<Integer> stack = new ConcurrentLinkedDeque<>(); // 多线程
如果需要最小栈功能,可以基于Deque包装实现:
java复制public class MinStackDeque {
private Deque<Integer> mainStack = new ArrayDeque<>();
private Deque<Integer> minStack = new ArrayDeque<>();
// 实现方法同前...
}
5.3 内存优化技巧
对于内存敏感的场景,可以考虑以下优化:
- 延迟移除:最小栈不立即pop,只有当主栈pop的值等于最小栈顶时才pop
- 存储索引:对于对象栈,可以存储最小值的索引而非对象本身
- 压缩存储:使用位运算等技巧压缩存储的数据
例如,当栈中元素是正整数时,可以借用符号位存储额外信息:
java复制public void push(int val) {
if (val <= getMin()) {
mainStack.push(val); // 存储原始值标记为最小值
min = val;
} else {
mainStack.push(val - min); // 存储差值
}
}
但这种优化会显著增加代码复杂度,除非在极端资源受限环境,否则通常得不偿失。
6. 测试与验证:确保实现的正确性
6.1 单元测试要点
为栈实现编写单元测试时,应覆盖以下场景:
- 空栈时的pop/peek/getMin操作
- 连续push后的状态验证
- push和pop交替操作
- 包含重复元素的情况
- 边界值测试(如Integer.MAX_VALUE/MIN_VALUE)
使用JUnit 5的测试示例:
java复制@Test
void testMinStack() {
MinStack stack = new MinStack();
assertThrows(EmptyStackException.class, stack::getMin);
stack.push(3);
assertEquals(3, stack.getMin());
stack.push(2);
stack.push(2);
assertEquals(2, stack.getMin());
stack.pop();
assertEquals(2, stack.getMin());
stack.pop();
assertEquals(3, stack.getMin());
}
6.2 性能测试方法
对于高频交易等性能敏感场景,需要进行压力测试:
java复制@Test
void testPerformance() {
MinStack stack = new MinStack();
int count = 1_000_000;
long start = System.nanoTime();
for (int i = 0; i < count; i++) {
stack.push(i);
stack.getMin();
}
long duration = System.nanoTime() - start;
System.out.printf("Average time per operation: %.2f ns%n",
(double)duration / (2 * count));
}
6.3 常见实现错误排查
-
最小值更新错误:
- 现象:getMin()返回的值比实际最小值大
- 原因:push时比较逻辑错误,或pop时未正确更新最小栈
- 修复:仔细检查push/pop中对最小栈的操作条件
-
空指针异常:
- 现象:操作空栈时抛出NullPointerException而非EmptyStackException
- 原因:未正确检查栈空状态
- 修复:在所有pop/peek/getMin操作前添加空栈检查
-
内存泄漏:
- 现象:栈长时间使用后内存持续增长
- 原因:pop时未清空数组引用(数组实现中)
- 修复:pop时将对应数组位置置null
7. 从栈到更复杂的数据结构
掌握了栈和最小栈的实现后,可以进一步探索相关数据结构:
-
单调栈:栈中元素保持单调递增或递减,常用于解决"下一个更大元素"类问题
java复制// 单调递减栈示例 Deque<Integer> monoStack = new ArrayDeque<>(); for (int num : nums) { while (!monoStack.isEmpty() && monoStack.peek() < num) { monoStack.pop(); } monoStack.push(num); } -
双端队列(Deque):两端都可以进行插入删除操作,结合了栈和队列的特性
-
优先队列(PriorityQueue):虽然也能获取最小值,但时间复杂度不同
理解这些数据结构的异同,能够帮助我们在不同场景下做出更合适的选择。比如,当需要频繁获取极值但不需要严格LIFO顺序时,优先队列可能是更好的选择。
