題目
給定 n 個非負整數表示每個寬度為 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 個單位的雨水(藍色部分表示雨水)。
輸入: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]
這裡有幾個點,比較不容易想明白:
為什麼是向左右兩邊尋找柱子的時候,要從它自己開始一直找到開頭、結尾,而不是隻看它左右的那兩個柱子;而且為什麼要找最高的,而不是隻要比它高就行:
因為這裡容易陷入一個思考誤區,就是覺得一個柱子能夠接水,只要它左右相鄰的兩個柱子比它高,把它圍起來,圍成一個桶,就可以接水了。
其實並非如此,一個柱子能接水,它左右兩邊是要有比它高的柱子,但是這兩個柱子並不是一定要和它相鄰;並且,這兩個柱子不僅比它高,而且還是最高的,看圖就明白了:
比如上圖中,柱子a是當前作為桶底的柱子,a的左右兩側柱子-a1、a1確實比它高,而且還和它相臨,但是再向左右兩側看,-a2、a2更高,他們圍城了一個更大的桶。所以,要向左右兩側一直遍歷到開頭、結尾,並且要找到最高的柱子作為桶的兩邊。我們在計算水的時候,不要想著怎麼去直接計算-a2到a2圍城的這個桶裡面的水有多少,而是指考慮某個柱子上方的水是多少就可以了,想象成柱狀圖,每個柱子上面的水也是一柱一柱的,不要被“水”這個字眼迷惑了。
比如,只計算a柱子上方的水,這個桶較低的一邊是-a2,高度為2,a的寬度是1,所以a柱子上方的水就是2。
同理,也可以計算出-a1柱子上方的水,這時要注意,-a1柱子本身高度為1,佔據了空間,所以要把它自身的高度減掉。
同理,也可以計算出a1柱子上方的水
最後,把-a1、a、a1三個柱子上方的水,加起來,就是-a2、a2圍城的桶裡的水了。
同理,遍歷每個可以作為桶底的柱子,計算出每個桶底上方的水,最後累加起來,就是最終的答案。- 遍歷桶底柱子的時候,我們不需要開頭、結尾的兩個柱子,因為他倆不可能作為桶底。
而在尋找左右兩測最高的桶邊柱子時,要包括開頭、結尾的兩個柱子,因為它們可以作為桶邊。 - 尋找左右桶邊的時候,把當前桶底這個柱子,也算在遍歷範圍中了,因為如果左右兩邊沒有比它高的柱子,那麼它就既是桶底、也是桶邊,可以想象成它就是一塊木頭樁子,顯然它上面是不能接水的。所以,最後在計算接水的時候,用桶邊較低高度-桶底自身高度,對它來說就是自己減自己等於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)