01背包
有一个容量为 N 的背包,用这个背包装下物品的价值最大,这些物品有两个属性:体积 w 和价值 v。
定义一个二维数组 dp 存储最大价值,其中 dp[i][j] 表示前 i 件物品体积不超过 j 的情况下能达到的最大价值。设第 i 件物品体积为 w,价值为 v,根据第 i 件物品是否添加到背包中,可以分两种情况讨论:
- 第 i 件物品没添加到背包,总体积不超过 j 的前 i 件物品的最大价值就是总体积不超过 j 的前 i-1 件物品的最大价值,dp[i][j] = dp[i-1][j]。
- 第 i 件物品添加到背包中,dp[i][j] = dp[i-1][j-w] + v。
第 i 件物品可添加也可以不添加,取决于哪种情况下最大价值更大。
因此,0-1 背包的状态转移方程为: dp[i][j] = max(dp[i - 1][j], dp[i - 1][j − w[i]] + v[i])
// W 为背包总体积
// N 为物品数量
// weights 数组存储 N 个物品的重量
// values 数组存储 N 个物品的价值
public int knapsack(int W, int N, int[] weights, int[] values) {
int[][] dp = new int[N + 1][W + 1];
for (int i = 1; i <= N; i++) {
int w = weights[i - 1], v = values[i - 1];
for (int j = 1; j <= W; j++) {
if (j >= w) {
dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - w] + v);
} else {
dp[i][j] = dp[i - 1][j];
}
}
}
return dp[N][W];
}
空间优化:
观察状态转移方程可以知道,前 i 件物品的状态仅与前 i-1 件物品的状态有关,因此可以将 dp 定义为一维数组,其中 dp[j] 既可以表示 dp[i-1][j], 也可以表示 dp[i][j]。
此时,dp[j] = max(dp[j], dp[j−w[i]]+v[i])
注意:
必须保证,在计算dp[ j ]的时候,dp[ j - w[i]]依然等于dp[i -1][ j - w[i] ]。
如果 j 从小到大计算,dp[ j - w[i]] 已经在 dp[j]之前被计算过了。用到的dp[ j - w[i]]是第i次循环计算出来的值, 相当于在执行:dp[ i ][ j ] = max{ dp[i - 1][ j ], dp[ i ][ j - w[i] ] }; (注意是dp[ i ][ j - w[i]] )
因为 dp[j-w] 表示 dp[i-1][j-w],因此不能先求 dp[i][j-w],防止将 dp[i-1][j-w] 覆盖。即要先计算 dp[i][j] 再计算 dp[i][j-w],程序实现时需要按倒序来循环求解。
public int knapsack(int W, int N, int[] weights, int[] values) {
int[] dp = new int[W + 1];
for (int i = 1; i <= N; i++) {
int w = weights[i - 1], v = values[i - 1];
for (int j = W; j >= 1; j--) {
if (j >= w) {
dp[j] = Math.max(dp[j], dp[j - w] + v);
}
}
}
return dp[W];
}
完全背包
完全背包与01背包不同就是每种物品可以有无限多个:一共有N种物品,每种物品有无限多个,第 i(i从1开始)种物品的重量为w[ i ],价值为v[ i ]。在总重量不超过背包承载上限W的情况下,能够装入背包的最大价值是多少?
分析
目标和变量和01背包没有区别,所以可定义与01背包问题几乎完全相同的状态:dp[i][j]表示将前i种物品装进限重为j的背包可以获得的最大价值, 0<=i<=N, 0<=j<=W
初始状态也是一样的,将dp[0][0…W]初始化为0,表示将前0种物品(即没有物品)装入书包的最大价值为0。那么当 i > 0 时dp[i][j]也有两种情况:
- 不装入第 i 种物品,即dp[i−1][j],同01背包;
- 装入第 i 种物品,此时和01背包不太一样,因为每种物品有无限个,所以此时不应该转移到dp[ i−1 ][j − w[i]]而应该转移到dp[ i ][j − w[i]],即装入第i种商品后还可以再继续装入第 i 种商品。
所以状态转移方程为: dp[i][j] = max(dp[i−1][j], dp[i][j−w[i]]+v[i]) // j >= w[i]
这个状态转移方程与01背包问题唯一不同就是max第二项不是dp[i-1]而是dp[i]。
可进行空间优化,优化后不同点在于这里的 j 只能正向枚举,而01背包只能逆向枚举,因为这里的max第二项是dp[ i ],而01背包是dp[i-1],即这里就是需要覆盖, 而01背包需要避免覆盖。所以伪代码如下:
// 完全背包问题思路一伪代码(空间优化版)
dp[0,...,W] = 0
for i = 1,...,N
for j = w[i],...,W
// 必须正向枚举!!!
dp[j] = max(dp[j], dp[j−w[i]]+v[i])
由上述伪代码看出,01背包和完全背包问题此解法的空间优化版解法唯一不同就是前者的 j 只能逆向枚举而后者的 j 只能正向枚举,这是由二者的状态转移方程决定的。此解法时间复杂度为O(NW), 空间复杂度为O(W)。
注意:
求解顺序的完全背包问题时,对物品的迭代应该放在最里层,对背包的迭代放在外层,只有这样才能让物品按一定顺序放入背包中。
例子
1. 分割等和子集 (leetcode 416)
可以看成一个背包大小为 sum/2 的 0-1 背包问题。
public boolean canPartition(int[] nums) {
int sum = 0;
for(int num: nums) {
sum += num;
}
if (sum % 2 == 1) {
return false;
}
int W = sum / 2;
boolean[] dp = new boolean[W + 1];
dp[0] = true;
for(int num: nums) {
for(int i = W; i >= num; i--) {
dp[i] |= dp[i - num];
}
}
return dp[W];
}
2. 目标和 (leetcode 494)
该问题可以转换为 Subset Sum 问题,从而使用 0-1 背包的方法来求解。
可以将这组数看成两部分,P 和 N,其中 P 使用正号,N 使用负号,有以下推导:
sum( P) + sum(N) = sum(nums)
sum( P) - sum(N) = target
2 * sum(N) = sum(nums) - target
因此只要找到一个子集,并且和等于 (sum(nums) - target)/2,就证明存在解。
public int findTargetSumWays(int[] nums, int target) {
int sum = 0;
for(int num: nums) {
sum += num;
}
if (sum < target) {
return 0;
}
if ((sum - target) % 2 != 0) {
return 0;
}
int W = (sum - target) / 2;
int[] dp = new int[W + 1];
dp[0] = 1;
for(int num: nums) {
for(int i = W; i >= num; i--) {
dp[i] += dp[i - num];
}
}
return dp[W];
}
3. 一和零 (leetcode 474)
这是一个多维费用的 0-1 背包问题,有两个背包大小,0 的数量和 1 的数量。
public int findMaxForm(String[] strs, int m, int n) {
if (strs == null || strs.length == 0) {
return 0;
}
int[][] dp = new int[m + 1][n + 1];
for(String str: strs) {
int zero = 0, one = 0;
for(char ch: str.toCharArray()) {
if (ch == '0') {
zero++;
} else if (ch == '1') {
one++;
}
}
for(int i = m; i >= zero; i--) {
for(int j = n; j >= one; j--) {
dp[i][j] = Math.max(dp[i][j], dp[i - zero][j - one] + 1);
}
}
}
return dp[m][n];
}
4. 零钱兑换 (leetcode 322)
因为硬币可以重复使用,因此这是一个完全背包问题。完全背包只需要将 0-1 背包的逆序遍历 dp 数组改为正序遍历即可。
public int coinChange(int[] coins, int amount) {
int[] dp = new int[amount + 1];
Arrays.fill(dp, amount + 1);
dp[0] = 0;
for(int coin: coins) {
for(int i = coin; i <= amount; i++) {
dp[i] = Math.min(dp[i], dp[i - coin] + 1);
}
}
return dp[amount] == (amount + 1) ? -1 : dp[amount];
}
5. 零钱兑换|| (leetcode 518)
完全背包问题,使用 dp 记录可达成目标的组合数目。
public int change(int amount, int[] coins) {
if (amount == 0) {
return 1;
}
int[] dp = new int[amount + 1];
dp[0] = 1;
for(int coin: coins) {
for(int j = coin; j <= amount; j++) {
dp[j] += dp[j - coin];
}
}
return dp[amount] == amount + 1 ? 0 : dp[amount];
}