動態規劃之揹包問題(一)

一切都要從一則故事說起。

話說有一哥們去森林裏玩發現了一堆寶石,他數了數,一共有n個。但他身上能裝寶石的就只有一個揹包,揹包的容量爲C。這哥們把n個寶石排成一排並編上號:0,1,2,…,n-1。第i個寶石對應的體積和價值分別爲V[i]和W[i](擦,目測這哥們是一苦逼程序員)。排好後這哥們開始思考:揹包總共也就只能裝下體積爲C的東西,那我要裝下哪些寶石才能讓我獲得最大的利益呢?

OK,如果是你,你會怎麼做?你斬釘截鐵的說:動態規劃啊!恭喜你,答對了。那麼讓我們來看看,動態規劃中最最最重要的兩個概念:狀態和狀態轉移方程在這個問題中分別是什麼。

我們要怎樣去定義狀態呢?這個狀態總不能是憑空想象或是從天上掉下來的吧。爲了方便說明,讓我們先實例化上面的問題。一般遇到n,你就果斷地給n賦予一個很小的數,比如n=3。然後設揹包容量C=10,三個寶石的體積爲5,4,3,對應的價值爲20,10,12。對於這個例子,我想智商大於0的人都知道正解應該是把體積爲5和3的寶石裝到揹包裏,此時對應的價值是20+12=32。接下來,我們把第三個寶石拿走,同時揹包容量減去第三個寶石的體積(因爲它是裝入揹包的寶石之一),於是問題的各參數變爲:n=2,C=7,體積{5,4},價值{20,10}。好了,現在這個問題的解是什麼?我想智商等於0的也解得出了:把體積爲5的寶石放入揹包(然後剩下體積2,裝不下第二個寶石,只能眼睜睜看着它溜走),此時價值爲20。這樣一來,我們發現,n=3時,放入揹包的是0號和2號寶石;當n=2時,我們放入的是0號寶石。這並不是一個偶然,沒錯,這就是傳說中的“全局最優解包含局部最優解”(n=2是n=3情況的一個局部子問題)。繞了那麼大的圈子,你可能要問,這都哪跟哪啊?說好的狀態呢?說好的狀態轉移方程呢?別急,它們已經呼之欲出了。

我們再把上面的例子理一下。當n=2時,我們要求的是前2個寶石,裝到體積爲7的揹包裏能達到的最大價值;當n=3時,我們要求的是前3個寶石,裝到體積爲10的揹包裏能達到的最大價值。有沒有發現它們其實是一個句式!OK,讓我們形式化地表示一下它們,定義d(i,j)爲前i個寶石裝到剩餘體積爲j的揹包裏能達到的最大價值。那麼上面兩句話即爲:d(2, 7)和d(3, 10)。這樣看着真是爽多了,而這兩個看着很爽的符號就是我們要找的狀態了。即狀態d(i,j)表示前i個寶石裝到剩餘體積爲j的揹包裏能達到的最大價值。上面那麼多的文字,用一句話概括就是:根據子問題定義狀態!你找到子問題,狀態也就浮出水面了。而我們最終要求解的最大價值即爲d(n, C):前n個寶石(0,1,2…,n-1)裝入剩餘容量爲C的揹包中的最大價值。狀態好不容易找到了,狀態轉移方程呢?顧名思義,狀態轉移方程就是描述狀態是怎麼轉移的方程(好廢話!)。那麼回到例子,d(2, 7)和d(3, 10)是怎麼轉移的?來,我們來說說2號寶石(記住寶石編號是從0開始的)。從d(2, 7)到d(3, 10)就隔了這個2號寶石。它有兩種情況,裝或者不裝入揹包。如果裝入,在面對前2個寶石時,揹包就只剩下體積7來裝它們,而相應的要加上2號寶石的價值12,d(3, 10)=d(2, 10-3)+12=d(2, 7)+12;如果不裝入,體積仍爲10,價值自然不變了,d(3,10)=d(2, 10)。記住,d(3, 10)表示的是前3個寶石裝入到剩餘體積爲10的揹包裏能達到的最大價值,既然是最大價值,就有d(3, 10)=max{ d(2,10), d(2, 7)+12 }。好了,這條方程描述了狀態d(i, j)的一些關係,沒錯,它就是狀態轉移方程了。把它形式化一下:d(i, j)=max{ d(i-1, j), d(i-1, j-V[i-1]) + W[i-1] }。注意討論前i個寶石裝入揹包的時候,其實是在考查第i-1個寶石裝不裝入揹包(因爲寶石是從0開始編號的)。至此,狀態和狀態轉移方程都已經有了。接下來,直接上代碼。

for(int i=0; i<=n; ++i){

for(int j=0;j<=C; ++j){

d[i][j] = i==0 ? 0 : d[i-1][j];

if(j>=V[i-1] && i>0)  d[i][j]>?= d[i-1][j-V[i-1]]+W[i-1];

}

}

i=0時,d(i, j)爲什麼爲0呢?因爲前0個寶石裝入揹包就是沒東西裝入,所以最大價值爲0。if語句裏,j>=V[i-1]說明只有當揹包剩餘體積j大於等於i-1號寶石的體積時,我才考慮把它裝進來的情況,不然d[i][j]就直接等於d[i-1][j]。i>0不用說了吧,前0個寶石裝入揹包的情況是邊界,直接等於0,只有i>0纔有必要討論,我是裝呢還是不裝呢。簡單吧,核心算法就這麼一丁點,接下來上完整代碼knapsack.cpp。

/**0-1 knapsack d(i, j)表示前i個物品裝到剩餘容量爲j的揹包中的最大重量**/
#include<cstdio>
using namespace std;
#define MAXN 1000
#define MAXC 100000

int V[MAXN], W[MAXN];
int d[MAXN][MAXC];

int main(){
	freopen("data.in", "r", stdin);//重定向輸入流
	freopen("data.out", "w", stdout);//重定向輸出流
	int n, C;
	while(scanf("%d %d", &n, &C) != EOF){
		for(int i=0; i<n; ++i)	scanf("%d %d", &V[i], &W[i]);
		
		for(int i=0; i<=n; ++i){
			for(int j=0; j<=C; ++j){
				d[i][j] = i==0 ? 0 : d[i-1][j];
				if(j>=V[i-1] && i>0)	d[i][j] >?= d[i-1][j-V[i-1]]+W[i-1];
			}
		}
		printf("%d\n", d[n][C]);//最終求解的最大價值
	}
	fclose(stdin);
	fclose(stdout);
	return 0;
}


其中freopen函數將標準輸入流重定向到文件data.in,這比運行程序時一點點手輸要方便許多,將標準輸出流重定向到data.out。data.in中每組輸入的第一行爲寶石數量n及揹包體積C,接下來會有n行的數據,每行兩個數對應的是寶石的體積及價值。本測試用例data.in如下:

5 10

4 9

3 6

5 1

2 4

5 1

4 9

4 20

3 6

4 20

2 4

5 10

2 6

2 3

6 5

5 4

4 6

data.out爲算法輸出結果,對應該測試用例,輸出結果如下:

19

40

15

好,至此我們解決了揹包問題中最基本的0/1揹包問題。等等,這時你可能要問,我現在只知道揹包能裝入寶石的最大價值,但我還不知道要往揹包裏裝入哪些寶石啊。嗯,好問題!讓我們先定義一個數組x,對於其中的元素爲1時表示對應編號的寶石放入揹包,爲0則不放入。讓我們回到上面的例子,對於體積爲5,4,3,價值爲20,10,12的3個寶石,如何求得其對應的數組x呢?(明顯我們目測一下就知道x={1 0 1},但程序可目測不出來)OK,讓我們還是從狀態說起。如果我們把2號寶石放入了揹包,那麼是不是也就意味着,前3個寶石放入揹包的最大價值要比前2個寶石放入揹包的價值大,即:d(3, 10)>d(2, 10)。再用字母代替具體的數字(不知不覺中我們就用了不完全歸納法哈),當d(i, j)>d(i-1, j)時,x(i-1)=1;OK,上代碼:

//輸出打印方案

int j = C;

for(int i=n; i>0; --i){

if(d[i][j] >d[i-1][j]){

         x[i-1] = 1;

                   j= j - V[i-1];//裝入第i-1個寶石後背包能裝入的體積就只剩下j - V[i-1]

         }

}

for(int i=0; i<n; ++i)  printf("%d ", x[i]);

 

好了,加入這部分內容,knapsack.cpp變爲如下:

/**0-1 knapsack d(i, j)表示前i個物品裝到剩餘容量爲j的揹包中的最大重量**/
#include<cstdio>
using namespace std;
#define MAXN 1000
#define MAXC 100000

int V[MAXN], W[MAXN], x[MAXN];
int d[MAXN][MAXC];

int main(){
	freopen("data.in", "r", stdin);
	freopen("data.out", "w", stdout);
	int n, C;
	while(scanf("%d %d", &n, &C) != EOF){
		for(int i=0; i<n; ++i)	scanf("%d %d", &V[i], &W[i]);
		for(int i=0; i<n; ++i)	x[i] = 0; //初始化打印方案
		
		for(int i=0; i<=n; ++i){
			for(int j=0; j<=C; ++j){
				d[i][j] = i==0 ? 0 : d[i-1][j];
				if(j>=V[i-1] && i>0)	d[i][j] >?= d[i-1][j-V[i-1]]+W[i-1];
			}
		}
		printf("%d\n", d[n][C]);
		
		//輸出打印方案
		int j = C;
		for(int i=n; i>0; --i){
			if(d[i][j] > d[i-1][j]){
				x[i-1] = 1;
				j = j - V[i-1];
			}
		}
		for(int i=0; i<n; ++i)	printf("%d ", x[i]);
		printf("\n");
	}
	fclose(stdin);
	fclose(stdout);
	return 0;
}

data.out輸出結果變爲:

19

1 1 0 1 0

40

1 0 1 0

15

1 1 0 0 1

 

至此,好像該解決的問題都解決了。當一個問題找到一個放心可靠的解決方案後,我們往往就要考慮一下是不是有優化方案了。爲了保持代碼的簡潔,我們暫且把寶石裝包方案的求解去掉。該算法的時間複雜度是O(n*C),即時間都花在兩個for循環裏了,這個應該是沒辦法再優化了。再看看空間複雜度,數組d用來保存每個狀態的值,空間複雜度爲O(n*C);數組V和W用來保存每個寶石的體積和價值,空間複雜度爲O(n)。程序總的空間複雜度爲O(n*C),這個是可以進一步優化的。首先,我們先把數組V和W去掉,因爲它們沒有保存的必要,改爲一邊讀入一邊計算:

int V = 0, W = 0;

……

for(int i=0; i<=n; ++i){

if(i>0)        scanf("%d %d", &V,&W);

for(int j=0; j<=C;++j){

         d[i][j] = i==0 ? 0 : d[i-1][j];

         if(j>=V && i>0)          d[i][j] >?= d[i-1][j-V]+W;

}

}

好了,接下來讓我們繼續壓榨空間複雜度。保存狀態值我們開了一個二維數組d,在看過把一維數組V和W變爲一個變量後,我們是不是要思考一下,有沒有辦法將這個二維數組也壓榨一下呢?換言之,這個二維數組中的每個狀態值我們真的有必要都保存麼?讓我們先來看一下以下的一張示意圖(參照《算法競賽入門經典》P169的圖畫的)


由上面那一小段優化過後的代碼可知,狀態轉移方程爲:d(i, j)=max{ d(i-1, j), d(i-1, j-V)+W },也就是在計算d(i, j)時我們用到了d(i-1,j)和d(i-1, j-V)的值。如果我們只用一個一維數組d(0)~d(C)來保存狀態值可以麼?將i方向的維數去掉,我們可以將原來二維數組表示爲一維數據:d(i-1, j-V)變爲d(j-V),d(i-1, j)變爲d(j)。當我們要計算d(i, j)時,只需要比較d(j)和d(j-V)+W的大小,用較大的數更新d(j)即可。等等,如果我要計算d(i, j+1),而它恰好要用到d(i-1, j)的值,那麼問題就出來了,因爲你剛剛纔把它更新爲d(i, j)了。那麼,怎麼辦呢?按照j遞減的順序即可避免這種問題。比如,你計算完d(i, j),接下來要計算的是d(i, j-1),而它的狀態轉移方程爲d(i, j-1)=max{ d(i-1, j-1), d(i-1, j-1-V)+W },它不會再用到d(i-1,j)的值!所以,即使該位置的值被更新了也無所謂。好,上代碼:

memset(d, 0, sizeof(d));

for(int i=0; i<=n; ++i){

if(i>0)        scanf("%d %d", &V,&W);

for(int j=C;j>=0; --j){

         if(j>=V && i>0)          d[j] >?= d[j-V]+W;

         }

}

優化後的完整代碼如下,此時空間複雜度僅爲O(C)。

/**0-1 knapsack d(i, j)表示前i個物品裝到剩餘容量爲j的揹包中的最大重量**/
#include<cstdio>
#include<cstdlib>
#include<cstring>
using namespace std;

int main(){
	freopen("data.in", "r", stdin);
	freopen("data.out", "w", stdout);
	int n, C, V = 0, W = 0;
	while(scanf("%d %d", &n, &C) != EOF){
		int* d = (int*)malloc((C+1)*sizeof(int));
		memset(d, 0, (C+1)*sizeof(int));
		
		for(int i=0; i<=n; ++i){
			if(i>0)	scanf("%d %d", &V, &W);
			for(int j=C; j>=0; --j){
				if(j>=V && i>0)	d[j] >?= d[j-V]+W;
			}
		}
		printf("%d\n", d[C]);
		free(d);
	}
	fclose(stdin);
	fclose(stdout);
	return 0;
}

OK,揹包問題暫時先講這麼多,以後接着講。

注:以上代碼均在Dev-Cpp下編譯運行成功,請大膽放心使用。:-)

網易博客:http://einking.blog.163.com/

新浪微博:http://weibo.com/h101010


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章