1. 程式人生 > >【演算法導論】動態規劃之“鋼管切割”問題

【演算法導論】動態規劃之“鋼管切割”問題

        動態規劃,其實跟分治法有些相似,基本思想都是將複雜的問題分成數個簡單的子問題,然後再去解決。它們的區別在於,分治法關注的子問題不相互“重疊”,而動態規劃關注的子問題,多是相互“重疊”的。比如在快速排序中,我們將資料分成兩部分,這兩部分再分別快速排序的遞迴思想,也就是將整個問題的排序劃分為子問題子陣列的排序,但是這兩個子陣列的排序之間並沒有相互聯絡,a子陣列的排序不會因為b子陣列的排序而得到任何“好處”或者“壞處”。但是有些時候,劃分的子問題之間卻是有聯絡的,比如下面的“鋼管切割”問題:

鋼管切割原始問題:

某公司生產長鋼管,然後一般,會將鋼條切斷,變成不同長度,然後去售賣。其中有個問題是,不同長度的鋼管

的售價是不一樣的,但是它們並不是完全按照比例來,比如2米的鋼管售價要比3米的鋼管售價要少,但是並不是2比3的比例。鋼管的長度售價表如下:

 長度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