手撕「最小栈」:从 O(n) 到 O(1),带你彻底搞懂辅助栈的精髓

64 阅读7分钟

手撕「最小栈」:从 O(n) 到 O(1),带你彻底搞懂辅助栈的精髓

大家好!今天我们来聊一道经典的面试高频题 —— LeetCode 155. 最小栈。

这道题虽然是 Easy 难度,但却是无数候选人被虐得死去活来的「伪装题」。很多人第一眼觉得「不就是维护一个最小值吗」,结果写出来的代码要么 getMin 是 O(n),要么空间爆炸,要么边界条件一堆 bug。

今天我带你从最暴力的实现,一步步进化到最优解,并且把所有容易踩的坑全部挖出来填平

题目回顾

设计一个支持 push、pop、top 操作,并能在 常数时间 内检索到 最小元素 的栈。

  • push(x) —— 将元素 x 推入栈中
  • pop() —— 删除栈顶元素
  • top() —— 获取栈顶元素
  • getMin() —— 检索栈中最小元素

要求:getMin() 操作必须是 O(1) 时间复杂度。

思路演化:四种实现方式

方案一:暴力法(面试直接去世)

// 1.js 中的实现
MiniStack.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;
}

缺点:

  • getMin 是 O(n),完全不满足题目常数时间要求
  • 每次调用都遍历一遍,时间复杂度灾难

面试官面无表情地在小本本上写下:PASS

方案二:辅助栈经典实现(大多数人的答案)

基本思路是:

  • 用 stack 保存正常数据
  • 用 minStack(辅助栈)保存「当前栈中的最小值历史」

关键规则:

  1. 入栈时:如果新元素 ≤ 辅助栈栈顶,就推入辅助栈(允许重复)
  2. 出栈时:如果弹出的元素 == 辅助栈栈顶,才把辅助栈也 pop
// 优化后的标准实现(推荐)
class MinStack {
    constructor() {
        this.stack = [];
        this.minStack = []; // 辅助栈
    }

    push(x) {
        this.stack.push(x);
        // 注意这里是 <= ,不是 < !这是最容易写错的地方!
        if (this.minStack.length === 0 || x <= this.minStack[this.minStack.length - 1]) {
            this.minStack.push(x);
        }
    }

    pop() {
        const popped = this.stack.pop();
        // 只有相等才弹出,保持两个栈同步
        if (popped === this.minStack[this.minStack.length - 1]) {
            this.minStack.pop();
        }
        return popped;
    }

    top() {
        return this.stack[this.stack.length - 1];
    }

    getMin() {
        if (this.minStack.length === 0) return null; // 或者抛错
        return this.minStack[this.minStack.length - 1];
    }
}

为什么必须是 <= 而不是 < ?—— 经典坑位!

这是这道题最容易出错的地方!很多人习惯性写成 <,然后就炸了。

举个例子:

push(3)
push(5)
push(2)
push(2)   // 两个相同的 2
pop()     // 弹出第二个 2
pop()     // 再弹出一个 2
getMin()  // 应该返回 3

如果你用 <:

minStack: [3, 2]   // 第二个 2 没进来!
pop 2 后 → [3]
pop 2 后 → [3]     // 正确

看起来没问题?

再来一个反例:

push(1)
push(1)
pop()
getMin() // 应该还是 1

如果用 <:

minStack: [1]        // 第二个 1 没进来
pop 后 → []          // 空了!错!

所以必须用 <= 才能正确处理重复值!

这是面试中最常见的 bug,记住:永远用 <=

方案三:极致空间优化(一个栈 + 巧妙编码) 一种很奇妙的方法

我们发现辅助栈里存的其实就是「历史最小值」,而且很多时候是重复的。能不能只用一个栈?

可以!利用数学编码,把最小值信息藏在原栈里。

class MinStack {
    constructor() {
        this.stack = [];
        this.min = Number.MAX_SAFE_INTEGER; // 当前最小值
    }

    push(x) {
        // 第一次,或者比当前最小还小
        if (this.stack.length === 0) {
            this.stack.push(x);
            this.min = x;
        } else if (x < this.min) {
            // 编码:存 2*x - min,相当于打个标记
            this.stack.push(2 * x - this.min);
            this.min = x;
        } else {
            this.stack.push(x);
        }
    }

    pop() {
        const top = this.stack.pop();
        if (top < this.min) {
            // 是编码过的值,需要解码恢复上一个 min
            const prevMin = 2 * this.min - top;
            this.min = prevMin;
            return this.min; // 返回的是被编码的那个 x
        } else {
            return top;
        }
    }

    top() {
        const top = this.stack[this.stack.length - 1];
        return top < this.min ? this.min : top;
    }

    getMin() {
        return this.min;
    }
}

单栈正确法:用公式“藏”历史 min

核心:每当最小值更新时,不直接 push 新值,而是 push 一个“假值”来记住旧 min

公式:假值 = 2 * 新最小值 - 旧最小值

这个假值一定 < 新最小值(因为旧最小值 > 新最小值,所以减完是负的或更小的)。

以后看到栈顶 < 当前 min,就知道它是“假值”,用公式剥开恢复旧 min。


完整手推对比(同一个例子)

操作:push(3) → push(5) → push(2) → pop() → getMin()

单纯 min 变量(错):
操作stackmin
push(3)[3]3
push(5)[3,5]3
push(2)[3,5,2]2
pop()[3,5]2 ← 错!应该 3
单栈法(对):
操作实际想 push当前 min编码计算实际存入栈stack 内容说明
push(3)3无 → 33[3]第一个正常
push(5)535 ≥ 3,不编码5[3,5]正常存
push(2)232 < 3 → 编码 2*2 - 3 = 11[3,5,1]藏了旧 min=3
pop()-2栈顶 1 < min=2 → 是假值! 真实弹出值 = 当前 min = 2 恢复旧 min = 2*2 - 1 = 3弹出 1[3,5]自动恢复到 3
getMin()-3---正确!

看到没?

  • 栈里存的 1 不是真实值,而是一个存档,里面藏着“上一个 min 是 3”
  • pop 时通过 < 当前 min 判断它是存档,公式一算就恢复

再推一个“重复最小值”例子(证明公式稳)

push(4) → push(4) → push(1) → pop() → pop()

操作实际 push当前 min编码实际存入stack说明
push(4)444[4]
push(4)444 == 4,不编码4[4,4]相等正常存
push(1)141 < 4 → 2*1 - 4 = -2-2[4,4,-2]藏旧 min=4
pop()-1栈顶 -2 < 1 → 假值 真实弹出=1 恢复 min=2*1 - (-2)=4弹出 -2[4,4]恢复到 4
pop()-4栈顶 4 >= 4,正常弹出 4弹出 4[4]

完美!即使有重复,公式也稳。


为什么公式是 2*x - oldMin?能不能是别的?

可以,但这个最简单:

  • 保证假值 < 新 min(因为 oldMin > x,所以 2x - oldMin < x)
  • 恢复时:oldMin = 2*x - 假值 数学对称,永不出错
  • JS 里 Number 范围大,不溢出;C++ 要用 long long

总结:和“单纯存 min”区别到底在哪?

项目单纯 min 变量单栈编码法
存历史最小值?否,只存当前是,用公式藏在栈里
pop 最小值后min 卡死,错自动恢复上一个
空间O(1) 但错O(1) 且对
原理静态变量动态“存档点”

单栈不是“存一个 min”,而是“每次最小值变了,就开一个存档点藏旧 min”

优点:只用一个栈,空间极致优化
缺点:容易溢出(JS 没事,C++/Java 要小心 long 范围),可读性差

方案四:终极优雅版(ES6 + 现代写法)

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

    push(val) {
        this.stack.push(val);
        if (!this.minStack.length || val <= this.minStack.at(-1)) {
            this.minStack.push(val);
        }
    }

    pop() {
        const val = this.stack.pop();
        if (val === this.minStack.at(-1)) {
            this.minStack.pop();
        }
        return val;
    }

    top() {
        return this.stack.at(-1);
    }

    getMin() {
        return this.minStack.at(-1);
    }
}

使用了 at(-1) 这个现代 API,代码极简优雅,推荐在面试中使用(除非面试官要求兼容 IE)

完整测试用例(建议手敲验证)

const minStack = new MinStack();
minStack.push(-2);
minStack.push(0);
minStack.push(-3);
console.log(minStack.getMin()); // -3
minStack.pop();
console.log(minStack.top());    // 0
console.log(minStack.getMin()); // -2

边界情况:

minStack.push(1);
minStack.push(1);
minStack.pop();
console.log(minStack.getMin()); // 1 正确

// 空栈情况
minStack.pop();
minStack.pop();
console.log(minStack.getMin()); // 应该处理空栈

复杂度分析

实现方式时间复杂度空间复杂度推荐指数
暴力遍历getMin O(n)O(1)0星
双栈(标准)全部 O(1)O(n)5星
单栈编码全部 O(1)O(1)4星
ES6 优雅版全部 O(1)O(n)5星

面试官追问清单(防不胜防)

  1. 如果栈特别大,有没有空间优化的方案?→ 回答单栈编码法
  2. 为什么辅助栈要用 <= 而不是 < ?→ 举重复值例子
  3. 如果元素是对象怎么办?→ 存索引或引用
  4. 支持并发怎么办?→ 加锁或使用线程安全栈
  5. 如何支持 getMax?→ 再加一个 maxStack,逻辑完全一样

总结:一句话记住最小栈

「主栈存数据,辅助栈存当前所有元素的最小值历史,且保持栈顶永远是当前最小值」

只要记住这句人话 + 永远用 <=,这道题就再也难不倒你。

最后

这道题本质考察的是:你能不能用空间换时间,并且处理好边界和重复值。

它出现在无数大厂面试中:字节、腾讯、阿里、美团、拼多多…… 只要你写对 <=,基本稳过。

喜欢记得点个赞+收藏,下次面试直接抄就行