初识:0/1 背包问题
0/1 背包问题可以说是所有背包问题的基础,它描述的场景是这样的:假设你有一个背包,载重上限是 W,你面前有 n 个物品,第 i 个物品的重量是 wi,价值是 vi,那么,在不超过背包重量上限的前提下,你能获得的最大物品价值总和是多少?
按照动态规划问题的四步走,咱们来分析一下这个问题。
状态定义
关于状态定义,我们首先来分析 0/1 背包问题中的自变量和因变量。
因变量比较好确定,就是问题中所求的最大价值总和。自变量呢?经过分析你会发现,物品种类和背包承重上限就是自变量,因为它们都能够影响价值总和的最大值。这样我们就可以设置一个二维的状态,状态定义如下:
0/1 背包状态定义:dp[i][j] 代表使用前 i 个物品,背包最大载重为 j 的情况下的最大价值总和。
推导状态转移方程
推导状态转移方程,也就是推导 dp[i][j] 的表达式。根据 dp[i][j] 的含义,我们可以将 dp[i][j] 可能达到最大值时的方案分成两类:一类是方案中不选择第 i 个物品的最大价值和,另一类是方案中选择了第 i 个物品的最大价值和。只需要在这两类方案的最大值中,选择一个价值和较大的方案,转移到 dp[i][j] 即可。下面,我们就分别表示一下这两种方案的公式。
不选择第 i 个物品的最大价值和,就是 dp[i - 1][j]。也就是说,在背包最大载重为 j 的情况下,前 i 个物品中,不选择第 i 个物品的最大价值和,就等于在前 i - 1 个物品中选择的最大价值和。
选择第 i 个物品的最大价值和就是 dp[i - 1][j - wi] + vi。关于这个公式的理解,可以参考我们前面讲的凑钱币问题,既然要求一定选择了第 i 个物品,那我们就可以先给第 i 个物品预留出来一个位置,然后给剩余的 i - 1 个物品留的载重空间就只剩下 j - wi 了,那么 i - 1 个物品选择的最大价值和是 dp[i - 1][j - wi],再加上 vi 就是选择第 i 个物品时,我们能够获得最大价值和。
最终,我们得到 dp[i][j] 的状态转移方程,如下所示:
1 | dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w[i]] + v[i]) |
正确性证明
动规算法的正确性证明,还是需要依赖于数学归纳法,下面我们开始数学归纳法的三步走。
首先,dp[0][j] = 0,就是当没有物品的时候,无论背包限重是多少,能得到的最大价值和都是 0,这也就是已知 k0 正确。
其次,假设我们已经正确计算得到了,在 i - 1 个物品的任意一种背包容量下的价值最大和值,也就是所有 dp[i - 1] 中的值。那么根据状态转移方程,我们也肯定可以正确的得到所有 dp[i] 中的值。
最后两步联立,整个求解过程对于任意 dp[i][j],均正确。
(认真理解这个证明过程,因为接下来的程序处理过程,其实和这个证明过程是一致的。)
程序设计与实现
1 |
|
get_dp 函数就是求解 0/1 背包问题的过程,函数传入两个整形参数 n 和 W,分别代表了物品数量与背包最大限重。程序中有三个数组:v、w 与 dp,v[i] 代表第 i 个物品的价值,w[i]代表第 i 个物品的重量,dp[i][j] 代表背包问题相关的状态。
这一段代码,采用了正向递推的程序实现。而且,如果你注意观察 get_dp 函数的实现过程,你会惊奇地发现,这就是数学归纳法的证明过程。
首先,初始化 dp[0] 阶段的所有值,也就是保证了 k0 成立;然后从 dp[1] 开始迭代计算到 dp[n] 中所有值,每一次 dp[i]依赖的就是 dp[i - 1] 中的值,只有 dp[i - 1] 中所有值是正确的,才能保证 dp[i]中所有值是正确的,这就是数学归纳法的第二步。最后,两步联立,就证明了以 dp 数组的第一维作为阶段,进行状态转移,计算得到的所有 dp 值均是正确的。
进阶:多重背包问题
其实这个问题,整体和 0/1 背包问题类似,只不过从 n 个物品变成了 n 种物品,且每种物品都有不同的数量,我们可以设定第 i 种物品的数量是 ci。
现在你有一个载重上限为 15kg 的背包,有如下 4 件物品:
- 镀金极客币,每个 4kg,每个价值 10 块钱,一共有 5 个;
- 胡船长手办,每个 3kg ,每个价值 7 块钱,一共有 4 个;
- 西瓜,每个 12kg ,每个价值 12 块钱,一共有 2 个;
- 哈密瓜,每个 9kg ,每个价值 8 块钱,一共有 7 个。
经过分析,在不超过背包载重上限的情况下,你可以选择 3 个镀金极客币和 1 个胡船长手办装到背包里面,这种选择方案能获得最大价值为:37 块钱。
回到我们说的这个多重背包问题,你想如何求解呢?
其实最简单的解决办法,就是把 n 种物品中的每一个,都看成是 0/1 背包中的一个物品,然后按照 0/1 背包问题的求解过程来做即可。这也就是说,如果一种物品有 12 件,就相当于 0/1 背包中多了 12 件物品,我们就多做 12 轮运算,要是有 120 件呢,那就是多做 120 轮运算。
这种做法虽然可行,可显然太浪费我们计算机的计算资源了。下面就让我们看看怎么做,才能更优化。
二进制拆分法
想象一个场景,假设你是一个卖白菜的老农,手上有 23 斤白菜和若干个筐,出于某种不知名的原因,你今天不能把称重器带到菜市场,只能提前把白菜称好装入不同的筐里贩卖给顾客。问题来了,白菜要如何分到这些筐里面,才能使得第一个顾客无论要多少斤白菜,你都能通过挑选其中的几筐白菜,从而满足顾客的需求呢?
一种最直接的装筐方法,就是每个筐里面装 1 斤白菜,共需要 23 个筐。这样,第一个顾客要多少斤白菜,你就给他多少筐就行。这种方法简单粗暴,可是用的筐太多了。
转换一个思路去想这件事:当你准备挑几个筐满足第一个顾客需求的时候,对于每个筐来说,都有两种状态,选或者不选,这不就是二进制每位上的数字么?我们就可以把每个筐,看成是二进制相应的位权。
可以看到,从第一个筐开始,我们依次装上 1 斤、2 斤、4 斤、8 斤,第五个筐应该装 16 斤的,可剩下的白菜不够 16 斤,所以就一起放到最后一个筐里面。这样,我们只需要 5 个筐,就装了 23 斤白菜,并且可以保证无论第一个客人要几斤白菜,都能满足他的需求。
多重背包的拆分优化
假设多重背包中,某一种物品有 23 件,转换到 0/1 背包问题中,就是 23 个物品,就跟前面一斤白菜装一筐的做法是一样的。我们虽然不知道,在 0/1 背包问题的最优方案中,这种物品被具体选择了多少件,可是只要我们通过一种合理的拆分方法,使得无论最优方案中选择了多少件这种商品,我们都可以组合出来。
简单粗暴地拆分成 23 份,是一种拆分方法,而二进制拆分法也是一种拆分方法,并且二进制拆分法只需要拆成 5 份物品,作为 0/1 背包问题中的 5 个单独的物品即可,这么做可以达到和拆分成 23 件物品等价的效果,并且节省了大量的计算资源。
例如,前面多重背包问题的那个例子中,按照原本简单粗暴的方式,我们是把 5 个镀金极客币、4 个胡船长手办、2 个西瓜、7 个哈密瓜,当作 18 个物品的 0/1 背包问题来求解的。但如果采用二进制拆分法,我们就会得到如下拆分方案:
1 | 1个镀金极客币,4kg每个,价值 10 块钱 |
这种拆分方案等价于求解 11 个物品的 0/1 背包问题,比之前求解的 18 个物品的 0/1 背包问题显然要优秀。
实际上,随着某个物品数量的增加,二进制拆分法的优势会愈加地明显。
(想一想 32 个二进制位能表示的数字大小)
程序设计与实现
待更新…
小结
- 0/1 背包问题中的自变量是物品的种类和背包限重,所以我们把这两维设计到了状态定义中。
- 多重背包问题可以转换成 0/1 背包进行求解,转换过程不同,效率也就不同。
- 二进制拆分法,本质思想就是二进制的数字表示法,0/1 表示两种状态,表示选或不选。