在算法与数据结构的世界中,栈(Stack)因其“后进先出”的特性被广泛应用于函数调用、表达式求值、括号匹配等场景。然而,当需求升级——例如要求在常数时间内获取栈中的最小元素时,标准栈便显得力不从心。此时,一个巧妙的解决方案应运而生:最小栈(Min Stack)。它不仅保留了栈的基本操作,还能以 O(1) 的时间复杂度返回当前最小值。本文将深入剖析其核心思想——辅助栈,并展示如何在 JavaScript 中高效实现这一结构。
问题的本质:为何不能直接遍历?
最直观的想法是在每次调用 getMin() 时遍历整个栈,找出最小值:
getMin() {
let min = Infinity;
for (let x of this.stack) {
if (x < min) min = x;
}
return min;
}
这种方法逻辑简单,但时间复杂度为 O(n),在频繁调用的场景下性能堪忧。尤其当栈中元素成千上万时,每次查询都需线性扫描,显然不可接受。
另一种思路是维护一个单独的 min 变量,在每次 push 时更新。但这在 pop 时会遇到致命问题:一旦最小值被弹出,无法知道次小值是多少,除非重新遍历。
因此,我们需要一种能动态记录历史最小值的机制——这正是辅助栈的用武之地。
辅助栈:同步记录最小值的历史
最小栈的核心思想是引入第二个栈——辅助栈(minStack),专门用于存储当前栈状态下的最小值序列。两个栈的操作严格同步,但入栈条件更严格:
- 主栈(stack):正常压入所有元素;
- 辅助栈(minStack):仅当新元素 小于或等于 当前辅助栈顶时,才将其压入。
这种策略确保辅助栈始终是一个单调非递增栈,其栈顶永远是主栈当前的最小值。
入栈操作
push(x) {
this.stack.push(x);
if (this.minStack.length === 0 || x <= this.minStack[this.minStack.length - 1]) {
this.minStack.push(x);
}
}
例如,依次压入 5, 3, 4, 3, 2:
- 主栈:
[5, 3, 4, 3, 2] - 辅助栈:
[5, 3, 3, 2]
注意,第二个 3 被压入辅助栈,是因为它等于当前栈顶(3)。这保证了后续弹出第一个 3 时,辅助栈仍保留另一个 3 作为最小值。
出栈操作
pop() {
const x = this.stack.pop();
if (x === this.minStack[this.minStack.length - 1]) {
this.minStack.pop();
}
}
出栈时,若弹出的元素恰好是当前最小值,则同步从辅助栈中移除。这样,辅助栈顶始终反映主栈剩余元素的最小值。
继续上面的例子,若执行两次 pop():
- 主栈变为
[5, 3, 4] - 辅助栈变为
[5, 3](因为弹出了2和3)
此时 getMin() 返回 3,正确无误。
查询操作
得益于辅助栈的设计,获取最小值变得极其简单:
getMin() {
return this.minStack[this.minStack.length - 1];
}
由于辅助栈顶始终是最小值,该操作时间复杂度为 O(1) ,且无需任何计算。
为什么需要“等于”也入栈?
一个常见的疑问是:为何在 x === minStack.top() 时也要压入辅助栈?
答案在于处理重复最小值。假设只在 x < min 时入栈,那么对于输入 [3, 3]:
- 主栈:
[3, 3] - 辅助栈:
[3](第二个3未入栈)
当第一次 pop() 后,主栈剩 [3],但辅助栈已空,导致 getMin() 失效。
通过允许“等于”入栈,我们确保每个最小值实例都在辅助栈中有对应记录,从而在弹出时能准确同步。
空间与时间的权衡
辅助栈方案以额外 O(n) 空间为代价,换取了所有操作的 O(1) 时间复杂度。在大多数实际应用中,这种空间换时间的策略是值得的,尤其是当 getMin() 被高频调用时。
此外,辅助栈的内存开销通常小于最坏情况。例如,若数据单调递增(如 1, 2, 3, 4),辅助栈仅存储 [1],空间效率极高。
在 JavaScript 中的实现考量
使用 ES5 构造函数风格,我们可以清晰地组织最小栈的结构:
function MinStack() {
this.stack = [];
this.minStack = [];
}
MinStack.prototype.push = function(x) { /* ... */ };
MinStack.prototype.pop = function() { /* ... */ };
MinStack.prototype.top = function() { /* ... */ };
MinStack.prototype.getMin = function() { /* ... */ };
这种写法兼容性好,且将方法定义在原型上,避免了每个实例重复创建函数,节省内存。
若使用 ES6 类语法,可进一步利用私有字段增强封装性,但在本题约束下,构造函数模式已足够清晰高效。
结语:优雅源于约束
最小栈的设计之美,在于它没有试图改变栈的基本规则,而是在其约束下,通过引入一个轻量级的辅助结构,巧妙解决了看似矛盾的需求。它提醒我们:在工程中,最优解往往不是推翻重来,而是在现有框架内找到最契合的补充。
辅助栈如同一个忠实的影子,默默记录着主栈的极值变迁。正是这种同步与克制,让 O(1) 的最小值查询成为可能——简洁、高效,且不失鲁棒。