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

19 KiB
Raw Blame History

柱状图中最大的矩形 (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: 弹出 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 实现

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
}

相关题目

  1. LeetCode 85. 最大矩形

    • 二维版本,每行看作柱状图
    • 先预处理每列的连续 1 的个数
    • 对每行调用本题的解法
  2. LeetCode 42. 接雨水

    • 单调递减栈
    • 找更大的柱子形成凹槽
  3. LeetCode 739. 每日温度

    • 单调递减栈
    • 找下一个更大的元素

总结

核心要点

  1. 单调递增栈:维护一个递增的索引序列
  2. 右边界确定:遇到更小的柱子时,栈顶元素的右边界确定
  3. 虚拟柱子:高度为 0强制清空栈
  4. 宽度计算:根据栈的状态动态计算

易错点

  • 忘记虚拟柱子,导致栈中元素未处理
  • 宽度计算错误(忘记减 1
  • 比较条件错误(使用 <= 而不是 <
  • 空栈情况未处理

为什么这样思考?

  • 单调栈本质上是在"延迟计算"
  • 每个柱子入栈后,等待找到它的右边界
  • 一旦找到右边界,立即计算该柱子能形成的最大矩形
  • 每个柱子只处理一次,时间复杂度 O(n)
  • 暴力解法的 O(n²) 优化到 O(n)