動態規劃兩題連刷,移動下標的小技巧
今天是LeetCode的37篇,我們繼續愉快的刷題。今天要刷的題目輸出LeetCode 63和64兩題,分別是Unique Paths II和Minimum Path Sum。
從題目的名稱我們就可以看出來,今天的題目都和path有關,其實不止如此,這兩題的題意也幾乎一樣,本質上都是上一篇文章所講的LeetCode 62題的延伸和拓展。這也是我們把這兩題放在一起解決的原因。
Unique Paths II
我們先來看第一題,Unique Paths II。它和62題基本一樣,都是機器人走一個矩形迷宮求解路徑總數的問題。大概就像是下面這張圖一樣,機器人從左上角出發,往右下角前進。
機器人只能往下或者是往右移動,不能往左或者是往上。並且這一次給定的矩形迷宮不再是所有點都可以訪問了,有些點設定了路障。機器人不能訪問設定了路障的點,請問在此情況下,機器人從起點走到終點的路徑一共有多少條?
樣例
Input:
[
[0,0,0],
[0,1,0],
[0,0,0]
]
Output: 2
Explanation:
There is one obstacle in the middle of the 3x3 grid above.
There are two ways to reach the bottom-right corner:
1. Right -> Right -> Down -> Down
2. Down -> Down -> Right -> Right
從樣例可以看出來,題目用一個二維陣列代表了矩形,陣列當中為1的點表示了路障。
如果你讀過我們上一篇文章,做過LeetCode 62題,你會發現這題幾乎是完全一樣的翻版,並且連解題思路都一樣。如果你沒有做過,可以通過下面的傳送門回顧一下:
LeetCode 62: 想到動態規劃就無敵了?這道題還有更牛的解法
我們套用一下62題的思路,其中動態規劃的解法是完全適用的。路障的存在只會影響路障的點本身以及它附近可以轉移到的點,對於它本身而言,它無法到達,自然可訪問的路徑數就是0。而對於它轉移到的點來說,這點無法訪問,自然貢獻也是0。所以我們只需要在轉移的過程當中將路障的位置置為0即可。
而通過排列組合求解答案的方法就無法使用了,因為路障和路障之間會有影響,所以我們沒有辦法僅僅通過排列組合就求出起點到路障處的路徑數量,也無法確定這個路障對於總體路徑數量帶來的變化量,更無法確定路障是否有把所有通路堵死。這些點之間互相影響,僅僅通過數學很難計算,所以不太方便使用。
所以我們只能通過動態規劃來求解,這段程式碼和上一題的幾乎也一樣,只不過做了一點細微的改動,加上了路障的判斷。
class Solution:
def uniquePathsWithObstacles(self, obstacleGrid: List[List[int]]) -> int:
n, m = len(obstacleGrid), len(obstacleGrid[0])
# 我們維護的下標i從1到n,j從1到m
dp = [[0 for _ in range(m+2)] for _ in range(n+2)]
dp[0][1] = 1
for i in range(1, n+1):
for j in range(1, m+1):
# 判斷當前位置是否可達,由於下標範圍不一致,所以要-1
if obstacleGrid[i-1][j-1] == 1:
dp[i][j] = 0
else:
dp[i][j] = dp[i-1][j] + dp[i][j-1]
return dp[n][m]
這題看著簡單,程式碼想要一次性寫對不容易,有一個坑點是我們dp陣列維護的下標範圍和題目給定的路障陣列的下標是不一樣的,我們設定了下標從1開始,這樣可以不用考慮轉移時陣列越界的問題。既然下標設定從1開始,我們在判斷對應位置是否是路障的時候,就需要i和j都-1。
我們繼續看下一題。
Minimum Path Sum
題意同樣是機器人走矩形迷宮,不過稍有不同的是,這一題當中加上了路徑長度的判斷,我們從起點開始,每到一個點都會有一個消耗。現在我們想要讓機器人從起點到達終點,所需要的最小消耗。
樣例
Input:
[
[1,3,1],
[1,5,1],
[4,2,1]
]
Output: 7
Explanation: Because the path 1→3→1→1→1 minimizes the sum.
如果你理解了上面一題的思路來做這題,幾乎是秒殺的,因為解題的思路完全一樣,只不過是我們dp陣列當中維護不再是到每個點的路徑數量,而是起點開始到這個點最小的距離。
對於每一個點i,j來說,它有兩個來源,分別是i-1,j 和i, j-1。狀態轉移方程就是 。這裡的cost陣列也就是題目給的每個點的花費。
我們直接來看程式碼:
class Solution:
def minPathSum(self, grid: List[List[int]]) -> int:
n = len(grid)
if n == 0:
return 0
m = len(grid[0])
# 同樣,我們dp維護的下標從1開始,初始化時賦值成無窮大
dp = [[0x3f3f3f3f for _ in range(m+2)] for _ in range(n+2)]
# 將0,1位置賦值成0,給(1, 1)提供一個消耗為0的入口
dp[0][1] = 0
for i in range(1, n+1):
for j in range(1, m+1):
# 由於下標從1開始,不用擔心越界,無腦轉移即可
dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + grid[i-1][j-1]
return dp[n][m]
這裡我們用了一個巧妙的方法,我們令 ,這是為了給 一個消耗為0的入口。這樣當我們執行 的時候,就會獲得0,這樣 就會自動完成,就不用我們在迴圈當中進行特判了。當然使用 if 特判也是可以的,但是這樣寫感覺更簡潔一些。
結尾
到這裡,關於LeetCode 63和64兩題就做完了,是不是很輕鬆呢?
在這兩題的程式碼當中我們用到了兩個技巧,一個是為了防止dp的時候超界,而進行大量的判斷,從而移動下標的技巧。另一個技巧是人為給初始位置提供一個初始選擇入口的技巧。有了這兩個技巧,可以大大簡化我們編碼的複雜度。不然你可能需要寫很多額外的邏輯來特殊處理一些邊界情況,這些邊界情況往往復雜並且容易出錯,因此能夠避免是再好不過了。
今天的文章就到這裡,原創不易,掃碼關注我,獲取更多精彩文章。