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