【LeetCode 84】柱状图中最大的矩形:单调栈的教科书级应用

3 阅读4分钟

在算法面试中, “柱状图中最大的矩形” (LeetCode 84)是一道极具代表性的经典题目。很多同学在初次遇到这道题时,往往会被各种边界条件绕晕。今天,我们将用最通俗的语言,带你彻底吃透这道题,掌握单调栈这一核心利器。

一、 题目解析:我们在找什么?

给定一个非负整数数组 heights,代表柱状图中各个柱子的高度。每个柱子的宽度为 1,我们需要找出其中可以勾勒出来的矩形的最大面积。

核心破题点:
不要一上来就想着“如何框选一个矩形”,而是要转换思维:以每一个柱子为基准,向左右两侧扩展,直到遇到比它矮的柱子。
也就是说,对于第 i 个柱子,我们需要找到:

  1. 左边第一个高度小于它的柱子索引 left
  2. 右边第一个高度小于它的柱子索引 right

那么,以第 i 个柱子为高的最大矩形面积就是:heights[i] * (right - left - 1)。遍历所有柱子,取最大值即可。

二、 从暴力到最优解的演进

1. 暴力枚举法(O(n²))

最直观的想法是:遍历每个柱子,然后分别向左右两边线性扫描,寻找第一个比它矮的柱子。

  • 缺点:时间复杂度高达 O(n²),当数据量达到 10^5 时,必然超时。

2. 单调栈优化(O(n))

暴力法的核心瓶颈在于“向左右寻找第一个比它矮的柱子”这个过程太慢了。有没有办法在一次遍历中,就把所有柱子的左右边界都确定下来?

单调栈就是为此而生的。我们维护一个单调递增栈(栈底到栈顶的高度依次递增)。

核心逻辑:
当我们遍历到一个新柱子 i 时,如果它的高度小于栈顶柱子的高度,这就意味着:

  • 对于栈顶柱子来说,当前柱子 i 就是它右边第一个比它矮的柱子(右边界确定)
  • 栈顶下面的那个元素,就是它左边第一个比它矮的柱子(左边界确定)

此时,我们就可以弹出栈顶,并计算它的最大矩形面积!

三、 代码实现与深度解析

为了处理边界情况(比如数组是单调递增的,导致栈内元素无法全部弹出计算),我们通常会在数组首尾各加一个高度为 0 的哨兵

/**
 * @param {number[]} heights
 * @return {number}
 */
var largestRectangleArea = function(heights) {
    // 1. 哨兵技巧:首尾加0,确保所有元素都能入栈并出栈计算
    heights = [0, ...heights, 0];
    const stack = [];
    let maxArea = 0;

    for (let i = 0; i < heights.length; i++) {
        // 2. 核心逻辑:当前高度 < 栈顶高度,说明找到了栈顶元素的右边界
        while (stack.length && heights[i] < heights[stack[stack.length - 1]]) {
            // 弹出栈顶,计算以该柱子为高的矩形面积
            const heightIndex = stack.pop();
            const height = heights[heightIndex];
            
            // 3. 确定宽度:右边界是当前i,左边界是新的栈顶
            // 如果栈空(理论上不会,因为有首哨兵0),左边界视为-1
            const leftIndex = stack.length ? stack[stack.length - 1] : -1;
            const width = i - leftIndex - 1;
            
            maxArea = Math.max(maxArea, height * width);
        }
        // 当前索引入栈,维持单调递增
        stack.push(i);
    }

    return maxArea;
};

代码深度解析(为什么这么写?)

  1. 为什么要加哨兵 [0, ...heights, 0]

    • 首部的 0:保证栈永远不为空。当弹出最后一个有效元素时,新的栈顶就是这个 0,避免了 leftIndex 的越界判断。
    • 尾部的 0:这是最关键的。如果原数组是 [1, 2, 3],遍历结束时栈里还剩下 [1, 2, 3],它们都没有触发“遇到更小元素”的条件。尾部的 0 会强制触发 while 循环,把栈里剩下的元素全部弹出并计算面积。
  2. 栈里存的是索引还是值?

    • 存的是索引 i。因为我们需要通过索引去获取高度 heights[i],同时还需要用索引来计算宽度 i - leftIndex - 1
  3. 时间复杂度真的是 O(n) 吗?

    • 是的。虽然代码里有一个 while 嵌套在 for 循环中,但每个元素最多只会入栈一次、出栈一次。总的操作次数是 2n,所以均摊时间复杂度是 O(n)。

四、 面试加分项

如果在面试中遇到这道题,除了写出上述代码,你还可以主动补充以下两点,展示你的工程思维:

  1. 空间换时间:单调栈本质上是用 O(n) 的空间复杂度,换取了 O(n) 的时间复杂度,这是算法设计中非常经典的权衡。
  2. 实际应用场景:这种“寻找左右两侧第一个更小/更大元素”的模型,在实际工程中常用于图像处理中的最大矩形检测仓库货物摆放优化以及数据可视化中的柱状图分析等场景