揹包問題

P01: 01揹包問題

題目:有N件物品和一個容量爲V的揹包。第i件物品的費用是c[i],價值是w[i]。求解將哪些物品裝入揹包可使價值總和最大。

基本思路:這是最基礎的揹包問題,特點是:每種物品僅有一件,可以選擇放或不放。用子問題定義狀態:即f[i][v]表示前i件物品恰放入一個容量爲v的揹包可以獲得的最大價值。則其狀態轉移方程便是:
f[i][v] = max{ f[i-1][v], f[i-1][v-c[i]] + w[i]}

這個方程非常重要,基本上所有跟揹包相關的問題的方程都是由它衍生出來的。所以有必要將它詳細解釋一下:“將前i件物品放入容量爲v的揹包中”這個子問題,若只考慮第i件物品的策略(放或不放),那麼就可以轉化爲一個只牽扯前i-1件物品的問題。如果不放第i件物品,那麼問題就轉化爲“前i-1件物品放入容量爲v的揹包中”,價值爲f[i-1][v];如果放第i件物品,那麼問題就轉化爲“前i-1件物品放入剩下的容量爲v-c[i]的揹包中”,此時能獲得的最大價值就是f[i-1][v-c[i]]再加上通過放入第i件物品獲得的價值w[i]。

優化空間複雜度

以上方法的時間和空間複雜度均爲O(N*V),其中時間複雜度基本已經不能再優化了,但空間複雜度卻可以優化到O(V)。

先考慮上面講的基本思路如何實現,肯定是有一個主循環i=1..N,每次算出來二維數組f[i][0..V]的所有值。那麼,如果只用一個數組f[0..V],能不能保證第i次循環結束後f[v]中表示的就是我們定義的狀態f[i][v]呢?f[i][v]是由f[i-1][v]和f[i-1][v-c[i]]兩個子問題遞推而來,能否保證在推f[i][v]時(也即在第i次主循環中推f[v]時)能夠得到f[i-1][v]和f[i-1][v-c[i]]的值呢?事實上,這要求在每次主循環中我們以v=V..0的順序推f[v],這樣才能保證推f[v]時f[v-c[i]]保存的是狀態f[i-1][v-c[i]]的值。僞代碼如下:

for i = 1..N
    for v = V..0
        f[v] = max{ f[v], f[v-c[i]]+w[i] };

其中的f[v] = max{ f[v],f[v-c[i]] }一句恰就相當於我們的轉移方程f[i][v] = max{f[i-1][v],f[i-1][v-c[i]]},因爲現在的f[v-c[i]]就相當於原來的f[i-1][v-c[i]]。如果將v的循環順序從上面的逆序改成順序的話,那麼則成了f[i][v]由f[i][v-c[i]]推知,與本題意不符,但它卻是另一個重要的揹包問題P02最簡捷的解決方案,故學習只用一維數組解01揹包問題是十分必要的。

#include <stdio.h>

const int MAX = 10010;
int V; //揹包體積
int f[MAX];

void ZeroOnePack (int cost, int weight)
{
    int v;
    for(v = V; v >= cost; --v)
        f[v] = f[v] > (f[v-cost] + weight) ? f[v] : (f[v-cost] + weight);
}

int main()
{
    int num = 5;  //東西個數
    V = 10;   //揹包的體積
    int volume[5] = {1,2,3,4,5};
    int value[5] = {5,4,3,2,1};

    for(int i = 0; i <= V; ++i)  //初始化:沒要求把揹包裝滿
    {
        f[i] = 0;
    }
    for(int i = 0; i < num; ++i)
        ZeroOnePack(volume[i], value[i]);

    printf("%d\n",f[V]);

    return 0;
}

0-1揹包問題最常見的兩種問法:
一是要求“恰好裝滿揹包”時的最優解。
二是“沒有要求必須把揹包裝滿”時的最優解。

這兩種問法的實現方法不同點主要在初始化上:
如果是第一種問法,要求恰好裝滿揹包,那麼在初始化時除了f[0]爲0其它f[1..V]均設爲- ∞,這樣就可以保證最終得到的f[N]是一種恰好裝滿揹包的最優解。
如果並沒有要求必須把揹包裝滿,而是隻希望價格儘量大,初始化時應該將f[0..V]全部設爲0。

#include <stdio.h>

const int MAX = 10010;
int f[MAX];
int V;    //揹包的體積

void ZeroOnePack (int cost, int weight)
{
    int v;
    for(v = V; v >= cost; v--)
        f[v] = f[v] > (f[v-cost] + weight) ? f[v] : (f[v-cost] + weight);
}

int main(void)
{
    int num = 4;
    V = 5;
    int volume[] = {2,2,1,4};
    int value[] = {1,3,2,3};

    f[0] = 0; //f[0]初始化爲0
    for(int i = 1; i <= V; i++)   //要求把揹包裝滿
    {
        f[i] = 0x8fffffff;        //初始化爲一個比較小的值
    }

    for(int i = 0; i < num; i++)
        ZeroOnePack(volume[i],value[i]);

    printf("%d\n",f[V]);

    return 0;
}

P02: 完全揹包問題

題目:有N種物品和一個容量爲V的揹包,每種物品都有無限件可用。第i種物品的費用是c[i],價值是w[i]。求解將哪些物品裝入揹包可使這些物品的費用總和不超過揹包容量,且價值總和最大。

基本思路:這個問題非常類似於01揹包問題,所不同的是每種物品有無限件。也就是從每種物品的角度考慮,與它相關的策略已並非取或不取兩種,而是有取0件、取1件、取2件……等很多種。如果仍然按照解01揹包時的思路,令f[i][v]表示
前i種物品恰放入一個容量爲v的揹包的最大權值。仍然可以按照每種物品不同的策略寫出狀態轉移方程,像這樣:
f[i][v] = max{ f[i-1][v-k*c[i]]+k*w[i] | 0<=k*c[i]<=v }
這跟01揹包問題一樣有O(N*V)個狀態需要求解,但求解每個狀態的時間已經不是常數了,求解狀態f[i][v]的時間是O(v/c[i]),總的複雜度是超過O(VN)的。

解題思路:既然01揹包問題是最基本的揹包問題,那麼我們可以考慮把完全揹包問題轉化爲01揹包問題來解。最簡單的想法是,考慮到第i種物品最多選V/c[i]件,於是可以把第i種物品轉化爲V/c[i]件費用及價值均不變的物品,然後求解這個01揹包問題。這樣完全沒有改進基本思路的時間複雜度,但這畢竟給了我們將完全揹包問題轉化爲01揹包問題的思路:將一種物品拆成多件物品。

但我們有更優的O(VN)的算法。這個算法使用一維數組,先看僞代碼:
    for i = 1..N
        for v = 0..V
              f[v] = max{f[v],f[v-c[i]]+w[i]};

你會發現,這個僞代碼與0-1揹包的僞代碼只有v的循環次序不同而已。爲什麼這樣一改就可行呢?首先想想爲什麼0-1揹包中要按照v=V..0的逆序來循環。這是因爲要保證第i次循環中的狀態f[i][v]是由狀態f[i-1][v-c[i]]遞推而來。換句話說,這正是爲了保證每件物品只選一次,保證在考慮“選入第i件物品”這件策略時,依據的是一個絕無已經選入第i件物品的子結果f[i-1][v-c[i]]。而現在完全揹包的特點恰是每種物品可選無限件,所以在考慮“加選一件第i種物品”這種策略時,卻正需要一個可能已選入第i種物品的子結果f[i][v-c[i]],所以就可以並且必須採用v=0..V的順序循環。這就是這個簡單的程序爲何成立的道理。

這個算法也可以以另外的思路得出。例如,基本思路中的狀態轉移方程可以等價地變形成這種形式:f[i][v] = max{f[i-1][v],f[i][v-c[i]]+w[i]},將這個方程用一維數組實現,便得到了上面的僞代碼。

最後抽象出處理一件完全揹包類物品的過程僞代碼,以後會用到:
procedure CompletePack(cost,weight)
    for v = cost..V
        f[v] = max{ f[v], f[v-cost]+weight }

#include <stdio.h>

const int MAX = 10010;
int f[MAX];   //MAX要比揹包的體積大
int V;        //揹包的體積

void CompletePack (int cost, int weight) //完全揹包
{
    int v;
    for (v = cost; v <= V; v++)
        f[v] = f[v] > (f[v - cost] + weight) ? f[v] : (f[v - cost] + weight);
}

int main(void)
{
    V = 9;
    int volume[3] = {1,2,3};
    int value[3] = {1,4,3};

    for(int i = 0; i <= V; i++) //沒有要求把揹包裝滿
    {
        f[i] = 0;
    }

    for(int i = 0; i < 3; i++)
        CompletePack(volume[i],value[i]);

    printf("%d\n",f[V]);

    return 0;
}

P03: 多重揹包問題

題目:有N種物品和一個容量爲V的揹包。第i種物品最多有n[i]件可用,每件費用是c[i],價值是w[i]。求解將哪些物品裝入揹包可使這些物品的費用總和不超過揹包容量,且價值總和最大。

基本算法:這題目和完全揹包問題很類似。基本的方程只需將完全揹包問題的方程略微一改即可,因爲對於第i種物品有n[i]+1種策略:取0件,取1件……取n[i]件。令f[i][v]表示前i種物品恰放入一個容量爲v的揹包的最大權值,則有狀態轉移方程:
f[i][v] = max{f[i-1][v-k*c[i]]+k*w[i]|0<=k<=n[i]}
複雜度是O(V*Σn[i])。

轉化爲01揹包問題

另一種好想好寫的基本方法是轉化爲01揹包求解:把第i種物品換成n[i]件01揹包中的物品,則得到了物品數爲Σn[i]的01揹包問題,直接求解,複雜度仍然是O(V*Σn[i])。

但是我們期望將它轉化爲01揹包問題之後能夠像完全揹包一樣降低複雜度。仍然考慮二進制的思想,我們考慮把第i種物品換成若干件物品,使得原問題中第i種物品可取的每種策略——取0..n[i]件——均能等價於取若干件代換以後的物品。另外,取超過n[i]件的策略必不能出現。

方法是:將第i種物品分成若干件物品,其中每件物品有一個係數,這件物品的費用和價值均是原來的費用和價值乘以這個係數。使這些係數分別爲1,2,4,...,2^(k-1),n[i]-2^k+1,且k是滿足n[i]-2^k+1>0的最大整數。例如,如果n[i]爲13,就將這種物品分成係數分別爲1,2,4,6的四件物品。 分成的這幾件物品的係數和爲n[i],表明不可能取多於n[i]件的第i種物品。另外這種方法也能保證對於0..n[i]間的每一個整數,均可以用若干個係數的和表示,這個證明可以分0..2^k-1和2^k..n[i]兩段來分別討論得出,並不難,希望你自己思考嘗試一下。

這樣就將第i種物品分成了O(log n[i])種物品,將原問題轉化爲了複雜度爲O(V*Σlog n[i])的01揹包問題,是很大的改進。

下面給出O(log amount)時間處理一件多重揹包中物品的過程,其中amount表示物品的數量:
procedure MultiplePack(cost,weight,amount)
if cost*amount >= V
{
    CompletePack(cost,weight)
    Return
}
integer k = 1
while k<amount
{
    ZeroOnePack(k*cost,k*weight)
    amount = amount-k
    k = k*2
}
ZeroOnePack(amount*cost,amount*weight)

希望你仔細體會這個僞代碼,如果不太理解的話,不妨翻譯成程序代碼以後,單步執行幾次,或者頭腦加紙筆模擬一下,也許就會慢慢理解了。

#include <stdio.h>
#include <stdlib.h>

int V;        //揹包的體積
const int MAX = 10010;
int f[MAX];   //MAX要比揹包的體積大

void ZeroOnePack (int cost, int weight)      //01揹包
{
    int v;
    for (v = V; v >= cost; v--)
        f[v] = f[v] > (f[v - cost] + weight) ? f[v] : (f[v - cost] + weight);
}

void CompletePack (int cost, int weight)     //完全揹包
{
    int v;
    for (v = cost; v <= V; v++)
        f[v] = f[v] > (f[v - cost] + weight) ? f[v] : (f[v - cost] + weight);
}

void MultiplePack(int cost, int weight, int amount)     //多重揹包
{
    if (cost * amount >= V)
        CompletePack (cost, weight);
    int k = 1;
    while (k < amount)
    {
        ZeroOnePack (k * cost, k * weight);
        amount = amount - k;
        k = k * 2;
    }
    ZeroOnePack (amount * cost, amount * weight);
}

int main(void)
{
    int t,i;
    int num = 2;
    V = 8;
    int volume[] = {2,4};
    int value[] = {100,100};
    int count[] = {3,2};

    for(i = 0; i <= V; i++) //沒有要求把揹包裝滿
    {
        f[i] = 0;
    }
    for(i = 0; i < num; i++)
        MultiplePack(volume[i], value[i], count[i]);

    printf("%d\n",f[V]);

    return 0;
}

輸出方案

一般而言,揹包問題是要求一個最優值,如果要求輸出這個最優值的方案,可以參照一般動態規劃問題輸出方案的方法:記錄下每個狀態的最優值是由狀態轉移方程的哪一項推出來的,換句話說,記錄下它是由哪一個策略推出來的。便可根據這條策略找到上一個狀態,從上一個狀態接着向前推即可。
還是以01揹包爲例,方程爲f[i][v]=max{f[i-1][v],f[i-1][v-c[i]]+w[i]}。再用一個數組g[i][v],設g[i][v]=0表示推出f[i][v]的值時是採用了方程的前一項(也即f[i][v]=f[i-1][v]),g[i][v]表示採用了方程的後一項。注意這兩項分別表示了兩種策略:未選第i個物品及選了第i個物品。那麼輸出方案的僞代碼可以這樣寫(設最終狀態爲f[N][V]):
i=N
v=V
while(i>0)
    if(g[i][v]==0)
        print "未選第i項物品"
    else if(g[i][v]==1)
        print "選了第i項物品"
        v=v-c[i]
另外,採用方程的前一項或後一項也可以在輸出方案的過程中根據f[i][v]的值實時地求出來,也即不須紀錄g數組,將上述代碼中的g[i][v]==0改成f[i][v]==f[i-1][v],g[i][v]==1改成f[i][v]==f[i-1][v-c[i]]+w[i]也可。

來源《揹包問題九講》:http://love-oriented.com/pack/

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