一、题目背景:什么是「最小栈」?
经典面试题:设计一个栈结构,要求支持以下四个操作,并且时间复杂度都为 O(1):
push(x):元素入栈pop():元素出栈top():获取栈顶元素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 === 0) {
return;
}
return this.stack[this.stack.length - 1];
};
这里:
push/pop/top都是标准栈操作,时间复杂度 O(1)- 目前为止,它只是一个“普通栈”,还没有最小值相关的逻辑
接下来要考虑:如何拿到当前栈里的最小值?
三、朴素方案:每次遍历一遍(O(n) 的 getMin)
最直接的想法是:每次需要最小值,就遍历一遍栈。
MiniStack.prototype.getMin = function() {
let minValue = Infinity; // 无穷大
const { stack } = this;
for (let i = 0; i < stack.length; i++) {
if (stack[i] < minValue) {
minValue = stack[i];
}
}
return minValue;
};
-
思路
- 定义一个
minValue,初始为Infinity - 遍历栈中所有元素,更新最小值
- 定义一个
-
复杂度
getMin的时间复杂度是 O(n)- 如果栈里有 10 万个元素,每查一次最小值就要扫 10 万次
在有些场景里,这已经够用了;但如果题目要求 getMin 必须是 O(1),或者你频繁查询最小值,这个方案就有明显性能问题。
四、为什么需要优化?
设想一个场景:
- 栈里有 10 万个元素
- 每秒要执行 几百次
getMin
如果每次 getMin 都是 O(n) 的遍历:
- 每秒要做“几十万到上百万次”比较
- 耗时会明显上来,性能瓶颈很容易出现在这里
更理想的设计是:
- 在入栈/出栈时,顺手维护好最小值信息
- 等到真正查询最小值时,只需 O(1) 时间返回即可
这就是典型的“用空间换时间”的思路。
五、思路升级:辅助栈 + 单调栈思想
1. 核心设计
我们可以再维护一个“辅助栈”,专门用来记录当前最小值的变化:
- 主栈(数据栈) :存所有的元素
- 辅助栈(最小值栈) :栈顶永远是当前所有元素中的最小值
规则如下:
-
入栈
push(x)- 步骤 1:把
x压入主栈 - 步骤 2:如果辅助栈为空,或者
x <= 辅助栈栈顶,再把x压入辅助栈
- 步骤 1:把
-
出栈
pop()- 步骤 1:从主栈弹出一个元素
y - 步骤 2:如果
y === 辅助栈栈顶,说明当前最小值被弹出了,辅助栈也要同步弹出一次
- 步骤 1:从主栈弹出一个元素
这样设计之后:
- 主栈:保存所有数据
- 辅助栈:保存“到当前为止的历史最小值”,本质上是一个单调不增栈
- 每次调用
getMin时,只要看一眼辅助栈的栈顶即可,时间复杂度 O(1)
2. 代码实现
const MiniStack = function() {
this.stack1 = []; // 主栈:存所有元素
this.stack2 = []; // 辅助栈:存历史最小值(单调栈)
};
MiniStack.prototype.push = function(x) {
this.stack1.push(x);
if (this.stack2.length === 0 || x <= this.stack2[this.stack2.length - 1]) {
this.stack2.push(x); // 单调栈:只在变小或相等时入辅助栈
}
};
MiniStack.prototype.pop = function() {
const value = this.stack1.pop();
if (value == this.stack2[this.stack2.length - 1]) {
this.stack2.pop();
}
return value;
};
MiniStack.prototype.top = function() {
return this.stack1[this.stack1.length - 1];
};
MiniStack.prototype.getMin = function() {
return this.stack2[this.stack2.length - 1];
};
六、复杂度与空间的权衡
-
时间复杂度
push:O(1)pop:O(1)top:O(1)getMin:O(1) —— 只读辅助栈栈顶
-
空间复杂度
- 多维护了一个辅助栈
- 最坏情况下(每次入栈都是新的更小值),辅助栈大小 ≈ 主栈大小
- 本质是多了一倍级别的空间开销,用来换取 O(1) 的
getMin
这是很多数据结构题的常见套路:
“不用额外空间就要多遍历几次;想要 O(1),就加一层辅助结构存中间信息。”
七、实现中容易踩的坑
在实际写代码时,有一些细节很容易写错,举两个常见的点:
-
top的边界判断-
正确写法应该是:
- 当栈不存在或长度为 0 时,返回空值
-
示例:
MiniStack.prototype.top = function() { if (!this.stack || this.stack.length === 0) { return; } return this.stack[this.stack.length - 1]; }; -
如果不小心写成
if (!this.stack || this.stack.length) return;,
那么只要长度大于 0,条件就成立,函数直接return,永远拿不到栈顶,这就是一个典型的逻辑 bug。
-
-
pop与辅助栈同步-
一定要记得:
- 只有当弹出的元素等于当前最小值时,辅助栈才需要同步
pop
- 只有当弹出的元素等于当前最小值时,辅助栈才需要同步
-
否则辅助栈会“卡在旧的最小值”,与真实数据不同步,
getMin就不正确了。
-
八、两种方案对比小结
-
方案一:单栈 + 每次遍历找最小值
-
优点
- 实现简单,逻辑直观
-
缺点
getMin是 O(n),频繁查询时性能很差
-
-
方案二:双栈 + 辅助栈(单调栈)
-
优点
push / pop / top / getMin全部是 O(1)- 适合面试和高频查询最小值的场景
-
缺点
- 代码略复杂一点
- 需要额外维护一个栈,空间开销更大
-
九、在面试中如何有条理地回答?
在面试被问到“如何实现一个支持 O(1) 取最小值的栈?”时,可以按这个顺序回答:
-
先给出朴素解法
- 说明用一个普通栈存数据
getMin每次遍历一遍栈,时间复杂度 O(n)
-
再说明为什么需要优化
- 频繁查询最小值时,O(n) 会成为性能瓶颈
-
最后提出双栈优化方案
- 再维护一个辅助栈,栈顶永远是当前最小值
- 入栈时:如果新元素小于等于当前最小值,同步压入辅助栈
- 出栈时:如果弹出的刚好是当前最小值,辅助栈也弹出一次
- 此时 4 个操作均为 O(1)
这种回答方式既表现出你的基础实现能力,又体现出对复杂度和优化的思考过程,非常加分。