LogoCSP Wiki By Yundou
2 DP

背包DP进阶

引入

背包DP除了最基础的01背包以外,还有多种更加复杂的模型,本篇将对这些模型进行进阶讲解。

完全背包

解释

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

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

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

过程

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

状态转移方程如下:

fi,j=maxk=0+(fi1,jk×wi+vi×k)f_{i,j}=\max_{k=0}^{+\infty}(f_{i-1,j-k\times w_i}+v_i\times k)

考虑做一个简单的优化。可以发现,对于 fi,jf_{i,j},只要通过 fi,jwif_{i,j-w_i} 转移就可以了。因此状态转移方程为:

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

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

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

疯狂的采药

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

示例代码

#include <iostream>
using namespace std;
constexpr int MAXN = 1e4 + 5;
constexpr int MAXW = 1e7 + 5;
int n, W, w[MAXN], v[MAXN];
long long f[MAXW];
 
int main() {
  cin >> W >> n;
  for (int i = 1; i <= n; i++) 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];  // 核心状态方程
  cout << f[W];
  return 0;
}

多重背包

多重背包也是 0-1 背包的一个变式。与 0-1 背包的区别在于每种物品有 kik_i 个,而非一个。

一个很朴素的想法就是:把「每种物品选 kik_i 次」等价转换为「有 kik_i 个相同的物品,每个物品选一次」。这样就转换成了一个 0-1 背包模型,套用上文所述的方法就可已解决。状态转移方程如下:

fi,j=maxk=0ki(fi1,jk×wi+vi×k)f_{i,j}=\max_{k=0}^{k_i}(f_{i-1,j-k\times w_i}+v_i\times k)

时间复杂度 O(Wi=1nki)O(W\sum_{i=1}^nk_i)

??? note "核心代码"

for (int i = 1; i <= n; i++) {
  for (int weight = W; weight >= w[i]; weight--) {
    // 多遍历一层物品数量
    for (int k = 1; k * w[i] <= weight && k <= cnt[i]; k++) {
      dp[weight] = max(dp[weight], dp[weight - k * w[i]] + k * v[i]);
    }
  }
}

二进制分组优化

考虑优化。我们仍考虑把多重背包转化成 0-1 背包模型来求解。

解释

显然,复杂度中的 O(nW)O(nW) 部分无法再优化了,我们只能从 O(ki)O(\sum k_i) 处入手。为了表述方便,我们用 Ai,jA_{i,j} 代表第 ii 种物品拆分出的第 jj 个物品。

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

过程

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

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

举几个例子:

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

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

时间复杂度 O(Wi=1nlog2ki)O(W\sum_{i=1}^n\log_2k_i)

实现

示例代码

index = 0;
for (int i = 1; i <= m; i++) {
  int c = 1, p, h, k;
  cin >> p >> h >> k;
  while (k > c) {
    k -= c;
    list[++index].w = c * p;
    list[index].v = c * h;
    c *= 2;
  }
  list[++index].w = p * k;
  list[index].v = h * k;
}

混合背包

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

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

for (循环物品种类) {
  if (是 0 - 1 背包)
    套用 0 - 1 背包代码;
  else if (是完全背包)
    套用完全背包代码;
  else if (是多重背包)
    套用多重背包代码;
}

例题

樱花

nn 种樱花树和长度为 TT 的时间,有的樱花树只能看一遍,有的樱花树最多看 AiA_{i} 遍,有的樱花树可以看无数遍。每棵樱花树都有一个美学值 CiC_{i},求在 TT 的时间内看哪些樱花树能使美学值最高。

核心代码

for (int i = 1; i <= n; i++) {
if (cnt[i] == 0) {  // 如果数量没有限制使用完全背包的核心代码
  for (int weight = w[i]; weight <= W; weight++) {
    dp[weight] = max(dp[weight], dp[weight - w[i]] + v[i]);
  }
} else {  // 物品有限使用多重背包的核心代码,它也可以处理0-1背包问题
  for (int weight = W; weight >= w[i]; weight--) {
    for (int k = 1; k * w[i] <= weight && k <= cnt[i]; k++) {
      dp[weight] = max(dp[weight], dp[weight - k * w[i]] + k * v[i]);
    }
  }
}
}

二维费用背包

题面

nn 个任务需要完成,完成第 ii 个任务需要花费 tit_i 分钟,产生 cic_i 元的开支。

现在有 TT 分钟时间,WW 元钱来处理这些任务,求最多能完成多少任务。

这道题是很明显的 0-1 背包问题,可是不同的是选一个物品会消耗两种价值(经费、时间),只需在状态中增加一维存放第二种价值即可。

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

实现

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);

分组背包

题面

nn 件物品和一个大小为 mm 的背包,第 ii 个物品的价值为 wiw_i,体积为 viv_i。同时,每个物品属于一个组,同组内最多只能选择一个物品。求背包能装载物品的最大总价值。

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

再说一说如何进行存储。我们可以将 tk,it_{k,i} 表示第 kk 组的第 ii 件物品的编号是多少,再用 cntk\mathit{cnt}_k 表示第 kk 组物品有多少个。

实现

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背包一样状态转移

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

有依赖的背包

题面

金明有 nn 元钱,想要买 mm 个物品,第 ii 件物品的价格为 viv_i,重要度为 pip_i。有些物品是从属于某个主件物品的附件,要买这个物品,必须购买它的主件。

目标是让所有购买的物品的 vi×piv_i \times p_i 之和最大。

考虑分类讨论。对于一个主件和它的若干附件,有以下几种可能:只买主件,买主件 + 某些附件。因为这几种可能性只能选一种,所以可以将这看成分组背包。

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

泛化物品的背包

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

杂项

小优化

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

背包问题变种

输出方案

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

int v = V;  // 记录当前的存储空间
 
// 因为最后一件物品存储的是最终状态,所以从最后一件物品进行循环
for (从最后一件循环至第一件) {
  if (g[i][v]) {
    选了第 i 项物品;
    v -= 第 i 项物品的重量;
  } else {
    未选第 i 项物品;
  }
}

求方案数

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

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

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

dpi=(dpi,dpici)\mathit{dp}_i=\sum(\mathit{dp}_i,\mathit{dp}_{i-c_i})

初始条件:dp0=1\mathit{dp}_0=1

因为当容量为 00 时也有一个方案,即什么都不装。

求最优方案总数

要求最优方案总数,我们要对 0-1 背包里的 dp\mathit{dp} 数组的定义稍作修改,DP 状态 fi,jf_{i,j} 为在只能放前 ii 个物品的情况下,容量为 jj 的背包「正好装满」所能达到的最大总价值。

这样修改之后,每一种 DP 状态都可以用一个 gi,jg_{i,j} 来表示方案数。

fi,jf_{i,j} 表示只考虑前 ii 个物品时背包体积「正好」是 jj 时的最大价值。

gi,jg_{i,j} 表示只考虑前 ii 个物品时背包体积「正好」是 jj 时的方案数。

转移方程:

如果 fi,j=fi1,jf_{i,j} = f_{i-1,j}fi,jfi1,jv+wf_{i,j} \neq f_{i-1,j-v}+w 说明我们此时不选择把物品放入背包更优,方案数由 gi1,jg_{i-1,j} 转移过来,

如果 fi,jfi1,jf_{i,j} \neq f_{i-1,j}fi,j=fi1,jv+wf_{i,j} = f_{i-1,j-v}+w 说明我们此时选择把物品放入背包更优,方案数由 gi1,jvg_{i-1,j-v} 转移过来,

如果 fi,j=fi1,jf_{i,j} = f_{i-1,j}fi,j=fi1,jv+wf_{i,j} = f_{i-1,j-v}+w 说明放入或不放入都能取得最优解,方案数由 gi1,jg_{i-1,j}gi1,jvg_{i-1,j-v} 转移过来。

初始条件:

memset(f, 0x3f3f, sizeof(f));  // 避免没有装满而进行了转移
f[0] = 0;
g[0] = 1;  // 什么都不装是一种方案

因为背包体积最大值有可能装不满,所以最优解不一定是 fmf_{m}

最后我们通过找到最优解的价值,把 gjg_{j} 数组里取到最优解的所有方案数相加即可。

实现

for (int i = 0; i < N; i++) {
  for (int j = V; j >= v[i]; j--) {
    int tmp = std::max(dp[j], dp[j - v[i]] + w[i]);
    int c = 0;
    if (tmp == dp[j]) c += cnt[j];                       // 如果从dp[j]转移
    if (tmp == dp[j - v[i]] + w[i]) c += cnt[j - v[i]];  // 如果从dp[j-v[i]]转移
    dp[j] = tmp;
    cnt[j] = c;
  }
}
int max = 0;  // 寻找最优解
for (int i = 0; i <= V; i++) {
  max = std::max(max, dp[i]);
}
int res = 0;
for (int i = 0; i <= V; i++) {
  if (dp[i] == max) {
    res += cnt[i];  // 求和最优解方案数
  }
}

例题:

On this page