每日leetcode——42. 接雨水

Ethan發表於2022-03-19

題目

給定 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)

相關文章