1. 程式人生 > 其它 >動態規劃詳解

動態規劃詳解

技術標籤:演算法演算法動態規劃

動態規劃的入門,一般是從斐波拉契數列開始。該數列由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)都只計算一次。這就是動態規劃的核心思想:

  1. 將原問題分解成一系列子問題
  2. 每個子問題只求解一次,儲存到一個狀態陣列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]