揹包類問題是動態規劃的一類問題模型,這類模型應用廣泛。揹包類問題通常可以轉化成以下模型:有若干個物品,每個物品有自己的重量和價值。選擇物品放進一個容量有限的揹包裡,求出在容量不超過最大限度的情況下能拿到的最大總價值。
01 揹包問題
揹包類問題中最簡單的是 01 揹包問題:有 \(n\) 個物品,編號分別為 \(1 \sim n\),其中第 \(i\) 個物品的價值是 \(v_i\),重量是 \(w_i\)。有一個容量為 \(c\) 的揹包,問選取哪些物品,可以使得在總重量不超過揹包容量的情況下,拿到的物品的總價值最大。這裡的每個物品都可以選擇拿或不拿。因為每個物品只能用一次,我們用 \(0\) 表示不要這個物品,用 \(1\) 表示要這個物品,因此每個物品的決策就是 \(0\) 或者 \(1\)。這就是 01 揹包這個名字的來源。
看到這個問題很多人的第一反應是使用貪心策略。對於每個物品,計算其價效比:用這個物品的價值除以其重量,得到一個比值。價效比越高,說明該物品越划算,應該儘量拿該物品。將所有物品按照價效比從高到低排序,只要當前揹包還能裝得下,就按照順序一個一個地放進揹包。
貪心策略對於大多數情況是比較有效的。不過很容易找到反例,例如,揹包容量是 \(100\),\(3\) 個物品的重量分別是 \(51,50,50\),價值分別是 \(52,50,50\),可以看到,\(1\) 號物品價效比很高,優先拿 \(1\) 號物品。可是一旦選擇了 \(1\) 號物品,揹包容量就只剩下 \(49\),無法再拿 \(2\) 號或者 \(3\) 號物品。可是如果放棄 \(1\) 號物品,選擇兩個看起來不是很划算的 \(2\) 號和 \(3\) 號物品,總的揹包容量剛好夠用,這時候的總價值是 \(100\),比剛才的 \(52\) 要多。
所以可以看到,貪心策略無效,需要尋找一個動態規劃的解決方案。首先劃分階段,那麼應該一個一個物品地考慮,先考慮第一個物品時的決策,然後考慮新加入一個物品,決策有沒有變化。而對於同一個物品,揹包容量不同,最優結果也是不同的,所以揹包容量也是狀態的一部分。
定義 \(dp_{i,j}\) 表示只考慮前 \(i\) 個物品(並且正準備考慮第 \(i\) 個物品),在揹包容量不超過 \(j\) 的情況下,能拿到的最大價值。對於當前物品,有兩種決策方式,分別是這個物品拿或者不拿。如果拿這個物品,那麼它會佔用 \(w_i\) 的重量,留給前 \(i-1\) 個物品的容量就只剩下 \(j-w_i\) 了,在這個基礎上我們能多拿到的價值是 \(v_i\)。如果不拿當前物品,相當於問題轉化成前 \(i-1\) 個物品可使用的揹包容量是 \(j\)。這兩種決策下我們應該選一個最優的,也就是取最大值,所以狀態轉移方程是:\(dp_{i,j} = \max \{ dp_{i-1,j}, dp_{i-1,j-w_i} + v_i \}\)
例如假設有 \(3\) 個物品,揹包容量是 \(4\),重量分別是 \(1,2,3\),價值分別是 \(2,3,1\)。考慮第 \(1\) 個物品,重量是 \(1\),價值是 \(2\)。計算 \(dp_{1,1}\) 的值,就是隻考慮第 \(1\) 個物品,並且揹包容量是 \(1\) 的時候,最大能取到的價值。如果不拿這個物品,該物品不佔揹包容量,相當於前 \(0\) 個物品,允許佔用的揹包容量是 \(1\),此時的最大價值是 \(dp_{0,1}\),顯然結果是 \(0\)。如果拿這個物品,它自己佔了一個單位空間,留給前 \(0\) 個物品的容量就是 \(0\),加上拿該物品獲得的價值 \(2\),結果是 \(dp_{0,0}+2\)。對於這兩種情況,選擇價值最大的一種,所以 \(dp_{1,1} = max \{ dp_{0,1},dp_{0,0}+2 \} = 2\)。同理,\(dp_{1,2},dp_{1,3},dp_{1,4}\) 也都可以計算出結果為 \(2\)。這表示,當只有第 \(1\) 個物品,揹包容量限制是 \(1 \sim 4\) 時,都可以拿到最大價值 \(2\),這與預期是相符的。
接下倆考慮新引入第 \(2\) 個物品,它的重量是 \(2\),價值是 \(3\),先考慮 \(dp_{2,1}\) 的值,因為目前的揹包容量是 \(1\),而第 \(2\) 個物品的重量是 \(2\),裝不下,所以此處的決策只能是不要第 \(2\) 個物品,\(dp_{2,1}=dp_{1,1}=2\)。在計算 \(dp_{2,2}\) 時,因為容量夠拿第 \(2\) 個物品,可以從拿或者不拿中選擇價值最大的。如果拿,剩下的揹包容量就只有 \(0\) 了,但是可以使拿到的價值加 \(3\),即 \(dp_{2,2}=\max \{ dp_{1,2},dp_{1,0}+3 \} = 3\),這個決策表明,當有 \(2\) 個物品,並且揹包容量是 \(2\) 時,拿第 \(2\) 個物品更划算,可以得到 \(3\) 的價值。按照同樣的計算方式可以依次得到 \(dp_{2,3}\) 和 \(dp_{2,4}\) 的值,結果都為 \(5\)。
考慮第 \(3\) 個物品後,用同樣的方式遞推,最終可以得到結果如下:
最終答案就是 \(dp_{3,4}\),含義是考慮前 \(3\) 個物品(也就是全部物品),揹包容量為 \(4\) 時,最大總價值為 \(5\)。容易發現,最後兩行結果一樣。這其實說明第 \(3\) 個物品在決策時產生不了選它能提升價值的情況。
透過這樣的方式,就完成了 01 揹包問題的求解,時間複雜度為 \(O(nc)\)。
例題:P1048 [NOIP2005 普及組] 採藥
本題是 01 揹包問題的模板題,採藥的總時間 \(T\) 就相當於揹包容量,每一株草藥就是一個物品,採藥花費的時間相當於重量,草藥的價值相當於物品的價值。
#include <cstdio>
#include <algorithm>
using std::max;
const int M = 105;
const int T = 1005;
int dp[M][T];
int main()
{
int t, m; scanf("%d%d", &t, &m);
for (int i = 1; i <= m; i++) { // 第i株草藥
int tm, val; scanf("%d%d", &tm, &val); // 草藥的採摘時間和價值
for (int j = 0; j <= t; j++) { // 揹包容量
dp[i][j] = dp[i - 1][j]; // 第i株草藥不選的決策必然能做
if (j >= tm) { // 揹包容量足夠才能考慮採摘
dp[i][j] = max(dp[i][j], dp[i - 1][j - tm] + val);
}
}
}
printf("%d\n", dp[m][t]);
return 0;
}
例題:P1164 小A點菜
由於“每種菜品只有一份”,所以本題是 01 揹包問題。與前面不同的是,這裡要求的不是最大價值而是方案數。
在 01 揹包問題中,我們是針對頂 \(i\) 個物品要或者不要的情況,求一個最大價值。而本題要計算方案數,如果不要第 \(i\) 個物品,則前 \(i-1\) 個物品有多少種方案,現在都可以納入前 \(i\) 個物品的方案。如果要第 \(i\) 個物品,那麼前 \(i-1\) 個物品在揹包容量減少的情況下的所有情況,也都可以轉移過來。所以總方案數是第 \(i\) 個物品要和不要兩種情況的和。
注意,當容量為 \(0\) 時,不選擇任何一種菜品,也是一種方案,需要初始化。
#include <cstdio>
const int N = 105;
const int M = 10005;
int dp[N][M];
int main()
{
int n, m; scanf("%d%d", &n, &m);
dp[0][0] = 1;
for (int i = 1; i <= n; i++) {
int a; scanf("%d", &a);
for (int j = 0; j <= m; j++) {
dp[i][j] = dp[i - 1][j];
if (j >= a) dp[i][j] += dp[i - 1][j - a];
}
}
printf("%d\n", dp[n][m]);
return 0;
}
習題:CF1954D Colored Balls
解題思路
考慮分組數量的消耗,每一組是最多取 \(2\) 個不同顏色的球。所以假如從全集中取了一部分球出來,其中某種顏色的球超過了一半,則分組數量就是這個顏色的球數,如果沒有一種顏色的球的數量過半,則分組數量是 \(\lceil \frac{球的總數}{2} \rceil\)。
所以按球的數量排序,假設當前列舉的第 \(i\) 種球的數量是最多的,則前面的 \(i-1\) 種球構成的總數可能會超過第 \(i\) 種球的數量,也可能不超過,這兩種情況需要的分組數已經在上一段中討論了。我們還需要知道從前 \(i-1\) 種球選子集後形成的球的各種總數的方案數,這是一個 01 揹包方案數問題。
參考程式碼
#include <cstdio>
#include <algorithm>
using std::sort;
typedef long long LL;
const int N = 5005;
const int MOD = 998244353;
int a[N], dp[N][N];
int main()
{
int n; scanf("%d", &n);
for (int i = 1; i <= n; i++) scanf("%d", &a[i]);
sort(a + 1, a + n + 1);
dp[0][0] = 1;
int ans = 0;
for (int i = 1; i <= n; i++) {
for (int j = 0; j <= 5000; j++) {
if (dp[i - 1][j] == 0) continue;
if (j <= a[i]) {
int add = 1ll * dp[i - 1][j] * a[i] % MOD;
ans = (ans + add) % MOD;
} else {
int add = 1ll * dp[i - 1][j] * ((a[i] + j + 1) / 2) % MOD;
ans = (ans + add) % MOD;
}
}
for (int j = 0; j <= 5000; j++) {
dp[i][j] = dp[i - 1][j];
if (j >= a[i]) dp[i][j] = (dp[i][j] + dp[i - 1][j - a[i]]) % MOD;
}
}
printf("%d\n", ans);
return 0;
}