01 揹包的多種寫法

Table of Contents

01 揹包 OJ 題有:HDU 2602

本文參考資料 挑戰程序設計競賽第二版 P51 以及 揹包九講

01 揹包我覺得是最經典的動態規劃問題之一,同時也是揹包問題中的最簡單的情形之一。所以有效掌握其不同形式的解法無論是對我們理解動態規劃思想,還是以後方便閱讀別人的揹包問題的解法都是大有裨益的。

問題描述

有 n 個重量和價值分別爲 weighti,valueiweight_i, value_i 的物品。從這些物品中挑選出總重量不超過 WW 的物品,求所有挑選方案中價值總和的最大值。

限制條件 :

  • 1n1001\leqslant n\leqslant100
  • 1weighti,valuei1001\leqslant weight_i, value_i \leqslant100
  • 1W100001\leqslant W \leqslant10000

樣例

輸入

n=4
(w, v) = {(2,3), (1,2), (3,4), (2,2)}
W=5

輸出

7(選擇 0、1、3 號物品)

解決方法

遞歸

無優化

// 輸入
int n, W;
int weight[MAX_N], value[MAX_N];

// 從第 i 個物品開始挑選總重小於 j 的部分
int rec(int i, int j) {
    int res;
    if (i == N) {
        // 已經沒有剩餘物品了
        res = 0;
    } else if (j <= weight[i]) {
        // 當前物品的重量大於剩餘允許重量,無法挑選這個物品
        res = rec(i+1, j);
    } else {
        // 挑選和不挑選兩種情況都嘗試一下,從中選最優的
        res = max(rec(i+1,j), rec(i+1, j-weight[i])+value[i]);
    }

    return res;
}

void solve() {
    printf("%d\n", rec(0, W));
}

分析:這種方法的搜索深度是 n,而且每一層的搜索都需要兩次分支,最壞就需要 O(2n)O(2^n) 的時間,當 n 比較大時就沒辦法解了。所以我們得對原來的算法做些優化。

優化之前,我們先來看下針對樣例輸入的情形下 rec 遞歸調用的情況。

如圖所示,rec 以 (3,2) 爲參數調用了兩次。如果參數相同,返回結果也應該相同,所以第二次調用時如果還計算一遍原來計算過的結果就會白白浪費計算時間。

想要用到原來計算出的結果我們就得把第一次計算的結果記錄下來,然後每次計算前看看是否已經計算過。

剪枝(記憶化搜索)

int dp[MAX_N+1][MAX_W+1]; // 記憶化數組

int rec(int i, int j) {
    if (dp[i][j] >= 0) {
        // 已經計算過的話直接使用之前的結果
        return dp[i][j];
    }

    int res;
    if (i == N) {
        res = 0;
    } else if (j <= weight[i]) {
        res = rec(i+1, j);
    } else {
        res = max(rec(i+1,j), rec(i+1, j-weight[i])+value[i]);
    }

    // 將結果記錄在數組中
    dp[i][j] = res;

    return res;
}

void sole() {
    // 用 -1 表示尚未計算過,初始化整個數組
    memset(dp, -1, sizeof(dp));
    printf("%d\n", rec(0, W));
}

這樣微小的改進能降低多少複雜度呢?對於同樣的參數,只會在第一次被調用到時執行遞歸部分,之後都會直接返回。參數的組合不過 nWnW 種,而函數內只調用 2 次遞歸,所以只需要 O(nW)O(nW) 的複雜度就能解決這個問題。

只需要略微改良,可解的問題的規模就可以大幅提高。這種方法一般被稱爲記憶化搜索。

窮竭搜索

如果對記憶化搜索還不是很熟練的話,可能會把前面的搜索寫成下面這樣

// 目前選擇的物品價值總和是 sum,從第 i 個物品之後的物品中挑選重量總和小於 j 的物品
int rec(int i, int j, int sum) {
    int res;
    if (i == n) {
        // 已經沒有剩餘物品了
        res = sum;
    } else if (j <= weight[i]) {
        // 當前物品的重量大於剩餘允許重量,無法挑選這個物品
        res = rec(i+1, j, sum);
    } else {
        // 挑選和不挑選兩種情況都嘗試一下,從中選最優的
        res = max(rec(i+1,j), rec(i+1, j-weight[i]), sum+value[i]);
    }

    return res;
}

void solve() {
    printf("%d\n", rec(0, W, 0));
}

在需要剪枝的情況下,可能會像這樣把各種參數都寫在函數上,但是在這種情況下會讓記憶化搜索難以實現,需要注意。

循環

逆向

接下來,我們來仔細研究一下前面的算法利用到的記憶化數組 dp。我們記 dp[i][j] 爲 rec 定義中的『從第 i 個物品開始挑選總重小於 j 時,總價值的最大值』。於是,我們有如下遞推式(狀態轉移方程

dp[n][j]=0(因爲已經是最後一個物品,沒有多於的物品提供其自身價值了)dp[i][j]={dp[i+1][j](j&lt;weight[i])max(dp[i+1][j],dp[i+1][jweight[i]]+value[i])() dp[n][j] = 0 \text{(因爲已經是最後一個物品,沒有多於的物品提供其自身價值了)} dp[i][j] = \begin{cases} dp[i+1][j] &amp; (j&lt;weight[i]) \\ max(dp[i+1][j], dp[i+1][j-weight[i]]+value[i]) &amp; (其他) \end{cases}

如上所示,不用寫遞歸函數,直接利用遞推式將各項的值計算出來,簡單地用二重循環也可以解決這一問題。

int dp[MAX_N+1][MAX_W+1] // dp 數組

void solve() {
    for (int i = n-1; i >= 0; i--) {
        for (int j = 0; j <= W; j++) {
            if (j < weight[i]) {
                dp[i][j] = dp[i+1][j];
            } else {
                dp[i][j] = max(dp[i+1][j], dp[i+1][j-weight[i]]+value[i])
            }
        }
    }
    
    printf("%d\n", dp[0][W]);
}

這個算法的複雜度與前面相同,也是 O(nW)O(nW),但是簡潔了很多。所以這種方式一步步按順序求出問題的解的方法被稱爲動態規劃法,也就是常說的 DP。解決問題時既可以按照如上方法從記憶化搜索出發推導出遞推式,熟練後也可以直接得出遞推式。

正向

剛剛講到的 DP 中關於 i 的循環是逆向進行的。反之,如果按照如下的方式定義遞推關係的話,關於 i 的循環就能正向進行。

下面的 := 是『定義爲』的意思

dp[i+1][j]:=ijdp[0][j]=0()dp[i+1][j]={dp[i][j](j&lt;weight[i])max(dp[i][j],dp[i][jweight[i]]+value[i])() dp[i+1][j] := 從前 i 個物品中選出總重量不超過 j 的物品時總價值的最大值 dp[0][j] = 0 \text{()} dp[i+1][j] = \begin{cases} dp[i][j] &amp; (j&lt;weight[i]) \\ max(dp[i][j], dp[i][j-weight[i]]+value[i]) &amp; (其他) \end{cases}

上面這個方程非常重要,基本上所有跟揹包相關的問題的方程都是由它衍生出來的。所以有必要將它詳細解釋一下:『從前 i 件物品中選取總重量不超過 j 的物品時總價值的最大值』這個子問題,若只考慮第 i 件物品的策略(放或不放),那麼就可以轉化爲一個只和前 i-1 件物品相關的問題。

  • 如果不放第 i 件物品,那麼問題就轉化爲『從前 i-1 件物品選取總重量不超過 j 的物品時總價值的最大值』,價值爲 dp[i][j]
  • 如果放第 i 件物品,那麼問題就轉化爲『從前 i-1 件物品選取總重量不超過 j-weight[i] 的物品時總價值的最大值』,此時能獲得的最大價值就是 dp[i][j-weight[i]] 再加上通過放入第 i 件物品獲得的價值 value[i]。
void solve() {
    for (int i = 0; i < n; i++) {
        for (int j = 0; j <= W; j++) {
            if (j < weight[i]) {
                dp[i+1][j] = dp[i][j];
            } else {
                dp[i+1][j] = max(dp[i][j], dp[i][j-weight[i]]+value[i])
            }
        }
    }
    
    printf("%d\n", dp[0][W]);
}

此代碼參考『揹包九講 1.2 基本思路』

void solve() {
    for (int i = 0; i < n; i++) {
        for (int j = weight[i]; j <= W; j++) {
            dp[i+1][j] = max(dp[i][j], dp[i][j-weight[i]]+value[i])
        }
    }
    
    printf("%d\n", dp[0][W]);
}

此外,除了運用遞推方式逐項求解之外,還可以把狀態轉移想象成從『前 i 個物品中選取總重不超過 j 時的狀態』向『前 i+1 個物品中選取總重不超過 j』和『前 i+1 個物品中選取總重不超過 j+weight[i] 時的狀態』的轉移。於是可以實現成如下形式:

void solve() {
   for (int i = 0; i < n; i++) {
       for (int j = 0; j<= W; j++) {
           dp[i+1][j] = max(dp[i+1][j]), dp[i][j]);
           if (j+weight[i] <= W) {
               dp[i+1][j+weight[i]] = max(dp[i+1][j+weight[i]], dp[i][j]+value[i]);
           }
       }
   }
   printf("%d\n", dp[n][W]);
}

如果像上述所示,把問題寫成從當前狀態遷移成下一狀態的形式的話,需要注意初項之外也需要初始化(這個問題中,因爲總價值的總和至少是 0,所以初值設爲 0 就可以了,不過根據問題不同也有可能需要初始化成無窮大)。

一維數組

以上的時間空間複雜度是 O(nW)O(nW),其中的時間複雜度應該已經不能再優化了,但空間複雜度卻可以優化到 O(W)O(W)

上面將的基本思路都是有一個主循環 for (int i = 0; i < n; i++),每次算出來二維數組 dp[MAX_N][MAX_W] 的所有值。那麼,如果只用一個一維數組 dp[MAX_W],能不能保證第 i 次循環結束後 dp[j] 中表示的就是我們定義的狀態 dp[i][j] 呢?dp[i][j] 是由 dp[i-1][j] 和 dp[i-1][j-weight[i]] 兩個子問題遞推而來的,能否保證在推 dp[i][j] 時(也即在第 i 次主循環中推 dp[j])能夠取用 dp[i-1][j] 和 dp[i-1][j-weight[i]] 的值呢?

事實上,這要求在每次主循環中,我們以 jW0j \xleftarrow[]{} W\dots 0 的遞減順序計算 dp[j],這樣才能保證計算 dp[j] 時 dp[i-weight[i]] 保存的是狀態 dp[i-1][j-weight[i]] 的值。

此代碼參考『揹包九講 1.3 優化空間複雜度』

int dp[MAX_W+1];
int weight[MAX_N+1];
int value[MAX_N+1];

void solve() {
   for (int i = 0; i < n; i++) {
       for (int j = W; j >= weight[i]; j--) {
           dp[j] = max(dp[j-weight[i]]+value[i], dp[j]);
       }
   }

   printf("%d\n", dp[W]);
}

同一個問題可能會有各種各樣的解決方法,諸如搜索的記憶化或者利用遞推關係的 DP,再或者從狀態轉移考慮的 DP 等。

對於這些各式各樣的方法,不妨先把自己最喜歡的方式掌握熟練。但是,有些問題不用記憶化搜索也許很難求解,反之,不同 DP 複雜度就會變大的情況也有,所以最好要掌握各種形式的 DP。

說明

本文基本就是按開頭那兩個參考書裏的來的,不懂的地方可以去原文看看。

HDU 2602

至於提到的 HDU 2602 解法,可以參考,這兩種思路都是上面提到的:

http://acm.hdu.edu.cn/discuss/problem/post/reply.php?postid=37113&messageid=1&deep=0

#include <stdio.h>
#include <string.h>
#define SIZE 20000
#define max(a,b) ((a)>(b)?(a):(b))
int vol[SIZE];
int val[SIZE];
int dp[SIZE];
int n,v;
int main(int argc, char const *argv[]){
	int m;
	scanf("%d",&m);
	while(m--){
		scanf("%d%d",&n,&v);
		memset(dp,0,sizeof(dp));
		for(int i=0;i<n;i++)
			scanf("%d",&val[i]);
		for(int i=0;i<n;i++)
			scanf("%d",&vol[i]);
		for(int i=0;i<n;i++)
			for(int j=v;j>=vol[i];j--)
				dp[j]=max(dp[j],dp[j-vol[i]]+val[i]);
		printf("%d\n",dp[v]);
	}
	return 0;
}

http://acm.hdu.edu.cn/discuss/problem/post/reply.php?postid=11214&messageid=1&deep=0

#include <iostream>
#include <stdlib.h>
#include <algorithm>
using namespace std;
int value[1001],volume[1001],dp[1001][1001];

int main()
{
    int t,n,v;
    scanf("%d",&t);
    while(t--)
    {
        scanf("%d%d",&n,&v);
        //memset(dp,0,sizeof(dp));
        //memset(a,0,sizeof(a));
        //memset(b,0,sizeof(b));
             
        for(int i=1;i<=n;i++)
            scanf("%d",&value[i]);
        for(int i=1;i<=n;i++)
            scanf("%d",&volume[i]);
         
        for(int i=1;i<=n;i++)
        {
            for(int j=0;j<=v;j++) 
            {
                if(j >=volume[i])
                    dp[i][j] = max(dp[i-1][j],dp[i-1][j-volume[i]] + value[i]);
                else
                    dp[i][j] = dp[i-1][j];
            }
        }
             
        printf("%d\n",dp[n][v]);
    }
    return 0;
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章