起因

面试官即排序算法、斐波那契数列后的第三个问题——背包问题
在学习的同时,我尽可能用通俗易懂的解释、代码注释、代码分析、问题优化加深这样的过程来和大家一起分析这个问题。

问题的描述

背包:背包容量一定,在一组东西中如何选择,使得背包最有价值
本质:是一个组合优化的问题。
问题描述:给一个固定大小,能够携重W的背包,以及一组有价值重量的物品,
请找出一个最佳的方案,使得装入包中的物品重量不超过W且总价值最大。

问题分析

我们以一个具体的例子进行分析
示例

物品个数 n:5
物品重量 weights:[2,2,6,5,4]
物品价值 values:[6,3,5,4,6]
背包总容量 W:10
需求:怎么装使得总重量w<10,且value价值最大

思路

假设一个函数为: f(i,j) :表示装入背包的最大价值
		其中的  i:表示装入的物品     (从第一个到第五个都有可能装入)
		其中的  j:表示背包目前的重量   (j<w)
逻辑分析:
对于一种物品,要么装入背包,要么不装。
所以对于一种物品的装入状态只是1或0, 此问题称为01背包问题

背包算法Python 背包算法 js_javascript

对第一行分析:
目前我们只有盒子0
当背包重量j是0时,什么都装不下,所以f(0,0)=0
当背包重量j是1时,什么都装不下,所以f(0,1)=0
当背包重量j等于2或者大于2时,盒子0能装入,所以f(0,j)=6  (j>=2)

即可得方程式:

背包算法Python 背包算法 js_背包算法Python_02


第二行

背包算法Python 背包算法 js_算法_03

对第二行分析:
目前有盒子0和盒子1
当背包重量j是0时,什么都装不下,所以f(1,0)=0
当背包重量j是1时,什么都装不下,所以f(1,1)=0
当背包重量j是2时,可以装盒子0或盒子1,需要比较盒子0和盒子1的价值,所以f(1,2)=6
当背包重量j是3时,可以装盒子0或盒子1,需要比较盒子0和盒子1的价值,所以f(1,3)=6
当背包重量j是4或者>4时,可以装盒子0和盒子1,所以f(1,j)=9 (j>=4)

方程式为:

f(i,j)=\left { f(i-1,j) j<w(i)\bigcap i>0;max\left {f(i-1,j), f(i-1,j-w(i))+v(i) j>=w(i)\right }\right }


背包算法Python 背包算法 js_前端_05


这个时候,递归就已经有一定的体现了,只要接下来的符合上述逻辑我们就可以实现JavaScript函数了。

我们看第三行:

背包算法Python 背包算法 js_前端_06

对第三行分析:
目前有盒子0,盒子1,盒子2
当背包重量j是0时,什么都装不下,所以f(2,0)=0
当背包重量j是1时,什么都装不下,所以f(2,1)=0
当背包重量j是2时,可以装盒子0或盒子1,装不下盒子2,需要比较盒子0和盒子1的价值,所以f(2,2)=6
当背包重量j是3时,可以装盒子0或盒子1,装不下盒子2,需要比较盒子0和盒子1的价值,所以f(2,3)=6
当背包重量j是4或者5时,可以装盒子0和盒子1,装不下盒子2,所以f(2,j)=9  (4<= j <6)
当背包重量j是6或者7时,可以选择装盒子2,也可以选择装盒子0和盒子1,比较价值,所以f(2,j)=9  (6<= j <8)
当背包重量j是8,9时,可以选择装下盒子2后在装下盒子0和盒子1中的一个,所以f(2,j)=11  (8<= j <10)
当背包重量j是10时,可以同时装下盒子0、盒子1、盒子2,所以f(2,10)=14

整理方程可得到第1行和第2行的使用方程:

背包算法Python 背包算法 js_javascript_07


解释:

背包算法Python 背包算法 js_背包算法Python_08


依次求出剩余行

背包算法Python 背包算法 js_矩阵_09


整合我们的方程式为:

背包算法Python 背包算法 js_背包算法Python_10

函数实现(上述方程式)

function knapsack(weights, values, w){
    var n = weights.length -1; // 获取盒子个数i  从盒子0开始
    var f=[[]]; //定义f的矩阵
    for(var j=0;j<=w;j++){
    	// 背包的重量j从0开始,一直到我们输入的w
        if(j<weights[0]){ // 背包的重量小于盒子0的重量,价值为0
        	f[0][j]=0;
        }else{
            f[0][j]=values[0]; // 否则容量为物品0的价值
            }
        }
        // 上述for循环实现了方程式的前两条
    for(var j=0;j<=w;j++){
    	// 当盒子数不是1个时 
        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)

执行结果

背包算法Python 背包算法 js_算法_11


到这里已经可以解决问题了,如果想深入的去学习,下面还有变种优化以及其他可以实现的思路

优化

合并循环

现在方法里面有两个大循环,它们可以合并成一个。

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++ ){ // i表示当前盒子
       for(var j = 0; j <= W; j++){ // j表示当前背包的重量
            if(i === 0){ //第一行
                f[i][j] = j < weights[i] ? 0 : values[i]
                // 三元表达式 小于盒子重量  价值0  大于盒子重量 价值第一个盒子重量
            }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]
}

这时候我们发现必须要有一个if条件去专门的处理盒子0的状态(即第一行的数据),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的边界问题,我们可以在第一行上面再加一行即i可以取-1值。

如图:

背包算法Python 背包算法 js_javascript_12

那么方程式如下:

背包算法Python 背包算法 js_javascript_13

function knapsack(weights, values, W){
    var n = weights.length;
    var f = new Array(n)
    f[-1] = new Array(W+1).fill(0) // 引入了-1行
    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]);
            }
        }
    }
    return f[n-1][W]
}

负一行的出现可以大大减少了在双层循环的分支判定。是一个很好的技巧。

选择物品(可能就会问:到底选择了那些盒子呢)

上面讲解了如何求得最大价值,现在我们看到底选择了哪些盒子,这个在现实中更有意义。

仔细观察矩阵,从背包算法Python 背包算法 js_算法_14逆着走向背包算法Python 背包算法 js_前端_15,设i=n-1,j=W,

背包算法Python 背包算法 js_前端_16


只要我们的方程式中背包算法Python 背包算法 js_javascript_17==背包算法Python 背包算法 js_前端_18成立就说明包里面有第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]
            }else{
                f[i][j] = Math.max(f[i-1][j], f[i-1][j-weights[i]]+values[i]);
            }
        }
    }
    // 数组逆遍历
    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])
    // selected.reverse() 数组的翻转函数 我们是逆遍历,数组翻过来
    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)

结果展示

背包算法Python 背包算法 js_背包算法Python_19

使用滚动数组压缩空间

所谓滚动数组,目的在于优化空间,因为目前我们是使用一个背包算法Python 背包算法 js_前端_20的二维数组来储存每一步的最优解。在求解的过程中,我们可以发现,当前状态只与前一行的状态有关,那么更之前存储的状态信息已经无用了,可以舍弃的,我们只需要存储当前状态和前一行状态,所以只需使用背包算法Python 背包算法 js_前端_21的空间,循环滚动使用,就可以达到跟背包算法Python 背包算法 js_算法_22一样的效果。这是一个非常大的空间优化。

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行的数据,因此很难解出挑选的物品,只能求最大价值。

使用一维数组压缩空间

观察我们的状态迁移方程:

背包算法Python 背包算法 js_算法_23


weights为每个物品的重量,values为每个物品的价值,W是背包的容量,i表示要放进第几个物品,j是背包现时的容量(假设我们的背包是魔术般的可放大,从0变到W)。我们假令i = 0

背包算法Python 背包算法 js_背包算法Python_24


f中的-1就变成没有意义,因为没有第-1行,而weights[0], values[0]继续有效,背包算法Python 背包算法 js_矩阵_25也有意义,因为我们全部放到一个一维数组中。于是:

背包算法Python 背包算法 js_javascript_26


这方程后面多加了一个限制条件,要求是从大到小循环。为什么呢?

假设有物体z容量2,价值很大,背包容量为5,如果j的循环顺序不是逆序,那么外层循环跑到物体时, 内循环在背包算法Python 背包算法 js_算法_27时 ,z被放入背包。当背包算法Python 背包算法 js_算法_28时,寻求最大价值,物体z放入背包,背包算法Python 背包算法 js_前端_29, 这里毫无疑问后者最大。 但此时背包算法Python 背包算法 js_矩阵_30中的背包算法Python 背包算法 js_矩阵_31

javascript实现:

function knapsack(weights, values, W){
    var n = weights.length;
    // 借助new Array()生成指定数组长度的假数据的时候,此时数组是空的
    // 使用fill()这个数组方法,由于没有传值,fill()会自动根据数组长度替换数组中所有的值为undefined
    var f = new Array(W+1).fill(0)
    // 值全为0
    for(var i = 0; i < n; i++) {
        for(var j = W; j >= weights[i]; j--){  
            f[j] = Math.max(f[j], f[j-weights[i]] +values[i]);
        }
        console.log(f.concat()) //调试
    }
    return f[W];
}
var b = knapsack([2,2,6,5,4],[6,3,5,4,6],10)
console.log(b)

背包算法Python 背包算法 js_前端_32

1.4 递归法解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])
    }
})

背包算法Python 背包算法 js_矩阵_33