柱状图中最大的矩形

题目介绍

力扣84题:https://leetcode-cn.com/problems/largest-rectangle-in-histogram/
给定 n 个非负整数,用来表示柱状图中各个柱子的高度。每个柱子彼此相邻,且宽度为 1 。

求在该柱状图中,能够勾勒出来的矩形的最大面积。
在这里插入图片描述
在这里插入图片描述

分析

题目要求计算最大矩形面积,我们可以发现,关键其实就在于确定矩形的“宽”和“高”(即矩形面积计算中的长和宽)。

而宽和高两者间又有制约条件:一定宽度范围内的高,就是最矮那个柱子的高度。

方法一:暴力法

一个简单的思路,就是遍历所有可能的宽度。也就是说,以每个柱子都作为矩形的左右边界进行计算,取出所有面接中最大的那个。

代码如下:

// 方法一:暴力法(遍历所有可能的宽度)
public int largestRectangleArea1(int[] heights){
    // 定义变量保存最大面积
    int largestArea = 0;

    // 遍历数组,作为矩形左边界
    for (int left = 0; left < heights.length; left ++){
        // 定义变量保存当前矩形高度
        int currHeight = heights[left];

        // 遍历数组,选取矩形右边界
        for (int right = left; right < heights.length; right ++){
            // 确定当前矩形的高度
            currHeight = (heights[right] < currHeight) ? heights[right] : currHeight;

            // 计算当前矩形面积
            int currArea = (right - left + 1) * currHeight;

            // 更新最大面积
            largestArea = (currArea > largestArea) ? currArea : largestArea;
        }
    }

    return largestArea;
}

复杂度分析

  • 时间复杂度:O(N^2)。很明显,代码中用到了双重循环,需要耗费平方时间复杂度来做遍历计算。这个复杂度显然是比较高的。
  • 空间复杂度:O(1)。只用到了一些辅助变量。

方法二:双指针

我们可以首先遍历数组,以当前柱子的高度,作为考察的矩阵“可行高度”。然后定义左右两个指针,以当前柱子为中心向两侧探寻,找到当前高度的左右边界。

左右边界的判断标准,就是出现了比当前高度矮的柱子,或者到达了数组边界。

代码实现如下:

// 方法二:双指针法(遍历所有可能的高度)
public int largestRectangleArea2(int[] heights){
    // 定义变量保存最大面积
    int largestArea = 0;

    // 遍历数组,以每个柱子高度作为最终矩形的高度
    for (int i = 0; i < heights.length; i++){
        // 保存当前高度
        int height = heights[i];

        // 定义左右指针
        int left = i, right = i;

        // 寻找左边界,左指针左移
        while (left >= 0){
            if (heights[left] < height) break;
            left --;
        }
        // 寻找右边界,右指针右移
        while (right < heights.length){
            if (heights[right] < height) break;
            right ++;
        }

        // 计算当前宽度
        int width = right - left - 1;

        // 计算面积
        int currArea = height * width;
        largestArea = (currArea > largestArea) ? currArea : largestArea;
    }

    return largestArea;
}

复杂度分析

  • 时间复杂度:O(N2)。尽管少了一重循环,但在内部依然要去暴力寻找左右边界,这个操作最好情况下时间复杂度为O(1),最坏情况下为O(N),平均为O(N)。所以整体的平均时间复杂度仍然是O(N2)。
  • 空间复杂度:O(1)。只用到了一些辅助变量。

方法三:双指针优化

在双指针法寻找左右边界的过程中我们发现,如果当前柱子比前一个柱子高,那么它的左边界就是前一个柱子;如果比前一个柱子矮,那么可以跳过之前确定更高的那些柱子,直接从前一个柱子的左边界开始遍历。

这就需要我们记录下每一个柱子对应的左边界,这可以单独用一个数组来保存。
代码演示如下:

// 方法三:双指针法改进
public int largestRectangleArea3(int[] heights){
    // 定义变量保存最大面积
    int largestArea = 0;

    // 定义两个数组,保存每个柱子对应的左右边界
    int n = heights.length;
    int[] lefts = new int[n];
    int[] rights = new int[n];

    // 遍历数组,计算左边界
    for (int i = 0; i < n; i++) {
        // 保存当前高度
        int height = heights[i];

        // 定义左指针
        int left = i - 1;

        // 左指针左移,寻找左边界
        while (left >= 0){
            if (heights[left] < height) break;
            left = lefts[left];    // 如果左边柱子更高,就直接跳到它的左边界柱子再判断
        }

        lefts[i] = left;
    }

    // 遍历数组,计算右边界
    for (int i = n - 1; i >= 0; i--) {
        // 保存当前高度
        int height = heights[i];

        // 定义右指针
        int right = i + 1;

        // 右指针右移,寻找右边界
        while (right < n){
            if (heights[right] < height) break;
            right = rights[right];    // 如果右边柱子更高,就直接跳到它的右边界柱子再判断
        }

        rights[i] = right;
    }

    // 遍历所有柱子,计算面积
    for (int i = 0; i < n; i++){
        int currArea = (rights[i] - lefts[i] - 1) * heights[i];
        largestArea = (currArea > largestArea) ? currArea : largestArea;
    }

    return largestArea;
}

复杂度分析

  • 时间复杂度:O(N)。我们发现,while循环内的判断比对总体数量其实是有限的。每次比对,或者是遍历到一个新元素的时候,或者是之前判断发现当前柱子较矮,需要继续和前一个柱子的左边界进行比较。所以总的时间复杂度是O(N)。
  • 空间复杂度:O(N)。用到了长度为n的数组来保存左右边界。

方法四:使用单调栈

从上面的算法中我们可以发现,“找左边界”最重要的,其实就是排除左侧不可能的那些元素,跳过它们不再遍历。所以我们可以考虑用这样一个数据结构,来保存当前的所有“候选左边界”。

当遍历到一个高度时,就让它和“候选列表”中的高度比较:如果发现它比之前的候选大,可以直接追加在后面;而如果比之前的候选小,就应该删除之前更大的候选。最终,保持一个按照顺序、单调递增的候选序列。过程中应该按照顺序,先比对最新的候选、再比对较老的候选。显然,我们可以用一个栈来实现这样的功能。栈中存放的元素具有单调性,这就是经典的数据结构单调栈了。

我们用一个具体的例子 [6,7,5,2,4,5,9,3] 来理解单调栈。我们需要求出每一根柱子的左侧且最近的小于其高度的柱子。初始时的栈为空。
(1)我们枚举 6,因为栈为空,所以 6 左侧的柱子是“哨兵”,位置为 -1。随后我们将 6 入栈。
栈:[6(0)]。(这里括号内的数字表示柱子在原数组中的位置索引)
(2)我们枚举 7,由于 6<7,因此不会移除栈顶元素,所以 7 左侧的柱子是 6,位置为 0。随后我们将 7 入栈。
栈:[6(0), 7(1)]
(3)我们枚举 5,由于 7≥5,因此移除栈顶元素 7。同样地, 6≥5,再移除栈顶元素 6。此时栈为空,所以 5 左侧的柱子是「哨兵」,位置为−1。随后我们将 5 入栈。
栈:[5(2)]
(4)接下来的枚举过程也大同小异。我们枚举 2,移除栈顶元素 5,得到 2 左侧的柱子是「哨兵」,位置为 −1。将 2 入栈。
栈:[2(3)]
(5)我们枚举 4,5 和 9,都不会移除任何栈顶元素,得到它们左侧的柱子分别是2,4 和 5,位置分别为 3,4 和 5。将它们入栈。
栈:[2(3), 4(4), 5(5), 9(6)]
(6)我们枚举 3,依次移除栈顶元素 9,5 和 4,得到 3 左侧的柱子是 2,位置为 3。将 3 入栈。
栈:[2(3), 3(7)]

这样一来,我们得到它们左侧的柱子编号分别为 [−1,0,−1,−1,3,4,5,3]。用相同的方法,我们从右向左进行遍历,也可以得到它们右侧的柱子编号分别为 [2,2,3,8,7,7,7,8],这里我们将位置 8 看作右侧的“哨兵”。在得到了左右两侧的柱子之后,我们就可以计算出每根柱子对应的左右边界,并求出答案了。

代码如下:

// 方法四:单调栈
 public int largestRectangleArea4(int[] heights){
     // 定义变量保存最大面积
     int largestArea = 0;

     // 定义两个数组,保存每个柱子对应的左右边界
     int n = heights.length;
     int[] lefts = new int[n];
     int[] rights = new int[n];

     // 定义一个栈
     Stack<Integer> stack = new Stack<>();

     // 遍历所有柱子,作为当前高度,先找左边界
     for (int i = 0; i < n ; i ++){
         while ( !stack.isEmpty() && heights[stack.peek()] >= heights[i] ){
             stack.pop();
         }

         // 所有大于等于当前高度的元素全部弹出,找到了左边界
         lefts[i] = stack.isEmpty() ? -1 : stack.peek();

         stack.push(i);
     }

     stack.clear();

     // 遍历所有柱子,作为当前高度,寻找右边界
     for (int i = n - 1; i >= 0; i --){
         while ( !stack.isEmpty() && heights[stack.peek()] >= heights[i] ){
             stack.pop();
         }

         // 所有大于等于当前高度的元素全部弹出,找到了左边界
         rights[i] = stack.isEmpty() ? n : stack.peek();

         stack.push(i);
     }

     // 遍历所有柱子,计算面积
     for (int i = 0; i < n; i++){
         int currArea = (rights[i] - lefts[i] - 1) * heights[i];
         largestArea = (currArea > largestArea) ? currArea : largestArea;
     }

     return largestArea;
 }

复杂度分析

  • 时间复杂度:O(N)。每一个位置元素只会入栈一次(在枚举到它时),并且最多出栈一次。因此当我们从左向右/从右向左遍历数组时,对栈的操作的次数就为O(N)。所以单调栈的总时间复杂度为 O(N)。
  • 空间复杂度:O(N)。用到了单调栈,大小为O(N)。

方法五:单调栈优化

当一个柱子高度比栈顶元素小时,我们会弹出栈顶元素,这就说明当前柱子就是栈顶元素对应柱子的右边界。所以我们可以只遍历一次,就求出答案。

代码如下:

// 方法五:单调栈优化
 public int largestRectangleArea(int[] heights){
     // 定义变量保存最大面积
     int largestArea = 0;

     // 定义两个数组,保存每个柱子对应的左右边界
     int n = heights.length;
     int[] lefts = new int[n];
     int[] rights = new int[n];

     // 初始化rights为右哨兵n
     for (int i = 0; i < n; i ++) rights[i] = n;

     // 定义一个栈
     Stack<Integer> stack = new Stack<>();

     // 遍历所有柱子,作为当前高度,先找左边界
     for (int i = 0; i < n ; i ++){
         while ( !stack.isEmpty() && heights[stack.peek()] >= heights[i] ){
             // 栈顶元素如果小于当前元素,那么它的右边界就是当前元素
             rights[stack.peek()] = i;
             stack.pop();
         }

         // 所有大于等于当前高度的元素全部弹出,找到了左边界
         lefts[i] = stack.isEmpty() ? -1 : stack.peek();

         stack.push(i);
     }

     // 遍历所有柱子,计算面积
     for (int i = 0; i < n; i++){
         int currArea = (rights[i] - lefts[i] - 1) * heights[i];
         largestArea = (currArea > largestArea) ? currArea : largestArea;
     }

     return largestArea;
 }

复杂度分析

  • 时间复杂度:O(N)。只有一次遍历,同样每个位置入栈一次、最多出栈一次。
  • 空间复杂度:O(N)。用到了单调栈,大小为O(N)。
  • 0
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值