手撕「最小栈」:从 O(n) 到 O(1),带你彻底搞懂辅助栈的精髓
大家好!今天我们来聊一道经典的面试高频题 —— LeetCode 155. 最小栈。
这道题虽然是 Easy 难度,但却是无数候选人被虐得死去活来的「伪装题」。很多人第一眼觉得「不就是维护一个最小值吗」,结果写出来的代码要么 getMin 是 O(n),要么空间爆炸,要么边界条件一堆 bug。
今天我带你从最暴力的实现,一步步进化到最优解,并且把所有容易踩的坑全部挖出来填平
题目回顾
设计一个支持 push、pop、top 操作,并能在 常数时间 内检索到 最小元素 的栈。
- push(x) —— 将元素 x 推入栈中
- pop() —— 删除栈顶元素
- top() —— 获取栈顶元素
- getMin() —— 检索栈中最小元素
要求:getMin() 操作必须是 O(1) 时间复杂度。
思路演化:四种实现方式
方案一:暴力法(面试直接去世)
// 1.js 中的实现
MiniStack.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;
}
缺点:
- getMin 是 O(n),完全不满足题目常数时间要求
- 每次调用都遍历一遍,时间复杂度灾难
面试官面无表情地在小本本上写下:PASS
方案二:辅助栈经典实现(大多数人的答案)
基本思路是:
- 用 stack 保存正常数据
- 用 minStack(辅助栈)保存「当前栈中的最小值历史」
关键规则:
- 入栈时:如果新元素 ≤ 辅助栈栈顶,就推入辅助栈(允许重复)
- 出栈时:如果弹出的元素 == 辅助栈栈顶,才把辅助栈也 pop
// 优化后的标准实现(推荐)
class MinStack {
constructor() {
this.stack = [];
this.minStack = []; // 辅助栈
}
push(x) {
this.stack.push(x);
// 注意这里是 <= ,不是 < !这是最容易写错的地方!
if (this.minStack.length === 0 || x <= this.minStack[this.minStack.length - 1]) {
this.minStack.push(x);
}
}
pop() {
const popped = this.stack.pop();
// 只有相等才弹出,保持两个栈同步
if (popped === this.minStack[this.minStack.length - 1]) {
this.minStack.pop();
}
return popped;
}
top() {
return this.stack[this.stack.length - 1];
}
getMin() {
if (this.minStack.length === 0) return null; // 或者抛错
return this.minStack[this.minStack.length - 1];
}
}
为什么必须是 <= 而不是 < ?—— 经典坑位!
这是这道题最容易出错的地方!很多人习惯性写成 <,然后就炸了。
举个例子:
push(3)
push(5)
push(2)
push(2) // 两个相同的 2
pop() // 弹出第二个 2
pop() // 再弹出一个 2
getMin() // 应该返回 3
如果你用 <:
minStack: [3, 2] // 第二个 2 没进来!
pop 2 后 → [3]
pop 2 后 → [3] // 正确
看起来没问题?
再来一个反例:
push(1)
push(1)
pop()
getMin() // 应该还是 1
如果用 <:
minStack: [1] // 第二个 1 没进来
pop 后 → [] // 空了!错!
所以必须用 <= 才能正确处理重复值!
这是面试中最常见的 bug,记住:永远用 <=
方案三:极致空间优化(一个栈 + 巧妙编码) 一种很奇妙的方法
我们发现辅助栈里存的其实就是「历史最小值」,而且很多时候是重复的。能不能只用一个栈?
可以!利用数学编码,把最小值信息藏在原栈里。
class MinStack {
constructor() {
this.stack = [];
this.min = Number.MAX_SAFE_INTEGER; // 当前最小值
}
push(x) {
// 第一次,或者比当前最小还小
if (this.stack.length === 0) {
this.stack.push(x);
this.min = x;
} else if (x < this.min) {
// 编码:存 2*x - min,相当于打个标记
this.stack.push(2 * x - this.min);
this.min = x;
} else {
this.stack.push(x);
}
}
pop() {
const top = this.stack.pop();
if (top < this.min) {
// 是编码过的值,需要解码恢复上一个 min
const prevMin = 2 * this.min - top;
this.min = prevMin;
return this.min; // 返回的是被编码的那个 x
} else {
return top;
}
}
top() {
const top = this.stack[this.stack.length - 1];
return top < this.min ? this.min : top;
}
getMin() {
return this.min;
}
}
单栈正确法:用公式“藏”历史 min
核心:每当最小值更新时,不直接 push 新值,而是 push 一个“假值”来记住旧 min。
公式:假值 = 2 * 新最小值 - 旧最小值
这个假值一定 < 新最小值(因为旧最小值 > 新最小值,所以减完是负的或更小的)。
以后看到栈顶 < 当前 min,就知道它是“假值”,用公式剥开恢复旧 min。
完整手推对比(同一个例子)
操作:push(3) → push(5) → push(2) → pop() → getMin()
单纯 min 变量(错):
| 操作 | stack | min |
|---|---|---|
| push(3) | [3] | 3 |
| push(5) | [3,5] | 3 |
| push(2) | [3,5,2] | 2 |
| pop() | [3,5] | 2 ← 错!应该 3 |
单栈法(对):
| 操作 | 实际想 push | 当前 min | 编码计算 | 实际存入栈 | stack 内容 | 说明 |
|---|---|---|---|---|---|---|
| push(3) | 3 | 无 → 3 | 无 | 3 | [3] | 第一个正常 |
| push(5) | 5 | 3 | 5 ≥ 3,不编码 | 5 | [3,5] | 正常存 |
| push(2) | 2 | 3 | 2 < 3 → 编码 2*2 - 3 = 1 | 1 | [3,5,1] | 藏了旧 min=3 |
| pop() | - | 2 | 栈顶 1 < min=2 → 是假值! 真实弹出值 = 当前 min = 2 恢复旧 min = 2*2 - 1 = 3 | 弹出 1 | [3,5] | 自动恢复到 3 |
| getMin() | - | 3 | - | - | - | 正确! |
看到没?
- 栈里存的 1 不是真实值,而是一个存档,里面藏着“上一个 min 是 3”
- pop 时通过 < 当前 min 判断它是存档,公式一算就恢复
再推一个“重复最小值”例子(证明公式稳)
push(4) → push(4) → push(1) → pop() → pop()
| 操作 | 实际 push | 当前 min | 编码 | 实际存入 | stack | 说明 |
|---|---|---|---|---|---|---|
| push(4) | 4 | 4 | 无 | 4 | [4] | |
| push(4) | 4 | 4 | 4 == 4,不编码 | 4 | [4,4] | 相等正常存 |
| push(1) | 1 | 4 | 1 < 4 → 2*1 - 4 = -2 | -2 | [4,4,-2] | 藏旧 min=4 |
| pop() | - | 1 | 栈顶 -2 < 1 → 假值 真实弹出=1 恢复 min=2*1 - (-2)=4 | 弹出 -2 | [4,4] | 恢复到 4 |
| pop() | - | 4 | 栈顶 4 >= 4,正常弹出 4 | 弹出 4 | [4] |
完美!即使有重复,公式也稳。
为什么公式是 2*x - oldMin?能不能是别的?
可以,但这个最简单:
- 保证假值 < 新 min(因为 oldMin > x,所以 2x - oldMin < x)
- 恢复时:oldMin = 2*x - 假值 数学对称,永不出错
- JS 里 Number 范围大,不溢出;C++ 要用 long long
总结:和“单纯存 min”区别到底在哪?
| 项目 | 单纯 min 变量 | 单栈编码法 |
|---|---|---|
| 存历史最小值? | 否,只存当前 | 是,用公式藏在栈里 |
| pop 最小值后 | min 卡死,错 | 自动恢复上一个 |
| 空间 | O(1) 但错 | O(1) 且对 |
| 原理 | 静态变量 | 动态“存档点” |
单栈不是“存一个 min”,而是“每次最小值变了,就开一个存档点藏旧 min” 。
优点:只用一个栈,空间极致优化
缺点:容易溢出(JS 没事,C++/Java 要小心 long 范围),可读性差
方案四:终极优雅版(ES6 + 现代写法)
class MinStack {
constructor() {
this.stack = [];
this.minStack = [];
}
push(val) {
this.stack.push(val);
if (!this.minStack.length || val <= this.minStack.at(-1)) {
this.minStack.push(val);
}
}
pop() {
const val = this.stack.pop();
if (val === this.minStack.at(-1)) {
this.minStack.pop();
}
return val;
}
top() {
return this.stack.at(-1);
}
getMin() {
return this.minStack.at(-1);
}
}
使用了 at(-1) 这个现代 API,代码极简优雅,推荐在面试中使用(除非面试官要求兼容 IE)
完整测试用例(建议手敲验证)
const minStack = new MinStack();
minStack.push(-2);
minStack.push(0);
minStack.push(-3);
console.log(minStack.getMin()); // -3
minStack.pop();
console.log(minStack.top()); // 0
console.log(minStack.getMin()); // -2
边界情况:
minStack.push(1);
minStack.push(1);
minStack.pop();
console.log(minStack.getMin()); // 1 正确
// 空栈情况
minStack.pop();
minStack.pop();
console.log(minStack.getMin()); // 应该处理空栈
复杂度分析
| 实现方式 | 时间复杂度 | 空间复杂度 | 推荐指数 |
|---|---|---|---|
| 暴力遍历 | getMin O(n) | O(1) | 0星 |
| 双栈(标准) | 全部 O(1) | O(n) | 5星 |
| 单栈编码 | 全部 O(1) | O(1) | 4星 |
| ES6 优雅版 | 全部 O(1) | O(n) | 5星 |
面试官追问清单(防不胜防)
- 如果栈特别大,有没有空间优化的方案?→ 回答单栈编码法
- 为什么辅助栈要用 <= 而不是 < ?→ 举重复值例子
- 如果元素是对象怎么办?→ 存索引或引用
- 支持并发怎么办?→ 加锁或使用线程安全栈
- 如何支持 getMax?→ 再加一个 maxStack,逻辑完全一样
总结:一句话记住最小栈
「主栈存数据,辅助栈存当前所有元素的最小值历史,且保持栈顶永远是当前最小值」
只要记住这句人话 + 永远用 <=,这道题就再也难不倒你。
最后
这道题本质考察的是:你能不能用空间换时间,并且处理好边界和重复值。
它出现在无数大厂面试中:字节、腾讯、阿里、美团、拼多多…… 只要你写对 <=,基本稳过。
喜欢记得点个赞+收藏,下次面试直接抄就行