当青训营遇上码上掘金
攒青豆接雨水
1.题目分析
- 现有n个宽度为1的柱子,给出n个非负整数依次表示柱子的高度,排列后如下雨所示,此时均匀从空间上向下撒下青豆,计算按此排列的竹子能借助多少青豆。(不考虑边角堆积)
不难看出这是一道十分常见的算法题,与某平台上的接雨水题目如出一辙,这一问题的核心难点在于:对于坐标i,如何在较短的时间复杂度内求出其两侧的最大高度,两侧最大高度的较小者减去当前柱子高度就可以求出位置i处可以承接的青豆。
对于这一套路题型,常见的有动态规划、单调栈以及双指针等解法来进行求解。下面我们将依次对这三种解法进行讲解。
2.题目解析
2.1动态规划(共需三次遍历)
对于下标i,落下青豆后这一长度为1的垂直区域内能够接到的最多青豆数量主要取决于其左侧和右侧的最大高度,以图中i = 3为例,其左侧最大高度为i = 0处的高度为5的柱子,右侧为i = 4处的高度为4的柱子。
此时我们可以得知该处可以接到的青豆数量为
min(5, 4) - 1 = 3 => min(l_max, r_max) - c_max
我们不难发现,在从左向右的遍历过程中,位置i左侧的最大高度是呈现非严格单调递增的,对于这种数据变化趋势,我们很懂已想到可以使用动态规划解法来求出i处的左侧最大高度,对于最左侧i = 0处的柱子,因为他不可能接住任何青豆,我们不妨将其左侧最大高度设置为height[0],这样我们便可以写出动态规划的状态转移方程如下:
l_max[i] = Math.max(l_max[i−1], height[i]) //1 <= i < n
同理,对于从右向左遍历中的右侧最大柱子高度,我们可以写出动态规划的状态转移方程如下:
r_max[i] = Math.max(r_max[i+1], height[i]) //0 <= i < n - 1
在遍历的到了l_max与r_max数组后,再进行一次遍历,便可以求出可以接住的青豆数量,代码实现如下:
//动态规划解法
public static int dynamic_programming(int[] height) {
int n = height.length;
//排除极端情况
if(n == 0) return 0;
//左侧最大高度
int[] l_max = new int[n];
l_max[0] = height[0];
for(int i = 1; i < n; i++) {
l_max[i] = Math.max(l_max[i-1], height[i]);
}
//右侧最大高度
int[] r_max = new int[n];
r_max[n-1] = height[n-1];
for(int i = n - 2; i >= 0; i--) {
r_max[i] = Math.max(r_max[i+1], height[i]);
}
int sum = 0;
for(int i = 0; i < n; i++) {
sum += Math.min(l_max[i], r_max[i]) - height[i];
}
return sum;
}
时间复杂度:O(n)
空间复杂度:O(n)
2.2单调栈解法(共需一次遍历)
在2.1的分析中,我们可以发现,从左到右的遍历过程中,坐标i处的左侧最大高度具有十分明显的单调递增性质,所以我很容易地联想到了单调栈。这时我们可以维护一个单调栈,栈内存储元素是数组下标,在遍历过程中,我们发现当前i处的柱子高度大于栈顶坐标c处的柱子高度,这也就意味着在左侧可能会出现一个接住青豆的空间。此时我们不妨将栈顶弹出,记为top,如果此时栈内元素不为空,这说明在坐标c的左侧存在比top高的柱子,这就在c处存在了一个密闭空间来接青豆,这一过程如下图所示:
由于单调栈内元素处的柱子高度始终递减,所以我们可以一直执行pop,直到栈为空或者栈顶元素高度处柱子高度大于heights[i],退出循环,将i入栈,这种方法只需遍历一次。代码实现如下:
//单调栈解法
public static int monotone_stack(int[] height) {
int n = height.length;
if(n == 0) return 0;
Stack<Integer> stk = new Stack<Integer>();
int sum = 0;
for(int i = 0; i < n; i++) {
while(!stk.isEmpty() && height[i] > height[stk.peek()]) {
int top = stk.pop();
if(stk.isEmpty()) {
break;
}
int l = stk.peek();
int w = i - l - 1;
int c = Math.min(height[l], height[i]) - height[top];
sum += w * c;
}
stk.push(i);
}
return sum;
}
时间复杂度:O(n)
空间复杂度:O(n)
2.3双指针(共需一次遍历)
2.1与2.2的解法本身便已经十分优秀了,但是我们仍可以在空间上进一步优化,我们发现如同木桶的短板原理一样,l_max[i], r_max[i]。中的较小者决定了i处能接的雨水,而且l_max[i]与r_max[i]分别是从左向右和从右向左遍历,互不干扰,所以我们可以设置两个指针l与r来更新l_max[i], r_max[i]。
我们分以下几种情况来进行讨论:\
- height[l] < height[r],由于l_max与r_max仅在l或r变动时更新,则一定会有l_max < r_max, 此时我们可以接到l_max - height[l]的青豆,将 l 加一
- 对于height[l] >= height[r], 同理可以获得r_max - height[i],并将 r 减一。两个指针相遇时退出,便可以得到答案
代码实现如下:
public static int double_pointer(int[] height) {
int sum = 0;
int l = 0, r = height.length - 1;
int l_max = 0, r_max = 0;
while(l < r) {
l_max = Math.max(l_max, height[l]);
r_max = Math.max(r_max, height[r]);
if(height[l] < height[r]) {
sum += l_max - height[l];
l++;
}
else {
sum += r_max - height[r];
r--;
}
}
return sum;
}
时间复杂度:O(n)
空间复杂度:O(1)
感想与收获
学会了使用码上掘金,以后有了更加好的展示代码的工具了