1. 程式人生 > >對01揹包的分析與理解(圖文)

對01揹包的分析與理解(圖文)

 

首先謝謝Christal_R文章(點選轉到連結)讓我學會01揹包

本文較長,但是長也意味著比較詳細,希望您可以耐心讀完。

題目:

現在有一個揹包(容器),它的體積(容量)為V,現在有N種物品(每個物品只有一個),每個物品的價值W[i]和佔用空間C[i]都會由輸入給出,現在問這個揹包最多能攜帶總價值多少的物品?

一.動態規劃與遞推解決01揹包

初步分析:

0. 淺談問題的分解

在處理到第i個物品時,可以假設一共只有i個物品,如果前面i-1個物品的總的最大價值已經定下來了,那麼第i個物品選不選將決定這1~i個物品能帶來的總的最大價值

剛剛是自頂向下,接下來反過來自底向上

,第1個物品選不選可以輕鬆地用初始化解決,接下來處理第i個物品時,假設只有2個物品就好,那他處理完後前2個物品能帶來的最大總價值就確定了,這樣一直推下去,就可以推出前n個物品處理完後能帶來的最大總價值

 

1.分層考慮解決"每個物品最多隻能裝一次"

每個物品只能裝一次,那麼就應該想到常用的一種方法,就是用陣列的縱軸來解決,對於n個物品,為它賦予i=1~n的編號,那麼陣列的縱軸就有n層,每層只考慮裝不裝這個物品,那麼分層考慮就可以解決最多裝一個的問題了

 

2.對0,1的理解

對於每個揹包,都只有0和1的情況,也就是拿或者不拿兩種情況

如果拿:那麼空間就會減一點,比如說現在在考慮第i個物品拿不拿,如果說當前剩餘空間為j,那麼拿了之後空間就變為j-c[i],但是總價值卻會增加一點,也就是增加w[i]

如果不拿:那麼空間不會變,還是j,但是總價值也不會變化

 

3.限制條件

所以對於這題來說有一個限制條件,就是空間不超出,然後目標就是在空間不超出的情況塞入物品使總價值最大,在前面,我們已經講了陣列的縱軸用來表示當前處理到第幾個物品,那麼只靠這個是不夠的,而且這個陣列的意義還沒有講

這題就是限制條件(空間)與價值的平衡,你往揹包中塞東西,價值多了,可是空間少了,這空間本來可能遇到價效比更高的物品但也可能沒遇到

4.具體的建立陣列解決問題

有了前面的限制情況和0,1的分析就可以建立陣列了

對於這個陣列,結合題目要求來說,陣列的意義肯定是當前的總價值,也就是第i個物品的總價值,那麼題目還有一個限制條件,只靠一個n層的一維陣列是不夠的,還需要二維陣列的橫軸來分析當前的剩餘容量

所以我們有了一個數組可以來解決問題了,這個陣列就叫f好了,然後它是一個二維陣列,它的縱軸有i層,我希望它從i=1~n,不想從下標0開始是為了美觀,然後這個二維陣列的橫軸代表著當前剩餘的空間,就用j來表示,j=0~V,0就是沒有空間的意思,V前面說了,是這個揹包的總容量

我們把這個二維陣列建立在int main()的上面,所以它一開始全部都是0,省去了接下來賦初值為0的功夫

有了陣列f[i][j],然後對於每個f[i][j],它表示的是已經處理到第i個物品了,當剩餘空間還有j時,能帶有的最大價值,也就是說f[i][j]儲存的是總價值

說是總價值,可是涉及到放物品還是不放物品的問題,所以再細緻點就是:當前剩餘空間為j,用這j空間取分析第i個物品裝不裝如,處理執行完行為後,f[i][j]就表示了當前能裝入的最大價值

 

5.推導遞推方程

PS:談一下對於動態規劃遞推的理解:處理到第i層時,假設前i-1層的資料都知道而且可以根據1~i-1層的資料推出i,那麼就成功了一半了,因為第i層如此,那麼第i-1層也可以根據1~i-2層推出,接下來只需要定義好陣列的初始條件和注意邊緣問題以及一些細節就可以了

對於第i個物品,假設前i-1個物品都已經處理完

如果第i個物品不能放入:這種情況就是揹包已經滿了,也就是當前剩餘空間j小於第i個物品的佔用空間C[i],

這種情況下,空間沒有變化,價值也沒有變化,對於空間沒有變化,即第i個物品的空間和第i-1個物品的空間j相同,對於價值沒有變化,也就是陣列f的值相同,然後開始利用前面的資料,也就是f[i][j]]=f[i-1][j]

 

如果第i個物品不想放入,那麼和不能放入其實是一樣的,動機不同但結果相同,f[i][j]]=f[i-1][j]

 

如果第i個物品放入了,那麼f[i][j]=f[i-1][j-c[[i]]+w[i],下面解釋一下這個公式,第i個物品的佔用空間為c[i],價值為w[i],f[i-1][j-c[[i]]+w[i]表示前i-1個物品在給它們j-c[[i]空間時能帶來的最大價值

再回到第i個物品的角度,此時有j個空間,如果已經確定要放入,為了使空間充分利用,肯定是這j個空間只分c[i](剛好夠塞下第i個物品),剩下的j-c[[i]全部給前面i-1個物品自由發揮,反正前面f[i-1][j-c[[i]]已經知道了,然後前面i-1個物品用j-c[i]的空間能帶來最大的利益f[i-1][j-c[[i]],第i個物品用c[i]的空間帶來利益w[i],所以如果第i個物品放入後,總利益是f[i][j]=f[i-1][j-c[[i]]+w[i]

 

但是,長遠來說,有一些偏極端情況,放入這個物品,也許它價值w[i]很高,但是它佔用空間c[i]也大,它的價效比可能很低,所以這時候就需要max函數了

當還有空間時:F[i,j] = max[F[i−1,j],F[i−1,j−C[i]] + W[i]

當空間不夠時:F[i,j] = F[i−1,j]

下面一個個解釋:

當還有空間時:這時有兩種方法,放還是不放,如果放,那麼利益由兩段組成1~i-1是一段,i是另一段;如果不妨,那麼利益和上一層剩j空間時相同,這兩個東西大小需要比較,因為如果放入,雖然加上了w[i],利益,可是衝擊了前i-1個物品的利益,如果不放,那麼沒有收穫到第i個物品的利益,但是把原來屬於1~i的空間j,分給了1~i-1個物品,說不定前1~i-1的每個物品都空間小,價值高,價效比高呢?

當空間不夠時,它也只能F[i,j] = F[i−1,j]了,沒有選擇的餘地

 

#include<bits/stdc++.h>//萬能標頭檔案
#define ll long long
using namespace std;
const ll maxn=100;
ll n,v,f[maxn][maxn];
ll c[maxn];//每個物品佔用空間
ll w[maxn];//每個物品的價值
int main()
{
    cin>>n>>v;
    for(ll i=1;i<=n;i++)
        scanf("%lld",&c[i]);
    for(ll i=1;i<=n;i++)
        scanf("%lld",&w[i]);
    for(ll i=1;i<=n;i++)//第i個物品
        for(ll j=v;j>=0;j--)//剩餘空間j
        {
            if(j >= c[i])//如果裝得下
                    f[i][j]=max( f[i-1][j-c[i]]+w[i],f[i-1][j]);
            else//如果裝不下
                f[i][j]=f[i-1][j];
        }
    cout<<f[n][v]<<endl;//輸出答案

}
01揹包普通版程式碼 點選加號展開程式碼,如果點不開可以看底下的程式碼

 

 

二.01揹包的空間優化

有了前面基礎版01揹包的學習,現在學習這個就容易多了

1.何為空間優化,為什麼要空間優化

在01揹包中通過對陣列的優化(用了滾動陣列的方法),可以使本來N*V的空間複雜度降低V,也就是把關於第幾個物品的N去掉了(下面會解釋為什麼可以這麼做)

至於為什麼要空間優化,首先是因為遞推本來就是用空間換時間,消耗的空間比較大,然後關於演算法的競賽一般都會有空間的限制要求,最後,在找工作面試時,面試官肯定會問一些優化的問題,平時養成優化的習慣面試時也有好處

2.為什麼這題可以降維

通過觀察可以發現對於普通版的01揹包遞推式,f[i][...]只和f[i-1][...]有關,那麼我們可以用一種佔用,一種滾動的方法來迴圈使用陣列的空間,所以這個方法叫滾動陣列,對於將來肯定用不到的資料,直接滾動覆蓋即可,具體的如何滾動會放下面講

還有就是滾動陣列的缺點犧牲了抹除了大量資料,不是每道題都可以用,但是在這,答案剛好是遞推的最後一步,所以直接輸出即可,遞推完後不需要呼叫那些已經沒了的資料,所以這題可以

下面先畫個圖理解一下滾動的大致概念

反正就是不斷覆蓋的過程

3.這題如何具體優化

下面開始具體化的分析

對於第i層,它只和第i-1層有關,但是對於剩餘空間j無法優化,所以現在拿i開刀,把他砍掉,用一個長度為V(總空間)的陣列來表示,然後每次相鄰的兩個i和i-1在上面一直滾動

所以現在建立一個數組f[V],一維陣列大小為V

首先建立兩個複合for迴圈

for(i=1~n)

  for(j=v~0)

記住這裡第二層迴圈必須是v~0而不是0~v,先記著,後面會解釋,

接下來的分析建議配合下面圖片學習

然後在迴圈的過程中,還是老樣子,假設我們已經迴圈到i=2這層了(也就是說i=1已經迴圈完了),然後對於i=2這一層,我們對j迴圈,j從v到0

假如現在j=v,我們讓f[j]=max(f[j],f[j-c[i]]+w[i])

在沒有覆蓋之前,所有的f資料都是屬於上一層也就是第一層的,我們就當作i-1層資料已經準備好了,然後把max內的拆成兩半分析,對於f[j]=f[j]就是不放的情況,那麼總價值沒有改變,所以對於f[j]=f[j]就是形式上的更新資料,把i-1層的f[j],給了i-1層的f[j]...對於f[j]=f[j-c[i]]+w[i],那個w[i]是肯定要加的不用討論,然後我們觀察一下,對於下標j-c[i]是不是肯定會小於j,那麼如果說j從V~0也就是從最大到最小,每次賦值處理都是從前面的格子看看資料參考,並沒有修改

再詳細點說的話就是對於f[j]=f[j-c[i]]+w[i],f[j-c[i]]是第i-1層的東西,讓j=v~0是為了讓f陣列每次滾動覆蓋時都是覆蓋接下來不需要用的位置,比如說處理到第f[8]位時,假如接下來的max判定後面那種方法總價值大,然後假設c[i]=3,這時後就相當與f[8]=f[8-c[i]=5]+w[i],我們這裡只是參考了f[5]的資料,並沒有改變它,因為說不定計算新一輪f[6]時又要用到舊的f[5]呢,可是我們重新整理了f[8]的數字後,再j--,計算f[7],再j--,計算f[6],都不會再用到f[8]這個資料,這是由於f[j-c[i]] 中的減c[i]導致的,反之,假若我們讓j=0~v,就可能出現新資料被新資料覆蓋的結果,我們是有"底線"的,只允許新資料覆蓋舊資料

對於j,如果要處理f[j]=max(f[j],f[j-c[i]]+w[i]),就得當j>=c[i]時處理,因為如果j<c[i],那麼j-c[i]為負,下標負的情況沒必要考慮,如果考慮了還可能會溢位

 其實對於max,還用另一個小東西代替,有沒有發現,如果f[j-c[i]]+w[i]>f[j],就選f[j-c[i]]+w[i],如果f[j-c[i]]+w[i]<f[j],那選f[j]和沒選一樣,所以待會的空間優化版省掉了max函式,少用一種函式

#include<bits/stdc++.h>//萬能標頭檔案
#define ll long long
using namespace std;
const ll maxn=100;
ll n,v,f[maxn];
ll c[maxn];//每個物品佔用空間
ll w[maxn];//每個物品的價值

int main()
{
    cin>>n>>v;
    for(ll i=1;i<=n;i++)
        scanf("%lld",&c[i]);
    for(ll i=1;i<=n;i++)
        scanf("%lld",&w[i]);
    for(ll i=1;i<=n;i++)//第i個物品
        for(ll j=v;j>=1;j--)//剩餘空間j
        {
            if(f[j]<=f[j-c[i]]+w[i] && j-c[i]>=0 )//二維陣列變一維陣列
                 f[j]=f[j-c[i]]+w[i];//如果值得改變並且j的空間還裝得下就賦新值
        }
    cout<<f[v]<<endl;//輸出答案

}
空間優化版01揹包 點選"+"號展開程式碼,為了排版好看把程式碼摺疊了,為了防止有人點不開文章底部還有一份沒摺疊的

三.初始化的細節

初始化有兩種,一種情況是隻要求價值最大,另外一種是要求完全剛好塞滿,第一種的初始化是賦值為0,第二種的初始化是賦值為負無窮,因為沒有塞滿,所以資料實際上不存在,也就是讓不存在的數不現實化,讓與這種數相關的資料都不可用化

下面貼一些揹包九講的文字

1.4 初始化的細節問題
我們看到的求最優解的揹包問題題目中,事實上有兩種不太相同的問法。 有的題目要求“恰好裝滿揹包”時的最優解,有的題目則並沒有要求必須把背 包裝滿。一種區別這兩種問法的實現方法是在初始化的時候有所不同。
3
如果是第一種問法,要求恰好裝滿揹包,那麼在初始化時除了F[0]為0,其 它F[1..V ]均設為−∞,這樣就可以保證最終得到的F[V ]是一種恰好裝滿揹包的 最優解。 如果並沒有要求必須把揹包裝滿,而是隻希望價格儘量大,初始化時應該 將F[0..V ]全部設為0。 這是為什麼呢?可以這樣理解:初始化的F陣列事實上就是在沒有任何物 品可以放入揹包時的合法狀態。如果要求揹包恰好裝滿,那麼此時只有容量 為0的揹包可以在什麼也不裝且價值為0的情況下被“恰好裝滿”,其它容量的 揹包均沒有合法的解,屬於未定義的狀態,應該被賦值為-∞了。如果揹包並非 必須被裝滿,那麼任何容量的揹包都有一個合法解“什麼都不裝”,這個解的 價值為0,所以初始時狀態的值也就全部為0了。 這個小技巧完全可以推廣到其它型別的揹包問題,後面也就不再對進行狀 態轉移之前的初始化進行講解。
初始化的細節問題-揹包九講

四.常數級的優化

1.5 一個常數優化
上面虛擬碼中的
for i = 1 to N for v = V to Ci
中第二重迴圈的下限可以改進。它可以被優化為
for i = 1 to N for v = V to max(V −ΣN i Wi,Ci) 這個優化之所以成立的原因請讀者自己思考。(提示:使用二維的轉移方程思 考較易。)
常數級優化-揹包九講

不得不說,這也太摳門了,演算法效率追求到極致

 

 五.小結

01揹包很重要,是後面的基礎

要學會推導狀態轉移方程與實現它

要學會去優化空間複雜度

PS:祝每個看到這裡的人都能掌握01揹包

 

 

接下來放一下程式碼大合集

普通版程式碼
#include<bits/stdc++.h>//萬能標頭檔案
#define ll long long
using namespace std;
const ll maxn=100;
ll n,v,f[maxn][maxn];
ll c[maxn];//每個物品佔用空間
ll w[maxn];//每個物品的價值
int main()
{
    cin>>n>>v;
    for(ll i=1;i<=n;i++)
        scanf("%lld",&c[i]);
    for(ll i=1;i<=n;i++)
        scanf("%lld",&w[i]);
    for(ll i=1;i<=n;i++)//第i個物品
        for(ll j=v;j>=0;j--)//剩餘空間j
        {
            if(j >= c[i])//如果裝得下
                    f[i][j]=max( f[i-1][j-c[i]]+w[i],f[i-1][j]);
            else//如果裝不下
                f[i][j]=f[i-1][j];
        }
    cout<<f[n][v]<<endl;//輸出答案

}
空間優化版程式碼
#include<bits/stdc++.h>//萬能標頭檔案
#define ll long long
using namespace std;
const ll maxn=100;
ll n,v,f[maxn];
ll c[maxn];//每個物品佔用空間
ll w[maxn];//每個物品的價值

int main()
{
    cin>>n>>v;
    for(ll i=1;i<=n;i++)
        scanf("%lld",&c[i]);
    for(ll i=1;i<=n;i++)
        scanf("%lld",&w[i]);
    for(ll i=1;i<=n;i++)//第i個物品
        for(ll j=v;j>=1;j--)//剩餘空間j
        {
            if(f[j]<=f[j-c[i]]+w[i] && j-c[i]>=0 )//二維陣列變一維陣列
                 f[j]=f[j-c[i]]+w[i];//如果值得改變並且j的空間還裝得下就賦新值
        }
    cout<<f[v]<<endl;//輸出答案

}