動態規劃(一)詳解揹包問題

1 引言

1.1 背景

在許多算法中都有子程序重複計算的問題。在 Fibi 計算中採用的存儲前面幾個結果數值的方法並不是很通用。這樣, 在很多情況下存儲中間結果全列表的方法就非常有用了。
這種存儲子程序結果列表的算法設計方法就稱爲動態規劃(dynamic programming)。
——《數據結構與算法分析(C++版)(第三版)》

Fibi 指的是書中使用循環實現的斐波那契數列代碼,如下:

long Fibi(int n){
	// Fibi(46) is largest value that fits in a long
	Assert((n > 0) && (n < 47), "Input out of range");
	long past, prev, curr;
	past = prev = curr = 1;
	for(int i = 3; i <= n; i++){
		past = prev;  			// past holds Fibi(n-2)
		prev = curr;  			// prev holds Fibi(n-1)
		curr = past + prev;  	// curr holds Fibi(n)
	} 
	return curr;
}

友情鏈接:(轉)《劍指offer》中的斐波那契數列系列問題

2.2 動態規劃問題的性質

任何思想方法都有一定的侷限性,超出了特定條件,它就失去了作用。同樣,動態規劃也並不是萬能的。適用動態規劃的問題必須滿足最優化原理和無後效性。
—— 百度百科詞條“動態規劃”

動態規劃解決的是優化問題,企圖通過將問題劃分成一個個子問題進而求取最優解,所以要求子問題的解是必須是最優的。同時,由於子問題一般帶有重疊性,也就是帶有重複計算的特點,所以我們希望保存中間結果來消除冗餘計算。本質上,動態優化是一種空間換取時間的算法。

使用暴力解法求解優化問題的過程中,我們需要遍歷問題的狀態空間,開銷很大;使用進化算法,如遺傳算法時,種羣的進化帶有隨機性,儘管理論上我們最終會獲得最優解,但是解決諸如陷入局部最優、調參等問題時,我們耗費的精力也是很可觀的。

2 揹包問題

揹包問題(Knapsack problem)是一種組合優化的NP完全問題。問題可以描述爲:給定一組物品,每種物品都有自己的重量和價格,在限定的總重量內,我們如何選擇,才能使得物品的總價格最高。
也可以將揹包問題描述爲決定性問題,即在總重量不超過W的前提下,總價值是否能達到V?它是在1978年由Merkel和Hellman提出的。
——百度百科詞條“揹包問題”

2.1 01揹包

01揹包問題中每種物品只有1個,物品只有兩種狀態,要麼在揹包內,要麼不在揹包內。

先給出狀態轉移方程,之後進行分析:
P[i][j]=max(P[i1][j], P[i1][jVol[i]]+Value[i])P[i][j] = max(P[i-1][j], \ P[i-1][j-Vol[i]] + Value[i])

其中,ii 表示第 ii 個物體,jj 表示揹包的容量,Vol[i]Vol[i]表示第 ii 個物體的體積, Value[i]Value[i] 表示第 ii 個物體的價值。

P[i][j]P[i][j] 表示對於容量爲 jj 的揹包來說,前 ii 件物體放置可以得到的最大價值。(重要)

2.1.1 分析

正如我們在引言中提到動態規劃的概念,在搜索空間中搜索最優解的過程中,我們將問題劃分成一個個子問題,我們會不斷地嘗試、計算、回退,重複計算的開銷很大,所以我們需要保存中間結果,這就是我們使用矩陣 P[i][j]P[i][j] 的原因。

那麼怎麼決定每一個子問題最優解 P[i][j]P[i][j] 的取值呢?狀態轉移方程是怎麼得到的呢?

舉個例子,求容量爲 10 的揹包的最佳放置方案。

假如我們已經決定了前 i1i-1 件物品的最優方案,得到了 P[i1][10]P[i-1][10] 的取值,這個值可能是 100(隨便假設的),接下來我們要考慮第 ii 件物品了,第 ii 件物品的價值是 2(也是隨便假設的)。

若第 ii 個物體在揹包裏,P[i][10]P[i][10] 的取值肯定不小於 P[i1][10]P[i-1][10],爲什麼?因爲如果將第 ii 件物品放進入揹包,但取值反而減小了,說明有一些物品爲了給物品 ii 騰位置被拿出來了。這纔會導致取值不增反減,也會使得我們的假設不成立。

所以,我們需要考察放入物品 ii 後的取值能否大於 P[i1][10]P[i-1][10],否則就沒有更新的必要了。

我們希望放入物品 ii 後的方案是最優的,不妨這樣考慮:ii 個物體我們已經假設在揹包裏了,那麼我們留下的空間只有 10Vol[i]10-Vol[i],若能找到這 10Vol[i]10-Vol[i] 的空間的最優值,在這個最優值的基礎上加上物品 ii 的價值,不就是我們想要考察的P[i][10]P[i][10] 的最優取值嗎?(當然,是不是最優還要和 P[i1][10]P[i-1][10] 比較一番~)

而我們的矩陣正是保存着這樣的最優值,注意到這 10Vol[i]10-Vol[i] 的空間只能留給前 i1i-1 個物體,也就是說我們實際上考察的是 (P[i1][10Vol[i]]+Value[i])(P[i-1][10-Vol[i]] + Value[i]) 的取值能不能大於 P[i1][10]P[i-1][10]

假如我們運氣好,找到的 P[i1][10Vol[i]]P[i-1][10-Vol[i]] 爲99,那麼毫無疑問,P[i][10]P[i][10] 可以更新爲 99+2=10199+2 =101 了。

將我們的例子泛化爲一般形式就能得到狀態轉移方程了。

2.1.2 解決方案

假設 n 表示物體個數,v 表示揹包容量。

常見的解決方案有兩種,一種就是使用遞歸,狀態轉移方程實際上也是遞歸的遞推公式,代碼如下:

	private static int[] value;  // 價值數組
    private static int[] volume; // 體積數組
    
	private static int KnapsackCore(int n, int v) {
        if (n < 0 || v < volume[n]) return 0;

        int temp1 = KnapsackCore(n - 1, v);
        int temp2 = KnapsackCore(n - 1, v - volume[n]) + value[n];

        return temp1 >= temp2 ? temp1 : temp2;
    }

第二種就是使用中間矩陣 PP 自底向上求取最優解。

具體怎樣更新矩陣,下面以體積分別爲 (1,2,3,4,5),價值分別爲 (5,4,3,2,1) 的五個物品爲例,假設揹包的容量爲 10。根據狀態轉移方程有下表:
在這裏插入圖片描述
從左到右,從上到下更新每一行即可,其中的藍色箭頭表示更新數值的計算對象,比如,P[1][3]=P[11][32]+4=P[0][1]+4=9P[1][3] = P[1-1][3-2] + 4 = P[0][1] + 4 = 9

注意邊界條件,超出邊界的數值直接取0。比如求P[1][1]P[1][1] 時,P[1][1]=P[11][12]+4=P[0][1]+4=4P[1][1] = P[1-1][1-2] + 4 = P[0][-1] + 4 = 4,很顯然 P[0][1]P[0][-1] 已經超出範圍了,直接令 P[0][1]=0P[0][-1] = 0 ,所以 P[1][1]=max(P[0][1],P[0][1]+4)=5P[1][1] = max(P[0][1], P[0][-1]+4) = 5。迭代結束後,右下角就是最終的答案。

爲什麼使用 v+1v+1 列,而不是使用 vv 列呢?
首先 vv 是可以取到0的,我們允許輸入爲0,其次,便於代碼直接使用數組的列標作爲容量進行計算。

爲什麼使用 nn 行,而不使用 n+1n+1 行?
很多文章中解決的問題的矩陣大小是 (n+1)(v+1)(n+1) * (v+1),其實使用 nn 還是 n+1n+1 都可以。使用 n+1n+1 行的矩陣就要求我們初始化的時候將第 0 行全部初始化爲 0 ,然後從第 1 行開始更新,而使用 nn 行矩陣就需要注意邊界條件,主要是第 0 行的更新需要做好判斷。

2.1.3 代碼


	private static int Knapsack(int v, int[] value, int[] volume)  throws Exception{
        int n = volume.length;
        if (n != value.length) // 注意代碼的魯棒性,面試時崩潰就涼了
            throw new Exception("Bad input!");
        if (n <= 0 || v <= 0) return 0;  
        
        int P[][] = new int[n][v + 1];  // 構建n*(v+1)矩陣
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < v + 1; j++) {
                int temp1 =  i > 0 ? P[i - 1][j]:0;  	// 檢查數組越界
                int temp2 =  i > 0 && j >= volume[i] ?  // 檢查數組越界
                	P[i - 1][j - volume[i]] : 0;
                if(j >= volume[i])	temp2 += value[i];  // j能放下物品i
                
                P[i][j] = temp1 >= temp2 ? temp1 : temp2;
            }
        }
        show(n, v + 1, value, volume, P);
        return P[n-1][v];
    }
	// 格式化輸出
    private static void show(int v, int[] value, int[] volume, int[][] P) throws Exception {
    	int n = volume.length;
        if (n != value.length)
            throw new Exception("Bad input!");
        if (n <= 0 || v <= 0) return 0;
    	
        System.out.print("Volume Value    ");
        for (int i = 0; i < v; i++) {
            System.out.printf("%3d", i);
        }
        System.out.println();
        for (int i = 0; i < n; i++) {
			System.out.printf("%4d  %4d  %4d", volume[i], value[i], i);
            for (int j = 0; j < v; j++) {
                System.out.printf("%3d", P[i][j]);
            }
            System.out.println();
        }
    }
    // 主函數
    public static void main(String[] args) {
        int[] va = {5, 4, 3, 2, 1};
        int[] vo = {1, 2, 3, 4, 5}; 
        try {
            System.out.println("返回值:" + Knapsack(10, va, vo));
        } catch (Exception e){
            e.printStackTrace();
        }   	 	
    }

結果:
在這裏插入圖片描述

2.1.4 改進

觀察發現,下一行的更新完全依賴於上一行,所以我們實際上可以使用一個 2*(v+1) 的矩陣改進這個算法,矩陣的兩行數值交替更新。代碼如下:

    private static int Knapsack(int n, int v, , int[] value, int[] volume) throws Exception {
        int n = volume.length;
        if (n != value.length)
            throw new Exception("Bad input!");
        if (n <= 0 || v <= 0) return 0;

        int P[][] = new int[2][v + 1];
        int flag = 0;
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < v + 1; j++) {
                int temp1 = i > 0 ? P[1 - flag][j] : 0;  // i-1換成1-flag
                int temp2 = i > 0 && j >= volume[i] ?
                        P[1 - flag][j - volume[i]] : 0;  // i-1換成1-flag
                if (j >= volume[i]) temp2 += value[i];

                P[flag][j] = temp1 >= temp2 ? temp1 : temp2;  // i換成flag
            }
            flag = 1 - flag; 
        }
        show(2, v + 1, va, vo, P);
        return P[1 - flag][v];
    }

結果:
在這裏插入圖片描述

2.1.5 進一步改進

問題還可以進一步簡化,繼續觀察發現,每一次更新的元素的列標都在上一行讀取的元素之後,剛好是錯開的,所以使用一個一維數組也能解決,如下圖:
在這裏插入圖片描述
實際上,狀態轉移方程已經變成了:
P[j]=max(P[j], P[jVol[i]]+Value[i])P[j] = max(P[j], \ P[j-Vol[i]] + Value[i])

特別注意我們需要從數組的末端開始更新,這樣才能準確讀取到上一行的值。如果從前往後更新,後半部分元素讀取的計算對象實際上已經被覆蓋。代碼如下:

private static int Knapsack(int v, int[] value, int[] volume) throws Exception {
		int n = volume.length;
        if (n != value.length)
            throw new Exception("Bad input!");
        if (n <= 0 || v <= 0) return 0;
		
        int P[] = new int[v + 1];  // v+1 一維數組
        for (int i = 0; i < n; i++) {
            for (int j = v; j >= 0; j--) { 	// 從v往前更新
                int temp1 = P [j];
                int temp2 = j >= volume[i] ?  // 注意數組越界,同時判斷j能否放下物品
                	P[j - volume[i]] + value[i] : 0;

                P[j] = temp1 >= temp2 ? temp1 : temp2;
            }
        }
        return P[v];
    }

結果:
在這裏插入圖片描述

2.1.6 其他測試用例

value = {6,3,5,4,6};
volume = {2,2,6,5,4};
n = 5, v =10;

結果:
在這裏插入圖片描述

2.2 完全揹包和多重揹包

完全揹包問題中物品的數量是無限的,而多重揹包中的物品數量是有限的。

注意到完全揹包問題的物品數量雖然是無限的,但是由於揹包的容量是有限的,實際上每種物品可以取的數量都是有限的,不多於 v/volume[i]v/volume[i],所以兩者在一定程度上可以相互轉化。假如多重揹包中的每一種物品的數量都超過 v/volume[i]v/volume[i],實際上就是完全揹包問題。

下面將兩個問題統一解決。

2.2.1 分析

我們使用 num[i]num[i] 表示每種物品可以取的最大數量。對於多重揹包問題,這個數量來自用戶的輸入,對於完全揹包問題,需要我們通過 v/volume[i]v/volume[i] 進行初始化。

和 01 揹包問題相比,每一次決定的是不再是一個物體,而是 num[i]num[i] 個物體的狀態。所以,狀態轉移方程改寫爲:
f[i][j]=max(f[i1][j],f[i1][jk×volume[i]]+k×value[i])f[i][j] = max(f[i-1][j],f[i-1][j-k \times volume[i]]+k \times value[i])

其中,0knum[i]0 \le k \le num[i]

和 01 揹包一樣,對方程進行優化,使用一維數組解決,方程改寫爲:
f[j]=max(f[j],f[jk×volume[i]]+k×value[i])f[j] = max(f[j],f[j-k \times volume[i]]+k \times value[i])

2.2.2 代碼

    private static int Knapsack(int v, int[] value, int[] volume, int[] num) throws Exception {
        int n = volume.length;
        if (n != value.length ||(num!=null && n != num.length))
            throw new Exception("Bad input");
        if (n <= 0 || v <= 0) return 0;
    
        if (num == null) {  // 沒有傳入數量限制,默認爲完全揹包問題
            num = new int[n];
            for (int i = 0; i < n; i++) {
                num[i] = v / volume[i];
            }
        }
        
		int P[] = new int[v + 1];
        for (int i = 0; i < n; i++) {
            for (int j = v; j >= 0; j--) {
                for (int k = 0; k <= num[i]; k++) {
                    int temp1 = P[j];
                    int temp2 = j >= k * volume[i] ? 
                    	P[j - k * volume[i]] + k * value[i] : 0;

                    P[j] = temp1 >= temp2 ? temp1 : temp2;
                }
            }
        }
        return P[v];
    }

2.2.3 測試

  1. 完全揹包測試用例:
    value = {9,5,3,1};
    volume = {7,4,3,2};
    num = null;
    n = 4, v =10;
    
    結果:
    在這裏插入圖片描述
  2. 多重揹包測試用例:
    value = {20, 10, 6};
    volume = {2, 2, 1};
    num = {2, 5, 10};
    n = 3, v =8;
    
    結果:
    在這裏插入圖片描述

2.3 揹包問題的其他優化

  1. 對完全揹包問題輸入用例的優化。假如存在物體a,b,其中 a 的體積大於 b,但是 a 的價值低於 b,那麼可以直接捨去 a。因爲物體是無限的,選擇“性價比”高的物體肯定更優。
  2. 雖然不能整體上對時間開銷進行優化,但由於我們的循環次數較多,而且每一層循環都是完全循環,可以從這裏入手,進行剪枝操作
  3. 規範用戶輸入,減少無用功。比如刪除體積大於揹包容量的物體、檢查 num[i]num[i]有沒有超出揹包的最大容量等。

3 - 其他形式的揹包問題

假定有一個揹包,揹包中有一定的空間,空間的容積用一個整數 KK 來定義。有 nn 件物品,每一件物品都有一定的體積,第 ii 件物品的體積記爲整數 KiK_i。揹包問題是,是否存在着 nn 件物品的一個子集,這個子集中的物品的體積之和正好爲 KK
可以正式定義揹包問題如下:找出 S{1,2,...,n}S \subset \{ 1,2, ... , n\},使得
iSki=K\sum_{i \in S}k_i = K

——《數據結構與算法分析(C++版)(第三版)》第16章 算法模式

這個揹包問題和上面的揹包問題有什麼區別嗎?
這個揹包問題實際上已經不是一個優化問題,因爲沒有優化目標。但仍然可以應用動態規劃的思想來處理,我們希望通過劃分子問題、自底向上解決這個問題。

使用 P[i][j]P[i][j] 來表示前 i1i-1 個物體能否解決容量爲 jj 的問題。由於本題考察的是存不存在這麼一個子集滿足條件,而不是最大價值,所以我們需要思考一下矩陣存的中間結果是什麼?

3.1 第一種方法

矩陣保存一個boolean,若子問題解決,則該值爲 true,反之爲 false。

比如,有體積分別爲{1,3,2,5}\{ 1,3,2,5\} 的 4 個物體,更新矩陣時,P[0][4]P[0][4] 爲 false,因爲只放第一個物體不能達到容量 4,前 1 個物體不能解決問題;P[1][4]P[1][4] 爲 true,前 2 個物體都放進去剛好爲 4;P[2][4]P[2][4] 爲 true,雖然達到容量 4 並不需要放入第 3 個物體,但前 3 個物品能夠解決問題,故應該爲 true;同理,P[3][4]P[3][4] 也爲 true,儘管第 4 個物品同樣沒用。

如下表,注意我們特別指定容積爲 0 的時候恆存在滿足條件的子集——空集

物品體積 序號/容積 0 1 2 3 4
1 0 true true false false false
3 1 true true false true true
2 2 true true true true true
5 3 true true true true true

按照我們的思路,似乎從上往下,再從左往右更新比較好。但是矩陣的更新方向其實影響不大,無非是 ii 作爲外層循環還是 jj 作爲外層循環的問題。

和 01 揹包問題一樣,我們可以對這個算法進行優化,使用一維矩陣 P[j]P[j] 解決問題。考慮從一維矩陣的右邊,也就是末端向前更新,對於 P[j]P[j],有:

  1. 假如 P[j]P[j] 爲真,說明容量爲 jj 的子問題已經被前面 i1i-1 個物體解決了,不需要更新。
  2. 假如 P[j]P[j] 爲假,說明前面 i1i-1 個物體不能解決子問題,考察使用物體 ii 能否解決問題?若物體 ii 的體積等於 jj 或者 P[jvolume[i]]P[j - volume[i]] 爲真,則 P[j]P[j] 爲真,反之,不需要更新。

代碼如下:

    private static boolean Knapsack(int v, int[] volume) {
        int n = volume.length;
        if (n == 0 || v < 0) return false;

        boolean P[] = new boolean[v + 1];
        P[0] = true;  // 容量0恆有解
        for (int i = 0; i < n; i++) {
            for (int j = v; j >= 0; j--) {
                if (!P[j] && j >= volume[i]) { // 注意數組越界,越界表示不存在子問題
                    if (j == volume[i] || P[j - volume[i]]) 
                    	P[j] = true;
                }
            }
        }
        return P[v];
    }

測試用例:

volume = {9, 2, 7, 4, 6};
v = 10;

結果:
在這裏插入圖片描述

3.2 第二種方法

第一種方法只記錄了結果存不存在,但不能得到結果具體放入了哪些物體,無論是二維數組還是一維數組都不能。

所以,我們希望矩陣除了記錄前 ii 個物體能否解決問題外,還希望矩陣記錄每個物體對於解決這個子問題是否有幫助,以便可以找到子集的具體元素。

所以,P[i][j]P[i][j] 實際上對應了三種狀態:

  1. ii 個物體沒有解決問題 jj
  2. ii 個物體解決了問題 jj,但子集不需要物體 ii
  3. ii 個物體解決了問題 jj,子集包括物體 ii

據此,我們使用一些可標記三種狀態的數據類型即可,比如字符數組、整型數組等。代碼如下,使用了整型數組,其中 0,-1,1 分別對應三種狀態

   private static void Knapsack(int v, int[] volume) {
        int n = volume.length;
        if (n == 0) return;

        int P[][] = new int[n][v + 1];
        // 默認容量0恆有解
        for (int i = 0; i < n; i++) {
            P[i][0] = -1;
        }
        for (int i = 0; i < n; i++) {
            for (int j = 1; j < v + 1; j++) {
            	// 先判斷前i-1個物體有沒有解決問題
            	// 注意,取值爲1或者-1都代表問題被解決
                if (i > 0 && (P[i - 1][j] == -1 || P[i - 1][j] == 1)) {
                    P[i][j] = -1;
                }
                // 再判斷物體i能否解決問題
                if (j == volume[i]) {
                    P[i][j] = 1;
                } else if (i > 0 && j >= volume[i]) {
                    if (P[i - 1][j - volume[i]] == -1 || P[i - 1][j - volume[i]] == 1) {
                        P[i][j] = 1;
                    }
                }
            }
        }
    }

測試用例:

volume = {9, 2, 7, 4, 1};
v = 10;

結果:
在這裏插入圖片描述
怎麼獲得子集中的元素?
觀察 P[4][10]P[4][10] 有解,且子集包含物體 4。但物體 4 體積只有 1,必然需要依賴子問題 9;物體只能放一次,故指針往上走,觀察 P[41][101]=P[3][9]P[4-1][10-1] = P[3][9] 有解,但是物體 3 不在子集內,指針繼續往上走一步,觀察到 P[31][9]=P[2][9]P[3-1][9] =P[2][9] 有解,且包含物體 2,但體積仍不夠,還要依賴子問題 2,指針繼續走到P[21][97]=P[1][2]P[2-1][9-7] = P[1][2],物體 1 也被包含到子集中。終於,總體積已經達到了 10,所以子集爲{4,2,1}\{ 4,2,1\}
在這裏插入圖片描述
有些子問題有兩個 1,代表什麼意思?
這代表子集的形式不止一種。對於容量 10 的問題來說,子集就有{4,2,1}\{ 4,2,1\}{4,0}\{ 4,0\}

4 - 參考

  1. 揹包問題詳解:01揹包、完全揹包、多重揹包
  2. 揹包問題詳解
  3. 百度百科詞條“揹包問題”
  4. 百度百科詞條“動態規劃”
  5. 《數據結構與算法分析(C++版)(第三版)》

正文結束。

部分代碼多次修改,如有校對錯誤,請留言指出。

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