1. 最小栈问题拆解:到底在考什么
1.1 题目背景与核心需求
我在写这第34课的时候,正好有朋友问我:"面了十几次算法,每次都能碰到最小栈,这题到底有多重要?"我说你先别急,先把题读懂。最小栈是 LeetCode 第 155 题,也是数据结构与算法面试里出场率极高的经典题。它表面上是"设计一个支持 push、pop、top 操作,并能在常数时间内检索到最小元素的栈",实际上考的是你在"受限数据结构"上做"功能扩展"的能力。
一句话说清楚需求:你手里有一个现成的栈,这个栈本身只支持先进后出,但题目要求你在不破坏这个特性的前提下,给栈装上一个"实时最小值仪表盘",而且这个仪表盘的读数必须是 O(1) 的。很多第一次刷题的人看到"常数时间检索最小值"会愣住,因为直觉上,数组里找最小值得遍历一遍,栈里找最小值当然也得把所有元素翻一遍,怎么可能 O(1)?
关键就在"实时"这两个字。我们需要的不是"某个时刻的最小值",而是"从当前栈底到栈顶这个集合的最小值",并且随着 push 和 pop 操作不断变化,任何时刻你都得能一口气报出来。这就意味着信息不能临时算,得"记账",每进入一个元素,就把这个元素到来时的状态记下来。
1.2 为什么暴力解法不靠谱
暴力解法其实很直观:每次调用 getMin 时,把栈里的元素全部遍历一遍,找出最小值,时间复杂度 O(n)。如果一个栈有 10 万个元素,你每调一次 getMin 就遍历 10 万个,效率低到没法看。面试官问你这题,本质上要听到的绝对不是这个答案,而是你能不能在"时间"和"空间"之间做取舍。
有人会想:那我用一个变量 min 专门存当前最小值,push 的时候比一下,如果新元素更小就更新 min,这不就 O(1) 了?问题来了,pop 的时候呢?你把栈顶弹出去,如果弹出去的恰好是最小值,那 min 就失效了。你得知道"上一个最小值"是什么,可这个信息光靠一个变量是存不下来的。这就像你只记住了一个公司里工资最高的人是谁,但你不知道第二高的是谁,万一最高的走了,你压根没法补位。
所以这里就自然引出一个思路:既然一个变量不够用,那我就用额外结构把"历史最小值"全部记下来。辅助栈就是这么来的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 三种主流解法:思路、对比与选型考量
2.1 辅助栈(同步栈)解法
这是最基础也最好理解的做法。我维护两个栈:一个主栈 data 存真实数据,一个辅助栈 mins 存"当前状态下的最小值"。
push 操作:
- data.push(x)
- 如果 mins 为空,或者 x 小于等于 mins 栈顶,就把 x 也压入 mins
- 否则,把 mins 栈顶元素再压一次
这里的细节是"小于等于"而不是"小于"。我见过很多人在这里栽跟头:如果新元素等于当前最小值,你不把它压进辅助栈,那 pop 的时候你就有概率丢掉唯一的最小值记录。为了稳妥,直接"小于等于"最省心。这样两个栈的高度永远一样,pop 的时候两边一起弹,根本不用判断。
getMin 操作就一行:return mins.top()。
这个方案时间上所有操作都是 O(1),空间上最坏情况是 O(n),因为辅助栈和主栈等长。它的优点是逻辑极度简单,几乎不可能写错,非常适合在面试中作为第一个答案给出,然后再优化。
2.2 只存历史最小值的压缩版辅助栈
如果你觉得辅助栈存了太多重复值,可以压缩一下。不进辅助栈的条件改为:仅当 x 小于等于当前 mins 栈顶时才入栈。这样 mins 栈里维护的是一条"最小值递减链",每个元素都是某一段时期的最小值纪录。
问题在于 pop 的时候需要判断:如果 data 弹出去的元素恰好等于 mins 栈顶,mins 也要弹栈。这里需要注意,因为压缩辅助栈的高度比主栈矮,两边不能无脑同步弹了,必须比较。另外,之前的同步栈里用的是"小于等于",这里如果也用"小于等于",遇到连续重复的最小值时,辅助栈会保留多个相同值,pop 的时候弹掉一个,还剩一个,也能正确工作。如果改成"小于",那就要特别小心重复值的边界问题。
我在教学时经常强调一句话:压缩版辅助栈是"记录历史变化点",同步版辅助栈是"记录每个时刻的状态"。 前者省空间,后者省判断,没有绝对优劣,看你更在意哪头。
2.3 差值法:常数额外空间的巧妙玩法
这是最"秀"的一种解法,思路是把数据栈里存的不是原始值,而是"当前值和当前最小值的差值"。
具体来说:
- push(x) 时,如果 x 小于当前最小值 min,先用一个变量存储旧最小值,然后更新 min = x,最后压入的数值是 x - 旧min(一个负数)
- 反之,压入 x - min(一个非负数)
- pop 时,如果栈顶元素是负数,说明弹出的这个位置恰好是"最小值更新点",需要把 min 恢复为旧最小值,即 min = min - stack.top()
- getMin 直接返回 min
这个方案空间上确实是 O(1),思路也很精妙,但你要付出的代价是:可读性差,边界条件多,而且数字可能溢出。如果面试官不强制要求常数额外空间,我不建议在面试写这个,除非你已经练得滚瓜烂熟。
2.4 三种方案横向对比
| 方案 | 时间复杂度 | 额外空间 | 可读性 | 错题率 | 推荐场景 |
|---|---|---|---|---|---|
| 同步辅助栈 | O(1) | O(n) | ★★★★★ | 极低 | 面试首选、工程实现 |
| 压缩辅助栈 | O(1) | O(n)(最坏) | ★★★★ | 中 | 需要省一点空间时 |
| 差值法 | O(1) | O(1) | ★★ | 高 | 炫技或面试官追问 |
我的建议很直接:面试时先答同步辅助栈,讲清楚为什么可以 O(1),再提压缩版,最后聊差值法让面试官看到你有深度。但工程上,我长期用的是同步辅助栈,因为它不会错,代码 review 的人也一眼能看懂。
3. 实操实现与核心环节代码解析
3.1 Python 实现:同步辅助栈
我用 Python 写一个最稳的版本给你,这也是我个人最推荐的实现方式:
python复制class MinStack:
def __init__(self):
self.data = []
self.mins = []
def push(self, x: int) -> None:
self.data.append(x)
if not self.mins or x <= self.mins[-1]:
self.mins.append(x)
else:
self.mins.append(self.mins[-1])
def pop(self) -> None:
self.data.pop()
self.mins.pop()
def top(self) -> int:
return self.data[-1]
def getMin(self) -> int:
return self.mins[-1]
这段代码的核心逻辑在于 push 里的那个 else。如果不加 else,只把新元素比最小值小的压入辅助栈,就会出现"辅助栈比主栈短"的情况,pop 时就需要额外的判断。而这里通过"每 push 一个,辅助栈必 push 一个",保证了两个栈严格同步,pop 的时候只管弹,完全不需要考虑边界状况。
有同学会问:这样辅助栈不是存了很多重复的最小值吗?是的,空间确实浪费了一些,但换来的好处是代码几乎不需要动脑。在工程里,栈的规模通常不会大到让辅助栈成为瓶颈,但逻辑错误一定会让人头疼。我个人在实际项目里就吃过亏,以后但凡写最小栈一律同步处理,省心。
3.2 Python 实现:压缩版辅助栈
压缩版稍微灵活一点,适合你想省空间时用:
python复制class MinStack:
def __init__(self):
self.data = []
self.mins = []
def push(self, x: int) -> None:
self.data.append(x)
if not self.mins or x <= self.mins[-1]:
self.mins.append(x)
def pop(self) -> None:
if self.data:
if self.data[-1] == self.mins[-1]:
self.mins.pop()
self.data.pop()
def top(self) -> int:
return self.data[-1]
def getMin(self) -> int:
return self.mins[-1]
这里我再强调一次那个"等于"的判断。如果用的是小于而不是小于等于,当你想压入一个和当前最小值相等的值时,辅助栈不会记录它。这时候如果主栈把这个相等的值弹出去,辅助栈会跟着弹出原本的最小值,但主栈里可能还有一个同样大小的值作为当前最小值。那个值当时没被记入辅助栈,于是 getMin 就错了。
你以为这只是极端情况,其实在面试中面试官特别喜欢考这种边界。所以我的经验是:要么用"小于等于"保证重复值也被记录,要么在 pop 时用"等于"来判断何时弹辅助栈。两者配套使用,才不会踩坑。
3.3 差值法的 Python 实现与边界分析
写给你感受一下:
python复制class MinStack:
def __init__(self):
self.stack = []
self.min_val = 0
def push(self, x: int) -> None:
if not self.stack:
self.stack.append(0)
self.min_val = x
else:
self.stack.append(x - self.min_val)
if x < self.min_val:
self.min_val = x
def pop(self) -> None:
if self.stack:
top = self.stack.pop()
if top < 0:
self.min_val = self.min_val - top
def top(self) -> int:
if self.stack[-1] < 0:
return self.min_val
return self.min_val + self.stack[-1]
def getMin(self) -> int:
return self.min_val
这里的关键是 top 方法。当栈顶存的是负值时,说明这个位置是新最小值的位置,真正的原始值就是当前最小值。当栈顶是非负值时,说明当前最小值没变,原始值等于差值加最小值。每次 pop 弹出负值时,需要恢复上一个最小值,恢复的公式是"旧最小值 = 当前最小值 - 栈顶差值",因为 x 比旧最小值小,差值 = x - 旧min,负数,所以旧 min = x - 差值 = min_val - top。
我建议不要只看代码,拿一组真实数字推演一遍。比如依次压入 3、5、2、1,你手动算一下每个位置的差值,直到你彻底转明白为止。如果转不明白也没关系,用回辅助栈,这不丢人,因为工程上可维护性远比炫技重要。
3.4 C++ 实现的注意要点
C++ 实现辅助栈的时候,有一个很多人忽略的点:数据类型的极限。如果题目给的数值范围是 int 范围内的正负 2 的 31 次方,那辅助栈存 int 没问题。但如果你用的是差值法,差值的区间理论上可能大到两个极端值之差,那就超出 int 范围了,必须用 long long。所以在 C++ 里,我通常会直接定义两个 stack<long long>,避免不必要的风险。
另一个 C++ 特有的坑是栈的 top() 行为:空栈时调用 top() 是未定义行为,程序可能直接崩溃。所以 pop 和 top 方法里要么先判空,要么保证调用方逻辑上不会出现空栈操作。在 LeetCode 上不会测到空栈 pop,但你在自己写测试用例时一定要覆盖。
4. 常见错误与排查技巧实录
4.1 错误一:辅助栈的"小于等于"写成了"小于"
这是我在带新人时碰到频率最高的错误。原因我前面已经分析过,核心就是重复最小值问题。你可以记一个口诀:更新辅助栈的条件要"包容相等",弹出辅助栈的条件要"严格相等",这样才不会丢历史记录。
如果你已经写了"小于",又不想改 push 的逻辑,那 pop 时必须改成 if self.data[-1] <= self.mins[-1] 才安全。同样是等于,但语义完全不同,一个是准备记录,一个是准备删除。写代码的时候把这两个逻辑放在一起对比着看,能省下不少调试时间。
4.2 错误二:pop 时忘记同步辅助栈
同步辅助栈方案里,很多人写 push 时记得两边一起更新,写 pop 时只弹了主栈,辅助栈纹丝不动。这样会导致 getMin 一直返回旧值,错误还特别隐蔽,因为只有你 pop 过最小值之后才会暴露。
排查技巧是打印两个栈的结构。比如压入 5、3、7,再 pop 一次,打印 data 和 mins,你会发现 mins 的高度应该还是 2 而不是 3。如果你发现高度对不上,基本就是同步逻辑的问题。我在调试这类问题时会写一个临时的 debug() 函数,把两个栈一起打出来,看一次就明白。
4.3 错误三:差值法中最小值的恢复公式搞反
差值法的核心是 pop 时如果栈顶是负数,要用 min_val = min_val - top 来恢复旧最小值。很多人会写成 min_val = min_val + top 或者干脆记成 min_val = top。这里我给你一个推演方法:
假设旧最小值是 old_min,新来的元素 x 比 old_min 小,那么压入的差值 d = x - old_min,是个负数。此时 min_val 更新为 x。弹出的时候,你手里有 d 和当前 min_val(也就是 x),要求 old_min:由于 d = x - old_min,所以 old_min = x - d = min_val - d。这里减 d 等于减一个负数,所以最终 min_val 变大了,恢复回旧的、更大的那个最小值。
如果你能把这个推导过程写出来,而不是死记公式,以后遇到类似的题都不会再错。记住一个核心:差值法只是把数据"编码"了,算原始值时永远记得"原值 = 差值 + 当时的最小值"。
4.4 面试现场容易出的追问
这题面试官最常见的追问就是:"能不能把空间复杂度降到 O(1)?"这时候你把差值法讲出来就很有优势。但接着他可能会问:"差值法有什么缺点?"你要能答出来:整数溢出风险和可读性差。如果他又问"有没有其他方案",你可以提用链表实现,每个节点存"当前值和到目前位置的最小值"两个字段,本质上和辅助栈一样,但把信息绑定在了节点上。
另外有些面试官会转变形式:"如果实现一个最大栈呢?"思路一模一样的,把"小于等于"换成"大于等于",辅助栈记录历史最大值就行。我建议你把这个变种也练一下,因为它能证明你是真正理解了方案,而不是死背模板。
我见过最离谱的一个追问是:"如果 pop 操作不存在的元素怎么办?"这种问题实际上测试你对栈边界条件的理解,你只需要说"栈操作本身应当是合法的,但如果要防御式编程,可以在 pop 前加 isEmpty 判断"就能应付过去。
5. 最小栈的工程应用与变种扩展
5.1 实际项目中最小栈用在哪
很多人觉得最小栈只是刷题玩具,我在做嵌入式相关项目时还真用过类似结构。当时需要监控一个实时数据流里的最小值窗口,数据不断进栈和出栈,每个时刻都要快速拿到当前窗口的最小值。这个结构用最小栈的思想稍微改造一下,就能实现"滑动窗口最小值"的高效解法。
另一个常见场景是代码编辑器里的"撤销栈"配合"最小操作代价"计算。比如你想记录当前操作队列里代价最小的操作是哪个,同时支持撤销操作,这时候一个辅助栈就能帮你把历史最小代价保持同步更新。虽然这些场景不一定叫"最小栈",但本质是一样的:用额外栈记录前缀信息。
5.2 从最小栈延伸出去的数据结构思维
最小栈的底层思维其实是一种很通用的技巧:用额外的存储空间,记录"前缀状态"的某种聚合信息。前缀和数组是同一种思想,线段树也是,稀疏表也一样,只不过它们在不同场景下有不同的操作复杂度。
如果你吃透了最小栈,再去看"实现一个支持 getMax 的队列"或者"滑动窗口最大值",会发现思路特别顺。尤其是滑动窗口最大值,它需要的是一个单调队列,和最小栈里的单调辅助栈异曲同工,只是去掉了一端的限制。这正好说明了为什么算法题不能死记硬背,要理解数据结构之间的转化逻辑。
5.3 后续练习建议
我建议把这几道题放在一起刷:LeetCode 155 最小栈、239 滑动窗口最大值、面试题 59 - II 队列的最大值、以及 150 逆波兰表达式求值。这几道题组成了一个"栈和队列的极值维护"专题,练完之后你会对"前缀聚合信息"有深一层的感觉。
另外,你还可以自己设计一个小实验:用随机生成的 10 万次操作,分别用辅助栈和差值法实现最小栈,对比一下运行时间和内存消耗。虽然理论上都是 O(1) 时间,但实际常数项的差异会让你直观感受到"理论复杂度相同、实际性能不同的"是怎么回事。这种实验比干看题解有用得多。
最后再分享一个小技巧:平时写算法题,遇到"要求 O(1) 完成某个查询"的题,先想能不能用额外空间换时间,再从"能不能只存变化点"来压缩空间。这是我刷了几年算法题下来最核心的解题方法论。最小栈只是这个方法论的入门案例,后面你会遇到无数个它的变体,打好这一课的基础很值得。
