01背包问题
题目
有 n 件物品和一个容量为 v 的背包。放入第 i 件物品耗费的费用是 c[i] ,得到的价值是 w[i] 。求解将哪些物品装入背包可使价值总和最大。
基本思路
这是最基础的背包问题,特点是:每种物品仅有一件,可以选择放或不放。
用子问题定义状态:即 f[i][v] 表示前 i 件物品恰放入一个容量为 v 的背包可以获得的最大价值。则其状态转移方程便是 \(f[i][v] = max(f[i−1][v], f[i − 1][v−c[i]] +w[i])\) 这个方程非常重要,基本上所有跟背包相关的问题的方程都是由它衍生出来的。所以有必要将它详细解释一下:“将前 i 件物品放入容量为 v 的背包中”这个子问题,若只考虑第 i 件物品的策略(放或不放),那么就可以转化为一个只和前 i − 1 件物品相关的问题。如果不放第 i 件物品,那么问题就转化为“前 i − 1 件物品放入容量为 v 的背包中”,价值为 f[i-1][v];如果放第 i 件物品,那么问题就转化为“前 i − 1 件物品放入剩下的容量为 v−c[i] 的背包中”,此时能获得的最大价值就是 f[i-1][v-c[i]] 再加上通过放入第 i 件物品获得的价值 w[i] 。
个人理解:思路是把问题拆分,“n 件物品放入容量为 v 的背包,价值最大” 可以拆分成 “前 n-1 件物品放入背包容量为(这里要分情况)的背包,价值最大” 和 “放不放入第 n 件物品”,放入和不放入取决于两种情况那种结果更好。同理可以向下继续拆分,变成考虑第 i 件物品放或不放。
这里顺便解决一下自己的疑问,”既然我们只关注背包容量为 v 的结果,为什么还要记录 f[i][1~v] “,实际上这里忽略了状态转移方程中放入第 i 件物品需要先知道 v[i-1][v-c[i]] ,即在遍历第 i 件物品时,我们应该已经记录前 i-1 件物品在容量考虑放入第 i 件物品情况下的价值最大的结果。
for(int i = 1; i <= n; i++) //n个物品
for(int j = 1; j <= v; j++){ //不同容量时的情况
if(j < c[i]) //如果不能容纳第 i 件物品
f[i][j] = f[i-1][j];
else //能容纳时需要比较
f[i][j] = max(f[i-1][j],f[i-1][j-c[i]]+w[i]); //状态转移方程
}
//改写成下面这样也行
for(int i = 1; i <= n; i++)
for(int j = c[i]; j <= v; j++) //排除不能容纳的情况
f[i][j] = max(f[i-1][j],f[i-1][j-c[i]]+w[i]);
优化空间复杂度
以上方法的时间和空间复杂度均为 O(n*v),其中时间复杂度应该已经不能再优化 了,但空间复杂度却可以优化到 O(v)。 先考虑上面讲的基本思路如何实现,肯定是有一个主循环 for(int i = 1; i <= n; i++),每次算出来二维数组 f[i][1~v] 的所有值。那么,如果只用一个数组 f[1~v],能不能保证第 i 次循环结束后 f[v] 中表示的就是我们定义的状态 f[i][v] 呢?f[i][v] 是由 f[i-1][v] 和 f[i-1][v-c[v]] 两个子问题递推而来,能否保证在推 f[i][v] 时(也即在第 i 次主循环中推 f[v] 时)能够取用 f[i-1][v] 和 f[i-1][v-c[v]] 的值呢?
事实上,这要求在每次主循环中我们以 for(int j = v; j > 0; j–) 的递减顺序计算 f[v],这样才能保证计算 f[v] 时 f[v-c[v]] 保存的是状态 f[i-1][v-c[v]] 的值。
个人理解:最重要的是知道如何用一维数组表示二维数组的值,其实就是用一维数组在第 i 次遍历开始前记录第 i-1 次遍历时的结果,这样就可以不用储存每一次遍历的结果,从而把二维数组降维为一维数组。那么这是如何实现的呢?首先,第 i 次循环中 j 循环的第一次循环中,j = v,即 f[j] = f[v],在此之前的第 i-1次循环已经结束,那么此时 f[v] 相当于 f[i-1][v],这就解决了第一个参数的问题。那么如何解决第二个参数的问题呢?这就是为什么 j 循环要用递减的顺序遍历了。因为 j-c[j] < j ,也就是说遍历 f[j] 时,采用递减顺序遍历,f[j-c[j]] 一定还没有被遍历过,那么它储存的就是此前第 i-1 次循环时的结果,即 f[i-1][j-c[j]] 。
for(int i = 1; i <= n; i++)
for(int j = v; j >= c[i]; j--) //递减顺序遍历
f[j] = max(f[j],f[j-c[j]]+w[i]);
初始化的细节问题
我们看到的求最优解的背包问题题目中,事实上有两种不太相同的问法。有的题目 要求“恰好装满背包”时的最优解,有的题目则并没有要求必须把背包装满。一种区别这两种问法的实现方法是在初始化的时候有所不同。
如果是第一种问法,要求恰好装满背包,那么在初始化时除了 f[0] 为 0,其它 f[1~v] 均设为 −∞,这样就可以保证最终得到的 f[v] 是一种恰好装满背包的最优解。 如果并没有要求必须把背包装满,而是只希望价格尽量大,初始化时应该将 f[0~v] 全部设为 0。
这是为什么呢?可以这样理解:初始化的 f 数组事实上就是在没有任何物品可以放入背包时的合法状态。如果要求背包恰好装满,那么此时只有容量为 0 的背包可以在什么也不装且价值为 0 的情况下被“恰好装满”,其它容量的背包均没有合法的解,属于未定义的状态,应该被赋值为 -∞ 。如果背包并非必须被装满,那么任何容量的背包 都有一个合法解“什么都不装”,这个解的价值为 0,所以初始时状态的值也就全部为 0 了。 这个小技巧完全可以推广到其它类型的背包问题,后面不再对进行状态转移之前的初始化进行讲解。
个人理解:因为 01背包是根据初始状态不断向后拓展的,所以初始状态根据要求设好,后面的结果一定是符合要求的。
一个常数优化
for(int i = 1; i <= n; i++)
for(int j = v; j >= c[i]; j--)
//可以优化成下面这种形式
for(int i = 1; i <= n; i++)
for(int j = v; j >= max(v-$\sum_i^nc[k]$,c[i]))
因为最后只关注 f[v] ,也就是只需要 f[v-c[i]] ,所以不需要遍历那么多次。
小结
01 背包问题是最基本的背包问题,它包含了背包问题中设计状态、方程的最基本思想。另外,别的类型的背包问题往往也可以转换成 01 背包问题求解。故一定要仔细体会上面基本思路的得出方法,状态转移方程的意义,以及空间复杂度怎样被优化。
完全背包问题
题目
有 n 种物品和一个容量为 v 的背包,每种物品都有无限件可用。放入第 i 种物品 的费用是 c[i] ,价值是 w[i] 。求解:将哪些物品装入背包,可使这些物品的耗费的费用总和不超过背包容量,且价值总和最大。
基本思路
这个问题非常类似于 01 背包问题,所不同的是每种物品有无限件。也就是从每种物品的角度考虑,与它相关的策略已并非取或不取两种,而是有取 0 件、取 1 件、取 2 件……直至取 ⌊v/c[i]⌋ 件等许多种。 如果仍然按照解 01 背包时的思路,令 f[i][v] 表示前 i 种物品恰放入一个容量为 v 的背包的最大权值。仍然可以按照每种物品不同的策略写出状态转移方程,像这样: \(f[i][v] = max(f(i−1,v − kc[i])+kw[i] | 0 ≤ kc[i] ≤ v)\) 这跟 01 背包问题一样有 O(vn) 个状态需要求解,但求解每个状态的时间已经不是常数了,求解状态 f[i][v] 的时间是 O($\frac{v}{c[i]}$),总的复杂度可认为是O($nv\sum\frac{v}{c[i]}$),是比较大的。
将 01 背包问题的基本思路加以改进,得到了这样一个清晰的方法。这说明 01 背包问题的方程的确是很重要,可以推及其它类型的背包问题。但我们还是要试图改进这个复杂度。
一个简单有效的优化
完全背包问题有一个很简单有效的优化,是这样的:若两件物品 i、j 满足 从 c[i] ≤ c[j] 且 w[i] ≥ w[j] ,则将可以将物品 j 直接去掉,不用考虑。 这个优化的正确性是显然的:任何情况下都可将价值小费用高的 j 换成物美价廉的 i,得到的方案至少不会更差。对于随机生成的数据,这个方法往往会大大减少物品的件数,从而加快速度。然而这个并不能改善最坏情况的复杂度,因为有可能特别设计的数据可以一件物品也去不掉。 这个优化可以简单的 O($n^2$) 地实现,一般都可以承受。另外,针对背包问题而言,比较不错的一种方法是:首先将费用大于 v 的物品去掉,然后使用类似计数排序的做法,计算出费用相同的物品中价值最高的是哪个,可以 O(v+n) 地完成这个优化。这个不太重要的过程就不给出伪代码了,希望你能独立思考写出伪代码或程序。
转化为01背包问题求解
01 背包问题是最基本的背包问题,我们可以考虑把完全背包问题转化为 01 背包问题来解。 最简单的想法是,考虑到第 i 种物品最多选 ⌊v/c[i]⌋ 件,于是可以把第 i 种物品转化为 ⌊V/Ci⌋ 件费用及价值均不变的物品,然后求解这个 01 背包问题。这样的做法完全没有改进时间复杂度,但这种方法也指明了将完全背包问题转化为 01 背包问题的思路:将一种物品拆成多件只能选 0 件或 1 件的 01 背包中的物品。 更高效的转化方法是:把第 i 种物品拆成费用为 c[i]$2^k$、价值为 w[i]$2^k$ 的若干件物品,其中 k 取遍满足 c[i]$2^k$ ≤ v 的非负整数。 这是二进制的思想。因为,不管最优策略选几件第 i 种物品,其件数写成二进制后,总可以表示成若干个 $2^k$ 件物品的和。这样一来就把每种物品拆成 O(log ⌊v/c[i]⌋) 件物 品,是一个很大的改进。
O(vn)的算法
这个算法使用一维数组
for(int i = 1; i <= n; i++)
for(int j = c[i]; j <= v; j++)
f[j] = max(f[j],f[j-c[i]]+w[i]);
你会发现,这个代码与 01 背包问题的代码只有 v 的循环次序不同而已。 为什么这个算法就可行呢?首先想想为什么 01 背包中要按照 v 递减的次序来循环。让 v 递减是为了保证第 i 次循环中的状态 f[i][v] 是由状态 f[i-1][v-c[i]] 递推而来。换句话说,这正是为了保证每件物品只选一次,保证在考虑“选入第 i 件物品”这件策略时,依据的是一个绝无已经选入第 i 件物品的子结果 f[i-1][v-c[i]] 。而现在完全背包的特点恰是每种物品可选无限件,所以在考虑“加选一件第 i 种物品”这种策略时, 却正需要一个可能已选入第 i 种物品的子结果 f[i][v-c[i]] ,所以就可以并且必须采用 v 递增的顺序循环。这就是这个简单的程序为何成立的道理。 值得一提的是,上面的代码中两层 for 循环的次序可以颠倒。这个结论有可能会带来算法时间常数上的优化。 这个算法也可以由另外的思路得出。例如,将基本思路中求解 f[i][v-c[i]] 的状态转移方程显式地写出来,代入原方程中,会发现该方程可以等价地变形成这种形式: \(f[i][v] = max(f[i-1][v],f[i][v-c[i]]+w[i])\) 将这个方程用一维数组实现,便得到了上面的代码。
个人理解,因为现在求 f[i][v] 时需要用到 f[i][v-c[i]] ,而 v-c[i] < v ,我们需要先遍历 v-c[i],所以是按递增的顺序遍历。
小结
完全背包问题也是一个相当基础的背包问题,它有两个状态转移方程。希望读者能够对这两个状态转移方程都仔细地体会,不仅记住,也要弄明白它们是怎么得出来的, 最好能够自己想一种得到这些方程的方法。 事实上,对每一道动态规划题目都思考其方程的意义以及如何得来,是加深对动态规划的理解、提高动态规划功力的好方法。