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

题目描述

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

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

例如:

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

其中最大矩形可以由高度 56 两根柱子组成,高度取 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 + 1r - 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 时,542 都遇到了右边第一个更矮的位置,都应该被结算。如果只用 if,就会漏掉一部分面积。

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

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

复制代码
h[mid] * width

而不是:

复制代码
h[i] * width

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

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

复制代码
(left, right)

宽度就是:

复制代码
right - left - 1

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

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

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

复杂度分析

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

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

总结

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

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

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

相关推荐
白狐_7981 小时前
408数据结构第8章:排序②——性质对比秒杀、场景选择与外部排序
java·数据结构·算法
码匠许师傅1 小时前
【C++ 面试真题】聊聊 C++ 的拷贝构造与拷贝赋值
java·c++·面试
Shell运维手记2 小时前
Linux 常用基础命令学习笔记
linux·运维·笔记·学习·算法·github
杨航 AI2 小时前
O(n log n):线性对数原理拆解 这个是排序算法的黄金复杂度之一。
算法·排序算法
船厂电气自动化ai大模型2 小时前
AI大模型与数学 第32课 函数凹凸性与二阶导数:拐点求解、凹凸区间计算(10道二阶导数计算题)
数据结构·人工智能·python·深度学习·算法
旖旎夜光2 小时前
LeetCode 3:无重复字符的最长子串(滑动窗口) —— 题解
数据结构·c++·算法·leetcode·滑动窗口
极创信息3 小时前
系统安全隐患全方位排查方案:标准化渗透测试全流程
java·opencv·struts·数据挖掘·eclipse·语音识别·hibernate
汉字萌萌哒4 小时前
2019CCF-CSP入门级C++试题解析
java·开发语言·c++
叠层归一研究院4 小时前
AGI 系统(十一):二阶网络 — 二阶递归 × 多主体 (元符号网络)
开发语言·人工智能·算法·php·agi