leetcode之Largest Rectangle in Histogram

时间:2023-03-09 08:48:36
leetcode之Largest Rectangle in Histogram

问题来源:Largest Rectangle in Histogram

问题描述:给定一个长度为n的直方图,我们可以在直方图高低不同的长方形之间画一个更大的长方形,求该长方形的最大面积。例如,给定下述直方图,

leetcode之Largest Rectangle in Histogram

我们可以以高度5宽度2画一个更大的长方形,如下图,该长方形即是面积最大的长方形。该问题是难度比较大的问题,但是很出名,经常作为面试题出现。最近陈利人老师给出该问题的一个O(n)解法,非常巧妙,并从二维三维角度对问题进行了扩展。我们在陈老师的基础上,对该问题进行深入分析,给出多种方法,拓展大家的视野。

leetcode之Largest Rectangle in Histogram

1. 解法一

如果在面试的过程中被问到这个题目,除非之前见过,否则一时很难想到解法。我们不妨从最笨的解法入手。在我看来,能把最笨的解法写出来也是很不错的,毕竟很多人见到这种题一下就蒙了。

最笨的解法是什么呢,就是遍历所有的起始和结束位置,然后计算该区域内长方形的面积,找到最大的。具体实现过程中,从第0个位置开始遍历起点,选定起点之后,从起点到末尾都可以当做终点,所以总共有O(n2)种可能。在固定了起点之后,后续对终点的遍历有一个特点:构成长方形的高度都不高于之前所构造长方形的高度,所以长方形的高度即是到当前终点为止的最小值,宽度即是终点位置减去起点位置加1。按照这个思路实现的C++代码如下:

class Solution {
public:
int largestRectangleArea(vector<int>& heights) {
int len=heights.size();
int max_size=0;
for(int i=0;i<len;i++)
{
int min_height=heights[i];
int current_size=min_height;
for(int j=i;j<len;j++)
{
if(heights[j]<min_height) min_height=heights[j];
current_size=min_height*(j-i+1);
if(current_size>max_size) max_size=current_size;
}
} return max_size;
}
};

上述代码可以通过几乎所有的测试用例,但是当测试用例为0到19999的连续整数时会超时。说明O(n2)的复杂度还是过高,需要进一步优化。下面我们看一下陈利人老师的解法,原文在《很神奇的解法!怎么求柱状图中的最大矩形?》。

新解法的核心在于考虑了直方图两个相邻长方形AB之间的关系。如果前一个长方形A低后一个长方形B高,则A肯定不会是某个大长方形的终点,因为我们可以安全地在A后面添加更高的B,使大长方形的宽度加1。如果A高B低,则A是可能的终点,假设我们就用A当做终点,并且以该长方形的高度当做大长方形的高度,看看可以往前延伸多长。根据上面这两条性质,我们可以维护一个递增序列(实际为非递减,当前后两个长方形的高度一样时,前一个长方形同样也不可能是终点,在此为了解释方便假定前后高度都不一样),当B高时就将B的位置添加到序列中,否则就弹出A的位置,并用A的位置作为终点,A的高度作为大长方形的高度计算面积。起点怎么确定呢,由于我们维护的是一个递增序列,在弹出A之后,序列中A的前一个位置所对应的长方形高度肯定低于A的高度,所以A的前一个长方形的位置加1即是大长方形的起点。因为我们每次都是对序列的末尾进行操作,所以可以用一个栈来维护此递增序列。大家可以通过下图仔细体会上面的分析。如果还是不理解,可以阅读上面提到的原文。

leetcode之Largest Rectangle in Histogram

陈老师的博客中给出的是python代码,我们将其改写成C++代码:

class Solution {
public:
int largestRectangleArea(vector<int>& heights) {
heights.push_back(-1);
int max_size=0;
int index=0;
stack<int> s; while(index<heights.size())
{
if(s.size()==0||heights[s.top()]<=heights[index])
{
s.push(index);
index++;
}
else
{
int top=s.top();
s.pop();
int size=0; if(s.size()==0)
{
size=heights[top]*index;
}
else
{
size=heights[top]*(index-s.top()-1);
} if(size>max_size) max_size=size;
}
} return max_size;
}
};

上述代码的核心就是判断前后两个长方形的高度,后一个高就添加到堆栈中,否则就弹出计算面积。该代码提交到leetcode上运行时间是24ms。上述代码用了STL中的stack,我们也可以用数组代替,此时代码可以修改如下:

class Solution {
public:
int largestRectangleArea(vector<int>& heights) {
heights.push_back(-1);
int max_size=0;
int index=0;
int *s=(int*)malloc(sizeof(int)*heights.size());
int stack_index=0; while(index<heights.size())
{
if(stack_index==0||heights[s[stack_index-1]]<=heights[index])
{
s[stack_index++]=index;
index++;
}
else
{
int top=s[--stack_index];
int size=0; if(stack_index==0)
{
size=heights[top]*index;
}
else
{
size=heights[top]*(index-s[stack_index-1]-1);
} if(size>max_size) max_size=size;
}
} free(s); return max_size;
}
};

由于少了系统调用,上述代码运行时间降为16ms。需要注意的一点是,stack_index指向的是堆栈头的上一个位置。

上面的两个代码虽然都能正确运行,但是有一个坏处,破坏了原始的输入数组。为什么要向原数组中添加一个-1呢?这个也比较容易理解,假如原始数组是递增的,我们不可能只添加不弹出,添加一个-1就可以弹出所有的元素。此处也可以对代码进行修改,避免破坏原始数组。方法就是遍历完所有的元素之后,判断堆栈是否为空,不为空就弹出并计算面积,比较简单,请大家自己实现。

2. 解法二

不知道大家对最长回文子串的几种解法是否了解。如果不考虑最优解法,最长回文子串问题可以有两种不同的思路:1. 确定头和尾,判断该子串是否为回文串;2. 指定回文串的中点,看能往两侧延伸多长。在最大矩形问题中,上面的解法一和回文子串的思路一相似,同理,我们也可以仿照思路二来解决最大矩形问题。我们可以将直方图的任意一个长方形当做中点,然后以该长方形的高度当做大长方形的高度,看可以往两侧延伸多长。这种思路其实更符合大家的思维方式。很明显,这种方法的复杂度也是O(n2),提交代码还是超时,我们对其进行优化。

leetcode之Largest Rectangle in Histogram

2.1 基于堆栈的解法

上面原始解法慢的原因也是没有考虑直方图相邻长方形之间的关系,我们分下面两种情况考虑,看是否有优化余地。当出现A这种情形时,其实我们可以获得一些有用的信息,这表明第i个长方形不能再往左扩展(以第i个长方形的高度往两侧扩展),进而我们可以求得left[i](在此left有两种不同的含义,既可以为向左扩展到的位置,也可以为向左扩展的长度,后续代码实现按第一种理解方式)。当出现B情形时,表明第i-1个长方形高,第i个长方形可以继续往左扩展,直到遇到A情形,然后计算left[i]。在实际情况中,由于A情形和B情形随机出现,所以前后两个长方形的位置并不一定相邻。采用数学归纳法我们可以求得所有的left值。可以看出,A情形只往末尾添加元素,B情形只在末尾弹出元素,从而我们可以用一个栈来维护单调递增(实际为非递减)队列。

leetcode之Largest Rectangle in Histogram

同理,我们可以通过倒序遍历数组来计算right。按照上述思路实现的代码如下:

class Solution {
public:
int largestRectangleArea(vector<int>& heights) {
int n=heights.size();
int* stack=new int[n];
int* right=new int[n];
int* left=new int[n]; int s=0;
for(int i=0;i<n;i++)
{
while(s>0&&heights[stack[s-1]]>=heights[i]) s--;
left[i]=(s==0?0:stack[s-1]+1);
stack[s++]=i;
} s=0;
for(int i=n-1;i>=0;i--)
{
while(s>0&&heights[stack[s-1]]>=heights[i]) s--;
right[i]=(s==0?n:stack[s-1]);
stack[s++]=i;
} int size=0,max_size=0; for (int i=0;i<n;i++)
{
size=(right[i]-left[i])*heights[i];
if(size>max_size)max_size=size;
} delete[] stack;
delete[] right;
delete[] left; return max_size;
}
};

上面代码看似是两重循环,但其实每个元素只进入堆栈一次,通过均摊分析可以得到复杂度为O(n)。提交到leetcode上运行时间为16ms。

2.2 基于单调队列的解法

除了上面巧妙的堆栈解法外,还可以用单调队列来解决。单调队列维护数列的下标,队列内的元素满足:

设单调队列从头部开始的元素值为xi,则xi<xi+1且axi<axi+1。

简单来说单调队列就是下标对应的元素是严格递增的顺序。当然在实际应用过程中,可能不严格单调。

在该问题中,该怎样应用单调队列呢?我们还是分两种情形考虑。考虑方法正好和基于堆栈的方法相反。假设我们维护了递增的单调队列(实际为非递减),当出现B情形时,我们其实可以知道第i-1个长方形不能再往右扩展,从而可以求得right[i-1];当出现A情形时,第i-1个长方形还可以继续向右扩展。只要高度递增,就往单调队列末尾添加元素,否则就计算单调队列末尾元素的right值。当我们遍历完所有的元素之后,单调队列中存在着一个递增的序列,表示剩余位置可以从队列头扩展到队列尾。依次计算扩展长度。

leetcode之Largest Rectangle in Histogram

同理,我们可以通过倒序遍历数组来计算left。按照上述思路实现的代码如下:

class Solution {
public:
int largestRectangleArea(vector<int>& heights) {
int* deq=new int[heights.size()];
int* right=new int[heights.size()];
int* left=new int[heights.size()];
int n=heights.size(); int s=0,t=0;
for(int i=0;i<n;i++)
{
while(s<t&&heights[deq[t-1]]>heights[i])
{
right[deq[t-1]]=i;
t--;
}
deq[t++]=i;
} while (s<t)
{
right[deq[s]]=deq[t-1]+1;
s++;
} s=0;
t=0;
for (int i=n-1;i>=0;i--)
{
while (s<t&&heights[deq[t-1]]>heights[i])
{
left[deq[t-1]]=i+1;
t--;
}
deq[t++]=i;
} while (s<t)
{
left[deq[s]]=deq[t-1];
s++;
} int size=0,max_size=0;
for (int i=0;i<n;i++)
{
size=(right[i]-left[i])*heights[i];
if(size>max_size)max_size=size;
} delete[] deq;
delete[] right;
delete[] left; return max_size;
}
};

代码的整体逻辑和基于堆栈的实现类似,只是由首先计算left变为首先求right。复杂度也是O(n),提交到leetcode上运行时间也是16ms。

3. 扩展

该问题非常有趣,可以做很多扩展。例如将长方形的宽度由1变为任意宽度,再或者将二维直方图变为三维直方图求最大长方体。针对这两个扩展的解法,大家可以参考陈利人老师“待字闺中”微信公众号中的分析。先给出两个原始链接:《举一反三,从一道面试题说起》和《快看,快看!求3D柱状图中的最大长方体有解了》。

针对第一个扩展,可以很容易采用上面的解法二实现。首先遍历一遍所有的长方形,累积长方形的宽度得到每个长方形在不同宽度下的起始位置。然后继续采用解法二中任意一种实现获得每个长方形往两侧的扩展位置。最后利用计算的起始位置和扩展位置即可计算最大面积,复杂度还是O(n)。