背包九讲学习笔记

背包问题是一类经典的动态规划问题,它非常灵活,需要仔细琢磨体会,本文先对背包问题的几种常见类型作一个总结,给出代码模板。

根据维基百科,背包问题(Knapsack problem)是一种组合优化的NP完全(NP-Complete,NPC)问题。问题可以描述为:给定一组物品,每种物品都有自己的重量和价格,在限定的总重量内,我们如何选择,才能使得物品的总价格最高。NPC问题是没有多项式时间复杂度的解法的,但是利用动态规划,我们可以以伪多项式时间复杂度求解背包问题。一般来讲,背包问题有以下几种分类:

  1. 01背包问题
  2. 完全背包问题
  3. 多重背包问题

此外,还存在一些其他考法,例如恰好装满、求方案总数、求所有的方案等。本文接下来就分别讨论一下这些问题。

以下是学习背包九讲的学习笔记。

1. 01背包

1.1 题目

最基本的背包问题就是01背包问题(01 knapsack problem):一共有N件物品,第i(i从1开始)件物品的重量为 \(w[i]\),价值为 $v[i] $。在总重量不超过背包承载上限 $W $ 的情况下,能够装入背包的最大价值是多少?

1.2 分析

如果采用暴力穷举的方式,每件物品都存在装入和不装入两种情况,所以总的时间复杂度是\(O(2^N)\),这是不可接受的。而使用动态规划可以将复杂度降至 $O(NW) $。我们的目标是书包内物品的总价值,而变量是物品和书包的限重,所以我们可定义状态 \(dp\) :

dp[i][j]表示将前i件物品装进限重为j的背包可以获得的最大价值, 0<=i<=N, 0<=j<=W

那么我们可以将dp[0][0…W]初始化为0,表示将前0个物品(即没有物品)装入书包的最大价值为0。那么当 i > 0 时dp[i][j]有两种情况:

  1. 不装入第i件物品,即dp[i−1][j]
  2. 装入第i件物品(前提是能装下),即dp[i−1][j−w[i]] + v[i]

即状态转移方程为

dp[i][j] = max(dp[i−1][j], dp[i−1][j−w[i]] + v[i]) // j >= w[i]

由上述状态转移方程可知,dp[i][j]的值只与dp[i-1][0,...,j-1]有关,所以我们可以采用动态规划常用的方法(滚动数组)对空间进行优化(即去掉dp的第一维)。需要注意的是,为了防止上一层循环的dp[0,...,j-1]被覆盖,循环的时候 j 只能逆向枚举(空间优化前没有这个限制),伪代码为:

// 01背包问题伪代码(空间优化版)
dp[0,...,W] = 0
for i = 1,...,N
    for j = W,...,w[i] // 必须逆向枚举!!!
        dp[j] = max(dp[j], dp[j−w[i]]+v[i])

时间复杂度为 \(O(NW)\), 空间复杂度为 \(O(W)\) 。由于W的值是W的位数的幂,所以这个时间复杂度是伪多项式时间。

动态规划的核心思想避免重复计算在01背包问题中体现得淋漓尽致。第 $ i$ 件物品装入或者不装入而获得的最大价值完全可以由前面 \(i-1\) 件物品的最大价值决定,暴力枚举忽略了这个事实。

2. 完全背包

2.1 题目

完全背包(unbounded knapsack problem)与01背包不同就是每种物品可以有无限多个:一共有N种物品,每种物品有无限多个,第 \(i\)\(i\) 从1开始)种物品的重量为\(w[i]\),价值为 $v[i] $。在总重量不超过背包承载上限 $W $的情况下,能够装入背包的最大价值是多少?

2.2 分析一

我们的目标和变量和01背包没有区别,所以我们可定义与01背包问题几乎完全相同的状态 \(dp\):

dp[i][j] 表示将前i种物品装进限重为j的背包可以获得的最大价值, 0 <= i <= N, 0 <= j <= W

初始状态也是一样的,我们将 \(dp[0][0…W]\)初始化为0,表示将前0种物品(即没有物品)装入书包的最大价值为0。那么当 \(i > 0\)dp[i][j]也有两种情况:

  1. 不装入第 $i $种物品,即dp[i−1][j],同01背包;
  2. 装入第 \(i\) 种物品,此时和01背包不太一样,因为每种物品有无限个(但注意书包限重是有限的),所以此时不应该转移到dp[i−1][j−w[i]]而应该转移到dp[i][j−w[i]],即装入第 \(i\) 种商品后还可以再继续装入第种商品。

所以状态转移方程为

dp[i][j] = max(dp[i−1][j], dp[i][j−w[i]] + v[i]) // j >= w[i]

这个状态转移方程与01背包问题唯一不同就是max第二项不是 \(dp[i-1]而是dp[i]\)

和01背包问题类似,也可进行空间优化,优化后不同点在于这里的 j 只能正向枚举而01背包只能逆向枚举,因为这里的max第二项是dp[i]而01背包是dp[i-1],即这里就是需要覆盖而01背包需要避免覆盖。所以伪代码如下:

// 完全背包问题思路一伪代码(空间优化版)
dp[0,...,W] = 0
for i = 1,...,N
    for j = w[i],...,W // 必须正向枚举!!!
        dp[j] = max(dp[j], dp[j−w[i]]+v[i])

由上述伪代码看出,01背包和完全背包问题此解法的空间优化版解法唯一不同就是前者的 j 只能逆向枚举而后者的 j 只能正向枚举,这是由二者的状态转移方程决定的。此解法时间复杂度为 \(O(NW)\) , 空间复杂度为 \(O(W)\)

2.3 分析二

除了分析一的思路外,完全背包还有一种常见的思路,但是复杂度高一些。我们从装入第 i 种物品多少件出发,01背包只有两种情况即取0件和取1件,而这里是取0件、1件、2件…直到超过限重\((k > j/w[i])\),所以状态转移方程为:

// k为装入第i种物品的件数, k <= j / w[i]
dp[i][j] = max ((dp[i-1][j − k*w[i]] + k*v[i]) for every k)

同理也可以进行空间优化,需要注意的是,这里max里面是dp[i-1],和01背包一样,所以 j 必须逆向枚举,优化后伪代码为

// 完全背包问题思路二伪代码(空间优化版)
dp[0,...,W] = 0
for i = 1,...,N
    for j = W,...,w[i] // 必须逆向枚举!!!
        for k = [0, 1,..., j/w[i]]
            dp[j] = max(dp[j], dp[j−k*w[i]]+k*v[i])

相比于分析一,此种方法不是在O(1)时间求得dp[i][j],所以总的时间复杂度就比分析一大些了。

2.4 分析三、转换成01背包

01背包问题是最基本的背包问题,我们可以考虑把完全背包问题转化为01背包问题来解:将一种物品转换成若干件只能装入0件或者1件的01背包中的物品。

最简单的想法是,考虑到第 i 种物品最多装入 W/w[i] 件,于是可以把第 i 种物品转化为 W/w[i] 件费用及价值均不变的物品,然后求解这个01背包问题。

更高效的转化方法是采用二进制的思想:把第 i 种物品拆成重量为 wi2kwi2k、价值为 vi2kvi2k 的若干件物品,其中 k 取遍满足 wi2k≤Wwi2k≤W 的非负整数。这是因为不管最优策略选几件第 i 种物品,总可以表示成若干个刚才这些物品的和(例:13 = 1 + 4 + 8)。这样就将转换后的物品数目降成了对数级别。具体代码见3.4节模板。

3. 多重背包

3.1 题目

多重背包(bounded knapsack problem)与前面不同就是每种物品是有限个:一共有N种物品,第i(i从1开始)种物品的数量为n[i],重量为w[i],价值为v[i]。在总重量不超过背包承载上限W的情况下,能够装入背包的最大价值是多少?

3.2 分析一

此时的分析和完全背包的分析二差不多,也是从装入第 i 种物品多少件出发:装入第i种物品0件、1件、…n[i]件(还要满足不超过限重)。所以状态方程为:

# k为装入第i种物品的件数, k <= min(n[i], j/w[i])
dp[i][j] = max{(dp[i-1][j − k*w[i]] + k*v[i]) for every k}

同理也可以进行空间优化,而且 j 也必须逆向枚举,优化后伪代码为

// 完全背包问题思路二伪代码(空间优化版)
dp[0,...,W] = 0
for i = 1,...,N
    for j = W,...,w[i] // 必须逆向枚举!!!
        for k = [0, 1,..., min(n[i], j/w[i])]
            dp[j] = max(dp[j], dp[j−k*w[i]]+k*v[i])

总的时间复杂度约为 \(O(NWn¯)=O(W∑_in_i)\)级别。

3.3 分析二、转换成01背包

采用2.4节类似的思路可以将多重背包转换成01背包问题,采用二进制思路将第 i 种物品分成了 \(O(logn_i)\) 件物品,将原问题转化为了复杂度为 \(O(W∑_ilogn_i)\)的 01 背包问题,相对于分析一是很大的改进,具体代码见3.4节。

3.4 代码模板

此节根据上面的讲解给出这三种背包问题的解题模板,方便解题使用。尤其注意其中二进制优化是如何实现的。

/*
用法:
    对每个物品调用对应的函数即可, 例如多重背包:
    for(int i = 0; i < N; i++) 
        multiple_pack_step(dp, w[i], v[i], num[i], W);

参数:
    dp   : 空间优化后的一维dp数组, 即dp[i]表示最大承重为i的书包的结果
    w    : 这个物品的重量
    v    : 这个物品的价值
    n    : 这个物品的个数
    max_w: 书包的最大承重
*/
void zero_one_pack_step(vector<int> &dp, int w, int v, int max_w) {
    for (int j = max_w; j >= w; j--) // 反向枚举!!!
        dp[j] = max(dp[j], dp[j - w] + v);
}

void complete_pack_step(vector<int> &dp, int w, int v, int max_w) {
    for (int j = w; j <= max_w; j++) // 正向枚举!!!
        dp[j] = max(dp[j], dp[j - w] + v);

    // 法二: 转换成01背包, 二进制优化
    // int n = max_w / w, k = 1;
    // while(n > 0){
    //     zero_one_pack_step(dp, w*k, v*k, max_w);
    //     n -= k;
    //     k = k*2 > n ? n : k*2;
    // }
}

void multiple_pack_step(vector<int> &dp, int w, int v, int n, int max_w) {
    if (n >= max_w / w) complete_pack_step(dp, w, v, max_w);
    else { // 转换成01背包, 二进制优化
        int k = 1;
        while (n > 0) {
            zero_one_pack_step(dp, w * k, v * k, max_w);
            n -= k;
            k = k * 2 > n ? n : k * 2;
        }
    }
}

3.5混合三种背包问题

3.5.1Description

有一些物品只能取 \(1\) 件,有一些物品可以取无数件,其他物品可以取 \(z_i\) 件。

3.5.2 Solution

首先考虑 \(01\) 背包和完全背包的混合。只要判断一下当前物品的种类,看看是要顺序还是逆序循环。加上多重背包:按照上面的方法,拆开物品,将其变成 \(01\) 背包(听说可以使用单调队列)。

3.5.3 Code

for (int i = 1; i <= n; i++) {
    if (can[i] == INF)
        for (int j = m; j >= w[i]; j--)
            f[j] = max(f[j], f[j - w[i]] + val[i]);
    else
        for (int j = w[i]; j <= m; j++)
            f[j] = max(f[j], f[j - w[i]] + val[i]);
}

4. 二维费用背包问题

4.1 Description

对于每件物品,这件物品必须同时付出这两种代价,对于每种代价都有一个可付出的最大值(背包容量)。问怎样选择物品可以得到最大的价值。

4.2 Solution

设 $f[i][u][v] $为前 \(i\) 个物品,两种代价分别为 \(u 和 v\) 能够得到的最大价值。转移方程:

\[f[i][u][v]=max(f[i−1][u−a[i]][v−b[i]]+val[i],f[i−1][u][v]) \]

可以按照上面优化,去掉 \(i\) 这一维。只要注意 \(01\) 背包是逆序循环,完全背包是顺序循环就行了。代码以 \(01\) 背包为例。

4.3 Code

#include <cstdio>
#include <iostream>
using namespace std;

const int N = 506;
int n, m1, m2, a[N], b[N], val[N], f[N][N];

int main() {
    scanf("%d%d%d", &n, &m1, &m2);
    for (int i = 1; i <= n; i++)
        scanf("%d%d%d", &a[i], &b[i], &val[i]);
    for (int i = 1; i <= n; i++)
        for (int j = m1; j >= a[i]; j--)
            for (int k = m2; k >= a[i]; k--)
                f[j][k] = max(f[j][k], f[j - a[i]][k - b[i]] + val[i]);
    printf("%d", f[m1][m2]);
    return 0;
}

5.分组背包问题

5.1 Description

\(n\) 个物品分为 \(k\) 组,每一组物品最多只能选一个。

5.2 Solution

问题变成了,你可以选择本组的一件,或者一件也不选。设 \(f[i][j]\) 为前 \(k\) 组物品花费 \(j\) 能得到的价值。转移方程: $$f[k][j]=max(f[k−1][j],f[k−1][j−w[i]]+val[i])$$

\[(i 属于第 k 组) \]

可以按照上面的优化方法,把 \(f\) 数组变成一维。保证每组只能选一个物品,容量的循环要在每一组物品的循环之外。具体看代码。

for (int i = 1; i <= k; i++)
    for (int j = m; j > 0; j--)
        for (int h = 1; h <= cnt[i]; h++) {
            int v = belong[i][h];
            f[j]  = max(f[j], f[j - w[v]] + val[v]);
        }

6. 有依赖的背包问题

6.1 Description

如果选物品 \(i\),必须选物品 $ j$。

6.2 Solution

如果把问题转化为一棵树,若 \(j\) 依赖于 \(i\),使 \(i\) 成为 \(j\) 的父亲。连出一虚拟根。只有选择了 \(i\),才能选择其子树。

\(f[i][j]\) 为以 \(i\) 为根的子树,已经选择了 \(j\) 个节点的最大价值。转移方程:

\[f[i][j]=max(f[i][j],f[s][k]+f[i][j−k]) (s 是 i 的儿子,0<k<j) \]

6.3 例题

洛谷 P2014 选课

此题相当于给出 \(n\) 个物品,背包容量为 \(m\),每个物品的重量为 \(1\),价值为 \(s_i\),最大化价值。物品之间存在依赖关系。这样就把问题转化成了一般形式,按上面的思路解即可。

6.4 Code

#include <cstdio>
#include <iostream>
using namespace std;

const int N = 2333;
struct edge {
    int nxt, to;
} e[N];
int n, m, fat, cnt = 0, s[N], f[N][N], head[N];

void add(int x, int y) {
    e[++cnt] = (edge){head[x], y};
    head[x]  = cnt;
}

void dp(int x, int fa) {
    f[x][1] = s[x];
    for (int i = head[x]; i; i = e[i].nxt) {
        int v = e[i].to;
        if (v == fa) continue;
        dp(v, x);
        for (int j = m + 1; j > 0; j--)
            for (int k = 1; k < j; k++)
                f[x][j] = max(f[x][j], f[v][k] + f[x][j - k]);
    }
}

int main() {
    scanf("%d%d", &n, &m);
    for (int i = 1; i <= n; i++) {
        scanf("%d%d", &fat, &s[i]);
        add(fat, i);
        add(i, fat);
    }
    dp(0, -1);
    printf("%d", f[0][m + 1]);
    return 0;
}

7. 泛化背包问题

7.1 Description

每个物品没有固定的重量和价值。给它重量 \(i\),就会得到价值 \(h(i)\)

7.2 Solution

设有泛化物品 \(h\)\(l\),其中 \(i(j)\) 表示给 \(i\) 这个泛化物品设置 \(j\) 费用能得到的价值。若 \(f\) 满足$ f(v)=max(h(k)+l(v−k))$,则称 \(f=h+l\)

实际上求最终结果的过程,就是不断求泛化物品之和的过程。


最后附上DD大牛的 背包九讲PDF下载:

Here

posted @ 2020-08-12 14:34  RioTian  阅读(591)  评论(0编辑  收藏  举报