力扣155:最小栈——用两个栈玩转“常数时间找最小值”的魔法!

72 阅读8分钟

题目编号:155
题目名称:最小栈(Min Stack)
难度等级:中等
标签:栈、设计
在线地址leetcode.cn/problems/mi…


引言:一个“简单”题背后的不简单

在 LeetCode 的世界里,“简单”标签常常让人放松警惕。但当你真正面对 “最小栈” 这道题时,你会发现:它表面温和,内里却藏着对数据结构理解深度的考验。

题目要求我们实现一个特殊的栈,除了支持常规操作外,还必须能在 常数时间 O(1) 内获取当前栈中的最小元素。

听起来好像不难?但如果你第一次写,很可能写出一个 O(n) 时间复杂度getMin() 方法——这在面试中会被直接打回重做!

本文将带你从 最朴素的暴力解法 出发,逐步深入到 优雅高效的双栈解法,并逐行解析代码,确保你不仅“会做”,更能“讲清楚”。


题目描述

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

支持的操作:

  • push(val):将元素 val 推入栈中。
  • pop():删除栈顶的元素。
  • top():获取栈顶元素。
  • getMin():获取栈中的最小元素。

示例输入与输出:

输入:
["MinStack","push","push","push","getMin","pop","top","getMin"]
[[],[-2],[0],[-3],[],[],[],[]]

输出:
[null,null,null,null,-3,null,0,-2]

执行过程解释:

MinStack minStack = new MinStack();
minStack.push(-2);   // stack = [-2]
minStack.push(0);    // stack = [-2, 0]
minStack.push(-3);   // stack = [-2, 0, -3]
minStack.getMin();   // 返回 -3(当前最小)
minStack.pop();      // 弹出 -3 → stack = [-2, 0]
minStack.top();      // 返回 0(栈顶)
minStack.getMin();   // 返回 -2(新的最小值)

关键点:每次 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) {
    return ;
  }
  return this.stack[this.stack.length - 1];
}
// O(n)
MiniStack.prototype.getMin = function() {
  // - 遍历一遍
  // - Infinity 是 js 中的无穷大
  let minValue = Infinity; // 无穷大
  const { stack } = this;
  for (let i = 0; i < stack.length; i++) {
    if (stack[i] < minValue) {
      minValue = stack[i];
    }
  }
  return minValue;
}

逐行解读:

  • 使用 ES5 构造函数 MiniStack(注意名字是 MiniStack,不是 MinStack,可能是笔误)。
  • stack 是一个普通数组,用作主栈。
  • push / pop / top 都是标准操作。
  • getMin 是 O(n) :每次都要遍历整个数组,找到最小值。

为什么这个解法“不合格”?

虽然逻辑正确,但它违反了题目的核心要求: “常数时间内检索最小元素”

  • 假设你执行 10⁵ 次 getMin(),每次都要遍历 10⁵ 个元素 → 总时间复杂度 O(10¹⁰),必然超时。
  • 在工程实践中,这种“懒惰缓存”策略在高频查询场景下是灾难性的。

结论:暴力解法可用于理解问题,但不能用于实际提交或面试。


第二种:高效解法——双栈法

现在,我们进入正题:如何在 O(1) 时间 内完成所有操作?

答案是:引入一个辅助栈(minStack) ,专门用来记录“历史最小值”。

原始代码:

// 使用 ES5 语法定义一个构造函数 MinStack 来实现一个栈,
// 这个栈支持在常数时间内获取最小元素的功能。
const MinStack = function() {
  // 初始化主栈 stack,用于存储所有入栈的数据项。
  this.stack = [];
  // 初始化辅助栈 minStack,用于追踪当前栈中的最小值。
  // 这是一个单调递减栈,即栈顶永远是当前所有数据项中的最小值。
  this.minStack = [];
};

// 定义 push 方法,用于向栈中添加新元素 val。
MinStack.prototype.push = function(val) {
  // 将新元素 val 添加到主栈 stack 中。
  this.stack.push(val);
  // 如果辅助栈为空,或者新元素 val 小于或等于当前最小值(辅助栈的栈顶),
  // 则将 val 也添加到辅助栈 minStack 中。这确保了 minStack 的栈顶始终是最小值。
  if (this.minStack.length === 0 || val <= this.minStack[this.minStack.length - 1]) {
    this.minStack.push(val);
  }
};

// 定义 pop 方法,用于移除栈顶元素,并返回该元素。
MinStack.prototype.pop = function() {
  // 首先从主栈 stack 中弹出栈顶元素,并将其保存在变量 topValue 中。
  const topValue = this.stack.pop();
  // 检查刚弹出的元素是否等于当前最小值(即辅助栈 minStack 的栈顶元素)。
  // 如果是,则也需要从辅助栈中弹出这个最小值,以保持辅助栈的正确性。
  if (topValue === this.minStack[this.minStack.length - 1]) {
    this.minStack.pop();
  }
  // 返回被弹出的元素值,满足题目要求。
  return topValue;
};

// 定义 top 方法,用于获取但不移除栈顶元素。
MinStack.prototype.top = function() {
  // 直接返回主栈 stack 的栈顶元素,这是最近添加且尚未弹出的元素。
  return this.stack[this.stack.length - 1];
};

// 定义 getMin 方法,用于获取当前栈中的最小元素。
MinStack.prototype.getMin = function() {
  // 因为辅助栈 minStack 的栈顶总是包含当前栈中的最小值,
  // 所以直接返回它的栈顶元素即可。
  // 注意:这里假设不会对空栈调用此方法,否则需要处理这种情况。
  return this.minStack[this.minStack.length - 1];
};

完全保留原始注释、格式、命名、甚至标点符号,一字未动!


深度剖析:双栈是如何协同工作的?

核心思想:空间换时间

我们牺牲一点额外空间(一个辅助栈),换取所有操作的 O(1) 时间复杂度

主栈(stack):

  • 存放所有入栈的原始数据。
  • 行为和普通栈完全一致。

辅助栈(minStack):

  • 只存放“关键最小值”。
  • 性质:从栈顶到栈底,元素非严格单调递减(即允许相等)。
  • 不变式minStack 的栈顶 = 当前 stack 中的最小值。

入栈(push)详解

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

为什么用 <= 而不是 <

考虑以下序列:

push(-2);
push(-2);
getMin(); // 应该是 -2
pop();    // 弹出一个 -2
getMin(); // 仍然应该是 -2!

如果只在 < 时才 push,那么第二个 -2 不会进入 minStack。当第一个 -2 被 pop 后,minStack 就空了,getMin() 会出错!

因此,相等的最小值也要压入辅助栈,以保证“数量匹配”。

✅ 这是本题最容易出错的细节之一!


出栈(pop)详解

const topValue = this.stack.pop();
if (topValue === this.minStack[this.minStack.length - 1]) {
  this.minStack.pop();
}
  • 先从主栈弹出。
  • 只有当弹出的值等于当前最小值时,才从辅助栈弹出。
  • 这样能保证 minStack 始终反映当前 stack 的最小值状态。

模拟全过程

操作stackminStack说明
new MinStack()[][]初始化
push(-2)[-2][-2]minStack 为空,push
push(0)[-2, 0][-2]0 > -2,不进 minStack
push(-3)[-2, 0, -3][-2, -3]-3 ≤ -2,push
getMin()返回 -3
pop()[-2, 0][-2]弹出 -3 == minStack 顶,同步 pop
top()返回 0
getMin()返回 -2

完美匹配题目示例!


边界情况测试

情况 1:连续相同最小值

push(1); push(1); push(1);
getMin(); // 1
pop();    // stack=[1,1], minStack=[1,1]
getMin(); // 1
pop();    // stack=[1], minStack=[1]
getMin(); // 1

✅ 正确,因为用了  <= 

情况 2:空栈操作(题目保证不会发生)

代码中未处理空栈调用 top()getMin() 的情况。但在 LeetCode 测试用例中,不会对空栈调用这些方法,所以安全。

若需鲁棒性,可加判断:

if (this.minStack.length === 0) throw new Error('Empty stack');

复杂度分析

操作时间复杂度空间复杂度
pushO(1)最坏 O(n)(当所有元素递减)
popO(1)
topO(1)
getMinO(1)
总空间O(n)(主栈 + 辅助栈)

辅助栈最坏情况下和主栈一样大(如输入序列为 [5,4,3,2,1]),但平均情况下远小于 n。


双栈 vs 单栈(其他解法简述)

除了双栈,还有两种常见解法:

1. 单栈 + 差值编码法(进阶)

  • 只用一个栈,存储“与当前最小值的差值”。
  • 需要处理整数溢出问题(在 JS 中不太明显,但在 C++/Java 中危险)。
  • 逻辑复杂,不易调试。

2. 对象栈法

  • 每个栈元素是一个对象 { val, min }min 表示到该位置为止的最小值。
  • 例如:push(-2) → {val: -2, min: -2};push(0) → {val: 0, min: -2}
  • 空间开销更大(每个元素多存一个字段),但逻辑清晰。

双栈法 在可读性、安全性、效率之间取得了最佳平衡,是面试首选!


面试高频问题

Q1:为什么辅助栈要用 <= 而不是 <

A:为了处理重复最小值的情况。如果只用 <,当多个相同最小值入栈后,pop 一次就会丢失最小值信息。

Q2:辅助栈的空间复杂度是多少?

A:最坏 O(n)(输入严格递减),最好 O(1)(输入递增)。

Q3:能否不用辅助栈?

A:可以,比如用差值法或对象栈,但双栈最直观、最安全。

Q4:如果要求 getMax() 呢?

A:同理,再加一个 maxStack,入栈时判断 val >= maxStack.top() 即可。


设计哲学:缓存的思想无处不在

“最小栈”的本质,是一种缓存策略

  • 我们在数据变化时(push/pop),同步更新缓存(minStack)。
  • 查询时直接读缓存,避免重复计算。

这种思想广泛应用于:

  • 数据库索引
  • Web 缓存(Redis)
  • 前端状态管理(Redux 中的 selector 缓存)
  • 编译器优化

学会“最小栈”,你就掌握了实时维护聚合信息的基本范式!


总结:从理解到掌握

维度暴力解法(1.js双栈解法(2.js
时间复杂度getMin: O(n)所有操作: O(1)
空间复杂度O(n)O(n)(最坏)
是否符合题意
面试推荐度不推荐强烈推荐
可扩展性好(可轻松扩展为最大栈)

结语

“最小栈”是一道经典的设计类题目,它不考复杂的算法,而考你对数据结构的理解和工程权衡能力。

通过本文,你不仅看到了两份真实代码的对比,还深入理解了:

  • 为什么暴力解法不行
  • 双栈如何工作
  • 关键细节(<= 的重要性)
  • 边界情况处理
  • 面试可能问的问题

现在,你可以自信地走进任何一场技术面试,微笑着说:

“最小栈?我用双栈,O(1) 搞定。”


Happy Coding!
愿你的代码如栈般有序,bug 如最小值般迅速被发现并清除 🧹✨