题目介绍
力扣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)。