咱們先從兩個問題入手,來學習動態規劃。java
某公司想要把一段長度爲n的鋼條切割成若干段後賣出,目前市場上長度爲i(0<i<10,i爲整數)的鋼條的價格行情以下:學習
長度i | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 其餘 |
---|---|---|---|---|---|---|---|---|---|---|---|
價格pi | 1 | 5 | 8 | 9 | 10 | 17 | 17 | 20 | 24 | 30 | 0 |
不考慮切割的成本(即切割成多少段隨意),請你給出一個使收益最大化的切割方案。測試
長度爲n的鋼條共有\(2^{n-1}\)種切割方案,最簡單作法是採用暴力方法,比較全部方案的收益,找出最大值。spa
暴力破解方法的關鍵是如何對全部的可能狀況進行不重不漏的分類。做以下考慮:code
由於不管咱們怎麼切割,總能夠當作是一段完整的長度爲i的鋼條加上另外一部分總長度爲n-i的鋼條(可能被切割,也可能沒有)。
那麼切割長度爲n的鋼條的最大收益能夠用以下公式表出:遞歸
\[ r_n = \max_{1\leq i\leq n}( p_i + r_{n-1}) \]數學
所以,咱們能夠採用一種被叫作自頂向下的遞歸方法去解決該問題。table
public static int cutRod(int[] p, int n) { if (n == 0) { return 0; } int max = Integer.MIN_VALUE; for (int i = 1; i <= n && i <= p.length; i++) { max = Integer.max(max, p[i - 1] + cutRod(p, n - i)); } return max; }
在測試時,咱們發現,當鋼條的長度稍微變大(好比n=30)時,上述程序的運行時間會大大增大。仔細考慮緣由,會發現實際上咱們作了不少重複的遞歸操做。好比在求解cutRod(p, n)
過程當中,咱們會遞歸求解cutRod(p, 0 ~ n-1)
,而在求解cutRod(p, n-1)
的過程當中,一樣咱們會遞歸求解cutRod(p, 1 ~ n-1)
,能夠看出,僅僅就是這兩次的調用,就重複調用了n-2次。時間效率固然會降低。class
用一個樹狀圖很容易就能看出(以n=4爲例):效率
設對於長度爲n的鋼條,上述程序的運行時間爲T(n),則T(n)可用以下遞歸式表示:
\[ T(n) = 1+ \sum_{i=0}^{n-1}T(j) \]
用數學概括法很容易計算出\(T(n) = 2^n\)。這也就解釋了爲何隨着n的「簡單」增大,時間會猛增。
既然上述程序重複計算了不少次,那麼咱們能夠將每次計算的結果保存起來,下次再須要計算一樣的問題時,就直接取出咱們計算的結果。
下面是改進以後的代碼:
public static int memoziedCutRod(int[] p, int n) { memoziedArray = memoziedArray == null ? new int[n] : memoziedArray; if (n == 0) { return 0; } if (memoziedArray[n - 1] > 0) { return memoziedArray[n - 1]; } int max = Integer.MIN_VALUE; for (int i = 1; i <= n && i <= p.length; i++) { max = Integer.max(max, p[i - 1] + memoziedCutRod(p, n - i)); } memoziedArray[n - 1] = max; return max; }
再次測試發現,時間效率有明顯的提升。
若是上述代碼再加上一個記錄各長度最優分割方案的「完整段」的長度的「備忘錄」,咱們即可以很容易的找出長度爲1~n的全部狀況的詳細的最優切割方案。
如下是n=10時的狀況:
i | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
---|---|---|---|---|---|---|---|---|---|---|---|
r[i] | 0 | 1 | 5 | 8 | 10 | 13 | 17 | 18 | 22 | 25 | 30 |
s[i] | 0 | 1 | 2 | 3 | 2 | 2 | 6 | 1 | 2 | 3 | 10 |
咱們用一個簡化版的遞歸樹再來描述上述調用過程,以n=4爲例子:
上述的自頂向下的調用過程在解決問題時,是從最大規模的問題入手,而最大規模的問題是依賴比它小的子問題的,所以便遞歸的去解決子問題,直到全部的子問題都解決了,該問題也就解決了。
既然咱們已經知道了,每一個問題的求解必將依賴它的子問題的解,那麼咱們何不直接按問題規模的從小到大的順序去依次解決呢?
public static int bottomUpCutRod(int[] p, int n) { int[] memoziedArray = new int[n + 1]; memoziedArray[0] = 0; int max = Integer.MIN_VALUE; for (int i = 1; i <= n; i++) { for (int j = 1; j <= i && j <= p.length; j++) { max = Integer.max(max, p[j - 1] + memoziedArray[i - j]); } memoziedArray[i] = max; } return max; }
在上述代碼中,咱們用了兩層嵌套循環替換了遞歸調用。其中,外層循環來控制問題的規模(規模從i~n);內層循環來求解當前規模的問題。由於在每次求解規模爲i的問題時,其子問題,即問題規模爲1~i-1的問題都已經求解出(存放在)memoziedArray
中,所以能夠從memoziedArray
中直接讀取出最優解。
上面咱們已經說過,不帶「備忘」的遞歸調用的時間複雜度爲\(2^n\);帶了「備忘」的自底向上的方法的時間複雜度爲\(n^2\),實際上就是一個等差數列的求和;能夠想象,自定向下的遞歸調用實際上和自底向上的方法沒有太大的差異(只是求解方向順序的不一樣,固然,遞歸調用因爲須要壓棧,空間複雜度會較大一些),所以時間複雜度也是\(n^2\)。
下一篇會從矩陣乘法鏈問題入手,繼續探討動態規劃問題。