力扣hot100之84:柱状图中最大的矩形
力扣hot100之84:柱状图中最大的矩形

LeetCode 84:柱状图中最大的矩形(Largest Rectangle in Histogram)题解

Largest Rectangle in Histogram 是 LeetCode Hot 100 里面一道非常经典的单调栈题。

这道题第一次看时,很多人都会觉得不太好下手。因为题目看起来很直观: 给定一组柱子的高度,要求你在这个柱状图里找到面积最大的矩形。

但真正开始做时,很容易卡在一个问题上:

如果以某一根柱子作为高度,它到底能向左和向右扩展到哪里?

这正是整道题的核心。

你给出的这份 Java 代码,使用的是这道题最经典、也非常标准的一种解法:

单调栈 + 预处理左右第一个更小元素

这种写法的核心思想是:

  1. 对于每一根柱子,找到它左边第一个比它低的位置

  2. 找到它右边第一个比它低的位置

  3. 这样它能作为“最矮柱子”支撑的最大宽度就确定了

  4. 面积也就能直接算出来

本文就结合这段代码,系统地讲一下这道题的思路。


题目介绍

给定 n 个非负整数,用来表示柱状图中各个柱子的高度。 每个柱子的宽度都为 1

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

例如:

heights = [2,1,5,6,2,3]

对应的柱状图中,最大矩形面积是:

10

因为可以选择高度为 56 那两根柱子形成的区域,最优矩形宽度为 2,高度取 5,所以面积为:

5 × 2 = 10

这道题的关键并不是枚举“矩形左边界和右边界”,而是换个角度思考:

如果固定某一根柱子作为矩形的最低高度,它最多能扩展多宽?


示例

示例 1

输入:heights = [2,1,5,6,2,3]
输出:10

最大矩形由中间高度为 56 的区域构成,面积为 10

示例 2

输入:heights = [2,4]
输出:4

因为:

  • 只选高度为 2 的两根柱子,面积是 2 × 2 = 4

  • 单独选高度为 4 的柱子,面积是 4 × 1 = 4

所以最大面积是 4


最直接的想法是什么?

如果最开始不考虑优化,一个很自然的思路是:

  • 枚举每一个区间 [l, r]

  • 找出这个区间里的最小高度

  • 用:

最小高度 × 区间宽度

计算矩形面积

  • 最后取最大值

但问题是,这样做复杂度会比较高。 因为:

  • 区间有很多种

  • 每个区间还要找最小值

即使做一些优化,整体仍然很容易超时。

所以这道题真正高效的做法,不是从“枚举区间”出发,而是从:

枚举每根柱子作为最矮柱子时,能形成的最大矩形

这个方向入手。


核心思路:以每根柱子为最低高度

假设我们固定某一根柱子 i,它的高度是:

heights[i]

如果让它作为矩形中的最矮柱子,那么这个矩形能向左右扩展的边界取决于什么?

答案是:

  • 向左一直扩展,直到遇到第一个比它矮的柱子

  • 向右一直扩展,直到遇到第一个比它矮的柱子

也就是说,我们需要知道两件事:

左边界

左边第一个严格小于 heights[i] 的位置

右边界

右边第一个严格小于 heights[i] 的位置

一旦这两个位置找到了,那么以 heights[i] 为最矮柱子的最大宽度就是:

rightLower[i] - leftLower[i] - 1

所以它能形成的最大矩形面积就是:

heights[i] × (rightLower[i] - leftLower[i] - 1)

整道题就转化成:

快速求每个位置左右两边第一个更小元素。

而这正是单调栈最擅长的问题。


为什么用单调栈?

因为我们想找的是:

  • 每个位置左边第一个更小元素

  • 每个位置右边第一个更小元素

这类“最近更小 / 更大元素”的问题,几乎都是单调栈的典型应用。

在这道题里,我们维护一个 单调递增栈,栈里存的是下标。 这样当遍历到一个新柱子时,如果它比栈顶对应的柱子更低,就说明:

  • 它就是那些更高柱子的“右边第一个更小元素”

于是可以不断弹栈并更新右边界。

同时,弹栈结束后,当前栈顶就是当前柱子左边第一个更小元素。

这就是你代码的核心逻辑。


Java 代码

下面是你给出的代码,我这里保留原始逻辑,并加上注释:

class Solution {
    public int largestRectangleArea(int[] heights) {
        Stack<Integer> s = new Stack<>();

        int[] lefLower = new int[heights.length];
        int[] rigLower = new int[heights.length];

        // 默认右边没有更小元素时,边界设为数组长度
        Arrays.fill(rigLower, heights.length);

        for (int i = 0; i < heights.length; i++) {
            // 当前柱子更低,说明它是栈中某些柱子的右边第一个更小元素
            while (!s.empty() && heights[s.peek()] > heights[i]) {
                rigLower[s.pop()] = i;
            }

            // 当前柱子左边第一个更小元素,就是弹栈后栈顶
            lefLower[i] = s.empty() ? -1 : s.peek();

            s.push(i);
        }

        int res = 0;

        for (int i = 0; i < heights.length; i++) {
            res = Math.max(res, (rigLower[i] - lefLower[i] - 1) * heights[i]);
        }

        return res;
    }
}

代码拆解

这段代码虽然不算很长,但第一次接触单调栈的人,很容易在 lefLowerrigLower、弹栈逻辑这几个地方绕进去。 下面一步一步来看。


1. 为什么栈里存的是下标,而不是高度?

Stack<Integer> s = new Stack<>();

因为我们最后不仅要知道“有没有更小的元素”,更要知道:

  • 左边第一个更小元素的 位置

  • 右边第一个更小元素的 位置

只有知道位置,才能算宽度。

如果栈里只存高度,那后面就没法直接计算:

rightLower[i] - leftLower[i] - 1

所以栈里必须存下标。


2. lefLowerrigLower 分别表示什么?

int[] lefLower = new int[heights.length];
int[] rigLower = new int[heights.length];

这两个数组是整道题最核心的中间结果。

lefLower[i]

表示:

位置 i 左边第一个严格小于 heights[i] 的下标

如果左边没有更小元素,就记为:

-1

rigLower[i]

表示:

位置 i 右边第一个严格小于 heights[i] 的下标

如果右边没有更小元素,就记为:

heights.length

为什么这样设置默认值? 因为这样后面算宽度时会非常方便,不需要额外分情况讨论。


3. 为什么要先把 rigLower 全部填成 heights.length

Arrays.fill(rigLower, heights.length);

因为有些柱子右边可能根本不存在更小元素。

例如:

[1,2,3,4]

其中最后几个柱子右边都没有更小值。

这时我们就把它们的“右边界”视为数组右边界之外的位置:

heights.length

这样在计算宽度时,统一公式仍然成立:

rigLower[i] - lefLower[i] - 1

所以这是一个很常见的边界初始化技巧。


4. 为什么 while 里要弹栈?

while (!s.empty() && heights[s.peek()] > heights[i]) {
    rigLower[s.pop()] = i;
}

这是整道题最关键的一段。

我们维护的是一个按高度单调递增的栈。 也就是说,栈顶到栈底对应的柱子高度是递增的。

当当前柱子 heights[i] 比栈顶柱子还低时,说明:

当前柱子就是栈顶柱子右边第一个更小元素。

所以:

  • 把栈顶弹出来

  • 记录它的右边界是当前 i

而且如果当前柱子还比新的栈顶更低,就继续弹,直到栈重新恢复单调递增。

这一步本质上是在批量确定很多柱子的右边界。


5. 为什么弹栈后栈顶就是当前柱子的左边第一个更小元素?

lefLower[i] = s.empty() ? -1 : s.peek();

因为栈里始终保持“高度单调递增”。

在当前柱子 i 进入栈之前,所有比它高的柱子已经在前面的 while 中被弹掉了。 所以此时留在栈顶的那个柱子,要么:

  • 比当前柱子更低

  • 要么栈空

并且由于栈顶是离当前位置最近的那个还没被弹掉的位置,所以它正好就是:

左边第一个更小元素

如果栈空,说明左边根本没有更小值,于是记为 -1


6. 为什么最后面积公式是这样?

(rigLower[i] - lefLower[i] - 1) * heights[i]

因为:

  • lefLower[i] 是左边第一个更小的位置

  • rigLower[i] 是右边第一个更小的位置

所以在这两个位置之间,中间所有柱子的高度都至少是 heights[i]

因此,以 heights[i] 为最矮柱子时,它能够支撑的最大宽度就是:

从 lefLower[i] 右边一个位置,到 rigLower[i] 左边一个位置

长度正好是:

rigLower[i] - lefLower[i] - 1

再乘上高度 heights[i],就是面积。


手动模拟一遍

我们用经典例子来走一下:

heights = [2,1,5,6,2,3]

第一步:求左边和右边第一个更小元素

最终会得到:

lefLower = [-1,-1,1,2,1,4]
rigLower = [1,6,4,4,6,6]

解释几个关键位置:

对于高度 5(下标 2)

  • 左边第一个更小元素是下标 1,对应高度 1

  • 右边第一个更小元素是下标 4,对应高度 2

所以它的最大宽度是:

4 - 1 - 1 = 2

面积是:

5 × 2 = 10

对于高度 6(下标 3)

  • 左边第一个更小元素是下标 2

  • 右边第一个更小元素是下标 4

宽度是:

4 - 2 - 1 = 1

面积:

6 × 1 = 6

对于高度 2(下标 4)

  • 左边第一个更小元素是下标 1

  • 右边没有更小元素,所以是 6

宽度:

6 - 1 - 1 = 4

面积:

2 × 4 = 8

最终所有柱子中最大面积就是:

10

这正好是答案。


为什么这个方法是正确的?

因为对于任意一根柱子 i,如果把它视为某个矩形的最低高度,那么这个矩形能够延伸的最大范围,一定会被:

  • 左边第一个更小柱子

  • 右边第一个更小柱子

限制住。

在这两个更小柱子之间的所有位置,柱子高度都不低于 heights[i],所以它们可以一起构成一个以 heights[i] 为高度的矩形。

而超过这两个边界之后,就会遇到更低柱子,矩形再扩展就不合法了。

所以:

  • 每根柱子都能对应一个“以它为最矮柱子”的最大矩形

  • 所有这些矩形中,最大面积一定就是答案

单调栈的作用,只是帮我们高效求出了每根柱子的左右边界而已。


复杂度分析

时间复杂度

虽然代码里有一个 for 加一个 while,但每个下标最多只会:

  • 入栈一次

  • 出栈一次

所以整体时间复杂度仍然是:

O(n)

空间复杂度

额外使用了:

  • 一个栈

  • 两个数组 lefLowerrigLower

所以空间复杂度是:

O(n)

这道题真正想考什么?

这道题表面上是在算面积,但本质上考察的是单调栈的典型应用。

第一,是否能把问题转化成“最近更小元素”

很多人第一次做这题,会一直盯着“矩形”。 但真正的突破口其实是:

以每根柱子为最低高度时,它左右能扩多远?

一旦想到这一步,问题就变成了最近更小元素问题,而这正是单调栈的主场。

第二,是否理解单调栈维护的到底是什么

栈维护的不是答案本身,而是一个单调结构。 借助这个单调结构,我们才能在遍历过程中高效确定很多元素的边界信息。

第三,是否会处理边界默认值

像这题里的:

  • 左边没有更小值时记为 -1

  • 右边没有更小值时记为 n

这些都是非常经典的技巧,能让后面的公式写得更统一。


和“接雨水”有什么相似之处?

这两题表面不同,但有一个共同点:

  • 都是在数组上找某种“范围”

  • 都需要借助局部边界信息来计算面积

不过它们的核心思路不一样:

接雨水

更核心的是左右最大值 / 双指针 / 单调栈。

柱状图最大矩形

更核心的是:

左右第一个更小元素

所以这题是单调栈专题里极其经典的一道模板题。


总结

这道题的核心思想可以概括成一句话:

对于每根柱子,找到它左右两边第一个更小元素的位置,这样就能确定它作为最矮柱子时能形成的最大矩形面积。

具体步骤就是:

  1. 用单调递增栈维护柱子下标

  2. 在遍历过程中确定每个柱子的右边第一个更小元素

  3. 弹栈结束后,当前栈顶就是它左边第一个更小元素

  4. 得到左右边界后,用公式:

(右边界 - 左边界 - 1) × 当前高度

计算面积

  1. 取所有面积中的最大值

这样就能在:

O(n)

的时间复杂度内解决问题。

这是一道非常经典、也非常值得反复理解的单调栈题。 因为它会让你真正体会到:

  • 单调栈到底解决什么问题

  • 最近更小元素为什么这么重要

  • 看似复杂的范围问题,如何被转化成边界问题

把这道题真正吃透之后,再去看:

  • 每日温度

  • 下一个更大元素

  • 接雨水

  • 最大矩形

这些单调栈题时,你会明显更容易抓住套路。

因为“柱状图中最大的矩形”本身就是单调栈专题里最经典的一道模板题。

如果对您有帮助的话,能否支持一下博主?
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇