点击蓝色“
五分钟学算法
”关注我哟
加个“
星标
”,天天中午 12:15,一起学算法
作者 | P.yh
来源 | 五分钟学算法
一、概述
背包问题是一类比较
特殊的动态规划
问题,这篇文章的侧重点会在答案的推导过程上,我们还是会使用之前提到的解动态规划问题的四个步骤来思考这类问题。
在讲述背包问题之前,首先提及一下,背包类动态规划问题和其他的动态规划问题的不同之处在于,
背包类动态规划问题会选用值来作为动态规划的状态
,你可以回顾下之前我们讨论过的动态规划问题,基本上都是利用数组或者是字符串的下标来表示动态规划的状态。
针对背包类问题,我们依然可以
画表格
来辅助我们思考问题,但是背包类问题有基本的雏形,题目特征特别明显,当你理解了这类问题的解法后,遇到类似问题基本上不需要额外的辅助就可以给出大致的解法,这也就是说,学习背包类问题是一个性价比很高的事情,理解了一个特定问题的解法,基本上一类问题都可以直接套这个解法。
二、问题雏形
首先我们来看看这样一个问题:
有 N 件物品和一个容量为 V 的背包。第 i 件物品的体积是 C[i],价值是 W[i]。求解将哪些物品装入背包可使价值总和最大。求出最大总价值
话不多说,我们还是按之前的分析四步骤来看看这个问题:
-
问题拆解
我们要求解的问题是 “
背包能装入物品的最大价值
”,这个问题的结果受到两个因素的影响,就是背包的大小,以及物品的属性(包括大小和价值)。
对于物品来说,只有两种结果,放入背包以及不放入背包
,这里我们用一个例子来画画表格:
假设背包的大小是 10,有 4 个物品,体积分别是 [2,3,5,7],价值分别是 [2,5,2,5]。
1、如果我们仅考虑将前一个物品放入背包,只要背包体积大于 2,此时都可以获得价值为 2 的最大价值:
图一
2、如果我们仅考虑将前两个物品放入背包,如果背包体积大于或等于 5,表示两个物体都可放入,此时都可以获得价值为 2+5=7 的最大价值,如果不能全都放入,那就要选择体积不超,价值最大的那个:
图二
3、如果我们仅考虑将前三个物品放入背包,如果背包体积大于或等于 10,表示三个物体都可放入,此时都可以获得价值为 2+5+2=9 的最大价值,如果不能全都放入,那就要选择体积不超,价值最大的那个方案:
图三
4、如果我们考虑将所有物品放入背包,我们可以依据前三个物品放入的结果来制定方案:
图四
这样,我们就根据物品和体积将问题拆分成子问题,也就是 “
前 n 个物品在体积 V 处的最大价值
” 可以由 “前 n - 1 个物品的情况” 推导得到。
-
状态定义
在问题拆解中,我们得知问题其实和背包的体积还有当前考虑的物品有关,因此我们可以定义
dp[i][j]
表示 “
考虑将前 i 个物品放入体积为 j 的背包里所获得的最大价值
”
-
递推方程
当我们考虑是否将第 i 个物品放入背包的时候,这里有两种情况
-
不放入,也就是不考虑第 i 个物品,那么问题就直接变成了上一个子问题,也就是考虑将 i - 1 个物品放入背包中,这样当前问题的解就是之前问题的解:
dp[i][j] = dp[i - 1][j]
-
如果背包体积大于第 i 个物品的体积,我们可以考虑将第 i 个物品放入,这个时候我们要和之前的状态做一个比较,选取最大的方案:
dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - C[i]] + W[i])
-
实现
实现这一环节还是主要考虑状态数组如何初始化,你可以看到,我们每次都要考虑 i - 1,另外还要考虑背包体积为 0 的情况,因此初始化数组时多开一格可以省去不必要的麻烦
public int zeroOnePack(int V, int[] C, int[] W) {
// 防止无效输入
if ((V <= 0) || (C.length != W.length)) {
return 0;
}
int n = C.length;
// dp[i][j]: 对于下标为 0~i 的物品,背包容量为 j 时的最大价值
int[][] dp = new int[n + 1][V + 1];
// 背包空的情况下,价值为 0
dp[0][0] = 0;
for (int i = 1; i <= n; ++i) {
for
(int j = 1; j <= V; ++j) {
// 不选物品 i 的话,当前价值就是取到前一个物品的最大价值,也就是 dp[i - 1][j]
dp[i][j] = dp[i - 1][j];
// 如果选择物品 i 使得当前价值相对不选更大,那就选取 i,更新当前最大价值
if ((j >= C[i - 1]) && (dp[i][j] 1][j - C[i - 1]] + W[i - 1])) {
dp[i][j] = dp[i - 1][j - C[i - 1]] + W[i - 1];
}
}
}
// 返回,对于所有物品(0~N),背包容量为 V 时的最大价值
return dp[n][V];
}
这里还有一个空间上面的优化,如果你回到我们之前画的表格,考虑前 i 个问题的状态只会依赖于前 i - 1 个问题的状态,也就是
dp[i][...]
只会依赖于
dp[i - 1][...]
,另外一点就是当前考虑的背包体积只会用到比其小的体积。
基于这些信息,我们状态数组的维度可以少开一维,但是遍历的方向上需要从后往前遍历,从而保证子问题需要用到的数据不被覆盖,优化版本如下:
public int zeroOnePackOpt(int V, int[] C, int[] W) {
// 防止无效输入
if ((V <= 0) || (C.length != W.length)) {
return 0;
}
int n = C.length;
int[] dp = new int[V + 1];
// 背包空的情况下,价值为 0
dp[0] = 0;
for (int i = 0; i for (int j = V; j >= C[i]; --j) {
dp[j] = Math.max(dp[j], dp[j - C[i]] + W[i]);
}
}
return dp[V];
}
这里,因为物品只能被选中 1 次,或者被选中 0 次,因此我们称这种背包问题为
01 背包问题
。
还有一类背包问题,物品可以被选多次或者 0 次,这类问题我们称为
完全背包问题
,这类背包问题和 01 背包问题很类似,略微的不同在于,在完全背包问题中,状态
dp[i][j]
依赖的是
dp[i - 1][j]
以及
dp[i][k] k < j
,你可以看看下面的实现代码:
public int completePack(int V, int[] C, int[] W) {
// 防止无效输入
if (V == 0 || C.length != W.length) {
return 0;
}
int n = C.length;
// dp[i][j]: 对于下标为 0~i 的物品,背包容量为 j 时的最大价值
int[][] dp = new int[n + 1][V + 1];
// 背包空的情况下,价值为 0
dp[0][0] = 0;
for (int i = 1; i <= n; ++i) {
for (int j = 1; j <= V; ++j) {
// 不取该物品
dp[i][j] = dp[i - 1][j];
// 取该物品,但是是在考虑过或者取过该物品的基础之上(dp[i][...])取
// 0-1背包则是在还没有考虑过该物品的基础之上(dp[i - 1][...])取
if ((j >= C[i - 1]) && (dp[i][j - C[i - 1]] + W[i - 1] > dp[i][j])) {
dp[i][j] = dp[i][j - C[i - 1]] + W[i - 1];
}
}
}
// 返回,对于所有物品(0~N),背包容量为 V 时的最大价值
return dp[n][V];
}
类似的,我们还是可以对状态数组进行空间优化,依据我们之前讨论的状态之间的依赖关系,
完全背包的空间优化我们直接把状态数组少开一维即可,遍历方式都不需要改变
:
public int completePackOpt(int V, int[] C, int[] W) {
if (V == 0 || C.length != W.length) {
return 0;
}
int n = C.length;
int[] dp = new int[V + 1];
for (int i = 0; i for (int j = C[i]; j <= V; ++j) {
dp[j] = Math.max(dp[j], dp[j - C[i]] + W[i]);
}
}
return dp[V];
}
下面,我们就根据这两类背包问题,看看遇到类似的问题我们是否可以套用上面我们介绍的解法。
三、相关题目实战
LeetCode 第 416 号问题:分割等和子集。
题目来源:
https://leetcode-cn.com/problems/partition-equal-subset-sum/
题目描述
给定一个
只包含正整数的非空
数组。是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
注意:
-
每个数组中的元素不会超过 100
-
数组的大小不会超过 200
示例 1:
输入: [1, 5, 11, 5]
输出: true
解释: 数组可以分割成 [1, 5, 5] 和 [11].
示例 2:
输入: [1, 2, 3, 5]
输出: false
解释: 数组不能分割成两个元素和相等的子集.
题目分析
题目给定一个数组,问是否可以将数组拆分成两份,并且两份的值相等,
这里并不是说分成两个子数组,而是分成两个子集
。
直观的想法是直接遍历一遍数组,这样我们可以得到数组中所有元素的和,这个和必须是偶数,不然没法分,其实很自然地就可以想到,我们要从数组中挑出一些元素,使这些元素的和等于原数组中元素总和的一半,“
从数组中找出一些元素让它们的和等于一个固定的值
”,这么一个信息能否让你想到背包类动态规划呢?
如果你能想到这个地方,再配上我们之前讲的
01 背包问题
的解法,那么这道题目就可以直接套解法了,这里我就不具体分析了。
参考代码
public boolean canPartition(int[] nums) {
if (nums == null || nums.length == 0) {
return false;
}
int sum = 0;
int n = nums.length;
for (int i = 0; i sum += nums[i];
}
if (sum % 2 != 0) {
return false;
}
int target = sum / 2;
boolean[] dp = new boolean[target + 1];
dp[0] = true;
for (int i = 0; i for (int j = target; j >= nums[i]; --j) {
dp[j] |= dp[j - nums[i]];
}
}
return dp[target];
}
LeetCode 第 322 号问题:零钱兑换。
题目来源:
https://leetcode-cn.com/problems/coin-change
题目描述
给定不同面额的硬币
coins
和一个总金额
amount
。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回
-1
。
示例 1:
输入: coins = [1, 2, 5], amount = 11
输出: 3
解释: 11 = 5 + 5 + 1
示例 2:
输入: coins = [2], amount = 3
输出: -1
说明:
你可以认为每种硬币的数量是无限的。
题目分析
题目给定一个数组和一个整数,数组里面的值表示的是每个硬币的价值,整数表示的是一个价值,问最少选择多少个硬币能够组成这个价值,硬币可以重复选择。
虽然这里只有一个输入数组,但是我们还是可以看到背包的影子,这里的整数就可以看作是背包的体积,然后数组里面的值可以看作是物品的体积,那物品的价值呢?
在这里,你可以形象地认为每个物品的价值是 1,最后我们要求的是填满背包的最小价值,因为这里物品是可以重复选择多次的,因此可以归类于
完全背包问题
,套用之前的解法就可以解题,唯一要注意的一点是,这里我们不在求最大价值,而求的是最小价值,因此我们需要先将状态数组初始化成无穷大。
参考代码
public int coinChange(int[] coins, int amount) {
int[] dp = new int[amount + 1];
Arrays.fill(dp, Integer.MAX_VALUE);
dp[0] = 0;
for (int i = 0; i for (int j = coins[i]; j <= amount; ++j) {
if (dp[j - coins[i]] != Integer.MAX_VALUE) {
dp[j] = Math.min(dp[j - coins[i]] + 1, dp[j]);
}
}
}
return dp[amount] == Integer.MAX_VALUE ? -1 : dp[amount];
}
辅助动画
LeetCode 第 518 号问题:零钱兑换II。
题目来源:
https://leetcode-cn.com/problems/coin-change-2/
题目描述
给定不同面额的硬币和一个总金额。写出函数来计算可以凑成总金额的硬币组合数。假设每一种面额的硬币有无限个。
示例 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: