今天想聊聊最小栈这道题。很多人刷到它的时候第一反应是“这不就是个栈吗”,但真正写起来才发现,getMin()的 O(1) 要求,把大家从 O(N) 的舒适区里硬生生拽了出来。这道题几乎出现在每一本算法面试题库里,LeetCode 上叫 Min Stack,剑指 Offer 里叫包含 min 函数的栈,核心就一句话:设计一个栈,支持push、pop、top、getMin四个操作,并且getMin要在常数时间内返回栈内最小值。
看起来简单,但它的价值不在于“能不能写出来”,而在于“能不能从 O(N) 走到 O(1)”。这篇文章会把从暴力解到辅助栈、再到差值法的完整进化路线拆开讲,配合可直接运行的代码、边界测试用例和我在实际面试中踩过的坑。无论是准备面试的初学者,还是想系统梳理栈类题目套路的开发者,都能从这里拿到一点东西。
1. 先看清题目在考什么:从暴力解到真正的需求
1.1 题目本来的样子
原题描述很短,大概是这样:设计一个支持push(val)、pop()、top()和getMin()的栈结构,要求getMin()的时间复杂度为 O(1)。
注意这里有个隐藏前提:push、pop、top本身是栈的基本操作,O(1) 是理所当然的,所以真正被“限时”的只有getMin()。这就意味着,你不能在调用getMin()的时候再去遍历整个栈,也不能用一个全局变量简单记录最小值,因为一旦执行pop(),全局最小值可能已经被弹出,你根本不知道剩下的元素里的最小值是多少。
很多人第一次看到这道题会想:那我用一个变量存最小值不就行了吗?push的时候更新它,getMin()直接返回它。但问题马上出现在pop上——如果弹出的恰好是这个最小值,你需要重新找到当前栈里的最小值,这一步如果不提前做预处理,就不可避免地要遍历剩余元素,时间复杂度就回到了 O(N)。所以这道题真正考的不是“能不能想到存一个最小值”,而是“你能不能想到把每个时刻的最小值历史都保存下来”。
1.2 暴力解其实没有错,只是不够优雅
先写一个最直观的版本,用数组模拟栈,getMin()时遍历整个栈:
class MinStack: def __init__(self): self.data = [] def push(self, val: int) -> None: self.data.append(val) def pop(self) -> None: self.data.pop() def top(self) -> int: return self.data[-1] def getMin(self) -> int: return min(self.data)这段代码完全符合题目的功能要求,所有操作都能正确返回结果。但getMin()是 O(N) 的,每次都要对整个栈做一次线性扫描。在面试场景里,如果面试官先让你实现一个能跑的版本,这个答案是可以的;但如果你止步于此,基本就拿不到这道题的加分项了。
我见过不少候选人在这里卡住,不是因为不会写min(),而是因为他们默认了“栈已经是 O(1) 了,getMin()花点时间也没什么”。但卖点恰恰在于:getMin()会被频繁调用,如果每次都是 O(N),那在一个大量读最小值的场景下,整体复杂度就被拖垮了。比如你实现一个历史价格监控系统,数据源源不断入栈,而查询“当前最低价”的频率可能比push还高,这时候一个 O(N) 的查询意味着系统响应时间随数据量线性增长,这显然是不可接受的。
所以暴力解的意义在于:先确保功能正确,再思考如何优化。这是很典型的算法思维第一步——不要一上来就想着最优解,先把问题做对,再一步步压缩复杂度。
2. 辅助栈:空间换时间的经典入门思路
2.1 核心思路与为什么能 O(1)
既然问题出在“弹出最小值之后不知道下一个最小值是谁”,那最直接的解决办法就是:把每一次push之后栈内的最小值都记下来,pop的时候同步扔掉对应的记录,这样getMin()就永远只需要看当前记录的最上面一项。
这需要一个辅助栈,我习惯叫它min_stack。它的长度始终和数据栈保持一致,第 i 项表示“数据栈前 i 个元素中的最小值”。用文字描述可能有点绕,直接看代码:
class MinStack: def __init__(self): self.data = [] self.min_stack = [] def push(self, val: int) -> None: self.data.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.data.pop() self.min_stack.pop() def top(self) -> int: return self.data[-1] def getMin(self) -> int: return self.min_stack[-1]每次push的时候,把“当前值”和“辅助栈顶(历史最小值)”做比较,较小的那个压入辅助栈。这相当于给数据栈的每个元素都配了一个“当时的最小值快照”。数据栈弹出的时候,辅助栈也同步弹出,因为栈的结构保证我们永远只需要关心栈顶的局部状态,之前的历史最小值已经按顺序记录在辅助栈下层了。
2.2 一个容易踩的坑:pop 时一定要同步回退
很多第一次写辅助栈的人会犯一个错:只在push时更新辅助栈,而pop的时候忘了把辅助栈也弹出去。比如这样:
def pop(self) -> None: self.data.pop()看起来好像没什么问题,但如果你在push的时候用了“小于等于才压入”的策略,那么辅助栈里记录的其实是“历史最小值序列”。一个典型场景:
- 依次 push 5、3、8,辅助栈记录 5、3、3。
- 执行 pop 弹出 8,辅助栈仍然停在 3 上,没问题。
- 再执行 pop 弹出 3,辅助栈还是 3,问题来了——数据栈现在只剩下 5,真实最小值应该是 5,但
getMin()返回的却是 3。
这就是为什么pop时数据栈和辅助栈必须同步弹出。算法题写到后面往往不是难在思路,而是难在这些“看起来理所当然”的同步逻辑。不少提交在 LeetCode 上报错,原因都出在这里。
2.3 复杂度分析与适用场景
这个版本的时间复杂度是 O(1),所有操作都只访问栈顶,不涉及遍历。空间复杂度是 O(N),因为辅助栈长度和数据栈一致。在面试里,这是一个标准的可接受答案,在 LeetCode 上能直接 AC。
但它有没有可以压缩的空间?有。辅助栈的最大问题在于:即使当前元素不是新的最小值,我们也会把“旧最小值”再复制一份压进去,这在最坏情况下会浪费大量空间。如果你 push 的数据是严格递增的,辅助栈里存的就全都是第一个元素的值,比如连续 push 1、2、3、4、5 一万次,辅助栈就存了一万个 1。这时候你可能会想:如果辅助栈只在出现新最小值时才增长,空间不是能省很多吗?
这就是另一个常见的变体:辅助栈只在val <= min_stack[-1]时才压入,pop时如果弹出的元素恰好等于辅助栈顶,再做同步弹出。这个版本在数据递增时辅助栈几乎不长,但代码要多写一个“弹出的元素是否等于当前最小值”的判断。我个人的经验是:这个压缩版的代码稍难懂,面试时容易说漏,所以除非面试官明确问“能不能优化空间”,我更推荐每一层都记录快照的版本,逻辑清楚,不容易写错。
3. 差值法:把辅助空间的常数摊掉
3.1 核心原理:栈里存 diff 而不是真值
如果你在面试中写完了辅助栈,面试官大概率会追问一句:“空间还能不能更省?”这时候就到了差值法出场的时候。
差值法的核心思想非常巧妙:栈里不再存原始值,而是存“当前值和当前最小值之间的差值”。同时维护一个全局变量min_val表示当前栈内的最小值。getMin()直接返回min_val,不需要额外空间;push、pop、top通过栈顶存的正负 diff 来判断如何更新和维护min_val。
说得更具体一点:栈顶元素存的是diff = val - min_val。如果diff >= 0,说明当前值不小于最小值,最小值不需要更新;如果diff < 0,说明当前值比已知最小值还小,这时候需要把min_val更新为val。
这里的关键点在于:当diff < 0时,val本身就被编码在了 diff 和min_val的关系里。因为diff = val - 旧 min_val,而旧 min_val 就是压入这个元素之前的最小值,所以当你知道了 diff 和新 min_val 之后,可以反推出当时的旧 min_val。
3.2 push / pop / top 的三种分支判断
直接看代码,我加了详细注释:
class MinStack: def __init__(self): self.stack = [] self.min_val = 0 def push(self, val: int) -> None: if not self.stack: # 第一个元素,栈空,直接建立基准 self.stack.append(0) self.min_val = val else: diff = val - self.min_val self.stack.append(diff) if diff < 0: # 新元素比当前最小值还小,更新最小值 self.min_val = val def pop(self) -> None: diff = self.stack.pop() if diff < 0: # 弹出的元素是当时的新低,需要回退最小值 self.min_val = self.min_val - diff def top(self) -> int: diff = self.stack[-1] if diff >= 0: # 当前栈顶对应的真实值就是 diff + min_val return diff + self.min_val else: # 栈顶元素是历史最小值,真实值就是它本身 return self.min_val def getMin(self) -> int: return self.min_val这段代码的妙处在于:空间复杂度从 O(N) 降到了 O(1),因为除了栈本身,我们只多维护了一个min_val变量。代价是每次push、pop、top都要做符号判断,逻辑比辅助栈复杂一些。
我拆开讲一遍执行流程,确保大家能真正理解,而不是背代码:
假设依次 push 3、5、2。
- push(3):栈空,压入 0,
min_val = 3。此时栈内元素含义:3。 - push(5):
diff = 5 - 3 = 2,压入 2。因为 2 >= 0,min_val不变,仍为 3。此时栈内元素含义:3、5。 - push(2):
diff = 2 - 3 = -1,压入 -1。因为 -1 < 0,更新min_val = 2。此时栈内元素含义:3、5、2。
现在执行 pop():
- 栈顶 diff = -1,说明它对应的是当时的新低 2。
min_val = min_val - (-1) = 2 + 1 = 3,正好回退到上一个最小值。数据栈弹出后,剩下 3、5。
再来执行 top():
- 栈顶 diff = 2,真实值 =
diff + min_val = 2 + 3 = 5,正确。
这就是差值法的完整逻辑:diff < 0的栈顶元素,其真实值就是min_val;diff >= 0的栈顶元素,其真实值等于diff + min_val。pop时也只有当栈顶 diff 为负时才需要回退min_val,因为只有负 diff 才意味着最小值发生了变化。
3.3 为什么实际工程中不常用
差值法在面试里是个亮眼的优化方案,但在真实工程里我几乎不会用,原因有两个。
第一是边界条件容易错。比如很多语言的 int 类型是 32 位有符号整数,当val很大而min_val很小的时候,diff = val - min_val可能会溢出。LeetCode 上这道题的默认环境是 Python,Python 的 int 是任意精度,所以不会暴露这个问题;但在 C++ 或 Java 里,你需要把 diff 声明成long long,否则就会在极端测试用例上翻车。我记得有段时间 LeetCode 新增了一些大数用例,不少用差值法提交的 C++ 代码直接溢出报错,改成长整型才通过。
第二是代码可读性差。一个维护 diff 的栈,对后期维护的人非常不友好。你看到栈里存着 -1、2、0 这样的数字,完全不知道原始值是什么。一旦有人不小心改了min_val的逻辑,bug 会非常隐蔽。辅助栈方案虽然多费点空间,但栈里存什么一目了然,可维护性高得多。所以我的建议是:面试时可以讲差值法展示你懂优化,但如果是写生产代码,我默认选辅助栈。
4. 节点内嵌 min:比辅助栈更直观的变体
4.1 思路与代码
除了辅助栈,还有一个不少人喜欢用的变体:不用两个栈,而是把“当前最小值”作为字段存进每个栈节点里。说得直白点,就是让每个元素除了自己的值,还带一个“压入它时整个栈的最小值”快照。
class MinStack: class Node: def __init__(self, val: int, min_val: int): self.val = val self.min_val = min_val self.next = None def __init__(self): self.head = None def push(self, val: int) -> None: if self.head is None: self.head = self.Node(val, val) else: node = self.Node(val, min(val, self.head.min_val)) node.next = self.head self.head = node def pop(self) -> None: self.head = self.head.next def top(self) -> int: return self.head.val def getMin(self) -> int: return self.head.min_val这个版本的本质和辅助栈一模一样,都是空间换时间,只不过把辅助栈的“同步记录”变成了嵌入式字段。它的好处在于不需要维护两个栈的长度同步,pop时不会出现辅助栈和数据栈不一致的问题,因为信息和节点绑定在一起,结构上天然安全。
但它的缺点也很明显:每个节点多存一个min_val字段,内存开销比辅助栈更大。因为辅助栈存的是 int,而节点不但存字段,还有对象头、引用指针等额外开销。在 Python 里这个差距尤其明显,一个 Node 实例的内存占用远远大于两个整数列表项。所以我很少在生产代码中这样写,一般只在写链表题或面试时作为方案讨论。
4.2 几种方案的横向对比
简单做个总结,方便你面试时快速选择合适的方案:
| 方案 | 核心思想 | 时间复杂度 | 空间复杂度 | 代码复杂度 | 推荐场景 |
|---|---|---|---|---|---|
| 暴力遍历 | getMin 时遍历栈 | O(N) | O(N) | 极低 | 功能验证,非面试答案 |
| 辅助栈(每层记录) | 每个元素存当时最小值快照 | O(1) | O(N) | 低 | 面试标准答案,生产首选 |
| 辅助栈(稀疏记录) | 只在最小值变化时入栈 | O(1) | 最坏 O(N),常数小 | 中 | 空间敏感或递增数据多 |
| 差值法 | 栈存 diff,全局维护 min | O(1) | O(1) | 高 | 面试展示优化能力 |
| 节点内嵌 min | 每个节点存最小值和 next | O(1) | O(N),常数大 | 低 | 链表场景或练习 |
从这张表能看出一个规律:所有 O(1) 的getMin方案,本质上都在做同一件事——把“查询时需要的计算”提前到“push 时完成”,也就是用预计算换取查询速度。这是典型的空间换时间思维,也是这类题目的核心考点。
5. 手写测试用例,把边界情况一次打穿
5.1 典型边界场景
写完代码之后,最忌讳的就是直接提交。我自己刷题的习惯是:先手写一批测试用例,把常见的坑全部踩一遍再上去交。针对最小栈,下面这些场景必须覆盖:
第一个是空栈操作。有些语言里getMin()定义在空栈上行为未定义,但你要确保自己的实现不会崩。Python 里直接self.min_stack[-1]会报 IndexError,所以题目一般会保证不会对空栈调用getMin()和pop(),但你自己测试时还是要留意。
第二个是递减序列。依次 push 5、4、3、2、1,每次getMin()都要返回当前栈顶位置对应的最小值。递减序列是辅助栈最容易暴露问题的地方,因为每一个元素都是新低,辅助栈会一路增长。
第三个是递增序列。依次 push 1、2、3、4、5,getMin()始终返回 1。递增序列对稀疏辅助栈方案是个福音,空间占用非常小,但如果你用的“每层记录”版本,空间会线性增长,没有任何优化空间。
第四个是重复最小值。比如依次 push 2、2、2、2,然后pop两次,getMin()仍然应该返回 2。很多人在重复值上翻车,因为他们在push时用了val < min_stack[-1](严格小于)而不是<=,导致重复最小值没有被正确记录,pop掉一个 2 之后,辅助栈顶变成了一个更大的数,getMin()就错了。
我建议每个想彻底掌握这道题的人,都花两分钟手动跑一遍下面这个测试用例:
stack = MinStack() stack.push(3) stack.push(5) assert stack.getMin() == 3 stack.push(2) stack.push(2) assert stack.getMin() == 2 stack.pop() assert stack.getMin() == 2 stack.pop() assert stack.getMin() == 3 stack.push(-1) assert stack.getMin() == -1如果这套用例能一次通过,说明你对最小栈的理解已经合格了。
5.2 常见问题与排查技巧实录
我在实际调试这道题时遇到的最高频问题,整理成一张速查表,方便你对照排查:
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| getMin 返回错误值 | push 时没同步更新辅助栈 | 检查压栈逻辑,每层都要记录快照 |
| pop 后 getMin 变错 | pop 时漏了同步弹出辅助栈 | 在数据栈 pop 的同时 pop 辅助栈 |
| 重复最小值处理错误 | push 时用<而不是<=判断 | 新值等于最小值时也要压入辅助栈,或压入的是旧 min 而非 val |
| 差值法返回负数或异常大数 | int 溢出 | 将 diff 声明为 long long 或改用辅助栈 |
| top 返回错误真实值 | 差值法分支判断写反 | 记住 diff < 0 时真实值就是 min_val |
| 对象初始化报错 | 在init里忘记初始化 min_stack | 每次创建对象都要重新初始化数据结构 |
这里我特别想提一个细节:push时辅助栈压入判断。如果你选择“每层记录”版本,if not self.min_stack or val <= self.min_stack[-1]和else: append(self.min_stack[-1])是等价的,但在面试时写成一简一繁的 if-else 有风险。我更推荐写成:
if not self.min_stack: self.min_stack.append(val) else: self.min_stack.append(min(val, self.min_stack[-1]))这样逻辑更直白,不用纠结符号,也不会在重复值上犯错。
6. 面试题背后真正的算法思维
6.1 从“能否实现”到“能否更好”的进阶
最小栈这道题,从 O(N) 到 O(1) 的进化,其实映射了算法思维里一条很重要的主线:不做重复计算,把开销前置。
暴力解的浪费在于:每次getMin()都把整个栈扫一遍,但很多扫描是完全重复的——前一个状态的最小值和后一个状态的最小值之间有大量重叠信息。辅助栈做的事很朴素:把每一次查询想得到的答案,在写入时就已经算好,存起来。这就是“预计算”思维,它贯穿了整个算法设计。
差值法看上去更炫,但它真正的意义不在“节省空间”,而在于展示你能不能用数学关系编码信息。栈里存的不再是数据本身,而是数据与状态的差值,通过一个全局变量作为“参照物”,就能完整还原所有原始数据。这是一种更高级的思维:当结构无法直接满足需求时,改变存储的语义。
面试官真正想看的,往往不是某一种具体解法,而是你在面对“标准数据结构无法满足新需求”时,如何分析、取舍、迭代。所以这道题在面试中的正确节奏应该是:
- 先用暴力解保证正确性。
- 再提出辅助栈方案,讲清楚空间换时间的本质。
- 如果被追问,再展示差值法,并主动指出它的溢出风险和可读性问题。
这样一套组合拳下来,既展现了扎实的编码能力,又体现了工程权衡意识,比闷头写出一个最优解给人的印象好得多。
6.2 这道题能迁移到哪些场景
最小栈的思路不止能解这一道题。只要你需要“在动态增删的数据结构中快速查询某个统计量”,空间换时间 + 预计算的思想都能派上用场。
最直接的是最小队列:设计一个队列,支持push、pop、getMin都是 O(1)。这比最小栈难,因为队列是先进先出,你不能只靠一个辅助栈维护。主流解法是用两个栈模拟队列,再叠加最小栈的思想。本质上还是“把状态分阶段记录”。
再延伸一下,单调栈算法其实也是同一个思维脉络:维护一个额外的单调递减或递增栈,用来快速获取当前区间的最值信息。比如接雨水、柱状图中最大的矩形,核心都是在遍历过程中维护一个单调结构,避免重复扫描。
如果你对算法的认识只停留在“会做这道题”,那最小栈也就只是一道题;如果你能从这道题里提炼出“预计算避免重复查询”“用辅助结构记录中间状态”这些通用套路,它就能帮助你在更多题目里打开思路。我个人刷题的经验是:遇到一道好题,值得花时间把它从暴力解到最优解的每一步都想明白,远比一天刷十道题有价值。
最后说点实际的:我在面试别人的时候,通常不看候选人能不能写出最终版本,而是看他怎么描述自己的思考过程。能直接写出辅助栈的人不少,但能主动说出“这个方案空间复杂度是 O(N),如果数据量很大可能是个瓶颈,还可以用差值法压到 O(1)”的人,寥寥无几。这道题真正的分水岭,就在于你有没有对复杂度做主动的、体系化的思考。你如果能把这一点练成习惯,那收获的可就不只是这一道题的 AC 了。