【恐怖の演算法】 揹包DP

余温辞發表於2024-12-03

【恐怖の演算法】 揹包DP

引入

在具體講何為「揹包 dp」前,先來看如下的例題:

題目傳送門:洛谷P2871 [USACO07DEC] Charm Bracelet S

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

\(0-1\) 揹包

解釋

例題中已知條件有第 \(i\) 個物品的重量 \(w_{i}\),價值 \(v_{i}\),以及揹包的總容量 \(W\)

\(DP\) 狀態 \(f_{i,j}\) 為在只能放前 \(i\) 個物品的情況下,容量為 \(j\) 的揹包所能達到的最大總價值。

考慮轉移。假設當前已經處理好了前 \(i-1\) 個物品的所有狀態,那麼對於第 \(i\) 個物品,當其不放入揹包時,揹包的剩餘容量不變,揹包中物品的總價值也不變,故這種情況的最大價值為 \(f_{i-1,j}\);當其放入揹包時,揹包的剩餘容量會減小 \(w_{i}\),揹包中物品的總價值會增大 \(v_{i}\),故這種情況的最大價值為 \(f_{i-1,j-w_{i}}+v_{i}\)

由此可以得出狀態轉移方程:

\(f_{i,j}=\max(f_{i-1,j},f_{i-1,j-w_{i}}+v_{i})\)

這裡如果直接採用二維陣列對狀態進行記錄,會出現 \(MLE\) 。可以考慮改用滾動陣列的形式來最佳化。

由於對 \(f_i\) 有影響的只有 \(f_{i-1}\),可以去掉第一維,直接用 \(f_{i}\) 來表示處理到當前物品時揹包容量為 \(i\) 的最大價值,得出以下方程:

\(f_j=\max \left(f_j,f_{j-w_i}+v_i\right)\)

務必牢記並理解這個轉移方程,因為大部分揹包問題的轉移方程都是在此基礎上推匯出來的。

實現

還有一點需要注意的是,很容易寫出這樣的 錯誤核心程式碼

for (int i = 1; i <= n; i++)
  for (int l = 0; l <= W - w[i]; l++)
    f[l + w[i]] = max(f[l] + v[i], f[l + w[i]]);
// 由 f[i][l + w[i]] = max(max(f[i - 1][l + w[i]], f[i - 1][l] + w[i]),
// f[i][l + w[i]]); 簡化而來

這段程式碼哪裡錯了呢?列舉順序錯了。

仔細觀察程式碼可以發現:對於當前處理的物品 \(i\) 和當前狀態 \(f_{i,j}\),在 \(j\geqslant w_{i}\) 時,\(f_{i,j}\) 是會被 \(f_{i,j-w_{i}}\) 所影響的。這就相當於物品 \(i\) 可以多次被放入揹包,與題意不符。(事實上,這正是完全揹包問題的解法)

為了避免這種情況發生,我們可以改變列舉的順序,從 \(W\) 列舉到 \(w_{i}\),這樣就不會出現上述的錯誤,因為 \(f_{i,j}\) 總是在 \(f_{i,j-w_{i}}\) 前被更新。

因此實際核心程式碼為

for (int i = 1; i <= n; i++)
  for (int l = W; l >= w[i]; l--) f[l] = max(f[l], f[l - w[i]] + v[i]);

這時,例題就很簡單了

#include <iostream>
using namespace std;
constexpr int MAXN = 13010;
int n, W, w[MAXN], v[MAXN], f[MAXN];

int main() {
  cin >> n >> W;
  for (int i = 1; i <= n; i++) cin >> w[i] >> v[i];  // 讀入資料
  for (int i = 1; i <= n; i++)
    for (int l = W; l >= w[i]; l--)
      if (f[l - w[i]] + v[i] > f[l]) f[l] = f[l - w[i]] + v[i];  // 狀態方程
  cout << f[W];
  return 0;
}

完全揹包

解釋

完全揹包模型與 \(0-1\) 揹包類似,與 \(0-1\) 揹包的區別僅在於一個物品可以選取無限次,而非僅能選取一次。

我們可以借鑑 \(0-1\) 揹包的思路,進行狀態定義:設 \(f_{i,j}\) 為只能選前 \(i\) 個物品時,容量為 \(j\) 的揹包可以達到的最大價值。

需要注意的是,雖然定義與 \(0-1\) 揹包類似,但是其狀態轉移方程與 \(0-1\) 揹包並不相同。

過程

可以考慮一個樸素的做法:對於第 \(i\) 件物品,列舉其選了多少個來轉移。這樣做的時間複雜度是 \(O(n^3)\) 的。

狀態轉移方程如下:

\(f_{i,j}=\max_{k=0}^{+\infty}(f_{i-1,j-k\times w_i}+v_i\times k)\)

考慮做一個簡單的最佳化。可以發現,對於 \(f_{i,j}\),只要透過 \(f_{i,j-w_i}\) 轉移就可以了。因此狀態轉移方程為:

\(f_{i,j}=\max(f_{i-1,j},f_{i,j-w_i}+v_i)\)

理由是當我們這樣轉移時,\(f_{i,j-w_i}\) 已經由 \(f_{i,j-2\times w_i}\) 更新過,那麼 \(f_{i,j-w_i}\) 就是充分考慮了第 \(i\) 件物品所選次數後得到的最優結果。換言之,我們透過區域性最優子結構的性質重複使用了之前的列舉過程,最佳化了列舉的複雜度。

\(0-1\) 揹包相同,我們可以將第一維去掉來最佳化空間複雜度。如果理解了 \(0-1\) 揹包的最佳化方式,就不難明白壓縮後的迴圈是正向的(也就是上文中提到的錯誤最佳化)。

二進位制分組最佳化

考慮最佳化。我們仍考慮把多重揹包轉化成 0-1 揹包模型來求解。

解釋

顯然,複雜度中的 \(O(nW)\) 部分無法再最佳化了,我們只能從 \(O(\sum k_i)\) 處入手。為了表述方便,我們用 \(A_{i,j}\) 代表第 \(i\) 種物品拆分出的第 \(j\) 個物品。

在樸素的做法中,\(\forall j\le k_i,A_{i,j}\) 均表示相同物品。那麼我們效率低的原因主要在於我們進行了大量重複性的工作。舉例來說,我們考慮了「同時選 \(A_{i,1},A_{i,2}\)」與「同時選 \(A_{i,2},A_{i,3}\)」這兩個完全等效的情況。這樣的重複性工作我們進行了許多次。那麼最佳化拆分方式就成為了解決問題的突破口。

過程

我們可以透過「二進位制分組」的方式使拆分方式更加優美。

具體地說就是令 \(A_{i,j}\left(j\in\left[0,\lfloor \log_2(k_i+1)\rfloor-1\right]\right)\) 分別表示由 2^{j} 個單個物品「捆綁」而成的大物品。特殊地,若 \(k_i+1\) 不是 \(2\) 的整數次冪,則需要在最後新增一個由 \(k_i-2^{\lfloor \log_2(k_i+1)\rfloor-1}\) 個單個物品「捆綁」而成的大物品用於補足。

舉幾個例子:

  • 6=1+2+3
  • 8=1+2+4+1
  • 18=1+2+4+8+3
  • 31=1+2+4+8+16
    顯然,透過上述拆分方式,可以表示任意 \(\le k_i\) 個物品的等效選擇方式。將每種物品按照上述方式拆分後,使用 0-1 揹包的方法解決即可。

程式碼

index = 0;
for (int i = 1; i <= m; i++) {
  int c = 1, p, h, k;
  cin >> p >> h >> k;
  while (k > c) {
    k -= c;
    list[++index].w = c * p;
    list[index].v = c * h;
    c *= 2;
  }
  list[++index].w = p * k;
  list[index].v = h * k;
}

混合揹包

混合揹包就是將前面三種的揹包問題混合起來,有的只能取一次,有的能取無限次,有的只能取 k 次。

這種題目看起來很嚇人,可是隻要領悟了前面幾種揹包的中心思想,並將其合併在一起就可以了。下面給出虛擬碼:

for (迴圈物品種類) {
  if (是 0 - 1 揹包)
    套用 0 - 1 揹包程式碼;
  else if (是完全揹包)
    套用完全揹包程式碼;
  else if (是多重揹包)
    套用多重揹包程式碼;
}

分組揹包

洛谷P1757 通天之分組揹包

分析

這種題怎麼想呢?其實是從「在所有物品中選擇一件」變成了「從當前組中選擇一件」,於是就對每一組進行一次 0-1 揹包就可以了。

再說一說如何進行儲存。我們可以將 t_{k,i} 表示第 k 組的第 i 件物品的編號是多少,再用 \mathit{cnt}_k 表示第 k 組物品有多少個。

實現

for (int k = 1; k <= ts; k++)          // 迴圈每一組
  for (int i = m; i >= 0; i--)         // 迴圈揹包容量
    for (int j = 1; j <= cnt[k]; j++)  // 迴圈該組的每一個物品
      if (i >= w[t[k][j]])             // 揹包容量充足
        dp[i] = max(dp[i],
                    dp[i - w[t[k][j]]] + c[t[k][j]]);  // 像0-1揹包一樣狀態轉移

這裡要注意:一定不能搞錯迴圈順序,這樣才能保證正確性。

有依賴的揹包

考慮分類討論。對於一個主件和它的若干附件,有以下幾種可能:只買主件,買主件 + 某些附件。因為這幾種可能性只能選一種,所以可以將這看成分組揹包。

如果是多叉樹的集合,則要先運算元節點的集合,最後算父節點的集合。

泛化物品的揹包

這種揹包,沒有固定的費用和價值,它的價值是隨著分配給它的費用而定。在揹包容量為 \(V\) 的揹包問題中,當分配給它的費用為 \(v_i\) 時,能得到的價值就是 \(h\left(v_i\right)\)。這時,將固定的價值換成函式的引用即可。

噩夢演算法の終結~

相關文章