【LeetCode Hot100 刷题日记 (73/100)】84. 柱状图中最大的矩形——单调栈🧠

5 阅读5分钟

📌 题目链接:84. 柱状图中最大的矩形 - 力扣(LeetCode)

🔍 难度:困难 | 🏷️ 标签:栈、数组、单调栈

⏱️ 目标时间复杂度:O(n)

💾 空间复杂度:O(n)


🧠 题目分析

给定一个非负整数数组 heights,每个元素表示柱状图中一个宽度为 1 的柱子的高度。要求找出能勾勒出的最大矩形面积

这个问题看似简单,但暴力解法的时间复杂度高达 O(n²) ,在 LeetCode 的约束(n ≤ 1e5)下会超时。因此,我们需要一种更高效的算法。

核心观察点是:对于每一个柱子,以其高度为矩形的高,向左右扩展直到遇到比它矮的柱子,此时形成的矩形面积即为该柱子所能贡献的最大面积

于是问题转化为:对每个位置 i,快速找到其左侧第一个小于它的位置 left[i] 和右侧第一个小于它的位置 right[i]

这正是**单调栈(Monotonic Stack)**的经典应用场景!


🧱 核心算法及代码讲解

🔁 什么是单调栈?

单调栈是一种特殊的栈结构,其中元素按照单调递增或单调递减的顺序排列。在本题中,我们使用单调递增栈(从栈底到栈顶,对应的高度严格递增)。

关键性质:当新元素入栈时,若破坏了单调性(即当前高度 ≤ 栈顶高度),则不断弹出栈顶,直到满足单调性为止。每次弹出时,可以确定被弹出元素的右边界!

🎯 为什么单调栈适用于本题?

  • 对于每个柱子 i,我们希望知道:

    • left[i] :i 左边第一个高度 < heights[i] 的位置;
    • right[i] :i 右边第一个高度 < heights[i] 的位置。
  • 单调栈可以在一次遍历中高效维护这些信息。

📜 算法步骤(优化版:单次遍历)

  1. 初始化 left 数组(记录左边界),right 数组(初始化为 n,即右哨兵);

  2. 使用一个栈 mono_stack 存储索引;

  3. 从左到右遍历 heights

    • 若栈非空且 heights[栈顶] >= heights[i],说明栈顶元素的右边界就是 i,将其弹出并设置 right[栈顶] = i
    • 此时栈顶(若存在)就是 i 的左边界;
    • i 入栈;
  4. 遍历结束后,未弹出的元素右边界仍为 n(已在初始化时设定);

  5. 最后遍历每个 i,计算 (right[i] - left[i] - 1) * heights[i],取最大值。

💻 C++ 核心代码(带详细行注释)

vector<int> left(n), right(n, n);  // right 初始化为 n(右哨兵)
stack<int> mono_stack;

for (int i = 0; i < n; ++i) {
    // 当前高度 <= 栈顶高度 → 栈顶的右边界就是 i
    while (!mono_stack.empty() && heights[mono_stack.top()] >= heights[i]) {
        right[mono_stack.top()] = i;  // 设置右边界
        mono_stack.pop();             // 弹出栈顶
    }
    // 栈空?说明左边没有更小的 → 左边界为 -1(左哨兵)
    // 否则,栈顶就是左边第一个更小的位置
    left[i] = (mono_stack.empty() ? -1 : mono_stack.top());
    mono_stack.push(i);  // 当前索引入栈
}

⚠️ 注意:这里使用 >= 而不是 > 是为了处理高度相等的情况。虽然会导致某些柱子的右边界“提前”,但最右边的那个同高柱子仍能正确计算出最大面积,因此不影响最终结果。


🧩 解题思路(分步详解)

Step 1️⃣:理解“以每个柱子为高”的思想

  • 枚举每个柱子作为矩形的高;
  • 向左右扩展,直到遇到比它矮的柱子(不能继续扩展);
  • 宽度 = 右边界 - 左边界 - 1;
  • 面积 = 高度 × 宽度。

Step 2️⃣:暴力法为何不行?

  • 双重循环找左右边界 → O(n²);
  • n = 1e5 时,操作次数 ≈ 1e10,必然超时。

Step 3️⃣:引入单调栈优化

  • 利用栈的 LIFO 特性和单调性,在 O(1) 均摊时间内找到每个元素的左右边界;
  • 总体时间复杂度降为 O(n)

Step 4️⃣:哨兵技巧简化边界处理

  • 左哨兵:位置 -1,高度视为 -∞;
  • 右哨兵:位置 n,高度视为 -∞;
  • 避免额外判断边界条件。

Step 5️⃣:单次遍历 vs 两次遍历

  • 方法一(官方):两次遍历分别求 left 和 right;
  • 方法二(优化):一次遍历同时确定 left 和 right(出栈时确定右边界);
  • 推荐使用方法二,代码更简洁,效率更高。

📊 算法分析

项目分析
时间复杂度O(n) :每个元素入栈和出栈各一次,均摊 O(1)
空间复杂度O(n) :需要 left、right 数组和栈,均为 O(n)
是否原地❌ 需要额外空间
面试高频点✅ 单调栈模板、哨兵思想、边界处理、面积计算逻辑

💡 面试加分项

  • 能清晰解释“为什么用 >= 而不是 >”;
  • 能手写单调栈模板;
  • 能对比暴力法与优化法的复杂度差异;
  • 能举一反三(如接雨水、最大矩形等类似题)。

💻 代码

✅ C++ 完整代码

#include <bits/stdc++.h>
using namespace std;
using ll = long long;

class Solution {
public:
    int largestRectangleArea(vector<int>& heights) {
        int n = heights.size();
        vector<int> left(n), right(n, n); // right 默认为 n(右哨兵)
        
        stack<int> mono_stack;
        for (int i = 0; i < n; ++i) {
            // 弹出所有高度 >= 当前高度的柱子,它们的右边界就是 i
            while (!mono_stack.empty() && heights[mono_stack.top()] >= heights[i]) {
                right[mono_stack.top()] = i;
                mono_stack.pop();
            }
            // 栈空则左边界为 -1,否则为栈顶
            left[i] = (mono_stack.empty() ? -1 : mono_stack.top());
            mono_stack.push(i);
        }
        
        int ans = 0;
        for (int i = 0; i < n; ++i) {
            // 宽度 = right[i] - left[i] - 1
            ans = max(ans, (right[i] - left[i] - 1) * heights[i]);
        }
        return ans;
    }
};

// 测试
signed main(){
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    cout.tie(nullptr);
    
    Solution sol;
    vector<int> h1 = {2,1,5,6,2,3};
    cout << sol.largestRectangleArea(h1) << "\n"; // 输出: 10
    
    vector<int> h2 = {2,4};
    cout << sol.largestRectangleArea(h2) << "\n"; // 输出: 4
    
    return 0;
}

✅ JavaScript 完整代码

/**
 * @param {number[]} heights
 * @return {number}
 */
var largestRectangleArea = function(heights) {
    const n = heights.length;
    const left = new Array(n);
    const right = new Array(n).fill(n); // 右哨兵为 n
    
    const stack = [];
    
    for (let i = 0; i < n; i++) {
        // 弹出所有高度 >= 当前高度的索引
        while (stack.length > 0 && heights[stack[stack.length - 1]] >= heights[i]) {
            const idx = stack.pop();
            right[idx] = i;
        }
        // 左边界:栈空则为 -1,否则为栈顶
        left[i] = stack.length === 0 ? -1 : stack[stack.length - 1];
        stack.push(i);
    }
    
    let ans = 0;
    for (let i = 0; i < n; i++) {
        const width = right[i] - left[i] - 1;
        ans = Math.max(ans, width * heights[i]);
    }
    return ans;
};

// 测试
console.log(largestRectangleArea([2,1,5,6,2,3])); // 10
console.log(largestRectangleArea([2,4]));         // 4

🌟 本期完结,下期见!🔥

👉 点赞收藏加关注,新文更新不迷路。关注专栏【算法】LeetCode Hot100刷题日记,持续为你拆解每一道热题的底层逻辑与面试技巧!

💬 欢迎留言交流你的解法或疑问!一起进步,冲向 Offer!💪

📌 记住:当你在刷题时,不要只看答案,要像写这篇文章一样,深入思考每一步背后的原理、优化空间和面试价值。这才是真正提升算法能力的方式!