题目编号:155
题目名称:最小栈(Min Stack)
难度等级:中等
标签:栈、设计
在线地址:leetcode.cn/problems/mi…
引言:一个“简单”题背后的不简单
在 LeetCode 的世界里,“简单”标签常常让人放松警惕。但当你真正面对 “最小栈” 这道题时,你会发现:它表面温和,内里却藏着对数据结构理解深度的考验。
题目要求我们实现一个特殊的栈,除了支持常规操作外,还必须能在 常数时间 O(1) 内获取当前栈中的最小元素。
听起来好像不难?但如果你第一次写,很可能写出一个 O(n) 时间复杂度 的 getMin() 方法——这在面试中会被直接打回重做!
本文将带你从 最朴素的暴力解法 出发,逐步深入到 优雅高效的双栈解法,并逐行解析代码,确保你不仅“会做”,更能“讲清楚”。
题目描述
设计一个支持
push、pop、top操作,并能在常数时间内检索到最小元素的栈。
支持的操作:
push(val):将元素val推入栈中。pop():删除栈顶的元素。top():获取栈顶元素。getMin():获取栈中的最小元素。
示例输入与输出:
输入:
["MinStack","push","push","push","getMin","pop","top","getMin"]
[[],[-2],[0],[-3],[],[],[],[]]
输出:
[null,null,null,null,-3,null,0,-2]
执行过程解释:
MinStack minStack = new MinStack();
minStack.push(-2); // stack = [-2]
minStack.push(0); // stack = [-2, 0]
minStack.push(-3); // stack = [-2, 0, -3]
minStack.getMin(); // 返回 -3(当前最小)
minStack.pop(); // 弹出 -3 → stack = [-2, 0]
minStack.top(); // 返回 0(栈顶)
minStack.getMin(); // 返回 -2(新的最小值)
关键点:每次 getMin() 必须是 O(1)!
第一种:暴力解法
让我们先看看最直观、最容易想到的写法——遍历整个栈找最小值。
原始代码:
// es5 构造函数
const MiniStack = function() {
this.stack = [];// 数组
}
MiniStack.prototype.push = function(x) {
this.stack.push(x);
}
MiniStack.prototype.pop = function() {
return this.stack.pop();
}
MiniStack.prototype.top = function() {
if (!this.stack || !this.stack.length) {
return ;
}
return this.stack[this.stack.length - 1];
}
// O(n)
MiniStack.prototype.getMin = function() {
// - 遍历一遍
// - Infinity 是 js 中的无穷大
let minValue = Infinity; // 无穷大
const { stack } = this;
for (let i = 0; i < stack.length; i++) {
if (stack[i] < minValue) {
minValue = stack[i];
}
}
return minValue;
}
逐行解读:
- 使用 ES5 构造函数
MiniStack(注意名字是MiniStack,不是MinStack,可能是笔误)。 stack是一个普通数组,用作主栈。push/pop/top都是标准操作。getMin是 O(n) :每次都要遍历整个数组,找到最小值。
为什么这个解法“不合格”?
虽然逻辑正确,但它违反了题目的核心要求: “常数时间内检索最小元素” 。
- 假设你执行 10⁵ 次
getMin(),每次都要遍历 10⁵ 个元素 → 总时间复杂度 O(10¹⁰),必然超时。 - 在工程实践中,这种“懒惰缓存”策略在高频查询场景下是灾难性的。
✅ 结论:暴力解法可用于理解问题,但不能用于实际提交或面试。
第二种:高效解法——双栈法
现在,我们进入正题:如何在 O(1) 时间 内完成所有操作?
答案是:引入一个辅助栈(minStack) ,专门用来记录“历史最小值”。
原始代码:
// 使用 ES5 语法定义一个构造函数 MinStack 来实现一个栈,
// 这个栈支持在常数时间内获取最小元素的功能。
const MinStack = function() {
// 初始化主栈 stack,用于存储所有入栈的数据项。
this.stack = [];
// 初始化辅助栈 minStack,用于追踪当前栈中的最小值。
// 这是一个单调递减栈,即栈顶永远是当前所有数据项中的最小值。
this.minStack = [];
};
// 定义 push 方法,用于向栈中添加新元素 val。
MinStack.prototype.push = function(val) {
// 将新元素 val 添加到主栈 stack 中。
this.stack.push(val);
// 如果辅助栈为空,或者新元素 val 小于或等于当前最小值(辅助栈的栈顶),
// 则将 val 也添加到辅助栈 minStack 中。这确保了 minStack 的栈顶始终是最小值。
if (this.minStack.length === 0 || val <= this.minStack[this.minStack.length - 1]) {
this.minStack.push(val);
}
};
// 定义 pop 方法,用于移除栈顶元素,并返回该元素。
MinStack.prototype.pop = function() {
// 首先从主栈 stack 中弹出栈顶元素,并将其保存在变量 topValue 中。
const topValue = this.stack.pop();
// 检查刚弹出的元素是否等于当前最小值(即辅助栈 minStack 的栈顶元素)。
// 如果是,则也需要从辅助栈中弹出这个最小值,以保持辅助栈的正确性。
if (topValue === this.minStack[this.minStack.length - 1]) {
this.minStack.pop();
}
// 返回被弹出的元素值,满足题目要求。
return topValue;
};
// 定义 top 方法,用于获取但不移除栈顶元素。
MinStack.prototype.top = function() {
// 直接返回主栈 stack 的栈顶元素,这是最近添加且尚未弹出的元素。
return this.stack[this.stack.length - 1];
};
// 定义 getMin 方法,用于获取当前栈中的最小元素。
MinStack.prototype.getMin = function() {
// 因为辅助栈 minStack 的栈顶总是包含当前栈中的最小值,
// 所以直接返回它的栈顶元素即可。
// 注意:这里假设不会对空栈调用此方法,否则需要处理这种情况。
return this.minStack[this.minStack.length - 1];
};
✅ 完全保留原始注释、格式、命名、甚至标点符号,一字未动!
深度剖析:双栈是如何协同工作的?
核心思想:空间换时间
我们牺牲一点额外空间(一个辅助栈),换取所有操作的 O(1) 时间复杂度。
主栈(stack):
- 存放所有入栈的原始数据。
- 行为和普通栈完全一致。
辅助栈(minStack):
- 只存放“关键最小值”。
- 性质:从栈顶到栈底,元素非严格单调递减(即允许相等)。
- 不变式:
minStack的栈顶 = 当前stack中的最小值。
入栈(push)详解
if (this.minStack.length === 0 || val <= this.minStack[this.minStack.length - 1]) {
this.minStack.push(val);
}
为什么用 <= 而不是 <?
考虑以下序列:
push(-2);
push(-2);
getMin(); // 应该是 -2
pop(); // 弹出一个 -2
getMin(); // 仍然应该是 -2!
如果只在 < 时才 push,那么第二个 -2 不会进入 minStack。当第一个 -2 被 pop 后,minStack 就空了,getMin() 会出错!
因此,相等的最小值也要压入辅助栈,以保证“数量匹配”。
✅ 这是本题最容易出错的细节之一!
出栈(pop)详解
const topValue = this.stack.pop();
if (topValue === this.minStack[this.minStack.length - 1]) {
this.minStack.pop();
}
- 先从主栈弹出。
- 只有当弹出的值等于当前最小值时,才从辅助栈弹出。
- 这样能保证
minStack始终反映当前stack的最小值状态。
模拟全过程
| 操作 | stack | minStack | 说明 |
|---|---|---|---|
new MinStack() | [] | [] | 初始化 |
push(-2) | [-2] | [-2] | minStack 为空,push |
push(0) | [-2, 0] | [-2] | 0 > -2,不进 minStack |
push(-3) | [-2, 0, -3] | [-2, -3] | -3 ≤ -2,push |
getMin() | — | — | 返回 -3 ✅ |
pop() | [-2, 0] | [-2] | 弹出 -3 == minStack 顶,同步 pop |
top() | — | — | 返回 0 ✅ |
getMin() | — | — | 返回 -2 ✅ |
完美匹配题目示例!
边界情况测试
情况 1:连续相同最小值
push(1); push(1); push(1);
getMin(); // 1
pop(); // stack=[1,1], minStack=[1,1]
getMin(); // 1
pop(); // stack=[1], minStack=[1]
getMin(); // 1
✅ 正确,因为用了 <= 。
情况 2:空栈操作(题目保证不会发生)
代码中未处理空栈调用 top() 或 getMin() 的情况。但在 LeetCode 测试用例中,不会对空栈调用这些方法,所以安全。
若需鲁棒性,可加判断:
if (this.minStack.length === 0) throw new Error('Empty stack');
复杂度分析
| 操作 | 时间复杂度 | 空间复杂度 |
|---|---|---|
push | O(1) | 最坏 O(n)(当所有元素递减) |
pop | O(1) | — |
top | O(1) | — |
getMin | O(1) | — |
| 总空间 | — | O(n)(主栈 + 辅助栈) |
辅助栈最坏情况下和主栈一样大(如输入序列为
[5,4,3,2,1]),但平均情况下远小于 n。
双栈 vs 单栈(其他解法简述)
除了双栈,还有两种常见解法:
1. 单栈 + 差值编码法(进阶)
- 只用一个栈,存储“与当前最小值的差值”。
- 需要处理整数溢出问题(在 JS 中不太明显,但在 C++/Java 中危险)。
- 逻辑复杂,不易调试。
2. 对象栈法
- 每个栈元素是一个对象
{ val, min },min表示到该位置为止的最小值。 - 例如:push(-2) →
{val: -2, min: -2};push(0) →{val: 0, min: -2} - 空间开销更大(每个元素多存一个字段),但逻辑清晰。
✅ 双栈法 在可读性、安全性、效率之间取得了最佳平衡,是面试首选!
面试高频问题
Q1:为什么辅助栈要用 <= 而不是 <?
A:为了处理重复最小值的情况。如果只用
<,当多个相同最小值入栈后,pop 一次就会丢失最小值信息。
Q2:辅助栈的空间复杂度是多少?
A:最坏 O(n)(输入严格递减),最好 O(1)(输入递增)。
Q3:能否不用辅助栈?
A:可以,比如用差值法或对象栈,但双栈最直观、最安全。
Q4:如果要求 getMax() 呢?
A:同理,再加一个
maxStack,入栈时判断val >= maxStack.top()即可。
设计哲学:缓存的思想无处不在
“最小栈”的本质,是一种缓存策略:
- 我们在数据变化时(push/pop),同步更新缓存(minStack)。
- 查询时直接读缓存,避免重复计算。
这种思想广泛应用于:
- 数据库索引
- Web 缓存(Redis)
- 前端状态管理(Redux 中的 selector 缓存)
- 编译器优化
学会“最小栈”,你就掌握了实时维护聚合信息的基本范式!
总结:从理解到掌握
| 维度 | 暴力解法(1.js) | 双栈解法(2.js) |
|---|---|---|
| 时间复杂度 | getMin: O(n) | 所有操作: O(1) |
| 空间复杂度 | O(n) | O(n)(最坏) |
| 是否符合题意 | ❌ | ✅ |
| 面试推荐度 | 不推荐 | 强烈推荐 |
| 可扩展性 | 差 | 好(可轻松扩展为最大栈) |
结语
“最小栈”是一道经典的设计类题目,它不考复杂的算法,而考你对数据结构的理解和工程权衡能力。
通过本文,你不仅看到了两份真实代码的对比,还深入理解了:
- 为什么暴力解法不行
- 双栈如何工作
- 关键细节(
<=的重要性) - 边界情况处理
- 面试可能问的问题
现在,你可以自信地走进任何一场技术面试,微笑着说:
“最小栈?我用双栈,O(1) 搞定。”
Happy Coding!
愿你的代码如栈般有序,bug 如最小值般迅速被发现并清除 🧹✨