“栈”不住的面试题?手撕最小栈,从 O(n) 到 O(1) 的降维打击!

17 阅读5分钟

“栈”不住的面试题?手撕最小栈,从 O(n) 到 O(1) 的降维打击!

面试官:“实现一个支持 getMin 的栈。”
你:“简单,遍历一下就行。”
面试官(微笑):“那时间复杂度呢?”
你(冷汗):“……”

别慌!今天我们就用 JavaScript + ES5 构造函数,把“最小栈”这个高频算法题彻底吃透。不仅搞定 O(1) 实现,还要深挖背后的辅助栈设计思想、单调栈特性、内存权衡,甚至关联到大厂常考的设计模式与性能优化策略

虽然它不是每场面试都会出现的“必考题”,但作为一道经典的数据结构典型题,它被阿里、腾讯、字节等一线大厂在初级/中级工程师面试中反复使用——原因很简单:小而精,能快速检验基本功


一、为什么“最小栈”频频出现在大厂面试中?

LeetCode 第 155 题《Min Stack》要求我们设计一个栈,在支持常规操作的同时,以 O(1) 时间复杂度获取当前最小值

这道题之所以成为高频题,并非因为它多难,而是因为它能高效考察多个维度的能力

  • 是否理解时间与空间的权衡
  • 能否处理边界条件与重复值
  • 是否具备将抽象数据结构映射到实际问题的能力

现实中,类似需求并不少见:

  • 浏览器历史记录中快速获取最早访问时间
  • 股票交易系统中实时追踪最低买入价
  • 撤销/重做(Undo/Redo)功能中的状态快照管理

所以,这道题不仅是算法题,更是工程思维的缩影


二、暴力解法:O(n) 的“老实人”写法

先看第一版代码(很多人初学时会这么写):

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

MinStack.prototype.getMin = function() {
    let minValue = Infinity;
    for (let i = 0; i < this.stack.length; i++) {
        if (this.stack[i] < minValue) {
            minValue = this.stack[i];
        }
    }
    return minValue;
}

✅ 优点:

  • 逻辑直观,易于实现
  • 空间复杂度 O(1)

❌ 缺点:

  • getMin() 时间复杂度为 O(n),不满足题目 O(1) 要求
  • 在高频调用场景下(如每 push 后查 min),性能急剧下降

三、进阶解法:O(1) 的“空间换时间”艺术

为了达到 O(1) 的 getMin,我们引入辅助栈(Auxiliary Stack)

const MinStack = function() {
    this.stack = [];   // 主栈:存储所有元素
    this.stack2 = [];  // 辅助栈:单调非递增,栈顶始终是最小值
}

MinStack.prototype.push = function(x) {
    this.stack.push(x);
    // 关键:只有当 x <= 辅助栈栈顶时,才入辅助栈
    if (this.stack2.length === 0 || this.stack2[this.stack2.length - 1] >= x) {
        this.stack2.push(x);
    }
}

MinStack.prototype.pop = function() {
    const top = this.stack.pop();
    // 如果弹出的元素等于辅助栈栈顶,则辅助栈也弹出
    if (top === this.stack2[this.stack2.length - 1]) {
        this.stack2.pop();
    }
    return top;
}

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

MinStack.prototype.getMin = function() {
    return this.stack2[this.stack2.length - 1]; // O(1)!
}

🔍 逐行解析:为什么这么写?

1. 辅助栈的维护逻辑
  • push 时:仅当新元素 ≤ 当前最小值 才压入辅助栈。

    • 使用 >= 而非 > 是为了正确处理重复最小值(如 push 2, 2, 1, 1)。
    • 若忽略等号,pop 两次 1 后辅助栈会提前变空,导致后续 getMin 错误。
2. 单调栈的本质
  • stack2 是一个单调非递增栈(Monotonic Stack)。
  • 这种结构广泛应用于滑动窗口最值、柱状图最大矩形等问题。
3. 空间 vs 时间的工程权衡
  • 空间复杂度:最坏 O(n)(当输入序列单调递减)
  • 但换来的是 所有操作 O(1) ,符合“用空间换时间”的经典工程策略。

🧠 大厂延伸问法
“如果内存非常紧张,还能优化吗?”
→ 可考虑差值编码法(只存与当前最小值的差),但会引入整数溢出风险,且代码复杂度高。在绝大多数场景下,辅助栈仍是最佳实践


四、深度拓展:背后的技术原理

1. 为什么不直接用 Set 或 Map?

  • Set 无法记录重复最小值(如两个 1)
  • Map 虽可计数,但 getMin 仍需 O(log n)(若基于红黑树)或无法保证顺序
  • 栈的 LIFO 特性天然匹配辅助栈的同步进出机制

2. 与前端开发有何关系?

  • 虽然本题不涉及 DOM 或框架,但栈是 JS 运行时的核心结构

    • 函数调用栈(Call Stack)
    • React Fiber 的协调回溯机制
    • 浏览器 history API 的前进/后退
  • 理解栈的 O(1) 操作,有助于理解 V8 如何高效管理执行上下文。

3. 设计模式的影子

  • 辅助栈本质是一种 Memoization(记忆化) :提前缓存计算结果,避免重复开销。
  • 也可视为对“最小值状态”的代理缓存,体现“关注点分离”思想。

五、高频面试题关联

相关题目考察点
MaxStack同理,辅助栈维护最大值
用两个栈实现队列栈与队列的转换思维
LRU Cache同样是空间换时间,但用哈希表+双向链表
滑动窗口最大值单调队列 vs 单调栈

六、总结:从“能跑”到“优雅”

  • 暴力解法:适合快速验证思路,但无法通过性能要求。
  • 辅助栈解法:体现工程权衡,是工业级标准答案。
  • 关键细节:重复值处理、空栈防御、API 一致性。

最后送大家一句面试心得:
“不会 O(1) 的最小栈,不一定挂;但如果你能讲清为什么用辅助栈、如何处理边界、空间是否可优化——那你已经赢了大多数人。”

赶紧动手实现一遍,下次面试官再问,你就可以微笑着说:
“我不仅会写,还能讲清楚背后的工程取舍。”