一、题目描述
给定 n 个非负整数,用来表示柱状图中各个柱子的高度。每个柱子彼此相邻,且宽度为 1。
求在该柱状图中,能够勾勒出来的矩形的最大面积。
示例:
| 示例 | 输入 | 输出 |
|---|---|---|
| 示例1 | heights = 2,1,5,6,2,3 | 10 |
| 示例2 | heights = 2,4 | 4 |
图示(示例1):
最大矩形面积 = 2 * 5 = 10(高度为2,宽度从索引0到5)
提示:
- 1 <= heights.length <= 10^5
- 0 <= heightsi <= 10^4
二、解题思路总览
拿到这道题,首先思考:最大矩形可能出现在哪里?
对于每个柱子 i,它作为最高矩形的高度时:
- 向左找连续的、高度 >= heightsi 的柱子
- 向右找连续的、高度 >= heightsi 的柱子
- 宽度 = right - left - 1
- 面积 = heightsi * width
核心难点: 如何快速找到每个柱子"能向左/向右扩展多远"?
暴力解法 O(n^2):
- 对于每个柱子,向左向右遍历找边界
- 10^5 数据会超时
关键洞察: 利用单调递增栈,栈中存储索引,栈顶元素的下方是"左边界",弹栈时自然找到"右边界"。
核心思路:
| 步骤 | 思路 | 说明 |
|---|---|---|
| 1 | 末尾加哨兵 | heights.push_back(-1),处理最后边界 |
| 2 | 维护单调递增栈 | 栈中索引对应的高度单调递增 |
| 3 | 弹栈计算面积 | 弹出的柱子 i,其右边界是当前索引,左边界是栈顶 |
| 4 | 更新最大面积 | ans = max(ans, heightsi * width) |
单调栈的核心思想:
- 栈顶下方是左边界(第一个比栈顶矮的柱子)
- 当前遍历位置是右边界(第一个比栈顶矮的柱子)
- 弹栈时,右边界已知,左边界是新的栈顶
三、完整代码(方法一:单调递增栈)
cpp
class Solution {
public:
int largestRectangleArea(vector<int>& heights) {
heights.push_back(-1); // 末尾加哨兵,处理最后边界
stack<int> st;
st.push(-1); // 栈底哨兵,便于处理边界
int ans = 0;
for (int right = 0; right < heights.size(); right++) {
int h = heights[right];
// 当栈顶高度 >= 当前高度时,弹栈计算面积
while (st.size() > 1 && heights[st.top()] >= h) {
int i = st.top(); // 弹出柱子的索引
st.pop();
int left = st.top(); // 左边界索引
// 宽度 = right - left - 1
ans = max(ans, heights[i] * (right - left - 1));
}
st.push(right);
}
return ans;
}
};
四、其他解法
方法二:分治法(递归分割)
cpp
class Solution {
public:
int largestRectangleArea(vector<int>& heights) {
return divide(heights, 0, heights.size() - 1);
}
private:
int divide(const vector<int>& heights, int left, int right) {
if (left > right) return 0;
if (left == right) return heights[left];
int mid = (left + right) / 2;
int minIdx = mid;
// 找最小高度的位置
for (int i = mid; i <= right; i++) {
if (heights[i] < heights[minIdx]) minIdx = i;
}
// 以最小高度为高的矩形
int area = heights[minIdx] * (right - left + 1);
// 分治左右两部分
return max({area,
divide(heights, left, minIdx - 1),
divide(heights, minIdx + 1, right)});
}
};
问题: 最坏 O(n^2),期望 O(n log n),但实现复杂,不推荐。
方法三:线段树 + 分治(进阶)
cpp
class Solution {
public:
int largestRectangleArea(vector<int>& heights) {
// 构建线段树找最小值位置,然后分治
// 实现较复杂,面试中较少考察
return 0;
}
};
问题: 实现太复杂,不推荐。
方法四:暴力解法(O(n^2),仅作对比)
cpp
class Solution {
public:
int largestRectangleArea(vector<int>& heights) {
int n = heights.size();
int ans = 0;
for (int i = 0; i < n; i++) {
int h = heights[i];
// 向左扩展
int left = i;
while (left > 0 && heights[left - 1] >= h) left--;
// 向右扩展
int right = i;
while (right < n - 1 && heights[right + 1] >= h) right++;
ans = max(ans, h * (right - left + 1));
}
return ans;
}
};
问题: 两层循环,最坏 O(n^2),不可用于生产。
五、算法流程图
以输入 heights = [2,1,5,6,2,3] 为例,逐步展示算法执行过程:
初始状态:
heights: [2, 1, 5, 6, 2, 3, -1]
indices: 0 1 2 3 4 5 6
ans: 0
st: [-1] (栈底哨兵)
Step 1: right=0, h=2
- heights[st.top()](2) >= 2? 是,但 st.size()=1,只入栈
st.push(0)
heights: [2, 1, 5, 6, 2, 3, -1]
^
st: [-1, 0] (高度: -, 2)
Step 2: right=1, h=1
- heights[st.top()](2) >= 1? 是,弹出 i=0
left = st.top() = -1
area = 2 * (1 - (-1) - 1) = 2 * 1 = 2
ans = max(0, 2) = 2
- heights[st.top()](-1) >= 1? 否,停止弹栈
st.push(1)
heights: [2, 1, 5, 6, 2, 3, -1]
^
st: [-1, 1] (高度: -, 1)
Step 3: right=2, h=5
- heights[st.top()](1) >= 5? 否,只入栈
st.push(2)
heights: [2, 1, 5, 6, 2, 3, -1]
^
st: [-1, 1, 2] (高度: -, 1, 5)
Step 4: right=3, h=6
- heights[st.top()](5) >= 6? 否,只入栈
st.push(3)
heights: [2, 1, 5, 6, 2, 3, -1]
^
st: [-1, 1, 2, 3] (高度: -, 1, 5, 6)
Step 5: right=4, h=2
- heights[st.top()](6) >= 2? 是,弹出 i=3
left = st.top() = 2
area = 6 * (4 - 2 - 1) = 6 * 1 = 6
ans = max(2, 6) = 6
- heights[st.top()](5) >= 2? 是,弹出 i=2
left = st.top() = 1
area = 5 * (4 - 1 - 1) = 5 * 2 = 10
ans = max(6, 10) = 10
- heights[st.top()](1) >= 2? 否,停止弹栈
st.push(4)
heights: [2, 1, 5, 6, 2, 3, -1]
^
st: [-1, 1, 4] (高度: -, 1, 2)
Step 6: right=5, h=3
- heights[st.top()](2) >= 3? 否,只入栈
st.push(5)
heights: [2, 1, 5, 6, 2, 3, -1]
^
st: [-1, 1, 4, 5] (高度: -, 1, 2, 3)
Step 7: right=6, h=-1 (哨兵)
- heights[st.top()](3) >= -1? 是,弹出 i=5
left = st.top() = 4
area = 3 * (6 - 4 - 1) = 3 * 1 = 3
ans = max(10, 3) = 10
- heights[st.top()](2) >= -1? 是,弹出 i=4
left = st.top() = 1
area = 2 * (6 - 1 - 1) = 2 * 4 = 8
ans = max(10, 8) = 10
- heights[st.top()](1) >= -1? 是,弹出 i=1
left = st.top() = -1
area = 1 * (6 - (-1) - 1) = 1 * 6 = 6
ans = max(10, 6) = 10
st.push(6)
heights: [2, 1, 5, 6, 2, 3, -1]
^
st: [-1, 6] (高度: -, -1)
最终结果:
ans = 10 ✓
**最大矩形:**高度为 5,宽度为 2(索引2到3),面积 = 5×2 = 10
六、逐行解析(方法一)
cpp
heights.push_back(-1);
哨兵技巧: 在末尾加一个高度为 -1 的柱子,这样遍历到最后时,所有剩余栈中的柱子都会被弹出并计算面积,避免额外处理边界。
cpp
stack<int> st;
st.push(-1);
栈初始化: 栈底放入 -1 作为哨兵,这样当栈中只有一个哨兵时,st.size() > 1 的判断可以统一处理边界。
cpp
for (int right = 0; right < heights.size(); right++) {
遍历: right 是当前柱子的索引,也是弹出柱子的"右边界"。
cpp
while (st.size() > 1 && heights[st.top()] >= h) {
弹栈条件: 当栈顶高度 >= 当前高度时,说明栈顶柱子的"右边界"找到了。弹栈计算以栈顶柱子为高的矩形面积。
cpp
int i = st.top();
st.pop();
int left = st.top();
ans = max(ans, heights[i] * (right - left - 1));
计算面积: i 是弹出的柱子索引(它的高度是矩形的高),left 是新的栈顶索引(第一个比 i 矮的左边界),right 是当前遍历位置(第一个比 i 矮的右边界),所以宽度 = right - left - 1。
七、复杂度分析
各方法复杂度对比
| 方法 | 时间复杂度 | 空间复杂度 | 说明 |
|---|---|---|---|
| 方法一:单调递增栈 | O(n) | O(n) | 标准解法,稳定高效 |
| 方法二:分治法 | O(n log n)(期望) | O(log n) | 最坏 O(n^2),不稳定 |
| 方法三:线段树+分治 | O(n log n) | O(n) | 实现复杂 |
| 方法四:暴力解法 | O(n^2) | O(1) | 不可用 |
核心: 方法一是 O(n),因为每个柱子最多入栈一次、出栈一次。
方法一详细复杂度分析
时间复杂度:O(n)
- 遍历一次:O(n)
- 每个柱子最多入栈一次、出栈一次
- 总弹栈次数 = O(n)
空间复杂度:O(n)
- 最坏情况:heights 单调递增,所有柱子都入栈
- 加上哨兵占位符
弹栈分析:
| 情况 | 弹栈次数 | 说明 |
|---|---|---|
| 单调递增 | O(n) | 每次都弹出前一个 |
| 单调递减 | 0 | 一次都不弹 |
| 随机 | 均摊 O(1) | 总弹栈次数 = O(n) |
八、面试追问 FAQ
| 问题 | 回答要点 |
|---|---|
| Q1: 为什么要在末尾加哨兵 -1? | 避免最后单独处理边界。如果没有哨兵,遍历结束后栈中还有柱子,需要额外弹出计算。哨兵是最常用的技巧。 |
| Q2: 栈底哨兵 -1 有什么用? | 统一处理边界。当弹栈后栈变空时,栈顶是 -1,可以用 right - (-1) - 1 = right 正确计算宽度。 |
Q3: 为什么条件是 heights[st.top()] >= h 而不是 > h? |
>= 保证栈中相同高度只保留最右边的一个,避免重复计算。如果用 >,会漏掉一些边界情况。 |
| Q4: 单调栈能解决哪些其他问题? | 每日温度、接雨水、最大矩形、下一个更大元素等。单调栈是处理"边界"问题的通用技巧。 |
| Q5: 如果要返回最大矩形的起始和结束位置怎么办? | 在弹栈时记录 left 和 right 的位置即可,start = left + 1,end = right - 1。 |
| Q6: 面试时推荐哪种方法? | 首选方法一(单调递增栈),代码简洁高效,面试高频题。 |
九、相关题目
| 题号 | 题目 | 难度 | 关联点 |
|---|---|---|---|
| 84 | 柱状图中最大的矩形 | 困难 | 本题 |
| 85 | 最大矩形 | 困难 | 二维扩展 |
| 739 | 每日温度 | 中等 | 单调栈入门 |
| 42 | 接雨水 | 困难 | 单调栈综合应用 |
| 496 | 下一个更大元素 I | 简单 | 单调栈入门 |
| 503 | 下一个更大元素 II | 中等 | 环形数组 |
推荐刷题顺序:
- 739(每日温度)→ 单调栈入门
- 496(下一个更大元素 I)→ 简单变种
- **本题(84.柱状图中最大的矩形)**→ 单调栈进阶
- 42(接雨水)→ 综合应用
- 85(最大矩形)→ 二维扩展
十、总结
| 维度 | 内容 |
|------|------|------|
| 考察知识点 | 单调栈、边界计算 |
| 难度 | 困难 |
| 核心思维 | 栈顶是当前高矩形,右边界已知,左边界是栈顶下方 |
| 关键技巧 | 末尾哨兵、栈底哨兵、弹栈计算面积 |
| 推荐解法 | 方法一(单调递增栈) |
| 变形题 | 最大矩形(二维)、接雨水 |
一句话总结: 柱状图最大矩形的核心是单调递增栈:弹栈时栈顶柱子的"右边界"是当前遍历位置,"左边界"是新的栈顶,宽度 = right - left - 1。哨兵技巧是简化代码的关键。