背包问题的详细理解(无基础->详细掌握)
一、0/1 背包问题
在0/1背包问题中,每个物品只能选一次,要么选择放入背包,要么不选。我们需要找到一种选择,使得背包的总价值最大且不超过背包的容量。
问题描述
- 有
n
个物品,每个物品有重量w[i]
和价值v[i]
。 - 背包容量为
W
,每个物品只能选择一次。
状态转移方程解释
- 状态定义:
dp[i][j]
表示前i
个物品中,容量为j
的背包可以达到的最大价值。 - 状态转移方程:
- 不选第
i
个物品:dp[i][j] = dp[i-1][j]
- 在这种情况下,我们选择不包含第
i
个物品。因此,容量为j
的背包在前i
个物品中可以达到的最大价值就是在前i-1
个物品中最大价值。也就是说,我们直接沿用之前的值,不做任何更改。
- 在这种情况下,我们选择不包含第
- 选择第
i
个物品:dp[i][j] = max(dp[i-1][j], dp[i-1][j - w[i]] + v[i])
- 在这种情况下,我们需要考虑是否加入第
i
个物品。如果我们决定加入第i
个物品,那么剩余的容量将减少为j - w[i]
。在剩余容量下,我们需要找到前i-1
个物品中可以取得的最大价值,并加上当前物品的价值v[i]
。最终,我们通过比较选择和不选择第i
个物品时的最大价值,取两者中的最大值来更新状态。 - 这里的核心思想在于利用已知的最优子问题解来推导当前问题的最优解,这也是动态规划的主要特征之一。在解决背包问题时,这种逐步增加物品并比较选择与不选择的过程,帮助我们有效地找到每种容量下的最大价值。
- 在这种情况下,我们需要考虑是否加入第
- 不选第
public class Knapsack01 {
public static void main(String[] args) {
int[] weights = {1, 3, 4, 5};
int[] values = {15, 50, 60, 90};
int capacity = 8;
int maxValue = knapsack01(weights, values, capacity);
System.out.println("\u6700\u5927\u4ef7\u503c\u4e3a:" + maxValue);
}
public static int knapsack01(int[] weights, int[] values, int capacity) {
int n = weights.length;
int[][] dp = new int[n + 1][capacity + 1];
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= capacity; j++) {
if (weights[i - 1] <= j) {
dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - weights[i - 1]] + values[i - 1]);
} else {
dp[i][j] = dp[i - 1][j];
}
}
}
// \u53ef\u89c6\u5316 dp \u8868\u683c
System.out.println("\u52a8\u6001\u89c4\u5212\u8868\u683c\uff1a");
for (int i = 0; i <= n; i++) {
for (int j = 0; j <= capacity; j++) {
System.out.print(dp[i][j] + "\t");
}
System.out.println();
}
return dp[n][capacity];
}
}
表格记录数据变化
物品数/背包容量 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
---|---|---|---|---|---|---|---|---|---|
0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
1 | 0 | 15 | 15 | 15 | 15 | 15 | 15 | 15 | 15 |
2 | 0 | 15 | 15 | 50 | 65 | 65 | 65 | 65 | 65 |
3 | 0 | 15 | 15 | 50 | 65 | 75 | 75 | 110 | 125 |
4 | 0 | 15 | 15 | 50 | 65 | 90 | 105 | 110 | 140 |
二、完全背包问题
在完全背包问题中,每个物品可以选取多次。问题的思路与0/1背包类似,状态定义相同,但状态转移有所不同。
状态转移方程
- 状态定义:与 0/1 背包类似,
dp[i][j]
表示前i
个物品中容量为j
的背包的最大价值。 - 状态转移方程:
dp[i][j] = max(dp[i-1][j], dp[i][j - w[i]] + v[i])
- 不选择第
i
个物品:dp[i][j] = dp[i-1][j]
,此时的解释与 0/1 背包一致,表示直接沿用前i-1
个物品的最佳解,意味着我们并不将当前物品纳入背包的选择中。 - 选择第
i
个物品:与 0/1 背包不同,这里可以多次选择相同的物品。因此,我们在选择第i
个物品后,还可以继续选择这个物品。在减少容量的基础上,我们加上当前物品的价值v[i]
,并查看之前相应容量下的最佳状态dp[i][j - w[i]]
。这意味着在完全背包问题中,我们不仅可以使用每个物品一次,还可以无限制地重复使用,这样可以更有效地利用背包的剩余容量。 - 完全背包问题中的动态规划求解方法与 0/1 背包的主要区别在于对物品的重复使用。我们通过更新状态,使得每个物品都可以被多次加入,只要当前容量足够容纳它。这使得求解过程更为灵活,也需要更为复杂的考虑,以确保所有可能的情况都得到评估。
- 不选择第
public class CompleteKnapsack {
public static void main(String[] args) {
int[] weights = {1, 3, 4, 5};
int[] values = {15, 50, 60, 90};
int capacity = 8;
int maxValue = completeKnapsack(weights, values, capacity);
System.out.println("最大价值为:" + maxValue);
}
public static int completeKnapsack(int[] weights, int[] values, int capacity) {
int n = weights.length;
int[] dp = new int[capacity + 1];
for (int i = 0; i < n; i++) {
for (int j = weights[i]; j <= capacity; j++) {
dp[j] = Math.max(dp[j], dp[j - weights[i]] + values[i]);
}
}
/// 可视化 dp 表格 System.out.println("动态规划表格:");
for (int j = 0; j <= capacity; j++) {
System.out.print(dp[j] + "\t");
}
System.out.println();
return dp[capacity];
}
}
表格记录数据变化
背包容量 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
---|---|---|---|---|---|---|---|---|---|
最大价值 | 0 | 15 | 30 | 50 | 65 | 90 | 105 | 120 | 140 |
三、多重背包问题
在多重背包问题中,每个物品的数量是有限的,不同于0/1背包和完全背包。
状态转移方程
- 状态定义:
dp[j]
表示容量为j
的背包可达到的最大价值。 - 状态转移方程:
- 对于每个物品
i
,我们可以选择0~k
个,其中1 <= k <= quantities[i]
,并且需要满足k * weights[i] <= j
。 - 我们通过一个嵌套循环来模拟不同数量的物品加入背包的情况,更新背包容量为
j
时的最大价值:dp[j] = max(dp[j], dp[j - k * weights[i]] + k * values[i])
。 - 在多重背包问题中,每个物品的数量是有限的,因此我们需要用双重循环去遍历每个物品的可能数量,并求得最大价值。这样,我们既能保证所有物品的数量限制得到满足,同时也能确保每种选择下的最大价值被计算出来。
- 这种方式通过逐层叠加每种物品的不同数量,从而在有限的物品中求得最大价值。这种方法的挑战在于要平衡物品数量的限制和容量的限制,以找到每种情况下的最优解。相比于 0/1 背包和完全背包,多重背包需要对每个物品的使用次数加以控制,因此状态的更新逻辑更为复杂,需要嵌套循环去计算每个可能的状态。
- 对于每个物品
public class MultipleKnapsack {
public static void main(String[] args) {
int[] weights = {1, 3, 4};
int[] values = {15, 50, 60};
int[] quantities = {2, 3, 1};
int capacity = 8;
int maxValue = multipleKnapsack(weights, values, quantities, capacity);
System.out.println("最大价值为:" + maxValue);
}
public static int multipleKnapsack(int[] weights, int[] values, int[] quantities, int capacity) {
int n = weights.length;
int[] dp = new int[capacity + 1];
for (int i = 0; i < n; i++) {
for (int j = capacity; j >= weights[i]; j--) {
for (int k = 1; k <= quantities[i] && k * weights[i] <= j; k++) {
dp[j] = Math.max(dp[j], dp[j - k * weights[i]] + k * values[i]);
}
}
}
// 可视化 dp 表格
System.out.println("动态规划表格:");
for (int j = 0; j <= capacity; j++) {
System.out.print(dp[j] + "\t");
}
System.out.println();
return dp[capacity];
}
}
表格记录数据变化
背包容量 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
---|---|---|---|---|---|---|---|---|---|
最大价值 | 0 | 15 | 30 | 50 | 65 | 90 | 105 | 115 | 125 |
四、分数背包问题(贪心算法)
分数背包问题不同于其他几种背包问题,在这种情况下,物品可以被部分选择。因此,使用贪心算法来实现。
问题描述
- 按照单位重量的价值来对物品进行排序,然后依次将物品加入背包。
状态转移方程解释
- 贪心策略:由于分数背包可以部分选择物品,动态规划并不是最优解法,而是使用贪心算法。
- 策略:按照单位重量的价值对物品进行排序,优先选择单位价值最高的物品。这样可以保证每次选择对背包总价值的增量最大。
- 在贪心策略中,我们从单位重量价值最高的物品开始选择,将其尽可能地加入到背包中。如果背包容量不足以容纳整个物品,则选择部分物品来填充剩余空间。
- 每次选择物品时,我们减小背包的容量,可以部分选择该物品以使背包的总价值最大化。这种方法的核心思想是尽量使单位价值最高的物品占据更多的容量,这样可以在有限容量内获得最大的收益。这也是贪心算法在分数背包问题中的最优解策略。
- 分数背包与其他背包问题不同的地方在于它允许部分选择,因此不再需要使用动态规划,而是通过排序和逐步贪心地选择来实现最优解。这使得问题求解的时间复杂度得到了有效降低。
import java.util.Arrays;
import java.util.Comparator;
public class FractionalKnapsack {
static class Item {
int weight, value;
Item(int weight, int value) {
this.weight = weight;
this.value = value;
}
}
public static void main(String[] args) {
Item[] items = {
new Item(1, 15),
new Item(3, 50),
new Item(4, 60),
new Item(5, 90)
};
int capacity = 8;
double maxValue = fractionalKnapsack(items, capacity);
System.out.println("最大价值为:" + maxValue);
}
public static double fractionalKnapsack(Item[] items, int capacity) {
Arrays.sort(items, Comparator.comparingDouble(i -> -1.0 * i.value / i.weight));
double maxValue = 0.0;
int remainingCapacity = capacity;
for (Item item : items) {
if (remainingCapacity >= item.weight) {
maxValue += item.value;
remainingCapacity -= item.weight;
} else {
maxValue += (double) item.value * remainingCapacity / item.weight;
break;
}
}
return maxValue;
}
}
表格记录数据变化
物品序号 | 重量 | 价值 | 单位重量价值 | 已选重量 | 剩余容量 | 当前总价值 |
---|---|---|---|---|---|---|
1 | 1 | 15 | 15.0 | 1 | 7 | 15.0 |
2 | 3 | 50 | 16.67 | 3 | 4 | 65.0 |
3 | 4 | 60 | 15.0 | 4 | 0 | 125.0 |
五、使用背包问题解法解决的其他算法问题
-
子集和问题
- 问题描述:给定一个整数集合,判断是否存在一个子集,使得其和等于给定的目标值。这个问题可以看作0/1背包问题的变形,每个物品的重量等于它的值,目标是找到和等于目标值的组合。
状态转移方程解释
-
状态定义:
dp[i][j]
表示前i
个数中是否可以找到一个子集,使得其和为j
。 -
状态转移方程:
dp[i][j] = dp[i - 1][j] || dp[i - 1][j - nums[i - 1]]
- 不选择当前元素:我们直接延续前
i-1
个元素是否可以达到和为j
的状态。 - 选择当前元素:如果选择第
i
个元素,那么需要看j - nums[i - 1]
是否能由前i-1
个元素构成。如果可以构成,则当前状态为true
,表示可以找到满足条件的子集。 - 这个问题的核心思想是将问题转化为一个决策问题,通过选择或不选择每个元素来判断是否可以达到目标和。动态规划表格的每个状态都基于之前的状态更新,从而逐步解决整个问题。
public class SubsetSum { public static void main(String[] args) { int[] nums = {3, 34, 4, 12, 5, 2}; int target = 9; boolean result = subsetSum(nums, target); System.out.println("是否存在和为目标值的子集:" + result); } public static boolean subsetSum(int[] nums, int target) { int n = nums.length; boolean[][] dp = new boolean[n + 1][target + 1]; for (int i = 0; i <= n; i++) { dp[i][0] = true; } for (int i = 1; i <= n; i++) { for (int j = 1; j <= target; j++) { if (nums[i - 1] <= j) { dp[i][j] = dp[i - 1][j] || dp[i - 1][j - nums[i - 1]]; } else { dp[i][j] = dp[i - 1][j]; } } } // 可视化 dp 表格 System.out.println("动态规划表格:"); for (int i = 0; i <= n; i++) { for (int j = 0; j <= target; j++) { System.out.print((dp[i][j] ? 1 : 0) + "\t"); } System.out.println(); } return dp[n][target]; } }
表格记录数据变化
数组长度/目标值 0 1 2 3 4 5 6 7 8 9 0 1 0 0 0 0 0 0 0 0 0 1 1 0 0 1 0 0 0 0 0 0 2 1 0 0 1 0 0 0 0 0 0 3 1 0 0 1 1 0 0 1 0 0 4 1 0 0 1 1 0 0 1 1 0 5 1 0 1 1 1 1 0 1 1 1 6 1 1 1 1 1 1 1 1 1 1 - 不选择当前元素:我们直接延续前
-
找零问题
- 问题描述:给定不同面值的硬币和一个总金额,求最少需要多少枚硬币可以凑成这个金额。如果没有硬币组合能组成总金额,则返回 -1。这个问题可以看作完全背包问题,每种面值的硬币可以选择无限次。
状态转移方程解释
-
状态定义:
dp[j]
表示凑成金额j
所需的最少硬币数。 -
状态转移方程:
dp[j] = min(dp[j], dp[j - coin] + 1)
- 对于每种硬币面值,计算在达到金额
j
时选择当前硬币是否可以减少硬币数量。dp[j - coin]
表示不选择当前硬币的最小数量,而+1
表示我们加入了当前硬币。 - 通过这种方式,我们能够找到在给定的硬币面值下,凑成目标金额所需的最少硬币数。这个问题的难点在于要同时考虑多个面值的硬币,找到使得数量最小的组合。
- 这种动态规划的解法通过从金额为
0
开始逐步向上递增,确保在每个阶段都找到最优解。这种方法也具有较高的时间效率,能够在有限的硬币面值下快速找到最优方案。
public class CoinChange { public static void main(String[] args) { int[] coins = {1, 2, 5}; int amount = 11; int result = coinChange(coins, amount); System.out.println("最少硬币数:" + result); } public static int coinChange(int[] coins, int amount) { int max = amount + 1; int[] dp = new int[amount + 1]; Arrays.fill(dp, max); dp[0] = 0; for (int coin : coins) { for (int j = coin; j <= amount; j++) { dp[j] = Math.min(dp[j], dp[j - coin] + 1); } } // 可视化 dp 表格 System.out.println("动态规划表格:"); for (int j = 0; j <= amount; j++) { System.out.print((dp[j] == max ? "∞" : dp[j]) + "\t"); } System.out.println(); return dp[amount] > amount ? -1 : dp[amount]; } }
表格记录数据变化
金额 0 1 2 3 4 5 6 7 8 9 10 11 最少硬币数 0 1 1 2 2 1 2 2 3 3 2 3 - 对于每种硬币面值,计算在达到金额
-
分割等和子集
- 问题描述:给定一个非负整数数组,判断是否可以将这个数组分割成两个子集,使得两个子集的和相等。这可以看作是一个0/1背包问题,目标是找到一个子集,使得其和等于数组总和的一半。
状态转移方程解释
-
状态定义:
dp[j]
表示是否可以通过选择数组中的若干元素,使得它们的和为j
。 -
状态转移方程:
dp[j] = dp[j] || dp[j - num]
- 对于每个元素
num
,如果当前容量j
可以通过之前的组合或者通过加入当前元素来实现,则dp[j]
为true
。 - 该问题可以看作是子集和问题的变种,目标是找到一个子集,其总和等于数组总和的一半。我们通过遍历每个元素,逐步判断在不同容量下是否可以找到相应的子集,从而得出最终结果。
- 分割等和子集问题的解决方法与子集和问题类似,通过动态规划逐步更新状态,确保每个状态的值基于之前的决策而来。最终,我们能够判断是否存在这样一个子集,使得其和等于目标值。
public class PartitionEqualSubsetSum { public static void main(String[] args) { int[] nums = {1, 5, 11, 5}; boolean result = canPartition(nums); System.out.println("是否可以分割为两个等和子集:" + result); } public static boolean canPartition(int[] nums) { int sum = Arrays.stream(nums).sum(); if (sum % 2 != 0) return false; int target = sum / 2; boolean[] dp = new boolean[target + 1]; dp[0] = true; for (int num : nums) { for (int j = target; j >= num; j--) { dp[j] = dp[j] || dp[j - num]; } } // 可视化 dp 表格 System.out.println("动态规划表格:"); for (int j = 0; j <= target; j++) { System.out.print((dp[j] ? 1 : 0) + "\t"); } System.out.println(); return dp[target]; } }
表格记录数据变化
子集和 0 1 2 3 4 5 6 7 8 9 10 11 是否可达 1 1 0 0 1 1 0 1 0 0 1 1 - 对于每个元素
总结
- 0/1 背包问题:每个物品只能选一次,使用二维数组保存状态。
- 完全背包问题:每个物品可以选多次,优化空间复杂度到一维数组。
- 多重背包问题:每个物品的数量有限,加入多重循环。
- 分数背包问题:物品可以部分选择,使用贪心算法。
- 背包问题的其他应用:子集和问题、找零问题、分割等和子集、工作选择问题等都可以通过背包问题的思路来解决。
每种问题都有各自的特点和适用场景,根据具体需求可以选择合适的算法。每个示例中都有代码和可视化部分帮助你理解动态规划表格的变化过程。希望这能帮助你理解不同类型的背包问题。