“栈”不住的面试题?手撕最小栈,从 O(n) 到 O(1) 的降维打击!
面试官:“实现一个支持 getMin 的栈。”
你:“简单,遍历一下就行。”
面试官(微笑):“那时间复杂度呢?”
你(冷汗):“……”
别慌!今天我们就用 JavaScript + ES5 构造函数,把“最小栈”这个高频算法题彻底吃透。不仅搞定 O(1) 实现,还要深挖背后的辅助栈设计思想、单调栈特性、内存权衡,甚至关联到大厂常考的设计模式与性能优化策略。
虽然它不是每场面试都会出现的“必考题”,但作为一道经典的数据结构典型题,它被阿里、腾讯、字节等一线大厂在初级/中级工程师面试中反复使用——原因很简单:小而精,能快速检验基本功。
一、为什么“最小栈”频频出现在大厂面试中?
LeetCode 第 155 题《Min Stack》要求我们设计一个栈,在支持常规操作的同时,以 O(1) 时间复杂度获取当前最小值。
这道题之所以成为高频题,并非因为它多难,而是因为它能高效考察多个维度的能力:
- 是否理解时间与空间的权衡
- 能否处理边界条件与重复值
- 是否具备将抽象数据结构映射到实际问题的能力
现实中,类似需求并不少见:
- 浏览器历史记录中快速获取最早访问时间
- 股票交易系统中实时追踪最低买入价
- 撤销/重做(Undo/Redo)功能中的状态快照管理
所以,这道题不仅是算法题,更是工程思维的缩影。
二、暴力解法:O(n) 的“老实人”写法
先看第一版代码(很多人初学时会这么写):
const MinStack = function() {
this.stack = [];
}
MinStack.prototype.push = function(x) {
this.stack.push(x);
}
MinStack.prototype.pop = function() {
return this.stack.pop();
}
MinStack.prototype.top = function() {
if (this.stack.length > 0) {
return this.stack[this.stack.length - 1];
}
}
MinStack.prototype.getMin = function() {
let minValue = Infinity;
for (let i = 0; i < this.stack.length; i++) {
if (this.stack[i] < minValue) {
minValue = this.stack[i];
}
}
return minValue;
}
✅ 优点:
- 逻辑直观,易于实现
- 空间复杂度 O(1)
❌ 缺点:
getMin()时间复杂度为 O(n),不满足题目 O(1) 要求- 在高频调用场景下(如每 push 后查 min),性能急剧下降
三、进阶解法:O(1) 的“空间换时间”艺术
为了达到 O(1) 的 getMin,我们引入辅助栈(Auxiliary Stack) :
const MinStack = function() {
this.stack = []; // 主栈:存储所有元素
this.stack2 = []; // 辅助栈:单调非递增,栈顶始终是最小值
}
MinStack.prototype.push = function(x) {
this.stack.push(x);
// 关键:只有当 x <= 辅助栈栈顶时,才入辅助栈
if (this.stack2.length === 0 || this.stack2[this.stack2.length - 1] >= x) {
this.stack2.push(x);
}
}
MinStack.prototype.pop = function() {
const top = this.stack.pop();
// 如果弹出的元素等于辅助栈栈顶,则辅助栈也弹出
if (top === this.stack2[this.stack2.length - 1]) {
this.stack2.pop();
}
return top;
}
MinStack.prototype.top = function() {
return this.stack[this.stack.length - 1];
}
MinStack.prototype.getMin = function() {
return this.stack2[this.stack2.length - 1]; // O(1)!
}
🔍 逐行解析:为什么这么写?
1. 辅助栈的维护逻辑
-
push时:仅当新元素 ≤ 当前最小值 才压入辅助栈。- 使用
>=而非>是为了正确处理重复最小值(如 push 2, 2, 1, 1)。 - 若忽略等号,pop 两次 1 后辅助栈会提前变空,导致后续
getMin错误。
- 使用
2. 单调栈的本质
stack2是一个单调非递增栈(Monotonic Stack)。- 这种结构广泛应用于滑动窗口最值、柱状图最大矩形等问题。
3. 空间 vs 时间的工程权衡
- 空间复杂度:最坏 O(n)(当输入序列单调递减)
- 但换来的是 所有操作 O(1) ,符合“用空间换时间”的经典工程策略。
🧠 大厂延伸问法:
“如果内存非常紧张,还能优化吗?”
→ 可考虑差值编码法(只存与当前最小值的差),但会引入整数溢出风险,且代码复杂度高。在绝大多数场景下,辅助栈仍是最佳实践。
四、深度拓展:背后的技术原理
1. 为什么不直接用 Set 或 Map?
- Set 无法记录重复最小值(如两个 1)
- Map 虽可计数,但
getMin仍需 O(log n)(若基于红黑树)或无法保证顺序 - 栈的 LIFO 特性天然匹配辅助栈的同步进出机制
2. 与前端开发有何关系?
-
虽然本题不涉及 DOM 或框架,但栈是 JS 运行时的核心结构:
- 函数调用栈(Call Stack)
- React Fiber 的协调回溯机制
- 浏览器 history API 的前进/后退
-
理解栈的 O(1) 操作,有助于理解 V8 如何高效管理执行上下文。
3. 设计模式的影子
- 辅助栈本质是一种 Memoization(记忆化) :提前缓存计算结果,避免重复开销。
- 也可视为对“最小值状态”的代理缓存,体现“关注点分离”思想。
五、高频面试题关联
| 相关题目 | 考察点 |
|---|---|
| MaxStack | 同理,辅助栈维护最大值 |
| 用两个栈实现队列 | 栈与队列的转换思维 |
| LRU Cache | 同样是空间换时间,但用哈希表+双向链表 |
| 滑动窗口最大值 | 单调队列 vs 单调栈 |
六、总结:从“能跑”到“优雅”
- 暴力解法:适合快速验证思路,但无法通过性能要求。
- 辅助栈解法:体现工程权衡,是工业级标准答案。
- 关键细节:重复值处理、空栈防御、API 一致性。
最后送大家一句面试心得:
“不会 O(1) 的最小栈,不一定挂;但如果你能讲清为什么用辅助栈、如何处理边界、空间是否可优化——那你已经赢了大多数人。”
赶紧动手实现一遍,下次面试官再问,你就可以微笑着说:
“我不仅会写,还能讲清楚背后的工程取舍。”