🧮在算法与数据结构的世界中,栈(Stack) 是一种经典的后进先出(LIFO, Last In First Out)线性结构。而 最小栈(Min Stack) 则是在普通栈的基础上,增加了一个关键功能:在常数时间内获取当前栈中的最小元素。这个问题在 LeetCode 第 155 题中被提出,是考察对栈操作和空间换时间思想的重要题目。
本文将深入探讨最小栈的两种主流实现方式:单一栈遍历法 和 双栈辅助法,详细分析其原理、代码实现、时间/空间复杂度,并通过示例演示其运行过程。同时,我们还将补充相关背景知识、边界处理技巧以及工程实践建议。
🔍 问题定义
设计一个支持以下操作的栈:
push(x):将元素 x 推入栈中。pop():删除栈顶的元素。top():获取栈顶元素。getMin():检索栈中的最小元素。
要求:getMin() 操作必须在 常数时间 O(1) 内完成。
⚠️ 注意:普通栈本身不支持快速获取最小值。若每次调用
getMin()都遍历整个栈,则时间复杂度为 O(n),无法满足题目要求。
📦 方法一:单一栈 + 遍历查找(简单但低效)
💡 思路概述
使用一个普通的数组作为栈,所有操作(push, pop, top)都按常规方式进行。
当需要获取最小值时,遍历整个栈,找出最小元素。
✅ 优点
- 实现极其简单。
- 空间占用少,仅需一个栈。
❌ 缺点
getMin()时间复杂度为 O(n) ,效率低下。- 在高频调用
getMin()的场景下性能堪忧。
🧾 代码实现(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 undefined; // 或抛出异常
}
return this.stack[this.stack.length - 1];
};
// O(n) 时间复杂度获取最小值
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;
};
📊 复杂度分析
| 操作 | 时间复杂度 | 空间复杂度 |
|---|---|---|
push | O(1) | O(1) |
pop | O(1) | O(1) |
top | O(1) | O(1) |
getMin | O(n) | O(1) |
| 总空间 | — | O(n) |
虽然此方法“能用”,但在实际面试或工程中通常不被接受,因为它违背了题目的核心要求——常数时间获取最小值。
🚀 方法二:双栈辅助法(推荐方案)
💡 核心思想:空间换时间
引入第二个栈(辅助栈) ,专门用于记录历史最小值。
每当主栈发生变化(push/pop),辅助栈同步更新,确保其栈顶始终是当前主栈的最小值。
这种设计使得 getMin() 只需返回辅助栈的栈顶,时间复杂度为 O(1) 。
🧠 辅助栈的维护策略
有两种常见策略:
✅ 策略 A:全量同步(冗余但稳定)
- 每次
push(x)时,将min(当前最小值, x)压入辅助栈。 pop()时,两个栈同时弹出。- 辅助栈长度始终等于主栈长度。
示例初始化:
min_stack = [Infinity]作为基准。
✅ 策略 B:条件入栈(节省空间)
- 仅当
x <= 当前最小值时,才将x压入辅助栈。 pop()时,若弹出元素等于辅助栈顶,则辅助栈也弹出。- 辅助栈长度 ≤ 主栈长度。
本文将分别展示这两种策略的实现。
🧾 实现一:全量同步
var MinStack = function() {
this.x_stack = []; // 主栈
this.min_stack = [Infinity]; // 辅助栈,初始化为无穷大
};
MinStack.prototype.push = function(x) {
this.x_stack.push(x);
// 将当前最小值(与新元素比较后的较小者)压入辅助栈
this.min_stack.push(Math.min(this.min_stack[this.min_stack.length - 1], x));
};
MinStack.prototype.pop = function() {
this.x_stack.pop();
this.min_stack.pop(); // 同步弹出
};
MinStack.prototype.top = function() {
return this.x_stack[this.x_stack.length - 1];
};
MinStack.prototype.getMin = function() {
return this.min_stack[this.min_stack.length - 1];
};
🔁 运行示例
| 操作 | x_stack | min_stack | 说明 |
|---|---|---|---|
| push(3) | [3] | [∞, 3] | min(∞,3)=3 |
| push(5) | [3,5] | [∞,3,3] | min(3,5)=3 |
| push(2) | [3,5,2] | [∞,3,3,2] | min(3,2)=2 |
| push(1) | [3,5,2,1] | [∞,3,3,2,1] | min(2,1)=1 |
| getMin() | — | 返回 1 | ✅ O(1) |
| pop() | [3,5,2] | [∞,3,3,2] | 同步弹出 |
| getMin() | — | 返回 2 | ✅ |
| top() | 返回 2 | — | 主栈顶 |
✅ 优点:逻辑清晰,无需判断,适合教学。 ❌ 缺点:辅助栈可能包含大量重复值,空间略高。
🧾 实现二:条件入栈
const MinStack = function(){
this.stack = []; // 主栈
this.minStack = []; // 辅助栈,仅存必要最小值
};
MinStack.prototype.push = function(x){
this.stack.push(x);
// 仅当 minStack 为空,或 x ≤ 当前最小值时,才入辅助栈
if(this.minStack.length === 0 || x <= this.minStack[this.minStack.length - 1]){
this.minStack.push(x);
}
};
MinStack.prototype.pop = function(){
if(this.stack.length === 0) return;
const poppedItem = this.stack.pop();
// 若弹出的是当前最小值,则辅助栈也要弹出
if(poppedItem === this.minStack[this.minStack.length - 1]){
this.minStack.pop();
}
return poppedItem;
};
MinStack.prototype.top = function(){
if(this.stack.length === 0) return undefined;
return this.stack[this.stack.length - 1];
};
MinStack.prototype.getMin = function(){
if(this.minStack.length === 0) return undefined;
return this.minStack[this.minStack.length - 1];
};
🔁 运行示例(相同操作序列)
| 操作 | stack | minStack |
|---|---|---|
| push(3) | [3] | [3] |
| push(5) | [3,5] | [3] |
| push(2) | [3,5,2] | [3,2] |
| push(1) | [3,5,2,1] | [3,2,1] |
| getMin() | — | 返回 1 |
| pop() | [3,5,2] | [3,2] |
| getMin() | — | 返回 2 |
✅ 优点:节省空间,尤其当数据单调递增时,
minStack几乎不增长。 ⚠️ 注意:比较时使用<=而非<,是为了处理重复最小值的情况。
例如:push(2), push(2),若用<,第二个2不会入minStack,导致第一次pop()后minStack为空,错误!
📈 复杂度对比总结
| 方法 | push | pop | top | getMin | 空间复杂度 | 是否满足 O(1) getMin |
|---|---|---|---|---|---|---|
| 单一栈遍历 | O(1) | O(1) | O(1) | O(n) | O(n) | ❌ |
| 双栈(全量同步) | O(1) | O(1) | O(1) | O(1) | O(n) | ✅ |
| 双栈(条件入栈) | O(1) | O(1) | O(1) | O(1) | ≤ O(n) | ✅ |
🎯 结论:双栈法是标准解法,其中“条件入栈”更优,兼顾效率与空间。
🛠️ 工程实践建议
-
边界处理:
- 栈空时调用
pop/top/getMin应返回undefined或抛出异常(根据需求)。 - 初始化辅助栈为
[Infinity]可避免首次push的特殊判断(全量同步法)。
- 栈空时调用
-
重复值处理:
- 必须使用
x <= minTop而非x < minTop,否则多个相同最小值会导致minStack提前清空。
- 必须使用
-
语言特性利用:
- JavaScript 中数组天然支持栈操作(
push/pop),无需手动实现链表。 - 可考虑使用
class语法提升可读性(现代 ES6+)。
- JavaScript 中数组天然支持栈操作(
-
扩展思考:
- 能否用单栈 + 编码技巧实现?(如存储差值)——可行但复杂,不推荐。
- 若还需支持
getMax()?可再加一个最大值辅助栈。
🌟 总结
最小栈问题看似简单,实则蕴含空间换时间的经典算法思想。通过引入辅助数据结构(辅助栈),我们将一个 O(n) 的查询操作优化至 O(1),极大提升了性能。
无论是教学演示还是实际编码,双栈辅助法都是最优选择。而“条件入栈”策略在保证正确性的前提下,进一步优化了空间使用,体现了工程师对细节的把控。
掌握这一模式,不仅能解决 LeetCode 155,还能迁移到其他需要实时维护极值的场景,如滑动窗口最小值、单调栈问题等。
🧠 记住:当一个问题要求“快速获取历史最值”时,辅助栈/辅助队列往往是破局关键!