在栈的基础操作上增加 “常数时间获取最小值” 的需求,是算法面试中高频出现的经典题目。本文将从问题本质出发,拆解 3 种主流解法的设计思路、JS 实现、复杂度分析,并深入探讨面试官的高频追问点,帮你彻底掌握这道题的核心逻辑。
一、问题回顾
设计一个支持 push、pop、top 操作,且能在 O (1) 时间 内检索最小元素的栈(MinStack),核心要求如下:
- 所有操作的时间复杂度尽可能最优(尤其是
getMin必须为 O (1)) - 空间复杂度需在合理范围内
- 处理边界情况(如栈空时操作已被题目限制,无需额外处理)
示例输入输出已明确,核心矛盾是:普通栈的 getMin 操作需要遍历栈元素(O (n) 时间),如何通过额外空间换时间,实现常数时间获取最小值?
二、3 种经典解法(JS 实现)
解法 1:辅助栈(同步最小元素)
核心思路
用两个栈实现:
-
主栈
stack:存储所有元素,负责push、pop、top基础操作 -
辅助栈
minStack:同步存储主栈中对应位置的最小值push时:若新元素 ≤minStack栈顶(或minStack为空),则新元素同时入minStack;否则将minStack栈顶元素再次入栈(保证两栈长度一致)pop时:两栈同时弹出元素(主栈弹出实际值,minStack弹出对应最小值)getMin时:直接返回minStack栈顶元素
JS 实现
class MinStack {
constructor() {
this.stack = []; // 主栈:存储所有元素
this.minStack = []; // 辅助栈:存储对应位置的最小值
}
push(val) {
this.stack.push(val);
// 辅助栈同步入栈:空栈或新元素更小/相等时,入新元素;否则入当前最小值
if (this.minStack.length === 0 || val <= this.minStack[this.minStack.length - 1]) {
this.minStack.push(val);
} else {
this.minStack.push(this.minStack[this.minStack.length - 1]);
}
}
pop() {
this.stack.pop();
this.minStack.pop(); // 同步弹出,保证两栈长度一致
}
top() {
return this.stack[this.stack.length - 1];
}
getMin() {
return this.minStack[this.minStack.length - 1];
}
}
选择理由
- 最经典、最易理解的解法,面试中优先推荐(思路清晰,无逻辑漏洞)
- 操作对称性强:
push/pop时两栈同步操作,避免边界判断错误 - 稳定性高:即使多次
push相同最小值,或弹出后恢复上一个最小值,都能正确处理
时间复杂度
push/pop/top/getMin均为 O (1):所有操作都是栈的顶端操作,无循环遍历
空间复杂度
- O (n):最坏情况下(元素单调递减),
minStack存储所有元素,与主栈空间相当
解法 2:辅助栈(优化空间,仅存最小值变更)
核心思路
对解法 1 的空间优化:minStack 仅存储 最小值发生变化时的元素,而非与主栈同步长度
push时:仅当新元素 ≤ 当前最小值(或minStack为空)时,才入minStack(记录最小值更新)pop时:仅当主栈弹出的元素 ==minStack栈顶元素(即当前最小值被弹出)时,minStack才弹出(恢复上一个最小值)getMin仍返回minStack栈顶元素
JS 实现
class MinStack {
constructor() {
this.stack = [];
this.minStack = [];
}
push(val) {
this.stack.push(val);
// 仅当新元素≤当前最小值时,才入辅助栈
if (this.minStack.length === 0 || val <= this.minStack[this.minStack.length - 1]) {
this.minStack.push(val);
}
}
pop() {
const topVal = this.stack.pop();
// 若弹出的是当前最小值,辅助栈同步弹出
if (topVal === this.minStack[this.minStack.length - 1]) {
this.minStack.pop();
}
}
top() {
return this.stack[this.stack.length - 1];
}
getMin() {
return this.minStack[this.minStack.length - 1];
}
}
选择理由
- 空间更优:当栈中存在大量重复元素或非递减元素时,
minStack长度远小于主栈(例如 push 1,2,3,4,minStack仅存 [1]) - 逻辑更简洁:减少辅助栈的冗余存储,适合对空间敏感的场景
- 本质与解法 1 一致,仅优化空间,理解成本低
时间复杂度
- 所有操作仍为 O (1):
pop时的判断是常数时间,无额外遍历
空间复杂度
- 最好 O (1)(元素单调递增时,
minStack仅存 1 个元素) - 最坏 O (n)(元素单调递减时,与解法 1 一致)
- 平均空间复杂度优于解法 1
解法 3:单栈存储(不使用辅助栈,存差值)
核心思路
不额外创建辅助栈,仅用一个栈存储 当前元素与当前最小值的差值,同时用变量 min 记录当前最小值
-
核心逻辑:
-
push时:- 若栈为空,
min = val,栈存入 0(差值为 0) - 若栈非空,计算
diff = val - min,栈存入diff - 若
diff < 0(说明新元素更小),更新min = val
- 若栈为空,
-
pop时:- 弹出栈顶
diff,若diff < 0(说明弹出的是之前的最小值),则恢复上一个最小值:min = min - diff(推导见下文) - 栈顶元素的真实值:若
diff >= 0则为min + diff,若diff < 0则为min(弹出前的min)
- 弹出栈顶
-
top时:根据栈顶diff计算真实值 -
getMin时:直接返回min变量
-
关键推导(恢复上一个最小值)
假设当前 min = newVal(新元素更小),存入的 diff = newVal - oldMin(diff < 0)当弹出该 diff 时,需要恢复 oldMin = newVal - diff(代入 diff 可得:oldMin = newVal - (newVal - oldMin) = oldMin,推导成立)
JS 实现
class MinStack {
constructor() {
this.stack = [];
this.min = null; // 记录当前最小值
}
push(val) {
if (this.stack.length === 0) {
this.min = val;
this.stack.push(0); // 第一个元素,差值为0
} else {
const diff = val - this.min;
this.stack.push(diff);
// 若diff<0,说明新元素更小,更新min
if (diff < 0) {
this.min = val;
}
}
}
pop() {
const diff = this.stack.pop();
// 若diff<0,说明弹出的是之前的最小值,恢复上一个min
if (diff < 0) {
this.min = this.min - diff;
}
}
top() {
const diff = this.stack[this.stack.length - 1];
// diff>=0:真实值=min+diff;diff<0:真实值=min(当前min是该元素)
return diff >= 0 ? this.min + diff : this.min;
}
getMin() {
return this.min;
}
}
选择理由
- 空间最优:仅用一个栈和一个变量,空间复杂度严格 O (n)(无额外辅助栈开销)
- 考察逻辑推导能力:适合面试中展示算法思维深度
- 无冗余存储:所有信息都通过 “差值 + 当前最小值” 隐含,设计巧妙
时间复杂度
- 所有操作均为 O (1):无任何循环,仅通过差值计算和变量更新实现
空间复杂度
- O (n):仅一个栈存储差值,无额外空间开销(最优空间复杂度)
三、解法对比与选择建议
| 解法 | 时间复杂度 | 空间复杂度(最坏) | 核心优势 | 核心劣势 | 适用场景 |
|---|---|---|---|---|---|
| 同步辅助栈 | O (1) 所有操作 | O(n) | 思路简单、稳定、易调试 | 空间冗余 | 面试优先推荐、工程实现(追求稳定性) |
| 优化辅助栈 | O (1) 所有操作 | O(n) | 空间更优、逻辑简洁 | 需判断弹出元素是否为最小值 | 空间敏感场景、日常开发 |
| 单栈差值法 | O (1) 所有操作 | O(n) | 空间最优、设计巧妙 | 逻辑复杂、易出错(差值计算) | 面试展示算法思维、极致空间优化 |
面试选择建议
- 优先说解法 1(同步辅助栈):思路清晰,面试官易理解,不易出错,能快速通过基础考察
- 主动补充解法 2(优化辅助栈):展示空间优化意识,说明 “为什么可以优化”(冗余存储的问题)
- 进阶补充解法 3(单栈差值法):若面试官追问 “能否不用辅助栈”,可展示该解法,体现逻辑推导能力
四、面试官高频 “拷打” 问题与应答
这道题的核心考察点是 “空间换时间” 的思想,以及边界情况处理能力。面试官通常会从以下角度追问:
1. 为什么辅助栈要存 “≤” 当前最小值,而不是 “<”?
-
应答:为了处理重复最小值的情况。例如连续 push -2、-2:
- 若用 “<”,辅助栈仅存第一个 -2;当弹出第二个 -2 时,主栈弹出后仍有 -2,但辅助栈已空,
getMin会出错 - 用 “≤” 可以让辅助栈同步存储重复最小值,确保弹出后仍能正确获取剩余元素的最小值
- 若用 “<”,辅助栈仅存第一个 -2;当弹出第二个 -2 时,主栈弹出后仍有 -2,但辅助栈已空,
2. 解法 2(优化辅助栈)中,pop 时为什么要判断 “弹出元素 == minStack 栈顶”?
- 应答:因为辅助栈仅存储 “最小值变更” 的元素,主栈中大部分元素(非最小值)弹出时,不会影响当前最小值,所以辅助栈无需弹出;只有当 “当前最小值被弹出” 时,才需要从辅助栈中恢复上一个最小值。
3. 单栈差值法中,是否会出现整数溢出问题?
- 应答:在 JS 中不会,因为 JS 没有整数溢出限制(数值以 64 位浮点数存储);但在 Java/C++ 等语言中,当
val和min为极值时(如Integer.MIN_VALUE),val - min会溢出,需要用long类型存储差值。 - 延伸:这是该解法的语言局限性,面试中需主动提及,展示考虑问题的全面性。
4. 除了这三种方法,还有其他解法吗?
-
应答:有,但不推荐:
- 暴力法:
getMin时遍历栈(O (n) 时间,不符合题目要求) - 链表法:每个节点存储当前值和当前最小值(本质与辅助栈一致,空间复杂度 O (n),但实现更复杂)
- 暴力法:
-
结论:最优解仍是辅助栈系列或单栈差值法,平衡了时间和空间。
5. 如何验证你的解法是正确的?
-
应答:结合示例和边界场景测试:
- 示例场景:push (-2,0,-3) → getMin (-3) → pop → top (0) → getMin (-2)
- 边界场景:连续 push 相同值(如 push (5,5,5))、单调递增(1,2,3)、单调递减(3,2,1)、空栈(题目已限制操作时非空,无需处理)
- 极值场景:push (Integer.MIN_VALUE)、push (Integer.MAX_VALUE)
五、总结
最小栈问题的核心是 “用空间换时间”,通过额外存储最小值相关信息,将 getMin 操作从 O (n) 优化到 O (1)。三种解法各有侧重:
- 同步辅助栈:稳、易理解,面试首选
- 优化辅助栈:省空间、逻辑简洁,日常开发推荐
- 单栈差值法:空间最优、思维巧妙,面试进阶展示
面试中,不仅要写出正确的代码,更要能解释 “为什么这么设计”“不同解法的优劣”“边界情况如何处理”,这才是面试官真正考察的核心 —— 算法思维和问题分析能力。掌握这道题的三种解法,既能应对基础面试,也能从容应对深度追问,帮你在面试中脱颖而出。