動態規劃通常用於解決最優化問題,在這類問題中,通過做出一組選擇來達到最優解。在做出每個選擇的同時,通常會生成與原問題形式相同的子問題。當多於一個選擇子集都生成相同的子問題時,動態規劃技術通常就會很有效,其關鍵技術就是對每個這樣的子問題都儲存其解,當其重複出現時即可避免重複求解。
鋼條切割問題
Serling公司購買長鋼條,將其切割為短鋼條出售。切割工序本身沒有成本支出。公司管理層希望知道最佳的切割方案。假定我們知道Serling公司出售一段長為i英寸的鋼條的價格為pi(i=1,2,…,單位為美元)。鋼條的長度均為整英寸。圖15-1給出了一個價格表的樣例。
鋼條切割問題是這樣的:給定一段長度為n英寸的鋼條和一個價格表pi(i=1,2,…n),求切割鋼條方案,使得銷售收益rn最大。注意,如果長度為n英寸的鋼條的價格pn足夠大,最優解可能就是完全不需要切割。
一、問題分析
長度為n英寸的鋼條共有2n-1種不同的切割方案,因為在距離鋼條左端i(i=1,2,…n)英寸處,總是可以選擇切割或不切割。
如果一個最優解將鋼條切割為k段(對某個),那麼最優切割方案,將鋼條切割為長度分別為i1,i2...ik的小段得到的最大收益為。
對於,。其中,pn對應不切割,對於每個i=1,2,…,n-1,首先將鋼條切割為長度為i和n-i的兩段,接著求解這兩段的最優切割收益ri和rn-i(每種方案的最優收益為兩段的最優收益之和)。當完成首次切割後,我們將兩段鋼條看成兩個獨立的鋼條切割問題例項。通過組合兩個相關子問題的最優解,並在所有可能的兩段切割方案中選取組合收益最大者,構成原問題的最優解。
鋼條切割問題還存在一種相似的但更為簡單的遞迴求解方法:將鋼條從左邊切割下長度為i的一段,只對右邊剩下的長度為n-i的一段繼續進行切割,對左邊的一段則不再進行切割。這樣得到的公式為:。這樣原問題的最優解只包含一個相關子問題(右端剩餘部分)的解,而不是兩個。
二、演算法實現
1、樸素遞迴演算法
自頂向下遞迴實現參考程式碼為:
int CutRod(const int *p, int n) { if (n == 0) { return 0; } int q = -1; for (int i = 1; i <= n; ++i) { int tmp = p[i] + CutRod(p, n - i); if (q < tmp) { q = tmp; } } return q; }
分析:自頂向下遞迴實現的CutRod效率很差,原因在於CutRod反覆地用相同的引數值對自身進行遞迴呼叫,即它反覆求解相同的子問題。它的執行時間為。對於長度為n的鋼條CutRod考察了所有2n-1種可能的切割方案。遞迴呼叫樹共有2n-1個葉結點,每個葉結點對應一種可能的切割方案。
2、動態規劃演算法
樸素遞迴演算法之所以效率很低,是因為它反覆求解相同的子問題。因此,動態規劃方法仔細安排求解順序,對每個子問題只求解一次,並將結果儲存下來。如果隨後再次需要此子問題的解,只需查詢儲存的結果,而不必重新計算。因此,動態規劃方法是付出額外的記憶體空間來節省計算空間。
動態規劃有兩種等價的實現方法。
(1)帶備忘的自頂向下法
此方法仍按自然的遞迴形式編寫過程,但過程會儲存每個子問題的解(通常儲存在一個陣列或雜湊表中)。當需要一個子問題的解時,過程首先檢查是否已經儲存過此解。如果是,則直接返回儲存的值,從而節省了計算時間;否則,按通常方式計算這個子問題。
(2)自底向上法
這種方法一般需要恰當定義子問題“規模”的概念,使得任何子問題的求解都只依賴於“更小的”子問題的求解。因此,我們可以將子問題按照規模順序,由小至大順序進行求解。當求解某個子問題時,它所依賴的那些更小的子問題都已求解完畢,結果已經儲存。每個子問題只需求解一次,當我們求解它時,它的所有前提子問題都已求解完成。
說明:兩種方法得到的演算法具有相同的漸進執行時間,僅有的差異是在某些特殊情況下,自頂向下方法並未真正遞迴地考察所有可能的子問題。由於沒有頻繁的遞迴函式呼叫的開銷,自底向上方法的時間複雜度函式通常具有更小的係數。
下面給出動態規劃-帶備忘的自頂向下過程參考程式碼:
1 int MemoizedCutRodAux(const int *p, int n, int *r) 2 { 3 if (r[n] >= 0) 4 { 5 return r[n]; //首先檢查所需的值是否存在 6 } 7 8 int q = -1; 9 if (n == 0) 10 { 11 q = 0; 12 } 13 else 14 { 15 for (int i = 1; i <= n; ++i) 16 { 17 int tmp = p[i] + MemoizedCutRodAux(p, n - i, r); 18 if (q < tmp) 19 { 20 q = tmp; 21 } 22 } 23 } 24 r[n] = q; 25 26 return q; 27 } 28 29 int MemoizedCutRod(const int *p, int n) 30 { 31 int *r = new int[n + 1]; 32 for (int i = 0; i <= n; ++i) 33 { 34 r[i] = -1; 35 } 36 37 return MemoizedCutRodAux(p, n, r); 38 }
下面給出動態規劃-自底向上過程參考程式碼:
int BottomUpCutRod(const int *p, int n) { int *r = new int[n + 1]; r[0] = 0; for (int i = 1; i <= n; ++i) { int q = -1; for (int j = 1; j <= i; ++j) { int tmp = p[j] + r[i - j]; q = q > tmp ? q : tmp; } r[i] = q; } return r[n]; }
說明:自頂向下與自底向上演算法具有相同的漸進執行時間。
最後給出重構解參考程式碼:
1 #include <iostream> 2 using namespace std; 3 4 void ExtendedBUCutRod(const int *p, int n, int *r, int *s) 5 { 6 r[0] = 0; 7 for (int i = 1; i <= n; ++i) 8 { 9 int q = -1; 10 for (int j = 1; j <= i; ++j) 11 { 12 int tmp = p[j - 1] + r[i - j]; 13 if (q < tmp) 14 { 15 q = tmp; 16 s[i] = j; 17 } 18 } 19 r[i] = q; 20 } 21 } 22 23 //r[]儲存長度為i的鋼條最大收益,s[]儲存最優解對應的第一段鋼條的切割長度 24 void PrintBUCutRod(const int *p, int n, int *r, int *s) 25 { 26 ExtendedBUCutRod(p, n, r, s); 27 cout << "長度為" << n << "的鋼條最大收益為:" << r[n] << endl; 28 29 cout << "最優方案的鋼條長度分別為:"; 30 while (n > 0) 31 { 32 cout << s[n] << " "; 33 n = n - s[n]; 34 } 35 cout << endl; 36 } 37 38 //Test 39 int main() 40 { 41 int n; 42 while (cin >> n) 43 { 44 int *p = new int[n]; 45 for (int i = 0; i < n; ++i) 46 { 47 cin >> p[i]; 48 } 49 int *r = new int[n + 1]; 50 int *s = new int[n + 1]; 51 52 PrintBUCutRod(p, n, r, s); 53 } 54 55 return 0; 56 }
一個測試案例為: