最小栈问题的 3 种经典解法

54 阅读9分钟

在栈的基础操作上增加 “常数时间获取最小值” 的需求,是算法面试中高频出现的经典题目。本文将从问题本质出发,拆解 3 种主流解法的设计思路、JS 实现、复杂度分析,并深入探讨面试官的高频追问点,帮你彻底掌握这道题的核心逻辑。

一、问题回顾

设计一个支持 pushpoptop 操作,且能在 O (1) 时间 内检索最小元素的栈(MinStack),核心要求如下:

  • 所有操作的时间复杂度尽可能最优(尤其是 getMin 必须为 O (1))
  • 空间复杂度需在合理范围内
  • 处理边界情况(如栈空时操作已被题目限制,无需额外处理)

示例输入输出已明确,核心矛盾是:普通栈的 getMin 操作需要遍历栈元素(O (n) 时间),如何通过额外空间换时间,实现常数时间获取最小值?

二、3 种经典解法(JS 实现)

解法 1:辅助栈(同步最小元素)

核心思路

用两个栈实现:

  • 主栈 stack:存储所有元素,负责 pushpoptop 基础操作

  • 辅助栈 minStack同步存储主栈中对应位置的最小值

    • push 时:若新元素 ≤ minStack 栈顶(或 minStack 为空),则新元素同时入 minStack;否则将 minStack 栈顶元素再次入栈(保证两栈长度一致)
    • pop 时:两栈同时弹出元素(主栈弹出实际值,minStack 弹出对应最小值)
    • getMin 时:直接返回 minStack 栈顶元素

JS 实现

class MinStack {
  constructor() {
    this.stack = []; // 主栈:存储所有元素
    this.minStack = []; // 辅助栈:存储对应位置的最小值
  }

  push(val) {
    this.stack.push(val);
    // 辅助栈同步入栈:空栈或新元素更小/相等时,入新元素;否则入当前最小值
    if (this.minStack.length === 0 || val <= this.minStack[this.minStack.length - 1]) {
      this.minStack.push(val);
    } else {
      this.minStack.push(this.minStack[this.minStack.length - 1]);
    }
  }

  pop() {
    this.stack.pop();
    this.minStack.pop(); // 同步弹出,保证两栈长度一致
  }

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

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

选择理由

  • 最经典、最易理解的解法,面试中优先推荐(思路清晰,无逻辑漏洞)
  • 操作对称性强:push/pop 时两栈同步操作,避免边界判断错误
  • 稳定性高:即使多次 push 相同最小值,或弹出后恢复上一个最小值,都能正确处理

时间复杂度

  • push/pop/top/getMin 均为 O (1):所有操作都是栈的顶端操作,无循环遍历

空间复杂度

  • O (n):最坏情况下(元素单调递减),minStack 存储所有元素,与主栈空间相当

解法 2:辅助栈(优化空间,仅存最小值变更)

核心思路

对解法 1 的空间优化:minStack 仅存储 最小值发生变化时的元素,而非与主栈同步长度

  • push 时:仅当新元素 ≤ 当前最小值(或 minStack 为空)时,才入 minStack(记录最小值更新)
  • pop 时:仅当主栈弹出的元素 == minStack 栈顶元素(即当前最小值被弹出)时,minStack 才弹出(恢复上一个最小值)
  • getMin 仍返回 minStack 栈顶元素

JS 实现

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

  push(val) {
    this.stack.push(val);
    // 仅当新元素≤当前最小值时,才入辅助栈
    if (this.minStack.length === 0 || val <= this.minStack[this.minStack.length - 1]) {
      this.minStack.push(val);
    }
  }

  pop() {
    const topVal = this.stack.pop();
    // 若弹出的是当前最小值,辅助栈同步弹出
    if (topVal === this.minStack[this.minStack.length - 1]) {
      this.minStack.pop();
    }
  }

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

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

选择理由

  • 空间更优:当栈中存在大量重复元素或非递减元素时,minStack 长度远小于主栈(例如 push 1,2,3,4,minStack 仅存 [1])
  • 逻辑更简洁:减少辅助栈的冗余存储,适合对空间敏感的场景
  • 本质与解法 1 一致,仅优化空间,理解成本低

时间复杂度

  • 所有操作仍为 O (1):pop 时的判断是常数时间,无额外遍历

空间复杂度

  • 最好 O (1)(元素单调递增时,minStack 仅存 1 个元素)
  • 最坏 O (n)(元素单调递减时,与解法 1 一致)
  • 平均空间复杂度优于解法 1

解法 3:单栈存储(不使用辅助栈,存差值)

核心思路

不额外创建辅助栈,仅用一个栈存储 当前元素与当前最小值的差值,同时用变量 min 记录当前最小值

  • 核心逻辑:

    1. push 时:

      • 若栈为空,min = val,栈存入 0(差值为 0)
      • 若栈非空,计算 diff = val - min,栈存入 diff
      • 若 diff < 0(说明新元素更小),更新 min = val
    2. pop 时:

      • 弹出栈顶 diff,若 diff < 0(说明弹出的是之前的最小值),则恢复上一个最小值:min = min - diff(推导见下文)
      • 栈顶元素的真实值:若 diff >= 0 则为 min + diff,若 diff < 0 则为 min(弹出前的 min
    3. top 时:根据栈顶 diff 计算真实值

    4. getMin 时:直接返回 min 变量

关键推导(恢复上一个最小值)

假设当前 min = newVal(新元素更小),存入的 diff = newVal - oldMindiff < 0)当弹出该 diff 时,需要恢复 oldMin = newVal - diff(代入 diff 可得:oldMin = newVal - (newVal - oldMin) = oldMin,推导成立)

JS 实现

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

  push(val) {
    if (this.stack.length === 0) {
      this.min = val;
      this.stack.push(0); // 第一个元素,差值为0
    } else {
      const diff = val - this.min;
      this.stack.push(diff);
      // 若diff<0,说明新元素更小,更新min
      if (diff < 0) {
        this.min = val;
      }
    }
  }

  pop() {
    const diff = this.stack.pop();
    // 若diff<0,说明弹出的是之前的最小值,恢复上一个min
    if (diff < 0) {
      this.min = this.min - diff;
    }
  }

  top() {
    const diff = this.stack[this.stack.length - 1];
    // diff>=0:真实值=min+diff;diff<0:真实值=min(当前min是该元素)
    return diff >= 0 ? this.min + diff : this.min;
  }

  getMin() {
    return this.min;
  }
}

选择理由

  • 空间最优:仅用一个栈和一个变量,空间复杂度严格 O (n)(无额外辅助栈开销)
  • 考察逻辑推导能力:适合面试中展示算法思维深度
  • 无冗余存储:所有信息都通过 “差值 + 当前最小值” 隐含,设计巧妙

时间复杂度

  • 所有操作均为 O (1):无任何循环,仅通过差值计算和变量更新实现

空间复杂度

  • O (n):仅一个栈存储差值,无额外空间开销(最优空间复杂度)

三、解法对比与选择建议

解法时间复杂度空间复杂度(最坏)核心优势核心劣势适用场景
同步辅助栈O (1) 所有操作O(n)思路简单、稳定、易调试空间冗余面试优先推荐、工程实现(追求稳定性)
优化辅助栈O (1) 所有操作O(n)空间更优、逻辑简洁需判断弹出元素是否为最小值空间敏感场景、日常开发
单栈差值法O (1) 所有操作O(n)空间最优、设计巧妙逻辑复杂、易出错(差值计算)面试展示算法思维、极致空间优化

面试选择建议

  1. 优先说解法 1(同步辅助栈):思路清晰,面试官易理解,不易出错,能快速通过基础考察
  2. 主动补充解法 2(优化辅助栈):展示空间优化意识,说明 “为什么可以优化”(冗余存储的问题)
  3. 进阶补充解法 3(单栈差值法):若面试官追问 “能否不用辅助栈”,可展示该解法,体现逻辑推导能力

四、面试官高频 “拷打” 问题与应答

这道题的核心考察点是 “空间换时间” 的思想,以及边界情况处理能力。面试官通常会从以下角度追问:

1. 为什么辅助栈要存 “≤” 当前最小值,而不是 “<”?

  • 应答:为了处理重复最小值的情况。例如连续 push -2、-2:

    • 若用 “<”,辅助栈仅存第一个 -2;当弹出第二个 -2 时,主栈弹出后仍有 -2,但辅助栈已空,getMin 会出错
    • 用 “≤” 可以让辅助栈同步存储重复最小值,确保弹出后仍能正确获取剩余元素的最小值

2. 解法 2(优化辅助栈)中,pop 时为什么要判断 “弹出元素 == minStack 栈顶”?

  • 应答:因为辅助栈仅存储 “最小值变更” 的元素,主栈中大部分元素(非最小值)弹出时,不会影响当前最小值,所以辅助栈无需弹出;只有当 “当前最小值被弹出” 时,才需要从辅助栈中恢复上一个最小值。

3. 单栈差值法中,是否会出现整数溢出问题?

  • 应答:在 JS 中不会,因为 JS 没有整数溢出限制(数值以 64 位浮点数存储);但在 Java/C++ 等语言中,当 val 和 min 为极值时(如 Integer.MIN_VALUE),val - min 会溢出,需要用 long 类型存储差值。
  • 延伸:这是该解法的语言局限性,面试中需主动提及,展示考虑问题的全面性。

4. 除了这三种方法,还有其他解法吗?

  • 应答:有,但不推荐:

    • 暴力法:getMin 时遍历栈(O (n) 时间,不符合题目要求)
    • 链表法:每个节点存储当前值和当前最小值(本质与辅助栈一致,空间复杂度 O (n),但实现更复杂)
  • 结论:最优解仍是辅助栈系列或单栈差值法,平衡了时间和空间。

5. 如何验证你的解法是正确的?

  • 应答:结合示例和边界场景测试:

    • 示例场景:push (-2,0,-3) → getMin (-3) → pop → top (0) → getMin (-2)
    • 边界场景:连续 push 相同值(如 push (5,5,5))、单调递增(1,2,3)、单调递减(3,2,1)、空栈(题目已限制操作时非空,无需处理)
    • 极值场景:push (Integer.MIN_VALUE)、push (Integer.MAX_VALUE)

五、总结

最小栈问题的核心是 “用空间换时间”,通过额外存储最小值相关信息,将 getMin 操作从 O (n) 优化到 O (1)。三种解法各有侧重:

  • 同步辅助栈:稳、易理解,面试首选
  • 优化辅助栈:省空间、逻辑简洁,日常开发推荐
  • 单栈差值法:空间最优、思维巧妙,面试进阶展示

面试中,不仅要写出正确的代码,更要能解释 “为什么这么设计”“不同解法的优劣”“边界情况如何处理”,这才是面试官真正考察的核心 —— 算法思维和问题分析能力。掌握这道题的三种解法,既能应对基础面试,也能从容应对深度追问,帮你在面试中脱颖而出。