Files
interview/16-LeetCode Hot 100/柱状图中最大的矩形.md
yasinshaw 67189941d8 docs: 改进 LeetCode 题目解题思路详细程度
改进以下三个题目的文档:
1. 最小栈 (LeetCode 155)
2. 最大正方形 (LeetCode 221)
3. 柱状图中最大的矩形 (LeetCode 84)

改进内容:
- 新增"思路推导"部分:从暴力解法分析开始,逐步优化
- 详细化"解题思路"部分:分步骤说明,增加 Q&A 问答
- 新增"关键细节说明":解释为什么这样写代码
- 新增"边界条件分析":覆盖各种特殊情况
- 新增"执行过程演示":完整示例跟踪
- 新增"常见错误":对比错误和正确写法
- 新增"进阶问题":扩展思路

参考文档:算法解题思路改进方案.md

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-08 21:32:25 +08:00

896 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 柱状图中最大的矩形 (Largest Rectangle in Histogram)
LeetCode 84. Hard
## 题目描述
给定 n 个非负整数,用来表示柱状图中各个柱子的高度。每个柱子彼此相邻,且宽度为 1。
求在该柱状图中,能够勾勒出来的矩形的最大面积。
**示例 1**
```
输入heights = [2,1,5,6,2,3]
输出10
解释:最大的矩形面积为 10由高度为 5 和 6 的两个柱子组成)
```
**示例 2**
```
输入heights = [2,4]
输出4
```
## 思路推导
### 暴力解法分析
**最直观的思路**:枚举所有可能的矩形
```go
func largestRectangleArea(heights []int) int {
n := len(heights)
maxArea := 0
// 枚举每个柱子作为矩形的高度
for i := 0; i < n; i++ {
height := heights[i]
// 向左右扩展,找到能形成的最大宽度
left := i
for left > 0 && heights[left-1] >= height {
left--
}
right := i
for right < n-1 && heights[right+1] >= height {
right++
}
width := right - left + 1
area := height * width
maxArea = max(maxArea, area)
}
return maxArea
}
```
**时间复杂度分析**
```
- 外层循环O(n) 枚举每个柱子
- 向左扩展O(n) 最坏情况遍历所有柱子
- 向右扩展O(n) 最坏情况遍历所有柱子
- 总计O(n) × O(n) = O(n²)
```
**问题**
- 对于严格递增的数组 [1,2,3,4,5],每次都要扩展到边界
- 时间复杂度 O(n²),大型数组会超时
### 优化思考
**观察**:暴力解法中,很多比较是重复的。
**关键问题**:能否避免重复比较,快速找到每个柱子的边界?
**思路1预处理 - 预先计算左右边界**
```go
// left[i]:柱子 i 向左能扩展到的最远位置
// right[i]:柱子 i 向右能扩展到的最远位置
func largestRectangleArea(heights []int) int {
n := len(heights)
left := make([]int, n)
right := make([]int, n)
for i := 0; i < n; i++ {
left[i] = i
for left[i] > 0 && heights[left[i]-1] >= heights[i] {
left[i]--
}
}
for i := n - 1; i >= 0; i-- {
right[i] = i
for right[i] < n-1 && heights[right[i]+1] >= heights[i] {
right[i]++
}
}
maxArea := 0
for i := 0; i < n; i++ {
area := heights[i] * (right[i] - left[i] + 1)
maxArea = max(maxArea, area)
}
return maxArea
}
```
**问题**:仍然是 O(n²),没有本质优化
**思路2单调栈优化**
核心洞察:**利用单调递增栈,可以在 O(1) 时间内确定每个柱子的右边界**
### 为什么这样思考?
**关键观察**
```
对于柱子 i它的右边界是
第一个高度小于 heights[i] 的柱子的位置 - 1
例如:[2, 1, 5, 6, 2, 3]
柱子 2高度 6
右边界是柱子 4高度 2的位置 - 1 = 3
因为 heights[4] = 2 < 6
```
**单调栈的作用**
- **维护一个递增的序列**:栈中柱子的高度严格递增
- **快速找到边界**:当遇到比栈顶小的柱子时,就找到了栈顶元素的右边界
- **避免重复比较**:每个柱子最多入栈一次、出栈一次
**为什么是单调递增?**
- 递增保证了:栈顶元素的右边界就是当前遇到的第一个更小的柱子
- 如果栈中元素不递增,就无法保证这一点
## 解题思路
### 核心思想
**单调递增栈**
- 栈中存储柱子的**索引**(不是高度)
- 栈中对应的柱子高度**严格递增**
- 当遇到比栈顶小的柱子时,弹出栈顶并计算面积
### 算法流程(详细版)
**步骤1初始化栈和遍历**
```go
stack := []int{} // 单调递增栈,存储索引
maxArea := 0
n := len(heights)
for i := 0; i <= n; i++ { // 注意:遍历到 n包含虚拟柱子
h := 0
if i < n {
h = heights[i]
}
// ...
}
```
**关键点**
- 为什么遍历到 n而不是 n-1
- 需要一个"虚拟柱子"高度为 0
- 强制弹出栈中所有剩余元素并计算面积
- 否则栈中元素会遗漏计算
**步骤2维护单调递增栈**
```go
for len(stack) > 0 && h < heights[stack[len(stack)-1]] {
// 栈顶元素的高度比当前柱子大
// 找到了栈顶元素的右边界(当前柱子 i
height := heights[stack[len(stack)-1]]
stack = stack[:len(stack)-1] // 弹出栈顶
// 计算宽度
width := i
if len(stack) > 0 {
width = i - stack[len(stack)-1] - 1
}
area := height * width
maxArea = max(maxArea, area)
}
stack = append(stack, i) // 当前柱子入栈
```
**关键点解释**
**Q1为什么条件是 `h < heights[stack[len(stack)-1]]`**
```
当遇到比栈顶小的柱子时:
- 栈顶元素的右边界确定了(当前柱子 i 的位置)
- 因为当前柱子是第一个比栈顶小的柱子
- 弹出栈顶并计算面积
示例:[2, 1, 5, 6, 2, 3]
i=4, h=2
栈:[2, 3](索引,对应高度 [5, 6]
比较h(2) < heights[3](6) ✓
- 弹出索引 3高度 6
- 右边界 = 4 - 1 = 3
- 计算面积6 × 宽度
```
**Q2宽度如何计算**
```
情况1栈为空
width = i
原因:弹出后栈为空,说明这是最小的柱子,可以扩展到最左边
情况2栈不为空
width = i - stack[len(stack)-1] - 1
原因:
- stack[len(stack)-1] 是弹出后新的栈顶
- 新栈顶的右边界 + 1 到 i-1 是弹出的柱子能扩展的范围
- 减 1 是因为不包括新栈顶的位置
示例:[2, 1, 5, 6, 2, 3]
索引: 0 1 2 3 4 5
弹出索引 3高度 6
- 栈变为 [2](索引 2高度 5
- width = 4 - 2 - 1 = 1
- 只有索引 3 这一个柱子能形成高度为 6 的矩形
```
**Q3为什么最后要入栈**
```go
stack = append(stack, i)
```
```
当前柱子入栈的原因:
1. 可能是未来某个柱子的左边界
2. 还没有找到它的右边界
3. 保持单调性,等待后续处理
```
**步骤3完整流程示例**
```
输入:[2, 1, 5, 6, 2, 3]
索引: 0 1 2 3 4 5
i=0, h=2:
栈:[] → [0]
(空栈,直接入栈)
i=1, h=1:
栈:[0]
比较1 < heights[0](2) ✓
弹出 0
height = 2
width = 1栈为空
area = 2 × 1 = 2
栈:[] → [1]
i=2, h=5:
栈:[1]
比较5 > heights[1](1) ✗
直接入栈
栈:[1, 2]
i=3, h=6:
栈:[1, 2]
比较6 > heights[2](5) ✗
直接入栈
栈:[1, 2, 3]
i=4, h=2:
栈:[1, 2, 3]
比较2 < heights[3](6) ✓
弹出 3
height = 6
width = 4 - 2 - 1 = 1
area = 6 × 1 = 6
比较2 < heights[2](5) ✓
弹出 2
height = 5
width = 4 - 1 - 1 = 2
area = 5 × 2 = 10 ← 当前最大
比较2 > heights[1](1) ✗
停止弹出
栈:[1] → [1, 4]
i=5, h=3:
栈:[1, 4]
比较3 > heights[4](2) ✗
直接入栈
栈:[1, 4, 5]
i=6, h=0虚拟柱子:
栈:[1, 4, 5]
依次弹出所有元素并计算...
最大面积10
```
### 关键细节说明
**细节1为什么需要虚拟柱子**
```go
for i := 0; i <= n; i++ { // 注意 i <= n
h := 0
if i < n {
h = heights[i]
}
// ...
}
```
```
没有虚拟柱子的问题:
输入:[2, 3, 4](严格递增)
栈:[0, 1, 2]
遍历结束后,栈中还有 [0, 1, 2]
这些元素没有找到右边界!
需要额外处理剩余元素
有虚拟柱子的好处:
i=3, h=0 会强制弹出所有元素
无需额外处理
```
**细节2为什么栈中存储索引而不是高度**
```go
stack := []int{} // 存储索引
// 而不是 []int{heights[0], heights[1], ...}
```
```
存储索引的原因:
1. 需要通过索引计算宽度
2. 索引是唯一的,高度可能重复
3. 方便确定位置关系
示例:[2, 2, 2]
如果存储高度:栈 = [2, 2, 2] 无法区分
如果存储索引:栈 = [0, 1, 2] 清晰明确
```
**细节3为什么是严格递增而不是非递减**
```go
// 条件h < heights[stack[len(stack)-1]]
// 使用 < 而不是 <=
// 如果使用 <= 会怎样?
for len(stack) > 0 && h <= heights[stack[len(stack)-1]] {
// 会弹出相等的元素
}
```
```
使用 < 的原因:
- 相等高度的柱子可以形成更大的矩形
- 弹出相等的元素会错过可能的更大面积
示例:[2, 2]
使用 <:栈 = [0, 1]
i=2虚拟柱子弹出 1, 0
最大面积 = 2 × 2 = 4 ✓
使用 <=i=1 弹出 0错误
栈 = [1]
最大面积 = 2 × 1 = 2 ✗
```
### 边界条件分析
**边界1空数组**
```
输入heights = []
输出0
处理循环不执行maxArea = 0
```
**边界2单个元素**
```
输入heights = [5]
输出5
过程:
i=0: 栈 = [0]
i=1: 弹出 0area = 5 × 1 = 5
maxArea = 5 ✓
```
**边界3所有元素相同**
```
输入heights = [3, 3, 3]
输出9
过程:
i=0: 栈 = [0]
i=1: 栈 = [0, 1]
i=2: 栈 = [0, 1, 2]
i=3: 依次弹出
弹出 2: area = 3 × 1 = 3
弹出 1: area = 3 × 2 = 6
弹出 0: area = 3 × 3 = 9 ✓
maxArea = 9
```
**边界4严格递增**
```
输入heights = [1, 2, 3, 4]
输出6不是 10
过程:
最大面积是 3×2=6高度 3宽度 2
或者 2×3=6高度 2宽度 3
无法形成 4×4 的矩形,因为高度不够
```
**边界5严格递减**
```
输入heights = [4, 3, 2, 1]
输出6
过程:
每个柱子都会立即弹出
最大面积 = 4 × 1 = 4
或者 3 × 2 = 6高度 3从索引 1 到 2
```
### 复杂度分析(详细版)
**时间复杂度**
```
- 外层循环O(n+1) ≈ O(n)
- 内层 while 循环:看似 O(n),实际上总计 O(n)
为什么内层循环总计 O(n)
- 每个索引最多入栈 1 次
- 每个索引最多出栈 1 次
- 总操作次数 = 2n = O(n)
总计O(n) + O(n) = O(n)
```
**空间复杂度**
```
- 栈空间O(n) 最坏情况
- 其他变量O(1)
- 总计O(n)
最坏情况:严格递增数组
栈会存储所有索引
```
## 执行过程演示
**示例**heights = [2, 1, 5, 6, 2, 3]
```
图示:
█ █
█ █ █
█ █ █ █ █
█ █ █ █ █
0 1 2 3 4 5
步骤详解:
Step 0: 初始化
stack = []
maxArea = 0
Step 1: i=0, heights[0]=2
stack = [] → [0]
(空栈,直接入栈)
Step 2: i=1, heights[1]=1
stack = [0]
弹出 0高度 2
宽度 = 1栈为空
面积 = 2 × 1 = 2
stack = [1]
maxArea = 2
Step 3: i=2, heights[2]=5
stack = [1] → [1, 2]
5 > 1直接入栈
Step 4: i=3, heights[3]=6
stack = [1, 2] → [1, 2, 3]
6 > 5直接入栈
Step 5: i=4, heights[4]=2
stack = [1, 2, 3]
弹出 3高度 6
宽度 = 4 - 2 - 1 = 1
面积 = 6 × 1 = 6
弹出 2高度 5
宽度 = 4 - 1 - 1 = 2
面积 = 5 × 2 = 10 ← 最大
stack = [1] → [1, 4]
maxArea = 10
Step 6: i=5, heights[5]=3
stack = [1, 4] → [1, 4, 5]
3 > 2直接入栈
Step 7: i=6虚拟柱子h=0
stack = [1, 4, 5]
弹出 5高度 3
宽度 = 6 - 4 - 1 = 1
面积 = 3 × 1 = 3
弹出 4高度 2
宽度 = 6 - 1 - 1 = 4
面积 = 2 × 4 = 8
弹出 1高度 1
宽度 = 6栈为空
面积 = 1 × 6 = 6
stack = []
maxArea = 10不变
最终结果10
```
## 代码实现
### Go 实现
```go
func largestRectangleArea(heights []int) int {
stack := []int{} // 单调递增栈,存储索引
maxArea := 0
n := len(heights)
// 遍历所有柱子,包括一个虚拟的末尾柱子(高度为 0
for i := 0; i <= n; i++ {
h := 0
if i < n {
h = heights[i]
}
// 当前柱子比栈顶小,弹出栈顶并计算面积
for len(stack) > 0 && h < heights[stack[len(stack)-1]] {
height := heights[stack[len(stack)-1]]
stack = stack[:len(stack)-1] // 弹出栈顶
// 计算宽度
width := i
if len(stack) > 0 {
width = i - stack[len(stack)-1] - 1
}
area := height * width
if area > maxArea {
maxArea = area
}
}
// 当前柱子入栈
stack = append(stack, i)
}
return maxArea
}
```
### 带详细注释的版本
```go
func largestRectangleArea(heights []int) int {
// 单调递增栈:存储柱子的索引
// 栈中对应的高度严格递增
stack := []int{}
maxArea := 0
n := len(heights)
// 遍历到 n包含虚拟柱子
for i := 0; i <= n; i++ {
// 当前柱子的高度
// 虚拟柱子的高度为 0用于清空栈
h := 0
if i < n {
h = heights[i]
}
// 维护单调递增栈
// 如果当前柱子比栈顶小,说明找到了栈顶的右边界
for len(stack) > 0 && h < heights[stack[len(stack)-1]] {
// 弹出栈顶,计算以该柱子为高的最大矩形
top := stack[len(stack)-1]
height := heights[top]
stack = stack[:len(stack)-1]
// 计算宽度
// 如果栈为空,说明当前是最小的柱子,可以扩展到最左边
// 如果栈不为空,宽度 = 当前位置 - 新栈顶位置 - 1
width := i
if len(stack) > 0 {
width = i - stack[len(stack)-1] - 1
}
// 计算面积并更新最大值
area := height * width
if area > maxArea {
maxArea = area
}
}
// 当前柱子入栈
stack = append(stack, i)
}
return maxArea
}
```
## 常见错误
### 错误1忘记虚拟柱子
**错误写法**
```go
for i := 0; i < n; i++ { // ❌ 只遍历到 n-1
h := heights[i]
// ...
}
// 栈中可能还有元素未处理!
```
**正确写法**
```go
for i := 0; i <= n; i++ { // ✓ 遍历到 n
h := 0
if i < n {
h = heights[i]
}
// ...
}
```
**原因**
- 虚拟柱子强制弹出所有剩余元素
- 避免栈中元素遗漏计算
### 错误2宽度计算错误
**错误写法**
```go
width = i - stack[len(stack)-1] // ❌ 没有减 1
```
**正确写法**
```go
width = i - stack[len(stack)-1] - 1 // ✓ 正确计算
```
**原因**
- stack[len(stack)-1] 是弹出后新的栈顶
- 新栈顶的位置不包含在宽度范围内
- 必须减 1
### 错误3比较条件错误
**错误写法**
```go
for len(stack) > 0 && h <= heights[stack[len(stack)-1]] {
// ❌ 使用 <= 会弹出相等的元素
}
```
**正确写法**
```go
for len(stack) > 0 && h < heights[stack[len(stack)-1]] {
// ✓ 只弹出严格更大的元素
}
```
**原因**
- 相等高度的柱子可以合并成更大的矩形
- 提前弹出会错过最优解
### 错误4忘记处理空栈情况
**错误写法**
```go
width = i - stack[len(stack)-1] - 1 // ❌ 空栈会越界
```
**正确写法**
```go
width := i
if len(stack) > 0 {
width = i - stack[len(stack)-1] - 1
}
```
**原因**
- 空栈说明当前是最小的柱子
- 可以扩展到最左边(索引 0
## 进阶问题
### Q1空间复杂度能否优化
**答案**:不能,单调栈本身就需要 O(n) 空间。
但是可以用数组模拟栈,避免动态分配:
```go
func largestRectangleArea(heights []int) int {
n := len(heights)
stack := make([]int, n+1) // 预分配
top := -1 // 栈顶指针
maxArea := 0
for i := 0; i <= n; i++ {
h := 0
if i < n {
h = heights[i]
}
for top >= 0 && h < heights[stack[top]] {
height := heights[stack[top]]
top--
width := i
if top >= 0 {
width = i - stack[top] - 1
}
area := height * width
if area > maxArea {
maxArea = area
}
}
top++
stack[top] = i
}
return maxArea
}
```
### Q2如何找到最大矩形的位置
**方案**:记录最大矩形的左右边界
```go
func largestRectangleAreaWithPosition(heights []int) (int, int, int) {
stack := []int{}
maxArea := 0
left, right := -1, -1
n := len(heights)
for i := 0; i <= n; i++ {
h := 0
if i < n {
h = heights[i]
}
for len(stack) > 0 && h < heights[stack[len(stack)-1]] {
top := stack[len(stack)-1]
height := heights[top]
stack = stack[:len(stack)-1]
l := 0
if len(stack) > 0 {
l = stack[len(stack)-1] + 1
}
r := i - 1
area := height * (r - l + 1)
if area > maxArea {
maxArea = area
left, right = l, r
}
}
stack = append(stack, i)
}
return maxArea, left, right
}
```
### Q3这道题和"接雨水"有什么区别?
**接雨水LeetCode 42**
- 找左右两边**更大**的柱子
- 使用单调**递减**栈
- 计算的是"凹槽"的面积
**最大矩形**
- 找左右边**更小**的柱子
- 使用单调**递增**栈
- 计算的是矩形面积
### Q4如何处理高度非常大的情况
**答案**:使用 64 位整数避免溢出
```go
func largestRectangleArea(heights []int) int64 {
stack := []int{}
var maxArea int64 = 0
n := len(heights)
for i := 0; i <= n; i++ {
h := 0
if i < n {
h = heights[i]
}
for len(stack) > 0 && h < heights[stack[len(stack)-1]] {
height := int64(heights[stack[len(stack)-1]])
stack = stack[:len(stack)-1]
var width int64 = int64(i)
if len(stack) > 0 {
width = int64(i - stack[len(stack)-1] - 1)
}
area := height * width
if area > maxArea {
maxArea = area
}
}
stack = append(stack, i)
}
return maxArea
}
```
## 相关题目
1. **LeetCode 85. 最大矩形**
- 二维版本,每行看作柱状图
- 先预处理每列的连续 1 的个数
- 对每行调用本题的解法
2. **LeetCode 42. 接雨水**
- 单调递减栈
- 找更大的柱子形成凹槽
3. **LeetCode 739. 每日温度**
- 单调递减栈
- 找下一个更大的元素
## 总结
**核心要点**
1. **单调递增栈**:维护一个递增的索引序列
2. **右边界确定**:遇到更小的柱子时,栈顶元素的右边界确定
3. **虚拟柱子**:高度为 0强制清空栈
4. **宽度计算**:根据栈的状态动态计算
**易错点**
- ❌ 忘记虚拟柱子,导致栈中元素未处理
- ❌ 宽度计算错误(忘记减 1
- ❌ 比较条件错误(使用 <= 而不是 <
- ❌ 空栈情况未处理
**为什么这样思考?**
- 单调栈本质上是在"延迟计算"
- 每个柱子入栈后,等待找到它的右边界
- 一旦找到右边界,立即计算该柱子能形成的最大矩形
- 每个柱子只处理一次,时间复杂度 O(n)
- 暴力解法的 O(n²) 优化到 O(n)