很多 Python 学习者刷到“柱状图中的最大矩形”这道题时,往往会有种奇怪的感觉:题目描述很简单,不就是一堆竖着的柱子里面找最大长方形吗?可自己一写,暴力解法在小数据量下明明能跑,提交到 LeetCode 上却直接超时;换成“枚举每个柱子当高,向左右扩展”的写法,看起来优化了不少,数据一大还是悬。这个题就是经典面试题 LeetCode 84,也是理解“单调栈”这个数据结构绕不过去的一道坎。
这篇文章我想从一个实际刷题者的视角,把这题的完整思考链路捋一遍:为什么暴力会挂、为什么要换枚举对象、单调栈到底在栈里维护了什么、Python 代码怎么写才不容易出错,以及它能直接迁移到的接雨水、二维最大矩形等题目。适合准备算法面试的 Python 开发者、正在刷 LeetCode 的初学者,以及想彻底搞懂单调栈原理而不是靠背模板的人。
1. 先从题目和暴力解说起:这题到底在求什么
1.1 把题面翻译成大白话
题目给你一个非负整数数组 heights,每个数字代表一根柱子的高度,所有柱子宽度都是 1。你要在这个柱状图内部画一个矩形,矩形的底边和 x 轴平行,且矩形完全被柱子覆盖,不能超出柱子边界,也不能悬空。问这个矩形的面积最大能是多少。
举个例子,heights = [2, 1, 5, 6, 2, 3],对应的最大面积是 10。这个 10 是怎么来的?是第 3 根柱子(高度 5)和第 4 根柱子(高度 6)共同撑起一个宽 2、高 5 的矩形,面积 5 × 2 = 10。注意这个矩形不能把第 5 根柱子(高度 2)也包含进来,否则高度只能取 2,面积反而变成 2 × 3 = 6。
这道题看起来像几何题,但它的难点根本不是几何,而是组合优化:候选矩形数量非常多,怎么快速找出最大那个。数组长度为 n,任意两个柱子之间都可能形成一个候选矩形,粗看下来候选数量就是 O(n²) 级别,处理起来必须格外小心。
1.2 暴力枚举的两种写法,以及它们各自的瓶颈
初学者最容易想到的第一种暴力是枚举所有左右边界,再计算当前区间内最矮的柱子高度作为矩形高度。区间 [left, right] 的宽度是 right - left + 1,矩形高度等于 min(heights[left:right+1]),面积就是两者相乘。
这种写法如果每次用 min() 重新扫区间,复杂度是 O(n³);稍微优化点,在固定左边界后向右扩展时同步维护最小值,复杂度降到 O(n²)。n 小的时候没问题,可 LeetCode 的测试数据经常把 n 拉到 10^5,O(n²) 意味着上亿次操作,Python 基本必挂。
第二种暴力稍微“聪明”一点:枚举每一根柱子作为矩形的高度,然后从这个位置向左、向右扩展,直到遇到比它矮的柱子就停。计算一下扩展出来的宽度,再乘当前柱高,更新最大值。
python复制def largest_rectangle_area_brutal(heights):
n = len(heights)
ans = 0
for i in range(n):
h = heights[i]
left = i
while left > 0 and heights[left - 1] >= h:
left -= 1
right = i
while right + 1 < n and heights[right + 1] >= h:
right += 1
ans = max(ans, h * (right - left + 1))
return ans
这段代码在最好情况下(比如所有柱子高度相等)单次扩展就能走到尽头,但最坏情况下(比如高度从低到高递增排列),每个柱子都要向外扫描一大段,整体还是 O(n²)。我最早刷这题时就是卡在这个版本上,逻辑觉得没毛病,提交就超时。后来才意识到,问题不是扩展写错了,而是每个柱子重复进行了太多次“找边界”的工作。
面试里如果只写出暴力版本,大概率只是及格分。面试官真正想看到的是,你能不能意识到重复计算,并想到用数据结构去复用已经算过的信息。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 破局点:不要枚举区间,要枚举“高度”
2.1 最大矩形的高度必然等于某一根柱子的高度
这句话是整个解法的地基。为什么成立?你可以想象一个矩形放在柱状图里,矩形的顶边一定贴在某一根柱子的顶部,或者被某根更高的柱子“顶住”。因为如果矩形的顶边悬空在柱子内部,没有贴到任何柱子的上沿,那它完全可以向上移动,直到顶到某根柱子的顶部,面积不变,仍然合法。
更严谨一点说,把矩形向下压缩到它的顶边恰好低于某根柱子顶部时,高度可以继续增加,直到触碰第一根挡住它的柱子。最终这个最大矩形的高度,一定会落在某一根柱子的“完整高度”上,而不是某个中间值。
这个洞察改变了枚举方式:我不再枚举矩形的左右边界,而是枚举每一根柱子 i,假设最终矩形的高度就是 heights[i],然后问自己一个问题——以这根柱子为高,矩形最远能向左向右延伸到哪里?
2.2 一旦确定高度,矩形边界由“左右第一个更矮柱子”决定
假设我们固定第 i 根柱子为矩形高度 h = heights[i]。因为矩形高度不能超过 h,所以它不能越过任何一根高度小于 h 的柱子。换句话说,以第 i 根柱子为中心,向左找到第一根高度小于 h 的柱子,记它的下标为 left;向右找到第一根高度小于 h 的柱子,记它的下标为 right。那么当前柱子的最大扩展宽度就是 right - left - 1。
这里有个容易想错的点:宽度不是 right - left + 1,而是 right - left - 1。因为 left 和 right 这两根柱子本身是“障碍物”,它们不能被包含进矩形,矩形真正覆盖的范围是 (left, right) 这个开区间内的所有柱子。
于是问题转化为:对每一根柱子 i,求出左边界 left[i] 和右边界 right[i]。有了这两个数组,一次 O(n) 扫描就能算出答案:
python复制ans = max(heights[i] * (right[i] - left[i] - 1) for i in range(n))
2.3 用“再扫描一遍”求左右边界照样是 O(n²)
知道要算 left[i] 和 right[i] 之后,很多人第一反应是:那我每个位置向左找第一个更矮的,向右找第一个更矮的就好了啊。这确实是正确方向,但效率还是不够。
如果对每个 i 都独立向左扫描,遇到递增序列时,每个位置都要回退到很远的地方,整体复杂度依然是 O(n²)。不信你可以试试 heights = [1, 2, 3, 4, ..., n] 这个极端场景:第 n 个柱子要向左数 n-1 次,第 n-1 个柱子要向左数 n-2 次,累加起来就是 O(n²)。
这里真正的问题是,我们求边界时没有利用“已经扫描过的信息”。比如从左往右扫到第 n 个柱子时,前面 n-1 个柱子的信息明明已经看过了,能不能把它们沉淀成某种结构,下次直接拿来用?这就是单调栈登场的理由。
3. 单调栈原理:一次遍历,左右边界同时确定
3.1 栈里维护的是一串“还没被淘汰的柱子”
单调栈的思路并不复杂:从左到右遍历柱子,维护一个栈,栈里存的是柱子的下标。为了保证查询高效,我们让栈里下标对应的柱子高度保持严格递增(也有做法是单调不减,后面细说)。
遍历到当前柱子 i 时,如果它的高度 heights[i] 小于栈顶柱子的高度,说明栈顶那根柱子的“右边界”已经出现了——就是当前这根更矮的柱子。于是把栈顶弹出,此时:
- 当前遍历到的位置 i,就是弹出柱子的右边界。
- 弹出后新的栈顶,就是弹出柱子左侧最近且高度小于它的柱子,也就是它的左边界。
- 这样一来,以弹出柱子为高的最大矩形面积可以直接算出:heights[弹出下标] × (i - 新栈顶下标 - 1)。
有人会问:为什么新的栈顶一定是“左侧最近且更矮”的柱子?因为栈里维护的高度是递增的,从栈底到栈顶高度逐渐变大。新栈顶在弹出柱子的左边,高度比弹出柱子矮,而且它是栈中离弹出柱子最近的下标。由于所有比它更靠右、又比当前柱子高的柱子都被弹掉了,所以它就是当前柱子的左边界。
计算完面积后,继续比较新的栈顶。只要当前柱子还是比栈顶矮,就继续弹出并计算。直到当前高度不小于栈顶高度,说明当前柱子无法顶掉栈顶,这时候把它压入栈,它的左边界自然就是新的栈顶(因为栈内所有柱子都比它矮)。
这个过程非常像“一波接一波地淘汰”:每个柱子入栈时只是安静待着,一旦遇到右边第一个比它矮的柱子,它就被迫出栈,并在出栈那一刻算出自己的最大面积。由于每个下标最多入栈一次、出栈一次,整体复杂度是 O(n),不是外层 for 套内层 while 看起来的 O(n²)。
3.2 手动模拟 [2, 1, 5, 6, 2, 3],看每一步发生了什么
光讲原理不够直观,我建议你亲手推一遍经典例子。为了后面解释方便,代码里先设置两个哨兵 0,放在数组首尾,也就是 heights = [0, 2, 1, 5, 6, 2, 3, 0],栈初始为 [0]。
我按“当前高度小于栈顶高度时弹出”的规则走一遍:
- i=1,高度 2:2 < 0 不成立,入栈,栈变成 [0, 1]。
- i=2,高度 1:1 < 2 成立,弹出下标 1(高度 2),此时新栈顶是 0,右边界是 i=2,面积 = 2 × (2 - 0 - 1) = 2。接着 1 < 0 不成立,停止弹出,下标 2 入栈,栈变成 [0, 2]。
- i=3,高度 5:5 < 1 不成立,入栈,栈变成 [0, 2, 3]。
- i=4,高度 6:6 < 5 不成立,入栈,栈变成 [0, 2, 3, 4]。
- i=5,高度 2:2 < 6 成立,弹出下标 4(高度 6),栈顶变为 3,面积 = 6 × (5 - 3 - 1) = 6。2 < 5 成立,弹出下标 3(高度 5),栈顶变为 2,面积 = 5 × (5 - 2 - 1) = 10。2 < 1 不成立,停止,下标 5 入栈,栈变成 [0, 2, 5]。
- i=6,高度 3:3 < 2 不成立,入栈,栈变成 [0, 2, 5, 6]。
- i=7,高度 0:0 < 3 成立,弹出下标 6(高度 3),栈顶变为 5,面积 = 3 × (7 - 5 - 1) = 3。0 < 2 成立,弹出下标 5(高度 2),栈顶变为 2,面积 = 2 × (7 - 2 - 1) = 8。0 < 1 成立,弹出下标 2(高度 1),栈顶变为 0,面积 = 1 × (7 - 0 - 1) = 6。0 < 0 不成立,停止,下标 7 入栈。
最终最大面积在弹出下标 3 时算到,结果是 10。你注意看,弹出下标 3 的那一步,右边界是 i=5,也就是高度 2 的那根柱子,左边界是下标 2,也就是高度 1 的那根柱子,所以宽度 = 5 - 2 - 1 = 2,正好是高度 5 和 6 这两根柱子。
3.3 相等高度时用 < 还是 <=?别看细节,影响挺大
这是新手最容易困惑的地方。弹出条件写成 heights[i] < heights[stack[-1]] 和 heights[i] <= heights[stack[-1]] 都能通过所有测试用例,但它们的语义不一样。
- 用 < 时,相等高度的柱子不会被立刻弹出。假设高度全是 2,比如 [2, 2, 2, 2],前几根 2 会依次入栈,直到末尾哨兵 0 出现,再从右往左依次弹出。最左侧那根 2 最后弹出时,计算出的宽度是整个数组长度,面积 8 就出现在这一步。
- 用 <= 时,相等高度的柱子会被后一个相同的柱子顶掉。同样是 [2, 2, 2, 2],第二根 2 会把第一根 2 弹掉,算出宽度 1,面积 2;第三根 2 再把第二根弹掉……不过右边保留的柱子最后也能兜底算出面积 8。
两种写法都能得到正确答案,但有一个实务建议:如果你在首尾加了哨兵 0,尽量用 <。因为哨兵 0 的高度和另一个哨兵 0 相等,如果用 <=,末尾哨兵可能会把左哨兵也弹出去,导致栈变空,后面取新栈顶时容易出问题。用 < 则左哨兵永远稳坐栈底,不需要额外判断栈是否为空。
4. Python 代码实现:哨兵技巧和几个容易踩的坑
4.1 为什么要在数组首尾各加一个 0
前面手推时我直接用了带哨兵的版本,现在解释一下哨兵的作用。左侧加 0:保证栈底永远有一个高度最小的元素,这样在处理过程中弹出任意柱子后,栈都不会为空,省去“栈空则左边界为 -1”这类特殊判断。右侧加 0:遍历结束后,这个 0 会把栈里所有真实柱子都 “逼” 出来,逐个计算面积,省掉循环结束后再单独清算栈内剩余元素的代码。
不使用哨兵也能写,但需要在弹出后判断栈是否为空,相当于把边界条件散落在代码各处。加哨兵的核心价值是让算法“自洽”:所有矩形都会在遍历过程中被自然计算,不需要额外收尾。
具体实现:
python复制def largest_rectangle_area(heights):
heights = [0] + heights + [0] # 左右哨兵
stack = [0] # 左哨兵的下标
max_area = 0
for i in range(1, len(heights)):
while heights[i] < heights[stack[-1]]:
cur = stack.pop() # 弹出更高柱子的下标
left = stack[-1] # 弹出后新栈顶,就是它的左边界
width = i - left - 1 # 右边界是当前 i,宽度为开区间长度
max_area = max(max_area, heights[cur] * width)
stack.append(i)
return max_area
这段代码非常短,但信息密度很大。每一次 while 循环只做一件事:把当前矩形高度固定为弹出柱子的高度,用左右边界算出面积,并更新最大值。
4.2 用常见用例验证代码逻辑
空数组、单根柱子、全部等高的场景都应该测一下,防止边界问题。
python复制assert largest_rectangle_area([2, 1, 5, 6, 2, 3]) == 10
assert largest_rectangle_area([2, 4]) == 4
assert largest_rectangle_area([2, 2, 2, 2]) == 8
assert largest_rectangle_area([1]) == 1
assert largest_rectangle_area([]) == 0
assert largest_rectangle_area([0, 0, 0]) == 0
我逐个说一下为什么这些断言能过。对于 [2, 4],最大矩形有两种取法,取高度 2 宽度 1 面积 2,或者取高度 4 宽度 1 面积 4,答案是 4。对于 [2, 2, 2, 2],最大矩形是整个柱状图,高度 2 宽度 4,面积 8,代码通过左侧最后弹出的柱子计算得到。空数组加哨兵后变成 [0, 0],不会进入任何计算逻辑,返回初始值 0。
这种写法在 Python 3 环境下直接运行就可以。LeetCode 的输入本身就是 list[int],不需要额外处理输入格式;如果是本地用字符串输入,拆分一下再转 int 即可。
4.3 Python 新手容易写错的三个细节
第一个是宽度公式。很多人把 width 写成 i - left 或者 i - left + 1,这是不对的。left 是障碍柱下标,i 是右边界障碍柱下标,能被包含的柱子只有 left + 1 到 i - 1,数量是 i - left - 1。你哪怕少写一个减号, [2, 1, 5, 6, 2, 3] 这个例子就会算出错误结果。
第二个是加哨兵之后遍历范围必须跟着变。如果原数组长度是 n,函数里 heights 已经变成 n + 2,for i in range(1, n) 这种写法会把末尾的哨兵漏掉,导致最后该清算的柱子没清算。正确写法是 range(1, len(heights)),让循环覆盖到追加进去的右哨兵。
第三个是别在 while 里把当前下标也弹出去。弹出操作只针对栈中已有的更高柱子,当前柱子即使高度很低,最后也要入栈。很多初学者在 while 结束后忘了 stack.append(i),结果下一轮循环栈里少了当前柱子,状态完全错乱。
另外补充一个 Python 特有的注意点:如果 heights 元素全是很大的整数,面积可能超过普通 32 位整数范围。Python 的 int 没有溢出问题,但如果你把这个算法移植到 C++ 或 Java,ans 记得用 long 类型。
4.4 时间复杂度到底是多少,面试怎么讲
代码结构是 for 循环里套 while,很多人第一反应是 O(n²)。实际不是。看每个下标的行为:它最多被压入栈一次,最多被弹出一次。每次弹出都会触发一次面积计算,所以总弹出次数不超过 n。for 循环本身只遍历 n + 2 个元素,总体操作量是 O(n)。即使某次 while 一口气弹出很多元素,那些元素也是之前入栈攒下来的,整体均摊下来还是线性。
这个“均摊分析”是面试时特别加分的点。你可以这样表达:虽然存在嵌套循环,但栈中每个元素只入栈出栈一次,所以总时间复杂度 O(n),空间复杂度 O(n),主要消耗在栈上。
5. 同一套单调栈思路,能直接秒掉哪些变体题
5.1 接雨水:弹栈时算的是“凹槽能存多少水”
LeetCode 42 接雨水和本题长得几乎一样,都是给一排柱子高度,不同点是 84 求柱子内部能画出的最大矩形,42 求柱子之间能存多少雨水。
接雨水的单调栈做法也是从左到右遍历,遇到当前高度大于栈顶高度时开始弹出,说明出现了一个可以存水的凹槽。凹槽的底是弹出的柱子高度,左右两侧分别是新栈顶和当前柱子,能存的水量是:
(min(左墙高, 右墙高) - 底高) × (当前下标 - 新栈顶下标 - 1)
核心结构几乎一模一样。所以你在面试时如果能把 84 讲透,面试官紧接着问 42,你只需要把“算面积”换成“算水量”,答题会非常顺手。
5.2 二维矩阵里的最大全 1 矩形:逐行投影成一维问题
LeetCode 85 最大矩形给的是一个二维 0/1 矩阵,要求找出只包含 1 的最大矩形面积。这题初看是二维问题,但可以逐行转成一维柱状图:
维护一个数组 heights,heights[j] 表示当前行往上数,第 j 列连续 1 的个数。遍历每一行,更新这个数组后,调用一次这题的 largest_rectangle_area 函数。因为“连续 1 的高度”本质上就是柱状图,所以二维问题被拆成 m 次一维问题,总复杂度 O(m × n)。
5.3 其它同源题目与实际问题迁移
除了上面两题,LeetCode 1504 统计全 1 子矩形数量也能用类似思路,只是每次弹栈时不仅要算最大面积,还要累加所有可能矩形的数量。思路仍然是把二维压缩成一维柱状图,再用单调栈维护每个柱子的左右边界。
实际工程里,柱状图最大矩形也有用武之地。比如图像处理中找二值图片里的最大白色矩形区域,可以把每一行看成新的柱状图,逐行扫描;再比如数据分析中,把一段连续上涨的成交量数据视为柱子,寻找支撑意义下的最大面积区间。只要问题能抽象成“固定一个高度,找左右第一个更矮位置”的形式,都可以尝试用单调栈。
我个人在实际做题中的体会是,这道题的价值不只在背模板,在于它逼着你把一个 O(n²) 的枚举问题,通过“改变枚举对象 + 用单调性消除重复计算”转成 O(n)。你如果能把这道题的思维过程完整复述出来,以后再遇到类似的数据结构题,就不会只想着套模板,而是会先问自己一句:我在重复计算什么?有没有办法用单调性一次性解决。这比记住某段代码重要得多。
