JavaScript背包问题详解(下)

927 阅读16分钟
原文链接: mp.weixin.qq.com

                                         

钟钦成

钟钦成,网名为司徒正美,国内著名的前端专家,对浏览器兼容性问题/选择器引擎/react内部机制等具有深厚的积累,开发有avalon/anu等前端框架,著有《JavaScript框架设计》一书。

1.4 选择物品

上篇讲解了如何求得最大价值,现在我们看到底选择了哪些物品,这个在现实中更有意义。许多书与博客很少提到这一点,就算给出的代码也不对,估计是在设计状态矩阵就出错了。

仔细观察矩阵,从 ${f(n-1,W)}$ 逆着走向 ${f(0,0)}$ ,设 i=n-1 , j=W ,如果 ${f(i,j)}$==${f(i-1,j-wi)+vi}$ 说明包里面有第i件物品,因此我们只要当前行不等于上一行的总价值,就能挑出第i件物品,然后j减去该物品的重量,一直找到 j = 0 就行了。

  1. function knapsack(weights, values, W){

  2.    var n = weights.length;

  3.    var f = new Array(n)

  4.    f[-1] = new Array(W+1).fill(0)

  5.    var selected = [];

  6.    for(var i = 0 ; i < n ; i++){ //注意边界,没有等号

  7.        f[i] = [] //创建当前的二维数组

  8.        for(var j=0; j<=W; j++){ //注意边界,有等号

  9.            if( j < weights[i] ){ //注意边界, 没有等号

  10.                f[i][j] = f[i-1][j]//case 1

  11.            }else{

  12.                f[i][j] = Math.max(f[i-1][j], f[i-1][j-weights[i]]+values[i]);//case 2

  13.            }

  14.        }

  15.    }

  16.    var j = W, w = 0

  17.    for(var i=n-1; i>=0; i--){

  18.         if(f[i][j] > f[i-1][j]){

  19.             selected.push(i)

  20.             console.log("物品",i,"其重量为", weights[i],"其价格为", values[i])

  21.             j = j - weights[i];

  22.             w +=  weights[i]

  23.         }

  24.     }

  25.    console.log("背包最大承重为",W," 现在重量为", w, " 总价值为", f[n-1][W])

  26.    return [f[n-1][W], selected.reverse() ]

  27. }

  28. var a = knapsack([2,3,4,1],[2,5,3, 2],5)

  29. console.log(a)

  30. var b = knapsack([2,2,6,5,4],[6,3,5,4,6],10)

  31. console.log(b)

1.4.1 使用滚动数组压缩空间

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

  1. function knapsack(weights, values, W){

  2.    var n = weights.length

  3.    var lineA = new Array(W+1).fill(0)

  4.    var lineB = [], lastLine = 0, currLine

  5.    var f = [lineA, lineB]; //case1 在这里使用es6语法预填第一行

  6.    for(var i = 0; i < n; i++){

  7.        currLine = lastLine === 0 ? 1 : 0 //决定当前要覆写滚动数组的哪一行

  8.        for(var j=0; j<=W; j++){

  9.            f[currLine][j] = f[lastLine][j] //case2 等于另一行的同一列的值

  10.            if( j>= weights[i] ){                        

  11.                var a = f[lastLine][j]

  12.                var b = f[lastLine][j-weights[i]] + values[i]

  13.                f[currLine][j] = Math.max(a, b);//case3

  14.            }

  15.        }

  16.        lastLine = currLine//交换行

  17.   }

  18.   return f[currLine][W];

  19. }

  20. var a = knapsack([2,3,4,1],[2,5,3, 2],5)

  21. console.log(a)

  22. var b = knapsack([2,2,6,5,4],[6,3,5,4,6],10)

  23. console.log(b)

我们还可以用更 hack 的方法代替 currLine, lastLine 。

  1. function knapsack(weights, values, W){

  2.    var n = weights.length

  3.    var f = [new Array(W+1).fill(0),[]], now = 1, last //case1 在这里使用es6语法预填第一行

  4.    for(var i = 0; i < n; i++){

  5.        for(var j=0; j<=W; j++){

  6.            f[now][j] = f[1-now][j] //case2 等于另一行的同一列的值

  7.            if( j>= weights[i] ){                        

  8.                var a = f[1-now][j]

  9.                var b = f[1-now][j-weights[i]] + values[i]

  10.                f[now][j] = Math.max(a, b);//case3

  11.            }

  12.         }

  13.         last = f[now]

  14.         now = 1-now // 1 - 0 => 1;1 - 1=> 0; 1 - 0= > 1 ....

  15.   }

  16.   return last[W];

  17. }

注意,这种解法由于丢弃了之前 N 行的数据,因此很难解出挑选的物品,只能求最大价值。

1.4.2使用一维数组压缩空间

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

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

我们假令 i = 0

f中的-1就变成没有意义,因为没有第-1行,而 weights[0] ,  values[0] 继续有效, ${f(0,j)}$ 也有意义,因为我们全部放到一个一维数组中。于是:

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

假设有物体 ${\cal z}$ 容量2,价值 ${vz}$ 很大,背包容量为5,如果 j 的循环顺序不是逆序,那么外层循环跑到物体 ${\cal z}$ 时, 内循环在 ${j=2}$ 时 , ${\cal z}$ 被放入背包。当 ${j=4}$ 时,寻求最大价值,物体 z 放入背包,  ${f(4)=max(f(4),f(2)+vz) }$ ,这里毫无疑问后者最大。但此时 ${f(2)+v_z}$ 中的 ${f(2)}$  已经装入了一次 ${\cal z}$ ,这样一来 ${\cal z}$ 被装入两次不符合要求, 如果逆序循环 j , 这一问题便解决了。

javascript 实现:

  1. function knapsack(weights, values, W){

  2.    var n = weights.length;

  3.    var f = new Array(W+1).fill(0)

  4.    for(var i = 0; i < n; i++) {

  5.        for(var j = W; j >= weights[i]; j--){  

  6.            f[j] = Math.max(f[j], f[j-weights[i]] +values[i]);

  7.        }

  8.        console.log(f.concat()) //调试

  9.    }

  10.    return f[W];

  11. }

  12. var b = knapsack([2,2,6,5,4],[6,3,5,4,6],10)

  13. console.log(b)

1.5 递归法解01背包

由于这不是动态规则的解法,大家多观察方程就理解了:

  1. function knapsack(n, W, weights, values, selected) {

  2.    if (n == 0 || W == 0) {

  3.        //当物品数量为0,或者背包容量为0时,最优解为0

  4.        return 0;

  5.    } else {

  6.        //从当前所剩物品的最后一个物品开始向前,逐个判断是否要添加到背包中

  7.        for (var i = n - 1; i >= 0; i--) {

  8.            //如果当前要判断的物品重量大于背包当前所剩的容量,那么就不选择这个物品

  9.            //在这种情况的最优解为f(n-1,C)

  10.            if (weights[i] > W) {

  11.                return knapsack(n - 1, W, weights, values, selected);

  12.            } else {

  13.                var a = knapsack(n - 1, W, weights, values, selected); //不选择物品i的情况下的最优解

  14.                var b = values[i] + knapsack(n - 1, W - weights[i], weights, values, selected); //选择物品i的情况下的最优解

  15.                //返回选择物品i和不选择物品i中最优解大的一个

  16.                if (a > b) {

  17.                    selected[i] = 0; //这种情况下表示物品i未被选取

  18.                    return a;

  19.                } else {

  20.                    selected[i] = 1; //物品i被选取

  21.                    return b;

  22.                }

  23.            }

  24.        }

  25.    }

  26. }        

  27. var selected = [], ws = [2,2,6,5,4], vs = [6,3,5,4,6]

  28. var b = knapsack( 5, 10, ws, vs, selected)

  29. console.log(b) //15

  30. selected.forEach(function(el,i){

  31.    if(el){

  32.        console.log("选择了物品"+i+ " 其重量为"+ ws[i]+" 其价值为"+vs[i])

  33.    }

  34. })

2. 完全背包问题

2.1 问题描述:

有 ${n}$ 件物品和 ${1}$ 个容量为W的背包。每种物品没有上限,第 ${i}$ 件物品的重量为 ${weights[i]}$ ,价值为 ${values[i]}$ ,求解将哪些物品装入背包可使价值总和最大。

2.2 问题分析:

最简单思路就是把完全背包拆分成01背包,就是把01背包中状态转移方程进行扩展,也就是说01背包只考虑放与不放进去两种情况,而完全背包要考虑 放0、放1、放2…的情况,

这个k当然不是无限的,它受背包的容量与单件物品的重量限制,即 ${j/weights[i]}$ 。假设我们只有1种商品,它的重量为20,背包的容量为60,那么它就应该放3个,在遍历时,就0、1、2、3地依次尝试。

程序需要求解 ${nW}$ 个状态,每一个状态需要的时间为 ${O(W/wi)}$ ,总的复杂度为 ${O(nWΣ(W/wi))}$ 。

我们再回顾01背包经典解法的核心代码

  1. for(var i = 0 ; i < n ; i++){

  2.   for(var j=0; j<=W; j++){

  3.       f[i][j] = Math.max(f[i-1][j], f[i-1][j-weights[i]]+values[i]))

  4.      }

  5.   }

  6. }

现在多了一个 k ,就意味着多了一重循环

  1. for(var i = 0 ; i < n ; i++){

  2.   for(var j=0; j<=W; j++){

  3.       for(var k = 0; k < j / weights[i]; k++){

  4.          f[i][j] = Math.max(f[i-1][j], f[i-1][j-k*weights[i]]+k*values[i]))

  5.        }

  6.      }

  7.   }

  8. }

javascript的完整实现:

  1. n completeKnapsack(weights, values, W){

  2.    var f = [], n = weights.length;

  3.    f[-1] = [] //初始化边界

  4.    for(var i = 0; i <= W; i++){

  5.        f[-1][i] = 0

  6.    }

  7.    for (var i = 0;i < n;i++){

  8.        f[i] = new Array(W+1)

  9.        for (var j = 0;j <= W;j++) {

  10.            f[i][j] = 0;

  11.            var bound = j / weights[i];

  12.            for (var k = 0;k <= bound;k++) {

  13.                f[i][j] = Math.max(f[i][j], f[i - 1][j - k * weights[i]] + k * values[i]);

  14.            }

  15.        }

  16.    }

  17.    return f[n-1][W];

  18. }

  19. //物品个数n = 3,背包容量为W = 5,则背包可以装下的最大价值为40.

  20. var a = completeKnapsack([3,2,2],[5,10,20], 5)

  21. console.log(a) //40

2.3 O(nW) 优化

我们再进行优化,改变一下f思路,让 ${f(i,j)}$ 表示出在前i种物品中选取若干件物品放入容量为 j 的背包所得的最大价值。

所以说,对于第 i 件物品有放或不放两种情况,而放的情况里又分为放1件、2件、…… ${j/w_i}$ 件。

如果不放, 那么 ${f(i,j)=f(i-1,j)}$ ;如果放,那么当前背包中应该出现至少一件第i种物品,所以 f(i,j) 中至少应该出现一件第 i 种物品,即 ${f(i,j)=f(i,j-wi)+vi}$ ,为什么会是 ${f(i,j-wi)+vi}$ ?

因为我们要把当前物品i放入包内,因为物品i可以无限使用,所以要用 ${f(i,j-wi)}$ ;如果我们用的是 ${f(i-1,j-wi)}$ , ${f(i-1,j-wi)}$ 的意思是说,我们只有一件当前物品 i ,所以我们在放入物品i的时候需要考虑到第 i-1 个物品的价值 ${f(i-1,j-wi)}$ ;但是现在我们有无限件当前物品 i ,我们不用再考虑第 i-1  个物品了,我们所要考虑的是在当前容量下是否再装入一个物品 i ,而 ${(j-w_i)}$ 的意思是指要确保 ${f(i,j)}$ 至少有一件第 i 件物品,所以要预留 c(i) 的空间来存放一件第 i 种物品。总而言之,如果放当前物品 i 的话,它的状态就是它自己”i”,而不是上一个”i-1”。

所以说状态转移方程为:

与01背包的相比,只是一点点不同,我们也不需要三重循环了

javascript 的完整实现:

  1. function unboundedKnapsack(weights, values, W) {

  2.    var f = [],

  3.        n = weights.length;

  4.    f[-1] = []; //初始化边界

  5.    for (let i = 0; i <= W; i++) {

  6.        f[-1][i] = 0;

  7.    }

  8.    for (let i = 0; i < n; i++) {

  9.        f[i] = [];

  10.        for (let j = 0; j <= W; j++) {

  11.            if (j < weights[i]) {

  12.                f[i][j] = f[i - 1][j];

  13.            } else {

  14.                f[i][j] = Math.max(f[i - 1][j], f[i][j - weights[i]] + values[i]);

  15.            }

  16.        }

  17.        console.log(f[i].concat());//调试

  18.    }

  19.    return f[n - 1][W];

  20. }

  21. var a = unboundedKnapsack([3, 2, 2], [5, 10, 20], 5); //输出40

  22. console.log(a);

  23. var b = unboundedKnapsack([2, 3, 4, 7], [1, 3, 5, 9], 10); //输出12

  24. console.log(b);

我们可以继续优化此算法,可以用一维数组写

我们用 ${f(j)}$ 表示当前可用体积j的价值,我们可以得到和01背包一样的递推式:

                                                                
  1. function unboundedKnapsack( weights, values, W) {

  2.     var n = weights .length ,

  3.    f = new Array( W + 1 ).fill (0 );

  4.     for(var i =0; i < n ; ++i ){

  5.         for(j = weights[ i]; j <= W; ++ j) {

  6.           var tmp = f[j -weights [i ]]+values [i ];

  7.          f [j] = (f [j ] > tmp ) ? f [j ] : tmp ;

  8.         }

  9.     }

  10.    console .log(f )//调试

  11.     return f [W];

  12. }

  13. var a = unboundedKnapsack([3 , 2, 2 ], [5 , 10, 20 ], 5); //输出40

  14. console .log(a );

  15. var b = unboundedKnapsack([2 , 3, 4 , 7], [ 1, 3 , 5, 9 ], 10); //输出12

  16. console .log(b );

3. 多重背包问题

3.1 问题描述:

有 ${n}$ 件物品和 ${1}$ 个容量为W的背包。每种物品最多有 numbers[i] 件可用,第 ${i}$ 件物品的重量为 ${weights[i]}$ ,价值为 ${values[i]}$ ,求解将哪些物品装入背包可使价值总和最大。

3.2 问题分析:

多重背包就是一个进化版完全背包。在我们做完全背包的第一个版本中,就是将它转换成01背包,然后限制 k 的循环

直接套用01背包的一维数组解法

  1. function knapsack(weights, values, numbers,  W){

  2.    var n = weights.length;

  3.    var f= new Array(W+1).fill(0)

  4.    for(var i = 0; i < n; i++) {

  5.        for(var k=0; k<numbers[i]; k++)//其实就是把这类物品展开,调用numbers[i]次01背包代码  

  6.         for(var j=W; j>=weights[i]; j--)//正常的01背包代码  

  7.             f[j]=Math.max(f[j],f[j-weights[i]]+values[i]);  

  8.    }

  9.    return f[W];

  10. }

  11. var b = knapsack([2,3,1 ],[2,3,4],[1,4,1],6)

  12. console.log(b)

定理:一个正整数 n 可以被分解成1,2,4,…,

3.3 使用二进制优化

其实说白了我们最朴素的多重背包做法是将有数量限制的相同物品看成多个不同的0-1背包。这样的时间复杂度为 ${O(WΣn(i))}$ , W 为空间容量 , n(i) 为每种背包的数量限制。如果这样会超时,我们就得考虑更优的拆分方法,由于拆成1太多了,我们考虑拆成二进制数,对于13的数量,我们拆成1,2,4,6(有个6是为了凑数)。 19 我们拆成1,2,4,8,4 (最后的4也是为了凑和为19)。经过这个样的拆分我们可以组合出任意的小于等于 n(i) 的数目(二进制啊,必然可以)。 j 极大程度缩减了等效为0-1背包时候的数量。 大概可以使时间复杂度缩减为 ${O(Wlog(ΣN(i))} $;2^(k-1),n-2^k+1(k是满足n-2^k+1>0的最大整数) 的形式,且 1~n 之内的所有整数均可以唯一表示成 1,2,4,…,2^(k-1),n-2^k+1 中某几个数的和的形式。

证明如下:

(1) 数列 1,2,4,…,2^(k-1),n-2^k+1 中所有元素的和为 n ,所以若干元素的和的范围为: [1, n] ;

(2)如果正整数 t<= 2^k – 1 ,则 t 一定能用 1,2,4,…,2^(k-1) 中某几个数的和表示,这个很容易证明:我们把t的二进制表示写出来,很明显, t 可以表示成  n=a02^0+a12^1+…+ak*2^(k-1) ,其中 ak=0 或者1,表示 t 的第 ak 位二进制数为0或者1.

(3)如果 t>=2^k ,设 s=n-2^k+1 ,则 t-s<=2^k-1 ,因而 t-s 可以表示成 1,2,4,…,2^(k-1) 中某几个数的和的形式,进而 t 可以表示成 1,2,4,…,2^(k-1) , s 中某几个数的和(加数中一定含有 s )的形式。

(证毕!)

  1. function mKnapsack(weights, values, numbers, W) {

  2.    var kind = 0; //新的物品种类

  3.    var ws = []; //新的物品重量

  4.    var vs = []; //新的物品价值

  5.    var n = weights.length;

  6.    /**

  7.     * 二进制分解

  8.     * 100=1+2+4+8+16+32+37,观察可以得出100以内任何一个数都可以由以上7个数选择组合得到,

  9.     * 所以对物品数目就不是从0都100遍历,而是0,1,2,4,8,16,32,37遍历,时间大大优化。

  10.     */

  11.    for (let i = 0; i < n; i++) {

  12.        var w = weights[i];

  13.        var v = values[i];

  14.        var num = numbers[i];

  15.        for (let j = 1; ; j *= 2) {

  16.            if (num >= j) {

  17.                ws[kind] = j * w;

  18.                vs[kind] = j * v;

  19.                num -= j;

  20.                kind++;

  21.            } else {

  22.                ws[kind] = num * w;

  23.                vs[kind] = num * v;

  24.                kind++;

  25.                break;

  26.            }

  27.        }

  28.    }

  29.    //01背包解法

  30.    var f = new Array(W + 1).fill(0);

  31.    for (let i = 0; i < kind; i++) {

  32.        for (let j = W; j >= ws[i]; j--) {

  33.            f[j] = Math.max(f[j], f[j - ws[i]] + vs[i]);

  34.        }

  35.    }

  36.    return f[W];

  37. }

  38. var b = mKnapsack([2,3,1 ],[2,3,4],[1,4,1],6)

  39. console.log(b) //9

总结

背包问题只是动态规则的一个常见问题,如果大家能通过背包悟出动态规则的真谛就最好不过。在现实开发中, 我们做搜索框的智能提示及表单快速滑动(元素的 diff )时就会用到它们,否则通过遍历这种经典的方式,性能是上不去的。

参考链接

http://www.ahathinking.com/archives/95.html http://www.hawstein.com/posts/f-knapsack.html http://blog.csdn.net/shuilan0066/article/details/7767082 http://blog.csdn.net/siyu1993/article/details/52858940 http://blog.csdn.net/Dr_Unknown/article/details/51275471 https://www.cnblogs.com/shimu/p/5667215.html http://www.saikr.com/t/2147 https://www.cnblogs.com/tgycoder/p/5329424.html http://blog.csdn.net/chuck001002004/article/details/50340819 https://www.cnblogs.com/favourmeng/archive/2012/09/07/2675580.html