从暴力到优雅:用单调栈破解柱状图最大矩形问题 力扣84. 柱状图中最大的矩形

24 阅读5分钟

image.png

这是 LeetCode 第 84 题,一道经典到不能再经典的栈应用题。今天带你从暴力解法一步步推导出最优解,理解算法优化的本质。

问题描述

给定 n 个非负整数,表示柱状图中各个柱子的高度。每个柱子彼此相邻,宽度为 1。求在这个柱状图中能够勾勒出来的矩形的最大面积。

输入: [2,1,5,6,2,3]
输出: 10
解释: 以高为5、宽为2的柱子构成的矩形面积最大

一、暴力解法:最直观的思路

核心思想

枚举每个柱子作为矩形的高,然后向左右两边扩展,找到能组成这个高度的最大宽度。

// 暴力解法 O(n²)
public int largestRectangleArea(int[] heights) {
    int ans = 0;
    int n = heights.length;
    
    for (int i = 0; i < n; i++) {
        int height = heights[i];
        
        // 向左扩展:找第一个小于当前高度的柱子
        int left = i - 1;
        while (left >= 0 && heights[left] >= height) {
            left--;
        }
        
        // 向右扩展:找第一个小于当前高度的柱子
        int right = i + 1;
        while (right < n && heights[right] >= height) {
            right++;
        }
        
        // 计算宽度(注意:left和right指向的是不满足条件的边界)
        int width = right - left - 1;
        ans = Math.max(ans, height * width);
    }
    return ans;
}

有什么问题?

这个解法很直观,但时间复杂度是 O(n²)。当数据量达到 10⁴ 或 10⁵ 时,就会超时。

瓶颈在哪里?

观察发现:对于每个柱子,我们都在重复扫描左右区域。比如数组 [1,2,3,4,5],每个柱子向左扩展时都要遍历前面所有元素。大量的重复计算拖慢了速度。

二、优化思路:能不能不重复扫描?

关键洞察

每个柱子的最大矩形面积,取决于左右两侧第一个比它矮的柱子位置。这两个"边界"确定了矩形的宽度。

柱子i就像一块木板,左右两个矮柱子就像两个"保镖"
挡住了它继续扩展的路

如果我们能快速知道每个柱子的左右边界,问题就解决了。

新问题:如何快速找边界?

这就变成了"下一个更小元素"问题:

  • 左侧边界:左边第一个比当前小的元素
  • 右侧边界:右边第一个比当前小的元素

这类问题有个共性:需要维护一个有序结构来快速查询。自然而然地,我们想到了——

三、单调栈:为解决这类问题而生

什么是单调栈?

单调栈是一种特殊的栈,栈内元素从底到顶保持单调递增或递减

对于这个题目,我们需要一个递增栈(从底到顶高度递增),这样栈顶元素就是"最近的小值"。

算法核心流程

遍历每个柱子时:

  1. 若当前柱子 ≥ 栈顶:直接入栈,保持递增
  2. 若当前柱子 < 栈顶:说明找到了栈顶柱子的右边界,可以计算面积了

用例子 [2,1,5,6,2,3] 演示:

i高度操作前栈操作过程操作后栈左边界
02[]push 0[0]-1
11[0]pop 0 → push 1[1]-1
25[1]push 2[1,2]1
36[1,2]push 3[1,2,3]2
42[1,2,3]pop 3, pop 2 → push 4[1,4]1
53[1,4]push 5[1,4,5]4

当弹出元素时,就是计算面积的时刻!

为什么这样就能找到边界?

  • 左边界:栈中前一个元素(栈顶下方)
  • 右边界:导致弹出的当前柱子(i)

因为栈是递增的,栈顶下面的元素一定比它小(或不存在),而当前柱子也比它小,所以这两个就是左右边界。

四、完整代码实现

class Solution {
    public int largestRectangleArea(int[] heights) {
        int n = heights.length;
        Deque<Integer> stack = new ArrayDeque<>();
        int maxArea = 0;
        
        // 遍历每个柱子
        for (int i = 0; i < n; i++) {
            // 当前柱子小于栈顶,开始计算面积
            while (!stack.isEmpty() && heights[stack.peek()] >= heights[i]) {
                int height = heights[stack.pop()];  // 当前柱子的高度
                int leftBound = stack.isEmpty() ? -1 : stack.peek();  // 左边界
                int width = i - leftBound - 1;  // 右边界是i
                maxArea = Math.max(maxArea, height * width);
            }
            stack.push(i);
        }
        
        // 处理栈中剩余的柱子(右边界都是n)
        while (!stack.isEmpty()) {
            int height = heights[stack.pop()];
            int leftBound = stack.isEmpty() ? -1 : stack.peek();
            int width = n - leftBound - 1;
            maxArea = Math.max(maxArea, height * width);
        }
        
        return maxArea;
    }
}

代码要点

  1. 栈存的是下标,不是高度,方便计算宽度
  2. >= 保证相等高度也能正确处理
  3. 最后要处理栈中剩余元素(它们的右边界是数组末尾)

五、复杂度分析

  • 时间复杂度:O(n)
    • 每个元素最多入栈一次、出栈一次
    • 虽然有两个 while 循环,但总体是线性时间
  • 空间复杂度:O(n)
    • 最坏情况下栈需要存储所有元素

相比暴力解法的 O(n²),这简直是质的飞跃

六、总结与延伸

学到了什么?

  1. 暴力是思考的起点:从 O(n²) 的朴素想法开始
  2. 识别重复计算:找到性能瓶颈所在
  3. 数据结构赋能:用栈消除重复扫描
  4. 模式抽象:将问题转化为"下一个更小元素"

单调栈还能做什么?

这类"下一个更大/更小元素"问题,都可以考虑单调栈:

  • 每日温度(下一个更高温度)
  • 接雨水(左右最大高度)
  • 最大矩形(二维版本)
  • 股票跨度(之前连续小于等于的天数)

一个思考题

如果把题目改成环形柱状图(首尾相连),该怎么做?


算法优化的本质,是用空间换时间,用巧妙的数据结构避免重复劳动。单调栈正是这种模式的最佳体现之一。

希望这篇文章能帮你真正理解单调栈的精髓。下次遇到"下一个更小元素"问题,你的脑海中应该会立刻浮现出那个从底到顶递增的神秘栈


(注:本文示例代码均为 Java,但思路是通用的。C++/Python 实现思路完全相同)