一、引言:为什么需要“最小栈”?
在日常开发或算法面试中,我们经常会遇到这样的需求:
“我有一个栈,除了正常的入栈、出栈操作外,还需要随时知道当前栈中的最小值,而且要非常快!”
如果每次查询最小值都遍历整个栈,时间复杂度是 O(n),在高频调用场景下性能堪忧。有没有办法让所有操作(包括获取最小值)都在常数时间内完成?
答案是:最小栈(Min Stack) 。
本文将带你从零开始,用原生 JavaScript 风格,深入理解最小栈的设计思想,并手写一个高效、健壮的实现。
二、核心设计思想
最小栈的关键在于 “用空间换时间” 。我们不重新计算最小值,而是在数据变化时同步维护它。
🧠 核心原则(牢记!)
✅ 最小栈只推入“小于或等于当前最小值”的元素
✅ 最小栈只在主栈弹出的元素等于当前最小值时才弹出
这两条规则看似简单,却是整个数据结构正确性的基石。
三、数据结构设计
我们维护两个栈:
| 栈名 | 作用 | 特点 |
|---|---|---|
stack | 主栈 | 存储所有原始数据,行为与普通栈完全一致 |
minStack | 辅助栈 | 单调非增栈,栈顶始终是当前全局最小值 |
🔍 什么是“单调非增”?
即栈中元素从底到顶不递增(可以相等,但不能变大)。
例如:[5, 3, 3, -1]是合法的;[5, 3, 4]是非法的。
四、原生 JavaScript 实现
下面是一个纯 ES5 风格的实现,兼容所有现代浏览器和 Node.js 环境:
js
编辑
function MinStack() {
this.stack = []; // 主栈:存储原始值
this.minStack = []; // 辅助栈:存储“历史最小值”序列
}
// 入栈:O(1)
MinStack.prototype.push = function (val) {
this.stack.push(val);
// 关键逻辑:只有当新值 ≤ 当前最小值时,才压入 minStack
if (this.minStack.length === 0 || val <= this.getMin()) {
this.minStack.push(val);
}
};
// 出栈:O(1)
MinStack.prototype.pop = function () {
if (this.stack.length === 0) return null;
const topVal = this.stack.pop();
// 关键逻辑:只有当弹出的值等于当前最小值时,minStack 才同步弹出
if (topVal === this.getMin()) {
this.minStack.pop();
}
return topVal;
};
// 获取栈顶元素:O(1)
MinStack.prototype.top = function () {
return this.stack.length > 0 ? this.stack[this.stack.length - 1] : null;
};
// 获取当前最小值:O(1)
MinStack.prototype.getMin = function () {
return this.minStack.length > 0 ? this.minStack[this.minStack.length - 1] : null;
};
五、原理深度剖析
场景 1:处理重复最小值
假设操作序列为:push(-2), push(0), push(-2)
| 步骤 | 主栈 stack | 辅助栈 minStack | 说明 |
|---|---|---|---|
| 初始 | [] | [] | — |
| push(-2) | [-2] | [-2] | 首个元素,直接入 minStack |
| push(0) | [-2, 0] | [-2] | 0 > -2,不入 minStack |
| push(-2) | [-2, 0, -2] | [-2, -2] | -2 ≤ -2,入 minStack! |
💡 如果这里用
<而不是<=,第二个-2就不会进入minStack。
当第一次pop()弹出-2后,getMin()会错误地返回undefined!
场景 2:出栈时的同步
继续上面的例子,执行 pop():
| 操作 | 主栈 | 辅助栈 | getMin() |
|---|---|---|---|
| pop() | [-2, 0] | [-2] | -2 ✅ |
| pop() | [-2] | [-2] | -2 (0 ≠ -2,minStack 不变) |
| pop() | [] | [] | null |
完美同步!
六、为什么不用单栈+差值法?
你可能会听说另一种“O(1) 空间”的解法:用单个栈存储与最小值的差值。
但这种方法有严重缺陷:
- ❌ 仅适用于整数(浮点数有精度问题)
- ❌ 容易溢出(差值可能超出数值范围)
- ❌ 逻辑复杂,调试困难
- ❌ 不符合“清晰优于 clever”的工程原则
📌 结论:在实际开发和面试中,双栈法是首选。它的 O(n) 空间代价换来的是极高的可读性、健壮性和通用性。
七、实战测试用例
js
编辑
const ms = new MinStack();
ms.push(5);
ms.push(3);
ms.push(3);
ms.push(1);
console.log(ms.getMin()); // 1
ms.pop(); // 弹出 1
console.log(ms.getMin()); // 3
ms.pop(); // 弹出 3
console.log(ms.getMin()); // 3(还有一个 3)
ms.pop(); // 弹出 3
console.log(ms.getMin()); // 5
ms.pop(); // 弹出 5
console.log(ms.getMin()); // null
✅ 所有边界情况均正确处理!
八、应用场景
- 实时监控系统:跟踪服务器负载、内存使用的最低值
- 金融交易:记录股票价格的历史最低点
- 算法竞赛:作为单调栈的变种,解决滑动窗口极值问题
- 撤销操作:在支持“回退”的编辑器中维护状态极值
九、总结与思考
| 要点 | 说明 |
|---|---|
| 核心思想 | 用辅助栈维护“历史最小值”序列 |
| 关键细节 | 使用 <= 而非 <,确保重复最小值被正确处理 |
| 时间复杂度 | 所有操作均为 O(1) |
| 空间复杂度 | 最坏情况 O(n) (输入为非递增序列) |
| 工程价值 | 清晰、安全、高效,适合生产环境 |
💬 最后的话:
最小栈教会我们的不仅是数据结构技巧,更是一种前瞻性维护状态的思维方式——
不要等到需要时才计算,而是在变化发生时就更新。
这种思想,在缓存设计、响应式编程、状态管理等领域无处不在。