背包 DP

在学习本章前请确认你已经学习了 动态规划部分简介

在具体讲何为“背包 dp”前,先来看如下的例题:

「USACO07 DEC」Charm Bracelet

题意概要:有 n 个物品和一个容量为 W 的背包,每个物品有重量 w_{i} 和价值 v_{i} 两种属性,要求选若干物品放入背包使背包中物品的总价值最大且背包中物品的总重量不超过背包的容量。

在上述例题中,由于每个物体只有 2 种可能的状态(取与不取),正如二进制中的 0 1 ,这类问题便被称为“0-1 背包问题”。

0-1 背包

例题中已知条件有第 i 个物品的重量 w_{i} ,价值 v_{i} ,以及背包的总容量 W

设 DP 状态 f_{i,j} 为在只能放前 i 个物品的情况下,容量为 j 的背包所能达到的最大总价值。

考虑转移。假设当前已经处理好了前 i-1 个物品的所有状态,那么对于第 i 个物品,当其不放入背包时,背包的剩余容量不变,背包中物品的总价值也不变,故这种情况的最大价值为 f_{i-1,j} ;当其放入背包时,背包的剩余容量会减小 w_{i} ,背包中物品的总价值会增大 v_{i} ,故这种情况的最大价值为 f_{i-1,j-w_{i}}+v_{i}

由此可以得出状态转移方程:

f_{i,j}=\max(f_{i-1,j},f_{i-1,j-w_{i}}+v_{i})

这里如果直接采用二维数组对状态进行记录,会出现 MLE。可以考虑改用滚动数组的形式来优化。

由于对 f_i 有影响的只有 f_{i-1} ,可以去掉第一维,直接用 f_{i} 来表示处理到当前物品时背包容量为 i 的最大价值,得出以下方程:

f_i=\max \left(f_i,f_{i-w_i}+v_i\right)

务必牢记并理解这个转移方程,因为大部分背包问题的转移方程都是在此基础上推导出来的。

还有一点需要注意的是,很容易写出这样的错误核心代码:

1
2
3
4
5
for (int i = 1; i <= W; i++)
  for (int l = 0; l <= W - w[i]; l++)
    f[l + w[i]] = max(f[l] + v[i], f[l + w[i]]);
// 由 f[i][l + w[i]] = max(max(f[i - 1][l + w[i]],f[i - 1][l] + w[i]),f[i][l +
// w[i]]); 简化而来

这段代码哪里错了呢?枚举顺序错了。

仔细观察代码可以发现:对于当前处理的物品 i 和当前状态 f_{i,j} ,在 j\geqslant w_{i} 时, f_{i,j} 是会被 f_{i,j-w_{i}} 所影响的。这就相当于物品 i 可以多次被放入背包,与题意不符。(事实上,这正是完全背包问题的解法)

为了避免这种情况发生,我们可以改变枚举的顺序,从 W 枚举到 w_{i} ,这样就不会出现上述的错误,因为 f_{i,j} 总是在 f_{i,j-w_{i}} 前被更新。

因此实际核心代码为

1
2
for (int i = 1; i <= n; i++)
  for (int l = W; l >= w[i]; l--) f[l] = max(f[l], f[l - w[i]] + v[i]);
例题代码
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#include <iostream>
const int maxn = 13010;
int n, W, w[maxn], v[maxn], f[maxn];
int main() {
  std::cin >> n >> W;
  for (int i = 1; i <= n; i++) std::cin >> w[i] >> v[i];
  for (int i = 1; i <= n; i++)
    for (int l = W; l >= w[i]; l--)
      if (f[l - w[i]] + v[i] > f[l]) f[l] = f[l - w[i]] + v[i];
  std::cout << f[W];
  return 0;
}

完全背包

完全背包模型与 0-1 背包类似,与 0-1 背包的区别仅在于一个物品可以选取无限次,而非仅能选取一次。

我们可以借鉴 0-1 背包的思路,进行状态定义:设 f_{i,j} 为只能选前 i 个物品时,容量为 j 的背包可以达到的最大价值。

需要注意的是,虽然定义与 0-1 背包类似,但是其状态转移方程与 0-1 背包并不相同。

可以考虑一个朴素的做法:对于第 i 件物品,枚举其选了多少个来转移。这样做的时间复杂度是 O(n^3) 的。

尽管这样看起来很蠢,我们还是写一下 dp 方程:

f_{i,j}=\max_{k=0}^{+\infty}(f_{i-1,j-k\times w_i}+v_i\times k)

然而这样显然不够优秀,我们要对它进行优化。

可以发现,对于 f_{i,j} ,只要通过 f_{i,j-w_i} 转移就可以了。dp 方程为:

f_{i,j}=\max(f_{i-1,j},f_{i,j-w_i}+v_i)

理由是当我们这样转移时, f_{i,j-w_i} 已经由 f_{i,j-2\times w_i} 更新过,那么 f_{i,j-w_i} 就是充分考虑了第 i 件物品所选次数后得到的最优结果。换言之,我们通过局部最优子结构的性质重复使用了之前的枚举过程,优化了枚举的复杂度。

与 0-1 背包相同地,我们可以将第一维去掉来优化空间复杂度。如果理解了 0-1 背包的优化方式,就不难明白压缩后的循环是正向的(也就是上文中提到的错误优化)。

「Luogu P1616」疯狂的采药

题意概要:有 n 种物品和一个容量为 W 的背包,每种物品有重量 w_{i} 和价值 v_{i} 两种属性,要求选若干个物品放入背包使背包中物品的总价值最大且背包中物品的总重量不超过背包的容量。

例题代码
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#include <iostream>
const int maxn = 1e5 + 10;
int n, W, w[maxn], v[maxn], f[maxn];
int main() {
  std::cin >> W >> n;
  for (int i = 1; i <= n; i++) std::cin >> w[i] >> v[i];
  for (int i = 1; i <= n; i++)
    for (int l = w[i]; l <= W; l++)
      if (f[l - w[i]] + v[i] > f[l]) f[l] = f[l - w[i]] + v[i];
  std::cout << f[W];
  return 0;
}

多重背包

多重背包也是 0-1 背包的一个变式。与 0-1 背包的区别在于每种物品可以选 k_i 次,而非 1 次。

一个很朴素的想法就是:把“每种物品选 k_i 次”等价转换为“有 k_i 个相同的物品,每个物品选一次”。这样就转换成了一个 0-1 背包模型,套用上文所述的方法就可已解决。时间复杂度: O(nW\sum k_i) ,可以轻松 TLE。

虽然我们失败了,但是这个朴素的想法可以给我们的思路提供一些借鉴——我们可以把多重背包转化成 0-1 背包模型来求解。

显然,复杂度中的 O(nW) 部分无法再优化了,我们只能从 O(\sum k_i) 处入手。

为了表述方便,我们用 A_{i,j} 代表第 i 种物品拆分出的第 j 个物品。

在朴素的做法中, \forall j\le k_i A_{i,j} 均表示相同物品。那么我们效率低的原因主要在于我们进行了大量重复性的工作。举例来说,我们考虑了“同时选 A_{i,1},A_{i,2} ”与“同时选 A_{i,2},A_{i,3} ”这两个完全等效的情况。这样的重复性工作我们进行了许多次。那么优化拆分方式就成为了解决问题的突破口。

我们可以通过“二进制分组”的方式使拆分方式更加优美。

具体地说就是令 A_{i,j}\left(j\in\left[0,\lfloor \log_2(k_i+1)\rfloor-1\right]\right) 分别表示由 2^{j} 个单个物品“捆绑”而成的大物品。特殊地,若 k_i+1 不是 2 的整数次幂,则需要在最后添加一个由 k_i-2^{\lfloor \log_2(k_i+1)\rfloor-1} 个单个物品“捆绑”而成的大物品用于补足。

举几个例子:

  • 6=1+2+3
  • 8=1+2+4+1
  • 18=1+2+4+8+3
  • 31=1+2+4+8+16

显然,通过上述拆分方式,可以表示任意 \le k_i 个物品的等效选择方式。将每种物品按照上述方式拆分后,使用 0-1 背包的方法解决即可。

时间复杂度 O(nW\sum\log k_i)

二进制分组代码
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
index = 0;
for (int i = 1; i <= m; i++) {
  int c = 1, p, h, k;
  cin >> p >> h >> k;
  while (k - c > 0) {
    k -= c;
    list[++index].w = c * p;
    list[index].v = c * h;
    c *= 2;
  }
  list[++index].w = p * k;
  list[index].v = h * k;
}
「Luogu P1776」宝物筛选_NOI 导刊 2010 提高(02)

题意概要:有 n 种物品和一个容量为 W 的背包,每种物品有 m_v{i} 个,同时每个物品有两种属性:重量 w_{i} 和价值 v_{i} 。要求选若干个物品放入背包使背包中物品的总价值最大且背包中物品的总重量不超过背包的容量。有一点需要注意的是本题数据范围较大,情况较多。

混合背包

混合背包就是将前面三种的背包问题混合起来,有的只能取一次,有的能取无限次,有的只能取 k 次。

这种题目看起来很吓人,可是只要领悟了前面几种背包的中心思想,并将其合并在一起就可以了。下面给出伪代码:

1
2
3
4
5
6
7
8
for (循环物品种类) {
  if ( 0 - 1 背包)
    套用 0 - 1 背包代码;
  else if (是完全背包)
    套用完全背包代码;
  else if (是多重背包)
    套用多重背包代码;
}
「Luogu P1833」樱花

题意概要:有 n 种樱花树和长度为 T 的时间,有的樱花树只能看一遍,有的樱花树最多看 A{i} 遍,有的樱花树可以看无数遍。每棵樱花树都有一个美学值 C{i} ,求在 T 的时间内看哪些樱花树能使美学值最高。

二维费用背包

先来一道例题: 「Luogu P1855」榨取 kkksc03

这道题是很明显的 0-1 背包问题,可是不同的是选一个物品会消耗两种价值(经费、时间)。这种问题其实很简单:方程基本不用变,只需再开一维数组,同时转移两个价值就行了!(完全、多重背包同理)

这时候就要注意,再开一维存放物品编号就不合适了,因为容易 MLE。

例题核心代码:

1
2
3
4
5
for (int k = 1; k <= n; k++) {
  for (int i = m; i >= mi; i--)    //对经费进行一层枚举
    for (int j = t; j >= ti; j--)  //对时间进行一层枚举
      dp[i][j] = max(dp[i][j], dp[i - mi][j - ti] + 1);
}

分组背包

再看一道例题: 「Luogu P1757」通天之分组背包

所谓分组背包,就是将物品分组,每组的物品相互冲突,最多只能选一个物品放进去。

这种题怎么想呢?其实是从“在所有物品中选择一件”变成了“从当前组中选择一件”,于是就对每一组进行一次 0-1 背包就可以了。

再说一说如何进行存储。我们可以将 t_{k,i} 表示第 k 组的第 i 件物品的编号是多少,再用 cnt_k 表示第 k 组物品有多少个。

例题核心代码:

1
2
3
4
5
6
for (int k = 1; k <= ts; k++)          //循环每一组
  for (int i = m; i >= 0; i--)         //循环背包容量
    for (int j = 1; j <= cnt[k]; j++)  //循环该组的每一个物品
      if (i >= w[t[k][j]])
        dp[i] = max(dp[i],
                    dp[i - w[t[k][j]]] + c[t[k][j]]);  //像0-1背包一样状态转移

这里要注意: 一定不能搞错循环顺序 ,这样才能保证正确性。

有依赖的背包

一道例题: 「Luogu P1064」金明的预算方案

这种背包问题其实就是如果选第 i 件物品,就必须选第 j 件物品,保证不会循环引用,一部分题目甚至会出现多叉树的引用形式。为了方便,就称不依赖于别的物品的物品称为“主件”,依赖于某主件的物品称为“附件”。

对于包含一个主件和若干个附件的集合有以下可能性:仅选择主件,选择主件后再选择一个附件,选择主件后再选择两个附件……需要将以上可能性的容量和价值转换成一件件物品。因为这几种可能性只能选一种,所以可以将这看成分组背包。

如果是多叉树的集合,则要先算子节点的集合,最后算父节点的集合。

泛化物品的背包

这种背包,没有固定的费用和价值,它的价值是随着分配给它的费用而定。在背包容量为 V 的背包问题中,当分配给它的费用为 v_i 时,能得到的价值就是 h\left(v_i\right) 。这时,将固定的价值换成函数的引用即可。

杂项

小优化

根据贪心原理,当费用相同时,只需保留价值最高的;当价值一定时,只需保留费用最低的;当有两件物品 i,j i 的价值大于 j 的价值并且 i 的费用小于 j 的费用是,只需保留 j

背包问题变种

输出方案

输出方案其实就是记录下来背包中的某一个状态是怎么推出来的。我们可以用 g_{i,v} 表示第 i 件物品占用空间为 v 的时候是否选择了此物品。然后在转移时记录是选用了哪一种策略(选或不选)。输出时的伪代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
int v = V;  //记录当前的存储空间
for (
    从最后一件循环至第一件)  //因为最后一件物品存储的是最终状态,所以从最后一件物品进行循环
{
  if (g[i][v]) {
    选了第 i 项物品;
    v -=  i 项物品的价值;
  } else
    未选第 i 项物品;
}

求方案数

对于给定的一个背包容量、物品费用、其他关系等的问题,求装到一定容量的方案总数。

这种问题就是把求最大值换成求和即可。

例如 0-1 背包问题的转移方程就变成了:

dp_i=\sum(dp_i,dp_{i-c_i})

初始条件: dp_0=1

因为当容量为 0 时也有一个方案:什么都不装!

求最优方案总数

求第 k 优解

参考资料

dd 大牛(崔添翼)的背包九讲,GitHub 仓库链接: tianyicui/pack: 背包问题九讲


评论