背包问题

背包类问题是动态规划的一类问题模型,这类模型应用广泛。背包类问题通常可以转化成以下模型:有若干个物品,每个物品有自己的重量和价值。选择物品放进一个容量有限的背包里,求出在容量不超过最大限度的情况下能拿到的最大总价值。

01 背包问题

背包类问题中最简单的是 01 背包问题:有 n 个物品,编号分别为 1n,其中第 i 个物品的价值是 vi,重量是 wi。有一个容量为 c 的背包,问选取哪些物品,可以使得在总重量不超过背包容量的情况下,拿到的物品的总价值最大。这里的每个物品都可以选择拿或不拿。因为每个物品只能用一次,我们用 0 表示不要这个物品,用 1 表示要这个物品,因此每个物品的决策就是 0 或者 1。这就是 01 背包这个名字的来源。

看到这个问题很多人的第一反应是使用贪心策略。对于每个物品,计算其性价比:用这个物品的价值除以其重量,得到一个比值。性价比越高,说明该物品越划算,应该尽量拿该物品。将所有物品按照性价比从高到低排序,只要当前背包还能装得下,就按照顺序一个一个地放进背包。

贪心策略对于大多数情况是比较有效的。不过很容易找到反例,例如,背包容量是 1003 个物品的重量分别是 51,50,50,价值分别是 52,50,50,可以看到,1 号物品性价比很高,优先拿 1 号物品。可是一旦选择了 1 号物品,背包容量就只剩下 49,无法再拿 2 号或者 3 号物品。可是如果放弃 1 号物品,选择两个看起来不是很划算的 2 号和 3 号物品,总的背包容量刚好够用,这时候的总价值是 100,比刚才的 52 要多。

所以可以看到,贪心策略无效,需要寻找一个动态规划的解决方案。首先划分阶段,那么应该一个一个物品地考虑,先考虑第一个物品时的决策,然后考虑新加入一个物品,决策有没有变化。而对于同一个物品,背包容量不同,最优结果也是不同的,所以背包容量也是状态的一部分。

定义 dpi,j 表示只考虑前 i 个物品(并且正准备考虑第 i 个物品),在背包容量不超过 j 的情况下,能拿到的最大价值。对于当前物品,有两种决策方式,分别是这个物品拿或者不拿。如果拿这个物品,那么它会占用 wi 的重量,留给前 i1 个物品的容量就只剩下 jwi 了,在这个基础上我们能多拿到的价值是 vi。如果不拿当前物品,相当于问题转化成前 i1 个物品可使用的背包容量是 j。这两种决策下我们应该选一个最优的,也就是取最大值,所以状态转移方程是:dpi,j=max{dpi1,j,dpi1,jwi+vi}

例如假设有 3 个物品,背包容量是 4,重量分别是 1,2,3,价值分别是 2,3,1。考虑第 1 个物品,重量是 1,价值是 2。计算 dp1,1 的值,就是只考虑第 1 个物品,并且背包容量是 1 的时候,最大能取到的价值。如果不拿这个物品,该物品不占背包容量,相当于前 0 个物品,允许占用的背包容量是 1,此时的最大价值是 dp0,1,显然结果是 0。如果拿这个物品,它自己占了一个单位空间,留给前 0 个物品的容量就是 0,加上拿该物品获得的价值 2,结果是 dp0,0+2。对于这两种情况,选择价值最大的一种,所以 dp1,1=max{dp0,1,dp0,0+2}=2。同理,dp1,2,dp1,3,dp1,4 也都可以计算出结果为 2。这表示,当只有第 1 个物品,背包容量限制是 14 时,都可以拿到最大价值 2,这与预期是相符的。

接下俩考虑新引入第 2 个物品,它的重量是 2,价值是 3,先考虑 dp2,1 的值,因为目前的背包容量是 1,而第 2 个物品的重量是 2,装不下,所以此处的决策只能是不要第 2 个物品,dp2,1=dp1,1=2。在计算 dp2,2 时,因为容量够拿第 2 个物品,可以从拿或者不拿中选择价值最大的。如果拿,剩下的背包容量就只有 0 了,但是可以使拿到的价值加 3,即 dp2,2=max{dp1,2,dp1,0+3}=3,这个决策表明,当有 2 个物品,并且背包容量是 2 时,拿第 2 个物品更划算,可以得到 3 的价值。按照同样的计算方式可以依次得到 dp2,3dp2,4 的值,结果都为 5

考虑第 3 个物品后,用同样的方式递推,最终可以得到结果如下:

image

最终答案就是 dp3,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;
}

上面的代码中使用了二维数组,空间复杂度为 O(MT),能否优化到一维呢?

由于数组 dp 只有相邻两行之间有关系,可以滚动数组优化。

参考代码
#include <cstdio>
#include <algorithm>
using std::max;
const int M = 105;
const int T = 1005;
int dp[2][T], tm[M], val[M];
int main()
{
int t, m; scanf("%d%d", &t, &m);
for (int i = 1; i <= m; i++) scanf("%d%d", &tm[i], &val[i]);
for (int i = 1; i <= m; i++) {
int cur = i % 2, pre = 1 - cur;
for (int j = 0; j <= t; j++) {
dp[cur][j] = dp[pre][j];
if (j >= tm[i]) dp[cur][j] = max(dp[cur][j], dp[pre][j - tm[i]] + val[i]);
}
}
printf("%d\n", dp[m % 2][t]);
return 0;
}

更进一步,能否优化到一维?可以,但要注意枚举顺序。

image

根据状态转移方程,要计算 D 的值,需要用到第 i1 行 E 和 C 两个位置的值。同理,计算 A 的值时需要用到 C 和 B 两个位置的值。在二维数组中,这些都可以正确执行。尝试压缩空间,将数组保留为一行。当计算完位置 D 的值以后,会覆盖掉原来位置 C 的值。

这时就产生了问题。当要计算位置 A 的值时,本来要用位置 C 的值,但是该值已被计算出来的位置 D 的值覆盖,拿不到想要的值,计算会发生错误。

所以需要将对 j 的循环逆序进行,先计算右边的列,再计算左边的列。从图中可以看到,如果在计算第 i 行的结果时先计算第 j 列位置 A 的值,然后把结果写到位置 B,即使覆盖掉第 i1 列的值也没有关系,因为再往前的计算(例如计算 D 位置的值)不会再用到位置 B 的值。

这样压缩空间后,代码变得更为简洁:

#include <cstdio>
#include <algorithm>
using std::max;
const int M = 105;
const int T = 1005;
int dp[T], tm[M], val[M];
int main()
{
int t, m; scanf("%d%d", &t, &m);
for (int i = 1; i <= m; i++) scanf("%d%d", &tm[i], &val[i]);
for (int i = 1; i <= m; i++) {
for (int j = t; j >= tm[i]; j--) {
dp[j] = max(dp[j], dp[j - tm[i]] + val[i]);
}
}
printf("%d\n", dp[t]);
return 0;
}

可以发现,最开始的代码里有一个 if 判断,而最终空间压缩后的代码中没有。优化前的代码中的 if 判断,是为了判断当前的消耗时间是否足够采摘第 i 株草药,如果能装下,就考虑采或不采两种决策。如果时间不够采,直接让 dpi,j 等于上一行的结果,也就是 dpi1,j。而在优化后的程序中,倒着循环到 tmi,这些都是时间足够采的情况。而小于 tmi 的部分,就直接不循环了,如此一来,这些位置的值也都不会改变,都会保留上一行的结果,正好是想要的效果。

例题:P1049 [NOIP2001 普及组] 装箱问题

首先,容易看出题目中的每个物品就是背包类问题中的“物品”,每个物品只能选一次,所以此题属于 01 背包问题。不过题目中并没有给出物品的价值和重量,只是给了物品的体积。需要找到对应物品价值和重量的定义方式,来吧问题转化成标准的背包问题。

题中问如何能让剩余空间最小,转化一下,其实就是问如何能尽量装最多的物品。求出物品的最大占用空间,用总空间减去最大占用空间,就能得到最小剩余空间了。所以,优化的目标就是如何利用最多的空间,可以看出,空间其实就是价值,想让价值尽可能大。同样,因为总体积不能超过 V,每个物品也有自己的体积,可以看出,物品的重量其实就是它的体积。因此,物品的重量和价值是一样的,都是它的体积。

定义 dpi,j 表示用前 i 个物品,背包容量为 j 时,能取得的最大收益(占用空间)。状态转移方程为:dpi,j=max{dpi1,j,dpi1,jvoli+voli}

参考代码
#include <cstdio>
#include <algorithm>
using std::max;
const int N = 35;
const int V = 20005;
int dp[N][V], vol[N];
int main()
{
int v, n; scanf("%d%d", &v, &n);
for (int i = 1; i <= n; i++) scanf("%d", &vol[i]);
for (int i = 1; i <= n; i++) {
for (int j = 0; j <= v; j++) {
dp[i][j] = dp[i - 1][j];
if (j >= vol[i]) dp[i][j] = max(dp[i][j], dp[i - 1][j - vol[i]] + vol[i]);
}
}
printf("%d\n", v - dp[n][v]);
return 0;
}

同样,也可以压缩到一维数组。

参考代码
#include <cstdio>
#include <algorithm>
using std::max;
const int N = 35;
const int V = 20005;
int dp[V], vol[N];
int main()
{
int v, n; scanf("%d%d", &v, &n);
for (int i = 1; i <= n; i++) scanf("%d", &vol[i]);
for (int i = 1; i <= n; i++) {
for (int j = v; j >= vol[i]; j--) {
dp[j] = max(dp[j], dp[j - vol[i]] + vol[i]);
}
}
printf("%d\n", v - dp[v]);
return 0;
}

例题:P1060 [NOIP2006 普及组] 开心的金明

此题是标准的 01 背包模板题。价格与重要度的乘积是该物品的价值,所以在读入重要度 wi 时,不妨直接将 wi 更新为 wi×vi。令 dpi,j 表示考虑前 i 个物品,钱数为 j 时,可以获得的最大价值。如果不选取第 i 个物品或钱不够时(j<vi),dpi,j=dpi1,j;如果选取第 i 个物品,付出 vi 元钱,收获 wi 的价值,即 dpi,j=dpi1,jvi+wi。状态转移方程是对以上两种情况取最大值。

参考代码
#include <cstdio>
#include <algorithm>
using std::max;
const int M = 30;
const int N = 30005;
int v[M], w[M], dp[M][N];
int main()
{
int n, m; scanf("%d%d", &n, &m);
for (int i = 1; i <= m; i++) {
scanf("%d%d", &v[i], &w[i]);
w[i] *= v[i];
}
for (int i = 1; i <= m; i++) { // 第i个物品
for (int j = 0; j <= n; j++) { // 钱数
dp[i][j] = dp[i - 1][j]; // 不要这个物品或钱不够
if (j >= v[i]) dp[i][j] = max(dp[i][j], dp[i - 1][j - v[i]] + w[i]); // 钱够
}
}
printf("%d\n", dp[m][n]);
return 0;
}

同样,也可以压缩到一维数组。

参考代码
#include <cstdio>
#include <algorithm>
using std::max;
const int M = 30;
const int N = 30005;
int v[M], w[M], dp[N];
int main()
{
int n, m; scanf("%d%d", &n, &m);
for (int i = 1; i <= m; i++) {
scanf("%d%d", &v[i], &w[i]);
w[i] *= v[i];
}
for (int i = 1; i <= m; i++) {
for (int j = n; j >= v[i]; j--) {
dp[j] = max(dp[j], dp[j - v[i]] + w[i]);
}
}
printf("%d\n", dp[n]);
return 0;
}

例题:P1164 小A点菜

由于“每种菜品只有一份”,所以本题是 01 背包问题。与前面不同的是,这里要求的不是最大价值而是方案数。

在 01 背包问题中,我们是针对顶 i 个物品要或者不要的情况,求一个最大价值。而本题要计算方案数,如果不要第 i 个物品,则前 i1 个物品有多少种方案,现在都可以纳入前 i 个物品的方案。如果要第 i 个物品,那么前 i1 个物品在背包容量减少的情况下的所有情况,也都可以转移过来。所以总方案数是第 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;
}

例题:P1510 精卫填海

根据题意,剩下的 n 块木石每块最多可以用一次,也可以选择不用,所以,这是一个 01 背包问题。定义 dpi,j 表示体力为 j 时,只考虑前 i 块木石的情况下所获得的最大体积。状态转移方程为:dpi,j=dpi1,j,dpi1,jwi+vali。式中,wi 表示将第 i 块木石衔到东海耗费的体力,vali 表示这块木石的体积。

此题想要尽可能留更多的体力,也就是尽可能少花费体力,相当于希望 dpi,jv 的情况下 j 尽可能小,这样剩余的最大体力为 cj。如果不存在大于等于 v 的状态,输出 Impossible

此题同样可以压缩到一维数组实现。

参考代码
#include <cstdio>
#include <algorithm>
using std::max;
const int C = 1e4 + 5;
int dp[C];
int main()
{
int v, n, c; scanf("%d%d%d", &v, &n, &c);
int ans = -1;
for (int i = 1; i <= n; i++) {
int val, w; scanf("%d%d", &val, &w);
for (int j = c; j >= w; j--) { // 枚举体力j
dp[j] = max(dp[j], dp[j - w] + val);
if (dp[j] >= v) ans = max(ans, c - j);
}
}
if (ans == -1) printf("Impossible\n");
else printf("%d\n", ans);
return 0;
}

例题:P1504 积木城堡

n 座城堡是相互独立的,可以单独考虑。

对于当前正在考虑的城堡,每块积木只能选去还是一次或不选,所以此题为 01 背包问题。每块积木是否选取会影响城堡的高度,用 dpi,j 表示通过前 i 块积木能否达到高度 j。因为关心的是能否达到,所以这个数组可以是布尔数组,dpi,j=false 代表不能达到高度 jdpi,j=true 表示能达到高度 j

len 表示第 i 块积木的高度,则状态转移方程相当于:if (dp[i][j - len]) dp[i][j] = true;,即如果使用这块积木之前的高度(当前高度减这块积木高度)可以达到,那么当前高度也可以达到。这样使用 01 背包问题的求解思路即可求出当前城堡能达到的所有高度。

现在题目要求的是哪个最大高度是所有城堡都能达到的。可以统计某个高度在这 n 个城堡下达到的次数,用 cntj 表示高度 j 有多少个城可以达到这个高度。这样在处理完每座城堡后,如果某个高度可以达到,对应计数加一即可。n 座城堡计算完成后,如果有 cntj=n,说明每个城堡都能实现这个高度,找这样的最大的 j 即可。

参考代码
#include <cstdio>
#include <algorithm>
using std::max;
const int N = 105;
bool dp[N * N];
int cnt[N * N];
int main()
{
int n; scanf("%d", &n);
int maxsum = 0;
for (int i = 1; i <= n; i++) { // n座城堡
int sum = 0;
dp[0] = true; // 一开始只有高度为0可以达到
while (true) { // 循环读入积木信息
int len; scanf("%d", &len);
if (len == -1) break;
sum += len;
for (int j = sum; j >= len; j--) {
dp[j] |= dp[j - len]; // 如果高度j-len可以到达,那么高度j也能到达
}
}
for (int j = sum; j >= 0; j--)
if (dp[j]) {
cnt[j]++; // 可以达到该高度的城堡数量+1
dp[j] = false; // 清空dp数组以备下一个城堡计算时使用
}
maxsum = max(maxsum, sum);
}
int ans = 0;
for (int i = maxsum; i >= 0; i--) { // 找到n个城堡都能达成的最大高度
if (cnt[i] == n) {
ans = i; break;
}
}
printf("%d\n", ans);
return 0;
}

习题:CF1954D Colored Balls

解题思路

考虑分组数量的消耗,每一组是最多取 2 个不同颜色的球。所以假如从全集中取了一部分球出来,其中某种颜色的球超过了一半,则分组数量就是这个颜色的球数,如果没有一种颜色的球的数量过半,则分组数量是 2

所以按球的数量排序,假设当前枚举的第 i 种球的数量是最多的,则前面的 i1 种球构成的总数可能会超过第 i 种球的数量,也可能不超过,这两种情况需要的分组数已经在上一段中讨论了。我们还需要知道从前 i1 种球选子集后形成的球的各种总数的方案数,这是一个 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;
}

例题:P4377 [USACO18OPEN] Talent Show G

分析:最优比值问题,用 01 分数规划的思想二分答案。

问题变成了选出总重量至少为 W 的一些奶牛,使得 txw 的和大于等于 0

可以用背包求出总重量为 i 的时候选出的和的最大值,特殊地,用 dpW 表示总重量 W 时的情况。

最后看 dpW 是否 0 就可以了,整体时间复杂度为 O(nwlogt)

参考代码
#include <cstdio>
#include <algorithm>
using std::max;
using ll = long long;
const int N = 255;
const int W = 1005;
const ll INF = 1e18;
int n, wlim, w[N], t[N];
ll dp[W];
bool check(int x) {
for (int i = 1; i <= wlim; i++) dp[i] = -INF;
for (int i = 1; i <= n; i++) {
ll value = t[i] - 1ll * w[i] * x;
for (int j = wlim; j >= max(0, wlim - w[i] + 1); j--)
if (dp[j] != -INF) dp[wlim] = max(dp[wlim], dp[j] + value);
for (int j = wlim; j >= w[i]; j--)
if (dp[j - w[i]] != -INF) dp[j] = max(dp[j], dp[j - w[i]] + value);
}
return dp[wlim] >= 0;
}
int main()
{
scanf("%d%d", &n, &wlim);
int l = 0, r = 0, ans = 0;
for (int i = 1; i <= n; i++) {
scanf("%d%d", &w[i], &t[i]); t[i] *= 1000;
r = max(r, t[i] / w[i]);
}
while (l <= r) {
int mid = (l + r) / 2;
if (check(mid)) {
l = mid + 1; ans = mid;
} else {
r = mid - 1;
}
}
printf("%d\n", ans);
return 0;
}

多重背包问题

在 01 背包问题中,每种物品只能选 1 次,稍微扩展一下:现在允许一种物品选多次,规定第 i 种物品重量是 wi,价值是 vi,并且最多可以选 mi 次。这类问题就叫做多重背包问题。注意,虽然一种物品是有多件的,但不一定要用,也不一定要用 mi 次,可以随便选用几次。

例题:P1776 宝物筛选

一个简单的思路就是转化成熟悉的问题,想办法把多重背包问题转化成 01 背包问题。考虑到既然每种物品有 mi 件,而这 mi 件物品都是一样的重量和价格,可以随便取若干件。不妨将这种最多能用 mi 次的物品,拆分成 mi 种只能用一次的物品,如此一来就又回归 01 背包问题了。在 01 背包的代码基础之上加一层循环,对于第 i 种物品,加一层进行 mi 次的循环,最里面还是正常循环背包容量。

参考代码
#include <cstdio>
#include <algorithm>
using std::max;
const int N = 105;
const int W = 4e5 + 5;
int v[N], w[N], m[N], dp[W];
int main()
{
int n, maxw; scanf("%d%d", &n, &maxw);
for (int i = 1; i <= n; i++) scanf("%d%d%d", &v[i], &w[i], &m[i]);
for (int i = 1; i <= n; i++) {
for (int cnt = 1; cnt <= m[i]; cnt++) { // 拆分成m[i]种只能用一次的物品
for (int j = maxw; j >= w[i]; j--) {
dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
}
}
}
printf("%d\n", dp[maxw]);
return 0;
}

分析一下时间复杂度:总的虚拟的物品数量应该是 mi,背包容量是 W,则总的时间复杂度为 O(Wmi),按照本题的数据范围,计算量是 4×109,会超时。

再考虑另外一种思路:多重决策。多重背包问题和 01 背包问题相比,主要的区别就在于:对于 01 背包问题中的前 i 个物品,当背包容量为 j 时,只有两种决策,要当前物品或者不要当前物品。但是在多重背包问题中,不止这两种决策,还可以选择某种物品要 2 次,要 3 次,……,要 mi 次。这种决策方式,叫做多重决策。

image

对于 01 背包问题,当计算 dpi,j(位置 A)的值时,它的值是从位置 B 和位置 C 转移过来的。但是对于多重背包问题,除了位置 B 和位置 C,可以继续考虑当前物品要 2 次,这时候 dpi,j=dpi1,j2wi+2vi,即背包容量减掉当前物品 2 次的重量后,在前 i1 个物品中能取到的最大价值,加上两倍当前物品的价值,即从位置 D 转移过来。同理,当前物品可以要 3 次、4 次,一直到 mi 次,这些位置得到的值,最终取最大的一个就是 dpi,j。状态转移方程为:dpi,j=max{dpi1,j,dpi1,jwi+vi,dpi1,j2wi+2vi,,dpi1,jmiwi+mivi}

当前,前提条件是背包容量 j 能够取当前物品 mi 次。如果不够,背包容量除以物品重量的商,就是最大能取当前物品的次数。

参考代码
#include <cstdio>
#include <algorithm>
using std::max;
const int N = 105;
const int W = 4e5 + 5;
int v[N], w[N], m[N], dp[W];
int main()
{
int n, maxw; scanf("%d%d", &n, &maxw);
for (int i = 1; i <= n; i++) scanf("%d%d%d", &v[i], &w[i], &m[i]);
for (int i = 1; i <= n; i++) {
for (int j = maxw; j >= w[i]; j--) {
for (int cnt = 1; cnt <= m[i] && cnt * w[i] <= j; cnt++) {
dp[j] = max(dp[j], dp[j - cnt * w[i]] + cnt * v[i]);
}
}
}
printf("%d\n", dp[maxw]);
return 0;
}

注意两种思路的不同之处。多重决策算法的第 2 层循环是背包容量,第 3 层循环枚举当前物品要用几次。而第一种思路的第 2 层循环是将当前物品拆分成 mi 种只能用 1 次的物品,第 3 层循环是背包容量。可以看到,这两种思路只是循环顺序交换了,计算量并没有很大的区别。因此,多重决策算法在本题的数据规模下,还是不能在时间限制内通过。

二进制拆分优化。不妨设当前物品的重量为 w,价值为 v,有 10 件,由于 10=1+2+4+3,可以把这种有 10 件的物品,看成:

  • 重量是 w,价值是 v 的物品(称为 1 倍物品)1 件;
  • 重量是 2w,价值是 2v 的物品(称为 2 倍物品)1 件;
  • 重量是 4w,价值是 4v 的物品(称为 4 倍物品)1 件;
  • 重量是 3w,价值是 3v 的物品(称为 3 倍物品)1 件。

上面的 4 种物品都是只能使用 1 次的,这样就转化成了有 4 种物品的 01 背包问题,比优化前转换为 10 种物品的 01 背包问题物品更少,运行速度更快。这种拆分方法,是把物品的使用次数拆分成多个 2 的整数次幂的和的形式,拆分的过程特别像把十进制整数转换成二进制整数的过程,所以该优化方法叫做二进制拆分优化。

为什么这么做是正确的呢?考虑到在最优决策情况下,当前物品所有可能取到的次数是 010 之间的数,而用二进制拆分优化,所有 010 之间的整数,都能用这 4 个数字的相加表示出来。例如,3=1+28=1+4+310=1+2+4+3,……。优化后总的时间复杂度降低为 O(Wlogmi)

参考代码
#include <cstdio>
#include <algorithm>
using std::max;
const int N = 105;
const int W = 4e5 + 5;
int v[N], w[N], m[N], dp[W];
int main()
{
int n, maxw; scanf("%d%d", &n, &maxw);
for (int i = 1; i <= n; i++) scanf("%d%d%d", &v[i], &w[i], &m[i]);
for (int i = 1; i <= n; i++) {
for (int cnt = 1; cnt <= m[i]; cnt *= 2) {
// 当前考虑cnt倍物品,每次循环翻倍,相当于依次枚举1倍物品,2倍物品,4倍物品,……
for (int j = maxw; j >= cnt * w[i]; j--) {
// 此时物品的重量是cnt*w[i],价值cnt*v[i]
dp[j] = max(dp[j], dp[j - cnt * w[i]] + cnt * v[i]);
}
m[i] -= cnt; // 从总数m[i]里减掉cnt
}
for (int j = maxw; j >= m[i] * w[i]; j--) { // 最后剩下一个m[i]倍物品
dp[j] = max(dp[j], dp[j - m[i] * w[i]] + m[i] * v[i]);
}
}
printf("%d\n", dp[maxw]);
return 0;
}

完全背包问题

01 背包问题是每种物品最多取一次,多重背包问题是每种物品有多件,但是限制了最多使用次数。如果进一步扩展,每种物品的数量都是无限多,这类问题就叫做完全背包问题。

如果还是考虑转化成 01 背包问题,由于背包容量 C 是有限的,即便物品可以取无限次,但是实际上对于重量为 wi 的物品,能取的次数最多是 C/wi 次,物品再多背包也装不下了。这样一来,问题就可以转化为多重背包问题。

如果按照多重决策的做法,那么对于每个物品来说,决策就不止取或者不取,而是可以选择:不取,取 1 次,取 2 次,取 3 次,……,直到背包装不下,类似的状态转移方程:dpi,j=max{dpi1,j,dpi1,jw+v,dpi1,j2w+2v,dpi1,j3w+3v,}

也就是在计算 dpi,j 时,需要循环 j/w 次,去枚举每种可能性,最后找最大价值。这样做时间复杂度较高,能否优化?

注意,在求 dpi,j 之前,已经计算完 dpi,jw 这个位置的值了:dpi,jw=max{dpi1,jw,dpi1,j2w+v,dpi1,j3w+2v,dpi1,j4w+3v,}

可以看出,当计算 dpi,j 时,从第 2 项开始,每一项都是跟 dpi,jw 对应的,只是相差了一个 v 而已,所以没有必要去计算这些项的最大值,它们的最大值就等于 dpi,jw+v

因此,在计算 dpi,j 时就没必要用循环了,可以得到:dpi,j=max{dpi1,j,dpi,jw+v}

再回顾一下 01 背包问题的状态转移方程:dpi,j=max{dpi1,j,dpi1,jw+v}

可以看到,几乎一样,只不过 01 背包问题是从上一行的位置 j 和位置 jw 转移的,而完全背包问题是从上一行的位置 j 和当前行的位置 jw 转移的。

image

要求的是 dpi,j 的值(位置 A),如果是 01 背包问题,它的值从位置 B 和位置 C 转移;如果是完全背包问题,它的值从位置 B 和位置 D 转移。

注意,位置 C 是上一行的第 j 列,而位置 D 是当前行的第 j 列。这个结论也可以理解成:因为完全背包问题中物品相当于有无限件,可以从上一次拿过这种物品的位置转移,看看是否可以再拿一次。

这样,完全背包问题的代码和 01 背包问题的代码非常接近。考虑能否压缩到一维空间?01 背包问题如果要用一维数组,需要把背包容量的循环按从大到小的顺序进行。因为每次计算均依赖左边的值,必须保证左边的值还没被覆盖成当前物品的值。而完全背包问题的状态转移方程就是要使用当前行左边的值,被覆盖成之后的值正好接下来就要使用,所以完全背包问题压缩空间后的代码,只需要把 01 背包问题代码中背包容量的循环改成正序。

例题:P1616 疯狂的采药

参考代码
#include <cstdio>
#include <algorithm>
using ll = long long;
using std::max;
const int M = 1e4 + 5;
const int T = 1e7 + 5;
int a[M], b[M];
ll dp[T];
int main()
{
int t, m; scanf("%d%d", &t, &m);
for (int i = 1; i <= m; i++) scanf("%d%d", &a[i], &b[i]);
for (int i = 1; i <= m; i++) {
for (int j = a[i]; j <= t; j++) {
dp[j] = max(dp[j], dp[j - a[i]] + b[i]);
}
}
printf("%lld\n", dp[t]);
return 0;
}

例题:P1679 神奇的四次方数

这个问题可以转化成背包问题。因为要把整数 m 分解成四次方数相加的形式,可以把每个四次方数看作物品,将四次方数的大小看作物品的重量,物品的价值则都是 1,因为选一次就代表多了一个数字。最终要凑成的整数 m 可看作背包的容量。需要在背包容量正好用光的情况下,找到最小总价值。

对于每个四次方数,可以不选,也可以使用无限次,显然这是一个完全背包问题。由于希望使用的数字数量尽可能少,所以 dp 数组要初始化为最大值,考虑到任何一个数 x 都起码可以拆分为 x14 相加,因此可初始化 dpi=i。而一开始总和 0 是可以构成的,且不需要数字,所以 dp0=0

参考代码
#include <cstdio>
#include <algorithm>
using std::min;
const int M = 1e5 + 5;
int dp[M];
int main()
{
int m; scanf("%d", &m);
for (int i = 1; i <= m; i++) {
dp[i] = i;
}
for (int i = 1; i * i * i * i <= m; i++) {
int num = i * i * i * i;
for (int j = num; j <= m; j++) {
dp[j] = min(dp[j], dp[j - num] + 1);
}
}
printf("%d\n", dp[m]);
return 0;
}

分组背包问题

前面介绍的 01 背包问题、多重背包问题和完全背包问题,物品之间都没有关系。一种物品要不要,要几个,都不会影响其他物品的选取。如果物品之间相互影响,比如所有的物品分为 k 组,每组内最多只能选一种物品,这样的问题就叫做分组背包问题。

分组背包问题比较好解决,假设所有的物品分为 k 组,第 1 组物品中包含 n1 种不同的物品,第 2 组物品中包含 n2 种不同的物品,……,第 k 组物品中包含 nk 种不同的物品,每种物品都只能要一次。不妨把每组看作一个大的物品,决策方式是:不要,要组内第 1 种物品,要组内第 2 组物品,……,要组内第 n1 种物品。再去看第 2 组,以此类推。设 dpi,j 表示只考虑前 i 组物品,且背包容量为 j 时,能拿到物品的最大价值,则有:dpi,j=max{dpi1,j,dpi1,jw1+v1,dpi1,jw2+v2,,dpi1,jwnk+vnk}

式中,w1,w2,,wnk 表示组内每种物品的重量,v1,v2,,vnk 表示组内每种物品的价值。

伪代码如下:

当前枚举第k组:
倒序枚举背包容量,目前容量为j:
对于每个属于第k组的物品i:
dp[j] = max(dp[j], dp[j - w[i]] + v[i])

例题:P1064 [NOIP2006 提高组] 金明的预算方案

首先明确物品的价值是什么,按照本题的定义,每个物品的价值是它的价格和重要度的乘积。所以在输入物品信息时,可以提前计算好这个价值,存在数组里面。而每个物品的价格,其实就相当于 01 背包问题里每个物品的重量,总的花费相当于背包容量。

另外,本题是有依赖的情况,如果要购买附件,就必须购买主件。这个依赖关系可以转化成分组背包问题。对于每个主件,最多有 2 个附件,因此,所有的购买情况包括:全都不要,只要主件,要主件和 1 号附件,要主件和 2 号附件,要主件和 1 号、2 号附件,共计 5 种情况。那么对于一个主件和它的附件,可以创建 4 个虚拟物品:

  • 1 个虚拟物品表示只要主件的情况,它的价值对应主件的价值,它的重量对应主件的价格。
  • 2 个虚拟物品表示要主件和 1 号附件的情况,它的价值对应主件和 1 号附件的价值之和,它的重量对应主件和 1 号附件的价格之和。
  • 3 个虚拟物品表示要主件和 2 号附件的情况,它的价值对应主件和 2 号附件的价值之和,它的重量对应主件和 2 号附件的价格之和。
  • 4 个虚拟物品表示要主件和 2 个附件的情况,它的价值对应主件和 2 个附件的价值之和,它的重量对应主件和 2 个附件的价格之和。

上述 4 个虚拟物品,最多只能选一个,或者一个都不选,可以把这 4 个物品看成是一个分组里的。这样一来,每个主件及其附件的依赖关系,就转化成了分组背包问题,可以套用之前的模型来计算。

参考代码
#include <cstdio>
#include <vector>
#include <algorithm>
using std::max;
using std::vector;
const int M = 65;
const int N = 32005;
int v[M], p[M], q[M], dp[N];
vector<int> accessories[M];
void update(int cap, int weight, int value) {
if (cap >= weight) dp[cap] = max(dp[cap], dp[cap - weight] + value);
}
int main()
{
int n, m; scanf("%d%d", &n, &m);
for (int i = 1; i <= m; i++) {
scanf("%d%d%d", &v[i], &p[i], &q[i]); // v[i]为价格
p[i] *= v[i]; // 价格和重要度的乘积
if (q[i] != 0) accessories[q[i]].push_back(i); // 如果是附件,在其主件处记录该附件的编号
}
for (int i = 1; i <= m; i++) {
if (q[i] == 0) { // 每个主件代表一组物品
for (int j = n; j >= 0; j--) { // 枚举背包容量
update(j, v[i], p[i]); // 只使用主件
if (accessories[i].size() > 0) { // 如果有附件
int acs = accessories[i][0];
update(j, v[i] + v[acs], p[i] + p[acs]); // 主件+附件1
}
if (accessories[i].size() > 1) { // 如果不止1个附件
int acs1 = accessories[i][0], acs2 = accessories[i][1];
// 主件+附件2
update(j, v[i] + v[acs2], p[i] + p[acs2]);
// 主件+2个附件
update(j, v[i] + v[acs1] + v[acs2], p[i] + p[acs1] + p[acs2]);
}
}
}
}
printf("%d\n", dp[n]);
return 0;
}

二维费用背包问题

之前背包问题的费用都是一维的,即每个物品有自己的重量。假设每个物品有二维费用,比如每个物品有自己的重量和体积,背包的限制也是二维的,总重量不能超过限制,总体积也不能超过限制,问如何选取物品能使得总价值最大,这就是二维费用背包问题。

既然费用多了一维,那么状态也可以增加一维。设 dpi,u,v 表示前 i 件物品两种费用限制分别为 uv 时可获得的最大价值,用 cidI 表示每种物品的两种费用,用 wi 表示该物品的价值。状态转移方程就是:dpi,u,v=max{dpi1,u,v,dpi1,uci,vdi+wi}

类似地,可以优化空间复杂度,降一维空间到二维数组。当每件物品只能使用一次时,uv 采用逆序的循环;当物品类似完全背包问题时,uv 采用顺序的循环;当物品类似多重背包问题时,拆分物品。

例题:P1507 NASA的食物计划

本题可以转化为背包问题:每种食物看作一种物品,食物的重量和体积可看作二维费用,食物的卡路里可看作价值,允许携带的总重量和总体积的限制是背包的两种容量限制。

参考代码
#include <cstdio>
#include <algorithm>
using std::max;
const int H = 405;
int dp[H][H];
int main()
{
int maxh, maxt, n; scanf("%d%d%d", &maxh, &maxt, &n);
for (int i = 1; i <= n; i++) {
int h, t, cal; scanf("%d%d%d", &h, &t, &cal);
for (int j = maxh; j >= h; j--) {
for (int k = maxt; k >= t; k--) {
dp[j][k] = max(dp[j][k], dp[j - h][k - t] + cal);
}
}
}
printf("%d\n", dp[maxh][maxt]);
return 0;
}
posted @   RonChen  阅读(145)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列1:轻松3步本地部署deepseek,普通电脑可用
· 按钮权限的设计及实现
· 【杂谈】分布式事务——高大上的无用知识?
点击右上角即可分享
微信分享提示