概述

背包问题是一类经典的动态规划问题。它通常可以抽象出一个容积为\(V\)的背包,\(n\)个物品且每个物品均有其对应的价值\(w\)\(weight\))和体积\(c\)\(cost\)),要求选出若干个物品,使其体积总和不超过\(V\)且价值总和最大。

上述问题即为\(01\)背包问题。\(01\)背包可以拓展出很多变种:

  1. 多重背包,每个物品的数量可以为复数;

  2. 完全背包,每个物品的数量不限;

  3. 分组背包,物品可以分成若干组,每组只能选一个物品;

  4. 依赖背包,选择某个物品还要选择其依赖的物品。

背包问题作为一类经典的动态规划模型,其可拓展性和难度均较高,如\(CSP2019-PJT3\)就是一个背包问题。

\(01\)背包

\(01\)背包是最基础的背包问题,对于每个物品仅有选和不选两种状态。复杂的背包问题均可以直接或间接转化成\(01\)背包。

\(dp_{i, j}\)为前\(i\)个物品,体积总和不超过\(j\)时的最大价值总和。对于第i个物品,我们有选与不选两种决策:

  1. 选,则前\(i – 1\)个物品的体积总和不能超过\(j – c[i]\),故\(dp_{i, j} = min(dp_{i – 1, j}, dp_{i – 1, j – c_{i}} + w_{i})\)

  2. 不选,不进行状态转移。

此算法的时间复杂度为\(O(NV)\),空间复杂度为\(O(NV),\)其中\(N\)为物品数,\(V\)为背包的最大容积。

朴素的\(01\)背包解法空间开销太大,考虑进一步优化状态。试去掉第一维,将状态改为\(dp_{j}\)表示体积不超过\(j\)时的最大价值总和。

考虑状态转移时\(dp_{j – c_{i}}\)的更新来源。因为当枚举到第\(i\)个物品时,最大体积为\(j\)时,第\(i\)个物品没有对\(dp_{j – c_{i}}\)进行转移,故而\(dp_{j – c_{i}}\)总是在仅有前\(i – 1\)个物品时的状态,即\(dp_{j – c_{i}}\)等于旧状态的\(dp_{i – 1, j – c_{i}}\),所以旧状态的第一维可以优化。

如果用一维状态求解,则枚举顺序需要发生变化。我们用两层循环枚举,第一层\(1\) -> \(n\),枚举物品;第二层\(V\)\(c_{i}\)倒序枚举体积。因为正序枚举可能会导致\(dp_{j}\)不等于\(dp_{i – 1,j}\)(具体证明略),所以需要倒序枚举体积。

如果 –脑抽– 无法理解状态优化,还可以使用滚动数组的技巧优化状态。因为状态转移只需要用到\(dp_{i – 1}\)时的状态,所以可以使用优化。此处不赘述原理,只给出具体写法:

for (int i = 1; i <= n; i++)
{
	for (int j = c[i]; j <= v; j++)
		dp[flag][j] = max(dp[flag][j], dp[!flag][j - c[i]] + w[i]);
	flag = !flag;
}

注意,因为最后数组多反转了一次,所以最终答案存在dp[!flag][v]中。

无论是状态删减或者滚动数组,\(01\)背包问题的最终解法时间复杂度均为\(O(NV)\),空间复杂度优化到了\(O(V)\)

多重背包

朴素算法

每个物品有其对应的属性:体积\(c\)、价值\(w\)和数量\(k\)。要求选出若干物品,使容积为\(V\)的背包可以装下且价值和最大。

对于这一类问题,我们可以加一重循环枚举物品数量\(k\),从而将问题转化成一个\(01\)背包问题。同样地,多重背包也可以进行空间的优化。注意到对于每个物品,如果倒序枚举体积,则\(dp_{j -c[i]\times k}\)永远是\(i – 1\)个物品时的状态,同\(01\)背包一样。

时间复杂度\(O(NV\sum_{i = 1}^{n}k_{i})\)

二进制优化

注意到对于一个\(n\)位的二进制数,它可以表示出\(0\) ~ \(2^{n} – 1\)范围内的任意一个数。联想到多重背包的物品个数\(k\),对于原来的第\(i\)个物品,我们抽象出若干个物品,对于抽象出的第\(j\)个物品,它的价值为\(2^{j} \times w_{i}\),它的体积为\(2^{j} \times c_{i}\)

如果第\(i\)个物品的个数\(k_{i}\)不是\(2\)的整次幂,则多抽象出一个价值为\(w_{i} \times (k_{i} – 2^{n} + 1)\),体积为\(c_{i} \times (k_{i} – 2^{n} + 1)\)的物品,其中\(n\)为最大的\(2^{n} < k_{i}\)的自然数。

这样,通过组合抽象出的若干个物品,我们总能组合出选\(0\) ~ \(k_{i}\)个物品\(i\)时的情况。整个问题就转化成了一个\(01\)背包问题。二进制优化后,多重背包的时间复杂度为\(O( NV\sum_{i = 1}^{n} log _{2}k_{i})\),是一个极大的优化!

模板

二进制优化

//建出抽象物品
for (int i = 1; i <= n; i++)
{
    int k;
    for (k = 1; n[i] - (1 << k) + 1; k++)
    {
        nc[ncnt] = c[i] * (1 << k);
        nw[ncnt] = w[i] * (1 << k);
        ncnt++;
    }
    k--;
    nc[ncnt] = c[i] * (n[i] - (1 << k) + 1);
    nw[ncnt] = w[i] * (n[i] - (1 << k) + 1);
    ncnt++;
}

完全背包

给定若干物品,每个物品有其对应的价值\(w\)和体积\(c\)。每个物品的数量无限,换言之,同一个物品可以选无数次。给定一个容积为\(V\)的背包,请选出若干物品,使得它们的体积之和不超过\(V\)且价值总和最大。

完全背包似乎是一个无解的问题。从多重背包的思考角度出发,我们枚举物品的个数\(k\)可以从\(0\)枚举到\(\infty\),因此,我们不可能求解完全背包问题。

实际上,完全背包问题是有解的。因为背包的容积有限,所以每个物品不能选超过\(\frac{V}{c_{i}}\)次,故而完全背包问题可以转化成多重背包问题求解。这种解法的时间复杂度是\(O(NV\sum_{i = 1}^{n}\frac{V}{c_{i}})\)

注意到\(dp_{j}\)实际上等于二维状态下(\(dp_{i, j}\)表示前\(i\)个物品体积不超过\(j\)时的最大价值和)的\(max(dp_{i – 1, j – c_{i}}, dp_{i – 1, j – c_{i} \times 2}, dp_{i – 1, j – c_{i} \times 3}, …)\),而\(dp_{j – c_{i}}\)等于\(max(dp_{i – 1, j – c_{i} \times 2}, dp_{i – 1, j – c_{i} \times 3}, dp_{i – 1, j – c_{i} \times 4}, …)\)
联想到\(dp_{j}\)实际上可以通过\(dp_{j – c_{i}}\)来转移,即\(dp_{j} = max(dp_{j}, dp_{j – c{i}} + w_{i})\)。具体证明此处不再赘述(因为显然)。

由此完全背包就转化成了与\(01\)背包相似的形式。因为进行状态转移时需要用到已经被更新过的\(dp_{j – c_{i}}\),所以需要正序枚举体积,与\(01\)背包不同

例题

参考代码如下:

#include <cstdio>
#include <algorithm>
using namespace std;
#define maxn 10005

int n, v;
int w[maxn], c[maxn], dp[maxn];

int main()
{
    scanf("%d%d", &v, &n);
    for (int i = 1; i <= n; i++)
        scanf("%d%d", &w[i], &c[i]);
    for (int i = 1; i <= n; i++)
        for (int j = c[i]; j <= v; j++)
            dp[j] = max(dp[j], dp[j - c[i]] + w[i]);
    printf("%d\n", dp[v]);
    return 0;
}

分组背包

给定\(n\)个物品,每个物品有其对应的体积\(c\)、价值\(w\)和所属组别。对于同一组的物品,只能在其中挑选一个。现在给出一个体积为\(V\)的背包,要求选出若干物品,使得其体积总和不超过\(V\)且价值总和最大。

实际上,分组背包仍然是一个\(01\)背包问题。设\(dp_{j}\)表示体积不超过\(j\)时的最大价值和,因为当枚举到第\(i\)组第\(k\)个物品时,\(dp_{j – c_{i, k}}\)总是前\(i – 1\)组物品时的状态,所以只需要多加一重循环枚举组内的物品即可。

例题

参考代码如下:

#include <cstdio>
#include <vector>
using namespace std;
#define maxn 1005

int n, v;
int cnt[maxn], dp[maxn];
vector<int> w[maxn], c[maxn];

int main()
{
    int x, y, z;
    scanf("%d%d", &v, &n);
    for (int i = 1; i <= n; i++)
    {
        scanf("%d%d%d", &x, &y, &z);
        c[z].push_back(x);
        w[z].push_back(y);
        cnt[z]++;
    }
    for (int i = 1; i <= n; i++)
        for (int j = v; j >= 0; j--)
            for (int k = 0; k < cnt[i]; k++)
                if (j >= c[i][k])
                    dp[j] = max(dp[j], dp[j - c[i][k]] + w[i][k]);
    printf("%d\n", dp[v]);
    return 0;
}

依赖背包

依赖背包是一类特殊的背包问题。问题中给出的物品大致可以分成两种:

  1. 主件,包含有若干个附件。

  2. 附件,从属于某个主件。将附件加入背包必须将主件也加入背包。

而依赖背包问题又大致可以分成两种类型:

  1. 附件不再有从属于其的附件。

  2. 附件可以有“附件的附件”。

对于第一类问题,可以暴力地枚举可以转移的状态。即,枚举附件的选举情况,再加上从属的主件进行转移,于是问题就变成了一个组合问题

对于第二类问题,正解是树形\(dp\),此处不再深入(因为我也不会)。

例题

此处附上笔者的\(AC\)代码,仅供参考:

#include <cstdio>
#include <algorithm>
using namespace std;
#define maxn 65
#define maxm 32005

int n, v;
int C[maxn], W[maxn], q[maxn];
int c[maxn][3], w[maxn][3], dp[maxm];

int main()
{
    scanf("%d%d", &v, &n);
    for (int i = 1; i <= n; i++)
    {
        scanf("%d%d%d", &C[i], &W[i], &q[i]);
        W[i] *= C[i];
        if (!q[i])
        {
            c[i][0] = C[i];
            w[i][0] = W[i];
        }
        else
        {
            int j = (c[q[i]][1] | w[q[i]][1]) ? 2 : 1;
            c[q[i]][j] = C[i];
            w[q[i]][j] = W[i];
        }
    }
    for (int i = 1; i <= n; i++)
    {
        for (int j = v; j >= 0; j--)
        {
            if (!q[i])
            {
                if (j >= c[i][0])
                    dp[j] = max(dp[j], dp[j - c[i][0]] + w[i][0]);
                if (j >= c[i][0] + c[i][1])
                    dp[j] = max(dp[j], dp[j - c[i][0] - c[i][1]] + w[i][0] + w[i][1]); 
                if (j >= c[i][0] + c[i][2])
                    dp[j] = max(dp[j], dp[j - c[i][0] - c[i][2]] + w[i][0] + w[i][2]);
                if (j >= c[i][0] + c[i][1] + c[i][2])
                    dp[j] = max(dp[j], dp[j - c[i][0] - c[i][1] - c[i][2]] + w[i][0] + w[i][1] + w[i][2]);
            }
        }
    }
    printf("%d\n", dp[v]);
    return 0;
}

变种问题

输出方案

给定一个\(01\)背包的标准输入,输出选取物品的方案。

\(dp_{j}\)表示体积不超过\(j\)时的最大价值和,\(g_{i, j}\)表示前\(i\)件物品、体积不超过\(j\)时的方案是否选取了第\(i\)个物品。\(g\)数组在状态转移时更新即可。

从最后一个物品开始查找。如果\(g_{i, v} = true\),说明第\(i\)个物品在方案中,\(v = v – c_{i}\),否则,说明第\(i\)个物品不在方案中。

参考代码:

#include <cstdio>
#include <stack>
using namespace std;
#define maxn 1005

int n, v;
int w[maxn], c[maxn], dp[maxn];
bool g[maxn][maxn];
stack<int> s;

int main()
{
    scanf("%d%d", &v, &n);
    for (int i = 1; i <= n; i++)
        scanf("%d%d", &c[i], &w[i]);
    //dp
    for (int i = 1; i <= n; i++)
    {
        for (int j = v; j >= c[i]; j--)
        {
            if (dp[j - c[i]] + w[i] > dp[j])
            {
                dp[j] = dp[j - c[i]] + w[i];
                g[i][j] = true;
            }
        }
    }
    //输出方案
    int tempv = v;
    for (int i = n; i >= 1; i--)
    {
        if (g[i][tempv])
        {
            s.push(i);
            tempv -= c[i];
        }
    }
    while (!s.empty())
    {
        printf("%d\n", s.top());
        s.pop();
    }
    return 0;
}

方案总数

\(f_{i, j}\)表示前\(i\)个物品、体积刚好为\(j\)时的方案总数。\(f_{i, j} = f_{i – 1, j} + f_{i – 1, j – c_{i}}\)

初始化\(f_{0} = 1\),因为什么都不选也是一种方案。

最优方案总数

\(f_{i, j}\)为前\(i\)个物品、体积刚好为\(j\)时的最大价值和,\(g_{i, j}\)为前\(i\)个物品,体积刚好为\(j\)时的最优方案总数。

  1. \(f_{i, j} = f_{i – 1, j}\)\(f_{i, j} \neq f_{i – 1, j – c_{i}} + w_{i}\),说明此时不选第\(i\)个物品会更优,\(g_{i, j} = g_{i – 1, j}\)

  2. \(f_{i, j} \neq f_{i – 1, j}\)\(f_{i, j} = f_{i – 1, j – c_{i}} + w_{i}\),说明此时将第\(i\)个物品放入背包会更优,\(g_{i, j} = g_{i – 1, j – c_{i}}\)

  3. \(f_{i, j} = f_{i – 1, j}\)\(f_{i, j} = f_{i – 1, j – c_{i}} + w_{i}\),说明此时选不选第\(i\)个物品都可以,\(g_{i, j} = g_{i – 1, j} + g_{i – 1, j – c_{i}}\)

例题链接

将每个博主的人数看做物品的体积,也就是 \(1\),粉丝人数看做价值,选 \(k\) 个人看做背包的体积,直接求解最优方案总数即可。

#include <cstdio>
#include <cstring>
#include <algorithm>
using namespace std;

const int maxn = 1e3 + 5;
const int maxv = 1e3 + 5;
const int mod = 1e9 + 7;

int t, n, v;
int c[maxn], w[maxn], dp[maxn][maxn], g[maxn][maxn];

int main()
{
    scanf("%d", &t);
    while (t--)
    {
        memset(dp, 0, sizeof(dp));
        scanf("%d%d", &n, &v);
        for (int i = 1; i <= n; i++)
        {
            scanf("%d", &w[i]);
            c[i] = 1;
        }
        for (int i = 1; i <= n; i++)
            for (int j = 0; j <= v; j++)
                if (j >= c[i])
                    dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - c[i]] + w[i]);
                else
                    dp[i][j] = dp[i - 1][j];
        for (int i = 0; i <= v; i++)
            g[1][i] = 1;
        for (int i = 2; i <= n; i++)
        {
            for (int j = 0; j <= v; j++)
            {
                if (j >= c[i])
                {
                    if ((dp[i][j] == dp[i - 1][j]) && (dp[i][j] != (dp[i - 1][j - c[i]] + w[i])))
                        g[i][j] = g[i - 1][j] % mod;
                    if ((dp[i][j] == (dp[i - 1][j - c[i]] + w[i])) && (dp[i][j] != dp[i - 1][j]))
                        g[i][j] = g[i - 1][j - c[i]] % mod;
                    if ((dp[i][j] == (dp[i - 1][j - c[i]] + w[i])) && (dp[i][j] == dp[i - 1][j]))
                        g[i][j] = (g[i - 1][j] + g[i - 1][j - c[i]]) % mod;
                }
                else
                    g[i][j] = g[i - 1][j];
            }
        }
        printf("%d\n", g[n][v]);
    }
    return 0;
}

版权声明:本文为lingspace原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://www.cnblogs.com/lingspace/p/backpack-problem.html