概述
来源0x3f:https://space.bilibili.com/206214
【宫水三叶】详解完全背包一维空间优化推导(附背包问题攻略)https://leetcode.cn/circle/discuss/GWpXCM/
文章目录
- 0-1背包、完全背包及其拓展(选/不选思想的代表)
- 0-1背包常见变形【至多/恰好/至少】 ※重要
- [494. 目标和](https://leetcode.cn/problems/target-sum/)
- 记忆化搜索
- 1:1翻译成递推(动态规划)
- 空间优化:滚动数组
- 空间优化(一个数组)从后往前遍历背包
- 完全背包变形
- [322. 零钱兑换](https://leetcode.cn/problems/coin-change/)
- 记忆化搜索
- 翻译成递推
- 空间优化(一维数组)从前到后遍历背包
- 练习
- [416. 分割等和子集](https://leetcode.cn/problems/partition-equal-subset-sum/)
- [279. 完全平方数](https://leetcode.cn/problems/perfect-squares/)
- [518. 零钱兑换 II](https://leetcode.cn/problems/coin-change-ii/)
- 0-1背包完全背包多重背包背包具体方案数【宫水三叶】
- 0-1背包问题
- 完全背包问题
- 多重背包问题
- 分组背包问题
- 背包问题具体方案数
- 总结
对边界条件的总结:
不同的题目,所要的答案不同, 比如:方案数,最大、小值,数字个数,能否构成?
这也就意味着 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]]
最大价值和的初始化:另外根据不同的状态定义,可以采取不同的初始化的策略:
- 状态定义为考虑前
i
件物品,体积最多(不超过)为j
的话(至多):全部初始化为 0,递推过程保证体积大于等于 0 - 状态定义为考虑前
i
件物品,体积恰好为j
的话(恰好):只有f[0][0]
初始为 0,其他为正无穷,递推过程保证体积大于等于 0 - 状态定义为考虑前
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
的完全平方数的最少数量 。
完全平方数 是一个整数,其值等于另一个整数的平方;换句话说,其值等于一个整数自乘的积。例如,1
、4
、9
和 16
都是完全平方数,而 3
和 11
不是。
示例 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];
}
}
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[i−1][C],w[i]+f[i−1][C−v[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
行格子顺序从 0
到 C
改为从 C
到 0
,我们可以将原本 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 就算当前 j
比 v[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[i−1][C−k∗v[i]]+k∗w[i]),0<=k∗v[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
行,物理上是一维的)的顺序从 C
到 0
改回从 0
到 C
即可。
// 有 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[i−1][C−k∗v[i]]+k∗w[i]),0<=k∗v[i]<=C,0<=k<=s[i]
将 s[i]
按照最坏复杂度计算,这时候算法复杂度应该是
O
(
N
∗
C
∗
C
)
O(N * C * C)
O(N∗C∗C)。
一、多重背包问题的常规解法:
我们可以基于 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;
}
}
总结
几乎所有的背包问题,都是先遍历物品,再遍历容量,最后再遍历决策(在遍历决策里可能又是通过循环实现)。
背包问题的本质其实是这么一个模型:我有一些额度(背包容量)用来做决策(选择物品),做决策会伴随着成本和收益,求解在额度以内的最大收益。
所有的背包问题的变种都离不开这个模型。
另外根据不同的状态定义,可以采取不同的初始化的策略:
- 状态定义为考虑前
i
件物品,体积最多(不超过)为j
的话:全部初始化为 0,递推过程保证体积大于等于 0 - 状态定义为考虑前
i
件物品,体积恰好为j
的话:只有f[0][0]
初始为 0,其他为正无穷,递推过程保证体积大于等于 0 - 状态定义为考虑前
i
件物品,体积至少为j
的话:只有f[0][0]
初始为 0,其他为正无穷,递推过程对体积没有要求,但负数的体积可以用体积为 0 来替代
最后
以上就是轻松信封为你收集整理的动态规划:0-1背包、完全背包及其变形【零神基础精讲】0-1背包、完全背包及其拓展(选/不选思想的代表)的全部内容,希望文章能够帮你解决动态规划:0-1背包、完全背包及其变形【零神基础精讲】0-1背包、完全背包及其拓展(选/不选思想的代表)所遇到的程序开发问题。
如果觉得靠谱客网站的内容还不错,欢迎将靠谱客网站推荐给程序员好友。
发表评论 取消回复