Hot 100 --- 柱状图中最大的矩形
本文概览:本文讲解柱状图中最大的矩形的核心思路:用单调递增栈一次遍历找到每根柱子的左右边界,从 O(n²) 降到 O(n)
一、题目

二、题目分析
1. 题目要求
给定一个非负整数数组 heights,每个元素表示柱状图中该位置柱子的高度,求能勾勒出的最大矩形面积。
例子:heights = [2, 1, 5, 6, 2, 3]
1 | 柱状图示意(每列代表一根柱子,列高 = 高度值): |
最大矩形是中间的 [5, 6],高度 5、宽度 2,面积 = 5 × 2 = 10。
2. 怎么才算一个"矩形"?
随便挑一根柱子,比如挑索引 4(高度 2)的柱子来看:
1 | 0 1 2 3 4 5 ← 索引 |
以这根柱子的高度 2 作为矩形的高,那么这个矩形要往左右扩展,什么情况下这个高度不成立?
左边或右边出现了一根比它矮的柱子,这个高度就不成立了。
因为矩形必须连续,碰到更矮的柱子就撑不过去。
找左右边界:
1 | 0 1 2 3 4 5 ← 索引 |
左边界:索引 1(高度 1,是第一根比 2 矮的柱子)
右边界:没有比它更矮的,所以到最右边为止
3. 面积公式怎么算?
矩形能覆盖的柱子是 [5, 6, 2, 3],宽度肉眼可见是 4。但代码里怎么算?
技巧:右边界没有时,自己在数组末尾补一根高度为 0 的"虚拟柱子"。
1 | 0 1 2 3 4 5 ← 索引 |
现在右边界就是索引 6,左边界是索引 1,宽度公式:
1 | width = right - left - 1 |
公式记忆:左右边界都是"比当前柱子矮的柱子",所以宽度要把两根边界柱子都排除掉,即
right - left - 1。
4. 暴力思路的问题
最直觉的做法:遍历每根柱子,每次都向左找第一根比它矮的,向右找第一根比它矮的。
1 | for (int i = 0; i < n; i++) { |
时间复杂度:O(n²),每根柱子都要重复扫已经扫过的元素。
5. 核心观察:暴力遍历中的浪费
关键问题:暴力遍历里,找柱子 i 的左边界时,已经把 [0, i-1] 都扫了一遍;但找柱子 i+1 的左边界时,又要重新扫 [0, i],这些信息完全重复了。
优化思路:能不能只遍历一次,就按顺序找到每根柱子的左右边界?
关键数据结构:单调递增栈。
维护一个高度递增的栈(栈里存索引)
当一根新柱子比栈顶柱子矮时,栈顶柱子的右边界就找到了(就是这根新柱子)
栈顶柱子的左边界就是它在栈里的下一个元素(栈顶出栈后,新的栈顶就是左边界)
和暴力最大的区别:不是实时算每根柱子的面积,而是延时计算——一直憋着算栈顶,到最后一样能算完所有柱子。
三、思路概览
1 | public int largestRectangleArea(int[] heights) { |
思路简要说明:
- 哨兵数组:在原数组头尾各加一个 0,保证栈内元素最后全部能弹出
- 单调递增栈:栈内索引对应的高度从底到顶递增
- 左边界:栈顶出栈后,新的栈顶就是它的左边界
- 右边界:触发栈顶出栈的当前柱子就是右边界
- 面积公式:
height * (right - left - 1) - 延时计算:不是实时算每根柱子,而是憋住算栈顶,最后一定能算完全部
四、思路详解
第一步:左右边界到底怎么确定?
回到核心定义:以柱子 i 的高度为矩形的高时,矩形能向左右延伸到哪?
1 | 0 1 2 3 4 5 ← 索引 |
左边界:从柱子 i 往左看,第一根比
heights[i]矮的柱子右边界:从柱子 i 往右看,第一根比
heights[i]矮的柱子
矩形的高是 heights[i],宽度是中间这段(不含边界),所以:
1 | width = right - left - 1 |
第二步:为什么是单调递增栈?
问题:怎么一次遍历就把每根柱子的左右边界都找出来?
思考:如果遍历过程中,已经处理过的柱子的高度是递增的,那么对于一根新柱子:
如果新柱子比栈顶高 → 直接入栈,栈顶的右边界还没到
如果新柱子比栈顶矮 → 栈顶柱子的右边界就是这根新柱子!
那左边界呢?
栈顶柱子出栈后,新的栈顶就是它在栈里的下一个元素,这个元素的高度比它矮(因为递增),所以就是它的左边界。
1 | 栈内(高度递增,左边是栈底,右边是栈顶): |
单调递增栈的本质:栈内每根柱子的左边界,就是它在栈里的前一根柱子;右边界,由后面第一个比它矮的柱子触发。
第三步:为什么要"延时"计算?
暴力思路:遍历到柱子 i 时,立刻算它的面积 → 需要重新扫左右边界 → O(n²)
单调栈思路:遍历到柱子 i 时,不一定是算柱子 i 的面积,而是算栈顶柱子的面积。
为什么这样也能算完所有柱子?
每根柱子入栈一次
每根柱子出栈一次(被某个更矮的柱子触发)
出栈的那一刻,它的左右边界都已确定,立即算面积
所以遍历结束时,所有柱子都必然被算过一次。
第四步:为什么要加哨兵?
问题:如果数组本身就是递增的,比如 [1, 2, 3, 4, 5],遍历到最后一根时,栈里所有元素都没机会出栈(因为没有更矮的柱子触发它们)。
解决:在数组末尾加一个 0,0 比任何柱子都矮,必然能把栈里所有元素全部弹出。
开头也要加 0:保证第一根柱子也有左边界(栈底的 0 就是它的左边界),同时避免栈空判断。
1 | 0 2 1 5 6 2 3 0 ← newHeight(头尾各补一个 0) |
哨兵的双重作用:
- 尾哨兵:保证遍历结束时栈内所有元素都能弹出,确保每根柱子都被算到
- 头哨兵:作为第一根柱子的左边界,避免
stack.peekLast()在栈只有一个元素时出错
第五步:为什么是 O(n)?
看起来有 while 嵌套在 for 里,应该是 O(n²)?
实际上:每个索引最多入栈一次,出栈一次。
外层 for 循环:n + 2 次
内层 while 循环:所有索引总共出栈 n + 2 次
所以总操作次数是 2(n+2),时间复杂度 **O(n)**。
完整执行过程
以 heights = [2, 1, 5, 6, 2, 3] 为例,加哨兵后 newHeight = [0, 2, 1, 5, 6, 2, 3, 0](长度 8):
1 | 初始:stack = [], maxArea = 0 |
关键观察:
最大面积 10 在
i=5时算出,对应中间的柱子[5, 6](高度 5,宽度 2)尾哨兵 0 在
i=7时把栈内剩余柱子全部弹出,保证不漏算最后栈里剩
[0, 7](两个哨兵),不影响结果
与"接雨水"的对比
| 维度 | 接雨水 | 柱状图中最大的矩形 |
|---|---|---|
| 关注点 | 凹槽能存多少水 | 单根柱子能撑多大矩形 |
| 单调栈方向 | 递减栈(找下一个更高) | 递增栈(找下一个更矮) |
| 触发条件 | 新柱子比栈顶高 | 新柱子比栈顶矮 |
| 边界定义 | 左右更高柱子围成凹槽 | 左右更矮柱子围成矩形 |
五、总结
柱状图中最大的矩形的核心思路:
- 矩形定义:以柱子 i 的高度为高,左右边界是第一根比它矮的柱子
- 面积公式:
height * (right - left - 1) - 单调递增栈:栈顶出栈后,新栈顶是左边界;触发出栈的柱子是右边界
- 延时计算:遍历到新柱子时算的是栈顶柱子的面积,不是新柱子的
- 哨兵技巧:头尾加 0,保证所有柱子都能出栈被算到
- **时间复杂度 O(n)**:每个索引最多入栈一次,出栈一次
- **空间复杂度 O(n)**:栈最多存储 n+2 个索引
单调栈的适用场景:
找"下一个更大/更小的元素"
需要维护一个单调序列
暴力遍历中有重复的边界查找

