6.动态规划:背包问题(0-1背包、完全背包及其变形)【灵神基础精讲】

来源0x3f:https://space.bilibili.com/206214

【宫水三叶】详解完全背包一维空间优化推导(附背包问题攻略)https://leetcode.cn/circle/discuss/GWpXCM/

对边界条件的总结:

不同的题目,所要的答案不同, 比如:方案数,最大、小值,数字个数,能否构成?

这也就意味着 dp 数组值可以为数值,也可以是 boolean 类型

另外,同样是数值的情况下,不同的要求,也会造成不同的初始值 f[0][0]

  • 能否构成: f[0][0] = True ; 0 可以构成 0

  • 方案数:f[0][0] = 1 ; 0 组成 0 只有一种方案

  • 数字个数:f[0][0] = 0 ; 0 组成 0 没有使用数字

  • 最大值、最小值: 问题一般会回归到 方案数 或 数字个数问题, 一般会使用到 max/min 函数约束答案,而且会使用 +-inf 初始化来表示极端情况。 比如:力扣 279 求最小数量(f[0][j] 的初始值也是要考虑的)


0-1背包、完全背包及其拓展(选/不选思想的代表)

0-1背包问题:有N件物品和一个容量为C的背包。第i件物品的费用是v[i],价值是w[i]。求解将哪些物品装入背包可使这些物品的费用总和不超过背包容量,且价值总和最大。

0-1背包常见变形【至多/恰好/至少】 ※重要

1、至多装capacity,求方案数/最大价值和

2、恰好装capacity,求方案数/最大/最小价值和

3、至少装capacity,求方案数/最小价值和

三种情况如何进行初始化?

状态转移方程dfs(i,c) = dfs(i-1,c) + dfs(i-1, c-w[i])==>f[i][c] = f[i-1][c] + f[i-c][c-w[i]],为了方便表示初始状态,常使用:

  • f[i+1][c] = f[i][c] + f[i][c-w[i]]

最大价值和的初始化:另外根据不同的状态定义,可以采取不同的初始化的策略:

  1. 状态定义为考虑前 i 件物品,体积最多(不超过)为 j 的话(至多):全部初始化为 0,递推过程保证体积大于等于 0
  2. 状态定义为考虑前 i 件物品,体积恰好为 j 的话(恰好):只有 f[0][0] 初始为 0,其他为正无穷,递推过程保证体积大于等于 0
  3. 状态定义为考虑前 i 件物品,体积至少为 j 的话(至少):只有 f[0][0] 初始为 0,其他为正无穷,递推过程对体积没有要求,但负数的体积可以用体积为 0 来替代

同理求方案数的初始化:

  • 至多:return 1
  • 恰好:return 1 if c == 0 else 0
  • 至少:return 1 if c <= 0 else 0

0-1背包回溯三问:

当前操作?

枚举第i个物品选还是不选

  • 不选,剩余容量不变
  • 选,剩余容量减少w[i]

子问题?

在剩余容量为c时,从前i个物品中得到的最大价值和

下一个子问题?

分类讨论:

  • 不选,在剩余容量为c时,从前i-1个物品中得到的最大价值和

  • 选,在剩余容量为c-w[i]时,从前i-1个物品中得到的最大价值和

# 01背包
@Cache
def dfs(i, c):
    if i < 0:
        return 0
   	if c < w[i]:
        return dfs(i-1, c)
   	return max(dfs(i-1, c), dfs(i-1, c - w[i] + v[i]))
return dfs(n-1, capacity)

494. 目标和

难度中等1515

给你一个整数数组 nums 和一个整数 target

向数组中的每个整数前添加 '+''-' ,然后串联起所有整数,可以构造一个 表达式

  • 例如,nums = [2, 1] ,可以在 2 之前添加 '+' ,在 1 之前添加 '-' ,然后串联起来得到表达式 "+2-1"

返回可以通过上述方法构造的、运算结果等于 target 的不同 表达式 的数目。

示例 1:

输入:nums = [1,1,1,1,1], target = 3
输出:5
解释:一共有 5 种方法让最终目标和为 3 。
-1 + 1 + 1 + 1 + 1 = 3
+1 - 1 + 1 + 1 + 1 = 3
+1 + 1 - 1 + 1 + 1 = 3
+1 + 1 + 1 - 1 + 1 = 3
+1 + 1 + 1 + 1 - 1 = 3

示例 2:

输入:nums = [1], target = 1
输出:1

提示:

  • 1 <= nums.length <= 20
  • 0 <= nums[i] <= 1000
  • 0 <= sum(nums[i]) <= 1000
  • -1000 <= target <= 1000

从什么都不知道的题目到0-1背包问题的推理过程:

设添加号的数和为p,则添加 - 号的数和为 sum-p,则 p-(sum-p) = target

==> p = (s+t)/2

问题转化成了:在nums中选择一些数字,使得和恰好为p

记忆化搜索
class Solution {
    int[] nums;
    int[][] cache;
    public int findTargetSumWays(int[] nums, int target) {
        for(int x : nums) target += x;
        if(target < 0 || target % 2 == 1) return 0;
        target /= 2;

        this.nums = nums;
        int n = nums.length;
        cache = new int[n][target+1];
        for (int i = 0; i < n; i++)
            Arrays.fill(cache[i], -1); // -1 表示没用访问过
        return dfs(n - 1, target);
    }
	
	// 定义dfs(i,c)表示在前 i 个数中和为 c 的方案数
    public int dfs(int i, int c){
        // c == 0,表示target=0,恰好,找到一个合法的方案数
        if(i < 0) return c == 0 ? 1 : 0;
        if(cache[i][c] != -1) return cache[i][c];
        int res = 0;
        if(c < nums[i]) {
            res = dfs(i-1, c); // 没有容量装下i位置的物体了
        }else{
            res = dfs(i-1, c) + dfs(i-1, c - nums[i]);
        }
        cache[i][c] = res;
        return res;
    }
}
1:1翻译成递推(动态规划)
class Solution {
    int[] nums;
    int[][] cache;
    public int findTargetSumWays(int[] nums, int target) {
        for(int x : nums) target += x;
        if(target < 0 || target % 2 == 1) return 0;
        target /= 2;

        int n = nums.length;
        // 定义f[i][j]表示 从前 i 个数中选择了若干元素,和为 j 的方案数
        // 选 or 不选
        // 不选: f[i][j] = f[i-1][j]
      	// 选: f[i][j] = f[i-1][j - nums[i]]
      	//  初始化f[0][0] = 1 表示容量为0和为0的方案树为1
      	// 最后返回 f[n][target]
        int[][] f = new int[n+1][target+1];
        f[0][0] = 1; 
        for(int i = 0; i < n; i++){
            for(int c = 0; c <= target; c++){
                if(c < nums[i]){
                    f[i+1][c] = f[i][c];
                }else{
                    f[i+1][c] = f[i][c] + f[i][c-nums[i]];
                }
            }
        }
        return f[n][target];

    }
}
空间优化:滚动数组

(i+1) ==> (i+1) % 2

class Solution {
    int[] nums;
    int[][] cache;
    public int findTargetSumWays(int[] nums, int target) {
        for(int x : nums) target += x;
        if(target < 0 || target % 2 == 1) return 0;
        target /= 2;

        int n = nums.length;
        int[][] f = new int[2][target+1];
        f[0][0] = 1; // 容量未0,所有数都选了
        for(int i = 0; i < n; i++){
            for(int c = 0; c <= target; c++){
                if(c < nums[i]){
                    f[(i+1) % 2][c] = f[i % 2][c];
                }else{
                    f[(i+1) % 2][c] = f[i % 2][c] + f[i % 2][c-nums[i]];
                }
            }
        }
        return f[n % 2][target];

    }
}
空间优化(一个数组)从后往前遍历背包

从后往前遍历容量c

class Solution {
    int[] nums;
    int[][] cache;
    public int findTargetSumWays(int[] nums, int target) {
        for(int x : nums) target += x;
        if(target < 0 || target % 2 == 1) return 0;
        target /= 2;

        int n = nums.length;
        int[] f = new int[target+1];
        f[0] = 1; // 容量未0,所有数都选了
        for(int i = 0; i < n; i++){
            for(int c = target; c >= nums[i]; c--){
                    f[c] = f[c] + f[c-nums[i]];
            }
        }
        return f[target];
    }
}

完全背包变形

完全背包问题 : 有 N 种物品和一个容量为 C 的背包,每种物品都有无限件。第i 件物品的体积是 v[i],价值是 w[i]。求解将哪些物品装入背包可使这些物品的费用总和不超过背包容量,且价值总和最大。

322. 零钱兑换

难度中等2307

给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。

计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1

你可以认为每种硬币的数量是无限的。

示例 1:

输入:coins = [1, 2, 5], amount = 11
输出:3 
解释:11 = 5 + 5 + 1

示例 2:

输入:coins = [2], amount = 3
输出:-1

示例 3:

输入:coins = [1], amount = 0
输出:0

提示:

  • 1 <= coins.length <= 12
  • 1 <= coins[i] <= 231 - 1
  • 0 <= amount <= 104
记忆化搜索
class Solution {
    private int[] coins;
    private int[][] cache;

    public int coinChange(int[] coins, int amount) {
        this.coins = coins;
        int n = coins.length;
        cache = new int[n][amount + 1];
        for (int i = 0; i < n; i++)
            Arrays.fill(cache[i], -1); // -1 表示没用访问过
        int ans = dfs(n - 1, amount);
        return ans < Integer.MAX_VALUE / 2 ? ans : -1;
    }

    private int dfs(int i, int c) {
        // 当 c==0 时,表示这是一个合法的方案
        if (i < 0) return c == 0 ? 0 : Integer.MAX_VALUE / 2; // 除 2 是防止下面 + 1 溢出
        if (cache[i][c] != -1) return cache[i][c];
        if (c < coins[i]) return cache[i][c] = dfs(i - 1, c);
        return cache[i][c] = Math.min(dfs(i - 1, c), dfs(i, c - coins[i]) + 1);
    }
}
翻译成递推
class Solution {
    public int coinChange(int[] coins, int amount) {
        int n = coins.length;
        int[][] f = new int[n + 1][amount + 1];
        Arrays.fill(f[0], Integer.MAX_VALUE / 2); // 除 2 是防止下面 + 1 溢出
        f[0][0] = 0;
        for (int i = 0; i < n; ++i)
            for (int c = 0; c <= amount; ++c)
                if (c < coins[i]) f[i + 1][c] = f[i][c];
                else f[i + 1][c] = Math.min(f[i][c], f[i + 1][c - coins[i]] + 1);
        int ans = f[n][amount];
        return ans < Integer.MAX_VALUE / 2 ? ans : -1;
    }
}
空间优化(一维数组)从前到后遍历背包
class Solution {
    public int coinChange(int[] coins, int amount) {
        int[] f = new int[amount + 1];
        Arrays.fill(f, Integer.MAX_VALUE / 2); // 除 2 是防止下面 + 1 溢出
        f[0] = 0;
        for (int x : coins)
            for (int c = x; c <= amount; ++c)
                f[c] = Math.min(f[c], f[c - x] + 1);
        int ans = f[amount];
        return ans < Integer.MAX_VALUE / 2 ? ans : -1;
    }
}

练习

其他练习:

416. 分割等和子集

难度中等1645

给你一个 只包含正整数非空 数组 nums 。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。

示例 1:

输入:nums = [1,5,11,5]
输出:true
解释:数组可以分割成 [1, 5, 5] 和 [11] 。

示例 2:

输入:nums = [1,2,3,5]
输出:false
解释:数组不能分割成两个元素和相等的子集。

提示:

  • 1 <= nums.length <= 200
  • 1 <= nums[i] <= 100
class Solution {
    // 每个元素只可以取一次,元素的价值和大小就是自己本身
    public boolean canPartition(int[] nums) {
        Arrays.sort(nums);
        int sum = 0;
        for(int num : nums) sum += num;
        if(sum % 2 != 0) return false;
        int target = sum / 2; // 背包为target时,有没有价值和为target(价值和只可能 <= target)
        int[] dp = new int[target+1]; // 空间优化:一个数组
        for(int i = 0; i < nums.length; i++){
            for(int j = target; j >= nums[i]; j--){
                dp[j] = Math.max(dp[j], dp[j-nums[i]] + nums[i]);
            }
        }
        return dp[target] == target ? true : false;
    }
}

279. 完全平方数

难度中等1619

给你一个整数 n ,返回 和为 n 的完全平方数的最少数量

完全平方数 是一个整数,其值等于另一个整数的平方;换句话说,其值等于一个整数自乘的积。例如,14916 都是完全平方数,而 311 不是。

示例 1:

输入:n = 12
输出:3 
解释:12 = 4 + 4 + 4

示例 2:

输入:n = 13
输出:2
解释:13 = 4 + 9

提示:

  • 1 <= n <= 104

朴素完全背包解法

class Solution {
    private static final int INF = Integer.MAX_VALUE / 2;
    public int numSquares(int n) {
        // 将可能用到的【物品】预处理出来
        List<Integer> list = new ArrayList<>();
        for(int i = 1; i * i <= n; i++) list.add(i*i);
        // 问题就变成了:给定数字中,每个数字可以使用无限次,求凑出目标值n所需要的最少数字个数是多少
        int[][] dp = new int[list.size()+1][n+1]; // 前i个数字中,凑出数字总和j所需要的最少数字个数

        //当没有任何数时,除了 f[0][0] 为 0(花费 0 个数值凑出 0),其他均为无效值
        Arrays.fill(dp[0], INF);  
        dp[0][0] = 0;

        // dp[i][j] = min(dp[i-1][j-k*t]+k) 0 <= k*t <= j; k:选k个数字i,第i个数字数值为t
        for(int i = 1; i <= list.size(); i++){
            int x = list.get(i-1);
            for(int j = 0; j <= n; j++){
                // 对于不选第 i 个数的情况
                dp[i][j] = dp[i-1][j];
                // 对于选 k 次第 i 个数的情况
                for(int k = 1; k * x <= j; k++){
                    // 能够选择 k 个 x 的前提是剩余的数字 j - k * x 也能被凑出
                    if(dp[i-1][j-k*x] != INF){
                        dp[i][j] = Math.min(dp[i][j], dp[i-1][j-k*x] + k);
                    }
                }
            }
        }
        return dp[list.size()][n];
    }
}

空间优化:

class Solution {
    private static final int INF = Integer.MAX_VALUE / 2;
    public int numSquares(int n) {
        // 将可能用到的【物品】预处理出来
        List<Integer> list = new ArrayList<>();
        for(int i = 1; i * i <= n; i++) list.add(i*i);
        // 问题就变成了:给定数字中,每个数字可以使用无限次,求凑出目标值n所需要的最少数字个数是多少
        int[] dp = new int[n+1]; // 前i个数字中,凑出数字总和j所需要的最少数字个数

        //当没有任何数时,除了 f[0][0] 为 0(花费 0 个数值凑出 0),其他均为无效值
        Arrays.fill(dp, INF);  
        dp[0] = 0;

        // dp[i][j] = min(dp[i-1][j-k*t]+k) 0 <= k*t <= j; k:选k个数字i,第i个数字数值为t
        for(int i = 1; i <= list.size(); i++){
            int x = list.get(i-1);
            for(int j = x; j <= n; j++){
                dp[j] = Math.min(dp[j], dp[j-x] + 1);
            }
        }
        return dp[n];
    }
}

518. 零钱兑换 II

难度中等1005

给你一个整数数组 coins 表示不同面额的硬币,另给一个整数 amount 表示总金额。

请你计算并返回可以凑成总金额的硬币组合数。如果任何硬币组合都无法凑出总金额,返回 0

假设每一种面额的硬币有无限个。

题目数据保证结果符合 32 位带符号整数。

示例 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

示例 2:

输入:amount = 3, coins = [2]
输出:0
解释:只用面额 2 的硬币不能凑成总金额 3 。

示例 3:

输入:amount = 10, coins = [10] 
输出:1

提示:

  • 1 <= coins.length <= 300
  • 1 <= coins[i] <= 5000
  • coins 中的所有值 互不相同
  • 0 <= amount <= 5000
class Solution {
    public int change(int amount, int[] coins) {
        int[] dp = new int[amount+1];
        dp[0] = 1;
        for(int x : coins){
            for(int j = x; j <= amount; j++){
                dp[j] = dp[j] + dp[j-x];
            }
        }
        return dp[amount];
    }
}

多重背包问题

多重背包问题 I :有 N 种物品和一个容量是 V 的背包。第 i 种物品最多有 s[i] 件,每件体积是 v[i],价值是 w[i]。求解将哪些物品装入背包,可使物品体积总和不超过背包容量,且价值总和最大。

其实就是在 0-1 背包问题的基础上,在容量允许的情况下,增加了每件物品可以选择多次的特点(但又不是无限次,是有限制的多次)。

// https://leetcode.cn/problems/number-of-ways-to-earn-points/solution/fen-zu-bei-bao-pythonjavacgo-by-endlessc-ludl/
class Solution {
    private static final int MOD = (int) 1e9 + 7;
    public int waysToReachTarget(int target, int[][] types) {
        var f = new int[target + 1];
        f[0] = 1;
        for (var p : types) {
            int count = p[0], marks = p[1];
            for (int j = target; j > 0; --j)
                for (int k = 1; k <= count && k <= j / marks; ++k)
                    f[j] = (f[j] + f[j - k * marks]) % MOD;
        }
        return f[target];
    }
}

2585. 获得分数的方法数

难度困难24

考试中有 n 种类型的题目。给你一个整数 target 和一个下标从 0 开始的二维整数数组 types ,其中 types[i] = [counti, marksi] 表示第 i 种类型的题目有 counti 道,每道题目对应 marksi 分。

返回你在考试中恰好得到 target 分的方法数。由于答案可能很大,结果需要对 109 +7 取余。

注意,同类型题目无法区分。

  • 比如说,如果有 3 道同类型题目,那么解答第 1 和第 2 道题目与解答第 1 和第 3 道题目或者第 2 和第 3 道题目是相同的。

示例 1:

输入:target = 6, types = [[6,1],[3,2],[2,3]]
输出:7
解释:要获得 6 分,你可以选择以下七种方法之一:
- 解决 6 道第 0 种类型的题目:1 + 1 + 1 + 1 + 1 + 1 = 6
- 解决 4 道第 0 种类型的题目和 1 道第 1 种类型的题目:1 + 1 + 1 + 1 + 2 = 6
- 解决 2 道第 0 种类型的题目和 2 道第 1 种类型的题目:1 + 1 + 2 + 2 = 6
- 解决 3 道第 0 种类型的题目和 1 道第 2 种类型的题目:1 + 1 + 1 + 3 = 6
- 解决 1 道第 0 种类型的题目、1 道第 1 种类型的题目和 1 道第 2 种类型的题目:1 + 2 + 3 = 6
- 解决 3 道第 1 种类型的题目:2 + 2 + 2 = 6
- 解决 2 道第 2 种类型的题目:3 + 3 = 6

示例 2:

输入:target = 5, types = [[50,1],[50,2],[50,5]]
输出:4
解释:要获得 5 分,你可以选择以下四种方法之一:
- 解决 5 道第 0 种类型的题目:1 + 1 + 1 + 1 + 1 = 5
- 解决 3 道第 0 种类型的题目和 1 道第 1 种类型的题目:1 + 1 + 1 + 2 = 5
- 解决 1 道第 0 种类型的题目和 2 道第 1 种类型的题目:1 + 2 + 2 = 5
- 解决 1 道第 2 种类型的题目:5

示例 3:

输入:target = 18, types = [[6,1],[3,2],[2,3]]
输出:1
解释:只有回答所有题目才能获得 18 分。

提示:

  • 1 <= target <= 1000
  • n == types.length
  • 1 <= n <= 50
  • types[i].length == 2
  • 1 <= counti, marksi <= 50
class Solution {
    private static final int MOD = (int)1e9+7;
    int[][] types, cache;
    public int waysToReachTarget(int target, int[][] types) {
        this.types = types;
        int n = types.length;
        cache = new int[n][target+1];
        for(int i = 0; i < n; i++)
            Arrays.fill(cache[i], -1);
        return dfs(n-1, target);
    }

    // 定义 dfs(i, score) 表示解答前 i 种题目,得分为 score 的方法数
    // 转移 枚举第 i 种题目答对 1题....counti题
    // 递归边界 
    //      dfs(i < 0, score = 0) = 1
    //      dfs(i < 0, score != 0) = 0
    //      if(score < 0) return 0;
    // 递归入口 dfs(len(types)-1, target)
    public int dfs(int i, int score){
        if(i < 0 && score == 0) return 1;
        if(i < 0 && score != 0) return 0;
        if(score < 0) return 0;
        if(cache[i][score] >= 0) return cache[i][score];
        int res = 0;
        int count = types[i][0], mark = types[i][1];
        for(int k = 0; k <= count; k++){
            res = (res + dfs(i-1, score - (k*mark))) % MOD;
        }
        return cache[i][score] = res % MOD;
    }
}

记忆化搜索转递推

class Solution {
    /** 一比一翻译 递归入口:dfs(n-1, target)
    		==> 最外层循环 for i in range(n),第二层循环 for score in range(target+1)
    			计算状态时还有一层循环 for k in range(count)
    	把题目看成物品,把得分看成背包,则问题转化为,选择k个物品,塞入背包中,恰好装满的方案数
    */
    private static final int MOD = (int)1e9+7;
    public int waysToReachTarget(int target, int[][] types) {
        int n = types.length;
        int[][] f = new int[n+1][target+1];
        f[0][0] = 1; // 恰好得到 target 分的方法数,只有f[0][0]才计算方案
        for(int i = 0; i < n; i++){ // 先遍历物品
            int count = types[i][0], mark = types[i][1];
            for(int j = 0; j <= target; j++){ // 再遍历背包
                for(int k = 0; k <= count && (k * mark) <= j; k++){
                    f[i+1][j] = (f[i+1][j] + f[i][j - k * mark]) % MOD;
                }
            }
        }
        return f[n][target];
    }
}

0-1背包\完全背包\多重背包\背包具体方案数【宫水三叶】

【宫水三叶】详解完全背包一维空间优化推导(附背包问题攻略)https://leetcode.cn/circle/discuss/GWpXCM/

0-1背包问题

0-1背包问题:有N件物品和一个容量为C的背包。第i件物品的费用是v[i],价值是w[i]。求解将哪些物品装入背包可使这些物品的费用总和不超过背包容量,且价值总和最大。

0-1 背包问题是众多背包问题中最简单的,其特点是物品不能重复放入。

定义状态:即f[i][C]表示前 i 件物品中选择了若干物品,放入一个容量为 C 的背包可以获得的最大价值。

状态转移方程为: f [ i ] [ C ] = m a x ( f [ i − 1 ] [ C ] , w [ i ] + f [ i − 1 ] [ C − v [ i ] ) f[i][C]=max(f[i−1][C],w[i]+f[i−1][C−v[i]) f[i][C]=max(f[i1][C],w[i]+f[i1][Cv[i])

即对于第 i 件物品,我们有两种决策方案:

  • 不选择第 i 件物品,则最大价值等于 f[i - 1][C] ,全部容量留给前 i - 1 件物品。
  • 选择第 i 件物品,则最大价值等于 w[i] + f[i - 1][C - v[i]] ,其中 w[i] 代表当前物品的价值,这样就用掉了 v[i] 的体积,留给前 i - 1 件物品的容量就剩下 C - v[i],所以总的价值等于 w[i] + f[i - 1][C - v[i]]

一、0-1 背包的dp[N][C+1]解法

根据状态转移方程,我们可以建立一个二维的 dp 数组来存储结果。

第一维代表物品的下标(范围从 0 到 N - 1),第二维代表了容量的变化(范围从 0 到 C)。并得知 base case dp[0][0]的初始值为 0,原问题的解在 dp[N - 1][C] 的格子里面。

public class Main{ 
    public static void main(String[] args){ 
        Scanner sc = new Scanner(System.in); 
        int N = sc.nextInt(); 
        int C = sc.nextInt(); 
        int[] v = new int[N]; 
        int[] w = new int[N]; 
        for (int i = 0; i < N; i++){ 
            v[i] = sc.nextInt(); 
            w[i] = sc.nextInt(); 
        }
        System.out.println(maxValue(N, C, v, w)); 
    }
	// 有N件物品和一个容量为C的背包。第i件物品的费用是v[i],价值是w[i]
    // 求解将哪些物品装入背包可使这些物品的费用总和不超过背包容量,且价值总和最大。
    private static int maxValue(int N, int C, int[] v, int[] w) {
        int[][] dp = new int[N][C + 1];
        dp[0][0] = 0;
        // 初始化:第0号物品可以装下v[0]容量的背包中
        for (int i = 0; i < C + 1; i++) {
            dp[0][i] = i >= v[0] ? w[0] : 0;
        }
        for (int i = 1; i < N; i++) { // 1. 先遍历物品
            for (int j = 0; j < C + 1; j++) { // 2. 再遍历背包
                int n = dp[i - 1][j]; // 不选该物品
                int y = 0;
                if (j >= v[i]) { // 如果容量 > 当前物品的费用,则可以选择该物品
                    y = w[i] + dp[i - 1][j - v[i]]; // 选择该物品
                }
                // 不选该物品:dp[i - 1][j]
                // 选该物品:dp[i - 1][j - v[i]] + w[i]
                dp[i][j] = Math.max(n, y);
            }
        }
        return dp[N - 1][C];
    }
}

二、0-1 背包的dp[2][C+1]解法

根据状态转移方程,我们可以知道计算某个格子的值,只需要依赖前一行(计算第 i 行格子只需要第 i - 1 行中的某些值)。

所以可以用一个只有两行的数组来存储中间结果,根据当前计算的行号是偶数还是奇数来交替使用第 0 行和第 1 行。

// 有N件物品和一个容量为C的背包。第i件物品的费用是v[i],价值是w[i]
// 求解将哪些物品装入背包可使这些物品的费用总和不超过背包容量,且价值总和最大。
private static int maxValue(int N, int C, int[] v, int[] w) {
    int[][] dp = new int[2][C + 1];
    dp[0][0] = 0;
    for (int i = 0; i < C + 1; i++) {
        dp[0][i] = i >= v[0] ? w[0] : 0;
    }
    for (int i = 1; i < N; i++) {
        for (int j = 0; j < C + 1; j++) {
            int n = dp[(i - 1)%2][j]; // 不选该物品
            int y = 0;
            if (j >= v[i]) {
                y = w[i] + dp[(i - 1)%2][j - v[i]]; // 选择该物品
            }
            dp[i%2][j] = Math.max(n, y);
        }
    }
    return dp[(N - 1)%2][C];
}

再次观察状态转移方程,我们发现当求解第 i 行格子的值的时候,不仅是只依赖第 i - 1 行,而且是明确只依赖第 i - 1 行的第 C 个格子和第 C - v[i] 个格子(也就是对应着第 i 个物品不选和选的两种情况)。

三、0-1 背包的dp[C+1]解法(滚动数组)

也就是说明确只依赖于 「上一个格子的位置」 以及 「上一个格子的左边位置」。

当我们将求解第 i 行格子顺序从 0C 改为从 C0,我们可以将原本 2 行的二维数组压缩到一行(转换为一维数组)。

// 有N件物品和一个容量为C的背包。第i件物品的费用是v[i],价值是w[i]
// 求解将哪些物品装入背包可使这些物品的费用总和不超过背包容量,且价值总和最大。
private static int maxValue(int N, int C, int[] v, int[] w) {
    int[] dp = new int[C + 1];
    for (int i = 0; i < C + 1; i++) {
        dp[i] = i >= v[0] ? w[0] : 0;
    }
    for (int i = 1; i < N; i++) {
        for (int j = C; j >= 0; j--) {
            int n = dp[j]; // 不选该物品
            int y = 0;
            if (j >= v[i]) {
                y = w[i] + dp[j - v[i]]; // 选择该物品
            }
            dp[j] = Math.max(n, y);
        }
    }
    return dp[C];
}

这样,当我们处理第 i 行的第 j 个格子的时候,访问的 dp[j]dp[j - v[i]] 其实是第 i - 1 行的结果。
然后通过比较之后,选择当中的最大值更新到 dp[j],这时候才代表第 i 行第 j 个格子被更新了。

这就是我们使用一维数组解决 0-1 背包问题的解法。它的时间复杂度仍然是 O(N_∗_C),但空间复杂度已经降为了 O(C)。


重点!!!

使用的是一维 dp,在处理第 i 行之前,数组装的都是第 i - 1 行的结果,所以可以对循环内部的判断进行简化:

// 有N件物品和一个容量为C的背包。第i件物品的费用是v[i],价值是w[i]
// 求解将哪些物品装入背包可使这些物品的费用总和不超过背包容量,且价值总和最大。
private static int maxValue(int N, int C, int[] v, int[] w) {
    int[] dp = new int[C + 1];
    for (int i = 0; i < N; i++) {
        for (int j = C; j >= v[i]; j--) {
            int n = dp[j]; // 不选该物品
            int y = w[i] + dp[j - v[i]]; // 选择该物品
            dp[j] = Math.max(n, y);
        }
    }
    return dp[C];
}

**当 j 不满足 j >= v[i] 的时候,注定无法选择第 i 个物品,**这时候的 dp[i] 应该是等于 dp[i - 1]因为使用的是一维数组,dp[i] 本身就是装着 dp[i - 1] 的值,所以无须再进行修改。

这和使用二维 dp 的方式不同,使用二维 dp 就算当前 jv[i] 要小,也要把上一行的 dp[i - 1][j] 的结果拉下来,赋值给 dp[i][j]

同时也是因为使用的是一维 dp,没有了访问 dp[i - 1][j] 这样的操作,也不需要先处理第 0 行,可以直接让循环从 i = 0 开始。

如何用一维 dp 来解决 0-1 背包十分重要,其他背包问题一定程度上都能转化成 0-1 背包来进行求解 或是 根据 0-1 背包的转移方程来稍作修改。


在0-1背包混动数组的前提下,此时dp[j]有两个选择:

  • 取自己dp[j] 相当于 二维dp数组中的dp[i-1][j],即不放物品i

  • dp[j - weight[i]] + value[i],即放物品i,指定是取最大的,毕竟是求最大价值,

所以递归公式为:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);

一维dp数组如何初始化?

dp[j]表示:容量为j的背包,所背的物品价值可以最大为dp[j],那么dp[0]就应该是0,因为背包容量为0所背的物品的最大价值就是0

dp数组在推导的时候一定是取价值最大的数,如果题目给的价值都是正整数那么非0下标都初始化为0就可以了。这样才能让dp数组在递归公式的过程中取的最大的价值,而不是被初始值覆盖了

那么我假设物品价值都是大于0的,所以dp数组初始化的时候,都初始为0就可以了。

一维dp数组遍历顺序:

for(int i = 0; i < weight.size(); i++) { // 遍历物品
    for(int j = bagWeight; j >= weight[i]; j--) { // 遍历背包容量
        dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
    }
}

这里大家发现和二维dp的写法中,遍历背包的顺序是不一样的!

  • 二维dp遍历的时候,背包容量是从小到大,而一维dp遍历的时候,背包是从大到小。

倒序遍历是为了保证物品i只被放入一次!。但如果一旦正序遍历了,那么物品0就会被重复加入多次!
所以从后往前循环,每次取得状态不会和之前取得状态重合,这样每种物品就只取一次了。

完全背包问题

完全背包问题 : 有 N 种物品和一个容量为 C 的背包,每种物品都有无限件。第i 件物品的体积是 v[i],价值是 w[i]。求解将哪些物品装入背包可使这些物品的费用总和不超过背包容量,且价值总和最大。

其实就是在 0-1 背包问题的基础上,增加了每件物品可以选择多次的特点(在容量允许的情况下)

状态转移方程 & 时间复杂度分析

根据和 0-1 背包问题的基本思路,我们可以写成如下的状态转移方程

  • f [ i ] [ C ] = m a x ( f [ i − 1 ] [ C − k ∗ v [ i ] ] + k ∗ w [ i ] ) , 0 < = k ∗ v [ i ] < = c f[i][C] = max(f[i - 1][C- k*v[i]] + k * w[i]), 0 <= k * v[i] <= c f[i][C]=max(f[i1][Ckv[i]]+kw[i]),0<=kv[i]<=c

该式子很好理解,k * v[i] 为 0 的时候代表不选当前的第 i 件物品,这时候有 f[i][C] = f[i - 1][C] ,当 k * v[i] 不为 0 的时候,代表第 i 件物品可能会被选择多次。

这时候二维 dp 的表格大小依然是 N * C 的,但是求解某个格子的值的时候,并不是单纯的比较上一行的两个格子,而是要比较多个格子。要比较的格子数量不确定,比较格子的数量等于最多可选第 i 件物品多少次,也就是 (C / v[i]) + 1 个。

这里的 C 为当前格子的容量,不是总容量,加一代表还要比较不选择第 i 个格子的情况),按照最坏的时间复杂度计算,最多要比较 C + 1 个格子,也就是上一行的 0 ~ C 的格子全部都要进行比较。

这时候计算某个格子的值不再是常数操作。时间复杂度为 O(N∗C∗C)

一、完全背包问题的常规解法

import java.util.Scanner;
public class Main{ 
    public static void main(String[] args){ 
        Scanner sc = new Scanner(System.in); 
        int N = sc.nextInt(); 
        int C = sc.nextInt(); 
        int[] v = new int[N]; 
        int[] w = new int[N]; 
        for (int i = 0; i < N; i++){ 
            v[i] = sc.nextInt(); 
            w[i] = sc.nextInt(); 
        }
        System.out.println(maxValue(N, C, v, w)); 
    }
	// 有 N 种物品和一个容量为 C 的背包,每种物品都有无限件。第 i 件物品的体积是 v[i],价值是 w[i]。
	// 求解将哪些物品装入背包可使这些物品的费用总和不超过背包容量,且价值总和最大。
    private static int maxValue(int N, int C, int[] v, int[] w) {
        int[] dp = new int[C + 1];
        for (int i = 0; i < N; i++) {
            for (int j = C; j >= v[i]; j--) {
                for (int k = 0 ;; k++) {
                    if (j < v[i] * k) {
                        break;
                    }
                    dp[j] = Math.max(dp[j], dp[j - v[i] * k] + w[i] * k);
                }
            }
        }
        return dp[C];
    }
}

我们在处理第 j 个格子的时候,某件物品可以不选或者选到超出容量限制为止(反映了完全背包问题物品可以选择无限次的特点)。由此我们得出了完全背包问题的常规解法,它的时间复杂度是大于O(N*C)的。

二、利用 0-1背包的一维DP方法求解完全背包

我们掌握了如何通过一维 dp 解决 0-1 背包问题,只需要将求解第 i 行格子(逻辑上的第 i 行,物理上是一维的)的顺序从 C0 改回从 0C 即可。

// 有 N 种物品和一个容量为 C 的背包,每种物品都有无限件。第 i 件物品的体积是 v[i],价值是 w[i]。
// 求解将哪些物品装入背包可使这些物品的费用总和不超过背包容量,且价值总和最大。
private static int maxValue(int N, int C, int[] v, int[] w) {
    int[] dp = new int[C + 1];
    for (int i = 0; i < N; i++) {

        // for (int j = C; j >= v[i]; j--) { // 0-1 背包问题
        for (int j = v[i]; j <= C; j++) { // 完全背包问题

            int n = dp[j]; // 不选该物品
            int y = w[i] + dp[j - v[i]]; // 选择该物品
            dp[j] = Math.max(n, y);
        }
    }
    return dp[C];
}

j 的处理顺序改为从小到大,这样就保证当我们使用 dp[j - v[i]] 时,该值是已经被计算过的。

这样所代表的状态含义是:当在容量为 j 的时候,考虑将 i 物品加入,剩余的 j - v[i] 容量也考虑加入物品 i(反映了完全背包问题的物品是可以被重复选择的特点)。

多重背包问题

多重背包问题 I :有 N 种物品和一个容量是 V 的背包。第 i 种物品最多有 s[i] 件,每件体积是 v[i],价值是 w[i]。求解将哪些物品装入背包,可使物品体积总和不超过背包容量,且价值总和最大。

其实就是在 0-1 背包问题的基础上,在容量允许的情况下,增加了每件物品可以选择多次的特点(但又不是无限次,是有限制的多次)。

既然对每件物品有选择数量上的限制,这意味着选择的数量 k 需要满足 0 <= k <= s[i]

能够很清晰的分析出状态转移方程:

  • f [ i ] [ C ] = m a x ( f [ i − 1 ] [ C − k ∗ v [ i ] ] + k ∗ w [ i ] ) , 0 < = k ∗ v [ i ] < = C , 0 < = k < = s [ i ] f[i][C] = max(f[i - 1][C - k*v[i]] + k * w[i]), 0 <= k * v[i] <= C, 0 <= k <= s[i] f[i][C]=max(f[i1][Ckv[i]]+kw[i]),0<=kv[i]<=C,0<=k<=s[i]

s[i]按照最坏复杂度计算,这时候算法复杂度应该是 O ( N ∗ C ∗ C ) O(N * C * C) O(NCC)

一、多重背包问题的常规解法:

我们可以基于 0-1 背包问题的一维 dp 解法,增加一个循环,从 0 开始遍历s[i]

得出以下的常规解法:

import java.util.Scanner;
class Main {
    public static void main(String[] arg) {
        Scanner sc = new Scanner(System.in); 
        int N = sc.nextInt(); 
        int C = sc.nextInt(); 
        int[] v = new int[N]; 
        int[] w = new int[N]; 
        int[] s = new int[N];
        for (int i = 0; i < N; i++){ 
            v[i] = sc.nextInt(); 
            w[i] = sc.nextInt(); 
            s[i] = sc.nextInt(); 
        }
        System.out.println(maxValue(N, C, s, v, w)); 
    }

    private static int maxValue(int N, int C, int[] s, int[] v, int[] w) {
        int[] dp = new int[C + 1];
        for (int i = 0; i < N; i++) {
            for (int j = C; j >= v[i]; j--) {
                for (int k = 0; k <= s[i] && j >= k * v[i]; k++) {
                    dp[j] = Math.max(dp[j], dp[j - k * v[i]] + k * w[i]);
                }
            }
        }
        return dp[C];
    }
}

二、多重背包问题的常规解法:

多重背包问题 II :题目和「多重背包问题 I」完全一样,只是数据范围从 百级 上升到 千级 。

所谓的「二进制优化」其实是指,我们如何将一个多重背包问题彻底转化为 0-1 背包问题,同时降低其复杂度。

0-1 背包问题讲的是物品列表里面的每个物品只能选择一次,而这里的多重背包问题则是每个物品有最大数量限制,所以我们可以将其进行「扁平化」。

如果第 i 件物品有 s1 件,则将 s1 个 i 物品放到物品列表;如果第 i + 1 件物品有 s2 件,则将 s2 件 i + 1 物品放到物品列表。

物品列表里面的每个物品有选和不选两种选择,这样就对应了多重背包问题中的每样物品可以不选或最多选择 s[i] 的特性。从而将一个多重背包问题彻底转换为 0-1 背包问题。但是光进行这样的「扁平化」并不能降低算法的复杂度。因为我们仍然要处理这么多的物品,反而增加了将物品「扁平化」的工作量。

算法的复杂度在常数级别上是上升的。这样做的意义在哪?

我们现在采取的「扁平化」策略是直接展开,一个数量为 10 的物品等效于 [1, 1, 1, 1, 1, 1, 1, 1, 1, 1] 。

这样并没有减少运算量,但是如果我们能将 10 变成小于 10 个数,那么这样的「扁平化」就是有意义的。
学过 Linux 的都知道文件权限最高是 7,代表拥有读、写、执行的权限。

但其实这个 7 是对应了 1、2、4 三个数字的,也就是 r : 1、w : 2、x : 4 ,三种权限的组合共有 8 种可能性。这里就采用了 3 个数来对应 8 种情况的“压缩”方法。我们也可以借鉴这样的思路,将原本为 n 的物品用 ceil(log(n))个数来代替。从而降低算法复杂度。

7 可以用 1、2、4 来代替,像刚刚提到的 10 ,我们可以使用 1、2、4、3 来代替,你可能会有疑问,为什么是 1、2、4、3,而不是 1、2、4、6 或者 1、2、4、8 呢?

其实把他们几个数加起来就知道了,1、2、4、6 可以表达的范围是 0~13,而 1、2、4、8 可以表达的范围是 0~15,而我们要求的是表达 10,大于 10 的范围是不能被选择的。

所以我们可以在 1、2、4 (表达的范围是 0~7)的基础上,增加一个数 3(由 10 - 7 而来),这样就能满足我们需要表达的范围 0~10。

来看看通过「扁平化」来解决多重背包问题的代码:

private static int maxValue(int N, int C, int[] s, int[] v, int[] w) {
    // 扁平化
    List<Integer> worth = new ArrayList<>();
    List<Integer> volume = new ArrayList<>();
    // 我们希望每件物品都进行扁平化,所以首先遍历所有的物品
    for (int i = 0; i < N; i++) {
        // 获取每件物品的出现次数
        int val = s[i];
        // 进行扁平化:如果一件物品规定的使用次数为 7 次,我们将其扁平化为三件物品:
        // 1*重量&1*价值、2*重量&2*价值、4*重量&4*价值
        //
        // 三件物品都不选对应了我们使用该物品 0 次的情况、
        // 只选择第一件扁平物品对应使用该物品 1 次的情况、
        // 只选择第二件扁平物品对应使用该物品 2 次的情况,
        // 只选择第一件和第二件扁平物品对应了使用该物品 3 次的情况 ... 
        for (int k = 1; k <= val; k *= 2) {
            val -= k;
            worth.add(w[i] * k);
            volume.add(v[i] * k);
        } 
        if (val > 0) {
            worth.add(w[i] * val);
            volume.add(v[i] * val);
        }
    }

    // 0-1 背包问题解决方案
    int[] dp = new int[C + 1];
    for (int i = 0; i < worth.size(); i++) {
        for (int j = C; j >= volume.get(i); j--) {
            dp[j] = Math.max(dp[j], dp[j - volume.get(i)] + worth.get(i));
        }
    }
    return dp[C];
}

分组背包问题

分组背包问题 :有 N 组物品和一个容量为 C 的背包。每组物品有若干个,同一组内的物品最多只能选一个。每件物品的体积是 v[i][j],价值是 w[i][j],其中 i 是组号,j 是组内编号。

求解将哪些物品装入背包,可使物品总体积不超过背包容量,且总价值最大。

import java.util.*;
class Main {
    public static void main(String[] arg) {
        Scanner sc = new Scanner(System.in);
        int N = sc.nextInt();
        int V = sc.nextInt();
        int[] S = new int[N];
        int[][] v = new int[N][];
        int[][] w = new int[N][];
        for (int i = 0; i < N; i++) {
            int si = sc.nextInt();
            S[i] = si;
            int[] vol = new int[si];
            int[] wor = new int[si];
            for (int j = 0; j < si; j++) {
                vol[j] = sc.nextInt();
                wor[j] = sc.nextInt();
            }
            v[i] = vol;
            w[i] = wor;
        }
        System.out.println(maxValue(N, V, S, v, w));
    }

    private static int maxValue(int N, int C, int[] S, int[][] v, int[][] w) {
        int[] dp = new int[C + 1];
        for (int i = 0; i < N; i++) {
            int[] vol = v[i];
            int[] wor = w[i];
            int si = S[i];
            for (int j = C; j >= 0; j--) {
                for (int k = 0; k < si; k++) {
                    if (j >= vol[k]) {
                        dp[j] = Math.max(dp[j], dp[j - vol[k]] + wor[k]);   
                    }
                }
            }
        }
        return dp[C];
    }
}

背包问题具体方案数

背包问题求方案数 :有 N 件物品和一个容量是 C 的背包。每件物品只能使用一次。第 i 件物品的体积是 v[i],价值是 w[i]。

求解将哪些物品装入背包,可使这些物品的总体积不超过背包容量,且总价值最大。输出最优选法的方案数。

import java.util.*;
class Main {
    private static final int mod = 1000000007;
    public static void main(String[] arg) {
        Scanner sc = new Scanner(System.in);
        int N = sc.nextInt();
        int V = sc.nextInt();
        int[] v = new int[N];
        int[] w = new int[N];
        for (int i = 0; i < N; i++) {
            v[i] = sc.nextInt();
            w[i] = sc.nextInt();
        }
        System.out.println(combineCount(N, V, v, w));
    }

    private static int combineCount(int N, int C, int[] v, int[] w) {
        // 构建一个 dp 数组,除了第一位为 0,其他位均为 Integer.MIN_VALUE
        int[] dp = new int[C + 1];
        for (int i = 1; i <= C; i++) dp[i] = Integer.MIN_VALUE;

        // 再构建一个 g 数组来存储不同容量下的方案数,除了第一位为 1,其他位均为 0
        int[] g = new int[C + 1];
        g[0] = 1;

        for (int i = 0; i < N; i++) {
            for (int j = C; j >= v[i]; j--) {
                int val = Math.max(dp[j], dp[j - v[i]] + w[i]);
                int cnt = 0;

                // val 有可能等于 dp[j],有可能等于 dp[j - v[i]] + w[i],也有可能都等于
                // 如果 val 为 dp[j],增加 g[j]
                if (val == dp[j]) cnt += g[j];
                // 如果 val 为 dp[j - v[i]] + w[i],增加 g[j - v[i]]
                if (val == dp[j - v[i]] + w[i]) cnt += g[j - v[i]];
                // 采用更为“高效”的取余方法
                if (cnt >= mod) cnt -= mod;

                dp[j] = val;
                g[j] = cnt;
            }
        }

        int max = 0;
        for (int i = 0; i <= C; i++) max = Math.max(max, dp[i]);
        int ans = 0;
        for (int i = 0; i <= C; i++) {
            if (dp[i] == max) {
                ans += g[i];
                if (ans >= mod) ans -= mod;
            }
        }
        return ans;
    }
}

总结

几乎所有的背包问题,都是先遍历物品,再遍历容量,最后再遍历决策(在遍历决策里可能又是通过循环实现)。

背包问题的本质其实是这么一个模型:我有一些额度(背包容量)用来做决策(选择物品),做决策会伴随着成本和收益,求解在额度以内的最大收益。

所有的背包问题的变种都离不开这个模型。

另外根据不同的状态定义,可以采取不同的初始化的策略:

  1. 状态定义为考虑前 i 件物品,体积最多(不超过)为 j 的话:全部初始化为 0,递推过程保证体积大于等于 0
  2. 状态定义为考虑前 i 件物品,体积恰好为 j 的话:只有 f[0][0] 初始为 0,其他为正无穷,递推过程保证体积大于等于 0
  3. 状态定义为考虑前 i 件物品,体积至少为 j 的话:只有 f[0][0] 初始为 0,其他为正无穷,递推过程对体积没有要求,但负数的体积可以用体积为 0 来替代
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值