1 背包问题就是动态规划
动态规划介绍:算法-动态规划-《算法导论3rd-P215》_hclbeloved的博客-CSDN博客
背包问题是一种动态规划的问题,且常用的是“从底到上”的动态规划。有的时候问题本身可能无法直接看出是背包问题,不过可以对问题进行抽象,底层逻辑如果相同,便可当作“背包问题”进行求解。
写在前面,参考链接:力扣
1.1 按照背包的类型分类:
0-1背包:每个元素最多被选择一次;求解的常是最值、选择个数、选择存在问题。
完全背包:每个元素可以被重复选择;求解的常是最值问题。
组合背包:每个元素可以被重复选择,且不考虑元素的相对顺序,求解的是组合的数量;
排列背包:每个元素可以被重复选择,但需要考虑元素的相对顺序,求解的是排列的数量;
1.2 按照背包所求解的问题的类型进行分类:
(1)求解最值(最大价值;最少零钱数目;)
(2)选择存在问题(等和子集是否存在;)
(3)排列组合问题(排列、组合的数量)
1.3 根据背包类型,选择循环模板;根据问题类型,选择dp类型
1.3.1 循环模板的选择
(1)0-1背包:416. 分割等和子集
二维dp:外循环nums,内循环target,target正序且target>=nums[i];
一维dp:外循环nums,内循环target,target倒序且target>=nums[i];
(2)完全背包:322. 零钱兑换279. 完全平方数
一维dp:外循环nums,内循环target,target正序且target>=nums[i];
(3)组合背包(不考虑顺序):518. 零钱兑换 II
一维dp:外循环nums,内循环target,target正序且target>=nums[i];
注意:求解模板与完全背包相同的原因是二者都可以重复选择元素。
(4)排列背包(考虑顺序):377. 组合总和 Ⅳ
一维dp:外循环target,内循环nums,target正序且target>=nums[i];
1.3.2 dp类型
(1)求解最值(最大价值;最少零钱数目;)
dp[i] = max/min(dp[i], dp[i-nums]+1)或dp[i] = max/min(dp[i], dp[i-num]+nums)
(2)存在问题(等和子集是否存在;)
dp[i]=dp[i] | dp[i-num]
(3)排列、组合问题(排列、组合的数量)
dp[i]+=dp[i-num]
2 0-1背包
题目:背包所能容纳的最大价值
有两个数组weights, values分别保存一些物品的重量和价值,现有一个容量为weight的背包,请设计一个方法来选择合适的物品放入背包,使其内部的价值最大。
2.1 从底到上的二维动态规划
分析:
2.1.1 状态定义
dp[i][j]:表示对于前i个物品,当背包的容量为j时,能装下的最大的价值;
2.1.2 base case:
dp[...][0]表示当背包的容量为0时,能装下的最大价值为0;
dp[0][...]表示当没有物品选择时,能装下的最大价值为0;
2.1.3 状态转移方程
现在要求解dp[i][j]:前i个物品,背包容量为j时,能装下的最大价值。
注意:第 i 个物品对应的索引是 i - 1。
这里分为两种情况:
第一:第 i 个物品的重量大于当前背包的容量 j,即 weights[i-1] > j,此时无法将第 i 个物品放入背包。即:dp[i][j] = dp[i-1][j];
第二:第 i 个物品的重量小于等于当前背包的容量 j,即 weights[i-1] <= j,此时有可以将第 i 个物品放入背包,也可以不放入,即:dp[i][j] = max(dp[i-1][j], values[i-1] + dp[i-1][j - weights[i-1]])。
2.1.4 代码实现
注意:内层 for 循环使用的是从“当前元素的重量”正序到“背包的目标weight”。
int knapsack(vector<int>& weights, vector<int>& values, int weight)
{
int n = weights.size();
// dp[i][j]:前i个物品,背包容量为j时,能装下的最大价值
// base case 已初始化
vector<vector<int>> dp(n+1, vector<int>(weight+1, 0));
for (int i = 1; i <= n; ++i)
{
// weight 为背包的目标容量
for (int j = 1; j <= weight; ++j)
{
// 第 i 个物品对应的索引是 i - 1
if (j < weights[i-1])
{
//背包的当前容量 j 小于 当前物品的重量,无法装入当前的背包
dp[i][j] = dp[i-1][j];
}
else
{
// 装⼊或者不装⼊背包,择优
dp[i][j] = max(dp[i-1][j], values[i-1] + dp[i-1][j - weights[i-1]]);
}
}
}
return dp[n][weight];
}
2.2 从底到上的一维动态规划
由上面的二维动态规划可以知道,在计算dp[i][j]时,实际上只和dp[i-1][j]以及dp[i-1][j - weights[i-1]]相关,即仅与上一层(i-1)层相关。
注意:内层 for 循环使用的是从“背包的目标weight”逆序到“当前元素的重量”。
int knapsack(vector<int>& weights, vector<int>& values, int weight)
{
// dp[i]:背包容量为j时,能装下的最大价值
// base case 已初始化
vector<int> dp(weight+1, 0);
// 物品的索引从0开始
for (int i = 0; i < weights.size(); ++i)
{
// weight 为背包的目标容量
// j >= weights[i] 保证背包的当前容量 j 大于等于 当前物品的重量
// 采用逆序保证 dp[j - weights[i]] 是在第 i-1 层所求得的值
for (int j = weight; j >= weights[i]; --j)
{
// 装⼊或者不装⼊背包,择优
dp[j] = max(dp[j], values[i] + dp[j - weights[i]]);
}
}
return dp[weight];
}
2.3 0-1背包题目
2.3.1 分割等和子集
剑指 Offer II 101. 分割等和子集 416. 分割等和子集
代码如下:
class Solution {
public:
//0-1背包的一维dp解决方法
bool canPartition(vector<int>& nums) {
int n = nums.size(), m = 0;
for (int x: nums)
m += x;
if (m % 2)
return false;
//目标和 m 为背包的目标容量; nums中的值为物品的重量; 物品的价值在这里并未体现
m /= 2;
//dp[j]表示是否可以组成j的和
vector<bool> dp(m + 1);
dp[0] = true;
for (int i = 0; i < n; ++i)
{
for (int j = m; j >= nums[i]; --j)
{
dp[j] = dp[j] | dp[j - nums[i]];
}
}
return dp[m];
}
};
2.3.2 目标和
class Solution {
public:
int findTargetSumWays(vector<int>& nums, int target) {
int sum = 0;
for (int n : nums)
{
sum += n;
}
if (target > sum || target < -sum)
return 0;
// 目标和 m 为背包的目标容量; nums中的值为物品的重量; 物品的价值在这里并未体现
// 假设选出几个数使其变为负数,选出的这几个数的和假设为 x,则有
// (sum-x)-x=target, sum-target = 2x
sum -= target;
if ((sum & 0x01) == 1)
return 0;
sum /= 2;
//dp[j]表示是否可以组成j的和的数量
vector<int> dp(sum+1, 0);
dp[0] = 1;
for (int n : nums)
{
for (int j = sum; j >= n; --j)
{
dp[j] += dp[j-n];
}
}
return dp[sum];
}
};
2.3.3 最后一块石头的重量II
class Solution {
public:
int lastStoneWeightII(vector<int>& stones) {
/*
最后一块石头的重量:从一堆石头中,每次拿两块重量分别为x,y的石头,若x=y,则两块石头均粉碎;若x<y,两块石头变为一块重量为y-x的石头求最后剩下石头的最小重量(若没有剩下返回0)
问题转化为:把一堆石头分成两堆,求两堆石头重量差最小值
进一步分析:要让差值小,两堆石头的重量都要接近sum/2;我们假设两堆分别为A,B,A<sum/2,B>sum/2,若A更接近sum/2,B也相应更接近sum/2
进一步转化:将一堆stone放进最大容量为sum/2的背包,求放进去的石头的最大重量MaxWeight,最终答案即为sum-2*MaxWeight。
至此转化为了 0-1 背包问题: stones 中的数据既代表了重量,又代表了价值。背包的目标容量为 sum/2 。
其实这个题和《目标和 https://leetcode-cn.com/problems/target-sum/》很相似,不过本题比“目标和”的题目更近了一步,本题相当于“目标和”的加强版,
加强的部分在于使得“目标和”尽可能的小。
链接:https://leetcode-cn.com/problems/partition-equal-subset-sum/solution/yi-pian-wen-zhang-chi-tou-bei-bao-wen-ti-a7dd/
*/
int sum = accumulate(stones.begin(), stones.end(), 0);
int target = sum / 2;
vector<int> dp(target + 1);
for (int stone : stones)
for (int i = target; i >= stone; i--)
dp[i] = max(dp[i], dp[i - stone] + stone);
return sum - 2 * dp[target];
}
};
3 完全背包
完全背包使用的也是“从底向上”的动态规划,且基本上都是一维的动态规划,这里与0-1背包不同的是base case是初始化为0的,比如下面的dp[0] = 0;对比上面的0-1背包,常常对base case初始化为1或者true。
注意:完全背包的内层 for 循环使用的是从“当前元素的重量”正序到“背包的目标weight”。
3.1 完全背包题目
3.1.1 零钱兑换
题目:322. 零钱兑换
代码实现具体如下:
class Solution {
public:
int coinChange(vector<int>& coins, int amount) {
//用完全背包的解决方法
//amount为背包的目标容量
//coins中的值为重量,价值就是选择这一个硬币时硬币的数量,即:选择这个硬币时,价值为 1
vector<int> dp(amount+1,amount+1);
dp[0]=0;
for (auto& coin : coins)
{
for(int i=1;i<=amount;i++)
{
//当前背包的容量如果大于coin,则可以选择放入背包,也可以选择不放入,进行比较后,选出最小值
if(i>=coin)
dp[i] = min(dp[i], 1 + dp[i-coin]);
}
}
return dp[amount]==amount+1?-1:dp[amount];
}
};
3.1.2 完全平方数
class Solution {
public:
/* 完全背包的最值问题
* 完全平方数最小为1,最大为sqrt(n),故题目转换为在nums=[1,2.....sqrt(n)]中选任意数平方和为target=n
* nums中的值为重量,价值就是选择这一个数字时的数量,即:选择这个数字时,价值为 1
* 等价题目:https://leetcode-cn.com/problems/coin-change/
*/
int numSquares(int n) {
vector<int> dp(n + 1, INT_MAX); //dp[i]:和为i的完全平方数的最小数量
dp[0] = 0;
for (int num = 1; num <= sqrt(n); num++)
{
for (int i = 0; i <= n; i++)
{
if (i >= num * num)
dp[i] = min(dp[i], 1 + dp[i - num * num]);
}
}
return dp[n];
}
};
4 组合背包
外循环nums,内循环target,target正序且target>=nums[i];
dp[i]+=dp[i-num]
class Solution {
public:
int change(int amount, vector<int>& coins) {
vector<int> dp(amount+1,0);
//base case
dp[0] = 1;
for (int coin : coins)
{
// 记录每添加一种面额的零钱,总金额j的变化
for (int j = coin; j <= amount; j++)
{
// 在上一钟零钱状态的基础上增大
// 例如对于总额5,当只有面额为1的零钱时,只有一种可能 5x1
// 当加了面额为2的零钱时,除了原来的那一种可能外
// 还加上了组合了两块钱的情况,而总额为5是在总额为3的基础上加上两块钱来的
// 所以就加上此时总额为3的所有组合情况
dp[j] += dp[j - coin];
}
}
return dp[amount];
}
};
5 排列背包
一维dp:外循环target,内循环nums,target正序且target>=nums[i];
dp[i]+=dp[i-num]
class Solution {
public:
//与518. 零钱兑换 i 有些相似:https://leetcode-cn.com/problems/coin-change-2/
// 零钱兑换 i 不考虑组合中元素的顺序,是组合问题;此处考虑了元素的相对顺序,是排列问题
/*
这道题中,给定数组 nums 和目标值 target,要求计算从 nums 中选取若干个元素,使得它们的和等于 target 的方案数。
其中,nums 的每个元素可以选取多次,且需要考虑选取元素的顺序。由于需要考虑选取元素的顺序,因此这道题需要计算的是选取元素的排列数。
可以通过动态规划的方法计算可能的方案数。用 dp[x] 表示选取的元素之和等于 x 的方案数,目标是求 dp[target]。
动态规划的边界是 dp[0]=1。只有当不选取任何元素时,元素之和才为 0,因此只有 1 种方案。
当 1≤i≤target 时,如果存在一种排列,其中的元素之和等于 i,则该排列的最后一个元素一定是数组 nums 中的一个元素。
假设该排列的最后一个元素是 num,则一定有 num≤i,对于元素之和等于 i−num 的每一种排列,在最后添加 num 之后即可得到一个元素之和等于 i 的排列,
因此在计算 dp[i] 时,应该计算所有的 dp[i−num] 之和。
由此可以得到动态规划的做法:
初始化 dp[0]=1;
遍历 i 从 1 到 target,对于每个 i,进行如下操作:
遍历数组 nums 中的每个元素 num,当 num≤i 时,将 dp[i−num] 的值加到 dp[i]。
最终得到 dp[target] 的值即为答案。
上述做法是否考虑到选取元素的顺序?答案是肯定的。因为外层循环是遍历从 1 到 target 的值,内层循环是遍历数组 nums 的值,
在计算 dp[i] 的值时,nums 中的每个小于等于 i 的元素都可能作为元素之和等于 i 的排列的最后一个元素。例如,1 和 3 都在数组 nums 中,
计算 dp[4] 的时候,排列的最后一个元素可以是 1 也可以是 3,因此 dp[1] 和 dp[3] 都会被考虑到,即不同的顺序都会被考虑到。
参考链接:https://leetcode-cn.com/problems/combination-sum-iv/solution/zu-he-zong-he-iv-by-leetcode-solution-q8zv/
*/
int combinationSum4(vector<int>& nums, int target) {
//从底到上的动态规划
vector<unsigned int> dp(target+1);
dp[0] = 1;
for (int i = 1; i <= target; i++)
{
for (int num : nums)
{
if (num <= i)
dp[i] += dp[i - num];
}
}
return dp[target];
}
};