TrapWater系列的题,思路是对每个given位置,要求从左右扫到这个位置的“最大/小值”,或者“累积计算结果”,等等,总之是一定要从边上撸过来……于是把类似思路的题总结了一下。
题目 | 简介 |
---|---|
11. Container With Most Water | 42的铺垫 |
42. Trapping Rain Water | 求左/右到当前点的最高柱子 |
238. Product of Array Except Self | 求左/右到当前点的累积乘积 |
926. Flip String to Monotone Increasing | 求左/右到当前点所需的flip数 |
84. Largest Rectangle in Histogram | 求histogram左/右比当前点矮的第一个 |
85. Maximal Rectangle | 求每一层的histogram的左右边界 |
11. Container With Most Water
Input: [1,8,6,2,5,4,8,3,7]
Output: 49 求最多能盛水的体积
这个题并不是左右扫两遍的,放在这里是为了42做铺垫。
11核心的思想就是:从两边往中间挪板,宽度已经减小了,就寄希望于水面高度可以增加了。原来的水面高度是由短的那个决定的,挪高的那个板,比现在水面高则没啥用,比现在水面还低那更没用了呵呵。所以,只有挪短的那个板,才有机会水体积变大。
class Solution {
public int maxArea(int[] height) {
int maxArea = Integer.MIN_VALUE;
int left = 0, right = height.length-1;
while (left < right) {
int area = (right - left) * Math.min(height[left], height[right]);
maxArea = Math.max(area, maxArea);
if (height[left] < height[right]) {//挪短的那个板,才有机会水体积变大
left++;
} else {
right--;
}
}
return maxArea;
}
}
42. Trapping Rain Water
Input: [0,1,0,2,1,0,1,3,2,1,2,1]
Output: 6
这个题比11难在哪里呢?
- 11没有“池底”的概念,于是只需要确定左右挡板的高度,就可以唯一确定水的容积,整个水的形状是长方形。
- 42有“池底”的概念,每个位置“池底”高度不一样,就要每个位置单独计算水柱的高度。整个水的形状是不规则图形。
左右各扫一遍的方法,就是针对“每个位置单独计算”的情况设计的:
- go right: 维护一个从左边到当前位置的最大值
- go left: 维护一个从右边到当前位置的最大值
然后取两者较小值,然后减去当前池底的高度,就是当前位置的水柱体积。
class Solution {
public int trap(int[] height) {
int water = 0;
int len = height.length;
int lIdx = 0, rIdx = len -1;
int lMax = Integer.MIN_VALUE, rMax = Integer.MIN_VALUE;
int[] lMaxArr = new int[len];
int[] rMaxArr = new int[len];
//go right
for (int i = 0; i < len; i++) {
lMax = Math.max(lMax, height[i]);
lMaxArr[i] = lMax;
}
//go left
for (int i = len-1; i >= 0; i--) {
rMax = Math.max(rMax, height[i]);
rMaxArr[i] = rMax;
}
//find min on each position
for (int i = 0; i < height.length; i++) {
water += (Math.min(lMaxArr[i], rMaxArr[i]) - height[i]);
}
return water;
}
}
有个trick可以改进:不需要memory,且只撸一遍。
和11. Container With Most Water 思路类似,木桶理论,挪短的那一边,才有可能得到更大的水体积。
想想原来我们为什么需要左右各扫一遍,用memory来记录“从开始到此位置”的最大值呢?因为对于a given position,我们需要知道从两边分别到此处的最大值,然而通常如果只扫一遍的话,只能知道从某一边到此处的最大值。
那为什么此处我们可以免掉memory呢?是因为这个题设定的特殊点:就是左右挡板从两端往中间挪,在挪到某个位置的时候(不管是从左边挪过来的还是右边挪过来的),就可以确定最终的“短板”了,因为至少”从左边扫过来的最大值“和”从右边扫过来的最大值“两者至少已经求出一个了,而最终的水柱高度是由两者中的短的决定,所以当下这个值就可以算作为当前位置最终的水柱高度了。
class Solution {
public int trap(int[] height) {
int left = 0, right = height.length-1;
int leftMax = 0, rightMax = 0;
int water = 0;
while (left <= right) {
leftMax = Math.max(leftMax, height[left]);
rightMax = Math.max(rightMax, height[right]);
if (leftMax < rightMax) {
water += leftMax - height[left];
left++;
} else {
water += rightMax - height[right];
right--;
}
}
return water;
}
}
238. Product of Array Except Self
Input: [1,2,3,4] 求除了它本身以外的所有数乘积。
Output: [24,12,8,6] ,O(n)时间且不能用除法。
这个题并不是盛水的问题,写在这里是因为238也是“左右各扫描一遍”来解决的问题。
这个题直觉想:对于a given number,就把除了它之外的数全乘起来就好,但这种方法时间O(n^2),太复杂且有太多重复计算。题目要求只能用O(n)时间。
怎么减少重复计算呢?一个straight-forward的改进是:用累积乘积。然而本题不能用除法,如果只撸一遍就不能避免用除法,于是,嗯,可以左右各来一遍。这里就发现了这个238和前面11,42盛水问题的联系了:
“对于a given position,我们需要知道从两边分别到此处的累积结果,然而通常如果只扫一遍的话,只能知道从某一边到此处的累积结果,于是需要左右各一遍。”
另外,凡是对时间要求苛刻的问题,都要想1)空间换时间;2)多撸几遍。这种数组上操作,且要求O(n)时间的,就可以考虑“多撸几遍”。
左右各扫一遍:
- go right: 求从左开始的累积“乘积”
- go left: 求从右开始的累积“乘积”
注意在go left的时候,每个位置上的从左开始的累积“乘积”已经算好了,于是不需要再开一个数组存从右开始的累积“乘积”,直接两者乘好,存入结果数组。
class Solution {
public int[] productExceptSelf(int[] nums) {
int len = nums.length;
int[] ret = new int[len];
ret[0] = 1;
// go right
for (int i = 1; i < len; i++) {
ret[i] = ret[i-1] * nums[i-1];
}
// go left
int rightAccum = 1;
for (int i = len-1; i >= 0; i--) {
ret[i] *= rightAccum;
rightAccum *= nums[i];
}
return ret;
}
}
926. Flip String to Monotone Increasing
Input: “00110”,Output: 1 求最少的flip使得整个数列单调增(非减)
Explanation: We flip the last digit to get 00111.
(这个题和238类似,也是需要累积结果。)
直觉:要求一个切分点,左边全(打算)改成0,右边全(打算)改成1。从左往右扫,对a given点,要求:1)它左边有几个1需要改成0,这个可以通过从左撸过来的时候顺便算(累积结果);2)它右边有几个0需要改成1,这个从左边撸过来就没法得知了。于是我们想到左右各扫一遍。
- f0[i]定义为:如果从左到第i位置全是0,需要几个flip。(go right)
- f1[i]定义为:如果从右到第i位置全是1,需要几个flip。(go left)
f0[i]和f1[i]是“加法原理”的关系(算这件事需要两步,1.算从左边到i的flip数,2.算从右边到i的flip数,两者是要加起来的),f0[i]+f1[i]作为“如果当前位置作为切分点”的flip数,对所有切分点的flip数们,维护一个最小值,就是最终结果啦。
这种“需要扫两遍”的,都没法合并成一遍(要不然为啥专门扫两遍)。这个题的代码可以把左右的两遍合并在一起做,其实只是表面合并,f0和f1两个DP数组用不同的下标i = 1, j = S.length() - 1来记录。
class Solution {
public int minFlipsMonoIncr(String S) {
int[] f0 = new int[S.length() + 1];
int[] f1 = new int[S.length() + 1];
for (int i = 1, j = S.length() - 1; j >= 0; i++, j--) {
f0[i] += f0[i - 1] + (S.charAt(i - 1) == '0' ? 0 : 1);//go right
f1[j] += f1[j + 1] + (S.charAt(j) == '1' ? 0 : 1);//go left
}
int res = Integer.MAX_VALUE;
for (int i = 0; i <= S.length(); ++i) {
res = Math.min(res, f0[i] + f1[i]);
}
return res;
}
}
84. Largest Rectangle in Histogram
Input: [2,1,5,6,2,3] 求其中长方形的最大面积
Output: 10
求最大面积,肯定是维护一个全局的maxArea,我们的任务就是尝试各种长宽搭配,使得不要错过那种最大的搭配。
- 前面盛水那两个11,42,就是不断挪左右挡板,每对确定的左右挡板位置,求这个搭配的盛水量。
- 这个题84,可以在每个柱子处把长方形高度利用到最高,然后宽度尽量往两边拓展,看这种情况下能得到多大的长方形。
于是捏,对于a given position,它不关心左右比它高的柱子,因为不是瓶颈。它最关心的是左右第一个比它矮的柱子firstLowerLeft/firstLowerRight,就是当前情况能拓展的边界了。
而如果在每个位置都向两边拓展,则时间O(n^2),而且有很多重复计算。用memory记录已经算出来的结果firstLowerLeft[p],可以减少重复计算。
比如第五个柱子高2,往左拓展的时候,到6的时候,firstLowerLeft[3]告诉你,比我低的下标是2(高度5),不管这个5是不是比原来那个高2的柱子高,都是高2的柱子值得考虑的可能比它矮的柱子candidate,不会错过“第一个比它矮的”。而且若高2的和高1的这俩柱子之间有很多很高的柱子,这样就可以跳过很多queries。
一个小细节,探索边界的时候,跳出while-loop之后,会停留在“比当前柱子矮”的那个下标上,而求长方形面积的时候,是要往中间收一个的。
class Solution {
public int largestRectangleArea(int[] heights) {
if (heights == null || heights.length == 0) {return 0;}
int[] firstLowerLeft = new int[heights.length]; //index
int[] firstLowerRight = new int[heights.length];
//scan from left to right
firstLowerLeft[0] = -1; //init
for (int i = 1; i < heights.length; i++) {
int p = i - 1;
while (p >= 0 && heights[p] >= heights[i]) {
p = firstLowerLeft[p];
}
firstLowerLeft[i] = p;
}
//scan from right to left
firstLowerRight[heights.length - 1] = heights.length; //init
for (int i = heights.length - 2; i >= 0; i--) {
int p = i + 1;
while (p < heights.length && heights[p] >= heights[i]) {
p = firstLowerRight[p];
}
firstLowerRight[i] = p;
}
//calculate max area
int maxArea = 0;
for (int i = 0; i < heights.length; i++) {
maxArea = Math.max(maxArea, heights[i] * ((firstLowerRight[i]-1) - (firstLowerLeft[i]+1) + 1));
}
return maxArea;
}
}
85. Maximal Rectangle
Input:
[
[“1”,“0”,“1”,“0”,“0”],
[“1”,“0”,“1”,“1”,“1”],
[“1”,“1”,“1”,“1”,“1”],
[“1”,“0”,“0”,“1”,“0”]
]
Output: 6 求最大长方形面积(由1表示)
这个和84.Largest Rectangle in Histogram很像,只不过那个84是有一个整齐的底边,这个是底端不对齐的。怎么办呢?一层一层搞。这样每一层都当作Histogram的底层,并向上构造当前行的Histogram。
构造好当前行的Histogram之后,和前面几个题思路类似,也需要求左右边界的过程,但不太一样:
- 84.histogram: 类似于DP的思路,从i-1开始查询下标,直到遇到比i矮的。
- 85.matrix:
curLeft定义为:只考虑当前行,的左边界下标。
leftBoundary定义为:考虑到当前行为止形成的histogram,当前列能向左拓展的左边界下标(受上一层制约,当然也受下一侧制约只不过还没生成)。
- 如果当前值是1,则继承上一层的leftBoundary,并和当前层curLeft求更靠右(求max)的leftBoundary值。
- 如果当前值是0,则恢复默认值leftBoundary=0,更新当前行的左边界curLeft=j+1(说明j往左都不用考虑了)
举个🌰:(来自morrischen2008)
0 0 0 1 0 0 0
0 0 1 1 1 0 0
0 1 1 1 1 1 0
第几层 | Histogram | 左右边界 |
---|---|---|
0 | 0 0 0 1 0 0 0 | l: 0 0 0 3 0 0 0 r: 7 7 7 4 7 7 7 |
1 | 0 0 0 1 0 0 0 0 0 1 1 1 0 0 | l: 0 0 2 3 2 0 0 r: 7 7 5 4 5 7 7 |
2 | 0 0 0 1 0 0 0 0 0 1 1 1 0 0 0 1 1 1 1 1 0 | l: 0 1 2 3 2 1 0 r: 7 6 5 4 5 6 7 |
class Solution {
public int maximalRectangle(char[][] matrix) {
if (matrix.length == 0) {return 0;}
int m = matrix.length;
int n = matrix[0].length;
int[] heights = new int[n];
int[] leftBoundary = new int[n];
int[] rightBoundary = new int[n];
Arrays.fill(leftBoundary, 0);
Arrays.fill(rightBoundary, n);
int maxArea = 0;
for (int i = 0; i < m; i++) {//处理第i层(累积前i层)
int curLeft = 0;
int curRight = n;
//计算当前层的Histogram高度(from either side)
for (int j = 0; j < n; j++) {
if (matrix[i][j] == '1') {
heights[j]++;
} else {
heights[j] = 0;
}
}
//go right:更新左边界
for (int j = 0; j < n; j++) {
if (matrix[i][j] == '1') {
leftBoundary[j] = Math.max(leftBoundary[j], curLeft);
} else {
leftBoundary[j] = 0;
curLeft = j + 1;
}
}
//go left:更新右边界
for (int j = n-1; j >= 0; j--) {
if (matrix[i][j] == '1') {
rightBoundary[j] = Math.min(rightBoundary[j], curRight);
} else {
rightBoundary[j] = n;
curRight = j;
}
}
//计算面积(from either size)
for (int j = 0; j < n; j++) {
maxArea = Math.max(maxArea, ((rightBoundary[j]-leftBoundary[j]) * heights[j]));
}
}
return maxArea;
}
}