01揹包,完全揹包,多重揹包,混合揹包詳解

文章轉載至:https://www.cnblogs.com/kuangbin/archive/2011/11/16/2250560.html

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(VN),其中時間複雜度應該已經不能再優化了,但空間複雜度卻可以優化到O。

    先考慮上面講的基本思路如何實現,肯定是有一個主循環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揹包問題是十分必要的。

    事實上,使用一維數組解01揹包的程序在後面會被多次用到,所以這裏抽象出一個處理一件01揹包中的物品過程,以後的代碼中直接調用不加說明。

    過程ZeroOnePack,表示處理一件01揹包中的物品,兩個參數cost、weight分別表明這件物品的費用和價值。

procedure ZeroOnePack(cost,weight);
for(v=V..cost)
    f[v]=max{f[v],f[v-cost]+weight};

    注意這個過程裏的處理與前面給出的僞代碼有所不同。前面的示例程序寫成v=V..0是爲了在程序中體現每個狀態都按照方程求解了,避免不必要的思維複雜度。而這裏既然已經抽象成看作黑箱的過程了,就可以加入優化。費用爲cost的物品不會影響狀態f[0..cost-1],這是顯然的。

    有了這個過程以後,01揹包問題的僞代碼就可以這樣寫:

for(i=1..N)
    ZeroOnePack(c[i],w[i]);

初始化的細節問題
    我們看到的求最優解的揹包問題題目中,事實上有兩種不太相同的問法。有的題目要求“恰好裝滿揹包”時的最優解,有的題目則並沒有要求必須把揹包裝滿。一種區別這兩種問法的實現方法是在初始化的時候有所不同。

    如果是第一種問法,要求恰好裝滿揹包,那麼在初始化時除了f[0]爲0其它f[1..V]均設爲-∞,這樣就可以保證最終得到的f[N]是一種恰好裝滿揹包的最優解。

    如果並沒有要求必須把揹包裝滿,而是隻希望價格儘量大,初始化時應該將f[0..V]全部設爲0。

    爲什麼呢?可以這樣理解:初始化的f數組事實上就是在沒有任何物品可以放入揹包時的合法狀態。如果要求揹包恰好裝滿,那麼此時只有容量爲0的揹包可能被價值爲0的nothing“恰好裝滿”,其它容量的揹包均沒有合法的解,屬於未定義的狀態,它們的值就都應該是-∞了。如果揹包並非必須被裝滿,那麼任何容量的揹包都有一個合法解“什麼都不裝”,這個解的價值爲0,所以初始時狀態的值也就全部爲0了。

    這個小技巧完全可以推廣到其它類型的揹包問題,後面也就不再對進行狀態轉移之前的初始化進行講解。

一個常數優化
    前面的僞代碼中有 for v=V..1,可以將這個循環的下限進行改進。

    由於只需要最後f[v]的值,倒推前一個物品,其實只要知道f[v-w[n]]即可。以此類推,對以第j個揹包,其實只需要知道到f[v-sum{w[j..n]}]即可,即代碼中的

for(i=1..N)
    for(v=V..0)

可以改成

for(i=1..n)
    bound=max{V-sum{w[i..n]},c[i]}
    for(v=V..bound)

這對於V比較大時是有用的。

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

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],f[i-1][v] }(0<=k*c[i]<=v)

    這跟01揹包問題一樣有O(VN)個狀態需要求解,但求解每個狀態的時間已經不是常數了,求解狀態f[i][v]的時間是O(v/c[i]),總的複雜度可以認爲是O(V*Σ(V/c[i])),是比較大的。

    將01揹包問題的基本思路加以改進,得到了這樣一個清晰的方法。這說明01揹包問題的方程的確是很重要,可以推及其它類型的揹包問題。但我們還是試圖改進這個複雜度。

一個簡單有效的優化
     完全揹包問題有一個很簡單有效的優化,是這樣的:若兩件物品i、j滿足c[i]<=c[j]且w[i]>=w[j],則將物品j去掉,不用考慮。這個優化的正確性顯然:任何情況下都可將價值小費用高得j換成物美價廉的i,得到至少不會更差的方案。對於隨機生成的數據,這個方法往往會大大減少物品的件數,從而加快速度。然而這個並不能改善最壞情況的複雜度,因爲有可能特別設計的數據可以一件物品也去不掉。

    這個優化可以簡單的O(N^2)地實現,一般都可以承受。另外,針對揹包問題而言,比較不錯的一種方法是:首先將容量大於V的物品去掉,然後使用類似計數排序的做法,計算出容量相同的物品中價值最高的是哪個(只要保留價值大的就行了,和上面的篩選一樣),可以O(V+N)地完成這個優化。這個不太重要的過程就不給出僞代碼了,希望你能獨立思考寫出僞代碼或程序。

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

更高效的轉化方法是:把第i種物品拆成容量爲c[i]*2^k、價值爲w[i]*2^k的若干件物品,其中k滿足c[i]*2^k<=V。這是二進制的思想,因爲不管最優策略選幾件第i種物品,總可以表示成若干個2^k件物品的和。這樣把每種物品拆成O(log V/c[i])件物品,是一個很大的改進。

但我們有更優的O(VN)的算法。

O(VN)的算法
這個算法使用一維數組,先看僞代碼:

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

    你會發現,這個僞代碼與P01的僞代碼只有v的循環次序不同而已。爲什麼這樣一改就可行呢?首先想想爲什麼P01中要按照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的順序循環。這就是這個簡單的程序爲何成立的道理。

    值得一提的是,上面的僞代碼中兩層for循環的次序可以顛倒。這個結論有可能會帶來算法時間常數上的優化。

    這個算法也可以以另外的思路得出。例如,將基本思路中求解f[i][v-c[i]]的狀態轉移方程顯式地寫出來,代入原方程中,會發現該方程可以等價地變形成這種形式:

f[i][v]=max{f[i-1][v],f[i][v-c[i]]+w[i]}

將這個方程用一維數組實現,便得到了上面的僞代碼。

最後抽象出處理一件完全揹包類物品的過程僞代碼:

procedure_CompletePack(cost,weight)
    for(v=c[i]..V)
        f[v]=max{f[v],f[v-c[i]]+w[i]};

總結
    完全揹包問題也是一個相當基礎的揹包問題,它有兩個狀態轉移方程,分別在“基本思路”以及“O(VN)的算法“的小節中給出。希望你能夠對這兩個狀態轉移方程都仔細地體會,不僅記住,也要弄明白它們是怎麼得出來的,最好能夠自己想一種得到這些方程的方法。事實上,對每一道動態規劃題目都思考其方程的意義以及如何得來,是加深對動態規劃的理解、提高動態規劃功力的好方法。

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],f[i][v] }(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(log2n[i])種物品,將原問題轉化爲了複雜度爲O(V*Σlog2n[i])的01揹包問題,是很大的改進。

    下面給出O(log amount)時間處理一件多重揹包中物品的過程,其中amount表示物品的數量:

procedure MultiplePack(cost, weight, amount) {
    if(cost*amount>=V)  //如果i種物品足夠多,那麼就相當與無限多了                          
        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);
}

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

O(VN)的算法
    多重揹包問題同樣有O(VN)的算法。這個算法基於基本算法的狀態轉移方程,但應用單調隊列的方法使每個狀態的值可以以均攤O(1)的時間求解。由於用單調隊列優化的DP已超出了NOIP的範圍,故本文不再展開講解。我最初瞭解到這個方法是在樓天成的“男人八題”幻燈片上。

小結
    這裏我們看到了將一個算法的複雜度由O(V*Σn[i])改進到O(V*Σlog n[i])的過程,還知道了存在應用超出NOIP範圍的知識的O(VN)算法。希望你特別注意“拆分物品”的思想和方法,自己證明一下它的正確性,並將完整的程序代碼寫出來。

P04: 混合三種揹包問題

問題
    如果將P01、P02、P03混合起來。也就是說,有的物品只可以取一次(01揹包),有的物品可以取無限次(完全揹包),有的物品可以取的次數有一個上限(多重揹包)。應該怎麼求解呢?

01揹包與完全揹包的混合
    考慮到在P01和P02中給出的僞代碼只有一處不同,故如果只有兩類物品:一類物品只能取一次,另一類物品可以取無限次,那麼只需在對每個物品應用轉移方程時,根據物品的類別選用順序或逆序的循環即可,複雜度是O(VN)。僞代碼如下:

for i=1..N
    if 第i件物品屬於01揹包
        for v=V..0 
            f[v]=max{f[v],f[v-c[i]]+w[i]};
    else if 第i件物品屬於完全揹包
        for v=0..V
            f[v]=max{f[v],f[v-c[i]]+w[i]};

再加上多重揹包
    如果再加上有的物品最多可以取有限次,那麼原則上也可以給出O(VN)的解法:遇到多重揹包類型的物品用單調隊列解即可。但如果不考慮超過NOIP範圍的算法的話,用P03中將每個這類物品分成O(log n[i])個01揹包的物品的方法也已經很優了。

    當然,更清晰的寫法是調用我們前面給出的三個相關過程。

for i=1..N
    if 第i件物品屬於01揹包
        ZeroOnePack(c[i],w[i])
    else if 第i件物品屬於完全揹包
        CompletePack(c[i],w[i])
    else if 第i件物品屬於多重揹包
        MultiplePack(c[i],w[i],n[i])

    在最初寫出這三個過程的時候,可能完全沒有想到它們會在這裏混合應用。我想這體現了編程中抽象的威力。如果你一直就是以這種“抽象出過程”的方式寫每一類揹包問題的,也非常清楚它們的實現中細微的不同,那麼在遇到混合三種揹包問題的題目時,一定能很快想到上面簡潔的解法,對嗎?

小結
    有人說,困難的題目都是由簡單的題目疊加而來的。這句話是否公理暫且存之不論,但它在本講中已經得到了充分的體現。本來01揹包、完全揹包、多重揹包都不是什麼難題,但將它們簡單地組合起來以後就得到了這樣一道一定能嚇倒不少人的題目。但只要基礎紮實,領會三種基本揹包問題的思想,就可以做到把困難的題目拆分成簡單的題目來解決。

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