1. 程式人生 > >0-1揹包問題的四種寫法

0-1揹包問題的四種寫法

本節回顧0-1揹包的基本模型,關於它的實現有很多種寫法,這裡對不同實現做個簡單列舉,主要是寫程式碼練手了,主要有以下幾方面內容:

==0-1揹包問題定義 & 基本實現
==0-1揹包使用滾動陣列壓縮空間
==0-1揹包使用一維陣列
==0-1揹包恰好背滿
==0-1揹包輸出最優方案
========================================

0-1揹包問題定義 & 基本實現

問題:有個容量為V大小的揹包,有很多不同重量weight[i](i=1..n)不同價值value[i](i=1..n)的物品,每種物品只有一個,想計算一下最多能放多少價值的貨物。

DP的關鍵也是難點是找到最優子結構和重疊子問題,進而找到狀態轉移方程
,編碼就相對容易些。最優子結構保證每個狀態是最優的,重疊子問題也即n狀態的求法和n-1狀態的求法是一樣的;DP在實現上一般是根據狀態轉移方程自底向上的迭代求得最優解(也可以使用遞迴自頂向下求解)。
回到0-1揹包,每個物體i,對應著兩種狀態:放入&不放入揹包。揹包的最優解是在面對每個物體時選擇能夠最大化揹包價值的狀態。0-1揹包的狀態轉移方程為
f(i,v) = max{ f(i-1,v), f(i-1,v-c[i])+w[i] }
f(i,v)表示前i個物體面對容量為v時揹包的最大價值,c[i]代表物體i的cost(即重量),w[i]代表物體i的價值;如果第i個物體不放入揹包,則揹包的最大價值等於前i-1個物體面對容量v的最大價值;如果第i個物體選擇放入,則揹包的最大價值等於前i-1個物體面對容量v-cost[i]的最大價值加上物體i的價值w[i]。
對於實現,一般採用一個二維陣列(狀態轉移矩陣)dp[i][j]來記錄各個子問題的最優狀態,其中dp[i][j]表示前i個物體面對容量j揹包的最大價值。

下面給出0-1揹包的基本實現,時間複雜度為O(N*V),空間複雜度也為O(N*V),初始化的合法狀態很重要,對於第一個物體即f[0][j],如果容量j小於第一個物體(編號為0)的重量,則揹包的最大價值為0,如果容量j大於第一個物體的重量,則揹包最大價值便為該物體的價值。為了能單步驗證每個狀態的最優解,程式最後將狀態轉移矩陣的有效部分輸出到了檔案。

程式碼如下:

#include <iostream>
using namespace std;
 
/* 0-1揹包 版本1
 * Time Complexity  O(N*V)
 * Space Complexity O(N*V)
 * 設 V <= 200 N <= 10
 * 狀態轉移方程:f(i,v) = max{ f(i-1,v), f(i-1,v-c[i])+w[i] }
 */
 
int maxValue[11][201]; /* 前i個物體面對容量j的最大價值,即子問題最優解 */
int weight[11];
int value[11];
int V, N;
 
void main()
{
    int i, j;
    scanf("%d %d",&V, &N);
    for(i = 0; i < N; ++i)
    {
        scanf("%d %d",&weight[i],&value[i]);
    }
    for(i = 0; i < N; ++i)
    {
        for(j = 0; j <= V; ++j) /* 容量為V 等號 */
        {
            if(i > 0)
            {
                maxValue[i][j] = maxValue[i-1][j];
                if(j >= weight[i]) /* 等號 */
                {
                    int tmp = maxValue[i-1][j-weight[i]] + value[i];
                    maxValue[i][j] = ( tmp > maxValue[i][j]) ? tmp : maxValue[i][j];
                }
            }else   /* 陣列第0行賦值 */
            {
                if(j >= weight[0])
                    maxValue[0][j] = value[0];
            }
        }
    }
 
    printf("%d",maxValue[N-1][V]);
 
    /* 重定向輸出結果到檔案 */
    freopen("C:\\dp.txt","w",stdout);
    for(i = 0; i <= N; ++i)
    {
        for(j = 0; j <= V; ++j)
        {
            printf("%d   ",maxValue[i][j]);
        }
        printf("\n");
    }
 
}
測試用例:
10 3
3   4
4   6
5   7

程式輸出的狀態轉移矩陣如下圖,第一行表示第1個物體對於容量0至V時的最優解,即揹包最大價值。該實現方法是0-1揹包最基本的思想,追蹤狀態轉移矩陣有助於加深理解,POJ上單純的0-1揹包題目也有不少,如3624等,可以水一下,加深理解。

========================================

0-1揹包使用滾動陣列壓縮空間

所謂滾動陣列,目的在於優化空間,從上面的解法我們可以看到,狀態轉移矩陣使用的是一個N*V的陣列,在求解的過程中,我們可以發現,當前狀態只與前一狀態的解有關,那麼之前儲存的狀態資訊已經無用了,可以捨棄的,我們只需要空間儲存當前的狀態和前一狀態,所以只需使用2*V的空間,迴圈滾動使用,就可以達到跟N*V一樣的效果。這是一個非常大的空間優化。
程式碼如下,我們可以在每輪內迴圈結束後輸出當前狀態的解,與上面使用二維陣列輸出的狀態轉移矩陣對比,會發現是一樣的效果,重定向輸出到文字有助加深理解。
#include <iostream>
using namespace std;
 
/* 0-1揹包 版本2
 * Time Complexity  O(N*V)
 * Space Complexity O(2*V)
 * 設 V <= 200 N <= 10
 * 狀態轉移方程:f(i,v) = max{ f(i-1,v), f(i-1,v-c[i])+w[i] }
 */
 
int maxValue[2][201]; /* 前i個物體面對容量j的最大價值,即子問題最優解 */
int weight[11];
int value[11];
int V, N;
 
void main()
{
    int i, j, k;
    scanf("%d %d",&V, &N);
    for(i = 0; i < N; ++i)
    {
        scanf("%d %d",&weight[i],&value[i]);
    }
    for(i = 0; i < N; ++i)
    {
        for(j = 0; j <= V; ++j) /* 容量為V 等號 */
        {
            if(i > 0)
            {
                k = i & 1;    /* i%2 獲得滾動陣列當前索引 k */
 
                maxValue[k][j] = maxValue[k^1][j];
                if(j >= weight[i]) /* 等號 */
                {
                    int tmp = maxValue[k^1][j-weight[i]] + value[i];
                    maxValue[k][j] = ( tmp > maxValue[k][j]) ? tmp : maxValue[k][j];
                }
            }else   /* 陣列第0行賦值 */
            {
                if(j >= weight[0])
                    maxValue[0][j] = value[0];
            }
        }
    }
 
    printf("%d",maxValue[k][V]);
 
    /* 重定向輸出結果到檔案 */
    freopen("C:\\dp.txt","w",stdout);
    for(i = 0; i <= 1; ++i)
    {
        for(j = 0; j <= V; ++j)
        {
            printf("%d ",maxValue[i][j]);
        }
        printf("\n");
    }
 
}
這種空間迴圈滾動使用的思想很有意思,類似的,大家熟悉的斐波那契數列,f(n) = f(n-1) + f(n-2),如果要求解f(1000),是不需要申請1000個大小的陣列的,使用滾動陣列只需申請3個空間f[3]就可以完成任務。

0-1揹包使用一維陣列

使用滾動陣列將空間優化到了2*V,在揹包九講中提到了使用一維陣列也可以達到同樣的效果,個人認為這也是滾動思想的一種,由於使用一維陣列解01揹包會被多次用到,完全揹包的一種優化實現方式也是使用一維陣列,所以我們有必要理解這種方法。
如果只使用一維陣列f[0…v],我們要達到的效果是:第i次迴圈結束後f[v]中所表示的就是使用二維陣列時的f[i][v],即前i個物體面對容量v時的最大價值。我們知道f[v]是由兩個狀態得來的,f[i-1][v]和f[i-1][v-c[i]],使用一維陣列時,當第i次迴圈之前時,f[v]實際上就是f[i-1][v],那麼怎麼得到第二個子問題的值呢?事實上,如果在每次迴圈中我們以v=v…0的順序推f[v]時,就能保證f[v-c[i]]儲存的是f[i-1][v-c[i]]的狀態。狀態轉移方程為:
f(v) = max{ f(v), f(v-c[i])+w[i] }     v = V...0; 
我們可以與二維陣列的狀態轉移方程對比一下
f(i,v) = max{ f(i-1,v), f(i-1,v-c[i])+w[i] }
正如我們上面所說,f[v-c[i]]就相當於原來f[i-1][v-c[i]]的狀態。如果將v的迴圈順序由逆序改為順序的話,就不是01揹包了,就變成完全揹包了,這個後面說。這裡舉一個例子理解為何順序就不是01揹包了

假設有物體z容量2,價值vz很大,揹包容量為5,如果v的迴圈順序不是逆序,那麼外層迴圈跑到物體z時,內迴圈在v=2時,物體z被放入揹包,當v=4時,尋求最大價值,物體z放入揹包,f[4]=max{f[4],f[2]+vz},這裡毫無疑問後者最大,那麼此時f[2]+vz中的f[2]已經裝入了一次物體z,這樣一來該物體被裝入揹包兩次了就,不符合要求,如果逆序迴圈v,這一問題便解決了。
程式碼如下,為了加深理解,可以在內迴圈結束輸出每一個狀態的情況到文字中,會發現與使用二維陣列時的狀態轉移矩陣都是一樣一樣的。
#include <iostream>
using namespace std;
 
/* 0-1揹包 版本3
 * Time Complexity  O(N*V)
 * Space Complexity O(V)
 * 設 V <= 200 N <= 10
 * 狀態轉移方程:v = V...0; f(v) = max{ f(v), f(v-c[i])+w[i] }
 */
 
int maxV[201];    /* 記錄前i個物品中容量v時的最大價值 */
int weight[11];
int value[11];
int V, N;
 
void main()
{
    int i, j;
    scanf("%d %d",&V, &N);
    for(i = 0; i < N; ++i)
    {
        scanf("%d %d",&weight[i],&value[i]);
    }
    /*
     * 對於第i輪迴圈
     * 求出了前i個物品中面對容量為v的最大價值
    */
    for(i = 0; i < N; ++i)
    {
        /*
         * 內迴圈實際上講maxV[0...v]滾動覆蓋前一輪的maxV[0...V]
         * 可輸出對照使用二維陣列時的情況
         * j從V至0逆序是防止有的物品放入揹包多次
        */
        for(j = V; j >= weight[i]; --j)   /* weight > j 的物品不會影響狀態f[0,weight[i-1]]  */
        {
            int tmp = maxV[j-weight[i]]+value[i];
            maxV[j] = (maxV[j] > tmp) ? maxV[j] : tmp;
        }
    }
    printf("%d",maxV[V]);
}
可以看出,使用一維陣列,程式碼非常簡練。

0-1揹包恰好背滿

在01揹包中,有時問到“恰好裝滿揹包”時的最大價值,與不要求裝滿揹包的區別就是在初始化的時候,其實對於沒有要求必須裝滿揹包的情況下,初始化最大價值都為0,是不存在非法狀態的,所有的都是合法狀態,因為可以什麼都不裝,這個解就是0,但是如果要求恰好裝滿,則必須區別初始化,除了f[0]=0,其他的f[1…v]均設為-∞或者一個比較大的負數來表示該狀態是非法的
這樣的初始化能夠保證,如果子問題的狀態是合法的(恰好裝滿),那麼才能得到合法的狀態;如果子問題狀態是非法的,則當前問題的狀態依然非法,即不存在恰好裝滿的情況
程式碼如下:
#include <iostream>
using namespace std;
 
int maxV[201];    /* 記錄前i個物品中容量v時的最大價值 */
int weight[11];
int value[11];
int V, N;
 
void main()
{
    int i, j;
    scanf("%d %d",&V, &N);
    for(i = 0; i < N; ++i)
    {
        scanf("%d %d",&weight[i],&value[i]);
    }
    for(i = 1; i <= V; ++i)  /* 初始化非法狀態 */
    {
        maxV[i] = -100;
    }
 
    for(i = 0; i < N; ++i)
    {
        for(j = V; j >= weight[i]; --j)
        {
            int tmp = maxV[j-weight[i]]+value[i];
            maxV[j] = (maxV[j] > tmp) ? maxV[j] : tmp;
        }
    }
}

為了加深理解,輸出每輪迴圈的狀態矩陣如下,對照每個物體的情況,就會理解為什麼做那樣的初始化了。

========================================

0-1揹包輸出最優方案

一般來講,揹包問題都是求一個最優值,但是如果要求輸出得到這個最優值的方案,就可以根據狀態轉移方程往後推,由這一狀態找到上一狀態,依次向前推即可。
這樣就可以有兩種實現方式,一種是直接根據狀態轉移矩陣向前推,另一種就是使用額外一個狀態矩陣記錄最優方案的路徑,道理都是一樣的。當然也可以使用一維陣列,程式碼更為簡練,這裡不羅列,相關程式碼可以到這裡下載。
程式碼如下:
#include <iostream>
using namespace std;
 
/* 0-1揹包 輸出最優方案 2 直接根據狀態陣列算
 * Time Complexity  O(N*V)
 * Space Complexity O(N*V)
 * 設 V <= 200 N <= 10
 * 狀態轉移方程:f(i,v) = max{ f(i-1,v), f(i-1,v-c[i])+w[i] }
 */
 
int maxValue[11][201]; /* 記錄子問題最優解 */
int weight[11];
int value[11];
int V, N;
 
void main()
{
    int i, j;
    scanf("%d %d",&V, &N);
    for(i = 0; i < N; ++i)
    {
        scanf("%d %d",&weight[i],&value[i]);
    }
    for(i = 0; i < N; ++i)
    {
        for(j = 0; j <= V; ++j)
        {
            if(i > 0)
            {
                maxValue[i][j] = maxValue[i-1][j];
                if(j >= weight[i])
                {
                    int tmp = maxValue[i-1][j-weight[i]] + value[i];
                    maxValue[i][j] = ( tmp > maxValue[i][j]) ? tmp : maxValue[i][j];
                }
            }else
            {
                if(j >= weight[0])
                    maxValue[0][j] = value[0];
            }
        }
    }
 
    printf("%d\n",maxValue[N-1][V]);
 
    i = N-1;
    j = V;
    while(i >= 0)
    {
        if(maxValue[i][j] == maxValue[i-1][j-weight[i]] + value[i])
        {
            printf("%d ",i);
            j = j - weight[i];
        }
        --i;
    }
}

01揹包是揹包問題的基礎,加深理解的最好方式就是動手寫一下,然後對照最終的狀態轉移矩陣一一比對。