学习目标:学习动态规划背包问题
- 01背包
- 完全背包
学习内容:
01背包问题
01背包是指的是所有物品只有一个,且背包容量固定,求装满背包的最大价值
关键问题在于动态转移方程,就是拿与不拿的问题,状态转移方程为dp[j] = max(不拿当前物品,拿当前物品) j为背包容量
在代码中表示为dp[j] = max(dp[j],dp[j-w[i] + v[i]]);
dp[j-w[i]]为剩余容量在装入物品i前的最大价值
具体步骤演示(01背包):
假设背包容量4kg,物品列表:手机(1kg,15元),金条(3kg,20元)
处理手机(1kg,15元):
从后向前更新(防止重复拿)
当j=1kg:可以装手机 → dp[1] = max(0, dp[1-1]+15) = 15
当j=2kg:可以装手机 → dp[2] = max(0, dp[2-1]+15) = 15
…同理直到4kg:
处理金条(3kg,20元):
当j=3kg:可以装金条 → dp[3] = max(15, dp[3-3]+20) = 20
当j=4kg:可以装金条 → dp[4] = max(15, dp[4-3]+20) = 35(手机+金条)
一句话总结状态转移:在拿与不拿之间做贪心选择
01背包倒序:
比如处理金条时,计算 dp[4] 要用旧的 dp[1](还没被当前物品更新过),保证金条只被拿一次。
// ================= 01背包问题 =================
// 参数:背包容量W,物品重量数组weights,物品价值数组values
// 返回:能获得的最大价值
int knapsack01(int W, vector<int>& weights, vector<int>& values) {
vector<int> dp(W + 1, 0); // dp[j] 表示容量为j时的最大价值
// 遍历每个物品
for (int i = 0; i < weights.size(); ++i) {
// 必须从后向前遍历容量(避免重复选择)
for (int j = W; j >= weights[i]; --j) {
dp[j] = max(dp[j], dp[j - weights[i]] + values[i]);
}
}
return dp[W];
}
如上述代码 先遍历物品 所以如果正序遍历的话会导致同一个物品会被重复放到背包中 无法保证物品只使用一次
倒序遍历的话 初始dp为0 就不会将物品重复加入其中
他物品是一个个处理的 先处理物品A 在处理物品B 在处理物品C
完全背包
与01背包不同的点是物品可以无限拿
场景:
比如你有无数个相同硬币(1元、2元、5元),问:用这些硬币凑出金额N元,最少需要多少个硬币?
或者:背包容量为W,物品可以无限拿,求最大总价值。
核心思想:
状态定义:dp[j] 表示容量为 j 的背包能获得的最大价值。
状态转移:
对于每个物品,从前往后遍历容量(允许重复拿取)。
dp[j] = max(dp[j], dp[j - w[i]] + v[i])
不拿当前物品:保持当前价值 dp[j]
拿当前物品:腾出 w[i] 空间,加上当前物品的价值 v[i]
int completeKnapsack(int W, vector<int>& weights, vector<int>& values) {
vector<int> dp(W + 1, 0); // dp[j]:容量j时的最大价值
// 遍历每个物品
for (int i = 0; i < weights.size(); ++i) {
// 正序遍历容量(允许重复选择)
for (int j = weights[i]; j <= W; ++j) {
dp[j] = max(dp[j], dp[j - weights[i]] + values[i]);
}
}
return dp[W];
}
可以这么理解 完全背包就是01背包的正序遍历 就是不需要解决重复拿的问题就是完全背包
代码方面基本上一致
力扣416. 分割等和子集 01背包例题
给你一个 只包含正整数 的 非空 数组 nums 。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
bool canPartition(vector<int>& nums) {
//核心思想在于背包容量为sum/2 找到是否存在元素相加刚好放满背包容量的
int sum = 0;
for(int num : nums){
sum += num;
}
if(sum %2!= 0 ) return false; //如果不能整除肯定不能分为两个子集
//nums为权重值
int target = sum/2;
vector<int> dp(sum/2 +1 , 0);
for(int i = 0; i < nums.size();i++){
for(int j = target ; j>= nums[i]; --j){ //01背包倒序遍历 停止条件为当前物体的权重值 要是低于权重值 则物品放不进去 遍历无意义
dp[j] = max(dp[j], dp[j-nums[i]]+ nums[i]);
}
}
if(dp[target] == target)return true;
return false;
279. 完全平方数
给你一个整数 n ,返回 和为 n 的完全平方数的最少数量 。
完全平方数 是一个整数,其值等于另一个整数的平方;换句话说,其值等于一个整数自乘的积。例如,1、4、9 和 16 都是完全平方数,而 3 和 11 不是。
动规五部曲分析如下:
确定dp数组(dp table)以及下标的含义
dp[j]:和为j的完全平方数的最少数量为dp[j]
dp数组内不一定就是要放最大价值 也可以放个数
确定递推公式
dp[j] 可以由dp[j - i * i]推出, dp[j - i * i] + 1 便可以凑成dp[j]。
此时我们要选择最小的dp[j],所以递推公式:dp[j] = min(dp[j - i * i] + 1, dp[j]);
递推公式也不一定要求max 也可以求min
dp数组如何初始化
dp[0]表示 和为0的完全平方数的最小数量,那么dp[0]一定是0。
有同学问题,那0 * 0 也算是一种啊,为啥dp[0] 就是 0呢?
看题目描述,找到若干个完全平方数(比如 1, 4, 9, 16, …),题目描述中可没说要从0开始,dp[0]=0完全是为了递推公式。
非0下标的dp[j]应该是多少呢?
从递归公式dp[j] = min(dp[j - i * i] + 1, dp[j]);中可以看出每次dp[j]都要选最小的,所以非0下标的dp[j]一定要初始为最大值,这样dp[j]在递推的时候才不会被初始值覆盖。
确定遍历顺序
我们知道这是完全背包,如果求组合数就是外层for循环遍历物品,内层for遍历背包。如果求排列数就是外层for遍历背包,内层for循环遍历物品。
class Solution {
public:
int numSquares(int n) {
//完全背包例题 1 4 9 16可以任取 有无限次取的机会
//问题转换成将容量为n的背包装满 最少需要多少数量的物品
//将第一个初始化为0 剩下的都初始化为最大值
//dp中存的是最小个数 j表示的背包容量
vector<int> dp(n + 1, INT_MAX);
dp[0] = 0;
for (int i = 1; i * i <= n; i++) { // 遍历物品 物品权值为i*i 所以i*i不能超过n
for (int j = i * i; j <= n; j++) { // 遍历背包 能放下i*i容量的背包必须大于等于i*i 所以j = i*i
dp[j] = min(dp[j - i * i] + 1, dp[j]);
}
}
return dp[n];
}
};
示例流程(以n=4为例):
i=1(物品1):
j=1:dp[1] = min(dp[0]+1, 1) = 1
j=2:dp[2] = min(dp[1]+1, 2) = 2
j=3:dp[3] = 3
j=4:dp[4] = 4
i=2(物品4):
j=4:dp[4] = min(dp[0]+1, 4) = 1
最终dp[4]=1,即4本身是平方数,只需1个。
可能会有的问题
如果dp数组中记录的是个数,为什么能够保证填满背包?
在遍历物品(平方数)时,只有当以下条件满足时才会更新状态:
dp[j] = min(dp[j - ii] + 1, dp[j]);
因为有1在 所以一定可以填满,只要在除法条件时才会更新,且j = ii;都是从能够放下该物品容量之后开始遍历 之前的1可以通过dp[j - i*i] 访问到,因此例如n=5时候,访问到 i = 2 j = 4时,
dp[4] = min(dp[4-4] + 1,dp[4]) = 1
dp[5] = min(dp[5-4] + 1 ,dp[4]) = 2
因此返回的结果为2 如果不懂逻辑可以多多进行模拟