算法導論讀書筆記(17)
動態規劃概述
和分治法同樣, 動態規劃 (dynamic programming)是經過組合子問題的解而解決整個問題的。分治法是將問題劃分紅一些獨立的子問題,遞歸地求解各子問題,而後合併子問題的解而獲得原問題的解。與此不一樣,動態規劃適用於子問題並不獨立的狀況,即各子問題包含公共的子子問題。在這種狀況下,分治法會重複地求解公共的子子問題。而動態規劃算法對每一個子問題只求解一次,將其結果保存在一張表中,從而避免重複。java
動態規劃一般用於 最優化問題 。此類問題可能有多種可行解。每一個解有一個值,而咱們但願找出具備最優(最大或最小)值的解。稱這樣的解爲該問題的「一個」最優解(而不是「肯定的」最優解),於是可能存在多個最優解。算法
動態規劃算法的設計能夠分爲以下4個步驟:sql
- 描述最優解的結構
- 遞歸定義最優解的值
- 按自底向上的方式計算最優解的值
- 由計算出的結構構造一個最優解
鋼條切割
鋼條切割問題是動態規劃問題的一個例子。塞林企業會買進長鋼條,將它們切割成短條後賣出(切割是免費的,不計成本)。塞林企業的老總想要知道鋼條怎麼切割最賺錢。數組
已知塞林企業對長度爲 i 英寸的鋼條的售價爲 pi 美圓,其中 i = 1,2,…。下圖給出了一張樣本價格表。bash
鋼條切割問題 的描述以下。給定一根長爲 n 的鋼條以及價格表 pi ,其中 i = 1,2,…, n ,找出鋼條切割並賣出後可取得的最大收益 rn 。ide
考慮一下 n = 4的狀況。下圖列出了切割4英寸鋼條的全部方式。根據樣本價格表,最後可知將4英寸鋼條切割成2根2英寸鋼條的收益最大。 p2 + p2 = 10。函數
一根長度爲 n 的鋼條共有 2n-1 種不一樣的切割方式。咱們這裏用普通加法符號表示一個分解,好比7 = 2 + 2 + 3就表示一根長度爲7的鋼條被切成3份,2根長度爲2,一根長度爲3。若是最優解將鋼條切割成 k 份( 1 <= k <= n ),那麼最優分解即爲 n = i1 + i2 + … + ik ,每段鋼條的長度爲 i1 , i2 ,…, ik ,對應的最大收益爲 rn = pi1 + pi2 + … + pik 。post
通常來講,咱們能夠將最優收益 rn 表示成以下形式:優化
rn = max
( pn , r1 + rn-1 , r2 + rn-2 ,…, rn-1 + r1 )
spa
第一個參數 pn 表明不切割時鋼條的價格。其它的 n - 1個參數首先將鋼條分爲2份,長度分別爲 i 和 n - i ( i = 1,2,…, n - 1),而後分別取得兩份的最優收益 ri 和 rn-i 以後作和。由於咱們不知道 i 取值爲多少時會獲得最優解,因此咱們必須計算全部可能狀況並從中選出最優解。
能夠看到,爲了解決規模爲 n 的初始問題,咱們首先要解決的是規模小一些的同類型問題。一旦咱們作出了一個劃分,咱們就能夠將劃分出的兩部分視爲鋼條切割問題的獨立的實例。總體最優解就包含在這相關的兩部分子問題之中。咱們說鋼條切割問題知足 最優子結構 的性質:某問題的最優解由相關子問題的最優解組合而成,且這些子問題能夠獨立求解。
下面以一種簡單的方式安排鋼條切割的遞歸結構,咱們能看到一個分解是由位於左側長度爲 i 的一份,以及位於右側的剩餘部分 n - i 。只有右側的部分可能再次被分解。這樣能夠獲得一個更簡潔的公式:
在上面的公式中,最優解只和一個相關的子問題有關(劃分後右側的剩餘部分)。
自頂向下的遞歸實現
下面的過程是一種很直接的,自頂向下,遞歸風格的實現。
CUT-ROD(p, n) 1 if n == 0 2 return 0 3 q = -∞ 4 for i = 1 to n 5 q = max(q, p[i] + CUT-ROD(p, n - i)) 6 return q
過程 CUT-ROD
接受一個價格的數組 p [ 1 .. n ]和一個整數 n 做爲參數,返回可能的最優解。若是你用本身最熟悉的語言實現了這個 CUT-ROD
過程並運行它,你會發現即便對於不太大的 n 值,你的程序也會花很長的時間才能得出結果。實際上,每次你將 n 值增長1,你的程序的運行時間大約要翻一番。
過程 CUT-ROD
的效率如此低下的緣由就是它不斷的重複解決相同的子問題。下圖給出了一個很好的說明,其中 n = 4,能夠看到,過程屢次重複計算 n = 2和 n = 1。
爲了分析 CUT-ROD
的運行時間,設 T ( n )爲問題規模爲 n 時調用 CUT-ROD
的總次數,該表達式等於根結點標記爲 n 的遞歸樹中的總結點數。該總數包含根結點上的初始調用。所以, T ( 0 ) = 1和
其中 j = n - i ,可得 T ( n ) = 2n ,所以 CUT-ROD
的運行時間是 n 的冪。
使用動態規劃解決鋼條切割問題
能夠看到,遞歸算法之因此效率低下,是由於它反覆求解相同的子問題。,所以,動態規劃會仔細安排求解順序,對每一個子問題只求解一次,並將結果保存下來以便以後查找。因而可知,動態規劃須要額外的內存空間來節省計算時間,是典型的 時空權衡 (time-memory trade-off)的例子。
動態規劃有兩種等價的實現方法。
- 帶備忘的自頂向下法(top-down with memoization)
- 此方法仍按天然的遞歸形式編寫過程,但過程會保存每一個子問題的解。
- 自底向上法(bottom-up method)
- 這種方法通常須要恰當定義子問題「規模」的概念,使得任何子問題的求解都只依賴於「更小」子問題的求解。於是咱們能夠將子問題按規模排序,由小到大一次求解。當求解某子問題時,它所依賴的那些更小子問題都已求解完畢,所以每一個子問題只求解一次。
兩種方法獲得的算法具備相同的漸進運行時間,但自底向上方法的時間函數一般具備更小的係數。
下面給出的是自頂向下 CUT-ROD
過程的僞碼,加入了備忘機制:
MEMOIZED-CUT-ROD(p, n) 1 let r[0..n] be a new array 2 for i = 0 to n 3 r[i] = -∞ 4 return MEMOIZED-CUT-ROD-AUX(p, n, r)
MEMOIZED-CUT-ROD-AUX(p, n, r) 1 if r[n] >= 0 2 reutrn r[n] 3 if n == 0 4 q = 0 5 else 6 q = -∞ 7 for i = 1 to n 8 q = max(q, p[i] + MEMOIZED-CUT-ROD-AUX(p, n - i, r)) 9 r[n] = q 10 return q
過程 MEMOIZED-CUT-ROD
首先檢查值是否已知,若是是,則返回;不然在第6~8行計算值 q ,第9行將 q 存入 r [ n ],最後返回 q 。
自底向上版本更簡單:
BOTTOM-UP-CUT-ROD(p, n) 1 let r[0..n] be a new array 2 r[0] = 0 3 for j = 1 to n 4 q = -∞ 5 for i = 1 to j 6 q = max(q, p[i] + r[j - i]) 7 r[j] = q 8 return r[n]
過程 BOTTOM-UP-CUT-ROD
採用子問題的天然順序:若 i < j ,則規模爲 i 的子問題比規模爲 j 的子問題「更小」。所以,過程依次求解規模爲 j = 0,1,…, n 的子問題。
子問題圖
當思考一個動態規劃爲問題時,咱們應該瞭解問題的子問題之間的依賴關係。
問題的 子問題圖 準確地表達了這些信息,子問題圖是一個有向圖,每一個定點惟一地對應一個子問題。若是求子問題 x 的最優解時須要直接用到子問題 y 的最優解,那麼在子問題圖中就會有一條從子問題 x 到子問題 y 的有向邊。下圖顯示了 n = 4時鋼條切割問題的子問題圖。
子問題圖 G = ( V , E )的規模能夠幫助咱們肯定動態規劃的運行時間。因爲每一個子問題只求解一次,所以算法運行時間等於每一個子問題求解時間之和。一般,一個子問題的求解時間與子問題圖中對應頂點的度成正比,而子問題的數目等於子問題的頂點數。所以,一般狀況下,動態規劃算法的運行時間與頂點和邊的數量呈線性關係。
重構解
上面的算法僅返回最優解的收益值,並未返回解自己。這裏能夠擴展該算法。
EXTENDED-BOTTOM-UP-CUT-ROD(p, n) 1 let r[0..n] and s[0..n] be new arrays 2 r[0] = 0 3 for j = 1 to n 4 q = -∞ 5 for i = 1 to j 6 if q < p[i] + r[j - i] 7 q = p[i] + r[j - i] 8 s[j] = i 9 r[j] = q 10 return r and s
鋼條切割問題的簡單Java實現
/** * 帶備忘的自頂向下方法 * * @param price 價格表 * @param n 待分割的長度 */ public static int memoizedCutRod(int[] price, int n) { int[] revenue = new int[n + 1]; for (int i = 0; i < revenue.length; i++) // 初始化revenue數組 revenue[i] = Integer.MIN_VALUE; return memoizedCutRodAux(price, n, revenue); } private static int memoizedCutRodAux(int[] price, int n, int[] revenue) { int q; if (revenue[n] >= 0) // 若是revenue數組中有記錄,就返回數組中的結果 return revenue[n]; if (n == 0) q = 0; else { q = Integer.MIN_VALUE; for (int i = 1; i <= n; i++) q = Integer.max(q, price[i] + memoizedCutRodAux(price, n - i, revenue)); revenue[n] = q; } return q; } /** * 自底向上法 * * @param price 價格表 * @param n 待分割的長度 */ public static int bottomUpCutRod(int[] price, int n) { int[] revenue = new int[n + 1]; int q; revenue[0] = 0; for (int j = 1; j <= n; j++) { q = Integer.MIN_VALUE; for (int i = 1; i <= j; i++) q = Integer.max(q, price[i] + revenue[j - i]); revenue[j] = q; } return revenue[n]; }