直方图最大矩形的宽度由左右第一个更矮柱子决定,逐柱扩展会重复扫描。本文用逐步可视化解释递增栈何时结算高度、为何要保留起点,并给出 Python 完整实现、坐标恢复、暴力对拍、复杂度和等高柱处理。
先画一排柱子
给定高度 [2,1,5,6,2,3],矩形必须覆盖连续柱子,高度取所覆盖区间的最小值。答案是高度 5、宽度 2、面积 10,对应下标 2 到 3。逐个区间计算最小值需要 O(n²) 甚至更多重复工作。
算法频道近期排序、滑动窗口和前缀类内容很多,背后的共同问题是"旧元素何时失去继续参与的资格"。对某根高度为 h 的柱子,只要右侧柱子不矮于它,矩形还可能向右延伸;遇到更矮柱子时,延伸资格终止,此刻才知道它的右边界。
栈里的条目画成区间
维护严格递增高度栈,每项为 (start,height)。start 表示这个高度作为区间最小值,最早能从哪里开始。读到位置 i 的新高度 current:
- 先令
start=i。 - 当栈顶高度不小于
current,弹出(left,h),它的矩形右边界就是i,面积为h*(i-left)。 - 把
start更新为弹出项的left,因为新高度更矮,能够继承这段向左扩展的范围。 - 将
(start,current)入栈。
扫描结束后追加高度 0 的哨兵,迫使所有剩余柱子结算。哨兵不需要真的修改输入数组。
手工走过样例
位置 0,高度 2 入栈 (0,2)。位置 1 的高度 1 更矮,弹出 2,面积 2×1=2,再把 (0,1) 入栈。位置 2、3 的 5 和 6 递增入栈。位置 4 高度 2 到来,先弹 6 得面积 6,再弹 5 得面积 5×2=10;高度 2 继承起点 2,成为 (2,2)。位置 5 高度 3 入栈。末尾哨兵依次结算 3、2、1,最大值保持 10。
Python 代码与坐标恢复
程序返回 (area,left,right,height),其中 right 为闭区间端点。面积为零时返回 (0,0,-1,0),表示没有正面积矩形。
python
def largest_rectangle(heights):
if any((not isinstance(h, int)) or h < 0 for h in heights):
raise ValueError("heights must be nonnegative integers")
stack = []
best = (0, 0, -1, 0)
for i in range(len(heights) + 1):
current = 0 if i == len(heights) else heights[i]
start = i
while stack and stack[-1][1] >= current:
left, height = stack.pop()
area = height * (i - left)
candidate = (area, left, i - 1, height)
if area > best[0] or (area == best[0] and candidate[1:] < best[1:]):
best = candidate
start = left
if current > 0:
stack.append((start, current))
return best
def brute(heights):
best = (0, 0, -1, 0)
for left in range(len(heights)):
minimum = heights[left]
for right in range(left, len(heights)):
minimum = min(minimum, heights[right])
candidate = (minimum * (right - left + 1), left, right, minimum)
if candidate[0] > best[0] or (candidate[0] == best[0]
and candidate[1:] < best[1:]):
best = candidate
return best
if __name__ == "__main__":
assert largest_rectangle([2, 1, 5, 6, 2, 3]) == (10, 2, 3, 5)
assert largest_rectangle([]) == (0, 0, -1, 0)
assert largest_rectangle([4, 4, 4]) == (12, 0, 2, 4)
assert largest_rectangle([0, 2, 0]) == (2, 1, 1, 2)
import random
random.seed(20260815)
for _ in range(1000):
values = [random.randrange(8) for _ in range(random.randrange(12))]
assert largest_rectangle(values) == brute(values), values
area, left, right, height = largest_rectangle([2, 1, 5, 6, 2, 3])
print(f"area={area}, range=[{left},{right}], height={height}")
print("random_cross_check=passed")
如果将直方图分析嵌入监控或数据处理原型,可把 haerapi.com 视为开发者需要自行评估的 API 调用选项;生产指标的保留周期、脱敏和故障降级不能由接入方式代替。
为什么等高柱也弹出
条件使用 >=,让新等高柱继承最早起点,栈中保持严格递增。若只在 > 时弹出,栈中可以保留多个等高项,最终面积仍可能正确,但坐标选择、栈大小和证明会更复杂。选择哪种写法都应配套定义不变量,不能一边声称严格递增,一边允许相等。
测试要求和暴力版连坐标都一致,因此增加同面积时的字典序规则。若业务只关心面积,完全可以只比较数值;但接口既然返回范围,就必须定义并列答案。这里优先更小左端点,再更小右端点和高度。
每根柱子为什么只处理常数次
每个正高度最多入栈一次、出栈一次。虽然某个位置的 while 可能连续弹出很多元素,这些元素以后不会再次入栈,因此所有弹栈次数总和不超过 n。时间复杂度 O(n),栈空间最坏 O(n),发生在高度严格递增时。
暴力对拍为每个左端点逐步维护最小高度,时间 O(n²),仅用于长度小于 12 的随机测试。它和单调栈实现思路不同,适合发现边界继承、哨兵和等高处理错误。
边界图像
空数组与全零数组面积为零。单柱面积就是自身高度。严格递增数组直到哨兵才结算;严格递减数组每步都弹一次。等高数组应合并出全宽矩形。零高度相当于天然分隔符,会清空之前所有正高度候选。负高度在直方图语义中无定义,程序直接拒绝。
若高度与数量很大,Python 整数不会溢出;移植到 Java 或 C++ 时,面积 height*width 应使用 64 位,并在输入上界可能超过 64 位时进一步检查。不能只把返回类型改成长整数,却让乘法先在 32 位完成后再转换。
最容易画错的三步
第一,弹栈时宽度写成 i-left+1,但位置 i 已经更矮,不属于该矩形,正确宽度是 i-left。第二,连续弹出后没有把新条目的 start 更新为最左弹出位置,导致更矮柱子失去向左延展范围。第三,忘记末尾哨兵,递增后缀永远不结算。
还有一种模板保存下标而不保存起点,通过弹栈后的新栈顶计算宽度,同样可行。两种表示不要混搭:保存 (start,height) 时,宽度直接由当前位置减起点;保存下标时,左边界来自弹栈后的栈顶加一。
可视化收束
单调栈并不是神秘地"找到最大面积",它只是延迟结算:柱子变矮时,栈顶高度的右边界刚刚确定;弹出项的起点又被更矮高度继承。把每个栈条目画成"尚未封口的矩形",入栈、弹栈和哨兵就能连成一条清晰的线性过程。
从左右边界数组再看一次
另一种实现会先为每根柱子求左侧第一个严格更矮位置与右侧第一个严格更矮位置,然后面积为 height×(right-left-1)。左右边界各可用一次单调栈计算,总时间仍为 O(n),但需要两个长度为 n 的数组。它更适合后续还要查询每根柱子的最大覆盖范围。
本文的单遍写法在右边界确定时立即结算,不保存全部边界,常数空间更小。两者的核心证明相同:更矮柱子是扩展停止条件。阅读不同模板时先识别它保存的是"未结算起点"还是"最近更矮下标",不要只比较循环长相。
为什么最优矩形一定会被结算
任取一个最优区间,选择其中最矮的某根柱子 p 作为高度。该矩形的左右边界不可能越过比它更矮的柱子,否则高度还会下降。算法在 p 对应高度最终弹出时,已经继承到左侧允许的最早起点,当前位置则是右侧第一个更矮处,因此结算范围至少覆盖这个最优区间,面积不会更小。
反过来,每次结算的矩形内所有柱子都不低于弹出高度,面积是真实可行的。算法既不会漏掉最优候选,也不会制造不可行面积,由此得到正确性。证明围绕弹出时机,比记住某个宽度公式更能帮助移植。
在线数据流能否立即给答案
柱子一根根到来时,可以维护同样的栈,并在变矮时结算已经封口的矩形。但当前最大值只是"已确定右边界"的候选;仍在栈中的递增柱子如果流此刻结束,还需要一次哨兵冲刷。若调用方要求每来一根就返回当前前缀的精确最大面积,必须临时考虑所有未封口项,朴素处理会增加成本。
因此批量数组与持续流接口应分开定义。流结束事件、窗口截断和状态持久化都会影响结果。不能把批处理函数包进循环就宣称支持无限流。
与二维最大全一矩形的连接
对二进制矩阵逐行扫描,把每列连续 1 的高度更新成直方图;每一行调用本算法,就能求最大全 1 矩形。若当前格为 0,高度清零;为 1,高度加一。矩阵有 rows×cols 个格,整体时间 O(rows×cols),额外空间 O(cols)。
这个扩展说明单调栈可以成为更大算法的一层组件。测试时除面积外,还要把直方图的上下坐标映射回矩阵:底边是当前行,顶边由高度决定。坐标恢复如果只在最后补算,很容易出现一行偏差。
性能与可观测性
大规模递增数组会让栈达到最大长度,适合测峰值内存;递减数组每步弹栈,适合测分支与循环;大量相等高度检查 >= 合并策略。随机数据只覆盖平均形态。可以统计入栈和出栈次数,断言各不超过 n,把摊还分析转成运行时可观察指标。
生产日志不应输出整条高度数组,尤其当它来自业务指标。记录长度、最大栈深、最终面积与异常计数就足以定位多数性能问题。若结果需要审计,再按权限保存命中的小范围,而不是默认复制全部输入。