最小栈:用辅助栈实现 O(1) 获取最小值的精巧设计

54 阅读4分钟

在算法与数据结构的世界中,(Stack)因其“后进先出”的特性被广泛应用于函数调用、表达式求值、括号匹配等场景。然而,当需求升级——例如要求在常数时间内获取栈中的最小元素时,标准栈便显得力不从心。此时,一个巧妙的解决方案应运而生:最小栈(Min Stack)。它不仅保留了栈的基本操作,还能以 O(1) 的时间复杂度返回当前最小值。本文将深入剖析其核心思想——辅助栈,并展示如何在 JavaScript 中高效实现这一结构。

问题的本质:为何不能直接遍历?

最直观的想法是在每次调用 getMin() 时遍历整个栈,找出最小值:

getMin() {
    let min = Infinity;
    for (let x of this.stack) {
        if (x < min) min = x;
    }
    return min;
}

这种方法逻辑简单,但时间复杂度为 O(n),在频繁调用的场景下性能堪忧。尤其当栈中元素成千上万时,每次查询都需线性扫描,显然不可接受。

另一种思路是维护一个单独的 min 变量,在每次 push 时更新。但这在 pop 时会遇到致命问题:一旦最小值被弹出,无法知道次小值是多少,除非重新遍历。

因此,我们需要一种能动态记录历史最小值的机制——这正是辅助栈的用武之地。

辅助栈:同步记录最小值的历史

最小栈的核心思想是引入第二个栈——辅助栈(minStack),专门用于存储当前栈状态下的最小值序列。两个栈的操作严格同步,但入栈条件更严格:

  • 主栈(stack):正常压入所有元素;
  • 辅助栈(minStack):仅当新元素 小于或等于 当前辅助栈顶时,才将其压入。

这种策略确保辅助栈始终是一个单调非递增栈,其栈顶永远是主栈当前的最小值。

入栈操作

push(x) {
    this.stack.push(x);
    if (this.minStack.length === 0 || x <= this.minStack[this.minStack.length - 1]) {
        this.minStack.push(x);
    }
}

例如,依次压入 5, 3, 4, 3, 2

  • 主栈:[5, 3, 4, 3, 2]
  • 辅助栈:[5, 3, 3, 2]

注意,第二个 3 被压入辅助栈,是因为它等于当前栈顶(3)。这保证了后续弹出第一个 3 时,辅助栈仍保留另一个 3 作为最小值。

出栈操作

pop() {
    const x = this.stack.pop();
    if (x === this.minStack[this.minStack.length - 1]) {
        this.minStack.pop();
    }
}

出栈时,若弹出的元素恰好是当前最小值,则同步从辅助栈中移除。这样,辅助栈顶始终反映主栈剩余元素的最小值。

继续上面的例子,若执行两次 pop()

  • 主栈变为 [5, 3, 4]
  • 辅助栈变为 [5, 3](因为弹出了 23

此时 getMin() 返回 3,正确无误。

查询操作

得益于辅助栈的设计,获取最小值变得极其简单:

getMin() {
    return this.minStack[this.minStack.length - 1];
}

由于辅助栈顶始终是最小值,该操作时间复杂度为 O(1) ,且无需任何计算。

为什么需要“等于”也入栈?

一个常见的疑问是:为何在 x === minStack.top() 时也要压入辅助栈?

答案在于处理重复最小值。假设只在 x < min 时入栈,那么对于输入 [3, 3]

  • 主栈:[3, 3]
  • 辅助栈:[3](第二个 3 未入栈)

当第一次 pop() 后,主栈剩 [3],但辅助栈已空,导致 getMin() 失效。

通过允许“等于”入栈,我们确保每个最小值实例都在辅助栈中有对应记录,从而在弹出时能准确同步。

空间与时间的权衡

辅助栈方案以额外 O(n) 空间为代价,换取了所有操作的 O(1) 时间复杂度。在大多数实际应用中,这种空间换时间的策略是值得的,尤其是当 getMin() 被高频调用时。

此外,辅助栈的内存开销通常小于最坏情况。例如,若数据单调递增(如 1, 2, 3, 4),辅助栈仅存储 [1],空间效率极高。

在 JavaScript 中的实现考量

使用 ES5 构造函数风格,我们可以清晰地组织最小栈的结构:

function MinStack() {
    this.stack = [];
    this.minStack = [];
}

MinStack.prototype.push = function(x) { /* ... */ };
MinStack.prototype.pop = function() { /* ... */ };
MinStack.prototype.top = function() { /* ... */ };
MinStack.prototype.getMin = function() { /* ... */ };

这种写法兼容性好,且将方法定义在原型上,避免了每个实例重复创建函数,节省内存。

若使用 ES6 类语法,可进一步利用私有字段增强封装性,但在本题约束下,构造函数模式已足够清晰高效。

结语:优雅源于约束

最小栈的设计之美,在于它没有试图改变栈的基本规则,而是在其约束下,通过引入一个轻量级的辅助结构,巧妙解决了看似矛盾的需求。它提醒我们:在工程中,最优解往往不是推翻重来,而是在现有框架内找到最契合的补充

辅助栈如同一个忠实的影子,默默记录着主栈的极值变迁。正是这种同步与克制,让 O(1) 的最小值查询成为可能——简洁、高效,且不失鲁棒。