LeetCode 84:柱状图中的最大矩形——单调栈如何确定左右边界

题目描述

给定 n 个非负整数 heights,用来表示柱状图中各个柱子的高度。每个柱子彼此相邻,且宽度为 1。

要求返回在这个柱状图中能够勾勒出的矩形的最大面积。

例如:

复制代码
heights = [2,1,5,6,2,3]
最大矩形面积 = 10

其中最大矩形可以由高度 5 和 6 两根柱子组成,高度取 5,宽度为 2,面积为 10。

最初卡住的地方

最开始的思路是:枚举每一根柱子,把它作为矩形高度,然后分别向左、向右扩展,找到它能覆盖的最大宽度。

这个思路本身很直观:

复制代码
固定 heights[i] 作为矩形高度
向左找连续高度 >= heights[i] 的柱子
向右找连续高度 >= heights[i] 的柱子
用 高度 * 宽度 更新答案

但问题在于复杂度。对于单调递增或单调递减数组,比如:

复制代码
[1,2,3,4,5]
[5,4,3,2,1]

每个位置都可能向一侧扫描很远,整体时间复杂度会退化到 O(n^2),数据量一大就容易超时。

后面改成单调栈时,又容易卡在三个细节上:

  • 遇到更矮柱子时,不能只弹出一次,而要持续弹出。

  • 计算面积时,高度应该用被弹出的柱子,而不是当前柱子。

  • 弹出后需要知道左边第一个更矮的位置,遍历结束后还要结算栈里剩下的柱子。

正确思路

这道题的核心是:对于每根柱子,找到它左边第一个更矮的柱子,以及右边第一个更矮的柱子。

如果某根柱子的下标是 mid:

  • 左边第一个更矮的位置是 l

  • 右边第一个更矮的位置是 r

那么以 heights[mid] 为高度的最大矩形范围就是:

复制代码
(l, r)

也就是从 l + 1 到 r - 1,所以宽度是:

复制代码
r - l - 1

为了高效找到这两个边界,可以用单调递增栈存下标。

遍历数组时:

  • 如果当前高度大于等于栈顶高度,说明还能维持递增关系,当前下标入栈。

  • 如果当前高度小于栈顶高度,说明栈顶柱子的右边第一个更矮位置已经出现,就是当前下标。

  • 弹出栈顶 mid 后,新的栈顶就是 mid 左边第一个更矮的位置。

  • 用 height = h[mid]、width = r - l - 1 计算面积。

为了减少边界判断,可以在原数组前后各加一个高度为 0 的哨兵:

复制代码
原数组: [2,1,5,6,2,3]
扩展后: [0,2,1,5,6,2,3,0]

前面的 0 用来避免弹栈后栈为空的特判;后面的 0 用来在遍历结束时强制清算栈中剩余的柱子。

手推过程

以这个数组为例:

复制代码
heights = [2,1,5,6,2,3]

加上前后哨兵后:

复制代码
h = [0,2,1,5,6,2,3,0]

栈里保存下标,并让这些下标对应的高度保持单调递增。

当前下标 i 当前高度 hi 操作 面积计算
0 0 左哨兵入栈 -
1 2 2 >= 0,入栈 -
2 1 1 < 2,弹出 1 高度 2,宽度 2 - 0 - 1 = 1,面积 2
2 1 1 >= 0,入栈 -
3 5 5 >= 1,入栈 -
4 6 6 >= 5,入栈 -
5 2 2 < 6,弹出 4 高度 6,宽度 5 - 3 - 1 = 1,面积 6
5 2 2 < 5,弹出 3 高度 5,宽度 5 - 2 - 1 = 2,面积 10
5 2 2 >= 1,入栈 -
6 3 3 >= 2,入栈 -
7 0 0 < 3,弹出 6 高度 3,宽度 7 - 5 - 1 = 1,面积 3
7 0 0 < 2,弹出 5 高度 2,宽度 7 - 2 - 1 = 4,面积 8
7 0 0 < 1,弹出 2 高度 1,宽度 7 - 0 - 1 = 6,面积 6

最大面积是 10。

这里最关键的一步是弹出高度 5 的时候:

复制代码
h = [0,2,1,5,6,2,3,0]
          l   mid   r

当遍历到右侧的 2 时,说明高度 5 的右边第一个更矮位置是当前下标 r。弹出 5 后,栈顶对应的高度是 1,也就是左边第一个更矮位置 l。

所以高度 5 能覆盖的范围是 (l, r),宽度是 r - l - 1 = 2。

伪代码

复制代码
构造新数组 h,长度为 n + 2
h[0] = 0
h[n + 1] = 0
把 heights 放到 h[1...n]

初始化栈,先放入下标 0
ans = 0

从 i = 1 遍历到 h.length - 1:
    while 当前高度 h[i] 小于栈顶高度 h[stack.peek()]:
        mid = stack.pop()
        height = h[mid]
        left = stack.peek()
        right = i
        width = right - left - 1
        ans = max(ans, height * width)

    当前下标 i 入栈

返回 ans

Java 代码

复制代码
import java.util.ArrayDeque;
import java.util.Deque;

class Solution {
    public int largestRectangleArea(int[] heights) {
        int n = heights.length;
        int ans = 0;

        int[] h = new int[n + 2];
        for (int i = 0; i < n; i++) {
            h[i + 1] = heights[i];
        }

        Deque<Integer> stack = new ArrayDeque<>();
        stack.push(0);

        for (int i = 1; i < h.length; i++) {
            while (!stack.isEmpty() && h[i] < h[stack.peek()]) {
                int mid = stack.pop();
                int height = h[mid];
                int left = stack.peek();
                int right = i;
                int width = right - left - 1;

                ans = Math.max(ans, height * width);
            }

            stack.push(i);
        }

        return ans;
    }
}

这份代码里,stack.isEmpty() 理论上不会在弹真实柱子时触发,因为前面有左哨兵 0。保留这个判断主要是为了代码更稳。

易错点

第一,栈里应该存下标,而不是只存高度。

因为最终要计算宽度,宽度依赖左右边界的下标:

复制代码
width = right - left - 1

第二,遇到当前柱子更矮时,要用 while 连续弹栈。

例如:

复制代码
[2,4,5,1]

遍历到 1 时,5、4、2 都遇到了右边第一个更矮的位置,都应该被结算。如果只用 if,就会漏掉一部分面积。

第三,面积高度要用被弹出的柱子。

弹出 mid 表示现在要结算"以 h[mid] 为高度"的最大矩形,所以面积应该是:

复制代码
h[mid] * width

而不是:

复制代码
h[i] * width

第四,宽度不是 right - mid,也不是 mid - left。

left 和 right 都是比 h[mid] 更矮的位置,它们本身不能包含进矩形,所以矩形覆盖的是开区间:

复制代码
(left, right)

宽度就是:

复制代码
right - left - 1

第五,前后哨兵各有作用。

  • 前面的 0:避免弹出后栈为空,省掉左边界特判。

  • 后面的 0:在最后触发弹栈,把单调递增时留在栈里的柱子全部结算。

复杂度分析

  • 时间复杂度:O(n)。每个下标最多入栈一次、出栈一次。

  • 空间复杂度:O(n)。额外使用了扩展数组和栈。

总结

这道题的关键不是直接去枚举矩形宽度,而是反过来想:对于每根柱子,把它作为高度时,它最多能向左右扩展到哪里。

单调递增栈正好可以解决这个问题。当前柱子比栈顶矮时,说明栈顶柱子的右边界确定了;弹出之后,新的栈顶就是它的左边界。

写这类题时可以重点自检三件事:栈里存的是不是下标,弹栈时是不是用被弹出的元素计算面积,宽度是不是按照 right - left - 1 来算。只要这三个点稳定,单调栈的结构就比较清晰了。

相关推荐
驭渊的小故事36 分钟前
MyBatis XML 开发实战:从配置到 CRUD 全解析
xml·java·mybatis
强哥聊算力3 小时前
《算力江湖·英伟达传奇》第七集:ChatGPT出现前夜,英伟达发生了什么?
服务器·人工智能·算法·ai·chatgpt·gpu算力
哦虎!5 小时前
【JavaEE】网络初识
java
for_ever_love__6 小时前
MySQL ORDER BY 排序优化:从 Using filesort 到索引有序扫描(含双路 / 单路排序原理)
java·mysql·优化·存储·by·order·filesort
我是人✓7 小时前
Java文件上传三方式对比:从传统到SpringBoot
java·开发语言
欣栀♡7 小时前
JVM 频繁 Full GC 先看日志括号里的 cause,jstat 的 FGC 在 JDK 8 上会骗人
java·jvm
Navigator_Z7 小时前
LeetCode //C - 1289. Minimum Falling Path Sum II
c语言·算法·leetcode
CCblandy7 小时前
令牌桶在 0.9 倍负载超发 19%、固定窗口边界放行 20 个:四种限流算法同构实测
后端·python·算法·性能优化·架构
IT研究室7 小时前
最新计算机毕业设计选题推荐-基于spring boot的葡萄酒文化宣传网站-网站-文档指导-Java-springboot
java·spring boot·课程设计
慧都小项7 小时前
MyEclipse 中创建和测试 JAX-RS REST 接口:从项目配置到 REST Explorer
java·restful·myeclipse·jax-rs