动态规划之背包问题

8 篇文章 7 订阅

前言

此篇博客主要讲常见的三种背包问题,0-1背包,完全背包,多重背包。为了加深理解,每个问题都有leetcode实例。

1. 0-1背包问题

1.1 题目

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

1.2 基本思路

这是最基础的背包问题,特点是:每种物品仅有一件,可以选择放或不 放。 用子问题定义状态:即F[i,v]表示前i件物品恰放入一个容量为v的背包可以 获得的最大价值。则其状态转移方程便是:

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

这个方程非常重要,基本上所有跟背包相关的问题的方程都是由它衍生 出来的。所以有必要将它详细解释一下:“将前i件物品放入容量为v的背包中”这个子问题,若只考虑第i件物品的策略(放或不放),那么就可以转化 为一个只和前i−1件物品相关的问题。如果不放第i件物品,那么问题就转化 为“前i−1件物品放入容量为v的背包中”,价值为F[i−1,v];如果放第i件物 品,那么问题就转化为“前i−1件物品放入剩下的容量为v −Ci的背包中”, 此时能获得的最大价值就是F[i−1,v −Ci]再加上通过放入第i件物品获得的价 值Wi。 伪代码如下:

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}

1.3 例题: leetcode 416. 分割等和子集

给定一个只包含正整数的非空数组。是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
注意:
每个数组中的元素不会超过 100
数组的大小不会超过 200
示例 1:
输入: [1, 5, 11, 5]
输出: true
解释: 数组可以分割成 [1, 5, 5] 和 [11].

0-1 背包问题也是最基础的背包问题,它的特点是:待挑选的物品有且仅有一个,可以选择也可以不选择。下面我们定义状态,不妨就用问题的问法定义状态试试看。

dp[i][j]:表示从数组的 [0, i] 这个子区间内挑选一些正整数,每个数只能用一次,使得这些数的和等于 j。

根据我们学习的 0-1 背包问题的状态转移推导过程,新来一个数,例如是 nums[i],根据这个数可能选择也可能不被选择:

如果不选择 nums[i],在 [0, i - 1] 这个子区间内已经有一部分元素,使得它们的和为 j ,那么 dp[i][j] = true;

如果选择 nums[i],在 [0, i - 1] 这个子区间内就得找到一部分元素,使得它们的和为 j - nums[i] ,我既然这样写出来了,你就应该知道,这里讨论的前提条件是 nums[i] <= j。
以上二者成立一条都行。于是得到状态转移方程是:

dp[i][j] = dp[i - 1][j] or dp[i - 1][j - nums[i]], (nums[i] <= j)

于是按照 0-1 背包问题的模板,我们不难写出以下代码。

参考代码:

public class Solution {
    public boolean canPartition(int[] nums) {
        int size = nums.length;

        int s = 0;
        for (int num : nums) {
            s += num;
        }

        // 特判 2:如果是奇数,就不符合要求
        if ((s & 1) == 1) {
            return false;
        }

        int target = s / 2;

        // 创建二维状态数组,行:物品索引,列:容量
        boolean[][] dp = new boolean[size][target + 1];
        // 先写第 1 行
        for (int i = 1; i < target + 1; i++) {
            if (nums[0] == i) {
                dp[0][i] = true;
            }
        }
        for (int i = 1; i < size; i++) {
            for (int j = 0; j < target + 1; j++) {
                dp[i][j] = dp[i - 1][j];
                if (j >= nums[i]) {
                    dp[i][j] = dp[i - 1][j] || dp[i - 1][j - nums[i]];
                }
            }
        }
        return dp[size - 1][target];
    }
}

这也是0-1背包问题的常规解法。

1.4 空间复杂度优化

根据我们学习过的 0-1 背包问题的经验,在填 dp 数组的时候,从第 2 行开始,每一行都参考了前一行的值,因此状态数组从可以从二维降到一维,从而减少空间复杂度。

注意:从第 2 行开始,每一行都参考了前一行的当前位置的值,并且还参考了前一行的小于当前位置的值。

public class Solution {

    public boolean canPartition(int[] nums) {
        int size = nums.length;
        
        int s = 0;
        for (int num : nums) {
            s += num;
        }
        if ((s & 1) == 1) {
            return false;
        }

        int target = s / 2;

        // 从第 2 行以后,当前行的结果参考了上一行的结果,因此使用一维数组定义状态就可以了
        boolean[] dp = new boolean[target + 1];
        // 先写第 1 行,看看第 1 个数是不是能够刚好填满容量为 target
        for (int j = 1; j < target + 1; j++) {
            if (nums[0] == j) {
                dp[j] = true;
                // 如果等于,后面就不用做判断了,因为 j 会越来越大,肯定不等于 nums[0]
                break;
            }
        }
        // 注意:因为后面的参考了前面的,我们从后向前填写
        for (int i = 1; i < size; i++) {
            // 后面的容量越来越小,因此没有必要再判断了,退出当前循环
            for (int j = target; j >= 0 && j >= nums[i]; j--) {
                dp[j] = dp[j] || dp[j - nums[i]];
            }
        }
        return dp[target];
    }
}

2. 完全背包问题

2.1 题目

有N种物品和一个容量为V 的背包,每种物品都有无限件可用。放入第i种 物品的耗费的空间是Ci,得到的价值是Wi。求解:将哪些物品装入背包,可使 这些物品的耗费的空间总和不超过背包容量,且价值总和最大。

2.2 基本思路

这个问题非常类似于01背包问题,所不同的是每种物品有无限件。也就是从 每种物品的角度考虑,与它相关的策略已并非取或不取两种,而是有取0件、 取1件、取2件……直至取⌊V /Ci⌋件等很多种。如果仍然按照解01背包时的思路,令F[i,v]表示前i种物品恰放入一个容量 为v的背包的最大权值。仍然可以按照每种物品不同的策略写出状态转移方 程,像这样:
F[i,v] = max{F[i−1,v−kCi] + kWi |0 ≤ kCi ≤ v}
这跟01背包问题一样有O(V N)个状态需要求解,但求解每个状态的时 间已经不是常数了,求解状态F[i,v]的时间是O( v Ci ),总的复杂度可以认为 是O(NV Σ V Ci ),是比较大的。 将01背包问题的基本思路加以改进,得到了这样一个清晰的方法。这说明01 背包问题的方程的确是很重要,可以推及其它类型的背包问题。但我们还是要 试图改进这个复杂度。

2.3 例题:leetcode 518. 零钱兑换 II

给定不同面额的硬币和一个总金额。写出函数来计算可以凑成总金额的硬币组合数。假设每一种面额的硬币有无限个。 
示例 1:
输入: amount = 5, coins = [1, 2, 5]
输出: 4
解释: 有四种方式可以凑成总金额:
5=5
5=2+2+1
5=2+1+1+1
5=1+1+1+1+1

此题是个典型的完全背包问题,思路:
dp[i][j] 表示 :用coins的前i个元素,加出j有多少种方法
用coins的前i个元素,加出j有多少种方法

参考代码:

    public int change(int amount, int[] coins) {
        if (amount < 0 || coins == null  || coins.length == 0) {
           return amount == 0 ? 1 : 0;
        }
        //dp[i][j]: [0~i]中加出j,有多少种方法
        int[][] dp = new int[coins.length][amount + 1];
        dp[0][0] = 1;
        for (int j = 1; j < amount + 1; ++j) {
            dp[0][j] = j % coins[0] == 0 ? 1 : 0;
        }
        for (int i = 1; i < coins.length; ++i) {
            dp[i][0] = 1;
            for (int j = 1; j < amount + 1; ++j) {
                dp[i][j] = dp[i - 1][j] + (j - coins[i] >= 0 ? dp[i][j - coins[i]] : 0);
            }
        }
        return dp[coins.length - 1][amount];
    }

2.4 空间复杂度优化

    public int change(int amount, int[] coins) {
        int[] dp = new int[amount + 1];
        dp[0] = 1;
        for (int i = 1; i < coins.length + 1; ++i) {
            for (int j = coins[i - 1]; j < amount + 1; ++j) {
                dp[j] += dp[j - coins[i - 1]];
            }
        }
        return dp[amount];
    }

完全背包问题的解决方法大体和0-1背包问题类似。

3. 多重背包问题

3.1 题目

有N种物品和一个容量为V 的背包。第i种物品最多有Mi件可用,每件耗费 的空间是Ci,价值是Wi。求解将哪些物品装入背包可使这些物品的耗费的空间 总和不超过背包容量,且价值总和最大。

3.2 基本算法

这题目和完全背包问题很类似。基本的方程只需将完全背包问题的方程略 微一改即可。 因为对于第i种物品有Mi+1种策略:取0件,取1件……取Mi件。令F[i,v]表示前i种物品恰放入一个容量为v的背包的最大价值,则有状态转移方程:
F[i,v] = max{F[i−1,v−k∗Ci] + k∗Wi |0 ≤ k ≤ Mi} 复杂度是O(V ΣMi)。

3.3 转化为01背包问题

另一种好想好写的基本方法是转化为01背包求解:把第i种物品换成Mi件01 背包中的物品,则得到了物品数为ΣMi的01背包问题。直接求解之,复杂度仍 然是O(V ΣMi)。 但是我们期望将它转化为01背包问题之后,能够像完全背包一样降低复杂 度。 仍然考虑二进制的思想,我们考虑把第i种物品换成若干件物品,使得原问 题中第i种物品可取的每种策略——取0…Mi件——均能等价于取若干件代换以后的物品。另外,取超过Mi件的策略必不能出现。 方法是:将第i种物品分成若干件01背包中的物品,其中每件物品有一个系 数。 这件物品的费用和价值均是原来的费用和价值乘以这个系数。令这些系数 分别为1,2,22 …2k−1,Mi −2k + 1,且k是满足Mi −2k + 1 > 0的最大整数。例 如,如果Mi为13,则相应的k = 3,这种最多取13件的物品应被分成系数分别 为1,2,4,6的四件物品。 分成的这几件物品的系数和为Mi,表明不可能取多于Mi件的第i种物品。另 外这种方法也能保证对于0…Mi间的每一个整数,均可以用若干个系数的和表 示。这里算法正确性的证明可以分0…2k−1和2k …Mi两段来分别讨论得出, 希望读者自己思考尝试一下。 这样就将第i种物品分成了O(logMi)种物品,将原问题转化为了复杂度 为O(V ΣlogMi)的01背包问题,是很大的改进。 下面给出O(logM)时间处理一件多重背包中物品的过程:

def MultiplePack(F,C,W,M) 
  if C ·M ≥ V 
    CompletePack(F,C,W) 
    return 
  k := 1 
  while k < M 
    ZeroOnePack(kC,kW)
    M := M −k 
    k := 2k 
  ZeroOnePack(C ·M,W ·M) 

希望你仔细体会这个伪代码,如果不太理解的话,不妨翻译成程序代码以后, 单步执行几次,或者头脑加纸笔模拟一下,以加深理解。

3.4 例题:leetcode 474. 一和零

在计算机界中,我们总是追求用有限的资源获取最大的收益。
现在,假设你分别支配着 m 个 0 和 n 个 1。另外,还有一个仅包含 0 和 1 字符串的数组。
你的任务是使用给定的 m 个 0 和 n 个 1 ,找到能拼出存在于数组中的字符串的最大数量。每个 0 和 1 至多被使用一次。
注意:
给定 0 和 1 的数量都不会超过 100。
给定字符串数组的长度不会超过 600。
示例 1:
输入: Array = {"10", "0001", "111001", "1", "0"}, m = 5, n = 3
输出: 4
解释: 总共 4 个字符串可以通过 5 个 0 和 3 个 1 拼出,即 "10","0001","1","0" 。

参考代码:

public class Solution {
    public int findMaxForm(String[] strs, int m, int n) {
        if(strs.length==0)
            return 0;
        //[0:i-1]的字符串物品中,j个0,k个1最多能够构成字符串数量。字符串为物品,0,1数量为背包限制。
        //dp[i][j]=max(dp[i][j],dp[i-0数量][j-1数量]+1)
        int[][] dp=new int[m+1][n+1];
        for(String str: strs){
            int zeros=0, ones=0;
            //统计该字符串的0,1数量
            for(int i=0; i<str.length(); i++){
                char c=str.charAt(i);
                if( c=='0')
                    zeros++;
                else
                    ones++;
            }
            for(int j=m; j>=zeros; j--)
                for(int k=n; k>=ones; k--)
                    dp[j][k]=Math.max(dp[j][k],1+dp[j-zeros][k-ones]);
        }
        return dp[m][n];
    }
}

4. 小结&参考资料

小结

背包问题还包括混合三种背包的问题,分组的背包问题等。具体都可以由着三种拓展,可以参考背包问题九讲

参考资料

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值