从暴力到优雅:深入理解「最小栈」的三种实现方式

70 阅读3分钟

在算法的世界里,时间与空间是一对永恒的矛盾。而“最小栈”问题,正是我们练习如何在二者之间取得平衡的经典案例。


引言:什么是最小栈?

最小栈(Min Stack) 是一种支持以下四种操作的数据结构:

  • push(x):将元素 x 压入栈中
  • pop():弹出栈顶元素
  • top():获取栈顶元素
  • getMin()以 O(1) 时间复杂度 获取栈中的最小值

乍一看,前三项是标准栈的操作,但最后一项却暗藏玄机——如何在常数时间内获取当前栈中的最小值?

本文将带你从最朴素的思路出发,逐步优化,最终实现一个高效、优雅的最小栈,并深入剖析其背后的单调栈思想


第一版:暴力遍历(O(n) 时间)

我们先来看最直观的做法:每次调用 getMin() 时,遍历整个栈,找出最小值。

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

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) ,频繁调用时性能堪忧。

想象一下,如果每压入一个元素后都要查一次最小值,那总时间复杂度就退化成了 O(n²) —— 这显然无法满足工程需求。


第二版:预留接口(未完成)

2.js 中,作者只定义了骨架,getMin 方法留空:

MiniStack.prototype.getMin = function() { 
  // O(n)
}

这其实是一个很好的教学设计:引导你思考——如何突破 O(n) 的瓶颈?

答案是:空间换时间


第三版:辅助栈 + 单调栈思想(O(1) 时间)

真正的优雅解法来了!我们引入一个辅助栈(minStack) ,专门用来维护当前栈中的最小值。

核心思想:

  • 主栈 stack 负责正常入栈出栈;
  • 辅助栈 stack2 维护一个非严格递减序列(即单调不增),栈顶始终是当前最小值。

入栈规则:

  • 当 x <= stack2[栈顶] 时,也将 x 压入 stack2

出栈规则:

  • 如果主栈弹出的元素等于 stack2 的栈顶,则 stack2 也同步弹出。
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 top = this.stack.pop();
  if (top === 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];
}

优点

  • 所有操作均为 O(1)  时间复杂度;
  • 代码简洁,逻辑清晰;
  • 利用了单调栈的经典思想。

💡 关键洞察
辅助栈其实是一个单调不增栈(Monotonic Decreasing Stack) 。它并不存储所有元素,只保留那些“曾经成为最小值”的候选者。这种“懒惰维护”策略,正是算法优化的精髓。


对比总结

实现方式getMin() 时间空间开销是否满足题目要求
暴力遍历O(n)O(1)
辅助栈(单调)O(1)O(n)

在大多数场景下,用少量额外空间换取时间效率的大幅提升,是非常值得的。


延伸思考:为什么用 >= 而不是 >

考虑如下入栈序列:[3, 1, 1]

  • 若使用 >,第二个 1 不会入辅助栈;
  • 当第一个 1 被 pop() 时,辅助栈变为空,此时 getMin() 就会出错!

因此,必须使用 >=,确保重复的最小值被完整记录,从而在出栈时能正确同步。


结语

最小栈问题看似简单,却蕴含着深刻的算法思想:

  • 空间换时间 的权衡艺术;
  • 单调栈 在动态维护极值中的巧妙应用;
  • 边界条件(如重复值)的严谨处理。

下次当你面对“需要快速获取历史极值”的问题时,不妨想想:是否可以用一个辅助结构来维护单调性?

算法之美,不在炫技,而在恰到好处的平衡。


参考资料

  • LeetCode 155. Min Stack
  • 《算法导论》——栈与单调性结构
  • 本文代码基于 ES5 构造函数实现,兼容性好,适合深入理解原型链机制

欢迎点赞、收藏、评论交流!你还有什么优化思路?