链接: https://leetcode.com/problems/maximal-rectangle/
【描述】
Given a 2D binary matrix filled with '0's and '1's, find the largest rectangle containing all ones and return its area.
【中文描述】
给一个二维数组, 算出里面最大的全1矩形面积,比如:
[
['1','1','1','0'],
['1','1','1','1']
]
显然,最大矩形面积是6, 就是图中粗体1组成的矩形。
————————————————————————————————————————————————————————————
【初始思路】
刚拿到题的时候毫无头绪, 想了半天想了个O(n3)的算法,遍历每一行,从每一行开始和后面的行进行'&'操作,每一步把与的结果和层数相乘得到面积,更新到max中, 最后返回max。
代码我根本没写,这个题肯定没这么蠢,写出来肯定也是TLE。
【Discuss】
最后无奈,去discuss看了下大家的做法,竟然看到了DP做法!当时惊呆了。根据最近学习DP的成果来看,DP用来计算那些求方案总数,求能不能等只需要最终结果的题非常适用。最大最小也可以用DP,但是我怎么都没想到要用DP去做这个题!毕竟这个题的DP递推函数太难构造了,所以就放弃了。后来看了大神的解法,顿时把膝盖跪烂了!!!也不得不承认,人和人之间智力的差距太大了!
这里先介绍下DP算法。
【解法一: DP】
这个题的DP函数确实很复杂,很抽象,也太难构造出来了。如果面试时候在毫无准备的时候想到这个函数的构造方法的,基本可以当场hire你了!
我来介绍一下大神算法的核心思想:
(1)以行为单位来看
(2)每一行计算当前情况以及之前行的积累情况, 根据两者的比较计算, 将计算结果存储在中间结果数组当前行里, 供下一行用,这确实是DP思想。
(3)每一行实时计算走到当前行,最大矩形的面积。
上面的描述太笼统,(1)(3)好理解, 对于DP来讲,这是肯定的。(2)的理解是本解法的精髓和核心。(2)理解了,这个算法其实非常简单!
那么,如何来做所谓的当前行的计算呢?
首先,可以确定的是,对于每一行来说, 肯定还是得根据不同的列来分别计算对待, 我们用 i 标记行,用 j 标记列。
那么DP函数到底怎么构造?二维数组?我们来分析下二维数组到底行不行。
我们定义dp[i][j]表示从[0][0]到[i][j]位置这个范围内最大的矩阵面积。看似合理,我们看下面这个例子:
左边是matrix, 右边是dp数组,那么"?"位置(dp[1][4])应该怎么填?左边是4,同时matrix[i][j-1]为1, 所以这里可以填5。 没错。 但是很显然,它可以组成一个面积为6的矩形,可是我们不知道从哪里算出这个6,因为上面、左面的数字并不能代表唯一的含义。 比如上面右图里的4, 到底代表的是同一行4个1组成的矩形?还是上下两行共同组成的矩形?
所以这样的dp函数是肯定不行的!可是除此之外我们也想不到其他的dp函数了,那就试试一维吧!
一维的dp[],每一位只能代表一个唯一的含义。仔细观察matrix矩阵,其实所谓的矩形还不是靠不同的列组成的,恰好这些列都是1,那么好,我们假设一个height[]函数, 每一位记录各个位有多少个1。然后我们实时根据height累积高度不就知道各个列的高度了么?面积就可以根据这个height算出来了,比如上面的matrix, 第二列计算完后, height = {2,1,2,2,2}, 那么最大面积根据LeetCode84算法,很容易求得6。
看似正确,我们用下面例子来看看:
按照上面算法, height此时就是1,2,3,2,1,根据leetCode84题做法, 最大面积是6。 显然是错的!
原因在于, 对于leetcode84题, 算直方图面积的时候,其实是在一维线性空间内计算的。而对于矩阵内求矩形面积, 变成了二维空间内计算。 不同行内1的长度相同的情况下,他们的起始终止位置可能是不同的, 那么面积就不能简单的用直方图的算法去计算。
所以, 大神算法在引入上面height函数的时候, 还加入了两个函数, left[j]、right[j], 用来标记在 j 列的位置如果是1的情况下, 它的左边界位置和右边界位置。
左边界的定义: 从0到该j位置, 第一个1的位置。
右边界的定义: 从j 到该行末尾,第一个0的位置
同时,对每一行给两个变量: leftBound(总体左边界) = 0、rightBound(总体右边界) = 总列数+1 (因为初始状态,rightBound应该在数组边界外)
比如, 上面矩阵第一行: 1 1 1 0 0, 对于第一列(其实是0列), 由于它是1, 所以它的左边界left[j] = max(总体左边界, left[j]) (*解释在下面), 对于第二列,也是总体左边界, 第三列还是总体左边界. 第四列的时候, matrix[0][3] == 0, 我们知道当前位是0,但是后一位是不是0不知道,我们姑且更新总体leftBound为j+1,因为很有可能下一个就是1, 那么总体左边界就是j + 1。然后j++之后, 到第5列, matrix[0][4] == 0, 总体左边界继续更新为j+1.
而对于第一行的右边界数组, 应该从右往左计算。最右边第5列为0, 根据定义,总体右边界rightBound可能为j(因为左边很有可能就是1了, 那么当前就是左边界, 所以右边界更新为j), 所以总体右边界更新为j。第4列,还是0, 那么总体右边界更新为j。第3列为1, 那么right[j] = min(rightBound, right[j])(*解释在下面)。第2列为1, right[j] = rightBound, 第一列同样, right[j] = rightBound。
同样再对第二行做如此计算。
对于left要取当前left[j]和leftBound中大值的解释:
我们可以把left[j]这个函数加深理解为, 从过往所有行到当前行, 在 j 这个位置,遇到的最晚的一个1在哪里, 那么当前left就应该更新到哪里。 因为left函数记录了过往所有行的左边界情况,所以,考虑左边界的时候不仅仅要考虑当前行的情况,还要根据过往所有行的情况(保存在left[j]里), 选择里面最大的一个(也就是最靠后的一个作为左边界)。 因为如果之前的左边界比现在的小,由于当前行左边界更靠后,那么在计算面积的时候也不能按照之前的左边界计算,只能按照当前左边界计算。如下图:
在对第二行进行处理的时候, j = 3的时候, left[j] = 1, 而当前的leftBound = 3。这个时候, left[j]应该更新为3,参与最后的计算。同理,看下图:
还是对第二行处理的时候, j =3, left[j] = 5, 而当前的leftBound = 3。 这个时候, left[j]应该更新为两者中大的,也就是更新成5, 因为当前行做计算的时候, 矩形左边界也只能从5开始算, 不能从3开始算。
同理, 右边界取两者中最小值也可以这么分析得出!
可能大家要问,根据这个怎么算面积?
我们看第一行左右边界全部更新完后的左右边界数组:
left: [0,0,0,0,0]
right: [3,3,3,5,5]
再结合height数组, height: [1,1,1,0,0].
面积 = height[j] * (right[j] - left[j]).
最大矩形面积就是:[3, 3, 3, 0, 0], 取其中最大值出来,更新到max里, 所以第一行执行完后, 最大面积为3。我们看和图上是相符的。
第二行更新完后的左右数组为:
left: [0, 1, 1, 1, 0]
right: [0, 3, 3, 4, 5]
height: [1, 2, 2, 1, 0]
最大矩形面积:[0, 4, 4, 3, 0], 最大矩形面积是4, 符合图上情况。
我们再看下面这个例子:
按照上面算法,第二行处理完后:
left: [0, 0, 0, 3, 3, 0, 6, 6, 6, 6]
right: [10,10,10, 5, 5,10,10,10,10,10]
height:[0, 1, 1, 1, 1, 0, 2, 2, 2, 2]
那么, 根据上面公式算出来, 矩形面积:[0, 10, 10, 2, 2, 0, 8, 8, 8, 8]。 最大矩形面积是10, 这显然不对啊!
问题出在哪里?
left,right我们已经分析过了, 计算方法肯定没问题。那么问题只能出在height上。
问题出在了,当前行 j 为0的时候, 我们把上面的height[j]继承到了这一行上。 为什么不能继承?
因为之前行已经全部计算过了,之前 j 位不为0, 而当前行 j 位为0的时候, 说明之前的矩形计算已经彻底结束了, 不应该再继承到这一行来。
所以height的更新机制应该修改为: 当matrix[i][j]==1时, height[j]++. 当matrix[i][j] == 0时, height[j] = 0. 这样就可以避免上面的结果影响到这一行的计算上来。
最后,我们来看看代码。
【Show me the Code!!!】
public static int maximalRectangleDP(char[][] matrix) {
if (matrix == null || matrix.length == 0) return 0;
int ROW = matrix.length;
int COL = matrix[0].length;
int[] left = new int[COL];
int[] right = new int[COL];
int[] height = new int[COL];
/**
* 初始化
*/
Arrays.fill(left, 0);
Arrays.fill(right, COL);
Arrays.fill(height, 0); int max = 0;//最大面积 /**
* 对每一行进行计算, 递推公式如下:
* 每一行开始时,左边界定为0, 右边界定为COL
* height[j]好算:
* 如果matrix[i][j] = 0, height[j]不变
* 如果matrix[i][j] = 1, height[j]++;
* left[j]从左往右算:
* 如果matrix[i][j] = 0, left[j]=0, 同时左边界变为当前j+1(因为潜在的左边界可能就在j+1)
* 如果matrix[i][j] = 1, left[j]= max(left[j], 左边界), 哪个大取哪个.
* (解释: 因为我们要的是过往所有行中0到该列位置最晚遇到1的位置)
* right[j]从右往左算:
* 如果matrix[i][j] = 0, right[j]=0, 同时右边界变为当前j(因为潜在的右边界就在当前j位置)
* 如果matrix[i][j] = 1, right[j]= min(right[j], 右边界), 哪个小取哪个.
* (解释: 因为我们要的是过往所有行中COL-1到该列位置最早遇到0的位置)
*/
for (int i = 0; i < ROW; i++) {
int leftBound = 0;
int rightBound = COL;//如果本行全为1, 那么从右往左第一个0应该在COL处, 这是个想象的位置, 只是为方便计算.
/**
* 算高度
*/
for (int j = 0; j < COL; j++) {
if (matrix[i][j] == '1') {
height[j]++;
} else {
height[j] = 0;
}
} /**
* 算左边界
*/
for (int j = 0; j < COL; j++) {
if (matrix[i][j] == '1') {
left[j] = Math.max(left[j], leftBound);
} else {
left[j] = 0;
leftBound = j + 1;
}
} /**
* 算右边界
*/
for (int j = COL - 1; j >=0; j--) {
if (matrix[i][j] == '1') {
right[j] = Math.min(right[j], rightBound);
} else {
rightBound = j;//当前行j到COL-1位置, 最早遇到0的位置可能就是当前
}
} /**
* 实时计算走到当前行的最大矩形面积
*
*/
for (int j = 0; j < COL; j++) {
max = Math.max((right[j] - left[j]) * height[j], max);
}
}
return max;
}
maximalRectangleDP
【解法二: Stack】
该题实际上还有一个解法,那就是也用84题用到的stack求解,事实上,这正是它紧随84题出现的原因。
要理解它为什么可以用84题解法来做,我们先从只有一行的矩阵来看。
1 0 1 1 1 0 0 1 1 1 1
如果我们把上面数字都理解成bar的高度,每个bar宽度为1, 那么上面这一行矩阵不就是84题么?!只有一行的矩阵,其实和84题的情况是等价的。换句话说, 84题是退化成一行矩阵情况的85题!
那么, 既然84题是退化成一行的,我们应该有个直觉, 那就是85题可以对每一行执行一次84题的算法, 最终应该能算出最大面积来!
对每一行计算,自然计算出的就是这一行里的最大面积。 要计算全部呢?简单, 如果当前行 j 位仍然是1,那么height[j]++。否则height[j]更新为0,这其实和上面DP算法的考虑是一样的,只要矩形无法连续, 那之前的结果也不应参与当前行的计算。
比如上面一行, 经过84题算法, 得出最面积为4. 再来第二行:
1 0 1 1 1 0 0 1 1 1 1
0 1 0 0 1 1 1 1 1 1 1
到第二行的时候, 我们的矩阵其实经过加和变为了: 0 1 0 0 2 1 1 2 2 2 2, 那么通过84题算法可以轻松算出最大面积为8。
好了, 说了这么多, 84题到底是怎么回事?
【84题:Largest Rectangle in Histogram】
链接:https://leetcode.com/problems/largest-rectangle-in-histogram/
给一个数组, 代表了一堆bar, 里面的数字代表了每个bar的高度。要求算出最大直方图面积, 比如下图:
最大面积是10, 5和6两个bar的公共部分,组成的面积就是10。
好吧, 这个题拿栈来做, 其实我很不喜欢这种题,除了一个很tricky的技巧外,学不到任何通用性的知识。但是这里还是记录下这个做法吧。
观察这个题,你会发现, 最大的面积肯定出现在这样的情况里:高的那些bar里。 或者, 存在于矮的但是覆盖比较宽的bar里。
OK, 那我们只需要从所有这些高的bar计算出面积来, 再从最后覆盖宽的bar计算出面积来, 最终比较其中最大的就可以了。
那么,怎么找出这些高的bar来,这是这个题的精髓所在。高的bar有个共同特征, 左右都低于它(废话!)。那我们遍历这些bar,如果遇到当前bar比之前的bar矮,我们就肯定可以认为当前bar是之前bar的右边,那我们就可以把左边的bar拿出来和当前bar做个比较,算出一个面积来。可是如果前一个bar做完计算后,它的前一个还是比现在这个高呢?那前一个也需要拿出来做计算。这个时候,我们需要有一个机制从前往后记录bar值,同时还能从后往前取bar值。显然,用stack!
好,算法描述如下:
遍历这些bar:
如果遇到比前一个矮的,就把前一个出栈,然后计算一下面积,然后再次和栈顶做比较,如果还是比栈顶矮,栈顶继续出栈,计算面积。如果栈顶bar已经比当前还矮了,当前bar入栈,继续。
如果比前一个高,就直接入栈,继续。
有一种情况,也需要考虑进去。上面算法结束后,明显stack里还可能会剩余bar,剩余的bar应该是一个不增序列。 所以,我们最终还需要针对stack里剩余的bar再算一次面积,最终更新max面积。
【Show me the Code!!!】
public static int largestRectangleArea(int[] height) {
if (height == null || height.length == 0 )
return 0;
Stack<Integer> stack = new Stack<Integer>();
int res = 0;
int i = 0;
while(i < height.length) {
while(!stack.isEmpty() && height[stack.peek()] >= height[i]) {
int index = stack.pop();
int temp = stack.isEmpty() ? i * height[index]
: ( (i - 1) - (stack.peek() + 1) + 1 ) * height[index];
res = Math.max(res,temp);
}
stack.push(i);
i++;
}
int count = 1;
while (!stack.isEmpty()) {
int index = stack.pop();
if (!stack.isEmpty() && height[index] == height[stack.peek()]) {
count++;
continue;
}
int temp = count * height[index];
res = Math.max(res,temp);
temp = stack.isEmpty() ? i * height[index]
: ( i - stack.peek() - 1 ) * height[index];
res = Math.max(res,temp);
}
return res;
}
largestRectangleArea