解锁最小栈:从O(n)到O(1)的效率跃迁

75 阅读4分钟

解锁最小栈:从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.

以上面示例为例,我们一步步看主栈与辅助栈的变化:

  1. MinStack初始化:主栈[],辅助栈[]
  2. push(-2):主栈空,辅助栈同步推入,主栈[-2],辅助栈[-2]
  3. push(0):0>-2,辅助栈不推,主栈[-2,0],辅助栈[-2]
  4. push(-3):-3<-2,辅助栈同步推入,主栈[-2,0,-3],辅助栈[-2,-3]
  5. getMin():返回辅助栈顶-3
  6. pop():主栈弹出-3,与辅助栈顶一致,辅助栈弹出-3,主栈[-2,0],辅助栈[-2]
  7. top():返回主栈顶0
  8. 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),但这是实现常数时间检索的必要代价。

这个问题的核心启示在于:当单一数据结构无法满足多操作的效率要求时,不妨引入辅助结构,通过合理的同步规则,将复杂操作拆解为简单的栈顶访问,这也是栈类问题中“单调栈”思想的基础应用。