【演算法導論】動態規劃之“鋼管切割”問題
動態規劃,其實跟分治法有些相似,基本思想都是將複雜的問題分成數個簡單的子問題,然後再去解決。它們的區別在於,分治法關注的子問題不相互“重疊”,而動態規劃關注的子問題,多是相互“重疊”的。比如在快速排序中,我們將資料分成兩部分,這兩部分再分別快速排序的遞迴思想,也就是將整個問題的排序劃分為子問題子陣列的排序,但是這兩個子陣列的排序之間並沒有相互聯絡,a子陣列的排序不會因為b子陣列的排序而得到任何“好處”或者“壞處”。但是有些時候,劃分的子問題之間卻是有聯絡的,比如下面的“鋼管切割”問題:
鋼管切割原始問題:
某公司生產長鋼管,然後一般,會將鋼條切斷,變成不同長度,然後去售賣。其中有個問題是,不同長度的鋼管
長度i | 1 2 3 4 5 6 7 8 9 10 |
價格Pi | 1 5 8 9 10 17 17 20 24 30 |
於是問題就來了,比如30米長的鋼管,要如何切割,切割成多長的幾條,才能讓售價最高,收益最高呢?
求解最佳收益和對應的分配方法:
樸素演算法:
最簡單直接的想法,就是用暴力破解,n長的鋼管,可以分解成i長和n-i長的兩段,因為i可以從0~n取值,所以我們可以對i不進行繼續切割,於是對於長為i的這一段,可以直接呼叫價錢陣列p[i]來得到價錢,然後加上對n-i遞迴呼叫求最優收益的函式的返回值。在過程之中記錄這些組合的最優收益,等迴圈結束的時候,就能得到最優的收益價錢。
假設r[n]代表的是n長的鋼管的切割最佳收益值,陣列p代表上面表中的價格,其中p[0]=0,從p[1]~p[10]對應上面表中的資料,那麼按照上面的想法,有公式:
r[n]=max(p[i]+r[n-i]),i從1到n,當n=0時,r[n]=0,因為0長的鋼管售價當然為0。
於是給以下實現程式碼:
int cut_rod(int* p, int n) { if (n == 0) { return 0; } int q = -1; for (int i = 1; i <= n; i++) { /* * 將n長的鋼條,分成i和n-i的兩段,i長的那段不切割,而n-i的那段求最大 * 切割收益方式,然後相加;而q值是所有的組合中,最大收益的那個 */ q = max(q, p[i] + cut_rod(p, n - i)); } return q; }
這種方法比較容易理解,但是效能是不是好呢?
可以簡單的以n=4的情況來看一下:
n=4的劃分(其中前面的那一段是直接使用p[i],後面一段呼叫函式來求最佳收益):
cut_rod(p,4)的劃分可能:
①1長和3長:p[1]+cut_rod(p,3)
②2長和2長:p[2]+cut_rod(p,2)
③3長和1長:p[3]+cut_rod(p,1)
④4長和0長:p[4]+cut_rod(p,0)
而其中cut_rod(p,3)又可以劃分為陣列p中元素與cut_rod(p,0),cut_rod(p,1)和cut_rod(p,2);以此類推,可以給出一種以遞迴呼叫樹的形式展示cut_rod遞迴呼叫了多少次:
不難從圖中看出,做了大量重複工作,以n=2的節點為例,分別在n=4和n=3的時候都被呼叫了。根據上圖,可以給出遞迴呼叫次數的一個公式,假設T(n)表示cut_rod第二個引數為n時的呼叫次數,T(0)這時候是為1的,因為根結點的第一次呼叫也要算進去。於是有:
T(n)=1+T(0)+T(1)+...+T(n-1)
使用歸納法,可以比較容易的得出:T(n)=2^n。
指數次冪的呼叫次數,顯然太大,我們稍微讓n大一點,則會讓整個過程變的漫長。
動態規劃演算法:
而實際上我們不需要在每次都去重新計算cut_rod的在n=2時的結果,只需要在第一次計算的時候將結果儲存起來,然後再需要的時候直接使用即可。這其實就是所謂的動態規劃演算法。
這裡的思路有兩種,一種叫帶備忘的自頂向下方法,是順著之前的程式碼,當需要的時候去檢查是不是已經計算好了,如果是,則直接使用,如果不是,則計算,並儲存結果。第二種思路是自底向上方法,不論需不需要,先將子問題一一解決,然後再來解決更一級的問題,但要注意的是,我們需要先從最小的子問題開始,依次增加規模,這樣每一次解決問題的時候,它的子問題都已經計算好了,直接使用即可。
帶備忘的自頂向下方法:
int memoized_cut_rod_aux(int* p, int n, int* r) {
if (r[n] >= 0) {
return r[n];
}
int q = -1;
if (n == 0) {
q = 0;
} else {
for (int i = 1; i <= n; i++) {
q = max(q, p[i] + memoized_cut_rod_aux(p, n - i, r));
}
}
r[n] = q;
return q;
}
/*
* 自頂向上的cut-rod的過程
*/
int memoized_cut_rod(int* p, int n) {
int* r = new int[n + 1];
//初始化r陣列,r陣列用來存放,某種解決方案的最大收益值,對於n長的鋼條而言,有n+1種切割方案,所以陣列n+1長
for (int i = 0; i <= n; i++) {
r[i] = -1;
}
return memoized_cut_rod_aux(p, n, r);
}
自底向上的方法:
/*
* 自底向上的方式,先計算更小的子問題,然後再算較大的子問題,由於較大的子問題依賴於更小的子問題的答案,所以在計算較
* 大的子問題的時候,就無需再去計算更小的子問題,因為那答案已經計算好,且儲存起來了
*/
int bottom_up_cut_rod(int p[], int n) {
int* r = new int[n + 1];
r[0] = 0; //將r[0]初始化為0,是因為0長的鋼條沒有收益
for (int j = 1; j <= n; j++) {
int q = -1;
/*
* 這裡不用i=0開始,因為i=0開始不合適,因為這裡總長就是為j,而劃分是i和j-i的劃分,如果i等於0,那麼
* 就意味著要知道r[j-0]=r[j]的值也就是j長的最好劃分的收益,但是我們這裡不知道。而且對於p[0]而言本身就沒有意義
* p陣列中有意義的資料下標是從1到n的
*/
for (int i = 1; i <= j; i++) {
q = max(q, p[i] + r[j - i]); //
}
r[j] = q;
}
return r[n];
}
上面兩種演算法的時間複雜度都是O(n^2)。
重構解
上面的程式碼只給出了最優的收益值,但是卻沒有給出最優收益到底是在那種切割分配方式下得到的,比如說n=9時,最佳收益為25,要分成3和6兩段。這裡可以使用另一個數組s來儲存分段情況,比如s[9]儲存3,然後我們讓n=9-3,就可以得到s[6]的最佳分段情況,發現就是6,於是就不需要繼續。
只需要將程式碼稍微修改即可達到目的:
#include<iostream>
using namespace std;
/*
* 儲存結果的結構體,裡面包含r和s兩個陣列,分別儲存最佳收益和最佳收益時的分段數值
*/
struct result {
int* r;
int* s;
int len;
result(int l) :
r(), s(), len(l) {
r = new int[len];
s = new int[len];
r[0] = 0;
}
~result() {
delete[] r;
delete[] s;
}
};
result* extended_bottom_up_cut_rod(int p[], int n) {
result* res = new result(n + 1);
int q = -1;
//外層的迴圈代表的是保留的不切割的那段
for (int i = 1; i <= n; i++) {
//內層的迴圈代表的是要分割的,且要求出最佳分割的那段
for (int j = 1; j <= i; j++) {
if (q < p[j] + res->r[i - j]) {
q = p[j] + res->r[i - j];
res->s[i] = j;
}
}
res->r[i] = q;
}
return res;
}
int main() {
int p[] = { 0, 1, 5, 8, 9, 10, 17, 17, 20, 24, 30 };
int n = 9;
result* res = extended_bottom_up_cut_rod(p, n);
cout << "最佳收益:" << res->r[9] << endl;
//迴圈輸出實際的最佳分割段長
cout << "分段情況:";
while (n > 0) {
cout << res->s[n] << ' ';
n = n - res->s[n];
}
delete res;
return 0;
}
執行上面程式,我們就可以的得到長度為9的鋼管的最佳收益以及對應的切割情況:
最佳收益:25
分段情況:3 6