1. 最小栈问题概述
最小栈(Min Stack)是LeetCode上经典的算法设计题(#155题),要求设计一个栈数据结构,除了支持常规的push、pop、top操作外,还能在常数时间内检索到栈中的最小元素。这个问题看似简单,却考察了对数据结构的深刻理解和创新设计能力。
在实际工程中,类似的需求并不少见。比如电商系统需要实时获取当前促销商品中的最低价格,监控系统需要快速获取最近一小时内的最低温度读数。这类场景都要求在基础数据结构上扩展出高效获取极值的能力。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 问题分析与解法设计
2.1 基础解法:辅助栈法
最直观的解法是使用两个栈:一个主栈存储实际数据,另一个辅助栈存储当前最小值。每次push操作时,比较新元素与辅助栈顶元素,将较小者压入辅助栈。这种方法的时空复杂度都是O(n)。
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);
} else {
minStack.push(minStack.peek());
}
}
public void pop() {
stack.pop();
minStack.pop();
}
public int top() {
return stack.peek();
}
public int getMin() {
return minStack.peek();
}
}
注意:这里必须使用
<=而非<,考虑重复最小值的情况。当多个相同最小值存在时,只有全部弹出后才更新最小值。
2.2 优化解法:差值存储法
更高效的做法是只使用一个栈,存储当前元素与历史最小值的差值。这种方法将空间复杂度优化到O(1)(不考虑栈本身的空间)。
java复制class MinStack {
private Stack<Long> stack;
private long min;
public MinStack() {
stack = new Stack<>();
}
public void push(int val) {
if(stack.isEmpty()) {
stack.push(0L);
min = val;
} else {
stack.push((long)val - min);
min = Math.min(min, val);
}
}
public void pop() {
long diff = stack.pop();
if(diff < 0) {
min = min - diff; // 恢复前一个min
}
}
public int top() {
long diff = stack.peek();
if(diff < 0) {
return (int)min;
} else {
return (int)(min + diff);
}
}
public int getMin() {
return (int)min;
}
}
这种方法需要注意整数溢出问题,当差值超过Integer范围时需要进行特殊处理。
3. 边界条件与异常处理
3.1 空栈处理
所有栈操作都需要考虑空栈情况:
- pop()和top()在空栈时应抛出异常
- getMin()在空栈时理论上无定义,LeetCode测试用例中通常不会出现
java复制public void pop() {
if(stack.isEmpty()) throw new EmptyStackException();
// ...原有逻辑
}
3.2 数值边界
特别是差值存储法需要注意:
- 当val-min超过Integer.MAX_VALUE时,差值会溢出
- 当val是Integer.MIN_VALUE时,某些运算可能导致问题
解决方案是使用long类型存储差值,或者在push时进行边界检查。
4. 性能对比与选择建议
| 方法 | push时间复杂度 | pop时间复杂度 | getMin时间复杂度 | 空间复杂度 |
|---|---|---|---|---|
| 辅助栈法 | O(1) | O(1) | O(1) | O(n) |
| 差值存储法 | O(1) | O(1) | O(1) | O(1) |
实际选择建议:
- 面试场景:推荐辅助栈法,逻辑清晰不易出错
- 竞赛场景:差值法更优,节省内存
- 生产环境:考虑使用线程安全的Deque替代Stack,或直接使用现成的MinStack实现
5. 实际工程应用案例
5.1 电商价格监控系统
在秒杀活动中,需要实时显示当前所有参与商品中的最低价格。使用最小栈可以高效实现这一需求:
java复制class PriceMonitor {
private Deque<Double> priceStack = new ArrayDeque<>();
private Deque<Double> minStack = new ArrayDeque<>();
public void addPrice(double price) {
priceStack.push(price);
if(minStack.isEmpty() || price <= minStack.peek()) {
minStack.push(price);
}
}
public double removeLastPrice() {
double price = priceStack.pop();
if(price == minStack.peek()) {
minStack.pop();
}
return price;
}
public double getCurrentMinPrice() {
return minStack.peek();
}
}
5.2 游戏中的成就系统
记录玩家连续登录天数,并快速获取最近N天中的最小登录天数(用于判断是否中断过登录):
java复制class LoginTracker {
private int[] buffer;
private int[] minBuffer;
private int top = -1;
public LoginTracker(int capacity) {
buffer = new int[capacity];
minBuffer = new int[capacity];
}
public void addLoginDays(int days) {
if(top == buffer.length - 1) {
// 循环缓冲区处理
System.arraycopy(buffer, 1, buffer, 0, top);
System.arraycopy(minBuffer, 1, minBuffer, 0, top);
top--;
}
buffer[++top] = days;
minBuffer[top] = (top == 0) ? days : Math.min(days, minBuffer[top-1]);
}
public int getMinLoginDays() {
return minBuffer[top];
}
}
6. 常见面试问题与解答
6.1 为什么辅助栈法需要存储重复的最小值?
考虑如下操作序列:
code复制push(3), push(1), push(1), push(2)
此时最小值为1。如果只在辅助栈中存储一个1,那么当连续pop两个1时,辅助栈会变空,导致getMin返回错误结果。
6.2 如何处理并发场景下的最小栈?
基本实现不是线程安全的。解决方案包括:
- 使用synchronized方法
- 使用ConcurrentLinkedDeque等并发集合
- 采用不可变设计,每次操作返回新栈
java复制class ConcurrentMinStack {
private final Deque<Integer> stack = new ConcurrentLinkedDeque<>();
private final Deque<Integer> minStack = new ConcurrentLinkedDeque<>();
public synchronized void push(int val) {
stack.push(val);
if(minStack.isEmpty() || val <= minStack.peek()) {
minStack.push(val);
}
}
// 其他方法也需要同步
}
6.3 如何扩展支持最大值的获取?
可以再增加一个辅助栈来跟踪最大值,原理与最小栈相同:
java复制class MinMaxStack {
private Stack<Integer> stack = new Stack<>();
private Stack<Integer> minStack = new Stack<>();
private Stack<Integer> maxStack = new Stack<>();
public void push(int val) {
stack.push(val);
minStack.push(minStack.isEmpty() ? val : Math.min(val, minStack.peek()));
maxStack.push(maxStack.isEmpty() ? val : Math.max(val, maxStack.peek()));
}
public int getMax() {
return maxStack.peek();
}
// 其他方法保持不变
}
7. 算法变种与扩展思考
7.1 队列中的最小值
类似问题:设计一个队列,能在O(1)时间内获取最小值。解法通常使用双端队列维护最小值序列:
java复制class MinQueue {
private Queue<Integer> queue = new LinkedList<>();
private Deque<Integer> minDeque = new LinkedList<>();
public void enqueue(int val) {
queue.offer(val);
while(!minDeque.isEmpty() && val < minDeque.peekLast()) {
minDeque.pollLast();
}
minDeque.offerLast(val);
}
public int dequeue() {
int val = queue.poll();
if(val == minDeque.peekFirst()) {
minDeque.pollFirst();
}
return val;
}
public int getMin() {
return minDeque.peekFirst();
}
}
7.2 带历史记录的最小栈
需要支持查询任意时刻的栈最小值。可以通过在每个栈节点中记录当前最小值实现:
java复制class HistoricalMinStack {
private static class Node {
int val;
int min;
Node next;
Node(int val, int min, Node next) {
this.val = val;
this.min = min;
this.next = next;
}
}
private Node top;
public void push(int val) {
if(top == null) {
top = new Node(val, val, null);
} else {
top = new Node(val, Math.min(val, top.min), top);
}
}
public int getHistoricalMin() {
return top.min;
}
}
8. 测试用例设计
完整的测试应该包括以下场景:
- 基本功能测试
- 边界值测试(空栈、极值)
- 重复值测试
- 连续push/pop交替测试
- 性能测试(大数据量)
示例测试用例:
java复制@Test
public void testMinStack() {
MinStack stack = new MinStack();
assertThrows(EmptyStackException.class, stack::pop);
stack.push(3);
assertEquals(3, stack.getMin());
stack.push(1);
assertEquals(1, stack.getMin());
stack.push(2);
assertEquals(1, stack.getMin());
stack.pop();
assertEquals(1, stack.getMin());
stack.pop();
assertEquals(3, stack.getMin());
}
@Test
public void testDuplicateMin() {
MinStack stack = new MinStack();
stack.push(2);
stack.push(2);
stack.push(1);
stack.push(1);
assertEquals(1, stack.getMin());
stack.pop();
assertEquals(1, stack.getMin());
stack.pop();
assertEquals(2, stack.getMin());
}
9. 与其他数据结构的比较
最小栈与优先队列(PriorityQueue)的区别:
- 优先队列的getMin是O(1),但pop是O(log n)
- 最小栈的所有操作都是O(1),但只能访问栈顶元素
- 优先队列支持任意顺序删除,最小栈只能LIFO
与单调栈的关系:
- 单调栈是维护栈内元素单调性的结构
- 最小栈的辅助栈实际上是一个非严格单调递减栈
10. 实际编码中的优化技巧
- 使用Deque替代Stack:Java的Stack类基于Vector,有同步开销,推荐使用ArrayDeque
- 预分配空间:对于已知最大容量的场景,可以初始化指定大小的数组
- 延迟更新:在pop时不一定立即更新min,可以等到getMin时再计算
- 位运算优化:对于特定范围的整数,可以用位运算压缩存储差值
java复制class OptimizedMinStack {
private Deque<Integer> stack = new ArrayDeque<>(100); // 预分配
private int min;
public void push(int val) {
if(stack.isEmpty()) {
min = val;
stack.push(0);
} else {
stack.push(val - min);
if(val < min) min = val;
}
}
public int pop() {
int diff = stack.pop();
int val = diff < 0 ? min : min + diff;
if(diff < 0) min = val - diff;
return val;
}
}
最小栈问题虽然简单,但深入理解其设计思想对掌握数据结构与算法至关重要。在实际工程中,类似的"带额外信息的扩展数据结构"模式非常常见,比如带过期时间的缓存、带权重的随机选择等。掌握这类问题的核心思路,能够帮助我们设计出更高效的解决方案。
