背包问题基础
背包问题用于求解有限背包容量下,装入物品的最大价值,并根据每件物品的数量划分为01背包、完全背包、多重背包等多种问题,其框架如下:
01背包问题
有n件物品和一个最多能背重量为w 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。 每件物品只能用一次 ,求解将哪些物品装入背包里物品价值总和最大。
暴力解法:每件物品本质上只有 装 与 不装 两种选择,因此完整遍历n个物品以搜索出所有情况,表现为一个满二叉树,时间复杂度为O(2^n)
因此需要动态规划方法来避免这种指数级别的时间复杂度:
1. dp数组及其下标含义:
为了同时考虑 物品价值 和 背包容量 两个维度,需要使用二维数组。此时,dp[i][j]
表示任取下标为[0-i]
的物品,放进容量为j
的背包,最大的价值总和:
以一组物品为例
编号 | 重量 | 价值 |
---|---|---|
物品0 | 1 | 15 |
物品1 | 3 | 20 |
物品2 | 4 | 30 |
首先考虑仅将物品0放入背包,即dp[0][j]
容量为0时放不下,自容量1开始,仅放物品0的价值均为15
接下来考虑包含物品0和物品1的情况,即dp[1][j]
容量为0时放不下,自容量1开始放入物品0,当容量3时可以放入价值更大的物品1,并在重量4全部放入
更好理解得,dp[1][4]
表示从物品0、1中任取放入容量为4的背包,最大价值为35
2. 递推公式:
思考dp[i][j]
可以由哪些情况推出?
A.不放物品 i:
此时dp[i-1][j]
仅选取物品0~物品i-1,即不放物品 i 的最大价值
dp[1][4]的一种情况是不放物品1,此时其可由dp[0][4]推导得到
B.放入物品 i :
那么就必须预留出物品 i 的容量,找出背包能支持放入的最大价值dp[i][j-weight(i)]
,再加上放入物品 i 的价值value(i)
dp[1][4]的另一种情况是放入物品1,此时dp[1][4-3]=dp[1][1]首次预留出了足够容量,再向其中放入物品1计算新的价值。
因此在本例子中可得递推公式:
dp[1][4] = max(dp[0][4], dp[0][1] + 物品1 的重量)
总结可得01背包问题的总体递推公式为:
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
3. 数组初始化:
在初始状态,二维数组中部分值是确定的:
- 背包容量为0:此时无论如何也不能放进物品,因此
dp[i][0]=0
- 仅物品0时放不下:即
j < weight[0]
,此时dp[0][j]=0
- 仅物品0时能放下:即
j >= weight[0]
,此时dp[0][j]=value[0]
4. 遍历顺序:
由递推公式可得,dp[i][j]
完全由dp[i-1][j]
推导而来,因此必须先计算出小编号物品的dp行,才能计算更大编号的物品。因此先遍历物品,然后遍历背包重量。
01背包问题场景题
小明需要携带一些材料,它们各自占据不同的空间,并且具有不同的价值。若小明的行李空间为 N,问小明应该如何抉择,才能携带最大价值的材料?规定每种材料只能选择一次,并且只有选与不选两种选择,不能进行切割。
输入描述
第一行包含两个正整数,第一个整数 M 代表研究材料的种类,第二个正整数 N,代表小明的行李空间。
第二行包含 M 个正整数,代表每种研究材料的所占空间。
第三行包含 M 个正整数,代表每种研究材料的价值。
输出描述
输出一个整数,代表小明能够携带的研究材料的最大价值。
输入示例
6 1
2 2 3 1 5 2
2 3 1 5 4 3
输出示例
5
代码实现
#include <bits/stdc++.h>
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数组, dp[i][j]代表行李箱空间为j的情况下,从下标为[0, i]的物品里面任意取,能达到的最大价值
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;
}
01背包问题的一维解法
我们使用二维数组求解01背包问题时:
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
分析该递推公式,其中dp[i]
的设计完全是为了标识第i-1
层的值,即然第i
层还未填充,如果能够把第i-1
层的值直接复制到第i
层上,那此时递推公式就变为:
dp[i][j] = max(dp[i][j], dp[i][j - weight[i]] + value[i]);
此时dp[i]
已无特殊意义,可直接移除变为一维:
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
此时再进行动态规划解题分析:
- dp数组及其下标含义:容量为
j
的背包,所背物品的最大价值为dp[j]
。 - 递推公式:
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
- 数组初始化:易得
dp[0]=0
- 遍历顺序:与二维不同的是,对于背包容量
j
的遍历要从大到小,这是因为用第i
行保存第i-1
行时,要想在本行左侧取到正确的dp[j - weight[i]]
,它就不能先于右侧被计算填充!否则正确值被覆盖,每j++
都会有一个物品i
被重复放入
for(int i = 0; i < weight.size(); i++) { // 遍历物品
for(int j = bagWeight; j >= weight[i]; j--) { // 遍历背包容量
代码实现
// 一维dp数组实现
#include <iostream>
#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) {
// 内层循环从 N 空间逐渐减少到当前研究材料所占空间
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;
}
416. 分割等和子集
给你一个 **只包含正整数 **的 **非空 **数组 nums
。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
示例 1:
输入:nums = [1,5,11,5] 输出:true 解释:数组可以分割成 [1, 5, 5] 和 [11] 。
提示:
1 <= nums.length <= 200
1 <= nums[i] <= 100
思路
两个子集的元素和相等为nums/2
,可以视为一个容量为nums/2
的,目标价值也为nums/2
的背包,每个数字的重量和价值等价于其数值本身,如果正好装满,则说明本题数组子集可分。
因此有特殊的
nums[i] = weight[i] = value[i]
- dp数组及其下标含义:背包用容量
j
装了价值d[j]
的物品,因此本题判正的条件可以定义为dp[target]=target
- 递推公式:将上述特殊等式代入可得
dp[j] = max(dp[j], dp[ j-num[i] ]+num[i])
- 数组初始化:易得
dp[0]=0
,又可知数组至多200个不超过100的元素,因此所需元素和=背包总重量=200*100/2=10000vector<int> dp(10001, 0);
- 遍历顺序:根据一维01背包问题逻辑,依旧是从小到大遍历物品,从大到小遍历背包容量
代码实现
class Solution {
public:
bool canPartition(vector<int>& nums) {
vector<int> dp(10001, 0);
int sum = 0;
for(int i=0; i<nums.size(); i++){
sum += nums[i];
}
int target = sum/2;
if (sum % 2 == 1) return false;
for(int i=0; i<nums.size(); i++){
for(int j=target; j>=nums[i]; j--){
dp[j] = max(dp[j], dp[j-nums[i]]+nums[i]);
}
}
if (dp[target] == target) return true;
return false;
}
};
后记
动态规划的核心在于递推公式的设计,它是循环体的主要部分;
此外,数组初始化决定了循环前dp数组的初值和长度、dp数组及其下标含义+遍历顺序共同决定了循环条件的设计。