动态规划问题1——01背包

90 阅读5分钟

从斐波那契数列说起

斐波那契数列(Fibonacci sequence),对于n(n>=0),n=0时,f(0)=0; n=1时,f(1)=1;n=2时,f(2)=f(0)+f(1);n=3时,f(2)=f(1)+f(2);以此类推...求解f(n)。 最早 ,我们可能在课本上学过,这是个经典的递归问题,于是,能很很快写出递归的代码:

    public int fib(int N) {
        if (N == 0) {
            return 0;
        }
        if (N == 1) {
            return 1;
        }
        return fib(N - 1) + fib(N - 2);
    }

然而,我们观察,在递归中,例如计算f(5),需要先计算f(4),f(3),f(2);计算f(6),又要去计算f(5),f(4),f(3),f(2)...其中有些计算过的值,会在递归中被反复计算。这个方法的时间复杂度为O(2^n)。显然这并不是个快速的算法。

经过观察,我们发现:f(n)=f(n-1)+f(n-2);

我们可以使用一个数组,来记录一些被计算过的值。

public int fib(int n) {
       if (n == 0) {
           return 0;
       }
       if (n == 1) {
           return 1;
       }
       int[] dp = new int[n + 1];
       dp[0] = 0;
       dp[1] = 1;
       for (int i = 2; i <= n; i++) {
           dp[i] = dp[i - 1] + dp[i - 2];
       }
       return dp[n];
   }

上述代码,下一个的值,依赖于前面两个的值,经过初始状态对0和1 的初始化之后,后面的值都会根据前面的依次被生成。这段代码时间复杂度为O(n)。 从这个简单的代码来看,我们先对一些前面的值进行的初始化操作,然后,使用了一个中间数组来存储前面计算的一些值,在计算后续值时,根据状态转移方程,来直接生成后续值。这里先体会下使用动态规划的基本思路。

一般对于dp问题,我们使用几维数组,可以简单看问题中的转态改变量,例如,在下面的01背包问题中,我们在把物品放入背包时候,改变的有物品的状态,背包的容量;所以我们使用一个二维的数组即可。这个小诀窍是我再lc上刷路径问题的时候发现的,根据DFS方法进行转换出来的,有兴趣的可以去lc上看一下那个路径问题的学习。

0-1背包问题解法

有C个物品,一个容量为V的背包,物品的价值数组为v,物品的重量数组为w,求解该背包可放置物品的最大价值。

首先来看物品的状态,一个物品有两种状态:

  • 背包容量足够,该物品能被放入背包;则该物品可以选择放入背包也可以选择不被放入背包;
  • 背包容量不足以放入该物品;

我们定义dp数组:dp[物品][重量].

对于动态规划的问题,除了要确定状态的转移,状态转移方程,对于数组的初始化和结束的处理,也是相当重要。

对于01背包的问题,在初始化时候,我们要考虑物品仅有第一个的情况,和背包可容纳为0的情况。

  • 先来看简单的背包为0的情况,在这种情况下,任何物品都放不进去,所以最大价值为0.
  • 然后是只有一个物品往包里放的情况,对于从[0,V]的容量的背包,当第一个物品的重量小于等于背包容量时候,才能被放进去,这时候,能取到第一个物品的价值。但是当第一个物品放不进去背包时候,能取到的最大价值还是0.

之后,我们确定状态转移方程,对于dp[物品][重量], 状态转移方程 : dp[i][fv] = max( dp[i-1][fv] , dp[i-1][fv-w[i]]+ v[i] )

  • dp[i-1][fv] :物品不能放入背包,只能继承之前的最大值。
  • dp[i-1][fv-w[i]]+ v[i] : 物品能放入背包,能放入的前提条件是fv - w[i] >= 0

如果还不太理解,可以自己根据这个二维数组画个表格看下。 如果还不能理解dp问题的状态,状态在转移,建议先去lc上把路径问题和动态规划的easy题目刷完,先找找感觉。

/**
    * @param V 最大容量
    * @param C 物品个数
    * @param v 价值数组
    * @param w 重量数组
    * @return
    */
   public int getMaxValue(int V, int C, int[] v, int[] w) {
       int[][] dp = new int[C][V + 1]; //[物品][重量]
       //初始化起始数据
       for (int fv = 0; fv <= V; fv++) {
           //初始化只有一件物品时候,如果能放入背包,则能取到此物品价值
           if (w[0] <= fv) {
               dp[0][fv] = v[0];
           }
       }
       for (int i = 1; i < C; i++) {
           for (int fv = 0; fv <= V; fv++) {
               //状态转移方程 : dp[i][fv]   =  max( dp[i-1][fv]  ,  dp[i-1][fv-w[i]]+ v[i] )
               if (fv - w[i] >= 0) {
                   //该物品大于剩余容量,可以放入背包,此时比较放入该物品与不放该物品的值
                   dp[i][fv] = Math.max(dp[i - 1][fv], dp[i - 1][fv - w[i]] + v[i]);
               } else {
                   //改物品不能放入背包,直接取不放该物品的值
                   dp[i][fv] = dp[i - 1][fv];
               }
           }
       }
       return dp[C - 1][V];
   }


   @Test
   public void test11() {
       int V = 17;
       int C = 5;
       int[] v = {4, 5, 10, 11, 13};
       int[] w = {3, 4, 7, 8, 9};
       System.out.println(getMaxValue(V, C, v, w)); //24
   }

使用滚动数组进行空间优化

上面我们解dp问题的时候,用了个二维数组,空间复杂度为O(n2)。下面我们来使用滚动数组,来对空间复杂度进行优化。

下面还是先用斐波那契的dp解法做demo.在斐波那契数列中,当前值等于前一个的值加上前前那个的值,我们之前在dp解法中,使用了长度为n+1的数组来保存之前的结果,但是我们发现,其实我们字实际运算中,只需要保存前两个值即可,我们下面对之前的解法进行下空间优化。

    public int fib(int n) {
       if (n == 0) {
           return 0;
       }
       if (n == 1) {
           return 1;
       }
       int[] dp = new int[2];
       dp[1] = 1;
       for (int i = 2; i <= n; i++) {
           int current = dp[0] + dp[1];
           dp[0] = dp[1];
           dp[1] = current;
       }
       return dp[1];
   }

优化之后,斐波那契数列的空间复杂度由O(N)变为O(1)了。

  • 其实上面在优化的时候,我们就已经使用到了滚动数组。下面来看下滚动数组的作用: 滚动数组是一种能够在动态规划中降低空间复杂度的方法,有时某些二维dp方程可以直接降阶到一维,在某些题目中甚至可以降低时间复杂度,是一种极为巧妙的思想。简要来说就是通过观察dp方程来判断需要使用哪些数据,可以抛弃哪些数据,一旦找到关系,就可以用新的数据不断覆盖旧的数据量来减少空间的使用。

观察背包问题的状态转移方程:dp[i][fv] = max( dp[i-1][fv] , dp[i-1][fv-w[i]]+ v[i] ) ,dp[i][fv]的值,只跟上一行中的上一个值dp[i-1][fv] dp[i-1][fv] 左边的一个值dp[i-1][fv-w[i]]+ v[i] )有关系。其他值我们可以像优化斐波那契数列那样,把用不到的空间优化掉。

粗略一看,其实我们在代码过程中,使用到的只有当前行和上一行 其他行我们可以不要,值使用两行保存运算值。

public int getMaxValueUsingArray(int V, int C, int[] v, int[] w) {
       int[][] dp = new int[2][V + 1]; //[物品][重量]
       //初始化起始数据
       for (int fv = 0; fv <= V; fv++) {
           //初始化只有一件物品时候,如果能放入背包,则能取到此物品价值
           if (w[0] <= fv) {
               dp[0][fv] = v[0];
           }
       }
       for (int i = 1; i < C; i++) {
           for (int fv = 0; fv <= V; fv++) {
               //状态转移方程 : dp[i][fv]   =  max( dp[i-1][fv]  ,  dp[i-1][fv-w[i]]+ v[i] )
               if (fv - w[i] >= 0) {
                   //该物品大于剩余容量,可以放入背包,此时比较放入该物品与不放该物品的值
                   dp[i & 1][fv] = Math.max(dp[(i - 1) & 1][fv], dp[(i - 1) & 1][fv - w[i]] + v[i]);
               } else {
                   //改物品不能放入背包,直接取不放该物品的值
                   dp[i & 1][fv] = dp[(i - 1) & 1][fv];
               }
           }
       }
       return dp[(C - 1) & 1][V];
   }

经过这次优化,我们把空间复杂度从O(n2)降低到了O(n).

目前我们只需使用两行长度为V + 1的数组,就能保存我们的中间运算结果,并能进行后面的计算。下面我们继续来对空间进行优化,使用一行V + 1的数组,来保存运算结果。思路是:我们需要 dp[i-1][fv] 左边的一个值dp[i-1][fv-w[i]]+ v[i] ),对于dp[i-1][fv] 我们可以直接保存在dp[fv]中,对于dp[i-1][fv-w[i]]+ v[i] ),我们使用dp[fv-w[i]]+ v[i] )

    public int getMaxValueUsingArray2(int V, int C, int[] v, int[] w) {
        int[] dp = new int[V + 1]; //[重量],表示此重量时候能获得的最大价值
        //初始化起始数据
        Arrays.fill(dp, 0);
        for (int i = 0; i < C; i++) { //物品
            for (int fv = V; fv >= w[i]; fv--) { //重量
                //从后往前遍历,物品不会被重复放置
                dp[fv] = Math.max(dp[fv], dp[fv - w[i]] + v[i]);
            }
        }
        return dp[V];
    }

首先先来对照代码看示例:

       // 价值 v :  1    3    4
       // 重量 w : 15   20   30
       // 背包容量:4

       // i :   0
       // fv : 4     3     2     1    0
       // dp:  15    15    15    15    0

       // i :    1
       // fv :  4     3     2     1    0
       // dp:   35    20    15    15   0

       // i :   2
       // fv : 4     3     2     1    0
       // dp:  35    20    15    15    0

遍历过程还是外层是物品,内层是重量。只不过这次重量是从大到小遍历的,这样做是为了保证遍历过程中,物品不会被重复放置。

下面来看为什么会重复:


        // i :   0
        // fv : 0     1     2     3    4
        // dp:   0    15    *   

如上 * 号位置,如果正序遍历,计算到 * 号位置时候,dp[2]=max(dp[1], dp[2-1] + v[0]),其中dp[1]=15,背包里面此时是第一个物品的价值; dp[2-1] + v[0] = dp[1]+v[0],这时候,你会发现,物品1被加了两次,这是不正确的。 对比使用两行数组时候,计算行的值,依赖于另一行中左侧的值,即小背包的最大价值。如果背包容量从小开始到大的话,物品被放入之后,在中间计算过程,还有可能有足够容量放置该物品,则它可能被再次放入。而背包重量从大到小去遍历,则没有这个问题,能保证物品只会被放入一次。

01背包小结

  • 注意dp数组的初始化值,初始化是dp问题开始的起点。另外,同样也要注意,结束时候,该如何取dp数组的值。
  • 找对状态转移方程。这是dp问题的关键,在一开始时候,可能我们有点儿感觉,这是个dp问题,但是不知道如何dp,这时候要多想想,回顾下你做过的dp问题。
  • dp问题的解数组,是根据dp问题的变化量来的,但是可以使用滚动数组优化空间使用。优化的前提是,你非常了解dp过程中,你使用到了哪些空间,哪些空间又是你使用不到的。