最小栈这道题,我见过太多人背题式刷过,结果一到面试官追问“如果相等呢”“pop之后怎么恢复”就卡壳。作为LeetCode 155和剑指Offer 30的原题,它看起来只是一道“设计题”,实际上是在考察一个非常关键的思维能力:当数据结构的某个状态发生变化后,怎么用最低的成本维持住“历史信息”。这节实训课,我就把双栈、辅助栈优化、单栈差值法这三种思路全部拆开讲,从推导过程到代码实现,再到我实际测试中踩过的坑,一次性讲透。无论你是刚学栈的后端新人,还是准备算法面试的求职者,这篇都能给你一个可以直接落地的完整答案。
1. 动手前先想清楚:最小栈到底在考什么?
1.1 先拆题:栈的基本操作里混进了一个 getMin
题目要求很简短:设计一个支持 push、pop、top、getMin 四种操作的数据结构,并且这四种操作的时间复杂度都得是 O(1)。很多人第一眼看到这题会觉得很简单:栈本身就是 O(1) 的 push、pop、top,剩下的问题只有一个,怎么让 getMin 也做到 O(1)。
关键就在这里。如果你不在任何额外结构里做记录,想拿到栈里的最小值,只能把栈从头到尾遍历一遍,这是典型的 O(n) 操作。有人会说,那我用一个变量记最小值不就行了吗?问题出在 pop 操作上:假设栈顶元素恰好是当前记录的最小值,你把它弹出之后,这个变量就再也拿不到“之前的最小值”了。因为最小值可能不是倒数第一个,而是藏在更下面,你只记录一个数值,丢失了“历史瞬间”。
所以这道题真正考察的,不是什么花哨的算法,而是你有没有意识到:数据结构的操作不是孤立的,pop 会改变整个集合,你必须在变化发生之前就把有价值的信息保存下来。这也是我把这节课放在整个系列这个位置的原因——它和动态规划里的状态记录、缓存设计里的快照思想,底层逻辑是相通的。
1.2 时间复杂度与空间复杂度的博弈
很多人刷题只关心“能不能过”,不关心“为什么这么设计”。最小栈这个题目非常典型地体现了时间换空间、空间换时间的取舍。你要 getMin 是 O(1),几乎注定要付出额外的空间代价;你要省空间,getMin 就得牺牲时间。
问题的本质是:栈的特点决定了我们只能从栈顶存取,而最小值可能在栈中的任意一层。为了让 getMin 不遍历,必须把“每一步的最小值”作为历史信息同步保存下来。你保存的历史信息越多,空间消耗越大,但查询越快;保存得越少,空间越省,但查询时需要的计算就越多。
这里有个经典的权衡点:如果允许 getMin 是 O(log n),那用最小堆来维护最小值当然也可以,但这就违背了题目 O(1) 的要求。所以最小栈这题几乎是在引导你走向“用空间换时间”的标准思路,本质上和你用缓存加速接口是一个道理。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 第一版思路:双栈方案(最容易上手的解法)
2.1 核心逻辑:用辅助栈记录每一步的“历史最小值”
第一次接触这题,我最推荐的双栈方案,逻辑非常直白:主栈正常存数据,辅助栈同步存“当前栈内最小值”。也就是说,每当你向主栈 push 一个元素,辅助栈也会 push 一个对应的值,这个值代表“主栈从栈底到当前栈顶”这个范围内的最小值。
可能有人会问,为什么辅助栈里每个位置都要存一个值,而不是只存一个全局最小值?因为栈是会 pop 的。假设主栈依次 push 了 5、3、8,辅助栈里存的就是 5、3、3。这时候 pop 掉 8,主栈变成 5、3,辅助栈只要同样 pop,栈顶还是 3,最小值依然是 3。如果 pop 掉 3,主栈剩 5、8,辅助栈 pop 后栈顶是 5,最小值自动恢复成 5。你发现没有,因为辅助栈和主栈长度严格相等、同步出入栈,所以辅助栈的栈顶永远能告诉你“当前主栈”的最小值,历史信息被完美保留下来了。
这个方案最明显的优点是直观,每个操作都是一次常数时间的栈操作,没有任何判断的歧义。缺点是空间上有冗余:哪怕你连续 push 了一堆大值,最小值没变过,辅助栈也会为每个新元素都压入一次同样的最小值。这个冗余空间在极端场景下会很难看。
2.2 代码实现与每一步的坑
我用 Python 写一个最标准的双栈实现,代码量很少,但细节都在边界条件里。
python复制class MinStack:
def __init__(self):
self.stack = []
self.min_stack = []
def push(self, val: int) -> None:
self.stack.append(val)
if not self.min_stack or val <= self.min_stack[-1]:
self.min_stack.append(val)
else:
self.min_stack.append(self.min_stack[-1])
def pop(self) -> None:
self.stack.pop()
self.min_stack.pop()
def top(self) -> int:
return self.stack[-1]
def getMin(self) -> int:
return self.min_stack[-1]
这里有个非常关键的细节:push 时判断条件用的是 value <= self.min_stack[-1],不是 <。因为在辅助栈的同步写法里,即使 val 和当前最小值相等,我们也需要把它记录到辅助栈中。为什么会这样?考虑一个场景:push 了 3 和 3,如果用严格小于判断,辅助栈只会记录第一个 3,第二个 3 不被记录。当 pop 时,辅助栈和主栈同步 pop,主栈里两个 3 都弹出去后,辅助栈可能只剩下一个历史值,这时 getMin 得到的仍然是 3,看起来好像没问题。但再想深一层,如果辅助栈只记录了少数值,而主栈每次 pop 都弹出一个辅助栈记录过的值,最终会导致辅助栈先于主栈弹空,后面所有 getMin 都直接越界。
所以在双栈方案里,只要辅助栈和主栈保持“同步长度”的写法,就必须在“相等时也入栈”,保证两条栈的长度完全一致。
2.3 为什么每次都要推入辅助栈
从上面的代码可以看出,关键不是每次压入 val 到辅助栈,而是每次压入“当前的最优解”。辅助栈的每个位置,都可以理解成主栈对应前缀的“状态快照”。
我常用一个生活类比解释这个思想:想象你在一家公司上班,你的直属领导一直在记录“这个团队目前最年轻的成员是谁”。每来一个新同事,他都会更新记录;每走一个人,他只要回到上一条记录,就能知道当前团队最年轻的是谁。辅助栈就是那一本“历史台账”,每一条都对应团队的一个时刻。没有台账,你只知道现在是哪个,但历史一旦发生变动,你就两眼一抹黑。
这套同步逻辑虽然浪费了一些空间,但换来了实现上的零负担:pop 不需要额外判断,getMin 不需要做任何运算。对于面试时时间紧、对手写代码稳定性要求高的场景,双栈方案是最不会出错的保底答案。
3. 优化辅助栈:减少冗余记录
3.1 只在出现新最小值时保存
双栈方案虽然简单,但空间效率低是硬伤。一个典型场景:主栈依次 push 5、6、7、8、9,辅助栈里每次都会压入 5、5、5、5、5,全是同一个值 5。这显然是可以优化的——当最小值没有变化时,为什么要重复记录呢?
所以优化的思路也很明确:辅助栈只在出现“新的更小值”时才压入。所谓新最小值,就是 val 小于等于当前辅助栈栈顶(也就是当前最小值),才需要记录。这时辅助栈的长度就不一定和主栈相同了,不是每个主栈元素都对应一个辅助栈元素。
python复制class MinStack:
def __init__(self):
self.stack = []
self.min_stack = []
def push(self, val: int) -> None:
self.stack.append(val)
if not self.min_stack or val <= self.min_stack[-1]:
self.min_stack.append(val)
def pop(self) -> None:
top = self.stack.pop()
if top == self.min_stack[-1]:
self.min_stack.pop()
def top(self) -> int:
return self.stack[-1]
def getMin(self) -> int:
return self.min_stack[-1]
注意这里的 pop 操作不再是无脑同步了。每次弹出主栈栈顶时,我需要先看一下:如果弹出的这个值恰好等于当前辅助栈的栈顶,说明主栈弹掉的就是“当前最小值”,所以辅助栈也要跟着弹;否则辅助栈不变。
3.2 重复最小值的边界:相等时也要入栈
这一版代码里,push 的判断条件我依然用了 <=,而不是 <。这里是个很容易踩坑的边界:如果允许重复值,并且只对严格小于的情况入辅助栈,会出现什么问题?
举个例子:依次 push 2、2、1。辅助栈的记录过程是:push 2,入栈;push 第二个 2,因为 2 < 2 不成立,所以不入栈;push 1,1 < 2,入栈。辅助栈最终是 [2, 1]。接着执行一次 pop,主栈弹出 1,1 等于辅助栈栈顶,辅助栈弹出,变为 [2]。再执行一次 pop,主栈弹出第二个 2,但此时辅助栈栈顶是 2,也相等,所以辅助栈弹出,变为空。再执行 getMin,直接越界。
问题出在第二个 2 没有入栈。如果 push 时用的是 <,当遇到“多个相同最小值”时,辅助栈中记录的最小值数量会少于主栈中实际出现的次数,pop 多次后就会把“未来的最小值记录”提前消耗掉。
正确做法是:当 val 小于等于当前最小值时都入栈。这样即使有多个相同的 2,辅助栈也会为每个 2 都保留记录,pop 时每个 2 都可以安全地弹出一条对应记录。
3.3 两种双栈方案的空间对比
我把两种方案放到同一张表里对比,方便你快速决策用哪种。
| 对比维度 | 同步双栈方案 | 优化辅助栈方案 |
|---|---|---|
| 辅助栈长度 | 严格等于主栈长度 | 小于或等于主栈长度 |
| push 判断 | 无条件压入当前最小值 | 仅当 val <= 当前最小值时压入 |
| pop 判断 | 无条件弹出 | 需要比较弹出值与辅助栈栈顶 |
| 空间冗余度 | 最小值不变时也重复记录 | 最小值不变时不记录 |
| 代码可读性 | 很高,逻辑最简单 | 略高,多一个判断分支 |
| 风险点 | 空间浪费 | 相等值边界容易写错 |
我平时写业务代码时更倾向优化辅助栈方案,因为空间占用更低;但如果是在面试现场手写,而且时间紧,同步双栈方案会是更稳的选择。两者时间复杂度完全一样,都是 O(1),空间复杂度最坏也都是 O(n),只是常数系数有差别,笔试场景下通常看不到明显差异。
4. 单栈差值法:把 min 藏进栈里
4.1 核心原理:记录差值避免额外栈
既然辅助栈的核心作用就是维护历史最小值,那我们能不能只用一个栈就完成同样的事?可以,但思路要转个弯。这里的核心技巧是:栈里不直接存原始值,而是存“当前值和当前最小值的差值”,同时用单独的变量 min 维护当前最小值。
我用一个公式来拆解:假设当前最小值是 min,现在要 push 一个值 x。如果 x 小于 min,那就说明要更新 min 为 x,并且把 x - old_min 这个差值压入栈中;如果 x 大于等于 min,那就把 x - min 压入栈中。为什么这么设计?因为当我们 pop 时,可以根据栈中存的差值反推出上一个最小值。
具体怎么反推?分两种情况讨论。第一种,如果栈顶存的是非负数,说明 x 大于等于当时的最小值,那这次 push 并没有产生新的最小值,pop 时直接弹出元素,min 不变。第二种,如果栈顶存的是负数,说明 x 小于当时的最小值,push 时更新了 min 为新值 x,那个负差值记录的就是“当前最小值相对于旧最小值的偏移量”。所以 pop 时,根据 old_min = current_min - stack_top 就能恢复旧的最小值。
这个思路很像压缩算法里的差分编码,用数据之间的相对关系替代绝对值,从而省去独立辅助栈的空间。
4.2 代码实现与类型溢出处理
我用 Java 写这个版本来演示,顺便说说溢出问题。
java复制class MinStack {
private Deque<Long> stack;
private long min;
public MinStack() {
stack = new ArrayDeque<>();
min = 0;
}
public void push(int val) {
if (stack.isEmpty()) {
stack.push(0L);
min = val;
} else {
long diff = (long) val - min;
stack.push(diff);
if (diff < 0) {
min = val;
}
}
}
public void pop() {
long diff = stack.pop();
if (diff < 0) {
min = min - diff;
}
}
public int top() {
long diff = stack.peek();
if (diff < 0) {
return (int) min;
}
return (int) (min + diff);
}
public int getMin() {
return (int) min;
}
}
这里的几个细节我得重点说明。第一,我特意把栈的类型写成了 Deque<Long>,差值用 long 来存,而不是 int。因为 val - min 有可能超出 int 的范围,比如 val 是 Integer.MAX_VALUE,min 是 Integer.MIN_VALUE,相减会溢出。先把 val 转成 long,就可以安全计算。第二,top 操作要判断栈顶差值是否为负:如果为负,说明当时 push 的是一个比旧最小值更小的值,栈顶对应的真实值就是当前 min;如果非负,真实值是 min + diff。
这个方案空间上只需要一个栈和一个变量,看起来最优,但代价是代码可读性下降,而且必须处理负数、整数溢出这些边界情况。如果你在面试时被追问“能不能不用辅助栈”,这一版可以作为加分项,但平时写代码如果没有特殊限制,我不会优先用它。
4.3 什么时候才真正值得用
单栈差值法看起来很优雅,但并不是所有场景都值得用它。因为在数学推导上多绕了一层,代码可读性和可维护性都明显降低。除非你明确知道内存空间极其紧张、数据量极大,比如嵌入式环境或者超大规模流式处理场景,否则我不会推荐在生产环境中使用这种技巧性写法。
还有一种场景是面试时主动加分。当面试官听完你的双栈解法后,追问一句“能不能把空间再压缩一下”,这时候能写出差值法,说明你对状态维护的理解不止停留在套模板层面。不过它有一个陷阱:如果题目要求支持更复杂的操作,比如随时获取最大值,那这个思路就不好扩展了,而辅助栈方案可以很容易地在同一个框架内再加一个 max_stack。所以我的建议是:先把双栈方案吃透,差值法作为扩展思路去理解,不要在基础不牢时一上来就搞技巧。
5. 常见问题与排查技巧实录
5.1 典型 Bug 速查表
这题看似简单,我帮人 review 代码时见过不少隐藏 bug,下面整理成一张速查表,方便你对照检查自己的实现。
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| pop 后 getMin 返回错误值 | pop 时没有同步更新辅助栈,或更新条件写错 | 检查辅助栈是否也在 pop,比较时用相等判断 |
| 连续 push 重复最小值后 getMin 空指针 | push 时用了严格小于判断,导致辅助栈记录不足 | 比较时使用小于等于,保证相等值也入栈 |
| 空栈调用 top 或 getMin | 没有做防御性判断,边界用例未覆盖 | 在方法入口加空栈判断,或者调用方保证不为空 |
| 差值法在 Java 中出现异常大数 | int 类型溢出 | 差值计算转 long,或者用大数类型存储 |
| pop 后 top 返回值和预期不一致 | 差值法里 pop 时 min 恢复顺序写反 | 先取差值,再根据差值是否为负更新 min |
5.2 测试用例清单:从空栈到重复值
我平时写算法题会坚持“写完成码先自测三组用例”的习惯,最小栈这题我建议至少覆盖以下几类:
第一,空栈边界:在栈为空时调用 getMin 应该抛出明确异常或返回空值,不能静默返回 0,否则会和真实最小值混淆。第二,单调递增序列:push 1、2、3、4,getMin 应该一直返回 1。这类场景用来验证辅助栈优化方案在“没有新最小值”时不会报错。第三,单调递减序列:push 4、3、2、1,每一步 getMin 都应该返回最新 push 的值。第四,重复最小值:push 2、2、2,然后连续 pop 三次,每次 getMin 都应该是 2,不能越界。第五,先递减再递增:push 5、3、4、1、2,pop 一次后 getMin 应该是 1,再 pop 两次后 getMin 应该是 3,验证历史最小值的恢复顺序。
这几组用例基本覆盖了所有代码分支,能让你的实现逻辑更接近正确。
5.3 我在代码评审里反复强调的三件事
第一,方法签名和边界行为要符合预期。很多候选人写 top 和 getMin 时不处理空栈,或者直接返回 Integer.MIN_VALUE 来代表空,这在业务代码里都是很危险的约定。更好的做法是继续抛出 NoSuchElementException,或者在文档里明确说明调用前必须非空,让调用方的错误尽早暴露。
第二,复杂度分析要说完整。面试官问“你的空间复杂度是多少”,不要只答 O(n),你可以顺便说一下:在最坏情况下,辅助栈长度等于主栈长度,所以是 O(n);在优化方案中,如果数据呈现单调递增,辅助栈只有 1 个元素,空间接近 O(1)。这种细节能让对方知道你理解的是本质,而不是在背题。
第三,不要为了炫技放弃可读性。单栈差值法虽然能省空间,但如果团队里其他人不熟悉这种写法,维护成本很高。生产环境里的第一大原则是代码清晰度,而不是在不需要优化的地方做极限优化。面试时先给出最稳妥的双栈同步方案,再在追问下沉稳地给出优化方案,这种表现远比一上来就写个晦涩的差值法要好。
回到最初的问题,最小栈这个题目真正的价值不在于你记住了几种解法,而在于你理解了为什么辅助栈能保存历史状态。我个人的习惯是:遇到这类“维护状态”的题目,先想清楚操作之间有什么联动,再动代码。用辅助栈也好,用差值存储也好,本质都是在为“历史”留一条退路。最后再分享一个小技巧:如果你在写这题时拿不准相等值该不该入辅助栈,就回去想想 pop 两次之后会不会越界,用这个“反向验证法”来检验自己的判断标准。这题的细节坑一旦踩过,下次遇到类似的设计题,你的思路会明显比别人多一层。
