背包问题

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

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\) 个物品后,用同样的方式递推,最终可以得到结果如下:

image

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

上面的代码中使用了二维数组,空间复杂度为 \(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 的值,需要用到第 \(i-1\) 行 E 和 C 两个位置的值。同理,计算 A 的值时需要用到 C 和 B 两个位置的值。在二维数组中,这些都可以正确执行。尝试压缩空间,将数组保留为一行。当计算完位置 D 的值以后,会覆盖掉原来位置 C 的值。

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

所以需要将对 \(j\) 的循环逆序进行,先计算右边的列,再计算左边的列。从图中可以看到,如果在计算第 \(i\) 行的结果时先计算第 \(j\) 列位置 A 的值,然后把结果写到位置 B,即使覆盖掉第 \(i-1\) 列的值也没有关系,因为再往前的计算(例如计算 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\) 株草药,如果能装下,就考虑采或不采两种决策。如果时间不够采,直接让 \(dp_{i,j}\) 等于上一行的结果,也就是 \(dp_{i-1,j}\)。而在优化后的程序中,倒着循环到 \(tm_i\),这些都是时间足够采的情况。而小于 \(tm_i\) 的部分,就直接不循环了,如此一来,这些位置的值也都不会改变,都会保留上一行的结果,正好是想要的效果。

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

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

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

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

参考代码
#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 背包模板题。价格与重要度的乘积是该物品的价值,所以在读入重要度 \(w_i\) 时,不妨直接将 \(w_i\) 更新为 \(w_i \times v_i\)。令 \(dp_{i,j}\) 表示考虑前 \(i\) 个物品,钱数为 \(j\) 时,可以获得的最大价值。如果不选取第 \(i\) 个物品或钱不够时(\(j < v_i\)),\(dp_{i,j} = dp_{i-1,j}\);如果选取第 \(i\) 个物品,付出 \(v_i\) 元钱,收获 \(w_i\) 的价值,即 \(dp_{i,j} = dp_{i-1,j-v_i}+w_i\)。状态转移方程是对以上两种情况取最大值。

参考代码
#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\) 个物品,则前 \(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;
}

例题:P1510 精卫填海

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

此题想要尽可能留更多的体力,也就是尽可能少花费体力,相当于希望 \(dp_{i,j} \ge v\) 的情况下 \(j\) 尽可能小,这样剩余的最大体力为 \(c-j\)。如果不存在大于等于 \(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 背包问题。每块积木是否选取会影响城堡的高度,用 \(dp_{i,j}\) 表示通过前 \(i\) 块积木能否达到高度 \(j\)。因为关心的是能否达到,所以这个数组可以是布尔数组,\(dp_{i,j}=false\) 代表不能达到高度 \(j\)\(dp_{i,j}=true\) 表示能达到高度 \(j\)

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

现在题目要求的是哪个最大高度是所有城堡都能达到的。可以统计某个高度在这 \(n\) 个城堡下达到的次数,用 \(cnt_j\) 表示高度 \(j\) 有多少个城可以达到这个高度。这样在处理完每座城堡后,如果某个高度可以达到,对应计数加一即可。\(n\) 座城堡计算完成后,如果有 \(cnt_j = 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\) 个不同颜色的球。所以假如从全集中取了一部分球出来,其中某种颜色的球超过了一半,则分组数量就是这个颜色的球数,如果没有一种颜色的球的数量过半,则分组数量是 \(\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;
}

多重背包问题

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

例题:P1776 宝物筛选

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

参考代码
#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;
}

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

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

image

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

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

参考代码
#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\) 层循环是将当前物品拆分成 \(m_i\) 种只能用 \(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\) 的整数次幂的和的形式,拆分的过程特别像把十进制整数转换成二进制整数的过程,所以该优化方法叫做二进制拆分优化。

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

参考代码
#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\) 是有限的,即便物品可以取无限次,但是实际上对于重量为 \(w_i\) 的物品,能取的次数最多是 \(C/w_i\) 次,物品再多背包也装不下了。这样一来,问题就可以转化为多重背包问题。

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

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

注意,在求 \(dp_{i,j}\) 之前,已经计算完 \(dp_{i,j-w}\) 这个位置的值了:\(dp_{i,j-w} = \max \{ dp_{i-1,j-w}, dp_{i-1,j-2w}+v, dp_{i-1,j-3w}+2v, dp_{i-1,j-4w}+3v, \cdots \}\)

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

因此,在计算 \(dp_{i,j}\) 时就没必要用循环了,可以得到:\(dp_{i,j} = \max \{ dp_{i-1,j}, dp_{i,j-w}+v \}\)

再回顾一下 01 背包问题的状态转移方程:\(dp_{i,j} = \max \{ dp_{i-1,j}, dp_{i-1,j-w}+v \}\)

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

image

要求的是 \(dp_{i,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\) 都起码可以拆分为 \(x\)\(1^4\) 相加,因此可初始化 \(dp_i = i\)。而一开始总和 \(0\) 是可以构成的,且不需要数字,所以 \(dp_0 = 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\) 组物品中包含 \(n_1\) 种不同的物品,第 \(2\) 组物品中包含 \(n_2\) 种不同的物品,……,第 \(k\) 组物品中包含 \(n_k\) 种不同的物品,每种物品都只能要一次。不妨把每组看作一个大的物品,决策方式是:不要,要组内第 \(1\) 种物品,要组内第 \(2\) 组物品,……,要组内第 \(n_1\) 种物品。再去看第 \(2\) 组,以此类推。设 \(dp_{i,j}\) 表示只考虑前 \(i\) 组物品,且背包容量为 \(j\) 时,能拿到物品的最大价值,则有:\(dp_{i,j} = \max \{ dp_{i-1,j}, dp_{i-1,j-w_1}+v_1, dp_{i-1,j-w_2}+v_2, \cdots, dp_{i-1,j-w_{n_k}} + v_{n_k} \}\)

式中,\(w_1, w_2, \cdots, w_{n_k}\) 表示组内每种物品的重量,\(v_1, v_2, \cdots, v_{n_k}\) 表示组内每种物品的价值。

伪代码如下:

当前枚举第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;
}

二维费用背包问题

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

既然费用多了一维,那么状态也可以增加一维。设 \(dp_{i,u,v}\) 表示前 \(i\) 件物品两种费用限制分别为 \(u\)\(v\) 时可获得的最大价值,用 \(c_i\)\(d_I\) 表示每种物品的两种费用,用 \(w_i\) 表示该物品的价值。状态转移方程就是:\(dp_{i,u,v} = \max \{ dp_{i-1,u,v}, dp_{i-1,u-c_i,v-d_i} + w_i \}\)

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

例题:P1507 NASA的食物计划

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

参考代码
#include <cstdio>
using ll = long long;
int x0, y0;
ll gcd(ll x, ll y) {
    return y == 0 ? x : gcd(y, x % y);
}
int calc(ll p) {
    ll q = y0 / p * x0; // 注意x0*y0可能会爆int
    ll g = gcd(p, q);
    if (g == x0) return 1;
    return 0;
}
int main()
{
    scanf("%d%d", &x0, &y0);
    int ans = 0;
    for (int i = 1; i * i <= y0; i++) {
        if (y0 % i == 0) {
            ans += calc(i);
            if (y0 / i != i) ans += calc(y0 / i); // 注意判断重复解
        }
    }
    printf("%d\n", ans);
    return 0;
}
posted @ 2024-05-31 19:08  RonChen  阅读(37)  评论(0编辑  收藏  举报