【算法-3/Lesson47(2025-11-28)】最小栈(Min Stack)详解:从基础实现到高效优化🧮

34 阅读6分钟

🧮在算法与数据结构的世界中,栈(Stack) 是一种经典的后进先出(LIFO, Last In First Out)线性结构。而 最小栈(Min Stack) 则是在普通栈的基础上,增加了一个关键功能:在常数时间内获取当前栈中的最小元素。这个问题在 LeetCode 第 155 题中被提出,是考察对栈操作和空间换时间思想的重要题目。

本文将深入探讨最小栈的两种主流实现方式:单一栈遍历法双栈辅助法,详细分析其原理、代码实现、时间/空间复杂度,并通过示例演示其运行过程。同时,我们还将补充相关背景知识、边界处理技巧以及工程实践建议。


🔍 问题定义

设计一个支持以下操作的栈:

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

要求getMin() 操作必须在 常数时间 O(1) 内完成。

⚠️ 注意:普通栈本身不支持快速获取最小值。若每次调用 getMin() 都遍历整个栈,则时间复杂度为 O(n),无法满足题目要求。


📦 方法一:单一栈 + 遍历查找(简单但低效)

💡 思路概述

使用一个普通的数组作为栈,所有操作(push, pop, top)都按常规方式进行。
当需要获取最小值时,遍历整个栈,找出最小元素。

✅ 优点

  • 实现极其简单。
  • 空间占用少,仅需一个栈。

❌ 缺点

  • getMin() 时间复杂度为 O(n) ,效率低下。
  • 在高频调用 getMin() 的场景下性能堪忧。

🧾 代码实现(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 === 0) {
    return undefined; // 或抛出异常
  }
  return this.stack[this.stack.length - 1];
};

// O(n) 时间复杂度获取最小值
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;
};

📊 复杂度分析

操作时间复杂度空间复杂度
pushO(1)O(1)
popO(1)O(1)
topO(1)O(1)
getMinO(n)O(1)
总空间O(n)

虽然此方法“能用”,但在实际面试或工程中通常不被接受,因为它违背了题目的核心要求——常数时间获取最小值


🚀 方法二:双栈辅助法(推荐方案)

💡 核心思想:空间换时间

引入第二个栈(辅助栈) ,专门用于记录历史最小值
每当主栈发生变化(push/pop),辅助栈同步更新,确保其栈顶始终是当前主栈的最小值。

这种设计使得 getMin() 只需返回辅助栈的栈顶,时间复杂度为 O(1)

🧠 辅助栈的维护策略

有两种常见策略:

✅ 策略 A:全量同步(冗余但稳定)

  • 每次 push(x) 时,将 min(当前最小值, x) 压入辅助栈。
  • pop() 时,两个栈同时弹出。
  • 辅助栈长度始终等于主栈长度。

示例初始化:min_stack = [Infinity] 作为基准。

✅ 策略 B:条件入栈(节省空间)

  • 仅当 x <= 当前最小值 时,才将 x 压入辅助栈。
  • pop() 时,若弹出元素等于辅助栈顶,则辅助栈也弹出。
  • 辅助栈长度 ≤ 主栈长度。

本文将分别展示这两种策略的实现。


🧾 实现一:全量同步

var MinStack = function() {
  this.x_stack = [];           // 主栈
  this.min_stack = [Infinity]; // 辅助栈,初始化为无穷大
};

MinStack.prototype.push = function(x) {
  this.x_stack.push(x);
  // 将当前最小值(与新元素比较后的较小者)压入辅助栈
  this.min_stack.push(Math.min(this.min_stack[this.min_stack.length - 1], x));
};

MinStack.prototype.pop = function() {
  this.x_stack.pop();
  this.min_stack.pop(); // 同步弹出
};

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

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

🔁 运行示例

操作x_stackmin_stack说明
push(3)[3][∞, 3]min(∞,3)=3
push(5)[3,5][∞,3,3]min(3,5)=3
push(2)[3,5,2][∞,3,3,2]min(3,2)=2
push(1)[3,5,2,1][∞,3,3,2,1]min(2,1)=1
getMin()返回 1✅ O(1)
pop()[3,5,2][∞,3,3,2]同步弹出
getMin()返回 2
top()返回 2主栈顶

✅ 优点:逻辑清晰,无需判断,适合教学。 ❌ 缺点:辅助栈可能包含大量重复值,空间略高。


🧾 实现二:条件入栈

const MinStack = function(){
  this.stack = [];     // 主栈
  this.minStack = [];  // 辅助栈,仅存必要最小值
};

MinStack.prototype.push = function(x){
  this.stack.push(x);
  // 仅当 minStack 为空,或 x ≤ 当前最小值时,才入辅助栈
  if(this.minStack.length === 0 || x <= this.minStack[this.minStack.length - 1]){
    this.minStack.push(x);
  }
};

MinStack.prototype.pop = function(){
  if(this.stack.length === 0) return;
  const poppedItem = this.stack.pop();
  // 若弹出的是当前最小值,则辅助栈也要弹出
  if(poppedItem === this.minStack[this.minStack.length - 1]){
    this.minStack.pop();
  }
  return poppedItem;
};

MinStack.prototype.top = function(){
  if(this.stack.length === 0) return undefined;
  return this.stack[this.stack.length - 1];
};

MinStack.prototype.getMin = function(){
  if(this.minStack.length === 0) return undefined;
  return this.minStack[this.minStack.length - 1];
};

🔁 运行示例(相同操作序列)

操作stackminStack
push(3)[3][3]
push(5)[3,5][3]
push(2)[3,5,2][3,2]
push(1)[3,5,2,1][3,2,1]
getMin()返回 1
pop()[3,5,2][3,2]
getMin()返回 2

✅ 优点:节省空间,尤其当数据单调递增时,minStack 几乎不增长。 ⚠️ 注意:比较时使用 <= 而非 <,是为了处理重复最小值的情况。
例如:push(2), push(2),若用 <,第二个 2 不会入 minStack,导致第一次 pop()minStack 为空,错误!


📈 复杂度对比总结

方法pushpoptopgetMin空间复杂度是否满足 O(1) getMin
单一栈遍历O(1)O(1)O(1)O(n)O(n)
双栈(全量同步)O(1)O(1)O(1)O(1)O(n)
双栈(条件入栈)O(1)O(1)O(1)O(1)≤ O(n)

🎯 结论:双栈法是标准解法,其中“条件入栈”更优,兼顾效率与空间。


🛠️ 工程实践建议

  1. 边界处理

    • 栈空时调用 pop/top/getMin 应返回 undefined 或抛出异常(根据需求)。
    • 初始化辅助栈为 [Infinity] 可避免首次 push 的特殊判断(全量同步法)。
  2. 重复值处理

    • 必须使用 x <= minTop 而非 x < minTop,否则多个相同最小值会导致 minStack 提前清空。
  3. 语言特性利用

    • JavaScript 中数组天然支持栈操作(push/pop),无需手动实现链表。
    • 可考虑使用 class 语法提升可读性(现代 ES6+)。
  4. 扩展思考

    • 能否用单栈 + 编码技巧实现?(如存储差值)——可行但复杂,不推荐。
    • 若还需支持 getMax()?可再加一个最大值辅助栈。

🌟 总结

最小栈问题看似简单,实则蕴含空间换时间的经典算法思想。通过引入辅助数据结构(辅助栈),我们将一个 O(n) 的查询操作优化至 O(1),极大提升了性能。

无论是教学演示还是实际编码,双栈辅助法都是最优选择。而“条件入栈”策略在保证正确性的前提下,进一步优化了空间使用,体现了工程师对细节的把控。

掌握这一模式,不仅能解决 LeetCode 155,还能迁移到其他需要实时维护极值的场景,如滑动窗口最小值、单调栈问题等。

🧠 记住:当一个问题要求“快速获取历史最值”时,辅助栈/辅助队列往往是破局关键!