柱状图中最大的矩形:单调栈消除「悬崖」的工程思维
柱状图中最大的矩形:单调栈消除「悬崖」的工程思维
适读人群:Java 开发者、冲击大厂算法面试的工程师 | 阅读时长:约22分钟 | 难度:Hard
开篇故事
我在某电商公司做了三年基础架构,有一段时间在做报表系统的可视化模块。有个需求:在柱状图上自动高亮"面积最大的矩形区域",用来做业绩达标率的可视化展示。当时的前端实现是暴力枚举,数据点多了之后渲染卡得要死,帧率掉到个位数。
我翻了一下代码,一看就是 O(n²) 甚至 O(n³) 的暴力。我说这个有 O(n) 的解法,用单调栈。前端同学表示没听说过,我花了一个下午把算法讲清楚、改了代码,页面渲染从卡死变成了流畅。
后来这段经历成了我面试候选人时会提到的一个案例——算法不只是刷题,它在工程里真的有用。
LeetCode 84 柱状图中最大的矩形,是我认为单调栈最典型的应用之一。接雨水(LeetCode 42)是找凹槽,这道题是找凸起,两者是镜像问题,用的却是同一个工具。
一、题目解析
LeetCode 84. 柱状图中最大的矩形(Largest Rectangle in Histogram)
给定 n 个非负整数,用来表示柱状图中各个柱子的高度。每个柱子彼此相邻,且宽度为 1。求在该柱状图中,能够勾勒出来的矩形的最大面积。
输入输出示例:
示例 1(经典情况):
输入:heights = [2,1,5,6,2,3]
输出:10
解释:最大矩形是由下标 2,3 两根柱子组成的高度为 5 的矩形,面积 = 5×2 = 10示例 2(只有一根柱子):
输入:heights = [2,4]
输出:4边界情况 3(单调递增):
输入:heights = [1,2,3,4,5]
输出:9
解释:最大矩形是 [3,4,5] 三根柱子,高度 3,面积 = 3×3 = 9边界情况 4(全相同高度):
输入:heights = [3,3,3,3]
输出:12边界情况 5(单根柱子很高):
输入:heights = [1,1,1,1,100]
输出:5
解释:全部 5 根柱子高度 1 的矩形面积 5,单独高度 100 的矩形面积也是 100,但 5 < 100考察的核心知识点:
- 枚举每根柱子作为矩形最矮柱
- 找每根柱子左右第一个比它矮的位置
- 单调栈高效解决"左/右第一个更小元素"问题
二、解法一:暴力解法
思路分析
关键洞察:最大矩形的高度必然等于某根柱子的高度(因为矩形高度是所有柱子中最矮的,所以它等于其中某根柱子的高度)。因此枚举每根柱子作为矩形的高度,找以该高度为基础能延伸的最大宽度。
对于柱子 i(高度 h = heights[i] ),矩形可以向左延伸直到遇到高度小于 h 的柱子,向右也一样。宽度 = 左边界到右边界的距离,面积 = h × 宽度。
暴力:对每根柱子,向左向右各扫描,找到边界。
class Solution {
public int largestRectangleArea(int[] heights) {
int n = heights.length;
int maxArea = 0;
for (int i = 0; i < n; i++) {
int h = heights[i];
// 向左找第一个高度 < h 的位置
int left = i;
while (left > 0 && heights[left - 1] >= h) {
left--;
}
// 向右找第一个高度 < h 的位置
int right = i;
while (right < n - 1 && heights[right + 1] >= h) {
right++;
}
int width = right - left + 1;
maxArea = Math.max(maxArea, h * width);
}
return maxArea;
}
}时间复杂度:O(n²),每根柱子最坏需要向两侧各扫描 O(n)。
空间复杂度:O(1)。
三、解法二:预计算左右边界数组
优化思路
暴力对每根柱子都重新扫描,有大量重复。可以分别预计算每根柱子的"左边界"和"右边界",再一次性计算所有面积。
用单调栈来计算每根柱子左边第一个比它矮的位置(Left Smaller)和右边第一个比它矮的位置(Right Smaller)。这是单调栈的经典应用之一。
class Solution {
public int largestRectangleArea(int[] heights) {
int n = heights.length;
int[] leftSmaller = new int[n]; // 左边第一个更矮柱子的下标
int[] rightSmaller = new int[n]; // 右边第一个更矮柱子的下标
// 用单调栈计算 leftSmaller
Deque<Integer> stack = new ArrayDeque<>();
for (int i = 0; i < n; i++) {
while (!stack.isEmpty() && heights[stack.peek()] >= heights[i]) {
stack.pop();
}
leftSmaller[i] = stack.isEmpty() ? -1 : stack.peek();
stack.push(i);
}
// 用单调栈计算 rightSmaller
stack.clear();
for (int i = n - 1; i >= 0; i--) {
while (!stack.isEmpty() && heights[stack.peek()] >= heights[i]) {
stack.pop();
}
rightSmaller[i] = stack.isEmpty() ? n : stack.peek();
stack.push(i);
}
// 计算每根柱子为最矮柱时的最大面积
int maxArea = 0;
for (int i = 0; i < n; i++) {
int width = rightSmaller[i] - leftSmaller[i] - 1;
maxArea = Math.max(maxArea, heights[i] * width);
}
return maxArea;
}
}时间复杂度:O(n),每个元素入栈出栈各一次。
空间复杂度:O(n),两个边界数组加栈。
四、解法三:单调栈一次遍历(工业级最优解)
核心洞见
上面两个栈的预计算版本虽然正确,但需要两次遍历。能否一次遍历就完成所有计算?
核心思路:维护一个单调递增栈(栈中元素高度从栈底到栈顶递增)。当遇到一个高度比栈顶矮的柱子时,说明找到了栈顶柱子的右边界。此时弹出栈顶,计算以栈顶为高度的矩形面积:
- 左边界:弹出后的新栈顶(因为单调栈保证了新栈顶是左边第一个更矮的)
- 右边界:当前遍历到的位置 i
- 宽度 = 右边界 - 左边界 - 1
为了处理"右边没有更矮柱子"的情况(单调递增序列末尾),在数组末尾添加一个高度为 0 的哨兵,强制弹出所有剩余元素。同样,在数组开头添加高度为 0 的哨兵,避免栈空时的边界判断。
完整 Java 代码
import java.util.ArrayDeque;
import java.util.Deque;
class Solution {
public int largestRectangleArea(int[] heights) {
int n = heights.length;
// 添加哨兵:头部和尾部各加一个高度为 0 的虚拟柱子
// 头部哨兵:保证栈永远不为空,避免空栈判断
// 尾部哨兵:强制把所有剩余元素从栈中弹出并计算
int[] h = new int[n + 2];
h[0] = 0; // 头部哨兵
h[n + 1] = 0; // 尾部哨兵
for (int i = 0; i < n; i++) h[i + 1] = heights[i];
// 单调递增栈,存下标(对应增加哨兵后的新数组)
Deque<Integer> stack = new ArrayDeque<>();
int maxArea = 0;
for (int i = 0; i < h.length; i++) {
// 遇到比栈顶矮的柱子,弹出并计算面积
while (!stack.isEmpty() && h[i] < h[stack.peek()]) {
int top = stack.pop(); // 被弹出的柱子(矩形高度)
int leftBound = stack.peek(); // 左边界(弹出后的新栈顶)
int width = i - leftBound - 1; // 矩形宽度
maxArea = Math.max(maxArea, h[top] * width);
}
stack.push(i);
}
return maxArea;
}
}复杂度分析
时间复杂度:O(n)
每个元素(包括哨兵)最多入栈一次、出栈一次,总操作次数 = 2(n+2),即 O(n)。
空间复杂度:O(n)
辅助数组 h 是 O(n),栈最坏情况下存所有元素(单调递增序列),也是 O(n)。
我在 LeetCode 上提交这个方案,运行时间 9ms,击败 96.1% 的 Java 用户。
不使用哨兵的版本(更通用但代码稍复杂):
class Solution {
public int largestRectangleArea(int[] heights) {
int n = heights.length;
Deque<Integer> stack = new ArrayDeque<>();
int maxArea = 0;
for (int i = 0; i <= n; i++) {
// i == n 时用虚拟高度 0 作为哨兵触发最终清栈
int curHeight = (i == n) ? 0 : heights[i];
while (!stack.isEmpty() && heights[stack.peek()] > curHeight) {
int h = heights[stack.pop()];
// 左边界:栈空时是 -1(表示左边没有更小的,宽度从 0 开始)
int leftBound = stack.isEmpty() ? -1 : stack.peek();
int width = i - leftBound - 1;
maxArea = Math.max(maxArea, h * width);
}
stack.push(i);
}
return maxArea;
}
}五、举一反三
同类型题目
LeetCode 42. 接雨水
和本题是"镜像"问题。接雨水找凹槽(低点),柱状图最大矩形找凸起(高点)。都用单调栈,接雨水用递减栈,本题用递增栈。
LeetCode 85. 最大矩形
在 0/1 矩阵中找最大全 1 矩形。逐行处理,把每一行当作柱状图的"柱高",每行调用本题的解法。是本题的二维扩展。
LeetCode 739. 每日温度
找每个元素右边第一个比它大的元素的距离。单调递减栈的直接应用,比本题简单,适合作为单调栈入门题。
LeetCode 496. 下一个更大元素 I
找每个元素右边第一个比它大的元素。单调栈+哈希表。
单调栈解题框架
单调递增栈(从栈底到栈顶值递增):
- 用来找"左/右第一个更小"的元素
- 当栈顶元素 > 当前元素时,栈顶找到了右边第一个更小元素
单调递减栈(从栈底到栈顶值递减):
- 用来找"左/右第一个更大"的元素
- 当栈顶元素 < 当前元素时,栈顶找到了右边第一个更大元素
面试中的变体和追问
为什么要在末尾加哨兵 0? 答:不加的话,遍历完整个数组后,栈里可能还有元素没被处理。哨兵 0 比任何柱子都矮,能强制触发所有剩余元素的弹出和面积计算。
哨兵有没有副作用? 答:没有。哨兵的高度是 0,它本身不会产生任何面积(h=0,面积=0),只起到触发器的作用。
六、踩坑实录
坑一:搞混单调递增栈和单调递减栈
现象: 找"左边第一个更小",结果用了递减栈,答案全错。
原因: 单调递增栈(栈顶最小)→ 弹出时找"右边第一个更小";单调递减栈(栈顶最大)→ 弹出时找"右边第一个更大"。记混了。
解法: 记住一个口诀:栈顶被弹出时,弹出它的就是它要找的"另一侧的那个"。如果维护递增栈,遇到更小的才弹,弹出的栈顶找的就是"右边第一个更小"。反过来类推。
坑二:面积计算中宽度的计算公式
现象: 面积偏小或偏大。
原因: 弹出栈顶 top 后,左边界是新栈顶 leftBound,右边界是当前 i。矩形的范围是 (leftBound, i) 开区间,宽度 = i - leftBound - 1,不是 i - leftBound。这个 -1 容易忘。
解法: 画图验证:leftBound=1, top=2, i=4,矩形覆盖下标 [2,3],宽度 = 2,计算:4 - 1 - 1 = 2,正确。
坑三:相等高度的柱子处理
现象: 输入 [2,2,2,2],期望输出 8,但代码输出 6。
原因: 单调栈中对"相等高度"的处理方式不统一。如果栈中 >= 当前高度就弹出,相等的柱子会在中间提前弹出,导致宽度计算偏窄。
解法: 本题可以用 > 或 >= 都能得到正确答案(因为最终哨兵会把所有元素清掉),但要保持一致。我习惯用 >(严格大于才弹出),这样相等高度的柱子会堆在栈里,最终由哨兵一起清算,计算的宽度更宽,覆盖了所有相等柱子的范围。
七、总结
柱状图最大矩形这道题,思维层次是:暴力枚举 → 预计算左右边界 → 单次扫描单调栈。每一步都有其合理性,理解了为什么这么做,比记住代码更重要。
三条核心要点:
枚举矩形高度(每根柱子的高度)而不是枚举左右边界,这是降低复杂度的关键抽象。确定了高度,再找能扩展的最大宽度,问题就变成了"左右第一个更小元素"。
单调栈天然解决"左/右第一个更大/更小元素"问题,O(n) 时间完成整个数组的这类查询。
哨兵技巧(在数组头尾加虚拟元素)可以简化边界判断,减少代码中的 if 检查,是单调栈编码的常见工程技巧。
延伸思考:
接雨水和柱状图最大矩形,两道 Hard 题,一个用递减栈,一个用递增栈,共同构成了单调栈应用的完整画面。面试前把这两道题都练熟,遇到单调栈类题目就不会慌了。
