leetcode常规算法题复盘(第四期)——接雨水

题目原文

42. 接雨水

给定 n 个非负整数表示每个宽度为 1 的柱子的高度图,计算按此排列的柱子,下雨之后能接多少雨水。

示例 1:

输入:height = [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 个单位的雨水(蓝色部分表示雨水)。 

示例 2:

输入:height = [4,2,0,3,2,5]
输出:9

提示:

  • n == height.length
  • 0 <= n <= 3 * 104
  • 0 <= height[i] <= 105

尝试解答

我有时真的很佩服力扣公司出测试样例的这群人,他们总能知道我的代码最大的漏洞在哪里,本身python的执行速度就不快,再加上我代码大多都在依赖多层循环,所谓卷中之卷之究极大卷怪,最终导致代码运行总是超时,这就让我只好再想一种其他方法(生活不易,菜鸡叹气。),先看我的代码:

 1 from functools import lru_cache
 2 @lru_cache(maxsize=32)
 3 class Solution:
 4     def trap(self, height) -> int:
 5         if len(height)==0:
 6             return 0
 7         rain = 0
 8         left = 0
 9         right = len(height)-1
10         i = 0
11         while(True):
12             left0 = -1
13             #print("left="+str(left))
14             #print("right="+str(right))
15             if (right-left)<2:
16                 return rain
17             for j in range(left,right+1):
18                 if height[j]<i:
19                     rain+=1
20                 if height[j]>=i+1:
21                     if left0 !=-1:
22                         continue
23                     left0 = left
24                     left = j
25             for j in range(right,left0-1,-1):
26                 if height[j]>=i+1:
27                     right = j
28                     break
29                 if j==0:
30                     return rain
31             i+=1
32         return rain
33     
34     
35 if __name__ == "__main__":
36     solu = Solution()
37     print(solu.trap([2,0,2]))

这个思路考虑起来十分形象,就是想象雨水是从最底部漫上来的,每次只向上涨一层的高度,每涨一层就判定一次哪些空位可以把水留住,至于再怎么判定,也很简单,某个空位的两边相同高度如果能留住雨水,那么这个空位也肯定能留住雨水,我们只需要找到这一层的最左和最右达到这个高度的“墙”,这两堵墙中间所有的空位都能留住雨水!实现起来非常简单了,算法流程图就不用画了,这个时候就该提一下测试人员了,他们猜出了有小傻瓜会使用双层遍历,于是第320次测试样例给了下图这么一个怪物,底下还要有一大部分我没截全,横向纵向的长度都非常爆炸:

 

 这就很让人抓狂,既然数据量这么巨大,那很显然只允许我将整个数据遍历一次

标准题解

并不是为了做题而做题,应该透过题目表面看到本质,下面会详细分析大佬们的多种思路(与我自己重复的就不讲了)

思路一:

这个思路就是我的思路,这里就学习一下大佬的高技术力代码,不做过多讲解 ,代码如下:

 1 public int trap(int[] height) {
 2     int sum = 0;
 3     int max = getMax(height);//找到最大的高度,以便遍历。
 4     for (int i = 1; i <= max; i++) {
 5         boolean isStart = false; //标记是否开始更新 temp
 6         int temp_sum = 0;
 7         for (int j = 0; j < height.length; j++) {
 8             if (isStart && height[j] < i) {
 9                 temp_sum++;
10             }
11             if (height[j] >= i) {
12                 sum = sum + temp_sum;
13                 temp_sum = 0;
14                 isStart = true;
15             }
16         }
17     }
18     return sum;
19 }
20 private int getMax(int[] height) {
21         int max = 0;
22         for (int i = 0; i < height.length; i++) {
23             if (height[i] > max) {
24                 max = height[i];
25             }
26         }
27         return max;
28 }
29 
30 
31 //作者:windliang
32 //链接:https://leetcode-cn.com/problems/trapping-rain-water/solution/xiang-xi-tong-su-de-si-lu-fen-xi-duo-jie-fa-by-w-8/
33 //来源:力扣(LeetCode)
34 //著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

 

时间复杂度O(m*n),空间复杂度O(1)这里大佬也承认这种方法目前已经AC不了了,证明这并不是一个好的方法

思路二:

思路二仍然属于半暴力的方法,时间复杂度为O(n**2),空间复杂度为O(1),与思路一按行爬升不同,本思路是进行按列移动。

求每一列的水,我们只需要关注当前列,以及左边最高的墙,右边最高的墙就够了。装水的多少,当然根据木桶效应,我们只需要看左边最高的墙和右边最高的墙中较矮的一个就够了。所以,根据较矮的那个墙和当前列的墙的高度可以分为三种情况。

1、较矮的墙的高度大于当前列的墙的高度

把正在求的列左边最高的墙和右边最高的墙确定后,然后为了方便理解,我们把无关的墙去掉。

 

这样就很清楚了,现在想象一下,往两边最高的墙之间注水。正在求的列会有多少水?
很明显,较矮的一边,也就是左边的墙的高度,减去当前列的高度就可以了,也就是 2 - 1 = 1,可以存一个单位的水。
2、较矮的墙的高度小于当前列的墙的高度

同样的,我们把其他无关的列去掉。

想象下,往两边最高的墙之间注水。正在求的列会有多少水?
正在求的列不会有水,因为它大于了两边较矮的墙。
3、较矮的墙的高度等于当前列的墙的高度。
    和上一种情况是一样的,不会有水。

明白了这三种情况,程序就很好写了,遍历每一列,然后分别求出这一列两边最高的墙。找出较矮的一端,和当前列的高度比较,结果就是上边的三种情况。代码如下:
作者:windliang
链接:https://leetcode-cn.com/problems/trapping-rain-water/solution/xiang-xi-tong-su-de-si-lu-fen-xi-duo-jie-fa-by-w-8/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

 1 public int trap(int[] height) {
 2     int sum = 0;
 3     //最两端的列不用考虑,因为一定不会有水。所以下标从 1 到 length - 2
 4     for (int i = 1; i < height.length - 1; i++) {
 5         int max_left = 0;
 6         //找出左边最高
 7         for (int j = i - 1; j >= 0; j--) {
 8             if (height[j] > max_left) {
 9                 max_left = height[j];
10             }
11         }
12         int max_right = 0;
13         //找出右边最高
14         for (int j = i + 1; j < height.length; j++) {
15             if (height[j] > max_right) {
16                 max_right = height[j];
17             }
18         }
19         //找出两端较小的
20         int min = Math.min(max_left, max_right);
21         //只有较小的一段大于当前列的高度才会有水,其他情况不会有水
22         if (min > height[i]) {
23             sum = sum + (min - height[i]);
24         }
25     }
26     return sum;
27 }
28 
29 //作者:windliang
30 //链接:https://leetcode-cn.com/problems/trapping-rain-water/solution/xiang-xi-tong-su-de-si-lu-fen-xi-duo-jie-fa-by-w-8/
31 //来源:力扣(LeetCode)
32 //著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

 

我个人总觉得这个思路也有AC不了的风险,这取决于m(y方向)与n(x方向)的大小,如果n>m且m也很大的话,那么它也AC不过(叉手手.jpg)

思路三:

肯定有很多人对思路二同样存在质疑,因此又提出了动态规划的思路对刚才的思路二进行了优化,我们注意到,解法二中。对于每一列,我们求它左边最高的墙和右边最高的墙,都是重新遍历一遍所有高度,这里我们可以优化一下。

首先用两个数组,max_left [i] 代表第 i 列左边最高的墙的高度,max_right[i] 代表第 i 列右边最高的墙的高度。(一定要注意下,第 i 列左(右)边最高的墙,是不包括自身的,和 leetcode 上边的讲的有些不同)

对于 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 了。代码如下:

 1 public int trap(int[] height) {
 2     int sum = 0;
 3     int[] max_left = new int[height.length];
 4     int[] max_right = new int[height.length];
 5     
 6     for (int i = 1; i < height.length - 1; i++) {
 7         max_left[i] = Math.max(max_left[i - 1], height[i - 1]);
 8     }
 9     for (int i = height.length - 2; i >= 0; i--) {
10         max_right[i] = Math.max(max_right[i + 1], height[i + 1]);
11     }
12     for (int i = 1; i < height.length - 1; i++) {
13         int min = Math.min(max_left[i], max_right[i]);
14         if (min > height[i]) {
15             sum = sum + (min - height[i]);
16         }
17     }
18     return sum;
19 }
20 
21 //作者:windliang
22 //链接:https://leetcode-cn.com/problems/trapping-rain-water/solution/xiang-xi-tong-su-de-si-lu-fen-xi-duo-jie-fa-by-w-8/
23 //来源:力扣(LeetCode)
24 //著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

 

空间复杂度O(n),时间复杂度O(n)。

计算原理上与思路二完全一样,但是对代码的算法本身进行了优化,显然这是用存储空间交换了计算时间,在这里我们看到算法的一个普遍性质,就是空间复杂度跟时间复杂度是可以以某种代价交换的,这给我一种启发,就是希望优化某种算法时,可以依据其实际需求,向提高某种复杂度而降低另一种复杂度的方向考虑。

思路四:

使用双指针的办法。先说说我认为的算法的新的普遍性质,我认为算法复杂度不应该只有时间复杂度跟空间复杂度,还应该有一个思维复杂度用来量化程序员对算法优化过程中投入的思维的复杂程度(谁说的?我说的!),应该是这三者之间以某种代价可以转换。像我这种猪脑子经常设计一些思维简单的算法,因此这个算法大概率同时在亏时间复杂度和空间复杂度,换个说法就是解放人脑虐待电脑。(哎~什么时候可以解放程序员大脑啊,怪就怪现在的计算机还是不行hhhh),回到正题。

 

 

代码如下:

 

 1 public int trap(int[] height) {
 2     int sum = 0;
 3     int max_left = 0;
 4     int max_right = 0;
 5     int left = 1;
 6     int right = height.length - 2; // 加右指针进去
 7     for (int i = 1; i < height.length - 1; i++) {
 8         //从左到右更
 9         if (height[left - 1] < height[right + 1]) {
10             max_left = Math.max(max_left, height[left - 1]);
11             int min = max_left;
12             if (min > height[left]) {
13                 sum = sum + (min - height[left]);
14             }
15             left++;
16         //从右到左更
17         } else {
18             max_right = Math.max(max_right, height[right + 1]);
19             int min = max_right;
20             if (min > height[right]) {
21                 sum = sum + (min - height[right]);
22             }
23             right--;
24         }
25     }
26     return sum;
27 }
28 
29 //作者:windliang
30 //链接:https://leetcode-cn.com/problems/trapping-rain-water/solution/xiang-xi-tong-su-de-si-lu-fen-xi-duo-jie-fa-by-w-8/
31 //来源:力扣(LeetCode)
32 //著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

 

 

上面是大佬的解释,我说一说我看懂代码后自己的解释,思路四跟思路三跟思路二在按列计算上的理念完全一样,思路二、三是指针位i的左右如果比i位置高了,那么我就可以断定i是可以存水的,而我根据左右最高位中较低的那一个得出水位的高度,跟i位置做差即可得出i位置所存的水量;思路四最为神奇的地方在于,我同时从左右两边设置两个指针分别为left、right,但是呢,我left指针只判断左边跟left的高低,right指针只判断右边跟right的高低,我凭什么这么判断,我是有前提的,我仅凭left左边比left高就能认定left必存水的前提就是right的右边有比left左边还高的柱子,这个水肯定跑不掉,同理,我仅凭right右边比right高就能认定right必存水的前提就是left的左边有比right右边还高的柱子,水也肯定跑不掉,后面的计算水高就跟思路二完全一样了。

思路五:

栈思想,我对这个事情深刻检讨,刚做完这道题的那天晚上,我把题目跟小老弟说了(北航学风恐怖如斯,恰饭睡觉吹牛皮都在讨论算法,大家都来北航读书啊hhhhh),小老弟说是不是跟”找山峰“问题很像,我当时张口就来,说这两个不是一回事,一个是连续的一个是离散的根本不沾边吧啦吧啦,结果现在脸肿肿的,这就是一回事,其实是一个”找山峰“,一个”找山谷“,都可以套用堆栈弹栈那套思想。

说到栈,我们肯定会想到括号匹配了。我们仔细观察蓝色的部分,可以和括号匹配类比下。每次匹配出一对括号(找到对应的一堵墙),就计算这两堵墙中的水。

我们用栈保存每堵墙。当遍历墙的高度的时候,如果当前高度小于栈顶的墙高度,说明这里会有积水,我们将墙的高度的下标入栈。如果当前高度大于栈顶的墙的高度,说明之前的积水到这里停下,我们可以计算下有多少积水了。计算完,就把当前的墙继续入栈,作为新的积水的墙。总体的原则就是,当前高度小于等于栈顶高度,入栈,指针后移。当前高度大于栈顶高度,出栈,计算出当前墙和栈顶的墙之间水的多少,然后计算当前的高度和新栈的高度的关系,重复第 2 步。直到当前墙的高度不大于栈顶高度或者栈空,然后把当前墙入栈,指针后移。

我们看具体的例子。

首先将 height [ 0 ] 入栈。然后 current 指向的高度大于栈顶高度,所以把栈顶 height [ 0 ] 出栈,然后栈空了,再把 height [ 1 ] 入栈。current 后移。


然后 current 指向的高度小于栈顶高度,height [ 2 ] 入栈,current 后移。

然后 current 指向的高度大于栈顶高度,栈顶 height [ 2 ] 出栈。计算 height [ 3 ] 和新的栈顶之间的水。计算完之后继续判断 current 和新的栈顶的关系。

current 指向的高度大于栈顶高度,栈顶 height [ 1 ] 出栈,栈空。所以把 height [ 3 ] 入栈。currtent 后移。

然后 current 指向的高度小于栈顶 height [ 3 ] 的高度,height [ 4 ] 入栈。current 后移。

 

然后 current 指向的高度小于栈顶 height [ 4 ] 的高度,height [ 5 ] 入栈。current 后移。

然后 current 指向的高度大于栈顶 height [ 5 ] 的高度,将栈顶 height [ 5 ] 出栈,然后计算 current 指向的墙和新栈顶 height [ 4 ] 之间的水。计算完之后继续判断 current 的指向和新栈顶的关系。此时 height [ 6 ] 不大于栈顶 height [ 4 ] ,所以将 height [ 6 ] 入栈。current 后移。

然后 current 指向的高度大于栈顶高度,将栈顶 height [ 6 ] 出栈。计算和新的栈顶 height [ 4 ] 组成两个边界中的水。然后判断 current 和新的栈顶 height [ 4 ] 的关系,依旧是大于,所以把 height [ 4 ] 出栈。计算 current 和 新的栈顶 height [ 3 ] 之间的水。然后判断 current 和新的栈顶 height [ 3 ] 的关系,依旧是大于,所以把 height [ 3 ] 出栈,栈空。将 current 指向的 height [ 7 ] 入栈。current 后移。

其实不停的出栈,可以看做是在找与 7 匹配的墙,也就是 3 。

 

而对于计算 current 指向墙和新的栈顶之间的水,根据图的关系,我们可以直接把这两个墙当做之前解法三的 max_left 和 max_right,然后之前弹出的栈顶当做每次遍历的 height [ i ]。水量就是 Min ( max _ left ,max _ right ) - height [ i ],只不过这里需要乘上两个墙之间的距离。可以看下代码继续理解下。

作者:windliang
链接:https://leetcode-cn.com/problems/trapping-rain-water/solution/xiang-xi-tong-su-de-si-lu-fen-xi-duo-jie-fa-by-w-8/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

代码如下:

 1 public int trap6(int[] height) {
 2     int sum = 0;
 3     Stack<Integer> stack = new Stack<>();
 4     int current = 0;
 5     while (current < height.length) {
 6         //如果栈不空并且当前指向的高度大于栈顶高度就一直循环
 7         while (!stack.empty() && height[current] > height[stack.peek()]) {
 8             int h = height[stack.peek()]; //取出要出栈的元素
 9             stack.pop(); //出栈
10             if (stack.empty()) { // 栈空就出去
11                 break; 
12             }
13             int distance = current - stack.peek() - 1; //两堵墙之前的距离。
14             int min = Math.min(height[stack.peek()], height[current]);
15             sum = sum + distance * (min - h);
16         }
17         stack.push(current); //当前指向的墙入栈
18         current++; //指针后移
19     }
20     return sum;
21 }
22 
23 //作者:windliang
24 //链接:https://leetcode-cn.com/problems/trapping-rain-water/solution/xiang-xi-tong-su-de-si-lu-fen-xi-duo-jie-fa-by-w-8/
25 //来源:力扣(LeetCode)
26 //著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

 

时间复杂度:虽然 while 循环里套了一个 while 循环,但是考虑到每个元素最多访问两次,入栈一次和出栈一次,所以时间复杂度是 O(n)。

空间复杂度:O(n)。栈的空间。

 

思路差距

思路差距这次不多说了,那就是思路一跟思路四或思路五的差距,思路一跟思路二之间有本质上的差别,优化的时候需要改思考方向,而思路二到思路四思考方向没有变,是在算法数据处理层面的优化,这一系列优化方法还是应该好好学习的。

正如 leetcode夜猫腾大佬的一首诗所说:

解法一: 月暗送湖风, 相寻路不通。 知在此塘中,AC 不让过。

解法二: 大力出奇迹,两个 for 循环。

解法三: 空间换时间,有房是大爷。

解法四: 四两拨千斤,两个小指针。

解法五: 欲穷世间理,上下而求索。

从解法一看到解法五,我仿佛看到了一个程序员不断通过总结,快速成长的过程。佩服,佩服!

技术差距

1、代码编写本身的技术差距正在变小,可以明显地感觉到,阅读大佬代码没有以前那么吃力了,可以较为轻松地理解大佬想表达什么,还是不能松懈。

2、解题速度是一个问题,随着熟练度提高,应该逐步提高编写代码的速度,——如何代码写得更快?这还是个待思考的问题。

3、代码性能是一个很大的问题,我往往因为思维复杂程度不够而写不出卓越性能的代码,这个问题的原因还是源于思路的差距,逐渐让自己适应更复杂的编码思想吧。

原文地址:https://www.cnblogs.com/monkiki/p/13893873.html