这是 LeetCode 第 84 题,一道经典到不能再经典的栈应用题。今天带你从暴力解法一步步推导出最优解,理解算法优化的本质。
问题描述
给定 n 个非负整数,表示柱状图中各个柱子的高度。每个柱子彼此相邻,宽度为 1。求在这个柱状图中能够勾勒出来的矩形的最大面积。
输入: [2,1,5,6,2,3]
输出: 10
解释: 以高为5、宽为2的柱子构成的矩形面积最大
一、暴力解法:最直观的思路
核心思想
枚举每个柱子作为矩形的高,然后向左右两边扩展,找到能组成这个高度的最大宽度。
// 暴力解法 O(n²)
public int largestRectangleArea(int[] heights) {
int ans = 0;
int n = heights.length;
for (int i = 0; i < n; i++) {
int height = heights[i];
// 向左扩展:找第一个小于当前高度的柱子
int left = i - 1;
while (left >= 0 && heights[left] >= height) {
left--;
}
// 向右扩展:找第一个小于当前高度的柱子
int right = i + 1;
while (right < n && heights[right] >= height) {
right++;
}
// 计算宽度(注意:left和right指向的是不满足条件的边界)
int width = right - left - 1;
ans = Math.max(ans, height * width);
}
return ans;
}
有什么问题?
这个解法很直观,但时间复杂度是 O(n²)。当数据量达到 10⁴ 或 10⁵ 时,就会超时。
瓶颈在哪里?
观察发现:对于每个柱子,我们都在重复扫描左右区域。比如数组 [1,2,3,4,5],每个柱子向左扩展时都要遍历前面所有元素。大量的重复计算拖慢了速度。
二、优化思路:能不能不重复扫描?
关键洞察
每个柱子的最大矩形面积,取决于左右两侧第一个比它矮的柱子位置。这两个"边界"确定了矩形的宽度。
柱子i就像一块木板,左右两个矮柱子就像两个"保镖"
挡住了它继续扩展的路
如果我们能快速知道每个柱子的左右边界,问题就解决了。
新问题:如何快速找边界?
这就变成了"下一个更小元素"问题:
- 左侧边界:左边第一个比当前小的元素
- 右侧边界:右边第一个比当前小的元素
这类问题有个共性:需要维护一个有序结构来快速查询。自然而然地,我们想到了——栈。
三、单调栈:为解决这类问题而生
什么是单调栈?
单调栈是一种特殊的栈,栈内元素从底到顶保持单调递增或递减。
对于这个题目,我们需要一个递增栈(从底到顶高度递增),这样栈顶元素就是"最近的小值"。
算法核心流程
遍历每个柱子时:
- 若当前柱子 ≥ 栈顶:直接入栈,保持递增
- 若当前柱子 < 栈顶:说明找到了栈顶柱子的右边界,可以计算面积了
用例子 [2,1,5,6,2,3] 演示:
| i | 高度 | 操作前栈 | 操作过程 | 操作后栈 | 左边界 |
|---|---|---|---|---|---|
| 0 | 2 | [] | push 0 | [0] | -1 |
| 1 | 1 | [0] | pop 0 → push 1 | [1] | -1 |
| 2 | 5 | [1] | push 2 | [1,2] | 1 |
| 3 | 6 | [1,2] | push 3 | [1,2,3] | 2 |
| 4 | 2 | [1,2,3] | pop 3, pop 2 → push 4 | [1,4] | 1 |
| 5 | 3 | [1,4] | push 5 | [1,4,5] | 4 |
当弹出元素时,就是计算面积的时刻!
为什么这样就能找到边界?
- 左边界:栈中前一个元素(栈顶下方)
- 右边界:导致弹出的当前柱子(i)
因为栈是递增的,栈顶下面的元素一定比它小(或不存在),而当前柱子也比它小,所以这两个就是左右边界。
四、完整代码实现
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++) {
// 当前柱子小于栈顶,开始计算面积
while (!stack.isEmpty() && heights[stack.peek()] >= heights[i]) {
int height = heights[stack.pop()]; // 当前柱子的高度
int leftBound = stack.isEmpty() ? -1 : stack.peek(); // 左边界
int width = i - leftBound - 1; // 右边界是i
maxArea = Math.max(maxArea, height * width);
}
stack.push(i);
}
// 处理栈中剩余的柱子(右边界都是n)
while (!stack.isEmpty()) {
int height = heights[stack.pop()];
int leftBound = stack.isEmpty() ? -1 : stack.peek();
int width = n - leftBound - 1;
maxArea = Math.max(maxArea, height * width);
}
return maxArea;
}
}
代码要点
- 栈存的是下标,不是高度,方便计算宽度
>=保证相等高度也能正确处理- 最后要处理栈中剩余元素(它们的右边界是数组末尾)
五、复杂度分析
- 时间复杂度:O(n)
- 每个元素最多入栈一次、出栈一次
- 虽然有两个 while 循环,但总体是线性时间
- 空间复杂度:O(n)
- 最坏情况下栈需要存储所有元素
相比暴力解法的 O(n²),这简直是质的飞跃!
六、总结与延伸
学到了什么?
- 暴力是思考的起点:从 O(n²) 的朴素想法开始
- 识别重复计算:找到性能瓶颈所在
- 数据结构赋能:用栈消除重复扫描
- 模式抽象:将问题转化为"下一个更小元素"
单调栈还能做什么?
这类"下一个更大/更小元素"问题,都可以考虑单调栈:
- 每日温度(下一个更高温度)
- 接雨水(左右最大高度)
- 最大矩形(二维版本)
- 股票跨度(之前连续小于等于的天数)
一个思考题
如果把题目改成环形柱状图(首尾相连),该怎么做?
算法优化的本质,是用空间换时间,用巧妙的数据结构避免重复劳动。单调栈正是这种模式的最佳体现之一。
希望这篇文章能帮你真正理解单调栈的精髓。下次遇到"下一个更小元素"问题,你的脑海中应该会立刻浮现出那个从底到顶递增的神秘栈。
(注:本文示例代码均为 Java,但思路是通用的。C++/Python 实现思路完全相同)