改进以下三个题目的文档: 1. 最小栈 (LeetCode 155) 2. 最大正方形 (LeetCode 221) 3. 柱状图中最大的矩形 (LeetCode 84) 改进内容: - 新增"思路推导"部分:从暴力解法分析开始,逐步优化 - 详细化"解题思路"部分:分步骤说明,增加 Q&A 问答 - 新增"关键细节说明":解释为什么这样写代码 - 新增"边界条件分析":覆盖各种特殊情况 - 新增"执行过程演示":完整示例跟踪 - 新增"常见错误":对比错误和正确写法 - 新增"进阶问题":扩展思路 参考文档:算法解题思路改进方案.md Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
19 KiB
19 KiB
柱状图中最大的矩形 (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
思路推导
暴力解法分析
最直观的思路:枚举所有可能的矩形
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:预处理 - 预先计算左右边界
// 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:初始化栈和遍历
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:维护单调递增栈
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:为什么最后要入栈?
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:为什么需要虚拟柱子?
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:为什么栈中存储索引而不是高度?
stack := []int{} // 存储索引
// 而不是 []int{heights[0], heights[1], ...}
存储索引的原因:
1. 需要通过索引计算宽度
2. 索引是唯一的,高度可能重复
3. 方便确定位置关系
示例:[2, 2, 2]
如果存储高度:栈 = [2, 2, 2] 无法区分
如果存储索引:栈 = [0, 1, 2] 清晰明确
细节3:为什么是严格递增而不是非递减?
// 条件: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: 弹出 0,area = 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 实现
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
}
带详细注释的版本
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:忘记虚拟柱子
❌ 错误写法:
for i := 0; i < n; i++ { // ❌ 只遍历到 n-1
h := heights[i]
// ...
}
// 栈中可能还有元素未处理!
✅ 正确写法:
for i := 0; i <= n; i++ { // ✓ 遍历到 n
h := 0
if i < n {
h = heights[i]
}
// ...
}
原因:
- 虚拟柱子强制弹出所有剩余元素
- 避免栈中元素遗漏计算
错误2:宽度计算错误
❌ 错误写法:
width = i - stack[len(stack)-1] // ❌ 没有减 1
✅ 正确写法:
width = i - stack[len(stack)-1] - 1 // ✓ 正确计算
原因:
- stack[len(stack)-1] 是弹出后新的栈顶
- 新栈顶的位置不包含在宽度范围内
- 必须减 1
错误3:比较条件错误
❌ 错误写法:
for len(stack) > 0 && h <= heights[stack[len(stack)-1]] {
// ❌ 使用 <= 会弹出相等的元素
}
✅ 正确写法:
for len(stack) > 0 && h < heights[stack[len(stack)-1]] {
// ✓ 只弹出严格更大的元素
}
原因:
- 相等高度的柱子可以合并成更大的矩形
- 提前弹出会错过最优解
错误4:忘记处理空栈情况
❌ 错误写法:
width = i - stack[len(stack)-1] - 1 // ❌ 空栈会越界
✅ 正确写法:
width := i
if len(stack) > 0 {
width = i - stack[len(stack)-1] - 1
}
原因:
- 空栈说明当前是最小的柱子
- 可以扩展到最左边(索引 0)
进阶问题
Q1:空间复杂度能否优化?
答案:不能,单调栈本身就需要 O(n) 空间。
但是可以用数组模拟栈,避免动态分配:
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:如何找到最大矩形的位置?
方案:记录最大矩形的左右边界
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 位整数避免溢出
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
}
相关题目
-
LeetCode 85. 最大矩形
- 二维版本,每行看作柱状图
- 先预处理每列的连续 1 的个数
- 对每行调用本题的解法
-
LeetCode 42. 接雨水
- 单调递减栈
- 找更大的柱子形成凹槽
-
LeetCode 739. 每日温度
- 单调递减栈
- 找下一个更大的元素
总结
核心要点:
- 单调递增栈:维护一个递增的索引序列
- 右边界确定:遇到更小的柱子时,栈顶元素的右边界确定
- 虚拟柱子:高度为 0,强制清空栈
- 宽度计算:根据栈的状态动态计算
易错点:
- ❌ 忘记虚拟柱子,导致栈中元素未处理
- ❌ 宽度计算错误(忘记减 1)
- ❌ 比较条件错误(使用 <= 而不是 <)
- ❌ 空栈情况未处理
为什么这样思考?
- 单调栈本质上是在"延迟计算"
- 每个柱子入栈后,等待找到它的右边界
- 一旦找到右边界,立即计算该柱子能形成的最大矩形
- 每个柱子只处理一次,时间复杂度 O(n)
- 暴力解法的 O(n²) 优化到 O(n)