深入思考0-1背包问题

来源:互联网 发布:四川省教师网络培训 编辑:程序博客网 时间:2024/06/08 06:05

问题描述:

  房间中有n件物品,每件物品具有固定的重量和价值,在背包总重量不超过W的情况下,如何组合物品使背包中物品总价值最大。

  为了便于表示,首先对问题进行形式化建模。集合S n ={i|i=0,1,...,n1}  表示可选取的物品集合,i 表示每件物品的编号,|S n |=n 表示物品数目。w i  v i  分别表示每件物品的重量和价值,W 表示背包承受重量的最大值。令D={B|B2 S n   iB w i W}  表示所有满足最大重量W 限制的方案集合,则最优选取方案可以表示为: F(S n ,W)=C ,其中CD,C    D iC v i  jC     v j  ,即集合C 是所有可能的选取方案中价值最大的。我们用fS n ,W= iF(S n ,W) v j  表示最优选择方案对应的最优值。

解题思路:有三种主要方法

1. 穷举法

这是是一个笨办法,但是对于问题规模小,并且不要求实时性的情况下,简单快捷。穷举法对应的Java代码如下:

public static int exhaustive(int[] weight,int[] value,int W){        int length=weight.length;        int maxValue=-1;            //遍历所有子集,用length个二进制位表示每一个物品是否被选中        int[] mask=new int[length];//掩码用于取出每一个二进制位        for(int k=0;k<length;k++){            mask[k]=0x01<<k;        }        for(int i=0;i<Math.pow(2, length);i++){            int sumWeight=0;            int sumValue=0;            for(int j=0;j<length;j++){                //当前物品被选中                if((i&mask[j])>0){                    sumWeight+=weight[j];                    sumValue+=value[j];                }            }            //重量限制与最优值更新            if(sumWeight<=W && sumValue>maxValue)                maxValue=sumValue;        }        return maxValue;    }

穷举法的输入规模是指数级别的,为2 |S n |  

2.整数规划

  整数规划是指一类要求问题中的全部或一部分变量为整数的数学规划,若在线性模型中,变量限制为整数,则称为整数线性规划。目前所流行的求解整数规划的方法往往只适用于整数线性规划。0-1背包问题是典型的整数线性规划问题。MATLAB中提供了intlinprog 函数用于求解整数规划问题,其对应的数学模型如下图所示(来自MATLAB帮助文档):

这里写图片描述

我们使用以下测试案例weight=<2,7,3,4,8,5,8,6,4,16>,value=<15,25,8,9,15,9,13,9,6,14>,W=34 ,整数规划数学模型为:x=<x 1 ,x 2 ,...,x 10 > 

x i ={ =0,i=1i  

min value T x such that{ weight T xW[0,0,0,0,0,0,0,0,0,0]x[1,1,1,1,1,1,1,1,1,1]  

相应的MATLAB整数规划程序为(我是用的版本为R2014b):

value=[15,25,8,9,15,9,13,9,6,14];weight=[2,7,3,4,8,5,8,6,4,16];W=34;intcon=1:10;lb=zeros(10);ub=ones(10);[x,maxvalue,exitflag,output]=intlinprog(-value,intcon,weight,W,[],[],lb,ub)

值得注意的是MATLAB中对于该测试案例求得的最优解为-87,因为intlinprog 函数只能求解极小值问题,故在求极大值时往往将原问题添加一个负号,转变为极小值问题。对于本题的最大值解应为87.


3.动态规划

  以上无论是穷举法还是整数规划,对于小规模的离线案例求解简洁明了,但是对于大规模的在线案例计算就显得力不从心。因此我们考虑使用动态规划方法来求解该问题。《算法导论》一书在动态规划原理一节中提到:某个问题是否适合应用动态规划算法,它是否具有最优子结构性质是一个好线索。所谓最优子结构,是指一个问题的最优解包含其子问题的最优解。回头开始的问题描述中的定义,我们尝试自顶向下缩减问题的规模。

  令子问题为S n1 =S n {k},kS n  ,即从当前可以选择的物品集合S n  中随便去掉一样k ,这时分两种情况讨论:

  a.若kF(S n ,W) ,则子问题最优解和原问题最优解具有关系:
F(S n1 ,Ww k )=F(S n ,W){k}   (1) 
f(S n1 ,Ww k )=f(S n ,W)v k    (2) 

  即如果去掉的物品k 在原问题的最优解中,则原问题最优解去掉k 后是子问题在Ww k  重量限制下的最优解。可以用反证法证明该结论。如果F(S n ,W){k} 不是子问题的最优解,则子问题的最优解F(S n1 ,Ww k ) 将比它大,这时将k 再放入背包中,得到的解将比F(S n ,W) 大,这与F(S n ,W) 是最优解相矛盾,因此公式(1) 成立。

  b.若kF(S n ,W) ,则子问题最优解和原问题最优解具有关系:
F(S n1 ,W)=F(S n ,W)   (3) 
f(S n1 ,W)=f(S n ,W)   (4) 
  即如果去掉的物品k 如果不在原问题的最优解中,则原问题的最优解和子问题在W 重量限制下的最优解相同。证明过程和上面类似,这里不再赘述。

3.1减治递归算法

  根据上面的四个公式,我们可以很容易地写出背包问题的递归算法。采用自顶向下的求解思路,将原问题中的元素依次移除,将原问题的最优解表达为子问题的最优解形式,直到物品集中不再有任何物品。然后再一一归纳得出上层问题的最优解。背包问题的递归算法Java程序如下:

/**     * 使用减治法求解01背包问题,要求weight数组升序排列     * @param weight     * @param value     * @param startIndex 子问题的起始下标,表示当前子问题范围为startIndex到length-1     * @param W 背包最大重量限制     * @return     */    public static int recursive(int[] weight,int[] value,int startIndex,int W){        int length=weight.length;        //递归出口,如果子问题只剩最后一个元素        if(startIndex==length-1){            if(weight[startIndex]<=W)                return value[startIndex];            else                return 0;        }        //情况a,startIndex在原题最优解中        int result1=0;        if(W-weight[startIndex]>=0){            result1=recursive(weight, value, startIndex+1, W-weight[startIndex])+value[startIndex];        }        //情况b,startIndex不在原问题最优解中        int result2=recursive(weight, value, startIndex+1, W);        //返回两种可能中的较大结果        return result1>result2?result1:result2;    }

  上面的程序有三点需要说明。第一,公式(1)(2)(3)(4) 中的k 是任意取的,它对每一次考虑任何物品都是成立的,程序中为了简便依次移除了第01...n1 件物品来缩减问题规模。第二,程序中的递推式是将原问题的最优解放在等号左边,而公式(1)(2)(3)(4) 则是将原问题的最优解放在等号右边,二者是一样的。第三,程序中情况a加入了限制条件:Ww k 0 ,这是因为如果物品k 的重量都已经超过背包重量的限制,那么k 必然不属于原问题最优方案。

  背包问题递归算法复杂度分析:背包问题的递归算法流程就像细胞分裂,每一个问题都分成了两种情况,对应两个子问题来求解。每取走一个物品就分裂成两个子问题,问题规模减1。因此该递归算法的时间复杂度应该是指数级别的,为2 n  n 为物品个数。可以发现,递归算法的效率并不高,和穷举方法接近。

3.2带备忘录的递归算法

  到这里我们得到的递归算法仍然不尽如人意,它具有指数规模的复杂度。但是如果仔细分析所有的子问题,你会发现,有些子问题是相同的,这就造成了大量的重复计算。子问题重叠是动态规划问题的另一个重要特征。

  在前面的描述中,我们通过每次移除一个物品(即k )来缩减问题规模。我们考虑只含有五件物品的原问题集合S 5 ={0,1,2,3,4} 。对应于上面的递归算法,它将按照如下流程得到四个规模依次递减的问题集:考虑取走物品0 ,得到S 4 ={1,2,3,4} ;然后考虑取走物品1 ,得到S 3 ={2,3,4} ;接着考虑取走物品2 ,得到S 2 ={3,4} ,最后考虑取走物品3 ,得到S 1 ={4} 。一个背包问题由其可取物品集合和背包重量限制W 确定。刚刚我们只考虑了物品集,现在将重量限制W 考虑在内。

  从原问题S 5  S 4  将产生两个对于S 4  的背包重量限制WWw 0  ,同理S 3  之上将产生2 2  个重量限制问题,S 2  上将产生2 3  个重量限制问题。依此类推S 1  上将产生2 n1 =2 4 =16 个重量限制问题。但是仔细思考一下,如果对于原问题W=10 ,也就意味着对于每一个规模集合,最多也就产生重量限制在010 之间的W+1 个子问题,而不是2 n1  个子问。因此我们可以使用一个二维数组记录下每一个已经求解过的子问题,当碰到相同的子问题时直接查表即可。记录数组的横坐标表示问题的规模,纵坐标表示背包重量限制,数组的大小对应着求解复杂度,因此带备忘录的递归算法复杂度为O(nW) ,相较于前面的指数复杂度已经大为改善。

带备忘录递归算法的Java程序如下:

public static int recursive(int[] weight,int[] value,int startIndex,int W,int[][] memo){        int length=weight.length;        //如果子问题只剩最后一个元素        if(startIndex==length-1){            if(weight[startIndex]<=W)                return value[startIndex];            else                return 0;        }    //情况1,startIndex在原题最优解中        int result1=0;        //如果子问题已经被计算,则不必重复求解        int W1=W-weight[startIndex];        if(W1>=0){            if(memo[startIndex+1][W1]>=0)                result1=memo[startIndex][W1]+value[startIndex];            else                result1=recursive(weight, value, startIndex+1, W-weight[startIndex],memo)+value[startIndex];        }    //情况2,startIndex不在原问题最优解中        int result2=0;        //如果子问题已经被计算,则不必重复求解        if(memo[startIndex+1][W]>=0)            result2=memo[startIndex][W];        else            result2=recursive(weight, value, startIndex+1, W);        //记录并返回两种可能中的较大结果        int max=result1>result2?result1:result2;        memo[startIndex][W]=max;        return max;    }    public static int recursiveWithMemo(int[] weight,int[] value,int W){        int length=weight.length;        int[][] memo=new int[length][W+1];        //初始化备忘录        for(int i=0;i<length;i++){            for(int j=0;j<W+1;j++){                memo[i][j]=-1;            }        }        return recursive(weight, value, 0, W, memo);        }

3.3自底向上动态规划方法

  自顶向下的带备忘录的递归方法考虑起来比较容易,但是递归程序不容易调试而且在有些项目中不允许使用递归程序,因此我们需要采用自底向上的方法,先考虑最小规模问题,然后再慢慢增大问题规模直到解决原问题。

  我们从S 1 ={4} 开始,依次向物品集中加入物品3、2、1、0。我们用表格来表示计算过程如下(此案例来自博客:动态规划之01背包问题(最易理解的讲解))

  首先当只有物品4时,我们可以很容易地判断当前问题的最优解。当w 4 W 时,最优解包含物品4,否则最优解为空集。蓝色的一行每一格的内容分别表示当前最优解集合,最优解集合的总重量和最优解对应的最优价值。

这里写图片描述

  紧接着考虑问题S 2 ={4,3} ,分两种情况讨论。

  a.物品3不在相应的最优解集合中,此时S 2  的最优解和上一步求得的S 1  最优解完全相同,如下图中红色的部分所示。

这里写图片描述

  b.物品3在相应的最优集合中,此时按照公式(1)(2) 计算S 2  的最优解。对应下图中的绿色部分。图中红色的连线表明其和S 1  的最优解的对应关系。

这里写图片描述

  然后综合比较a、b两种情况下求得的最优解集合,对于每个单元格取最大值作为其最终最优解。如下图所示。

这里写图片描述

  重复以上步骤,当所有物品都被加入到候选集中时,即可求得原问题的最优解。最终结果如下图所示:

这里写图片描述

使用Java编写的01背包问题的动态规划算法如下:

public static int dynamic(int[] weight,int[] value,int W){        int length=weight.length;        int[][] result=new int[length][W+1];        for(int i=length-1;i>=0;i--){            for(int j=0;j<W+1;j++){                if(i==length-1){//对于刚开始只有一个物品的问题                    if(weight[i]<=j)                        result[i][j]=value[i];                    else                        result[i][j]=0;                }else{//当加入物品超过1个                    //情况a                    int a=result[i+1][j];                    //情况b                    int b=0;                    int W2=j-weight[i];                    //子问题的重量限制必须大于等于0                    if(W2>=0)                        b=result[i+1][W2]+value[i];                    //取两种情况下的最大值作为当前问题的解                    result[i][j]=(a>=b?a:b);                }            }        }        return result[0][W];    }

从表格分析过程可知,该动态规划算法的复杂度为O(nW) .

总结

  本文由浅入深讨论了01背包问题的不同解决思路与相应的方法效率、适用范围等特点,并一一给出了相应的示例程序。着重介绍了动态规划方法的详细思路,重点展示了如何构造最优子结构与如何使用带备忘录的自顶向下方法与自底向上方法解决子问题重叠。但是文中也存在语言过于书面化,结构零散等缺点,希望大家批评指正。

参考资料

《算法导论》