用 JS 实现支持 O(1) 取最小值的栈

47 阅读5分钟

一、题目背景:什么是「最小栈」?

经典面试题:设计一个栈结构,要求支持以下四个操作,并且时间复杂度都为 O(1):

  • push(x) :元素入栈
  • pop() :元素出栈
  • top() :获取栈顶元素
  • getMin() :获取当前栈内的最小值

普通栈只关心“后进先出”,不关心最小值。要在 O(1) 时间拿到最小值,就必须在数据结构设计上做一点“小优化”。

二、从普通栈开始:只用一个数组

先从最简单的栈开始,用数组模拟栈结构:

// 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;
    }
    return this.stack[this.stack.length - 1];
};

这里:

  • push / pop / top 都是标准栈操作,时间复杂度 O(1)
  • 目前为止,它只是一个“普通栈”,还没有最小值相关的逻辑

接下来要考虑:如何拿到当前栈里的最小值?

三、朴素方案:每次遍历一遍(O(n) 的 getMin)

最直接的想法是:每次需要最小值,就遍历一遍栈

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;
};
  • 思路

    • 定义一个 minValue,初始为 Infinity
    • 遍历栈中所有元素,更新最小值
  • 复杂度

    • getMin 的时间复杂度是 O(n)
    • 如果栈里有 10 万个元素,每查一次最小值就要扫 10 万次

在有些场景里,这已经够用了;但如果题目要求 getMin 必须是 O(1),或者你频繁查询最小值,这个方案就有明显性能问题。

四、为什么需要优化?

设想一个场景:

  • 栈里有 10 万个元素
  • 每秒要执行 几百次 getMin

如果每次 getMin 都是 O(n) 的遍历:

  • 每秒要做“几十万到上百万次”比较
  • 耗时会明显上来,性能瓶颈很容易出现在这里

更理想的设计是:

  • 在入栈/出栈时,顺手维护好最小值信息
  • 等到真正查询最小值时,只需 O(1) 时间返回即可

这就是典型的“用空间换时间”的思路。

五、思路升级:辅助栈 + 单调栈思想

1. 核心设计

我们可以再维护一个“辅助栈”,专门用来记录当前最小值的变化

  • 主栈(数据栈) :存所有的元素
  • 辅助栈(最小值栈) :栈顶永远是当前所有元素中的最小值

规则如下:

  • 入栈 push(x)

    • 步骤 1:把 x 压入主栈
    • 步骤 2:如果辅助栈为空,或者 x <= 辅助栈栈顶,再把 x 压入辅助栈
  • 出栈 pop()

    • 步骤 1:从主栈弹出一个元素 y
    • 步骤 2:如果 y === 辅助栈栈顶,说明当前最小值被弹出了,辅助栈也要同步弹出一次

这样设计之后:

  • 主栈:保存所有数据
  • 辅助栈:保存“到当前为止的历史最小值”,本质上是一个单调不增栈
  • 每次调用 getMin 时,只要看一眼辅助栈的栈顶即可,时间复杂度 O(1)

2. 代码实现

const MiniStack = function() {
    this.stack1 = []; // 主栈:存所有元素
    this.stack2 = []; // 辅助栈:存历史最小值(单调栈)
};

MiniStack.prototype.push = function(x) {
    this.stack1.push(x);
    if (this.stack2.length === 0 || x <= this.stack2[this.stack2.length - 1]) {
        this.stack2.push(x); // 单调栈:只在变小或相等时入辅助栈
    }
};

MiniStack.prototype.pop = function() {
    const value = this.stack1.pop();
    if (value == this.stack2[this.stack2.length - 1]) {
        this.stack2.pop();
    }
    return value;
};

MiniStack.prototype.top = function() {
    return this.stack1[this.stack1.length - 1];
};

MiniStack.prototype.getMin = function() {
    return this.stack2[this.stack2.length - 1];
};

六、复杂度与空间的权衡

  • 时间复杂度

    • push:O(1)
    • pop:O(1)
    • top:O(1)
    • getMin:O(1) —— 只读辅助栈栈顶
  • 空间复杂度

    • 多维护了一个辅助栈
    • 最坏情况下(每次入栈都是新的更小值),辅助栈大小 ≈ 主栈大小
    • 本质是多了一倍级别的空间开销,用来换取 O(1) 的 getMin

这是很多数据结构题的常见套路:
“不用额外空间就要多遍历几次;想要 O(1),就加一层辅助结构存中间信息。”

七、实现中容易踩的坑

在实际写代码时,有一些细节很容易写错,举两个常见的点:

  • top 的边界判断

    • 正确写法应该是:

      • 当栈不存在或长度为 0 时,返回空值
    • 示例:

      MiniStack.prototype.top = function() {
          if (!this.stack || this.stack.length === 0) {
              return;
          }
          return this.stack[this.stack.length - 1];
      };
      
    • 如果不小心写成 if (!this.stack || this.stack.length) return;
      那么只要长度大于 0,条件就成立,函数直接 return,永远拿不到栈顶,这就是一个典型的逻辑 bug。

  • pop 与辅助栈同步

    • 一定要记得:

      • 只有当弹出的元素等于当前最小值时,辅助栈才需要同步 pop
    • 否则辅助栈会“卡在旧的最小值”,与真实数据不同步,getMin 就不正确了。

八、两种方案对比小结

  • 方案一:单栈 + 每次遍历找最小值

    • 优点

      • 实现简单,逻辑直观
    • 缺点

      • getMin 是 O(n),频繁查询时性能很差
  • 方案二:双栈 + 辅助栈(单调栈)

    • 优点

      • push / pop / top / getMin 全部是 O(1)
      • 适合面试和高频查询最小值的场景
    • 缺点

      • 代码略复杂一点
      • 需要额外维护一个栈,空间开销更大

九、在面试中如何有条理地回答?

在面试被问到“如何实现一个支持 O(1) 取最小值的栈?”时,可以按这个顺序回答:

  • 先给出朴素解法

    • 说明用一个普通栈存数据
    • getMin 每次遍历一遍栈,时间复杂度 O(n)
  • 再说明为什么需要优化

    • 频繁查询最小值时,O(n) 会成为性能瓶颈
  • 最后提出双栈优化方案

    • 再维护一个辅助栈,栈顶永远是当前最小值
    • 入栈时:如果新元素小于等于当前最小值,同步压入辅助栈
    • 出栈时:如果弹出的刚好是当前最小值,辅助栈也弹出一次
    • 此时 4 个操作均为 O(1)

这种回答方式既表现出你的基础实现能力,又体现出对复杂度和优化的思考过程,非常加分。