背包DP:从入门到入机
\(\text{01}\) 背包
\(01\) 背包是背包问题的原神。
有 \(n\) 件物品和一个容量为 \(m\) 的背包。第 \(i\) 件物品的重量是 \(w_i\),价值是 \(v_i\)。求解将哪些物品装入背包可使这些物品的重量总和不超过背包容量,且价值总和最大。
设 \(\text{DP}\) 状态 \(\text{dp}_{i,j}\) 为在只能放前 \(i\) 个物品的情况下,容量为 \(j\) 的背包所能达到的最大总价值。
考虑转移。假设当前已经处理好了前 \(i-1\) 个物品的所有状态,那么对于第 \(i\) 个物品,当其不放入背包时,背包的剩余容量不变,背包中物品的总价值也不变,故这种情况的最大价值为 \(\text{dp}_{i-1,j}\);当其放入背包时,背包的剩余容量会减小 \(w_i\),背包中物品的总价值会增大 \(v_i\),故这种情况的最大价值为 \(\text{dp}_{i-1,j-w_i}+v_i\)。
由此可以得出状态转移方程:
但是如果直接用二维数组存状态会爆空间,所以考虑用滚动优化。由于对 \(\text{dp}_i\) 有影响的只有 \(\text{dp}_{i-1}\),可以去掉第一维,直接用 \(f_j\) 来表示处理到当前物品时背包容量为 \(j\) 的最大价值,转移方程为:
这个转移是背包问题转移的原神。
代码细节
for(int i = 1; i <= n; i++)
for(int j = m; j >= w[i]; j--)
dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
为什么 \(j\) 要倒序枚举?这是为了确保 \(01\) 背包的规则“每件物品至多选一次”。因为是倒序枚举,所以 \(\text{dp}_j\) 一定会在 \(\text{dp}_{j-w_i}\) 前被更新,即更新 \(\text{dp}_j\) 时所用到的 \(\text{dp}_{j-w_i}\) 一定还是上一轮的旧状态,而不会受到本轮已经更新的状态的干扰,从而做到在唯一一次的转移后,第 \(i\) 件物品至多被选则 \(1\) 次。可以看到,这恰恰符合了二维形式的状态转移方程。
如果正序枚举 \(j\) 会怎么样?这会使 \(\text{dp}_{j-w_i}\) 在 \(\text{dp}_j\) 之前更新。假设我们现在枚举到了 \(j=j_0\),并且更新了 \(\text{dp}_{j_0} \leftarrow \text{dp}_{j_0-w_i}+v_i\),然后又枚举到了 \(j=j_0+w_i\),如果此时 \(\text{dp}_j\) 被更新,就会是 \(\text{dp}_{j_0+w_i} \leftarrow \text{dp}_{j_0} + v_i=\text{dp}_{j_0-w_i}+2v_i\),我们就会选择这件物品两次,不符合要求。原因在于此时更新 \(\text{dp}_j\) 所用到的 \(\text{dp}_{j-w_i}\) 可能会是本轮已经更新的新状态,这就是两种枚举方式带来的根本不同。
倒序枚举保证了每件物品至多选一次,而正序枚举允许我们多次选取同一件物品,也就是新的一种背包类型——完全背包。
完全背包
完全背包与 \(01\) 背包的区别仅在于同一件物品可以选取无限次,而非仅能选取一次。
还是先来考虑二维的转移方程。容易想到一个立方级复杂度的暴力转移,也就是在 \(01\) 背包的基础上多枚举一层选取物品的个数 \(k\)。
考虑一个简单的优化:用 \(\text{dp}_{i,j-w_i}\) 来更新 \(\text{dp}_{i,j}\),方程为
其正确性基于局部最优子结构的性质,更新 \(\text{dp}_{i,j}\) 时所用的 \(\text{dp}_{i,j-w_i}\) 已经由 \(\text{dp}_{i,j-2w_i}\) 更新过,\(\text{dp}_{i,j-w_i}\) 已经是充分考虑了第 \(i\) 件物品所选次数后的最优结果。
将转移方程进行滚动优化后,再次得到了跟 \(01\) 背包同样的转移:
for(int i = 1; i <= n; i++)
for(int j = w[i]; j <= m; j++)
dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
在讲 \(01\) 背包的代码细节时候已经展示了完全背包的写法,只需要把 \(j\) 改成正序枚举即可。在完全背包中,更新 \(\text{dp}_j\) 所用到的 \(\text{dp}_{j-w_i}\) 可能会是本轮已经更新的新状态,这又正好跟二维形式的转移方程对上了。可以看到,虽然两种背包的转移方程进行滚动优化后是相同的,但因为原始的二维转移有差异,导致我们需要通过不同的枚举顺序来限制新状态应该由什么旧状态更新,从而保证转移的正确性。
与 \(01\) 背包的对比
背包类型 | 二维形式下的旧状态 | \(j\) 的枚举顺序 | 时间复杂度 |
---|---|---|---|
\(01\) 背包 | \(\text{dp}_{i-1,j-w_i}\) | 倒序 | \(O(nm)\) |
完全背包 | \(\text{dp}_{i,j-w_i}\) | 正序 | \(O(nm)\) |
多重背包
多重背包与 \(01\) 背包的区别仅在于同一件物品可以选取至多 \(k_i\) 次,而非仅能选取一次。
最简单的想法就是转化成 \(01\) 背包,其等价于有 \(k_i\) 件相同的物品,每种物品只能选一次。套用 \(01\) 背包的解法就可以了。
转移方程:
for (int i = 1; i <= n; i++)
for (int j = m; j >= w[i]; j--)
for (int k = 1; k * w[i] <= j && k <= cnt[i]; k++)
dp[j] = max(dp[j], dp[j - k * w[i]] + k * v[i]);
复杂度是 \(O(m\sum k_i)\),最坏情况下能达到立方级(最原始的完全背包),非常不可接受。所以多重背包常见有二进制优化和单调队列优化,这里介绍二进制优化。
二进制优化
设 \(A_{i,j}\) 表示第 \(i\) 件物品拆分后的第 \(j\) 件物品,那么上面的做法就是将第 \(i\) 件物品拆成了 \(A_{i,1},A_{i,2},\cdots,A_{i,k_i}\) 共 \(k_i\) 件相同的物品。这样拆分后,实际上只要选择的物品个数相同,那么就是等效的,例如选择 \(A_{i,1},A_{i,2}\) 与选择 \(A_{i,2},A_{i,3}\) 是完全一样的,上面的做法就低效在这个地方。有没有一种理想的拆分方式,使得任意选择方案互不等效呢?有的兄弟,有的,它就是二进制拆分法。
令 \(A_{i,j}[0\le j < \lfloor\log_2 (k_i+1) \rfloor]\) 表示 \(2^j\) 个相同的物品 \(i\) “捆绑”而成的物品。特殊地,若 \(k_i+1\) 不是 \(2\) 的整数次幂,需要再补一个由 \((k_i-2^{\lfloor\log_2 (k_i+1) \rfloor-1})\) 个相同的物品 \(i\) “捆绑”而成的物品。显然对于任意不大于 \(k_i\) 的选择次数,都可以通过拆分出来的 \(O(\log k_i)\) 个物品组合而成,此时再用上面的代码,复杂度就可以降为 \(O(m\sum\log k_i)\)。
idx = 0;
for (int i = 1; i <= m; i++) {
int c = 1;
k = cnt[i];
while (k > c) {
k -= c;
list[++idx].w = c * w[i];
list[idx].v = c * v[i];
c <<= 1;
}
list[++idx].w = w[i] * k;
list[idx].v = v[i] * k;
}
混合背包
混合背包就是同时存在上文所介绍的三种背包的问题,表现为有的物品只能选一次,有的物品可以选至多给定的次数,有的物品可以无限选。代码也只需要把上面三种问题的代码结合在一起就可以了。\(01\) 背包和多重背包可以一起处理,它们都属于只能选有限次数的类型。
for(int i = 1; i <= n; i++){
if(完全背包)
for(int j = w[i]; j <= m; j++) dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
else{
for (int j = m; j >= w[i]; j--)
for (int k = 1; k * w[i] <= j && k <= cnt[i]; k++)
dp[j] = max(dp[j], dp[j - k * w[i]] + k * v[i]);
}
}
多维费用背包
选择一个物品会同时消耗不同的体积/费用。
以二维费用 \(01\) 背包为例,只需要给状态再增开一维费用即可。
for(int i = 1; i <= n; i++)
for(int j = m; j >= w[i]; j--)
for(int k = t; k >= s[i]; k--)
dp[j][k] = max(dp[j][k], dp[j - w[i]][k - s[i]] + v[i]);
分组背包
每个物品有一个所属的分组,每个分组中只能选一个物品。
设 \(\text{dp}_{i,j}\) 表示只前 \(i\) 组物品,容量为 \(j\) 的最大价值。
三重循环,注意循环层和枚举的顺序,转移方程直接看代码就可以了。
for (int k = 1; k <= ts; k++) //枚举分组
for (int i = m; i >= 0; i--) //像01背包一样枚举空间
for (int j = 1; j <= cnt[k]; j++) //然后再枚举组内物品
if(j >= w[k][j]) dp[i] = max(dp[i], dp[i - w[k][j]] + v[k][j]);
有依赖的背包
问题形如存在主附件关系的物品,选择附件必须要选择主件。分类讨论,不重不漏地拆分成若干个物品(例如主件,主件+附件 \(1\),主件+附件 \(2\) 等等),这些拆分后的物品作为一个分组,转换成分组背包即可。