動態規划算法(Dynamic Programming,簡稱 DP)


動態規划算法(Dynamic Programming,簡稱 DP)

淺談動態規划

動態規划算法(Dynamic Programming,簡稱 DP)似乎是一種很高深莫測的算法,你會在一些面試或算法書籍的高級技巧部分看到相關內容,什么狀態轉移方程,重疊子問題,最優子結構等高大上的詞匯也可能讓你望而卻步。

 

而且,當你去看用動態規划解決某個問題的代碼時,你會覺得這樣解決問題竟然如此巧妙,但卻難以理解,你可能驚訝於人家是怎么想到這種解法的。

 

實際上,動態規划是一種常見的「算法設計技巧」,並沒有什么高深莫測,至於各種高大上的術語,那是嚇唬別人用的,只要你親自體驗幾把,這些名詞的含義其實顯而易見,再簡單不過了。

 

至於為什么最終的解法看起來如此精妙,是因為動態規划遵循一套固定的流程:遞歸的暴力解法 -> 帶備忘錄的遞歸解法 -> 非遞歸的動態規划解法。這個過程是層層遞進的解決問題的過程,你如果沒有前面的鋪墊,直接看最終的非遞歸動態規划解法,當然會覺得牛逼而不可及了。

 

當然,見的多了,思考多了,是可以一步寫出非遞歸的動態規划解法的。任何技巧都需要練習,我們先遵循這個流程走,算法設計也就這些套路,除此之外,真的沒啥高深的。

 

本文會通過兩個個比較簡單的例子:斐波那契和湊零錢問題,揭開動態規划的神秘面紗,描述上述三個流程。后續還會寫幾篇文章探討如何使用動態規划技巧解決比較復雜的經典問題。

 

首先,第一個快被舉爛了的例子,斐波那契數列。請讀者不要嫌棄這個例子簡單,因為簡單的例子才能讓你把精力充分集中在算法背后的通用思想和技巧上,而不會被那些隱晦的細節問題搞的莫名其妙。后續,困難的例子有的是。

 

步驟一、暴力的遞歸算法

 

int fib(int N) { if (N == 1 || N == 2) return 1; return fib(N - 1) + fib(N - 2); }

 

這個不用多說了,學校老師講遞歸的時候似乎都是拿這個舉例。我們也知道這樣寫代碼雖然簡潔易懂,但是十分低效,低效在哪里?假設 n = 20,請畫出遞歸樹。

 

PS:但凡遇到需要遞歸的問題,最好都畫出遞歸樹,這對你分析算法的復雜度,尋找算法低效的原因都有巨大幫助。

 

 

這個遞歸樹怎么理解?就是說想要計算原問題 f(20),我就得先計算出子問題 f(19) 和 f(18),然后要計算 f(19),我就要先算出子問題 f(18) 和 f(17),以此類推。最后遇到 f(1) 或者 f(2) 的時候,結果已知,就能直接返回結果,遞歸樹不再向下生長了。

 

遞歸算法的時間復雜度怎么計算?子問題個數乘以解決一個子問題需要的時間。

 

子問題個數,即遞歸樹中節點的總數。顯然二叉樹節點總數為指數級別,所以子問題個數為 O(2^n)。

 

解決一個子問題的時間,在本算法中,沒有循環,只有 f(n - 1) + f(n - 2) 一個加法操作,時間為 O(1)。

 

所以,這個算法的時間復雜度為 O(2^n),指數級別,爆炸。

 

觀察遞歸樹,很明顯發現了算法低效的原因:存在大量重復計算,比如 f(18) 被計算了兩次,而且你可以看到,以 f(18) 為根的這個遞歸樹體量巨大,多算一遍,會耗費巨大的時間。更何況,還不止 f(18) 這一個節點被重復計算,所以這個算法及其低效。

 

這就是動態規划問題的第一個性質:重疊子問題。下面,我們想辦法解決這個問題。

 

步驟二、帶備忘錄的遞歸解法

 

明確了問題,其實就已經把問題解決了一半。即然耗時的原因是重復計算,那么我們可以造一個「備忘錄」,每次算出某個子問題的答案后別急着返回,先記到「備忘錄」里再返回;每次遇到一個子問題先去「備忘錄」里查一查,如果發現之前已經解決過這個問題了,直接把答案拿出來用,不要再耗時去計算了。

 

一般使用一個數組充當這個「備忘錄」,當然你也可以使用哈希表(字典),思想都是一樣的。

 

int fib(int N) { if (N < 1) return 0; // 備忘錄全初始化為 0 vector<int> memo(N + 1, 0); // 初始化最簡情況 memo[1] = memo[2] = 1; return helper(memo, N); } int helper(vector<int>& memo, int n) { // 未被計算過 if (n > 0 && memo[n] == 0) memo[n] = helper(memo, n - 1) + helper(memo, n - 2); return memo[n]; }

 

現在,畫出遞歸樹,你就知道「備忘錄」到底做了什么。

 

 

實際上,帶「備忘錄」的遞歸算法,把一棵存在巨量冗余的遞歸樹通過「剪枝」,改造成了一幅不存在冗余的遞歸圖,極大減少了子問題(即遞歸圖中節點)的個數。

 

遞歸算法的時間復雜度怎么算?子問題個數乘以解決一個子問題需要的時間。

 

子問題個數,即圖中節點的總數,由於本算法不存在冗余計算,子問題就是 f(1), f(2), f(3) ... f(20),數量和輸入規模 n = 20 成正比,所以子問題個數為 O(n)。

 

解決一個子問題的時間,同上,沒有什么循環,時間為 O(1)。

 

所以,本算法的時間復雜度是 O(n)。比起暴力算法,是降維打擊。

 

至此,帶備忘錄的遞歸解法的效率已經和動態規划一樣了。實際上,這種解法和動態規划的思想已經差不多了,只不過這種方法叫做「自頂向下」,動態規划叫做「自底向上」。

 

啥叫「自頂向下」?注意我們剛才畫的遞歸樹(或者說圖),是從上向下延伸,都是從一個規模較大的原問題比如說 f(20),向下逐漸分解規模,直到 f(1) 和 f(2) 觸底,然后逐層返回答案,這就叫「自頂向下」。

 

啥叫「自底向上」?反過來,我們直接從最底下,最簡單,問題規模最小的 f(1) 和 f(2) 開始往上推,直到推到我們想要的答案 f(20),這就是動態規划的思路,這也是為什么動態規划一般都脫離了遞歸,而是由循環迭代完成計算。

 

步驟三、動態規划

 

有了上一步「備忘錄」的啟發,我們可以把這個「備忘錄」獨立出來成為一張表,就叫做 DP table 吧,在這張表上完成「自底向上」的推算豈不美哉!

 

int fib(int N) { vector<int> dp(N + 1, 0); dp[1] = dp[2] = 1; for (int i = 3; i <= N; i++) dp[i] = dp[i - 1] + dp[i - 2]; return dp[N]; }

 

 

畫個圖就很好理解了,而且你發現這個 DP table 特別像之前那個「剪枝」后的結果,只是反過來算而已。實際上,帶備忘錄的遞歸解法中的「備忘錄」,最終完成后就是這個 DP table,所以說這兩種解法其實是差不多的,大部分情況下,效率也基本相同。

 

這里,引出「動態轉移方程」這個名詞,實際上就是描述問題結構的數學形式:

 

 

為啥叫「狀態轉移方程」?為了聽起來高端。你把 f(n) 想做一個狀態 n,這個狀態 n 是由狀態 n - 1 和狀態 n - 2 相加轉移而來,這就叫狀態轉移,僅此而已。

 

你會發現,上面的幾種解法中的所有操作,例如 return f(n - 1) + f(n - 2),dp[i] = dp[i - 1] + dp[i - 2],以及對備忘錄或 DP table 的初始化操作,都是圍繞這個方程式的不同表現形式。可見列出「狀態轉移方程」的重要性,它是解決問題的核心。很容易發現,其實狀態轉移方程直接代表着暴力解法。

 

千萬不要看不起暴力解,動態規划問題最困難的就是寫出狀態轉移方程,即這個暴力解。優化方法無非是用備忘錄或者 DP table,再無奧妙可言。

 

這個例子的最后,講一個細節優化。細心的讀者會發現,根據斐波那契數列的狀態轉移方程,當前狀態只和之前的兩個狀態有關,其實並不需要那么長的一個 DP table 來存儲所有的狀態,只要想辦法存儲之前的兩個狀態就行了。所以,可以進一步優化,把空間復雜度降為 O(1):

 

int fib(int n) { if (n < 2) return n; int prev = 0, curr = 1; for (int i = 0; i < n - 1; i++) { int sum = prev + curr; prev = curr; curr = sum; } return curr; }

 

有人會問,動態規划的另一個重要特性「最優子結構」,怎么沒有涉及?下面會涉及。斐波那契數列的例子嚴格來說不算動態規划,以上旨在演示算法設計螺旋上升的過程。當問題中要求求一個最優解或在代碼中看到循環和 max、min 等函數時,十有八九,需要動態規划大顯身手。

 

下面,看第二個例子,湊零錢問題,有了上面的詳細鋪墊,這個問題會很快解決。

 

題目:給你 k 種面值的硬幣,面值分別為 c1, c2 ... ck,再給一個總金額 n,問你最少需要幾枚硬幣湊出這個金額,如果不可能湊出,則回答 -1 。

 

比如說,k = 3,面值分別為 1,2,5,總金額 n = 11,那么最少需要 3 枚硬幣,即 11 = 5 + 5 + 1 。下面走流程。

 

一、暴力解法

 

首先是最困難的一步,寫出狀態轉移方程,這個問題比較好寫:

 

其實,這個方程就用到了「最優子結構」性質:原問題的解由子問題的最優解構成。即 f(11) 由 f(10), f(9), f(6) 的最優解轉移而來。

 

記住,要符合「最優子結構」,子問題間必須互相獨立。啥叫相互獨立?你肯定不想看數學證明,我用一個直觀的例子來講解。

 

比如說,你的原問題是考出最高的總成績,那么你的子問題就是要把語文考到最高,數學考到最高...... 為了每門課考到最高,你要把每門課相應的選擇題分數拿到最高,填空題分數拿到最高...... 當然,最終就是你每門課都是滿分,這就是最高的總成績。

 

得到了正確的結果:最高的總成績就是總分。因為這個過程符合最優子結構,“每門科目考到最高”這些子問題是互相獨立,互不干擾的。

 

但是,如果加一個條件:你的語文成績和數學成績會互相制約,此消彼長。這樣的話,顯然你能考到的最高總成績就達不到總分了,按剛才那個思路就會得到錯誤的結果。因為子問題並不獨立,語文數學成績無法同時最優,所以最優子結構被破壞。

 

回到湊零錢問題,顯然子問題之間沒有相互制約,而是互相獨立的。所以這個狀態轉移方程是可以得到正確答案的。

 

int coinChange(vector<int>& coins, int amount) { if (amount == 0) return 0; int ans = INT_MAX; for (int coin : coins) { // 金額不可達 if (amount - coin < 0) continue; int subProb = coinChange(coins, amount - coin); // 子問題無解 if (subProb == -1) continue; ans = min(ans, subProb + 1); } return ans == INT_MAX ? -1 : ans; }

 

畫出遞歸樹:

 

 

時間復雜度分析:子問題總數 x 每個子問題的時間。子問題總數為遞歸樹節點個數,這個比較難看出來,是 O(n^k),總之是指數級別的。每個子問題中含有一個 for 循環,復雜度為 O(k)。所以總時間復雜度為 O(k*n^k),指數級別。

 

二、帶備忘錄的遞歸算法

 

int coinChange(vector<int>& coins, int amount) { // 備忘錄初始化為 -2 vector<int> memo(amount + 1, -2); return helper(coins, amount, memo); } int helper(vector<int>& coins, int amount, vector<int>& memo) { if (amount == 0) return 0; if (memo[amount] != -2) return memo[amount]; int ans = INT_MAX; for (int coin : coins) { // 金額不可達 if (amount - coin < 0) continue; int subProb = helper(coins, amount - coin, memo); // 子問題無解 if (subProb == -1) continue; ans = min(ans, subProb + 1); } // 記錄本輪答案 memo[amount] = (ans == INT_MAX) ? -1 : ans; return memo[amount]; }

 

不畫圖了,很顯然「備忘錄」大大減小了子問題數目,完全消除了子問題的冗余,所以子問題總數不會超過金額數 n,即子問題數目為 O(n)。處理一個子問題的時間不變,仍是 O(k),所以總的時間復雜度是 O(kn)。

 

三、動態規划

 

int coinChange(vector<int>& coins, int amount) { vector<int> dp(amount + 1, INT_MAX); dp[0] = 0; for (int i = 0; i < dp.size(); i++) { // 內層 for 在求所有子問題 + 1 的最小值 for (int coin : coins) { if (i - coin < 0) continue; dp[i] = min(dp[i], 1 + dp[i - coin]); } } return dp[amount] == INT_MAX ? -1 : dp[amount]; }

 

 

最后總結

 

如果你不太了解動態規划,還能看到這里,真得給你鼓掌,相信你已經掌握了這個算法的設計技巧。

 

計算機解決問題其實沒有任何奇技淫巧,它唯一的解決辦法就是窮舉,窮舉所有可能性。算法設計無非就是先思考“如何窮舉”,然后再追求“如何聰明地窮舉”。

 

列出動態轉移方程,就是在解決“如何窮舉”的問題。之所以說它難,一是因為很多窮舉需要遞歸實現,二是因為有的問題本身的解空間復雜,不那么容易窮舉完整。

 

備忘錄、DP table 就是在追求“如何聰明地窮舉”。用空間換時間的思路,是降低時間復雜度的不二法門,除此之外,試問,還能玩出啥花活?


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM