[动态规划] 6 背包问题

参考:

  1. 动态规划之背包问题
  2. 动态规划之背包问题系列
  3. 背包问题-笔记整理
  4. 动态规划:完全背包、多重背包
  5. 《背包九讲》

注:本文内容大多来源于《背包九讲》。目前,只学习了:

  • 0-1背包问题
  • 完全背包
  • 多重背包(其中“可行性问题 O(V N) 的算法”暂时未看)

1 概念

背包问题是一类经典的动态规划问题。

1.1 什么是背包问题

维基百科-背包问题背包问题(Knapsack problem)是一种组合优化NP完全问题。问题可以描述为:给定一组物品,每种物品都有自己的重量和价格,在限定的总重量内,我们如何选择,才能使得物品的总价格最高。问题的名称来源于如何选择最合适的物品放置于给定背包中

1.2 背包问题种类

就ACM或者其它算法竞赛而言,背包问题可以分为8种类型,其中最基础的是0/1背包问题。这八种问题分别为:

  1. 0-1背包问题
  2. 完全背包问题
  3. 多重背包问题
  4. 混合三种背包问题
  5. 二维费用背包问题
  6. 分组背包问题
  7. 有依赖的背包问题
  8. 求背包问题的方案总数

img

下面分情况来讨论:

2 0-1背包问题

问题描述:给你一个可装载重量为 W 的背包和 N 个物品,每个物品有重量和价值两个属性。其中第 i 个物品的重量为 wt[i],价值为 val[i],现在让你用这个背包装物品,最多能装的价值是多少?

举个简单的例子,输入如下:

N = 3, W = 4
wt = [2, 1, 3]
val = [4, 2, 3]

我们按照以前写的 动态规划模板的分析思路来分析:

  1. step1,明确「状态」和「选择」

    1. 先说状态,如何才能描述一个问题局面?答:我们需要知道“物品的数量” 和 “背包的容量” 就可以形成一个背包问题。所以,状态有两个,就是「背包的容量」和「可选择的物品」
    2. 再说选择,对于每件物品,你能选择什么?答:选择就是「装进背包」或者「不装进背包」

    当我们明确第一步之后,我们就可以先把框架写出来:

    for 状态1 in 状态1的所有取值:
        for 状态2 in 状态2的所有取值:
            for ...
                dp[状态1][状态2][...] = 择优(选择1,选择2...)
    
    

    然后,在接下来的分析中,对框架里面的内容进行完善。

  2. step2,明确dp 数组的定义

    首先看看刚才找到的「状态」,有两个,也就是说我们需要一个二维 dp 数组。

    dp[i][w] 的定义如下:对于前 i 个物品,当前背包的容量为 w,这种情况下可以装的最大价值是 dp[i][w]

    比如说,如果 dp[3][5] = 6,其含义为:对于给定的一系列物品中,若只对前 3 个物品进行选择,当背包容量为 5 时,最多可以装下的价值为 6。

    现在,我们可以细化上面的框架:

    int[][] dp[N+1][W+1]
    dp[0][..] = 0
    dp[..][0] = 0
    
    for i in [1..N]:
        for w in [1..W]:
            dp[i][w] = max(
                把物品 i 装进背包,
                不把物品 i 装进背包
            )
    return dp[N][W]
    
  3. step3,根据「选择」,思考状态转移的逻辑:

    简单说就是,上面伪码中「把物品 i 装进背包」和「不把物品 i 装进背包」怎么用代码体现出来呢?所以,状态转移方程可写成: F [ i , v ] = m a x F [ i − 1 , v ] ,   F [ i − 1 , v − C i ] + W i F[i, v] = max{F[i − 1, v],\ F[i − 1, v − Ci ] + Wi} F[i,v]=maxF[i1,v], F[i1,vCi]+Wi

    答:这就要结合对 dp 数组的定义,看看这两种选择会对状态产生什么影响:

    1)、如果你没有把这第 i 个物品装入背包,那么很显然,最大价值 dp[i][w] 应该等于 dp[i-1][w],继承之前的结果。

    2)、如果你把这第 i 个物品装入了背包,那么 dp[i][w] 应该等于 dp[i-1][w - wt[i-1]] + val[i-1]

    说明:

    1. 首先,由于 i 是从 1 开始的,所以 valwt 的索引是 i-1 时表示第 i 个物品的价值和重量。
    2. dp[i-1][w - wt[i-1]] 也很好理解:你如果装了第 i 个物品,就要寻求剩余重量 w - wt[i-1] 限制下的最大价值,加上第 i 个物品的价值 val[i-1]

    综上就是两种选择,我们都已经分析完毕,也就是写出来了状态转移方程,可以进一步细化代码:

    for i in [1..N]:
        for w in [1..W]:
            dp[i][w] = max(
                dp[i-1][w],
                dp[i-1][w - wt[i-1]] + val[i-1]
            )
    return dp[N][W]
    
  4. step4,把伪码翻译成代码,处理一些边界情况

那现在我们就可以写出代码了,完整代码如下所示:

/*
* W 背包所能承受的最大重量;
* N 总共有N个物体;
* wt 每一个物体的重量;
* 每一个物体的价值;
*/
int knapsack(int W, int N, vector<int>& wt, vector<int>& val) {
	// dp数组:对于前 `i` 个物品,当前背包的容量为 `w`,这种情况下可以装的最大价值是 `dp[i][w]`
	vector<vector<int>> dp(N + 1, vector<int>(W+1, 0)); // base case: dp[..][0] = 0,dp[0][..] = 0

	// 状态转移
	for (int i = 1; i <= N; i++) {
		for (int w = 1; w <= W; w++) {
			if (w - wt[i - 1] < 0) // 这种情况下只能选择不装入背包
				dp[i][w] = dp[i - 1][w];
			else { // 两种选择:装、不装
				dp[i][w] = max(dp[i - 1][w],
					dp[i - 1][w - wt[i - 1] ] + val[i - 1]);
			}
		}
	}

	return dp[N][W];
}

2.1 空间优化

参考:

  1. [动态规划系列] —— 背包DP之01背包

为了便于说明,将使用下面图片中的表达式中的字母表示。所以有必要说明以下其含义:

  • F[i, v] 相当于我们的dp[i][w]
  • C i C_i Ci 表示 第i件物品的重量;
  • W i W_i Wi 表示 第i件物品的价值;
  • V V V 表示背包所能承受的总重量;

image-20210715094726943

以上方法的时间和空间复杂度均为O(V*N),其中时间复杂度应该已经不能再优化 了,但空间复杂度却可以优化到 O(V)

先考虑上面讲的基本思路如何实现,肯定是有一个主循环 i ← 1... N i ← 1 ... N i1...N,每次算出来 二维数组$F[i, 0 … V] $的所有值。那么,如果只用一个数组 F [ 0... V ] F[0 ... V ] F[0...V],能不能保证第 i 次循环结束后 F [ v ] F[v] F[v] 中表示的就是我们定义的状态 F [ i , v ] F[i, v] F[i,v]呢? F [ i , v ] F[i, v] F[i,v]是由 F [ i − 1 , v ] F[i − 1, v] F[i1,v] F [ i − 1 , v − C i ] F[i − 1, v − Ci ] F[i1,vCi] 两个子问题递推而来,能否保证在推 F [ i , v ] F[i, v] F[i,v] 时(也即在第 i 次主循环中 推 F [ v ] F[v] F[v]时)能够取用 F [ i − 1 , v ] F[i − 1, v] F[i1,v] F [ i − 1 , v − C i ] F[i − 1, v − Ci ] F[i1,vCi]的值呢?

答:事实上,这要求在每次主循环中我们以 v ← V . . . 0 v ← V . . . 0 vV...0递减顺序计算 F [ v ] F[v] F[v],这样才 能保证计算 $F[v] $时 F [ v − C i ] F[v − Ci ] F[vCi]保存的是状态 F [ i − 1 , v − C i ] F[i − 1, v − Ci] F[i1,vCi] 的值。伪代码如下:

F[0..V ] ←0
for i ← 1 to N
	for v ← V to Ci // 这里逆序(递减)
		F[v] ← max{F[v], F[v − Ci] + Wi}

其中的 F [ v ] ← m a x F [ v ] , F [ v − C i ] + W i F[v] ← max{F[v], F[v − Ci ] + Wi} F[v]maxF[v],F[vCi]+Wi 一句,恰就对应于我们原来的转移方程,因为现在的 $F[v − Ci ] $就相当于原来的 F [ i − 1 , v − C i ] F[i − 1, v − Ci ] F[i1,vCi]如果将 v v v 的循环顺序从上面的逆序改成顺序的话那么则成了 F [ i , v ] F[i, v] F[i,v] F [ i , v − C i ] F[i, v − Ci ] F[i,vCi] 推导得到,与本题意不符

循环部分代码如下:

	// 状态转移
	for (int i = 1; i <= N; i++) {
		for (int w = W; w >= 1; w++) { // ⭐⭐ 逆序
			if (w - wt[i - 1] < 0) // 这种情况下只能选择不装入背包
				dp[w] = dp[w];
			else { // 两种选择:装、不装
				dp[[w] = max([w],
					dp[w - wt[i - 1] ] + val[i - 1]);
			}
		}
	}

2.2 初始化的细节

F数组:对应于 dp数组;

我们看到的求最优解的背包问题题目中,事实上有两种不太相同的问法:

  1. 有的题目 要求“恰好装满背包”时的最优解;
  2. 有的题目则并没有要求必须把背包装满;

一种区别 这两种问法的实现方法是在初始化的时候有所不同:

  1. 第一种问法,要求恰好装满背包,那么在初始化时除了 $F[0] $为 0 0 0,其它 F [ 1.. V ] F[1..V ] F[1..V] 均设为 − ∞ −∞ ,这样就可以保证最终得到的 F [ V ] F[V ] F[V] 是一种恰好装满背包的最优解。
  2. 第二种问法,没有要求必须把背包装满,而是只希望价格尽量大,初始化时应该将 F [ 0.. V ] F[0..V ] F[0..V] 全部设为 0。

为什么要这样呢?

答:可以这样理解:初始化的 F F F 数组事实上就是在没有任何物品可以放 入背包时的合法状态。

  • 如果要求背包恰好装满,那么此时只有容量为 0 的背包可以在什 么也不装且价值为 0 的情况下被“恰好装满”,其它容量的背包均没有合法的解,属于 未定义的状态,应该被赋值为 − ∞ -∞ 了。
  • 如果背包并非必须被装满,那么任何容量的背包 都有一个合法解“什么都不装”,这个解的价值为 0,所以初始时状态的值也就全部为 0 了。

3 完全背包问题

问题描述:有 N 种物品和一个容量为 V 的背包,每种物品都有无限件可用。第i件物品的费用(即体积,下同)是w[i],价值是val[i]。求解:将哪些物品装入背包,可使这些物品的耗费的费用总和 不超过背包容量,且价值总和最大?

3.1 基本思路

这个问题非常类似于 01 背包问题,所不同的是每种物品有无限件。也就是从每种 物品的角度考虑,与它相关的策略已经不是 取|不取 两种,而是有${取 0 件、取 1 件、取 2 件……直至取 ⌊V /Ci⌋} $件等许多种。

现在我们按照 0-1 背包问题的思路来进行分析:

1、明确「状态」和「选择」

  • 状态有两个,就是「背包的容量」和「可选择的物品」;
  • 选择就是「装进背包」或者「不装进背包」;

明白了状态和选择,动态规划问题基本上就解决了,只要往这个框架套就完事儿了:

for 状态1 in 状态1的所有取值:
    for 状态2 in 状态2的所有取值:
        for ...
            dp[状态1][状态2][...] = 计算(选择1,选择2...)

2、明确 dp 数组的定义

首先看看刚才找到的「状态」,有两个,也就是说我们需要一个二维 dp 数组。

dp[i][j] 的定义如下:

下面的 F [ i , v ] F[i, v] F[i,v] 对应 dp[i][j]

F [ i , v ] F[i, v] F[i,v] 表示前 i i i 种物品恰放入一个容量为 v v v 的背包的最大权值

3、状态转移方程

仍然可以按照每种物品不同的策略写出状态转移方程,像这样:

F [ i , v ] = m a x { F [ i − 1 , v − k C i ] + k W i   ∣   0 ≤ k C i ≤ v } F[i, v] = max\{F[i − 1, v − kC_i ] + kW_i\ |\ 0 ≤ kCi ≤ v\} F[i,v]=max{F[i1,vkCi]+kWi  0kCiv}
这跟 0-1 背包问题一样有$ O(V N) $个状态需要求解,但求解每个状态的时间已经不 是常数了,求解状态 $ F[i, v] $ 的时间是 O ( v / C i ) O( v/ Ci ) O(v/Ci) ,总的复杂度可以认为是 O ( N V   Σ V C i ) O(NV\ Σ\frac {V} {Ci} ) O(NV ΣCiV),是比较大的。

其中:F[i-1][j-K*C[i]]+K*W[i] 表示前i-1种物品中选取若干件物品放入剩余空间为j-K*C[i]的背包中所能得到的最大价值 + k件第i种物品;

这样代码应该是三层循环(物品数量,物品种类,背包大小这三个循环),伪代码如下:

/*
* 其中:
* 物品种数为N;
* 背包容量为V;
* 第i种物品体积为C[i];
* 第i种物品价值为W[i];
*/
F[0][] ← {0}  
  
F[][0] ← {0}  
  
for i←1 to N  
  
    do for j←1 to V  
  
        do for k←0 to j/C[i]  
  
           if(j >= k*C[i])  
  
                then F[i][k] ← max(F[i][k],F[i-1][j-k*C[i]]+k*W[i])  
  
return F[N][V]

很明显,这样一般情况下会超时,需要转化成时间复杂度比较低的在进行求解。那我们就尝试把它转化为0-1 背包问题。

3.2 法2:转化为 0-1背包问题

0-1 背包问题是最基本的背包问题,我们可以考虑把完全背包问题转化为 01 背包问 题来解。

  1. 最简单的想法是:考虑到第 i i i 种物品最多选 ⌊ V / C i ⌋ ⌊V /Ci⌋ V/Ci 件,于是可以把第 i i i 种物品转 化为 ⌊ V / C i ⌋ ⌊V /Ci⌋ V/Ci 件费用及价值均不变的物品,然后求解这个 0-1背包问题 。

    这样的做法完 全没有改进时间复杂度,但这种方法也指明了将完全背包问题转化为 01 背包问题的思 路:将一种物品拆成多件只能选 0 件或 1 件的 01 背包中的物品

  2. 更高效的转化方法是利用二进制思想:把第 i i i 种物品拆成费用为 C i 2 k C_i2 ^k Ci2k 、价值为 W i 2 k W_i2^k Wi2k 的若干件物品,其中 k k k 取遍满足 C i 2 k ≤ V C_i2^k ≤ V Ci2kV 的非负整数(k从0开始取值)

    这是二进制的思想。因为,不管最优策略选几件第 i 种物品,其件数写成二进制后, 总可以表示成若干个 2 k 2^k 2k 件物品的和。这样一来就把每种物品拆成 O ( l o g ⌊ V / C i ⌋ ) O(log ⌊V /Ci⌋) O(logV/Ci) 件物 品,是一个很大的改进。

下面主要讨论利用二进制思想的方法:

3.2.1 利用二进制思想

参考:如何理解多重背包的二进制优化?

下面举个例子,来说明这种思想:

image-20210715164116871(图片来源:https://www.bilibili.com/video/BV1MA41177cg?from=search&seid=5594537006591314903)

如上图例子所示:

  • 朴素思想:我们一次只拿一个苹果,然后需要n次才能将这些苹果取出;
  • 二进制化:我们把50 进行“二进制化”表示。即: { 2 0 , 2 1 , 2 2 , 2 3 , 2 4 , 50 − 2 5 + 1 } \{2^0, 2^1, 2^2, 2^3, 2^4,50-2^5+1\} {20,21,22,23,24,5025+1},即把50用 { 1 , 2 , 4 , 8 , 16 , 19 } \{1,2,4,8,16,19\} {1,2,4,8,16,19} 这几个数字来表示。我们可以使用这几个数来表示 0~50 中的任意一个数,所以,这样我们就可以简化 取任意n个苹果时,只要推出其中的某些箱子即可(此时,6个箱子就能够表示)。

此时,我们可以看出 二进制拆分 的思想:

将第 i i i 种物品拆分成若干件物品,每件物品的体积和价值乘以一个拆分系数( { 2 0 , 2 1 , 2 2 , . . . , 2 k − 1 , s i − 2 k + 1 } \{2^0, 2^1, 2^2, ..., 2^{k-1}, s_i-2^{k}+1\} {20,21,22,...,2k1,si2k+1}),就可以转化成 0-1 背包问题 求解

例如: s i = 12 s_i = 12 si=12,拆分系数为 1,2,4,(12 - 2^3 +1)=5,转化成 4 件 0-1背包物品: ( v i , w i ) , ( 2 v i , 2 w i ) , ( 4 v i , 4 w i ) , ( 5 v i , 5 w i ) (v_i, w_i), (2v_i, 2w_i), (4v_i, 4w_i), (5v_i, 5w_i) (vi,wi),(2vi,2wi),(4vi,4wi),(5vi,5wi)

***这里的 s i s_i si 的值就是满足 C i 2 k ≤ V C_i2^k ≤ V Ci2kV 中的最大的 k k k 的值。***

注意,我们分系数的最后一项的表达式。因为最后一项有可能不能用2的指数形式表示。

接下来,我们就可以按照 0-1背包问题的思路进行解题。不过我们现在需要注意的是:

  1. 给定的物品已经不再是N种了,而是原来的每种物品 二进化之后分成的若干件物品 的总量了;
  2. 给定的总容量依然是V;
  3. 现在的每一种物品的费用是 C i 2 k C_i2 ^k Ci2k,价值是 W i 2 k W_i2^k Wi2k

举个例子说明:

原始情况:

  • 原始的容量为V = 10
  • 物品种类为N = 4
  • 每种物品的费用(重量)为cost = [1, 3, 5, 6]
  • 每种物品的价值为val = [2,3,4,5]

转化后的情况:

  • 容量v = 10;

  • 进行二进制转化:

    第一件物品:

    costval C i 2 k ≤ V C_i2^k ≤ V Ci2kV
    1 ∗ 2 0 = 1 1*2^0 = 1 120=1 2 ∗ 2 0 = 2 2*2^0 = 2 220=2 1 ∗ 2 0 = 1 ≤ 10 1*2^0 = 1≤ 10 120=110
    1 ∗ 2 1 = 2 1*2^1 = 2 121=2 2 ∗ 2 1 = 4 2*2^1 = 4 221=4 1 ∗ 2 1 = 2 ≤ 10 1*2^1 = 2≤ 10 121=210
    1 ∗ 2 2 = 4 1*2^2 = 4 122=4 2 ∗ 2 2 = 8 2*2^2 = 8 222=8 1 ∗ 2 2 = 4 ≤ 10 1*2^2 = 4≤ 10 122=410
    1 ∗ 2 3 = 8 1*2^3 = 8 123=8 2 ∗ 2 3 = 16 2*2^3 = 16 223=16 1 ∗ 2 3 = 8 ≤ 10 1*2^3 = 8≤ 10 123=810
    1 ∗ 2 4 = 16 1*2^4 = 16 124=16 2 ∗ 2 3 = 16 ≥ 10 2*2^3 = 16 ≥ 10 223=1610

    所以第一件物品转化后,编程4件物品;

    剩下的3件物品转化过程同理。

  • 转化后的每种物品的费用(重量)为cost = [1,2,4,8, 3,6, 5,10, 6]

  • 转化后的每种物品的价值为 val = [2,4,8,16, 3,6, 4,8, 5];

  • 转化后的物品种类为: N = 9

⭐,至此,我们就可以按照转化后的数据,按照 0-1背包问题进行解题了。(0-1背包问题解题参照[2 0-1背包问题](#2 0-1背包问题))。

3.3 法3: O ( V N ) O(V N) O(VN) 的算法

参考:

  1. 完全背包问题状态转移方程解释
  2. [动态规划系列] —— 背包DP之完全背包

首先,我们在[3.1 基本思路](#3.1 基本思路) 中提到过的状态转移方程为: F [ i , v ] = m a x { F [ i − 1 , v − k C i ] + k w i   ∣   0 ≤ k C i ≤ v } F[i, v] = max\{F[i − 1, v − kC_i ] + kw_i\ |\ 0 ≤ kCi ≤ v\} F[i,v]=max{F[i1,vkCi]+kwi  0kCiv}

根据上面的公式,我们的可以得到 F [ i ] [ v − c [ i ] ] + w [ i ] F[i][v - c[i]] + w[i] F[i][vc[i]]+w[i] 下面我们进行证明:

  • 1,令 v ′ = v − c i v^{'} = v - c_i v=vci k ′ k^{'} k 就是变量(为了区分它这里不是 k k k

  • 2,写出
    F [ i ] [ v − c [ i ] ] + w [ i ] = m a x { F [ i − 1 , v ′ − k ′ C i ] + k ′ w i + w [ i ]   ∣   0 ≤ k ′ C i ≤ v ′ } F[i][v - c[i]] + w[i] = max\{F[i − 1, v^{'} − k^{'} C_i ] + k^{'}w_i+ w[i]\ |\ 0 ≤ k^{'}Ci ≤ v^{'}\} F[i][vc[i]]+w[i]=max{F[i1,vkCi]+kwi+w[i]  0kCiv}

  • 3, 可以算出 k ′ k^{'} k 的取值范围: 0 ≤ k ′ C i ≤ v ′ 0 ≤ k^{'}Ci ≤ v^{'} 0kCiv ==> 0 ≤ k ′ ≤ v ′ / c i 0 ≤ k^{'} ≤ v^{'}/{c_i} 0kv/ci ==> 0 ≤ k ′ ≤ ( v − C i ) / C i 0 ≤ k^{'} ≤ (v - C_i)/{C_i} 0k(vCi)/Ci ==> 0 ≤ k ′ ≤ v / C i − 1 0 ≤ k^{'} ≤ v/{C_i} - 1 0kv/Ci1 ==而我们知道 k ≤ v / C i k ≤ v/C_i kv/Ci ==> 得到: k ′ = k − 1 k^{'} = k -1 k=k1

  • 4, 将第3步得到的结论带入第2步中: F [ i ] [ v − c [ i ] ] + w [ i ] = m a x { F [ i − 1 , v − C i − ( k − 1 ) C i ] + ( k − 1 ) w i + w [ i ]   ∣   0 ≤ ( k − 1 ) C i ≤ v − C i } F[i][v - c[i]] + w[i] = max\{F[i − 1, v - C_i − (k-1) C_i ] + (k-1)w_i+ w[i]\ |\ 0 ≤ (k-1)C_i ≤ v-C_i\} F[i][vc[i]]+w[i]=max{F[i1,vCi(k1)Ci]+(k1)wi+w[i]  0(k1)CivCi} ==整理==> F [ i ] [ v − c [ i ] ] + w [ i ]   = m a x { F [ i − 1 , v − ( 1 + k ) C i ] + k W i   ∣   0 ≤ k C i ≤ v } F[i][v - c[i]] + w[i]\ = max\{F[i − 1, v − (1+ k)C_i ] + kW_i\ |\ 0 ≤ kCi ≤ v\} F[i][vc[i]]+w[i] =max{F[i1,v(1+k)Ci]+kWi  0kCiv}

  • 所以,这里我们就知道了“ F [ i ] [ v − c [ i ] ] + w [ i ] F[i][v - c[i]] + w[i] F[i][vc[i]]+w[i] = F [ i , v ] F[i, v] F[i,v]

其实就是 让 F [ i ] [ v − c [ i ] ] F[i][v - c[i]] F[i][vc[i]] 中的参数带入原来的公式。

此时,我们就可以按照我们分析的状态转移方程的操作写出成利于编码的形式:

coins[] 对应公式中的 C i C_i Ci

dp[][] 对应公式中的 F [ ] [ ] F[][] F[][]

w i wi wi 表示该物品对应的价值;

v v v 表示当前容量(循环时,对应内层循环的 j);

  • 如果如果你不把这第 i 个物品装入背包,也就是说你不使用 coins[i] 这个面值的硬币,那么凑出面额 j 的方法数 dp[i][j] 应该等于 dp[i-1][j],继承之前的结果。
  • 如果你把这第 i 个物品装入了背包,也就是说你使用 coins[i] 这个面值的硬币,那么 dp[i][j] 应该等于 dp[i][j-coins[i-1]]

F [ i , v ] = m a x ( F [ i − 1 , v ] , F [ i , v − C i ] + w i ) F[i, v] = max(F[i − 1, v], F[i, v − Ci] + wi) F[i,v]=max(F[i1,v],F[i,vCi]+wi)

接下来我们就可以利用这个公式进行代码的书写了:

/*
* W 背包所能承受的最大重量;
* N 总共有N个物体;
* wt 每一个物体的重量;
* 每一个物体的价值;
*/
int knapsack(int W, int N, vector<int>& wt, vector<int>& val) {
	// dp数组:对于前 `i` 个物品,当前背包的容量为 `w`,这种情况下可以装的最大价值是 `dp[i][w]`
	vector<vector<int>> dp(N + 1, vector<int>(W+1, 0)); // base case: dp[..][0] = 0,dp[0][..] = 0

	// 状态转移
	for (int i = 1; i <= N; i++) {
		for (int w = 1; w <= W; w++) {
			if (w - wt[i - 1] < 0) // 这种情况下只能选择不装入背包
				dp[i][w] = dp[i - 1][w];
			else { // 两种选择:装、不装
				dp[i][w] = max(dp[i - 1][w],
					dp[i][w - wt[i - 1] ] + val[i - 1]); // ⭐相比于01背包,只有这一行做了改动
			}
		}
	}
	return dp[N][W];
}

对比01背包,我们基本上只做了一处改动,就是标记有⭐一处。其他大致都相同。

3.3.1 空间优化

同样的,我们对完全背包版本的代码作状态压缩(空间优化)。

/*
* W 背包所能承受的最大重量;
* N 总共有N个物体;
* wt 每一个物体的重量;
* 每一个物体的价值;
*/
int knapsack(int W, int N, vector<int>& wt, vector<int>& val) {
	// dp数组:对于前 `i` 个物品,当前背包的容量为 `w`,这种情况下可以装的最大价值是 `dp[i][w]`
    // ⭐,现修改为1维
	vector<int> dp(W+1, 0);
    
    // base case
    for(int i = 0; i <= W+1; i++) {
        dp[i] = 0;
    }

	// 状态转移
	for (int i = 1; i <= N; i++) {
		for (int w = 1; w <= W; w++) {
			if (w - wt[i - 1] < 0) // 这种情况下只能选择不装入背包
				dp[w] = dp[w];
			else { // 两种选择:装、不装
				dp[w] = max(dp[w],
					dp[w - wt[i - 1] ] + val[i - 1]); // ⭐相比于01背包,只有这一行做了改动
			}
		}
	}
	return dp[W];
}

注意:相对于0-1背包问题,这里的内层遍历是 顺序的

4 多重背包

题目如下:

N N N 种物品和一个容量为 V V V 的背包。第 i i i 种物品最多有 M i M_i Mi 件可用,每件耗费的 空间是 C i C_i Ci,价值是 W i W_i Wi。求解:将哪些物品装入背包可使这些物品的耗费的空间总和不超 过背包容量,且价值总和最大。


4.1 基本算法

这题目和完全背包问题很类似,(只不过是 k k k 的取值范围有所区别???)。基本的方程只需将完全背包问题的方程略微一改 即可。

因为对于第 i i i 种物品有 $M_i + 1 $ 种策略:取 0 0 0 件,取 1 1 1 件……取 M i M_i Mi 件。令 F [ i , v ] F[i, v] F[i,v] 表示前 i i i 种物品恰放入一个容量为 V V V 的背包的最大价值,则有状态转移方程(选取第 i i i 件物品时): F [ i , v ] = m a x { F [ i − 1 , v − k ∗ C i ] + k ∗ w i   ∣   0 ≤ k ≤ M i } F[i,v] = max\{F[i − 1, v − k ∗ Ci] + k ∗ wi\ |\ 0 ≤ k ≤ Mi\} F[iv]=max{F[i1,vkCi]+kwi  0kMi}

说明,不一定能取 M i M_i Mi 件,因为要考虑 总容量 V V V ,不能超过 V V V。所以, k k k 的范围其实需要同时满足: 0 ≤ k ≤ M i    & &    0 ≤ k ∗ C i ≤ V {0 ≤ k ≤ Mi}\ \ {\&\&}\ \ {0≤k*C_i≤V} 0kMi  &&  0kCiV

即: F [ i , v ] = m a x { F [ i − 1 , v − k ∗ C i ] + k ∗ w i   ∣   0 ≤ k ≤ M i    & &    0 ≤ k ∗ C i ≤ V } F[i,v] = max\{F[i − 1, v − k ∗ Ci] + k ∗ wi\ |\ {0 ≤ k ≤ Mi}\ \ {\&\&}\ \ {0≤k*C_i≤V}\} F[iv]=max{F[i1,vkCi]+kwi  0kMi  &&  0kCiV}

同样的,我们可以求出(求解过程详见 [3.3 法3: O ( V N ) O(V N) O(VN) 的算法](##3.3 法3: O ( V N ) O(V N) O(VN) 的算法)): F [ i , v − C i ] + w [ i ]   = m a x { F [ i − 1 , v − C i − k ∗ C i ] + ( k − 1 ) ∗ w i + w i   ∣   0 ≤ k ≤ M i    & &    0 ≤ k ∗ C i + C i ≤ V } F[i,v- C_i] + w[i]\ = max\{F[i − 1, v −C_i - k∗ Ci] + (k-1) ∗ w_i+w_i\ |\ {0 ≤ k ≤ Mi}\ \ {\&\&}\ \ {0≤k*C_i +C_i≤V}\} F[ivCi]+w[i] =max{F[i1,vCikCi]+(k1)wi+wi  0kMi  &&  0kCi+CiV} ==整理==> F [ i , v − C i ] + w [ i ]   = m a x { F [ i − 1 , v − k ∗ C i ] + k ∗ w i   ∣   0 ≤ k ≤ M i    & &    0 ≤ k ∗ C i ≤ V } F[i,v- C_i] + w[i]\ = max\{F[i − 1, v - k∗ Ci] + k∗ w_i\ |\ {0 ≤ k ≤ Mi}\ \ {\&\&}\ \ {0≤k*C_i≤V}\} F[ivCi]+w[i] =max{F[i1,vkCi]+kwi  0kMi  &&  0kCiV}

然后根据我们选取,不选取的情况:

coins[] 对应公式中的 C i C_i Ci

dp[][] 对应公式中的 F [ ] [ ] F[][] F[][]

  • 如果如果你不把这第 i 个物品装入背包,也就是说你不使用 coins[i] 这个面值的硬币,那么凑出面额 j 的方法数 dp[i][j] 应该等于 dp[i-1][j],继承之前的结果。
  • 如果你把这第 i 个物品装入了背包,也就是说你使用 coins[i] 这个面值的硬币,那么 dp[i][j] 应该等于 dp[i][j-coins[i-1]]

F [ i , v ] = m a x { F [ i − 1 ] [ v ] ,   F [ i , v − C i ] + k ∗ W i } F[i,v] = max\{F[i-1][v],\ F[i, v −C_i] + k ∗ Wi\} F[iv]=max{F[i1][v], F[i,vCi]+kWi}

然后,我们就可以按照 [3.3 法3: O ( V N ) O(V N) O(VN) 的算法](##3.3 法3: O ( V N ) O(V N) O(VN) 的算法) 中讲解的代码进行实施。

复杂度是 O ( V Σ M i ) O(V ΣM_i) O(VΣMi)

4.2 转化为 0-1背包问题

另一种好想好写的基本方法是转化为 0-1背包求解。转化为 0-1背包问题有两种思路:

  1. 比较容易想到的:把第 i i i 种物品换成 M i Mi Mi 件 0-1背包中的物品,则得到了物品数为 Σ M i ΣMi ΣMi 的 0-1背包问题。直接求解之,复杂度仍然是 O ( V Σ M i ) O(V ΣMi) O(VΣMi)

  2. 利用二进制思想:我们考虑把第 i i i 种物品换成若干件物品,使得原问题中第 i i i 种物品可取的每种策略—— 取 0... M i 0 . . . Mi 0...Mi 件均能等价于取若干件代换以后的物品。另外,取超过 Mi 件的策略必不能出现。

    具体方法:将第 i i i 种物品分成若干件 0-1背包中的物品,其中每件物品有一个系数。这件物品的费用和价值均是原来的费用和价值乘以这个系数。这些系数分别为令这些系数分别为 1 , 2 , 2 2 . . . 2 k − 1 , M i − 2 k + 1 1, 2, 2^2 . . . 2^{k−1}, Mi − 2^k + 1 1,2,22...2k1,Mi2k+1,且 k 是满足 M i − ( 2 k + 1 ) > 0 Mi − (2^k + 1) > 0 Mi(2k+1)>0 的最大整数。

    例如,如果 Mi 为 13,则相应的 k = 3,这种最多取 13 件的物品应被分成系数分别为 1, 2, 4, 6 的四件 物品。

    注意:分成的这几件物品的系数和为 Mi,表明不可能取多于 Mi 件的第 i 种物品。另外 这种方法也能保证对于 0 . . . Mi 间的每一个整数,均可以用若干个系数的和表示。

    这样就将第 i 种物品分成了 O ( l o g M i ) O(logMi) O(logMi)种物品,将原问题转化为了复杂度为 O ( V Σ l o g M i ) O(V ΣlogMi) O(VΣlogMi) 的 01 背包问题,是很大的改进。

    接下来,我们就可以按照 [3.2.1 利用二进制思想](###3.2.1 利用二进制思想) 中的思路进行代码的实现了。

5 背包问题 问法的变化

以上涉及的各种背包问题都是要求在背包容量(费用)的限制下求可以取到的最大价值,但背包问题还有很多种灵活的问法,在这里值得提一下。

如将问法变为:

  • 求解最多可以放多少件物品 或者 最多可以装满多少背包的空间?

    答:这都可以根 据具体问题利用前面的方程求出所有状态的值(F 数组)之后得到。

  • 如果要求的是“总价值最小”“总件数最小”?

    答:只需将状态转移方程中的 max 改成 min 即可。

下面说一些变化较大的。

5.1 输出最优方案

一般而言,背包问题是要求一个最优值,如果要求输出这个最优值的方案,可以参 照一般动态规划问题输出方案的方法:记录下每个状态的最优值是由状态转移方程的哪一项推出来的,换句话说,记录下它是由哪一个策略推出来的。便可根据这条策略找到 上一个状态,从上一个状态接着向前推即可

以 0-1背包为例,方程为 F [ i , v ] = m a x { F [ i − 1 , v ] ,   F [ i − 1 , v − C i ] + W i } F[i, v] = max\{F[i − 1, v],\ F[i − 1, v − Ci ] + Wi\} F[i,v]=max{F[i1,v], F[i1,vCi]+Wi}。用 一个数组 G [ i , v ] G[i, v] G[i,v]

  • G [ i , v ] = 0 G[i, v] = 0 G[i,v]=0表示推出 F [ i , v ] F[i, v] F[i,v] 的值时是采用了方程的前一项(也即 F [ i , v ] = F [ i − 1 , v ] F[i, v] = F[i − 1, v] F[i,v]=F[i1,v]);
  • G [ i , v ] = 1 G[i, v] = 1 G[i,v]=1 表示采用了方程的后一项。

注意这两项分别表示了两 种策略:未选第 i 个物品及选了第 i 个物品。

那么输出方案的伪代码可以这样写(设最 终状态为 F [ N , V ] F[N, V ] F[N,V]):

i ←N
v ← V
while i > 0
	if G[i, v] = 0
		print 未选第 i 项物品
	else if G[i, v] = 1
		print 选了第 i 项物品
		v ← sv − Ci
	i ← i − 1

5.2 求可行的方案总数

对于一个给定了背包容量、物品费用、物品间相互关系(分组、依赖等)的背包问题,除了再给定每个物品的价值后求可得到的最大价值外,还可以得到装满背包或将背包装至某一指定容量的方案总数。

对于这类改变问法的问题,一般只需将状态转移方程中的 max 改成 sum 即可。例如若每件物品均是完全背包中的物品,转移方程即为:
F [ i , v ] = s u m { F [ i − 1 , v ] , F [ i , v − C i ] } F[i, v] = sum\{F[i − 1, v], F[i, v − Ci]\} F[i,v]=sum{F[i1,v],F[i,vCi]}
初始条件是 F [ 0 , 0 ] = 1 F[0, 0] = 1 F[0,0]=1

事实上,这样做可行的原因在于状态转移方程已经考察了所有可能的背包组成方案。

5.3 求 最优方案的总数

这里的最优方案是指物品总价值最大的方案。以 01 背包为例。

结合求最大总价值和方案总数两个问题的思路,最优方案的总数可以这样求: F [ i , v ] F[i, v] F[i,v] 代表该状态的最大价值, G [ i , v ] G[i, v] G[i,v] 表示这个子问题的最优方案的总数,则在求 F [ i , v ] F[i, v] F[i,v] 的同时求 G [ i , v ] G[i, v] G[i,v] 的伪代码如下:

G[0, 0] ← 1
for i ← 1 to N
	for v ← 0 to V
		F[i, v] ← max{F[i − 1, v], F[i − 1, v − Ci] + Wi}
		G[i, v] ← 0
		if F[i, v] = F[i − 1, v]
			G[i, v] ← G[i, v] + G[i − 1][v]
		if F[i, v] = F[i − 1, v − Ci] + Wi
			G[i, v] ← G[i, v] + G[i − 1][v − Ci]

6 小结

  1. 注意 0-1背包问题、完全背包 中的内层for循环的遍历顺序:
    1. 0-1背包问题 逆序
    2. 完全背包 顺序
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值