動態規划 (Dynamic Programming)
什么是動態規划?
動態規划算法通常基於一個遞推公式及一個或多個初始狀態。當前子問題的解將由上一個子問題的解推出。
動態規划和分治法相似,都是通過分解,求解,並組合子問題來求解原問題。分治法將問題划分成相互獨立互不相交的子問題,遞歸求解子問題,再將它們的解組合起來,求出原問題的解。與之相反,動態規划應用於子問題重疊的情況,即不同的子問題具有公共的子子問題。在這種情況下,分治算法會做出許多不必要的工作,它會反復的求解那些公共子子問題。而動態規划算法對每個子子問題只求解一次,將結果保存到表格(數組)中,從而無需每次求解一個子子問題都要重新計算。
動態規划之鋼條切割問題
假定我們知道某公司出售一段長度為i英寸的鋼條的價格為p[i](i=1,2,3….)鋼條長度為整英寸如圖給出價格表的描述(任意長度的鋼條價格都有)
現在先給一段長度為n的鋼條,問怎么切割,獲得的收益最大 rn?
考慮n=4的時候,有以下8種切割方式
假如一個最優解把n段切成了k段(1<=k<=n),那么最優切割方案: i及下標表示第i段的長度,n為鋼條的總長度。
最大收益: p及下標表示第i段的收益,r為鋼條的總收益。
接下來對這個問題進行求解,我們先用普通的遞歸方法求解:
我們從鋼條的左邊切下長度為i的一段,只對右邊剩下長度為n-i的一段繼續進行切割,對左邊的不再切割。
這樣,當第一段長度為n的時候,收益為p[n],剩余長度為0,收益為0(這也是遞歸的基本問題),對應的總收益為p[n]。
當第一段長度為i的時候,收益為p[i],剩余長度為n-i,對應的總收益為p[i]加上剩余的n-i段再進行當第一段長度為i的時候,收益為p[i],剩余長度為n-i-i,....直到剩余長度為0,收益為0。
所以遞歸方程式為:
pi就是就是p[i],可以看出每次都要進行從1到n的遍歷。
代碼實現 - 自頂向下遞歸實現
1 #include <iostream> 2 int UpDown(int n, int * p)//參數n是長度,參數p是價格表 3 { 4 if (n == 0) return 0;//遞歸的基本問題 5 int tempMaxPrice = 0; 6 for (int i = 1; i < n + 1; i++) 7 { 8 int maxPrice = p[i] + UpDown(n - i, p); 9 if (maxPrice > tempMaxPrice) 10 { 11 tempMaxPrice = maxPrice; 12 } 13 } 14 return tempMaxPrice; 15 } 16 int main() 17 { 18 int p[11]{ 0, 1, 5, 8, 9, 10, 17, 17, 20, 24, 30 };//索引代表 鋼條的長度,值代表價格 19 std::cout << UpDown(4,p) <<std::endl; 20 }
動態規划的方法進行求解
上面的方法之所以效率很低,是因為它反復求解相同的子問題。比如求r[9]和r[8]的時候都求解了r[7],就是說r[7]被求解了兩次。因此,動態規划算法安排求解的順序,對每個子問題只求解一次,並將結果保存到數組中。如果隨后再次需要此子問題的解,只需查找保存的結果,不必重新計算。因此動態規划的方法是付出額外的內存空間來節省計算時間。
動態規划有兩種等價的實現方法(我們使用上面的鋼條切割問題為例,實現這兩種方法)
第一種方法是 帶備忘的自頂向下法:
此方法依然是按照自然的遞歸形式編寫過程,但過程中會保存每個子問題的解(通常保存在一個數組中)。當需要計算一個子問題的解時,過程首先檢查是否已經保存過此解。如果是,則直接返回保存的值,從而節省了計算時間;如果沒有保存過此解,按照正常方式計算這個子問題。我們稱這個遞歸過程是帶備忘的。
代碼實現 - 自頂向下動態規划實現
1 #include <iostream> 2 int result[11]{ 0 }; 3 int UpDown(int n, int* p)//求得長度為n的最大收益 4 { 5 if (n == 0) return 0; 6 if (result[n] != 0)//這里直接返回記錄的結果 7 { 8 return result[n]; 9 } 10 int tempMaxPrice = 0; 11 for (int i = 1; i < n + 1; i++) 12 { 13 int maxPrice = p[i] + UpDown(n - i, p); 14 if (maxPrice > tempMaxPrice) 15 { 16 tempMaxPrice = maxPrice; 17 } 18 } 19 result[n] = tempMaxPrice;//將計算過的長度為n的鋼條切割的最大收益記錄起來 20 return tempMaxPrice; 21 } 22 int main() 23 { 24 int p[11] = { 0, 1, 5, 8, 9, 10, 17, 17, 20, 24, 30 };//索引代表 鋼條的長度,值代表價格 25 std::cout << UpDown(4,p); 26 }
第二種方法是 自底向上法(常用的方法):
首先恰當的定義子問題的規模,使得任何問題的求解都只依賴於更小的子問題的解。因而我們將子問題按照規模排序,按從小到大的順序求解。當求解某個問題的時候,它所依賴的更小的子問題都已經求解完畢,結果已經保存到了數組中。
代碼實現 - 自底向上動態規划實現
1 #include <iostream> 2 int result[11]{ 0 }; 3 int BottomUp(int n, int* p) 4 { 5 for (int i = 1; i < n + 1; i++) 6 { 7 int tempMaxPrice = 0; 8 for (int j = 1; j <= i; j++)//下面取得 鋼條長度為i的時候的最大收益 9 { 10 int maxPrice = p[j] + result[i - j]; 11 if (maxPrice > tempMaxPrice) 12 { 13 tempMaxPrice = maxPrice; 14 } 15 } 16 result[i] = tempMaxPrice; 17 } 18 return result[n]; 19 } 20 int main() 21 { 22 23 int p[11] = { 0, 1, 5, 8, 9, 10, 17, 17, 20, 24, 30 };//索引代表 鋼條的長度,值代表價格 24 std::cout << BottomUp(4,p); 25 }
可以看出自頂向下的動態規划求解和普通的遞歸求解差不多,不過動態規划遞歸調用時帶了備忘錄,記錄了已經解決的問題,所以對於上文提到的r[7],我們只求解了一次。
自底向上的動態規划也用了備忘錄,不過它只是迭代求解,並沒有進行遞歸,所以這也是我們常用方法。
以上有什么不足的地方和應該改進的地方,歡迎各路大神批評指正,筆者一定虛心接受。謝謝!