深入理解最小栈(Min Stack):用原生 JavaScript 实现 O(1) 极值查询

57 阅读4分钟

一、引言:为什么需要“最小栈”?

在日常开发或算法面试中,我们经常会遇到这样的需求:

“我有一个栈,除了正常的入栈、出栈操作外,还需要随时知道当前栈中的最小值,而且要非常快!”

如果每次查询最小值都遍历整个栈,时间复杂度是 O(n),在高频调用场景下性能堪忧。有没有办法让所有操作(包括获取最小值)都在常数时间内完成

答案是:最小栈(Min Stack)

本文将带你从零开始,用原生 JavaScript 风格,深入理解最小栈的设计思想,并手写一个高效、健壮的实现。


二、核心设计思想

最小栈的关键在于 “用空间换时间” 。我们不重新计算最小值,而是在数据变化时同步维护它

🧠 核心原则(牢记!)

最小栈只推入“小于或等于当前最小值”的元素
最小栈只在主栈弹出的元素等于当前最小值时才弹出

这两条规则看似简单,却是整个数据结构正确性的基石。


三、数据结构设计

我们维护两个栈:

栈名作用特点
stack主栈存储所有原始数据,行为与普通栈完全一致
minStack辅助栈单调非增栈,栈顶始终是当前全局最小值

🔍 什么是“单调非增”?
即栈中元素从底到顶不递增(可以相等,但不能变大)。
例如:[5, 3, 3, -1] 是合法的;[5, 3, 4] 是非法的。


四、原生 JavaScript 实现

下面是一个纯 ES5 风格的实现,兼容所有现代浏览器和 Node.js 环境:

js
编辑
function MinStack() {
  this.stack = [];      // 主栈:存储原始值
  this.minStack = [];   // 辅助栈:存储“历史最小值”序列
}

// 入栈:O(1)
MinStack.prototype.push = function (val) {
  this.stack.push(val);
  
  // 关键逻辑:只有当新值 ≤ 当前最小值时,才压入 minStack
  if (this.minStack.length === 0 || val <= this.getMin()) {
    this.minStack.push(val);
  }
};

// 出栈:O(1)
MinStack.prototype.pop = function () {
  if (this.stack.length === 0) return null;
  
  const topVal = this.stack.pop();
  
  // 关键逻辑:只有当弹出的值等于当前最小值时,minStack 才同步弹出
  if (topVal === this.getMin()) {
    this.minStack.pop();
  }
  
  return topVal;
};

// 获取栈顶元素:O(1)
MinStack.prototype.top = function () {
  return this.stack.length > 0 ? this.stack[this.stack.length - 1] : null;
};

// 获取当前最小值:O(1)
MinStack.prototype.getMin = function () {
  return this.minStack.length > 0 ? this.minStack[this.minStack.length - 1] : null;
};

五、原理深度剖析

场景 1:处理重复最小值

假设操作序列为:push(-2), push(0), push(-2)

步骤主栈 stack辅助栈 minStack说明
初始[][]
push(-2)[-2][-2]首个元素,直接入 minStack
push(0)[-2, 0][-2]0 > -2,不入 minStack
push(-2)[-2, 0, -2][-2, -2]-2 ≤ -2,入 minStack!

💡 如果这里用 < 而不是 <=,第二个 -2 就不会进入 minStack
当第一次 pop() 弹出 -2 后,getMin() 会错误地返回 undefined

场景 2:出栈时的同步

继续上面的例子,执行 pop()

操作主栈辅助栈getMin()
pop()[-2, 0][-2]-2 ✅
pop()[-2][-2]-2 (0 ≠ -2,minStack 不变)
pop()[][]null

完美同步!


六、为什么不用单栈+差值法?

你可能会听说另一种“O(1) 空间”的解法:用单个栈存储与最小值的差值。

但这种方法有严重缺陷:

  • ❌ 仅适用于整数(浮点数有精度问题)
  • ❌ 容易溢出(差值可能超出数值范围)
  • ❌ 逻辑复杂,调试困难
  • ❌ 不符合“清晰优于 clever”的工程原则

📌 结论:在实际开发和面试中,双栈法是首选。它的 O(n) 空间代价换来的是极高的可读性、健壮性和通用性


七、实战测试用例

js
编辑
const ms = new MinStack();

ms.push(5);
ms.push(3);
ms.push(3);
ms.push(1);

console.log(ms.getMin()); // 1

ms.pop(); // 弹出 1
console.log(ms.getMin()); // 3

ms.pop(); // 弹出 3
console.log(ms.getMin()); // 3(还有一个 3)

ms.pop(); // 弹出 3
console.log(ms.getMin()); // 5

ms.pop(); // 弹出 5
console.log(ms.getMin()); // null

✅ 所有边界情况均正确处理!


八、应用场景

  1. 实时监控系统:跟踪服务器负载、内存使用的最低值
  2. 金融交易:记录股票价格的历史最低点
  3. 算法竞赛:作为单调栈的变种,解决滑动窗口极值问题
  4. 撤销操作:在支持“回退”的编辑器中维护状态极值

九、总结与思考

要点说明
核心思想用辅助栈维护“历史最小值”序列
关键细节使用 <= 而非 <,确保重复最小值被正确处理
时间复杂度所有操作均为 O(1)
空间复杂度最坏情况 O(n) (输入为非递增序列)
工程价值清晰、安全、高效,适合生产环境

💬 最后的话
最小栈教会我们的不仅是数据结构技巧,更是一种前瞻性维护状态的思维方式——
不要等到需要时才计算,而是在变化发生时就更新
这种思想,在缓存设计、响应式编程、状态管理等领域无处不在。