01揹包詳解第一版

Wind發表於2021-10-28

title: "01揹包詳解"
author: Sun-Wind
date: October 27, 2021

本貼背景:蒟蒻突然被要求去講題.............

什麼是01揹包

0-1 揹包問題:給定n種物品和一個容量為C的揹包,物品i的重量是wi,其價值為vi 。
問:應該如何選擇裝入揹包的物品,使得裝入揹包中的物品的總價值最大?

在上述例子中,由於每個物體只有兩種可能的狀態(取與不取),對應二進位制中的0和1,這類問題便被稱為「0-1 揹包問題」。

0-1揹包問題實質上是一個動態規劃問題,解決這個問題我們需要從前一個狀態遞推到下一個狀態,最終遞推到我們想要的狀態

遞推函式

考慮這樣一個函式B(n,c)
這個函式表示從n個物品裡面選擇物品,揹包容量為c所能達到的最大價值
既然是動態規劃的問題,我們應該從上一個狀態尋求思路,找尋兩個狀態之間的聯絡

狀態轉換

試想一下,假如我們需要從4個物品裡面選擇物品,我們應該先考慮前3個物品的狀態,然後再考慮第4個物品是放還是不放
利用二進位制的思想,如果四個物品都不放我們用狀態表示為0000
都放用狀態表示為1111,其他的狀態可以類比推理
顯然,要想得到最優解,對應這個最優解的狀態就一定是0000~1111其中的一種

細節思考

假如,我們知道放前三個物品所對應的最優解是101,也就是取第1件和第3件,第2件不選,這樣選讓目前的揹包能達到最大的價值
現在考慮第四件,第四件我們知道要麼選要麼就不選
如果要選第4件物品,並且這時候揹包還能放第4件物品,那麼顯然我們應該把這件物品放入揹包中
當然存在另外的一種矛盾的情況,就是這個時候揹包的容量已經不夠了,有些物品已經佔據了揹包的格子,但是把這些物品拿出來放第4個物品的價值反而要更大
就是說如果考慮第4件物品最好的狀態可能是1001,0011,甚至可能是0001
如下圖所示
pic1
當然,如果不拿這第4個物品的價值更大,那最優解當然是不拿

既然這樣,我們的遞推方程就可以自然地得出
B(n,c) = max(B(n-1,c),B(n-1,c-w) + v)
其中w指的是這個物品的體積,v指的是這個物品的價值。在之前的例子中指的是第4個物品的體積和價值
也就是說前面的某個物品可能會多>=w的容量
我們把之前的物品拿出來,然後放入現在考慮的物品,就像之前所討論的物品4一樣

核心程式碼

根據上述的推論,我們可以得到如下的程式碼

for(i = 1; i <= m; ++i)//列舉個數
    for(j = 1; j <= n; ++j)//列舉容量
    {
        if(w[i] <= j)//如果能放
            dp[i][j] = max(dp[i - 1][j],dp[i - 1][j - w[i]] + v[i]);
        else//上一個物品的狀態
            dp[i][j] = dp[i - 1][j];
    }

注意一點,這裡在考慮每一個物品時我們列舉了揹包的每一份容量考慮,目的是保證考慮到每一個狀態
這裡的dp陣列模擬的就是上述的B(n,c)函式
時間複雜度為O(NV)

例題講解1

hdu2602

題目翻譯

很多年前,在泰迪的故鄉有一個被稱為“骨頭收集者”的人。這個男人喜歡收集各種各樣的骨頭,如狗,牛,鳥......
骨收集器有一個很大的袋子,沿著他收集的旅行有很多骨骼,顯然,不同的骨骼有不同的價值和不同的體積,現在給出了每次骨頭的價值和體積,你可以計算骨收集器可以獲得的總價值的最大值最多?

輸入

第一行包含整數T,案例的數量。
其次是T例,每種情況三行,第一行包含兩個整數n,v,(n <= 1000,v <= 1000)表示骨骼的數量和他袋子的體積。第二行包含表示每個骨骼體積的n個整數。第三行包含表示每個骨骼的價值的n個整數。

輸入

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

輸出

14

顯然,這是一道01揹包的板子題,我們直接套上我們的核心程式碼就可以解決
程式碼如下

#include<iostream>
using namespace std;
const int N = 1e3+5;
int dp[N][N];//表示B函式
int w[N];//表示體積
int v[N];//表示價值
int n,m;
int main()
{
    ios::sync_with_stdio(false);
    cin.tie(0);
    int t;
    cin >> t;
    while(t--){
       cin >> n >> m;
       for(int i = 1; i <= n; ++i)
       for(int j = 0; j <= m; ++j)
        dp[i][j] = 0; //每一次要重新把陣列更新為初始狀態,防止被上一個樣例影響
        //輸入
       for(int i = 1; i <= n; ++i)
        cin >> v[i];
       for(int i = 1; i <= n; ++i)
        cin >> w[i];
        //核心程式碼
       for(int i = 1; i <= n; ++i)
        for(int j = 0;j <= m;j++)
            if(j >= w[i])
                dp[i][j] = max(dp[i-1][j],dp[i-1][j-w[i]] + v[i]);
            else
                dp[i][j] = dp[i-1][j];

       cout << dp[n][m] << endl;//最後的結果,即遞推到最後的狀態是考慮n個物品,揹包容量為m時能得到的最大價值
    }
    return 0;
}

01揹包空間複雜度的優化

剛剛我們從二維的角度來思考B(n,c)函式,空間複雜度為O(nv),現在我們嘗試把空間複雜度降到O(v)
這時我們的B函式只有一個引數C(揹包的容量)
也就是說我們在每次遍歷時,揹包裡面剛開始存的是上一個狀態的,核心程式碼變成了這樣

for(i = 1; i <= m; ++i)//列舉個數
    for(j = w[i]; j <= n; ++j)//列舉容量
        dp[j] = max(dp[j],dp[j - w[i]] + v[i]);

像我們之前的思考那樣
如果j < w[i] 之前是dp[i][j] = dp[i-1][j]
這裡就不考慮dp[j],所以dp[j]將儲存上一次的狀態,等價於上述的式子
如果j >= w[i],之前是dp[i][j] = max(dp[i-1][j],dp[i-1][j-w[i]] + v[i]);
現在是dp[j] = max(dp[j],dp[j - w[i]] + v[i]);
兩者都是在考慮i-1個物品時容量為j的最大價值和上一狀態要把這個物品放進去這兩個狀態之間
得到的最大價值
既然都是等價的,理論上我們應該可以直接套用這個新的板子,而且還省了一點程式碼

細節思考

其實依然存在一些問題,等價但不完全等價,關鍵點在於迴圈順序
試著考慮這樣的一個問題,我們考慮j狀態和2j狀態
j狀態的所面臨的問題

dp[j] = max(dp[j],dp[j - w[i]] + v[i]);

2j狀態所面臨的問題

dp[2j] = max(dp[2j],dp[2j-w[i]] + v[i]);

當j=w[i]時我們可以看到

dp[j] = max(dp[j],dp[0] + v[i]);
dp[2j] = max(dp[2j],dp[j] + v[i]);

對於同一個物品,在迴圈到j=w[i]和2j時都要考慮放與不放的問題
所以我們可能在dp[j]時已經把這個物品放進去了,但是在dp[2j]時我們又放了一次
這就違背了題目中每個物品只有一件的題意

問題出在哪裡?
理論上難道不是等價的嗎
其實我們可以發現dp[2j] = max(dp[2j],dp[j] + v[i]);這裡的dp[j]如果已經被更新過(也就是已經被放進去過一次了)那麼它儲存的就是這個狀態,而不是上一個狀態

真正的優化

所以我們重新考慮迴圈的順序,我們採用倒序迴圈,也就是

for(i = 1; i <= m; ++i)//列舉個數
    for(j = n; j >= w[i]; --j)//列舉容量
        dp[j] = max(dp[j],dp[j - w[i]] + v[i]);

顯然,這樣我們就可以保證max中比較的狀態都是上一個狀態
空間優化迎刃而解

優化過後的程式碼

#include<iostream>
using namespace std;
const int N = 1e3+5;
int dp[N];//表示B函式
int w[N];//表示體積
int v[N];//表示價值
int n,m;
int main()
{
    ios::sync_with_stdio(false);
    cin.tie(0);
    int t;
    cin >> t;
    while(t--){
       cin >> n >> m;
       for(int i = 0; i <= m; ++i)
        dp[i] = 0; //每一次要重新把陣列更新為初始狀態,防止被上一個樣例影響
        //輸入
       for(int i = 1; i <= n; ++i)
        cin >> v[i];
       for(int i = 1; i <= n; ++i)
        cin >> w[i];
        //核心程式碼
       for(int i = 1; i <= n; ++i)
        for(int j = m;j >= w[i];--j)
                dp[j] = max(dp[j],dp[j-w[i]] + v[i]);

       cout << dp[m] << endl;//最後的結果,即遞推到最後的狀態是考慮n個物品,揹包容量為m時能得到的最大價值
    }
    return 0;
}

背景:之所以要寫擴充套件是害怕到時候沒到下課就把上面講完了

01揹包擴充套件之完全揹包

什麼是完全揹包

完全揹包問題:和01揹包大致類似,唯一不一樣的是每個物品不是隻有一件了,而是有無限多件了,這時候問你揹包所能獲得的最大價值是多少

思路解析

既然很多地方都和01揹包一樣,那麼我們可以從01揹包中來獲取思路
我們發現無限多件物品其實就等價於可以重複地放這個物品

想到什麼了沒
我們在討論01揹包問題的時候,其中就考慮了重複放置物品的問題

for(i = 1; i <= m; ++i)//列舉個數
    for(j = w[i]; j <= n; ++j)//列舉容量
        dp[j] = max(dp[j],dp[j - w[i]] + v[i]);

還記得我們最初優化的那個錯誤的程式碼嗎,沒錯,它討論的就是完全揹包的問題,每個物品可以重複的放在揹包當中
所以其實我們在討論01揹包時已經順帶解決了完全揹包的問題,上述程式碼就是完全揹包的核心程式碼

例題講解2

洛谷P1616

題目大意

一個人有m的時間採n種藥,每種藥可以無限次採摘,問在規定時間內所能採得藥物得最大價值

輸入

70 3
71 100
69 1
1 2

輸出

140

這是一道完全揹包的板子題
在題目中,時間相當於揹包,藥物相當於物品

#include<iostream>
using namespace std;
const int N = 1e7+5;
long long dp[N];
int w[10005],v[10005];
int main()
{
    int n,m;
    cin >> m >> n;
    for(int i = 1; i <= n; ++i)
        cin >> w[i] >> v[i];
    for(int i = 1; i <= n; ++i)
        for(int j = w[i]; j <= m; ++j)
            dp[j] = max(dp[j],dp[j-w[i]] + v[i]);//這一段核心程式碼和之前解釋的一樣
    cout << dp[m] << endl;
}

01揹包擴充套件之多重揹包

什麼是多重揹包

多重揹包也是 0-1 揹包的一個變式。與 0-1 揹包的區別在於每種物品有ki個,而非一個,也不是無窮多個
多重揹包和01揹包,完全揹包都不相同,關鍵在於它的每個物品都有上限

樸素做法

考慮一個樸素的做法,既然總的物品有數量限制,假設物品的數量和為sum
那麼問題就轉化為有sum個物品,每個物品只有一件的01揹包問題
轉化之後的程式碼

for(int i = 1; i <= sum; ++i)
    for(int j = b; j >= w[i]; --j)
        dp[j] = max(dp[j],dp[j - w[i]] + m);

時間複雜度為O(sum*V)

例題講解3

Acwing4
此題是完全揹包的模板題,上述解釋看懂了應該就沒有什麼問題

#include<iostream>
using namespace std;
int w[105],v[105];
int dp[105];
int main()
{
    int a,b;
    cin >> a >> b;
    while(a--)
    {
        int n,m,s;
        cin >> n >> m >> s;
        for(int i = 1; i <= s; ++i)//分割為01揹包問題
            for(int j = b; j >= n; --j)
                dp[j] = max(dp[j],dp[j - n] + m);
    }
    cout << dp[b] << endl;
}

這是第一版的01揹包
後續還有完全揹包的二進位制優化,分組揹包和混合揹包問題
有興趣的同學可以看一下
如果支援過5,可以考慮寫第二版
好吧,今天的分享就到這裡,創作不易,感謝大家的支援

相關文章