今天是动态规划的另一个大类,背包问题。
背包问题的分类

这张图中涵盖了我们能遇到的绝大部分背包问题。
首先是01背包问题
01背包问题
01背包问题: 有n件物品和一个最多能背重量为w 的背包。第i件物品的重量是weighti,得到的价值是valuei 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。
对于01背包问题的解法,从dp数组的定义来看,可以写成二维数组和一维数组,接下来使用五部曲来分析分析这两种的写法区别。
这里先放一个例子,分析也都是通过这个例子来进行的:
背包最大重量为4。
物品为:
| 重量 | 价值 | |
|---|---|---|
| 物品0 | 1 | 15 |
| 物品1 | 3 | 20 |
| 物品2 | 4 | 30 |
问背包能背的物品最大价值是多少?
二维dp数组
1.dp数组的含义
因为背包问题涉及背包容量与物品两个维度,所以可以使用二维数组。那么对于dpij来说,这里假设i表示物品编号,j表示物品容量。
那么对于dp数组的含义呢?

从图中可以看出来,dp数组的含义
dpij 表示从下标为0-i的物品里任意取,放进容量为j的背包,价值总和最大是多少。
2.递推公式
dpij是由那些位置所决定的呢?
假设对于dp14,表示在0到1之间的物品任取放入背包容量4的背包中所能得到的最大价值。
求取 dp14 有两种情况:
- 放物品1
- 还是不放物品1
假设不放物品1,那么这个值应该是物品0放入背包4的最大价值,是15。
假设放入物品1,那么在放入物品1 之前需要确保背包的容量足够,那么才能放入物品1,并且需要价值最大,那么就需要保证出去物品1的容量后,背包的价值应改保持最大,此时表示为dpi-1j-weigth\[i]。那么再加上物品1的价值,就是放物品1的最大价值。
综上来看:
dpij=max(dpi-1j,dpi-1j-weigthi+valuei)
3.初始化

根据之前的分析,就能很清楚的看出来如何初始化。而其他的部分,按照dp的递推公式来看,初始化为什么其实都不重要,因为都会被覆盖掉。
4.遍历顺序
两个维度,物品与背包容量,其实先遍历谁都一样,结合dp公式和图来看,每一个dp都是有上一层和左上角位置所决定的,那么无论先遍历谁,这两个值都会提前被确定下来,所以不会影响dpij的值。
题目:携带研究材料(第六期模拟笔试)
题目描述
小明是一位科学家,他需要参加一场重要的国际科学大会,以展示自己的最新研究成果。他需要带一些研究材料,但是他的行李箱空间有限。这些研究材料包括实验设备、文献资料和实验样本等等,它们各自占据不同的空间,并且具有不同的价值。
小明的行李空间为 N,问小明应该如何抉择,才能携带最大价值的研究材料,每种研究材料只能选择一次,并且只有选与不选两种选择,不能进行切割。
输入描述
第一行包含两个正整数,第一个整数 M 代表研究材料的种类,第二个正整数 N,代表小明的行李空间。
第二行包含 M 个正整数,代表每种研究材料的所占空间。
第三行包含 M 个正整数,代表每种研究材料的价值。
输出描述
输出一个整数,代表小明能够携带的研究材料的最大价值。
题目分析:
标准的01背包问题。看代码
cpp
#include<iostream>
#include<string>
#include<vector>
using namespace std;
int main()
{
int n, bagweight;// bagweight代表行李箱空间
cin >> n >> bagweight;
vector<int> weight(n, 0); // 存储每件物品所占空间
vector<int> value(n, 0); // 存储每件物品价值
for(int i = 0; i < n; ++i) {
cin >> weight[i];
}
for(int j = 0; j < n; ++j) {
cin >> value[j];
}
//dp数组定于
vector<vector<int>> dp(weight.size(), vector<int>(bagweight + 1, 0));
// 初始化, 因为需要用到dp[i - 1]的值
// j < weight[0]已在上方被初始化为0
// j >= weight[0]的值就初始化为value[0]
for (int j = weight[0]; j <= bagweight; j++) {
dp[0][j] = value[0];
}
for(int i = 1; i < weight.size(); i++) { // 遍历科研物品
for(int j = 0; j <= bagweight; j++) { // 遍历行李箱容量
if (j < weight[i]) dp[i][j] = dp[i - 1][j]; // 如果装不下这个物品,那么就继承dp[i - 1][j]的值
else {
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
}
}
}
cout << dp[n - 1][bagweight] << endl;
return 0;
}
一维dp数组(滚动数组)
在使用二维数组的时候,递推公式:dpij = max(dpi - 1j, dpi - 1j - weight\[i] + valuei);
其实可以发现如果把dpi - 1那一层拷贝到dpi上,表达式完全可以是:dpij = max(dpij, dpij - weight\[i] + valuei);
与其把dpi - 1这一层拷贝到dpi上,不如只用一个一维数组了,只用dpj(一维数组,也可以理解是一个滚动数组)。
那么继续五部曲:
dp数组的含义,和二维数组的差不多
在一维dp数组中,dpj表示:容量为j的背包,所背的物品价值可以最大为dpj。
递推公式
因为是将上一层的复制过来,所以不用考虑i的问题
dpj = max(dpj, dpj - weight\[i] + valuei)
因为是复制过来的,dpj这个位置本身是有值的,所以需要和自身比较。
初始化
dp0的位置很明显是0,那么其他位置呢?考虑到递推公式中需要和自身进行比较,为了不影响这个过程,初始化为0即可。
遍历顺序
这里先给出遍历的代码
cpp
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]);
}
}
可以看到,遍历背包容量的时候使用了倒序,这是因为为正序遍历会导致在求dpj时,将dpj的上一层提前复制,而在这一层会依靠上一次的结果,导致物品被多次放入。所以倒序遍历是为了保证物品i只被放入一次!
那么他们之间的顺序是否可以像二维dp那样颠倒呢?
不可以。因为他是依赖于上一层的。
同样的题目,使用一维数组求解。
题目:携带研究材料(第六期模拟笔试)
cpp
#include<iostream>
#include<string>
#include<vector>
using namespace std;
int main()
{
// 读取 M 和 N
int M, N;
cin >> M >> N;
vector<int> costs(M);
vector<int> values(M);
for (int i = 0; i < M; i++) {
cin >> costs[i];
}
for (int j = 0; j < M; j++) {
cin >> values[j];
}
// 创建一个动态规划数组dp,初始值为0
vector<int> dp(N + 1, 0);
for(int i=0;i<M;i++){
for(int j=N;j>=costs[i];j--){
dp[j]=max(dp[j],dp[j-costs[i]]+values[i]);
}
}
// 输出dp[N],即在给定 N 行李空间可以携带的研究材料最大价值
cout << dp[N] << endl;
return 0;
}
好了,看完01背包的基础,来看一看他的应用题
题目:分割等和子集
给你一个 只包含正整数 的 非空 数组 nums 。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
示例 1:
输入:nums = [1,5,11,5]
输出:true
解释:数组可以分割成 [1, 5, 5] 和 [11] 。
题目分析:
首先,本题要求集合里能否出现总和为 sum / 2 的子集。
那么来一一对应一下本题,看看背包问题如何来解决。
只有确定了如下四点,才能把01背包问题套到本题上来。
- 背包的体积为sum / 2
- 背包要放入的商品(集合里的元素)重量为 元素的数值,价值也为元素的数值
- 背包如果正好装满,说明找到了总和为 sum / 2 的子集。
- 背包中每一个元素是不可重复放入。
那么按照五部曲开始走。
dpj表示 背包总容量(所能装的总重量)是j,放进物品后,背的最大重量为dpj。那么如果背包容量为target, dptarget就是装满 背包之后的重量,所以 当 dptarget == target 的时候,背包就装满了。
01背包的递推公式为:dpj = max(dpj, dpj - weight\[i] + valuei);
本题,相当于背包里放入数值,那么物品i的重量是numsi,其价值也是numsi。
所以递推公式:dpj = max(dpj, dpj - nums\[i] + numsi);
cs
public class Solution {
public bool CanPartition(int[] nums) {
int sum=0;//数组和
for(int i=0;i<nums.Length;i++){
sum+=nums[i];
}
if (sum % 2 == 1) return false;
int target = sum / 2;
int[] dp = new int[10001];//拆成2半,所有取和的一半即可
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]);
}
}
if (dp[target] == target)
return true;
return false;
}
}
对于更详细的解析与其他语言的代码块,可以去代码随想录上查看。