【动态规划】字节跳动算法笔试遭遇雨水坑问题
动态规划是五大常用算法(动态规划、分治、贪心、回溯和分支界定)其中之一,今天我们就来聊聊字节跳动的社招中一道动态规划的笔试题,废话不多说,我们直接上菜。
给定 n 个非负整数表示每个宽度为 1 的柱子的高度图,计算按此排列的柱子,下雨之后能接多少雨水。要求时间复杂度为O(n),空间复杂度为O(1)。
上面是由数组 [0,1,0,2,1,0,1,3,2,1,2,1] 表示的高度图,在这种情况下,可以接 6 个单位的雨水(蓝色部分表示雨水)。
示例:
输入: [0,1,0,2,1,0,1,3,2,1,2,1]
输出: 6
解题
这道题是求每根柱子中的积水,最终将每根柱子的积水求和。一根柱子积水多少与左右两边的柱子有直接的关系,一根柱子的积水等于左边最高的柱子和右边最高的柱子中较矮的一个柱子的高度减去当前自己柱子的高度。
这里可以分为以下三种情况:
1、当前柱子的两边最高柱子都比自己矮或等于自己的高度,此时不会积水:
2、当前柱子的某一边最高柱子比自己高,而另一边比自己矮,此时也不会积水:
3、只有当前柱子的两边最高柱子都比自己高,才会积水,且积水大小为两边最高柱子中的其中较矮的一方:
明白了这三种情况,程序就很好写了,遍历每一列,然后分别求出这一列两边最高的墙。找出较矮的一端,和当前列的高度比较,结果就是上边的三种情况中的第三个才有积水了:
public int trap(int[] height) {
int sum = 0;
//最两端的列不用考虑,因为一定不会有水。所以下标从 1 到 length - 2
for (int i = 1; i < height.length - 1; i++) {
int max_left = 0;
//找出左边最高
for (int j = i - 1; j >= 0; j--) {
if (height[j] > max_left) {
max_left = height[j];
}
}
int max_right = 0;
//找出右边最高
for (int j = i + 1; j < height.length; j++) {
if (height[j] > max_right) {
max_right = height[j];
}
}
//找出两端较小的
int min = Math.min(max_left, max_right);
//只有较小的一段大于当前列的高度才会有水,其他情况不会有水
if (min > height[i]) {
sum = sum + (min - height[i]);
}
}
return sum;
}
这种解法的时间复杂度是O(n²),遍历每一列需要 n,找出左边最高和右边最高的墙加起来刚好又是一个 n,所以是 n²。空间复杂度是O(1)。
动态规划求解
这种解法虽然非常直直观,但时间复杂度为O(n²),没有满足出题者的要求。我们注意到,以上解法中,我们求每一根柱子的左边最高的墙和右边最高的墙时,都重新遍历一遍所有柱子的高度,这里我们可以优化一下。
这里我们可以用两个数组,max_left [i] 代表第 i 列左边最高的墙的高度,max_right[i] 代表第 i 列右边最高的墙的高度。
对于 max_left我们其实可以这样求:max_left [i] = Max(max_left [i-1],height[i-1])。当前柱子左边的一根柱子的最高高度的柱子与当前左边一根柱子的高度选一个较大的,就是当前列左边最高的墙了。
对于 max_right我们可以这样求:max_right[i] = Max(max_right[i+1],height[i+1]) 。当前柱子右边的一根柱子的最高高度的柱子与当前柱子右边一根柱子的高度选一个较大的,就是当前列右边最高的墙了。
这样,我们再根据前一个算法继续优化,就不用在 for 循环里每次重新遍历一次求 max_left 和 max_right 了。
这里使用到了动态规划,那什么又是动态规划呢?动态规划算法通常基于一个递推公式及一个或多个初始状态,向后推算出一个最优解。即当前子问题的解将由上一次子问题的解推出。
以上雨水坑中,则是通过上一个max_left[i-1]/max_right[i-1] 求得下一个max_left[i]/max_right[i]。
public int trap(int[] height) {
int sum = 0;
int[] max_left = new int[height.length];
int[] max_right = new int[height.length];
for (int i = 1; i < height.length - 1; i++) {
max_left[i] = Math.max(max_left[i - 1], height[i - 1]);
}
for (int i = height.length - 2; i >= 0; i--) {
max_right[i] = Math.max(max_right[i + 1], height[i + 1]);
}
for (int i = 1; i < height.length - 1; i++) {
int min = Math.min(max_left[i], max_right[i]);
if (min > height[i]) {
sum = sum + (min - height[i]);
}
}
return sum;
}
我们可以看到,分别有三个for循环,分别都是循环了解决队列的长度,所以时间复杂度是O(3n),已经优化了时间复杂度了。这个算法虽然解决了时间复杂度问题,但带来了空间复杂度的问题,新增了两个max_left和max_right数组,所以空间复杂度为O(n)。
动态规划+双指针实现优化
以上优化还是不能满足出题者提出的时间复杂度和空间复杂度的要求,我们还需要继续优化。我们可以看到,for循环中有两个for (int i = 1; i < height.length - 1; i++),我们可以将这两个for循环合并。
同时我们可以发现,以上max_left [ i ] 数组中的元素我们其实只用一次,然后就再也不会用到了,我们可以使用一个max_left 变量来代替数该数组:
public int trap(int[] height) {
int sum = 0;
int max_left = 0;
int[] max_right = new int[height.length];
for (int i = height.length - 2; i >= 0; i--) {
max_right[i] = Math.max(max_right[i + 1], height[i + 1]);
}
for (int i = 1; i < height.length - 1; i++) {
max_left = Math.max(max_left, height[i - 1]);
int min = Math.min(max_left, max_right[i]);
if (min > height[i]) {
sum = sum + (min - height[i]);
}
}
return sum;
}
那我们能否也将max_right[i]放进去呢?目前来说是不行的,因为我们遍历是从左向右遍历的。如果我们是从右向左遍历,max_right[i]数组也能放进去了。
有了这个想法,那我们能否同时从两边向中间遍历呢,一个向左遍历,一个向右遍历,这个时候两个数组都能放在同一个for循环中了。那么什么时候从左到右,什么时候从右到左呢?
我们只需要判断左边的柱子和右边的柱子哪一边是较矮的一方,然后取较矮的一方,之后再与之前最高高度的柱子比较,取最高高度的柱子与下一根柱子比较,如果下一根柱子比较矮放还矮,这个时候就是积水的大小了。
然后较矮方再向左或向右移动(如果左边是较矮方,就继续向左移动,右边是较矮方,就继续向右移动),继续与另一边的柱子比较。
public int trap(int[] height) {
int sum = 0;
int max_left = 0;
int max_right = 0;
int left = 1;
int right = height.length - 2; // 加右指针进去
for (int i = 1; i < height.length - 1; i++) {
//从左到右更
if (height[left - 1] < height[right + 1]) {
max_left = Math.max(max_left, height[left - 1]);
int min = max_left;
if (min > height[left]) {
sum = sum + (min - height[left]);
}
left++;
//从右到左更
} else {
max_right = Math.max(max_right, height[right + 1]);
int min = max_right;
if (min > height[right]) {
sum = sum + (min - height[right]);
}
right--;
}
}
return sum;
}
这个时候的时间复杂度是O(n),空间复杂度为O(1)了。
end