1. 程式人生 > 實用技巧 >淺談幾類揹包題

淺談幾類揹包題

淺談幾類揹包題

摘要

揹包問題作為一個經典問題在動態規劃中是很基礎的一個部分,然而以 0-1 揹包問題為原題,衍生轉變出的各類題目,可以說是千變萬化,當然解法也各有不同,如此就有了繼續探究的價值。 本文就 4 道揹包變化的題做一些探討研究,提出本人的一些做法,希望能起到拋磚引玉的作用。

關鍵字

動態規劃 揹包 優化

正文

一、 引言

揹包問題是運籌學中的一個經典的優化難題,是一個 NP-完全問題,但其有 著廣泛的實際應用背景,是從生活中一個常見的問題出發展開的: 一個揹包,和很多件物品,要在揹包中放一些物品,以達到一定的目標。

在資訊學中,把所有的資料都量化處理後,得到這樣的一個問題: 0-1 揹包問題:給定 n 件物品和一個揹包。物品 i 的價值是 Wi ,其體積為 Vi,揹包 的容量為 C。可以任意選擇裝入揹包中的物品,求裝入揹包中物品的最大總價值。

在選擇裝入揹包的物品時,對每件物品 i ,要麼裝入揹包,要麼不裝入揹包。 不能將物品 i 多次裝入揹包,也不能只裝入部分物品 i (分割物品 i)。 因此,該 問題稱為 0-1 揹包問題。 用於求解 0-1 揹包問題的方法主要有回溯演算法、貪婪演算法、遺傳演算法、禁忌 搜尋演算法、模擬退火演算法等。

在高中階段,我們所謂的經典 0-1 揹包問題,保證了所有量化後的資料均為 正整數,即是一個特殊的整數規劃問題,本文中如無特殊說明均以此為前提。其 經典的 \(O(n*C)\)

動規解法是: 狀態是在前 i 件物品中,選取若干件物品其體積總和不大於 j,所能獲得的最大價值為 \(F_i[j]\),當前的決策是第 i 件物品放或者不放,最終得到轉移方程:

\[F_i[j] = F_{i-1}[j] \color{yellow}\quad \left( \rm V_i > j\ge 0\right) \]

\[F_i[j] = \max\{F_{i-1}[j],F_{i-1}[j-\rm{V_i}]+W_i \}\quad \color{yellow}(C\ge j\ge V_i) \]

其中由於 Fi只與 Fi-1 有關,可以用滾動陣列來節省程式的空間複雜度。以下 就是經典演算法的虛擬碼。

FOR i: = 1 TO n
    FOR j: = C DOWNTO V_i
        MAX (F[j],F[j-V_i]+W_i)
    END FOR
END FOR

二、 揹包的基本變換

1. 完全揹包

完全揹包問題:
給定 n 種物品和一個揹包。第 i 種物品的價值是 \(W_i\) ,其體積為 \(V_i\),揹包的容量為 C,同一種物品的數量無限多。可以任意選擇裝入揹包中的物品, 求裝入揹包中物品的最大總價值。

這個問題完全可以轉化為 0-1 揹包問題來解決,即把第 i 種物品分割成 \((C div Vi)\)件物品,再用 0-1 揹包問題的經典動規實現。 但是,這個演算法的時間複雜度太高,並不能作為一種實用的方法來實現。

很容易注意到,這個問題對於 0-1 揹包來說,是另一個極端,每種物 品都可以無限制地取,只要改變轉移方程,就可以構造出新的演算法: 狀態是在前 i 種物品中,選取若干件物品其體積總和不大於 j,所能獲得的最大 價值為 \(F_i[j]\),當前的決策是第 i 件物品放(多件)或者不放,轉移方程是

\[F_i[j] = F_{i-1}[j] \color{yellow}\quad \left( \rm V_i > j\ge 0\right) \]

\[F_i[j] = \max\{F_{i-1}[j],F_{i}[j-\rm{V_i}]+W_i \}\quad \color{yellow}(C\ge j\ge V_i) \]

//注意第二個是 Fi而不是 Fi-1,與 0-1 揹包區別僅在於此,因為允許在放過的基礎上再增加一件。

這樣,這個問題就有了與 0-1 揹包一樣時間複雜度\(O(n*C)\)的解決方法, 同樣的可以用滾動陣列來實現。

FOR i: = 1 TO n
    FOR  j: =V_i TO C
        MAX(F[j], F[j-V]+W[i]) → F[j]
    END FOR
END FOR

2. 多次揹包

多次揹包問題:給定 n 種物品和一個揹包。第 i 種物品 的價值是 \(W_i\) ,其體積為 \(V_i\),數量是 \(K_i\)件,揹包的容量為 C。可以任意選擇裝入揹包中的物品,求裝入背 包中物品的最大總價值。

和完全揹包一樣,可以直接套用 0-1 揹包問題的經典動規實現,但是 效率太低了,需要尋找更高效的演算法。

首先對於第 i 種物品,不能確定放多少件才是最優的,因為並沒有什麼 可以證明放一件或者全放一定會更優。換句話說,最優解所需要的件數, 可能是 0 到 \(K_i\)中的任何數。

在日常生活中,如果需要能拿得出 1 到 Ki的任意整數數額的錢,往往不會帶 \(K_i\)個一元錢,因為那實在是太不方便了,取而代之的是帶一些 1 元 和其他一些面值各不相同的非 1 數額的錢。 這種思想完全可以運用到這道題上!

不需要把一種物品拆分成 \(K_i\) 份,而是隻要物品拆分到能湊出 1 到 \(K_i\) 之間任意數量的程度就可以了。

可以證明,按照二進位制的拆分能使件數達到最小,把 \(K_i\) 拆分成 \(1,2,4,\dots,2^t,K_i-2^{t-1}+1\color{yellow}\ \ \ (2^{t-2}>K_i\ge2^{t-1}\),就一定可以滿足最優解要求了。下一步, 還是用 0-1 揹包的經典演算法。

如此,我們得到了一個時間複雜度為\(\texttt{O}(C*\Sigma(\log_2K_i))\)的演算法。

FOR i: = 1 TO n
1 → m
WHILE K_i > 0
    IF m > K_i
        THEN K_i → m
    K_i - m → K_i
    FOR j: = C DOWNTO V_i * m
        MAX( F[j] , F[j-V_i*m]+W_i*m ) → F[j]
    END FOR
    m * 2 → m
    END WHILE
END FOR

此演算法還能加上一個優化,判斷如果 \(K_i\)大於 C/Vi,則多出的部分是沒 有意義的,可以捨去。時間複雜度就可以優化到 \(O(n*C*\log_2C)\)。由於在高中階段碰到的題中 C 的值很有限,所以這個演算法在實際應用上的效果已經 可能滿足一般的需求了。 但是在下一節中,有一個更高效的解決方法.

單調佇列優化☆

長度限制最大連續和問題:給出長度為 n 的序列 \(X_i\),求這個序列中長度不超過 \(l_{\max}\)的最大連續和。

首先考慮最簡單的做法,就是直接用 \(O(n*l_{\max})\)的二次迴圈求最大值

FOR i: = 1 TO n 
    0 → s 
    FOR j: = i DOWNTO Max( i-lmax+1 ,1) 
        s + Xj → s 
        IF s > ans THEN s → ans 
    END FOR 
END FOR 

\(S_i\)\(X_1\)\(X_i\) 的總和,就可以看到,如果確定一個端點後,要做的 就是在 S 陣列的一個連續區間取一個最值,區間最值問題完全可以用線段樹來實現。 但是這個題目的另一個特性是區間的長度是固定的,而且每個區間都 只需要取一次,所以我們可以用更簡單的資料結構來實現——單調佇列。

先來研究一下單調佇列,以維護最大值為例,在滿足序列中的編號遞增以後,還要滿足元素的值的遞減。

L L+1 ... R-1 R
編號 \(A_L\) \(A_{L+1}\) ... \(A_{R-1}\) \(A_R\)
數值 \(B_L\) \(B_{L+1}\) ... \(B_{R-1}\) \(B_R\)

滿足\(A_{i+1}>A_i>A_{i-1}\operatorname{and} B_{i-1}>B_i>B_{i+1}\ \ \color{yellow}(R>i>L)\)

單調佇列除佇列首元素出佇列外,還需要用一定的操作來維護佇列的 特殊性質。如果進入了一個新的元素(a,b),其中 a 必然大於 \(A_R\),但是 b 可 能會大於等於 \(B_R\)。既然 b 大於等於 \(B_R\),而元素 R 又是要先於新元素出隊 列,那麼元素 R 就已經失去價值,因為接下來新元素必然都會比元素 R 更 優。所以現在就可以刪除元素 R 了。

重複上面的步驟,直到前面沒有元素或者滿足 \(B_R>b\) 為止,再讓新元素 進佇列。顯然,當前的佇列首元素,必定是這個區間的最大值

PROCEDURE INSERT a , b  
    WHILE R >= L AND b > B[ R ] 
        DO R - 1 → R 
    R + 1 → R 
    a → A[ R ]  
    b → B[ R ] 
END 

如此完成的單調佇列,雖然不能保證每一次的操作是 O(1),但是因為 每個元素只進佇列一次,並出佇列一次,所以總效率是 O(n)。

當然,這道題其實就是單調佇列的基本功能,而我們希望的是能把它用來優化揹包問題,所以現在重新考慮 多次揹包問題 。

對於第 i 種物品來說,已知體積 v,價值 w,數量 k,那麼可以按照當 前列舉的體積 j 對 v 的餘數把整個動規陣列分成 v 份,以下是 v=3 的情況:

j 0 1 2 3 4 5 6 7 8 ...
\(j \mod v\) 0 1 2 0 1 2 0 1 2 ...

我們可以把每一份分開處理,假設餘數為 d。

編號 j 0 1 2 3 4 5 ...
對應體積 d d+v \(d+2*v\) \(d+3*v\) \(d+4*v\) \(d+5*v\) ...

現在看到分組以後,編號 j 可以從 j-k 到 j-1 中的任意一個編號轉移而 來(因為相鄰的體積正好相差 v),這看上去已經和區間最大值有點相似了。 但是注意到由於體積不一樣,顯然體積大的價值也會大於等於體積小的, 直接比較是沒有意義的,所以還需要把價值修正到同一體積的基礎上。比 如都退化到 d,也就是說用\(F[j*v+d]- j*w\) 來代替原來的價值進入佇列。 對於第 i 件物品,轉移虛擬碼

FOR d: = 0 TO v-1                         //列舉餘數,分開處理 
    清空佇列   
    FOR j: = 0 TO (C-d) div v             //j 列舉標號,對應體積為 j*v+d 
        INSERT j , F[ j*v+d ] – j * w     //插入佇列 
        IF A[ L ] < j - k THEN L + 1 → L  //如果佇列的首元素已經失效 
        B[ L ] + j * w → F[ j*v+d ]       //取佇列頭更新 
    END FOR 
END FO

已知單調佇列的效率是 O(n),那麼加上單調佇列優化以後的多次揹包, 效率就是 O(n*C)了。

三、 其他幾類揹包問題

1. 樹形依賴揹包(選課)☆

樹形依賴揹包問題:給定 n 件物品和一個揹包。第 i 件物品 的價值是 \(W_i\) ,其 體積為 \(V_i\),但是依賴於第 \(X_i\)件物品(必須選取 \(X_i\)後才能取 i,如果無依賴則 \(X_i=0\)), 依賴關係形成森林,揹包的容量為 C。可以任意選擇裝入揹包中的物品,求裝入揹包中物品的最大總價值。

這道題需要在 treedp 的基礎上用揹包實現。

泛化物品——定義: 考慮這樣一種物品,它並沒有固定的費用(體積)和價值,而是它的價值隨著 你分配給它的費用(體積)變化而變化。

泛化物品可以用一個一維陣列來表示體積與價值的關係 Gj表示當體積 為 j 的時候,相對應的價值為 \(G_j\ \ \color{yellow}(C>=j\ge0)\)。 顯然,之前的揹包動規陣列 \(F_i\),就是一件泛化物品,因為 \(F_i[j]\)表示的 正是體積為 j 的時候的最大價值。同樣的,多件物品也是可以合併成一件泛化物品。

泛化物品的和:

把兩個泛化物品合併成一個泛化物品的運算,就是列舉體積分配給兩個泛化物 品,滿足: \(G[j] = \max\{ G_1[j-k] + G_2[k] \}\ \ \color{yellow} (C\ge j\ge k\ge0)\)

把兩個泛化物品合併的時間複雜度是 O(C^2)。

對於具有樹形依賴關係的揹包問題,我們可以把每棵子樹看作是一個 泛化物品,那麼一棵子樹的泛化物品就是子樹根節點的這件物品的泛化物 品與由根所連的所有子樹的泛化物品的和。 整個動規過程就是從葉子進行到根,對於每一棵子樹的操作就是:

PROCEDURE DEAL i , v , w      //第 i 個節點 體積為 v 價值為 w 
    FOR j: = v TO C           //初始化這件泛化物品 
        w → F_i[ j ] 
    END FOR 
    FOR s: = 1 TO n 
        IF s 是 i 的兒子 THEN 
        F_i 與 F_s 的和 → F_i 
        END IF 
    END FOR 
END

一次只能把兩個泛化物品合併,那麼要把 n 個泛化物品合併成一個就 需要 n-1 次合併,所以這個演算法效率是 \(O(n*C^2+n^2)\) ,當然,其中的 \(O(n^2)\) 可以用鄰接表的記邊方法變成 \(O(n)\),最終的效率就是 \(O(n*C^2)\)

回顧經典 0-1 揹包問題,在那個經典演算法中,求泛化物品與一件物品 的和,只需要 O(C)的時間複雜度,推論出:

泛化物品與一件物品的和: 把一個泛化物品與一件物品合併成一個泛化物品,可以用類似於 0-1 揹包經典動 規的方法求出。

\[G[j] = G_1[j] \ \ \color{yellow}{(v>j\ge0)} \]

\[G[j] = \max\{ G_1[j] , G_1[j-v]+w \}\ \ \color{yellow}(C\ge j\ge v) \]

這樣的合併,時間複雜度僅為 O(C),同樣也是合併了一件物品,效率 比求兩件泛化物品的和快很多。那麼有沒有辦法用這種 O(C)的合併方式來 代替計算兩個泛化物品的和來處理這道題呢?

泛化物品的並: 因為兩個泛化物品之間存在交集,所以不能同時兩者都取,那麼我們就需要求 泛化物品的並,對同一體積,我們需要選取兩者中價值較大的一者,效率 O(C)。

\(G[j] = \max\{ G_1[j] , G_2[j] \} \color{yellow}\quad(C\ge j\ge0)\)

重新考慮對以 i 為根的子樹的處理,假設當前需要處理 i 的一個兒子 s。

如果我們在當前的 \(F_i\)中強制放入物品 s 後作為以 s 為根的子樹的初始 狀態的話,那麼處理完以 s 為根的子樹以後,\(F_s\)就是與 \(F_i\)有交集的泛化物 品(實際上是 \(F_s\)包含 \(F_i\)),同時,Fs必須滿足放了物品 s,即 \(F_s[j]\color{yellow}\quad (V_s>j\ge0)\) 已經無意義了,而 \(F_s[j]\color{yellow}\quad(C>=j\ge V_s)\)必然包含物品 s。為了方便,經過處理以 後,在程式中規定只有\(F_s[j]\color{yellow}\quad(C-V_s\ge j\ge0)\)是合法的。

接下來只需要把 \(F_s\)\(F_i\)的並賦給\(F_i\),就完成了對一個兒子的處理。如 此,我們需要的總時間複雜度僅為 O(n*C)。

PROCEDURE DEAL i , C 
    FOR s: = 1 TO n 
        IF s 是 i 的兒子 THEN 
            Fi → Fs 
            DEAL s , C – Vs            //揹包容量減小 Vs 
            FOR K: =Vs TO C            //求兩者的並 
                Max ( Fi[ k ] , Fs[ k-Vs ] + Ws ) → Fi[ k ] 
            END FOR 
        END IF 
    END FOR 
END

2. PKU3093☆

PKU3093:給定 n 件物品和一個揹包,第 i 件物品的體積為 Vi,揹包的容量為 C。 要求把一些物品放入揹包使得剩下的物品都放不下,求方案數。

暫時先不考慮“使剩下的物品都放不下”的條件,那就是求 0-1 揹包 的所有可行方案。 用 \(F_i[j]\)表示前 i 件物品中選若干件總體積為 j 的方案數,初始為 \(F_0[0]=1\),轉移方程是

\[F_i[j] = F_{i-1}[j]\color{yellow}\quad (Vi>j) \]

\[F_i[j] = F_{i-1}[j] + F_{i-1}[j-Vi]\color{yellow}\quad(j>=Vi) \]

顯然這個演算法的效率是 O(n*C)的,它計算了所有裝放揹包的方案數。

現在考慮“使剩下的物品都放不進去”的條件,如果剩下的物品中體 積最小為 v,那麼方案數就是 \(\text{sum }\{ F_n[j] \}\color{yellow}\quad(C\ge j>C-v)\)。前提是我們事先確定 了剩下中體積最小的是哪個。 對體積排序後,下一步就是列舉 i 作為剩餘物品中體積最小的一件。

對 於所有 \(s<i\) 的物品必須都要放入揹包,對於 i 則不能放入揹包,對於 s>i 的 物品做 0-1 揹包可行方案的統計,將 \(\text{sum}\{ F_n[j]\}\color{yellow}\quad(C\ge j>C-V_i)\)累加到 ans。 由於每次都需要對 n-i 件物品做統計,一共統計 n 次,效率是 \(O(n^2*C)\)

0 → sum                      //sum 記 1 到 i-1 的物品體積總和 
FOR i: = 1 TO N 
    F 陣列清零 
    1 → F[ sum ]                //初始化 
    FOR s: = i + 1 TO N       //統計可行方案數 
        FOR j: = C DOWNTO Vs + sum 
            F[ j ] + F[ j-Vs ] → F[ j ] 
        END FOR 
    END FOR 
    FOR k: = C DOWNTO C - Vi + 1    //累加總方案數 
        IF k >= sum THEN ans + F[ k ] → ans
    END FOR
    sum + Vi → sum 
END FOR

可以發現,同一個物品多次被考慮放入揹包,這樣會造成時間的浪費。 觀察得到,第 i 件物品共考慮了 i-1 次。每一次迴圈都會少一件物品。如果 把整個過程倒置,每件物品是否可以只考慮一次呢?

由於初始狀態不一樣,我們還需要把初始狀態統一。可以讓每次 \(F[0]=1\),總容量為 \(C-sum\)。 但是隻統一初始化狀態還不夠,因為每次的揹包容量還是不同的,做 揹包統計的時候,揹包容量不可以是一個變值,也必須要統一,所以每次 考慮一件物品都要用最大容量 C 來更新揹包。 一次操作之後要將 \(\text{sum}\{ F[j] \}\color{yellow}\quad(C-sum\ge j>C-sum-V_i , j\ge 0)\)累加到 ans。 現在,每件物品都只考慮一次,揹包體積統一是 C,那麼效率就變成 了 O(n*C)。

0 → sum 
1 → F[ 0 ] 
FOR i: = 1 TO n 
    sum + Vi → sum 
END FOR 
FOR i: = n DOWNTO 1 
    sum - Vi → sum 
    FOR j: = C - sum DOWNTO  Max( C – sum – Vi + 1 , 0 )//累加總方案數 
        ans + F[ k ] → ans 
    END FOR 
    FOR j: = C DOWNTO Vi             //考慮第 i 件物品放入揹包 
        F[ j ] + F[ j – Vi ] → F[ j ] 
    END FOR
END FOR

四、 總結

回顧全文的四道揹包題:

  • 對於完全揹包問題,用轉化方程就解決了;
  • 對於多次揹包,使用了單調佇列優化來實現 \(O(n*C)\)的效率;在樹形依賴揹包問題中,探索了新的概念,最終完成演算法的轉化;
  • 在 PKU3093 一題中,通過合併相似 操作來達到優化的效果。 雖然用到的方法各不相同,每個方法都不僅僅限於揹包問題,完全可以靈 活運用到其他問題中。
  • 文中提到的問題最後都用時間複雜度 O(n*C)的演算法解決了,這並不是 說所有揹包題都可以優化到這個程度,但是,可以肯定的是不會有比這個更快的 效率了。
  • 就目前來說,揹包類的題目還有很多沒有得到很好的解決,等待著大家去 繼續探索研究。