有 N 件物品和一个容量是 V 的背包。每件物品有且只有一件。
第 i 件物品的体积是 v[i] ,价值是 w[i] 。
求解将哪些物品装入背包,可使这些物品的总体积不超过背包容量,且总价值最大。
示例 1:
输入: N = 3, V = 4, v = [4,2,3], w = [4,2,3]
输出: 4
解释: 只选第一件物品,可使价值最大。
示例 2:
输入: N = 3, V = 5, v = [4,2,3], w = [4,2,3]
输出: 5
解释: 不选第一件物品,选择第二件和第三件物品,可使价值最大。
参考答案
这是最为基础的背包问题,每种物品只有一件,可以选择取或者不取。
问题描述可以归结为:将N种物品有选择地放入容量为V的背包中,要求背包中的物品价值最大。
尝试提炼其子问题:将i种物品有选择地放入容量为V的背包中,要求背包中的物品价值最大。
那么由子问题转移到父问题的方程为:
f(i,V) = max{f(i-1,V), f(i-1,V-v[i]) + w[i]}
解释如下:“将前i件物品放入容量为V的背包中”这个子问题,若只考虑第i件物品的策略(放或者不放),那么就可以转化为一个只关系到前i-1件物品的问题。
- 如果不放第i件物品,那么问题就转化为“前i-1件物品放入容量为v的背包中”;
- 如果放第i件物品,那么问题就转化为“前i-1件物品放入剩下的容量为V-v[i]的背包中”,此时能获得的最大价值就是f(i-1, V-v[i])再加上通过放入第i件物品获得的价值w[i]。
时间复杂度已经无法优化,我们可以尝试优化空间复杂度。
观察状态转移方程,发现当前状态i只和前一个状态有关i-1,那么我们可以用滚动数组,逆序遍历的方式进行空间优化。
function knapsack(weights, values, W){ var n = weights.length -1 var f = [[]] for(var j = 0; j <= W; j++){ if(j < weights[0]){ //如果容量不能放下物品0的重量,那么价值为0 f[0][j] = 0 }else{ //否则等于物体0的价值 f[0][j] = values[0] } } for(var j = 0; j <= W; j++){ for(var i = 1; i <= n; i++ ){ if(!f[i]){ //创建新一行 f[i] = [] } if(j < weights[i]){ //等于之前的最优值 f[i][j] = f[i-1][j] }else{ f[i][j] = Math.max(f[i-1][j], f[i-1][j-weights[i]] + values[i]) } } } return f[n][W] } var a = knapsack([2,2,6,5,4],[6,3,5,4,6],10) console.log(a)
合并循环
现在方法里面有两个大循环,它们可以合并成一个。
function knapsack(weights, values, W){ var n = weights.length; var f = new Array(n) for(var i = 0 ; i < n; i++){ f[i] = [] } for(var i = 0; i < n; i++ ){ for(var j = 0; j <= W; j++){ if(i === 0){ //第一行 f[i][j] = j < weights[i] ? 0 : values[i] }else{ if(j < weights[i]){ //等于之前的最优值 f[i][j] = f[i-1][j] }else{ f[i][j] = Math.max(f[i-1][j], f[i-1][j-weights[i]] + values[i]) } } } } return f[n-1][W] }
然后我们再认真地思考一下,为什么要孤零零地专门处理第一行呢?f[i][j] = j < weights[i] ? 0 : values[i]是不是能适用于下面这一行f[i][j] = Math.max(f[i-1][j], f[i-1][j-weights[i]] + values[i]) 。Math.max可以轻松转换为三元表达式,结构极其相似。而看一下i-1的边界问题,有的书与博客为了解决它,会添加第0行,全部都是0,然后i再往下挪。其实我们也可以添加一个${-1}$行。那么在我们的方程中就不用区分${i==0}$与${0>0}$的情况,方程与其他教科书的一模一样了!
function knapsack(weights, values, W){ var n = weights.length; var f = new Array(n) f[-1] = new Array(W+1).fill(0) for(var i = 0 ; i < n ; i++){ //注意边界,没有等号 f[i] = new Array(W).fill(0) for(var j=0; j<=W; j++){//注意边界,有等号 if( j < weights[i] ){ //注意边界, 没有等号 f[i][j] = f[i-1][j] }else{ f[i][j] = Math.max(f[i-1][j], f[i-1][j-weights[i]]+values[i]);//case 3 } } } return f[n-1][W] }
选择物品
上面讲解了如何求得最大价值,现在我们看到底选择了哪些物品,这个在现实中更有意义。许多书与博客很少提到这一点,就算给出的代码也不对,估计是在设计状态矩阵就出错了。
仔细观察矩阵,从${f(n-1,W)}$逆着走向${f(0,0)}$,设i=n-1,j=W,如果${f(i,j)}$==${f(i-1,j-w_i)+v_i}$说明包里面有第i件物品,因此我们只要当前行不等于上一行的总价值,就能挑出第i件物品,然后j减去该物品的重量,一直找到j = 0就行了。
function knapsack(weights, values, W){ var n = weights.length; var f = new Array(n) f[-1] = new Array(W+1).fill(0) var selected = []; for(var i = 0 ; i < n ; i++){ //注意边界,没有等号 f[i] = [] //创建当前的二维数组 for(var j=0; j<=W; j++){ //注意边界,有等号 if( j < weights[i] ){ //注意边界, 没有等号 f[i][j] = f[i-1][j]//case 1 }else{ f[i][j] = Math.max(f[i-1][j], f[i-1][j-weights[i]]+values[i]);//case 2 } } } var j = W, w = 0 for(var i=n-1; i>=0; i--){ if(f[i][j] > f[i-1][j]){ selected.push(i) console.log("物品",i,"其重量为", weights[i],"其价格为", values[i]) j = j - weights[i]; w += weights[i] } } console.log("背包最大承重为",W," 现在重量为", w, " 总价值为", f[n-1][W]) return [f[n-1][W], selected.reverse() ] } var a = knapsack([2,3,4,1],[2,5,3, 2],5) console.log(a) var b = knapsack([2,2,6,5,4],[6,3,5,4,6],10) console.log(b)
使用滚动数组压缩空间
所谓滚动数组,目的在于优化空间,因为目前我们是使用一个${ij}$的二维数组来储存每一步的最优解。在求解的过程中,我们可以发现,当前状态只与前一行的状态有关,那么更之前存储的状态信息已经无用了,可以舍弃的,我们只需要存储当前状态和前一行状态,所以只需使用${2j}$的空间,循环滚动使用,就可以达到跟${i*j}$一样的效果。这是一个非常大的空间优化。
function knapsack(weights, values, W){ var n = weights.length var lineA = new Array(W+1).fill(0) var lineB = [], lastLine = 0, currLine var f = [lineA, lineB]; //case1 在这里使用es6语法预填第一行 for(var i = 0; i < n; i++){ currLine = lastLine === 0 ? 1 : 0 //决定当前要覆写滚动数组的哪一行 for(var j=0; j<=W; j++){ f[currLine][j] = f[lastLine][j] //case2 等于另一行的同一列的值 if( j>= weights[i] ){ var a = f[lastLine][j] var b = f[lastLine][j-weights[i]] + values[i] f[currLine][j] = Math.max(a, b);//case3 } } lastLine = currLine//交换行 } return f[currLine][W]; } var a = knapsack([2,3,4,1],[2,5,3, 2],5) console.log(a) var b = knapsack([2,2,6,5,4],[6,3,5,4,6],10) console.log(b)
注意,这种解法由于丢弃了之前N行的数据,因此很难解出挑选的物品,只能求最大价值。
递归法解01背包
function knapsack(n, W, weights, values, selected) { if (n == 0 || W == 0) { //当物品数量为0,或者背包容量为0时,最优解为0 return 0; } else { //从当前所剩物品的最后一个物品开始向前,逐个判断是否要添加到背包中 for (var i = n - 1; i >= 0; i--) { //如果当前要判断的物品重量大于背包当前所剩的容量,那么就不选择这个物品 //在这种情况的最优解为f(n-1,C) if (weights[i] > W) { return knapsack(n - 1, W, weights, values, selected); } else { var a = knapsack(n - 1, W, weights, values, selected); //不选择物品i的情况下的最优解 var b = values[i] + knapsack(n - 1, W - weights[i], weights, values, selected); //选择物品i的情况下的最优解 //返回选择物品i和不选择物品i中最优解大的一个 if (a > b) { selected[i] = 0; //这种情况下表示物品i未被选取 return a; } else { selected[i] = 1; //物品i被选取 return b; } } } } } var selected = [], ws = [2,2,6,5,4], vs = [6,3,5,4,6] var b = knapsack( 5, 10, ws, vs, selected) console.log(b) //15 selected.forEach(function(el,i){ if(el){ console.log("选择了物品"+i+ " 其重量为"+ ws[i]+" 其价值为"+vs[i]) } })