《最小栈的巧妙设计:用辅助栈实现 O(1) 获取最小值》

最小栈:用辅助栈实现 O(1) 获取最小值的巧妙设计

栈(Stack)是程序员最熟悉的数据结构之一------后进先出、操作简单,广泛应用于函数调用、表达式求值、括号匹配等场景。但你是否想过:如果在标准栈的基础上,要求随时以 O(1) 的时间复杂度获取当前栈中的最小元素 ,该如何设计?这正是 155. 最小栈 - 力扣(LeetCode)所提出的核心挑战。

为了解决这个问题,我们可以借助一个辅助栈 (也称为单调栈)来实现 O(1) 时间复杂度获取最小值的功能。本文将从问题出发,逐步讲解两种实现方式------朴素方法与优化方法,并重点剖析"辅助栈"这一精巧设计的思想与实现细节。


一、问题定义:什么是"最小栈"?

"最小栈"是指在支持标准栈操作(push、pop、top)的基础上,额外支持一个 getMin() 方法,用于返回当前栈中所有元素的最小值,且要求该方法的时间复杂度为 O(1)。

例如:

  • 执行 push(3) → 栈:[3] → 最小值:3
  • 执行 push(1) → 栈:[3,1] → 最小值:1
  • 执行 push(2) → 栈:[3,1,2] → 最小值:1
  • 执行 pop() → 栈:[3,1] → 最小值:1
  • 再次 pop() → 栈:[3] → 最小值:3

关键在于:无论栈如何变化,getMin() 都能立刻返回当前最小值,无需重新扫描。


二、朴素解法:每次遍历找最小值

最直观的想法是:每次调用 getMin() 时,遍历整个栈,找出最小元素。这种实现简单直接,但效率低下。

js 复制代码
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];
}

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;
}

这种方法的问题显而易见:

  • getMin() 的时间复杂度是 O(n)
  • 如果频繁调用 getMin(),整体性能会显著下降;
  • 不符合"高效数据结构"的设计目标。

因此,我们需要一种更聪明的办法。


三、优化解法:引入辅助栈(单调栈)

核心思想是:用空间换时间 。我们额外维护一个"辅助栈",专门用来记录当前主栈状态下的最小值。这个辅助栈始终保持"非严格递减"(即单调不增),其栈顶始终是当前全局最小值。

1. 辅助栈的工作原理

  • 入栈(push)时

    • 将元素压入主栈;
    • 同时判断:如果辅助栈为空,或者新元素 ≤ 辅助栈栈顶,则也将该元素压入辅助栈。
  • 出栈(pop)时

    • 从主栈弹出元素;
    • 如果弹出的元素等于辅助栈栈顶,则辅助栈也同步弹出。
  • 获取最小值(getMin)时

    • 直接返回辅助栈的栈顶元素,时间复杂度 O(1)。

这种设计保证了:辅助栈的每一个元素,都对应主栈在某个时刻的最小值。即使主栈中有重复元素,只要它们影响了最小值,辅助栈就会记录。

2. 代码实现

kotlin 复制代码
const MiniStack = function() {
  this.stack = [];     // 主栈
  this.stack2 = [];    // 辅助栈(单调栈)
}

MiniStack.prototype.push = function(x) {
  this.stack.push(x);
  // 如果辅助栈为空,或新元素小于等于辅助栈栈顶,则入辅助栈
  if (this.stack2.length === 0 || this.stack2[this.stack2.length - 1] >= x) {
    this.stack2.push(x);
  }
}

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

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

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

注意:这里使用 >= 而不是 >,是为了处理重复最小值 的情况。例如连续 push 两个 1,若只在 < 时入辅助栈,则第二个 1 出栈时辅助栈已空,导致错误。


四、为什么辅助栈能正确工作?

让我们通过一个例子验证其正确性:

操作序列:

  1. push(5) → 主栈:[5],辅助栈:[5]
  2. push(3) → 主栈:[5,3],辅助栈:[5,3](3 ≤ 5)
  3. push(4) → 主栈:[5,3,4],辅助栈:[5,3](4 > 3,不入)
  4. push(3) → 主栈:[5,3,4,3],辅助栈:[5,3,3](3 ≤ 3)
  5. getMin() → 返回 3 ✅
  6. pop() → 弹出 3,主栈:[5,3,4],辅助栈:[5,3](弹出匹配)
  7. getMin() → 返回 3 ✅
  8. pop() → 弹出 4,主栈:[5,3],辅助栈不变(4 ≠ 3)
  9. getMin() → 返回 3 ✅
  10. pop() → 弹出 3,主栈:[5],辅助栈:[5]
  11. getMin() → 返回 5 ✅

可以看到,辅助栈始终与主栈的"最小值历史"同步,即使中间有非最小值插入,也不影响正确性。


五、空间与时间的权衡

  • 时间复杂度

    • push:O(1)
    • pop:O(1)
    • top:O(1)
    • getMin:O(1)
  • 空间复杂度:O(n)

    • 最坏情况下(如元素单调递减),辅助栈与主栈一样大;
    • 平均情况下,辅助栈远小于主栈。

这种"以空间换时间"的策略,在现代计算环境中通常是值得的,尤其是当 getMin 被频繁调用时。


六、常见误区与注意事项

  1. 比较条件必须包含等于(≥)
    若只在 < 时入辅助栈,会导致重复最小值被错误忽略。例如 push(1), push(1),若第二个 1 不入辅助栈,则第一次 pop 后辅助栈为空,无法正确返回最小值。
  2. 出栈时要比较值,而非引用
    在 JavaScript 中,数字是原始类型,直接比较即可。但在其他语言中若使用对象,需注意相等性判断。
  3. 辅助栈不是"所有历史最小值",而是"当前有效最小值序列"
    它只保留那些在某个前缀中曾是最小值的元素,且按出现顺序排列。

七、应用场景

最小栈的设计思想不仅限于面试题,它在以下场景中也有实际价值:

  • 实时监控系统中的最低温度、最低价格、最低延迟;
  • 算法竞赛中需要动态维护极值的栈结构;
  • 编译器或解释器中对作用域变量的最小值追踪;
  • 金融交易系统中对滑动窗口最小值的快速查询(结合双端队列可扩展)。

结语

最小栈通过引入一个辅助栈,巧妙地将"获取最小值"这一看似需要全局扫描的操作,转化为对栈顶元素的常数时间访问。这种设计体现了算法中经典的"空间换时间"思想,也展示了单调栈这一数据结构的强大能力。

理解最小栈的关键,在于把握"辅助栈与主栈的同步逻辑"------它不是简单复制,而是有选择地记录关键状态。掌握这一模式,不仅能解决最小栈问题,还能迁移到最大栈、滑动窗口最值、直方图最大矩形等更复杂的算法问题中。

在编程实践中,当我们面对"动态维护极值"的需求时,不妨思考:是否可以借助一个辅助结构,将 O(n) 降为 O(1)?答案往往就藏在像"最小栈"这样简洁而优雅的设计之中。

相关推荐
San301 小时前
反转字符串与两数之和:两道简单题背后的 JavaScript 思维深度
javascript·算法·面试
拉不动的猪1 小时前
判断dom元素是否在可视区域的常规方式
前端·javascript·面试
小兵张健1 小时前
腾讯云智面试
面试
喜欢吃燃面1 小时前
算法竞赛中的堆
c++·学习·算法
资深web全栈开发1 小时前
LeetCode 1590:使数组和能被 p 整除(前缀和 + 哈希表优化)
算法·leetcode·前缀和·算法优化·哈希表·go 语言·取模运算
CoderYanger1 小时前
递归、搜索与回溯-综合练习:27.黄金矿工
java·算法·leetcode·深度优先·1024程序员节
Hilaku1 小时前
如何用隐形字符给公司内部文档加盲水印?(抓内鬼神器🤣)
前端·javascript·面试
zs宝来了1 小时前
HOT100系列-堆类型题
数据结构·算法·排序算法
猫头虎-前端技术1 小时前
小白也能做AI产品?我用 MateChat 给学生做了一个会“拍照解题 + 分步教学”的AI智能老师
前端·javascript·vue.js·前端框架·ecmascript·devui·matechat