有 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]);
}
});