动态规划之背包问题(一):01背包问题

01背包问题

无意间发现背包问题有那么多变种,决心花点时间钻研下。主要参考了博文“背包问题九讲”

1. 问题描述

有N件物品和一个容量为V 的背包。放入第i件物品花费的容量是Ci,得到的价值是Wi。求解将哪些物品装入背包可使价值总和最大。

2. 动态规划

动态规划解决问题的基本步骤,根据算法导论(第三版):

1. 描述最优解的结构
2. 递归定义最优解的值
3. 按自底向上的方式计算最优解的值
4. 由计算出的结果构造一个最优解

对于这四点,我是这么理解的:

1. “最优解的结构”我们可以理解为一个“状态函数”,每一个函数都有自己的自变量与变量,且这些变量都来自于问题本身。
2. 寻找这些状态函数的递推关系,而且是由小问题退出大问题的递推关系,称之为“状态转移函数”
3. 动态规划与分治法的一大区别就是对子问题的存储以及重复利用从而减少了计算量。所以利用状态转移函数,所有的状态的解存放在表里,自底向上地求出最终解。

应用于本问题:

先简单地回顾下:动态规划适合于解决有很多重复子问题的情况,分治法相当于把所有的问题重复性的计算,贪心算法必须保证局部最优=全局最优。现在我们确定本问题的状态函数:

本题中,有如下几个”变量”:每个物品的容量Ci,每个物品的价值Wi,背包装入物品之后的背包容量C,背包放入物品之后的背包价值W,已经放入背包中的物品个数n。注意到我们最终要解决的问题是:“将N件物品放入容量为V的背包中背包的最大价值”,所以建立的状态函数为:

W = F[ i ] [ v ]

其中,i代表可以被放入背包的前i个物品,v代表背包的容量。在i和v改变的过程中,其状态函数的值也在不断改变,确定出本题的状态转移函数如下:

F [i, v] = max{F [i − 1, v], F [i − 1, v − Ci] + Wi}

等号左边代表将前i个物品放入容量为v的背包中所能得到的最大价值。该问题可以分解为两种情况:

  1. 第i件物品被放入背包,则有 F [i, v] = F [i − 1, v − Ci] + Wi
  2. 第i件物品没有被放入,F [i, v] = F [i − 1, v]

由此可知,可以通过i递增的方式,由放入很少的物品的问题逐步推出放入很多物品的情况

3. 确定问题细节

虽然是“普通的01背包问题”,但是细究起来还是有如下几个分类:

  1. 是否需要我们确定出最优解情况下选择了哪几件物品
  2. 是否要求背包最终一定是被填满的状态

4. 二维数组实现

根据状态函数W = F[ i ] [ v ]可知,我们需要一个二维数组存放所有子问题的解,其伪代码如下

    F [0, 0..V ] ← 0

    for i ← 1 to N
        for v ← Ci to V
            F [i, v] ← max{F [i − 1, v], F [i − 1, v − Ci] + Wi}

循环逐层进行,每一层处理一个物品个数i,后一个添加进的物品i+1的所有子问题由上一层得到,时间与空间复杂度均为O(N*V)

二维数组的数据是一层一层生成的,所以其初始化仅需要对第一层,也就是F [0 , 0…V]进行即可

根据状态转移函数,每一层的数据都是由上一层的决定,所以F的初始化过程对于最终结果来讲非常重要。第一行的初始化数据代表了一种“初始正确的状态”,其值是否被正常地初始化直接决定了后面的结果是否正确。

说到这里,我们考虑下如何处理背包一定要填满的问题。先给出结论:

如果题目要求背包必须放满,那么 F [0] [0…V] 中仅仅有 F[0][0] 为0,其余值需被设置为-∞

利用反证法证明:

对于f[0][0…v],仅有f[1][c1]是正常填满的,f[1][c1]=f[0][0]+w1,而如果我们设置其他f[0][1…v]也为0,假设设置f[0][1]为0,那么有f[1][1+c1]=w1,也就是说把第一个物品放入容量为1+c1的背包中也是正常填满的,产生矛盾,所以这里设置其他的初始化值为负数。

另外,利用二维数组,我们可以保存所有的子问题,而根据状态转移函数,只有在第i件物品被放入的时候,才会发生价值的改变,我们计算出最终被放入的物品是哪几件。

5. 一维数组实现

也许您已经留意到了,既然结果的产生是逐行生成的,那么为了节省空间,在不需要确定选择了哪些物品的情况下。我们可以使用一维数组动态地储存每个i对应的情况。伪代码如下:

    F [0..V ] ←0

    for i ← 1 to N
        for v ← V to Ci
            F [v] ← max{F [v], F [v − Ci] + Wi}

注意到内层循环使用了逆序,这是因为根据状态转移函数,只有在逆序的情况下,我们才能保证求解F[ i ][ v ]的时候用到的子问题是F [ i-1 ] [ v ]而非F [ i ] [ v ]。

6. 一个常数优化

“背包问题九讲”中提到了一个空间复杂度优化:

上面伪代码中的

        for i ← 1 to N
            for v ← V to Ci

中第二重循环的下限可以改进。它可以被优化为

        for i ← 1 to N
            for v ← V to max(V − sum(Ci to CN), Ci)

这个优化之所以成立的原因请读者自己思考。(提示:使用二维的转移方程思考较易。)

根据状态转移函数:

F [v] = max{F [v], F [v − ci] + wi}

可知有两个约束条件:

  1. v-ci要大于等于0;
  2. F[v-ci]要存在

根据1,要满足 v>=ci
根据2,要满足 v-ci要被计算到

我们用sum(i,n)表示对c[]从第i个到最后一个物品容量的累加。
举例说明:

    n = 3;
    int c[]={3,4,5};
    int w[]={4,5,6};
    m = 10;//背包容量

外循环是i从小到大的顺序,依次求出最终我们所需的最大value,另外,每一层循环依次更新 f[ ]。

对于最终结果f[10]和物品个数i=3,我们仅仅需要知道i=2的时候的f[10-c[2]] = f[5]和f[5]之后的所有值即可,对于f[10-c[2]]和物品个数i=2,仅需要知道i=1的时候的f[5-c[1]]= f[10-c[2]-c[1]] = f[1]和其之后的所有值即可。

以此类推,对于f[m-sum(i+1,n)]物品个数1,仅仅需要知道f[m-sum(i,n)]和其之后的所有值即可。

在所有需要知道的值里面,在i=1时的f[m-sum(i,n)]是下标最小的一个,所以为了保证所有可能的求值都可以正常进行,需要保证j至少能取到j >= m-sum(i,n)

数组f长度为m+1,因为要存放0个物品的情况,存放第i个物品时,对应c[i-1],w[i-1]。

但是,这个求和会包含所有i和i以后的物品,并且这个值可能小于0。将前述条件j > c[i-1] 添加进来,两个条件一起取并集,于是有j>=max(m-sum(i,n),c[i-1])

7. 代码

/*
 * 一、普通的01背包问题
 * 
 * 问题描述:有N件物品和一个容量为V 的背包。放入第i件物品花费的容量是Ci,得到的价值是Wi。求解将哪些物品装入背包可使价值总和最大。
 * 
 * 关键点:每种物品只有一个,full代表是否要求背包填满。
 * 
 * 核心算法:利用状态转移函数:F [i, v] = max{F [i − 1, v], F [i − 1, v − Ci] + Wi} ,其中F [i, v] 代表将前i个物品放入容量为v的背包中能够得到的最大价值。
 */
public class Package01 {
    public static int MINIMUM = Integer.MIN_VALUE;

    public int max(int a,int b){
        return a>b?a:b;
    }

    /*
     * 版本1,使用一个二维数组储存F [i, v]
     * 优势:可以倒推出选取物品的路径
     * 缺点,二维数组实现,空间复杂度待提升。
     */
    public int[][] package_one(int[] c, int[] w, int m, boolean full){          //m代表背包的最大重量
        if(c.length!=w.length){System.out.println("数据有误");return null;}
        int n = c.length;
        /*
         *  二维数组的初始化,根据状态转移函数,f[1][0...m]是由f[0][0...m]得到,以此类推。所以二维数组的初始化就是f[0][1...m]的初始化
         *  f[0][1...m]代表着当需要放入的物品为0个,背包的容量为1到m的这m个子问题的初始值。很显然,根据状态转移函数,后面的每个子问题的结果就是由这m个初始值决定的。
         *  如果题目要求背包必须放满,那么f[0][0...m]中仅仅有f[0][0]为0,反证法:对于f[1][0,m],仅有f[1][c1]是正常填满的,f[1][c1]=f[0][0]+w1,而如果
         *  我们设置其他f[0][1...m]也为0,假设设置f[0][1]为0,那么有f[1][1+c1}=w1,也就是说把第一个物品放入容量为c1的背包中也是正常填满的,所以这里设置其他的初始化值为负数。
         */

        //起始位置f[0][0]代表将0个物品放入容量为0的背包。
        int[][] f = new int[n+1][m+1];
        if(full){
            for(int p=0;p<f.length;p++){
                for(int q=0;q<f[0].length;q++){
                    f[p][q] = MINIMUM;
                }
            }
            f[0][0] = 0;
        }
        // 外层循环:放入物品的数量。内层循环:背包容量。
        for(int i=1;i<=n;i++){
            for(int j=1;j<=m;j++){
                if(c[i-1]<=j){
                    f[i][j] = max(f[i-1][j],f[i-1][j-c[i-1]]+w[i-1]);
                }
                else f[i][j] = f[i-1][j];
            }
        }
        return f;
    }

    /*
     * 获取选取的物品的路径
     */
    public void getPath(int[][] f,int[] c,int[] w){
        int rows = f.length;
        int columns = f[0].length;
        boolean flag = true;
        int i = columns-1;
        int j = rows-1;
        while(flag){
            if(f[j][i]<0){
                System.out.println("无效的结果");
                flag=false;
            }
            else if(f[j][i]>f[j-1][i]){
                System.out.println("放入了第"+j+"个物品,其容量为:"+c[j-1]+"其价值为:"+w[j-1]);
                // 判断第j个物品被放入
                i -= c[j-1];
                j --;
            }
            else{
                // 判断第j个物品没有被放入,
                j --;
            }
            // 判断j如果等于0,结束循环
            if(j==0){
                flag = false;
            }
        }
    }

    /*
     *  版本2:使用一维数组
     *  注意到:双重for循环是逐行进行的,每一行处理前j个物品,而根据状态转移函数:F [i, v] = max{F [i − 1, v], F [i − 1, v − Ci] + Wi}
     *  每一行处理的数据来自于上一行数据的第v-ci个和第v个,所以当我们不需要选取的物品路径的时候,我们可以将其转化为空间复杂度。
     *  由于v-ci和v都小于等于v,所以循环从v到ci,由大到小进行。
     */
    public int[] package_two(int[] c,int[] w,int m,boolean full){
        if(c.length!=w.length){System.out.println("数据有误");return null;}
        int n = c.length;
        int f[] = new int[m+1];
        if(full){
            for(int i=0;i<m;i++){
                f[i] = MINIMUM;
            }
            f[0] = 0;
        }

//      for(int i=1;i<=n;i++){
//          for(int j=m;j>0;j--){
//              if(c[i-1]<=j)
//                  f[j] = max(f[j],f[j-c[i-1]]+w[i-1]);
//          }
//      }

        // 进一步的,上述可修改为:
//      for(int i=1;i<=n;i++){
//          for(int j=m;j>=c[i-1];j--){
//              f[j] = max(f[j],f[j-c[i-1]]+w[i-1]);
//          }
//      }

//       此外,还可以进一步地修改为:
//       原因:
//       根据状态转移函数:F [v] = max{F [v], F [v − ci] + Wi}
//       可知有两个约束条件:1. v-ci要大于等于0;       2. F[v-ci]要存在
//       所以根据1,要满足v>=ci
//       根据2,要满足要计算到v-ci,
//       我们用sum(i,n)表示对c[]从第i个到最后一个元素的累加。
//       举例说明: 外循环是i从小到大的顺序,依次求出最终我们所需的最大value,另外,每一层循环依次更新f[]。
//       对于我们的最终结果f[10]和物品个数i=3,我们仅仅需要知道i=2的时候的f[10-c[2]] = f[5]和f[5]之后的所有值即可,对于f[10-c[2]]和物品个数i=2,仅需要知道i=1的时候的f[5-c[1]]= f[10-c[2]-c[1]] = f[1]和其之后的所有值即可。
//       以此类推,对于f[m-sum(i+1,n)]物品个数i,我们仅仅需要知道f[m-sum(i,n)]和其之后的所有值即可。
//       在我们所有需要知道的值里面,在i=1时的f[m-sum(i,n)]是下标最小的一个,所以为了保证所有可能的求值都可以正常进行,需要保证j至少能取到j >= m-sum(i,n)
//       但是,这个求和会包含所有i和i以后的物品,并且这个值可能小于0。将前述条件j > c[i-1] 添加进来,两个条件一起取并集,于是有j>=max(m-sum(i,n),c[i-1])

        int sum = 0;
        for(int i=1;i<=n;i++){
            for(int j=i;j<=n;j++){
                sum += c[j-1];
            }
            for(int j=m;j>=max(m-sum,c[i-1]);j--){
                f[j] = max(f[j],f[j-c[i-1]]+w[i-1]);
            }
            sum = 0;
        }

        return f;
    }

    public static void main(String[] args){
         int c[]={3,4,5};
         int w[]={4,5,6};
         Package01 pack = new Package01();

         // 测试版本1
//       int f[][] = pack.package_one(c,w,10,false);
//       System.out.println(f[f.length-1][f[0].length-1]);
//       pack.getPath(f,c,w);
//       
         // 测试版本2
         int f2[] = pack.package_two(c, w, 10, false);
         for(int i=0;i<f2.length;i++){
             System.out.println(f2[i]);
         }
    }
}
  • 6
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值