【恐怖の演算法】 揹包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)\)。這時,將固定的價值換成函式的引用即可。
噩夢演算法の終結~