動態規劃詳解
動態規劃的入門,一般是從斐波拉契數列開始。該數列由0和1開始,後面的每一項數字都是前面兩項數字的和,定義如下:
F(0) = 0, F(1) = 1
F(n) = F(n - 1) + F(n - 2), 其中 n > 1
給定一個n,要求F(n),用遞迴方法可以很容易地解決這個問題,程式碼如下:
def fib(self, n: int) -> int:
if n == 0 or n == 1:
return n
return fib(n-1) + fib(n-2)
然而,上面的遞迴方法會造成大量重複計算,因為有很多重複子問題,時間複雜度為O(2^n)。例如,在求f(5)時,需要先求子問題f(4)和f(3)。遞迴地求f(4)時,又要先求子問題f(3)和f(2),這裡的f(3)與求f(5)時的子問題就重複了。
為了解決這個問題,我們就想到一個方法:如果我們每次都把結果儲存下來,複雜度就會大大降低。能不能讓每個重複的子問題都只計算一次,即每個F(n)都只計算一次。這就是動態規劃的核心思想:
- 將原問題分解成一系列子問題
- 每個子問題只求解一次,儲存到一個狀態陣列
dp[]
中
用動態規劃來解斐波拉契數列:
def fib(self, n: int) -> int:
if n == 0 or n == 1:
return n
dp = [0] * (n+1)
dp[0] = 0
dp[1] = 1
for i in range(2, n+1):
dp[ i] = dp[i-1] + dp[i-2]
return dp[n]
將時間複雜度由O(2^n)降低到了O(n),真香!
如果看了上面的內容,你還是雲裡霧裡,那麼:
很正常
理解了dp的思想,也不一定會刷題,下面分享一套自己的刷題模板。首先來看一個經典的爬樓梯問題:
假設你正在爬樓梯。需要 n 階你才能到達樓頂。每次你可以爬 1 或 2 個臺階。你有多少種不同的方法可以爬到樓頂呢?
前面說了動態規劃的核心思想,是把一個問題分解成許多個不互相重合的子問題。對於這個爬樓梯問題,我們需要怎麼開始分解呢?
一個很好的方法是,先找到最後一步的解決方法,即得到最終答案的前一步。對於這個例子,最後一步爬完樓梯之後會到達頂樓。那麼要到達頂樓、最後一步應該是什麼呢?
很顯然,由於每次只能爬一個或者兩個臺階,那麼到達頂樓之前,要麼在倒數第一層,最後爬一個臺階登頂。要麼在倒數第二層,最後爬兩個臺階登頂。這樣問題就分解成了:
到達頂樓的方法 = 到達倒數第一層的方法 + 到達倒數第二層的方法
這樣就可以自頂向下遞推,用dp[i]
來表示到達第i
層的方法,寫成虛擬碼:
dp[i] = dp[i-1] + dp[i-2]
這裡再回顧一下前面,為什麼遞迴的時間複雜度非常爆炸?
因為計算dp[i]
的時候需要計算一次dp[i-2]
,計算dp[i-1]
的時候,由於dp[i-1] = dp[i-2] + dp[i-3]
,也需要計算一次dp[i-2]
,這裡就重複了,學過程式設計的人都知道,遞迴裡的重複是非常恐怖的。
回過來,dp[]
陣列叫做狀態陣列,目的是儲存之前每一次計算的值。既然計算後面的值需要用到前面的值,那麼就肯定是從前面開始計算,即自底向上。
到這裡,大家都應該發現了,我們將問題從遞迴的自頂向下、變成了動態規劃的自底向上,從而降低了複雜度。即:
遞迴 -> 動態規劃
|| ||
自頂向下 -> 自底向上
那麼爬樓梯問題的的動態規劃解應該為:
def climbStairs(self, n: int) -> int:
if n == 1:
return 1
dp = [0]*(n+1)
dp[0] = 0
dp[1] = 1
dp[2] = 2
for i in range(3, n+1):
dp[i] = dp[i-1] + dp[i-2]
return dp[n]