解锁最小栈:从O(n)到O(1)的效率跃迁
在栈的基础操作中,push、pop、top早已是我们熟悉的“老伙计”,但当需求升级为“常数时间内获取最小元素”时,普通栈的局限性便暴露无遗。今天,我们就围绕这个经典的“最小栈”问题,从基础解法到优化方案,拆解其实现逻辑与性能优化思路。
一、问题核心:不止于“栈”,更要“快”
最小栈的核心需求的是在支持栈常规操作(push、pop、top)的同时,让getMin操作达到O(1)的时间复杂度。这一需求的背后是实际开发中的效率考量——若面对3×10⁴次高频调用,低效的实现会直接导致性能瓶颈。
先回顾普通栈的局限:普通栈的元素是无序的,要获取最小值只能遍历整个栈,时间复杂度为O(n)。因此,优化的关键在于如何“记住”当前栈内的最小值,避免重复遍历。
二、基础解法:遍历查找,简单却低效
最直观的思路是:用数组模拟主栈存储元素,当需要getMin时,遍历整个栈找到最小值。这种方法无需额外空间,但getMin的效率堪忧。
1. 代码实现(ES5构造函数)
// es5 构造函数
const MinStack = function () {
this.stack = []; // 用数组模拟主栈
}
// 入栈:直接将元素推入主栈
MinStack.prototype.push = function (x) {
this.stack.push(x);
}
// 出栈:直接弹出主栈顶部元素
MinStack.prototype.pop = function() {
return this.stack.pop();
}
// 获取栈顶元素:返回数组最后一位
MinStack.prototype.top = function() {
if(!this.stack || !this.stack.length) {
return;
}
return this.stack[this.stack.length - 1];
}
// 获取最小值:遍历主栈查找
MinStack.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;
}
2. 解法分析
该方案的push、pop、top操作均为O(1),但getMin需遍历整个栈,时间复杂度为O(n)。当栈内元素极多或getMin调用频繁时,会出现明显的性能问题,无法满足“常数时间检索”的核心要求。
三、最优解法:辅助栈,用空间换时间
核心思路是引入“辅助栈”,专门存储主栈对应状态下的最小值。通过保持辅助栈与主栈的“同步”,让辅助栈的栈顶始终是当前主栈的最小值,从而实现getMin的O(1)操作。
1. 辅助栈的核心逻辑
- 入栈规则:当主栈推入元素val时,若辅助栈为空,或val小于等于辅助栈栈顶元素,则将val也推入辅助栈(确保辅助栈栈顶始终是最小值);若val大于辅助栈栈顶,则不操作辅助栈。
- 出栈规则:当主栈弹出元素时,若弹出的元素与辅助栈栈顶元素相等,则辅助栈也同步弹出(确保两者状态一致);否则仅弹出主栈元素。
2. 代码实现(ES5构造函数)
// es5 构造函数
const MinStack = function () {
this.stack = []; // 主栈:存储所有元素
this.stack1 = []; // 辅助栈:存储对应状态的最小值
}
// 入栈:主栈必推,辅助栈按需推
MinStack.prototype.push = function (x) {
this.stack.push(x);
// 辅助栈空或当前值≤栈顶,才推入
if (this.stack1.length === 0 || this.stack1[this.stack1.length - 1] >= x) {
this.stack1.push(x);
}
}
// 出栈:主栈必弹,辅助栈按需弹
MinStack.prototype.pop = function () {
// 若主栈弹出值与辅助栈顶一致,辅助栈同步弹出
if (this.stack.pop() == this.stack1[this.stack1.length - 1]) {
this.stack1.pop();
}
}
// 获取栈顶:与基础解法一致
MinStack.prototype.top = function () {
if (!this.stack || !this.stack.length) {
return;
}
return this.stack[this.stack.length - 1];
}
// 获取最小值:直接取辅助栈顶
MinStack.prototype.getMin = function () {
return this.stack1[this.stack1.length - 1];
}
3. 结合示例理解执行流程
输入:
["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);
minStack.push(0);
minStack.push(-3);
minStack.getMin(); --> 返回 -3.
minStack.pop();
minStack.top(); --> 返回 0.
minStack.getMin(); --> 返回 -2.
以上面示例为例,我们一步步看主栈与辅助栈的变化:
- MinStack初始化:主栈[],辅助栈[]
- push(-2):主栈空,辅助栈同步推入,主栈[-2],辅助栈[-2]
- push(0):0>-2,辅助栈不推,主栈[-2,0],辅助栈[-2]
- push(-3):-3<-2,辅助栈同步推入,主栈[-2,0,-3],辅助栈[-2,-3]
- getMin():返回辅助栈顶-3
- pop():主栈弹出-3,与辅助栈顶一致,辅助栈弹出-3,主栈[-2,0],辅助栈[-2]
- top():返回主栈顶0
- getMin():返回辅助栈顶-2
整个流程完全匹配示例输出,且getMin始终只需读取辅助栈顶,实现了常数时间操作。
四、性能对比与总结
| 解法 | push时间 | pop时间 | top时间 | getMin时间 | 空间复杂度 |
|---|---|---|---|---|---|
| 遍历查找法 | O(1) | O(1) | O(1) | O(n) | O(n) |
| 辅助栈法 | O(1) | O(1) | O(1) | O(1) | O(n)(最坏情况) |
辅助栈法通过“空间换时间”的策略,将getMin的时间复杂度从O(n)优化到O(1),完美满足题目需求。最坏情况下(所有元素递减),辅助栈与主栈元素数量一致,空间复杂度仍为O(n),但这是实现常数时间检索的必要代价。
这个问题的核心启示在于:当单一数据结构无法满足多操作的效率要求时,不妨引入辅助结构,通过合理的同步规则,将复杂操作拆解为简单的栈顶访问,这也是栈类问题中“单调栈”思想的基础应用。