揹包四講 (AcWing演算法基礎課筆記整理)

zi_mei發表於2022-03-18

揹包四講

揹包問題(Knapsack problem)是一種組合優化的NP完全問題。問題可以描述為:給定一組物品,每種物品都有自己的重量和價格,在限定的總重量內,我們如何選擇,才能使得物品的總價格最高。問題的名稱來源於如何選擇最合適的物品放置於給定揹包中。相似問題經常出現在商業、組合數學,計算複雜性理論、密碼學和應用數學等領域中。也可以將揹包問題描述為決定性問題,即在總重量不超過W的前提下,總價值是否能達到V?它是在1978年由Merkle和Hellman提出的。

---百度百科

本筆記參考視訊與部落格:

揹包九講專題_嗶哩嗶哩_bilibili

dd大牛的《揹包九講》 - 賀佐安 - 部落格園 (cnblogs.com)

但是筆者做了簡化,只選取了其中較為方便理解的4種揹包問題進行詳解,故取名為揹包四講(AcWing演算法基礎課?四講)。

  • 具體四種問題如下圖:

image-20220318163010680

0x1 01揹包

問題描述

有 N 件物品和一個容量是 V 的揹包。每件物品只能使用一次。

第 i 件物品的體積是 vi,價值是 wi。

求解將哪些物品裝入揹包,可使這些物品的總體積不超過揹包容量,且總價值最大。
輸出最大價值。

輸入格式

第一行兩個整數,N,V,用空格隔開,分別表示物品數量和揹包容積。

接下來有 N 行,每行兩個整數 vi,wi,用空格隔開,分別表示第 i 件物品的體積和價值。

輸出格式

輸出一個整數,表示最大價值。

資料範圍

0<N,V≤1000
0<vi,wi≤1000

輸入樣例

4 5
1 2
2 4
3 4
4 5 

輸出樣例

8 

分析

樸素版做法

image-20220318164839476

解答

#include<iostream>
#include<algorithm>
using namespace std;

const int N=1010;

int n,m;
int v[N],w[N];//分別記錄每個物品的體積和價值
int f[N][N];//f[i][j]來記錄前i個物品且體積不超過j的最大價值

int main()
{
    cin>>n>>m;
    for(int i=1;i<=n;i++) cin>>v[i]>>w[i];
    
    for(int i=1;i<=n;i++)
    {
        for(int j=0;j<=m;j++)
        {
            //1.當前不把第i件物品放入揹包
            f[i][j]=f[i-1][j];
            
            //2.
            //如果當前物品體積沒有超過j(v[i]<=j),則把第i件物品放入揹包
            //由於要選取第i件物品,之前之前的f[i-1][]不僅不能超過f[][j],還要全部預留v[i]的體積空間。
            //這樣就得到了從前i-1個物品裡選體積不超過j-v[i]的最大價值:f[i-1][j-v[i]],最後加上w[i]。
            //這兩種情況取最大者
            if(v[i]<=j) f[i][j]=max(f[i][j],f[i-1][j-v[i]]+w[i]);
        }
    }
    cout<<f[n][m]<<endl;
    return 0;
}

空間優化(一維化)

我們已經得到了樸素版做法。這裡要注意揹包問題的分析與程式碼的優化是沒有關聯的,一般是先根據分析,寫出樸素做法,再經過推導得到優化程式碼。

分析:通過猜想,我們發現可以試圖將 f[i][j] 降為成 f[j] 以優化空間。現在對原來程式碼進行修改。

for(int i=1;i<=n;i++)
    {
        for(int j=0;j<=m;j++)
        {
            //當前不把第i件物品放入揹包的情況可以直接刪[i]與[i-1],
            //因為f[j]=f[j]正好是把上層迴圈舊的f[j]更新掉
            f[j]=f[j];
            
            //當前物品體積沒有超過j(v[i]<=j),則把第i件物品放入揹包的情況不能直接刪,
            //因為j是遞增的,而j-v[i]一定小於j,
            //所以下面的語句在給f[][j]賦值時,f[][j-v[i]]一定是先更新過的,所以就是最新的f[i][j-v[i]],我們知道f[i][j-v[i]]不一定等於f[i-1][j-v[i]],這顯然是不合題意的。
            //if(v[i]<=j) f[i][j]=max(f[i][j],f[i-1][j-v[i]]+w[i]);
            //所以簡單寫成下面這樣是錯誤的
            //if(v[i]<=j) f[j]=max(f[j],f[j-v[i]]+w[i]);
            //這裡我們發現如果j從大到小迴圈,由於j是遞減的,並且j-v[i]一定小於j,所以在賦值給f時j-v[i]是還未更新的上層迴圈值,也就是f[i-1][]的值,這樣就符合題意。
            
        }
    }

修改後程式碼如下

for(int i=1;i<=n;i++)
    {
        for(int j=m;j>=0;j--)
        {
            f[j]=f[j];
            f[j]=max(f[j],f[j-v[i]]+w[i]);
            if(v[i]<=j) f[j]=max(f[j],f[j-v[i]]+w[i]);
        }
    }

進一步刪掉多餘程式碼,整合判斷條件後如下(終極版):

#include<iostream>
#include<algorithm>

using namespace std;

const int N=1e3+10;

int n,m;
int v[N],w[N];
int f[N];

int main()
{
    cin>>n>>m;
    for(int i=1;i<=n;i++) cin>>v[i]>>w[i];
    
    for(int i=1;i<=n;i++)
    {
        for(int j=m;j-v[i]>=0;j--)
        {
            f[j]=max(f[j],f[j-v[i]]+w[i]);
        }
    }
    cout<<f[m]<<endl;
    return 0;
}

總結

01揹包問題是最基本的揹包問題,它包含了揹包問題中設計狀態、方程的最基本思想,另外,別的型別的揹包問題往往也可以轉換成01揹包問題求解。故一定要仔細體會上面基本思路的得出方法,狀態轉移方程的意義,以及最後怎樣優化的空間複雜度。

0x2 完全揹包

問題描述

有 N 種物品和一個容量是 V 的揹包,每種物品都有無限件可用。

第 i 種物品的體積是 vi,價值是 wi。

求解將哪些物品裝入揹包,可使這些物品的總體積不超過揹包容量,且總價值最大。
輸出最大價值。

輸入格式

第一行兩個整數,N,V,用空格隔開,分別表示物品種數和揹包容積。

接下來有 N 行,每行兩個整數 vi,wi,用空格隔開,分別表示第 i 種物品的體積和價值。

輸出格式

輸出一個整數,表示最大價值。

資料範圍

0<N,V≤1000
0<vi,wi≤1000

輸入樣例

4 5
1 2
2 4
3 4
4 5 

輸出樣例

10 

分析

樸素版做法

12161_62fd29d844-1

解答

//樸素版做法
#include <iostream>
#include <algorithm>

using namespace std;

const int N=1010;

int f[N][N];
int v[N],w[N];
int n,m;

int main()
{
    cin>>n>>m;

    for(int i=1;i<=n;i++) cin>>v[i]>>w[i];
    
    for(int i=1;i<=n;i++)
    {
        for(int j=0;j<=m;j++)
        {
            for(int k=0;k*v[i]<=j;k++)//不選的時候,k=0,0<=j恆成立,和題。
            //選的時候,體積最大情況為只選當前第i件物品,只要k*v[i]不比j大就行。
            {
                f[i][j]=max(f[i][j],f[i-1][j-k*v[i]]+k*w[i]);
            }
        }
    }
    
    cout<<f[n][m]<<endl;
    return 0;
}

時間優化(簡單推導)

上面樸素做法最壞情況為O(n*m^2) v[i]取1時 達到10^9, 在超時的邊緣瘋狂試探?

我們決定對時間進行優化,觀察發現

f[i][j]=max(f[i-1][j],f[i-1][j-v]+w,f[i-1][j-2v]+2w,...f[i-1][取到最小])

f[i][j-v]=max(f[i-1][j-v],f[i-1][j-2v]+w,...f[i-1][取到最小])

我們發現可以使用下面的f[i][j-v]+w替換上面的後半部分式子

f[i][j]=max(f[i-1][j],f[i][j-v]+w),此時的狀態轉移方程就得到了簡化

簡化版DP 時間複雜度O(n^2)

//時間優化
#include<iostream>
#include<algorithm>

using namespace std;

const int N=1e3+10;

int n,m;
int v[N],w[N];
int f[N][N];

int main()
{
    cin>>n>>m;
    for(int i=1;i<=n;i++) cin>>v[i]>>w[i];
    
    
    for(int i=1;i<=n;i++)
        for(int j=0;j<=m;j++)
        {
            f[i][j]=f[i-1][j];
            if(j-v[i]>=0) f[i][j]=max(f[i][j],f[i][j-v[i]]+w[i]);
        }
    cout<<f[n][m]<<endl;
    return 0;
}

在時間優化基礎上優化空間

//時間優化+空間優化
#include<iostream>
#include<algorithm>

using namespace std;

const int N=1e3+10;

int n,m;
int v[N],w[N];
int f[N];

int main()
{
    cin>>n>>m;
    for(int i=1;i<=n;i++) cin>>v[i]>>w[i];
    
    
    for(int i=1;i<=n;i++)
        for(int j=v[i];j<=m;j++)//if(j-v[i]<0)迴圈會直接跳過,所以j從v[i]開始就好了。
        {
            f[j]=max(f[j],f[j-v[i]]+w[i]);
        }
    cout<<f[m]<<endl;
    return 0;
}

總結

完全揹包問題也是一個相當基礎的揹包問題。希望你能夠對狀態轉移方程和優化的推導過程都仔細地體會,不僅記住,也要弄明白它們是怎麼得出來的,最好能夠自己想一種得到這些方程的方法。事實上,對每一道動態規劃題目都思考其方程的意義以及如何得來,是加深對動態規劃的理解、提高動態規劃功力的好方法。

0x3 多重揹包

問題描述

有 N 種物品和一個容量是 V 的揹包。

第 i 種物品最多有 si 件,每件體積是 vi,價值是 wi。

求解將哪些物品裝入揹包,可使物品體積總和不超過揹包容量,且價值總和最大。
輸出最大價值。

輸入格式

第一行兩個整數,N,V,用空格隔開,分別表示物品種數和揹包容積。

接下來有 N 行,每行三個整數 vi,wi,si,用空格隔開,分別表示第 i 種物品的體積、價值和數量。

輸出格式

輸出一個整數,表示最大價值。

資料範圍

由下面解法確定

輸入樣例

4 5
1 2 3
2 4 1
3 4 3
4 5 2 

輸出樣例

10 

樸素版做法

資料範圍

0<N,V≤100
0<vi,wi,si≤100

分析

這題目和完全揹包問題很類似。基本的方程只需將完全揹包問題的方程略微一改即可,因為對於第i種物品有n[i]+1種策略:取0件,取1件……取 n[i]件。令f[i][v]表示前i種物品恰放入一個容量為v的揹包的最大權值,則:f[i][v]=max{f[i-1][j-k*v[i]]+ k*w[i]}。只不過每件物品有件數限制k<=s[i]。那麼直接模仿照抄完全揹包的樸素版本一定是可行的。

解答

#include<iostream>
#include<algorithm>

using namespace std;

const int N=1e4+10;

int n,m;
int v[N],w[N];
int f[N][N];
int s[N];

int main()
{
    cin>>n>>m;
    
    for(int i=1;i<=n;i++) cin>>v[i]>>w[i]>>s[i];
    
    for(int i=1;i<=n;i++)
    {
        for(int j=0;j<=m;j++)
        {
            for(int k=0;k*v[i]<=j&&k<=s[i];k++)
            {
                f[i][j]=max(f[i][j],f[i-1][j-k*v[i]]+k*w[i]);
            }
        }
    }
    cout<<f[n][m]<<endl;
    return 0;
}

但是這裡我們想模仿完全揹包時間優化的程式碼卻不可以。

對於完全揹包我們有

f[i][j]=max(f[i-1][j],f[i-1][j-v]+w,f[i-1][j-2v]+2w,...f[i-1][取到最小])

f[i][j-v]=max(f[i-1][j-v],f[i-1][j-2v]+w,...f[i-1][取到最小])

但是多重揹包由於有件數限制,雖然下面的式子是成立的,但是f[i-1,j-sv]不一定能取到最小。

第二行多的f[i-1,j-(s+1)v]+sw使得我們的推導無法進行下去,因為從遞推式中減去一項是不容易做到的。

image-20220318200924435

時間優化(二進位制優化)

資料範圍

0<N≤10000<N≤1000
0<V≤20000<V≤2000
0<vi,wi,si≤20000<vi,wi,si≤2000

提示

本題考查多重揹包的二進位制優化方法。

分析

由樸素做法的最後一段分析可知,我們需要一種新的方法來優化多種揹包問題。否則是無法通過這一題的,因為時間複雜度超時。這裡介紹二進位制優化方法。

二進位制優化方法原理淺談

我們知道二進位制由0和1兩個陣列成,任意的10進位制數都可以由二進位制數表示。

而0,1可以類比成揹包問題的第i個數選(1)與不選(0)。

那麼通過列舉全部(1,2,4,8,...,2^n)也就是二進位制的(1,10,100,1000,10000,...),這些數的選與不選就可以表示所有的10進位制數。

例如:(十進位制數)11=(二進位制數)1000+10+1;只要選這三個數就可以表示11。

並且這些二進位制數選與不選都是唯一的,所以相當於把問題轉換成了01揹包問題。

這種優化對於大數尤其明顯,例如有1024個商品,在正常情況下要列舉1025次 , 二進位制思想下轉化成01揹包只需要列舉10次。

解答

這裡我們直接寫二進位制優化+空間優化的版本(因為資料範圍較大,不優化空間會MLE)

#include<iostream>
#include<algorithm>

using namespace std;

const int N=2e6+10,M=2010;

int v[N],w[N];
int n,m;
int f[M];
int cnt;

int main()
{
    cin>>n>>m;
    
    int a,b,s;
    //下面是二進位制優化過程
    for(int i=1;i<=n;i++)
    {
        cin>>a>>b>>s;
        for(int j=1;j<=s;j*=2)//把二進位制件數(1,10,100,1000...)存進去
        {
            cnt++;
            v[cnt]=j*a;
            w[cnt]=j*b;
            s-=j;
        }
        if(s>0)//這裡s的值一定小於2^(j+1),s為二進位制表示迴圈後(s-=j;)剩的數。
        {
            cnt++;
            v[cnt]=s*a;
            w[cnt]=s*b;
        }
    }
    
    n=cnt;//一定要更新n的值,因為cnt才是二進位制優化後的實際物品個數
    
    //下面和01揹包空間優化程式碼一致
    for(int i=1;i<=n;i++)
        for(int j=m;j>=v[i];j--)
        {
            f[j]=max(f[j],f[j-v[i]]+w[i]);
        }
    
    cout<<f[m]<<endl;
    return 0;
}

總結

這裡二進位制優化十分的優雅,請務必在掌握前面幾種揹包題型後好好理解,運用。

0x4 分組揹包

樸素做法

分析

1606_ecd551d6b7-捕獲

解答

#include<iostream>
#include<algorithm>

using namespace std;

const int N=110;

int n,m;
int s[N],v[N][N],w[N][N];//這裡多一維記錄這一組的第幾個就好
int f[N][N];

int main()
{
    cin>>n>>m;
    
    for(int i=1;i<=n;i++) 
    {
        cin>>s[i];
        for(int k=1;k<=s[i];k++)
            cin>>v[i][k]>>w[i][k];
    }
    
    for(int i=1;i<=n;i++)
    {
        for(int j=0;j<=m;j++)
        {
            f[i][j]=f[i-1][j];
            for(int k=1;k<=s[i];k++)//唯一和01揹包不同點,每一組的每個都要判斷一遍,用迴圈寫
            {
                if(j>=v[i][k]) f[i][j]=max(f[i][j],f[i-1][j-v[i][k]]+w[i][k]);
            }
        }
    }
    
    cout<<f[n][m];
    
    return 0;
}

空間優化(一維化)

#include<iostream>
#include<algorithm>

using namespace std;

const int N=110;

int n,m;
int s[N],f[N];
int v[N][N],w[N][N];

int main()
{
    cin>>n>>m;
    
    for(int i=1;i<=n;i++)
    {
        cin>>s[i];
        for(int k=1;k<=s[i];k++)
        {
            cin>>v[i][k]>>w[i][k];//這裡用k不用j,和下面程式碼保持一致,方便讀者理解
        }
    }
    
    for(int i=1;i<=n;i++)
    {
        for(int j=m;j>=0;j--)
        {
            for(int k=1;k<=s[i];k++)
            {
                if(j>=v[i][k]) f[j]=max(f[j],f[j-v[i][k]]+w[i][k]);//借鑑01揹包空間優化
            }
        }
    }
    cout<<f[m]<<endl;
    return 0;
}

總結

分組的揹包問題將彼此互斥的若干物品稱為一個組,這建立了一個很好的模型。不少揹包問題的變形都可以轉化為分組的揹包問題,由分組的揹包問題進一步可定義“泛化物品”的概念,十分有利於解題。

小結

這四種揹包問題被選入AcWing演算法基礎課動態規劃第一章。大家應細細體會程式碼的優雅性,與推理的嚴謹性。作為學習演算法的基礎好好掌握。(基礎不等同於簡單,base != easy)由於筆者整理的較為倉促,難免有紕漏,歡迎評論指出。

最後引用 賀大牛的一句話送給大家,觸類旁通、舉一反三,應該也是一個OIer應有的品質吧。

相關文章