题目

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

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

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

思路

暴力解法

暴力解法虽然简单,但是这道题的暴力思路一时我还没反应过来,花了挺久时间才想明白咋回事。
首先,大体思路就是,遍历每个柱子,也就是遍历数组每个元素,然后每个元素为中心,向左、右两侧遍历寻找比它高的最高的柱子,这样就能计算出这个柱子上方能接多少水。

看不懂没关系,开始我也看不懂,接下来结合代码详细说一下就懂了:
1.
首先,最外层for循环:for i in range(1, size-1)
遍历柱子,每个柱子作为桶底,向左右两侧寻找高柱子作为桶的左右两个边
由于数组的开头、结尾这两个柱子是边界,它们当桶底不可能形成一个桶,所以遍历的时候,是从第2根柱子开始到倒数第2根柱子结束,所以是range(1,size-1),而不是range(size)

2.
接下来,每次遍历拿到作为桶底的柱子,以它为中心,向左右两侧再遍历,寻找比它高的最高的柱子,作为桶底的左右两个边

for i in range(1, size-1):
    max_left,max_right = 0,0
    # 寻找i左边最高的柱子
    for j in range(i+1):
        max_left = max(height[j],max_left)
    # 寻找i右边最高的柱子
    for k in range(i,size):
        max_right = max(height[k], max_right)

    ans += min(max_left,max_right) - height[i]

这里有几个点,比较不容易想明白:

  • 为什么是向左右两边寻找柱子的时候,要从它自己开始一直找到开头、结尾,而不是只看它左右的那两个柱子;而且为什么要找最高的,而不是只要比它高就行:
    因为这里容易陷入一个思考误区,就是觉得一个柱子能够接水,只要它左右相邻的两个柱子比它高,把它围起来,围成一个桶,就可以接水了。
    其实并非如此,一个柱子能接水,它左右两边是要有比它高的柱子,但是这两个柱子并不是一定要和它相邻;并且,这两个柱子不仅比它高,而且还是最高的,看图就明白了:
    image.png
    比如上图中,柱子a是当前作为桶底的柱子,a的左右两侧柱子-a1、a1确实比它高,而且还和它相临,但是再向左右两侧看,-a2、a2更高,他们围城了一个更大的桶。所以,要向左右两侧一直遍历到开头、结尾,并且要找到最高的柱子作为桶的两边。

    我们在计算水的时候,不要想着怎么去直接计算-a2到a2围城的这个桶里面的水有多少,而是指考虑某个柱子上方的水是多少就可以了,想象成柱状图,每个柱子上面的水也是一柱一柱的,不要被“水”这个字眼迷惑了。
    image.png
    比如,只计算a柱子上方的水,这个桶较低的一边是-a2,高度为2,a的宽度是1,所以a柱子上方的水就是2。
    同理,也可以计算出-a1柱子上方的水,这时要注意,-a1柱子本身高度为1,占据了空间,所以要把它自身的高度减掉。
    同理,也可以计算出a1柱子上方的水
    最后,把-a1、a、a1三个柱子上方的水,加起来,就是-a2、a2围城的桶里的水了。
    image.png
    同理,遍历每个可以作为桶底的柱子,计算出每个桶底上方的水,最后累加起来,就是最终的答案。

  • 遍历桶底柱子的时候,我们不需要开头、结尾的两个柱子,因为他俩不可能作为桶底。
    而在寻找左右两测最高的桶边柱子时,要包括开头、结尾的两个柱子,因为它们可以作为桶边。
  • 寻找左右桶边的时候,把当前桶底这个柱子,也算在遍历范围中了,因为如果左右两边没有比它高的柱子,那么它就既是桶底、也是桶边,可以想象成它就是一块木头桩子,显然它上面是不能接水的。所以,最后在计算接水的时候,用桶边较低高度-桶底自身高度,对它来说就是自己减自己等于0,刚好符合逻辑。
    当然,你也可以在遍历时,不把桶底这个柱子算在遍历范围内,只不过遇到左右没有比它高的柱子这种情况时,还要单独写代码处理,就比较麻烦,所以这样写算是一个小技巧。
def trap(height) -> int:
    size = len(height)
    ans = 0
    
    # 遍历每个可以作为桶底的柱子,开头、结尾两个柱子不能作为桶底,不在遍历范围内
    for i in range(1,size-1):
        max_left,max_right = 0,0
        # 寻找i柱子左侧比自己高的最高柱子,没有的话i自己就是最高柱子
        # 开头的柱子可以作为桶边,在遍历范围内
        for j in range(i+1):
            max_left = max(height[j],max_left)
        # 寻找i柱子右侧比自己高的最高柱子,没有的话i自己就是最高柱子
        # 结尾的柱子可以作为桶边,在遍历范围内
        for k in range(i,size):
            max_right = max(height[k], max_right)
        # 较低的桶边柱子高度 - 桶底柱子高度,就是桶底柱子上方的储水量
        # 每个桶底柱子上方储水量累加,就是最终答案
        ans += min(max_left,max_right) - height[i]
    return ans

时间复杂度:O(n^2),每遍历一个元素,就要遍历一次数组
空间复杂度:O(1)

动态规划

理解了暴力解法的思路,动态规划就很容易了。
动态规划的原理和暴力解法一模一样,只不过先提前遍历数组,从左向右遍历一遍,找出每个柱子左侧的最高柱子的高度;从右向左遍历一遍,找出每个柱子右侧的最高柱子的高度。
这样就事先找出了每个柱子作为桶底,它左右两侧的桶边的最大高度。然后直接计算就可以得出每个柱子作为桶底,它上方能接多少雨水了。

和暴力解法一样,找最高桶边柱子的时候,开头、结尾两个柱子是在查找范围内的。
而计算雨水的时候,开头、结尾两个柱子不可能接的住水,所以不在计算范围内。

def trap(height) -> int:
    n = len(height)
    # 存储最大高度的数组
    max_left = [0]*n
    max_right = [0]*n
    
    # 从左往右遍历,寻找每个桶底柱子左侧的最高柱子
    for i in range(n):
        # i=0左侧开头柱子就是它自己
        # 其他柱子自己和之前的比较,高的就是最高柱子
        max_left[i] = max(height[i],height[i] if i==0 else max_left[i-1])
        
    # 从右往左遍历,寻找每个桶底柱子右侧的最高柱子
    for i in range(n-1,-1,-1):
        # i=n-1右侧结尾柱子就是它自己
        # 其他柱子自己和之前的比较,高的就是最高柱子
        max_right[i] = max(height[i],height[i] if i==n-1 else max_right[i+1])
    
    # 计算每个桶底柱子,上方能接多少水,此时计算范围是range(1,n-1)不含开头、结尾的柱子
    ans = sum(min(max_left[i],max_right[i])-height[i] for i in range(1,n-1))
    return ans

时间复杂度:O(n),只遍历了3次数组
空间复杂度:O(n),存储最大高度的数组使用了额外的空间

单调栈

遍历每个柱子,如果当前柱子高于栈顶的柱子,那说明栈顶的柱子有可能作为桶底,形成桶可以接水。将栈顶的桶底柱子弹出,此时栈顶的柱子是桶的左边,计算当前这个桶的储水量。
如果当前柱子,还高于此时栈顶的柱子,说明这个柱子之前是桶的左边,现在也可能作为桶底去接水,将这个栈顶柱子也弹出,此时栈顶的柱子是新的桶的左边,计算新的桶的储水量。
重复上述循环,直到当前柱子低于栈顶柱子,此时栈顶柱子不可能是桶底了,不能再形成桶了,或者栈空了,则继续下一轮遍历。

总结:
当前所遍历的柱子,看作桶的右边
栈中栈顶的柱子,看作桶底
栈中栈顶前一个柱子,看作桶的左边
就是通过循环、入栈操作,看看每个柱子作为桶的右边,它和前面的柱子能否形成一个桶
形成桶就计算这个桶底的储水量,计算完成后,将这个桶底柱子弹出栈
再往前看,能否和前面的柱子形成一个桶
以此类推

def trap(height) -> int:
    ans = 0
    stack = []
    
    for i in range(len(height)):
        # 栈里有柱子,右柱 高于 桶底
        while stack and height[i] > height[stack[-1]]:
            # 此时栈顶 是桶底柱子的下标
            bottom = stack.pop()
            # 弹出桶底空栈了,说明这个桶没有左柱,跳出
            if not stack:
                break
            # 否则,就是一个桶,计算这个桶底的储水量
            # 桶底被弹出后,此时栈顶是左柱的下标
            # 桶的宽度=右柱下标 - 左柱下标 -1
            w = i - stack[-1] -1
            # 桶能储水的高度 = 较低的桶边 - 桶底的高度
            # 假如遇到 左柱、桶底 一样高的情况,则经过计算这个桶能储水的高度为0,无法储水
            # 会继续while循环,继续向前寻找桶底、左柱
            h = min(height[stack[-1]], height[i]) - height[bottom]
            ans += w*h
        # 当前右柱,计算完它作为右柱,和前面的柱子可能形成的桶的储水量后,它也入栈,作为后面柱子的左柱、桶底
        stack.append(i)
    
    return ans

时间复杂度:单次遍历O(n),每个条形块最多访问两次(由于栈的弹入和弹出),并且弹入和弹出栈都是O(1)的。
空间复杂度:O(n)。 栈最多在阶梯型或平坦型条形块结构中占用O(n)的空间

双指针

平时常见的for循环,可以看作是单指针的,从开始遍历到结束
双指针,就可以想象成有两个指针,一个从开头往后遍历,一个从结尾向前遍历,直到两个指针相遇,整个数组也就遍历完了。

我们定义两个指针left、right,left从开头向右移动遍历,right从结尾向左移动遍历
指针指向的元素,作为桶底。但是现在有left、right两个指针,选哪个作为桶底呢?
选择高度较低的那个作为桶底,因为一个桶要想能够接水,桶边不可能比桶底还低。
比如,当前left指向高度为1的柱子,right指向了高度为3的柱子,那么left指针指向的柱子就是桶底。
接下来就计算,left指针指向的柱子上方能接多少水。一个桶能接多少水,是由较短的桶边决定的,加入left指针左侧的柱子中,最大高度为2,那么就是由这个高度为2的柱子决定了当前left指向的桶底能存2-1=1的水。
这里可能会问,那如果left左侧柱子中,存在最大高度是3,30,或者300的柱子呢?还是要用左侧柱子的最大高度 - 桶底的高度吗,这样不就错了吗?
其实,我们的逻辑是,在选择left、right指向的柱子哪个作为桶底时,永远选择较矮的那个作为桶底。
因为如果上述情况发生,假设左侧有一个高度是30的柱子,那么left指针就会一直停在30这个柱子这里,因为right指针遍历的柱子中没有比30高的,桶底都在right指向的这边,除非随着遍历的进行,right遇到了一个高于30的柱子,桶底才到了left这边,left才会开始移动。
所以不可能会出现,选择了较矮的left做桶底,结果left左边的最大高度会高于right的高度的情况。
right的计算也和left一样。

def trap(height) -> int:
    ans = 0
    left,right = 0, len(height)-1
    left_max,right_max = 0,0

    # 两个指针同时遍历数组,left从左向右遍历,right从右向左遍历
    # 当两个指针相遇时,即left=right时,数组就遍历完了
    while left < right:
        # 用来保存指针扫描过的柱子的最大高度
        left_max = max(left_max, height[left])
        right_max = max(right_max, height[right])
        if height[left] < height[right]:
            ans += left_max - height[left]
            left +=1
        else:
            ans += right_max -height[right]
            right -=1
    return ans

时间复杂度:O(n)
空间复杂度:O(1)


Ethan
140 声望11 粉丝

水平较低,只是记录,谨慎参阅


引用和评论

0 条评论