《代码随想录第四十二天》——完全背包、零钱兑换II、组合总和IV、爬楼梯(进阶)
本篇文章的所有内容仅基于C++撰写。
1. 完全背包
1.1 题目
携带研究材料(可重复)
小明是一位科学家,他需要参加一场重要的国际科学大会,以展示自己的最新研究成果。他需要带一些研究材料,但是他的行李箱空间有限。这些研究材料包括实验设备、文献资料和实验样本等等,它们各自占据不同的重量,并且具有不同的价值。
小明的行李箱所能承担的总重量是有限的,问小明应该如何抉择,才能携带最大价值的研究材料,每种研究材料可以选择无数次,并且可以重复选择。
-
输入描述
第一行包含两个整数,n,v,分别表示研究材料的种类和行李所能承担的总重量
接下来包含 n 行,每行两个整数 wi 和 vi,代表第 i 种研究材料的重量和价值
输出描述 -
输出一个整数,表示最大价值。
-
输入示例
4 5
1 2
2 4
3 4
4 5 -
输出示例
10 -
提示信息:第一种材料选择五次,可以达到最大值。
-
数据范围:
1 <= n <= 10000;
1 <= v <= 10000;
1 <= wi, vi <= 10^9.
1.2 分析
完全背包和01背包的不同之处就在于完全背包中的物品数量无限,是可重复放入的。
假如背包最大重量为4,物品为:
重量 价值
物品0 1 15
物品1 3 20
物品2 4 30
每件商品都有无限个,问背包能背的物品最大价值是多少?
开始动规分析五部曲。
- dp[i][j] 表示从下标为[0-i]的物品,每个物品可以取无限次,放进容量为j的背包,价值总和最大是多少。
- 递推公式
这里依然拿dp[1][4]的状态来举例: (01背包理论基础(二维数组)中也是这个例子,要注意下面的不同之处)
求取 dp[1][4] 有两种情况:
- 放物品1
- 还是不放物品1
如果不放物品1, 那么背包的价值应该是 dp[0][4] 即 容量为4的背包,只放物品0的情况。推导方向如图:
如果放物品1, 那么背包要先留出物品1的容量,目前容量是4,物品1 的容量(就是物品1的重量)为3,此时背包剩下容量为1。
容量为1,只考虑放物品0 和物品1 的最大价值是 dp[1][1], 注意 这里和 01背包理论基础(二维数组)有所不同,01背包中是dp[0][1]。这是因为01背包中物品都只有一个,没放肯定就不在背包中;而完全背包的物品有无数个,即使当前没放,在之前的背包容量中也可能放入了。如图:
两种情况,分别是放物品1 和 不放物品1,我们要取最大值(毕竟求的是最大价值):dp[1][4] = max(dp[0][4], dp[1][1] + 物品1 的价值)
得到递推公式:dp[i][j] = max(dp[i - 1][j], dp[i][j - weight[i]] + value[i]);
- dp数组如何初始化
首先从dp[i][j]的定义出发,如果背包容量j为0的话,即dp[i][0],无论是选取哪些物品,背包价值总和一定为0。
再看其他情况:
状态转移方程 dp[i][j] = max(dp[i - 1][j], dp[i][j - weight[i]] + value[i]); 可以看出有一个方向 i 是由 i-1 推导出来,那么i为0的时候就一定要初始化。就是在背包容量允许的情况下,一直往里装物品0.
// 初始化 dp
vector<vector<int>> dp(weight.size(), vector<int>(bagweight + 1, 0));
for (int j = weight[0]; j <= bagWeight; j++) {
dp[0][j] = dp[0][j - weight[0]] + value[0];
}
- 确定遍历顺序
01背包理论基础(二维数组)中,先遍历物品还是先遍历背包都是可以的。
因为两种遍历顺序,对于二维dp数组来说,递推公式所需要的值,二维dp数组里对应的位置都有。
完全背包也一样,既可以 先遍历物品再遍历背包,也可以 先遍历背包再遍历物品。
另外要注意,如果使用一维数组,完全背包的先遍历背包还是先遍历物品都可以,不像01背包中必须先遍历物品再遍历背包。但是,完全背包中的物品和背包(主要是背包)都必须从前向后遍历。
1.3 代码
- 二维数组
#include <iostream>
#include <vector>
using namespace std;
int main() {
int n, bagWeight;
int w, v;
cin >> n >> bagWeight;
vector<int> weight(n);
vector<int> value(n);
for (int i = 0; i < n; i++) {
cin >> weight[i] >> value[i];
}
vector<vector<int>> dp(n, vector<int>(bagWeight + 1, 0));
// 初始化
for (int j = weight[0]; j <= bagWeight; j++)
dp[0][j] = dp[0][j - weight[0]] + value[0];
for (int i = 1; i < n; i++) { // 遍历物品
for(int j = 0; j <= bagWeight; j++) { // 遍历背包容量
if (j < weight[i]) dp[i][j] = dp[i - 1][j];
//在完全背包中,不存在重量为0的物品,不然就会无限地放下去,所以这里的j完全可以从1开始取值
else dp[i][j] = max(dp[i - 1][j], dp[i][j - weight[i]] + value[i]);
}
}
cout << dp[n - 1][bagWeight] << endl;
return 0;
}
- 一维数组
#include <iostream>
#include <vector>
using namespace std;
int main() {
int N, bagWeight;
cin >> N >> bagWeight;
vector<int> weight(N, 0);
vector<int> value(N, 0);
for (int i = 0; i < N; i++) {
int w;
int v;
cin >> w >> v;
weight[i] = w;
value[i] = v;
}
vector<int> dp(bagWeight + 1, 0);
for(int j = 0; j <= bagWeight; j++) { // 遍历背包容量
for(int i = 0; i < weight.size(); i++) { // 遍历物品
if (j - weight[i] >= 0) dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
}
cout << dp[bagWeight] << endl;
return 0;
}
2. 零钱兑换II
2.1 题目
零钱兑换
给你一个整数数组 coins 表示不同面额的硬币,另给一个整数 amount 表示总金额。
请你计算并返回可以凑成总金额的硬币组合数。如果任何硬币组合都无法凑出总金额,返回 0 。
假设每一种面额的硬币有无限个。
题目数据保证结果符合 32 位带符号整数。
示例 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:
输入:amount = 3, coins = [2]
输出:0
解释:只用面额 2 的硬币不能凑成总金额 3 。
示例 3:
输入:amount = 10, coins = [10]
输出:1
提示:
1 <= coins.length <= 300
1 <= coins[i] <= 5000
coins 中的所有值 互不相同
0 <= amount <= 5000
2.2 分析
本题和纯完全背包不一样,纯完全背包是凑成背包最大价值是多少,而本题是要求凑成总金额的物品组合个数!注意题目描述中是凑成总金额的硬币组合数,组合不强调元素之间的顺序,排列强调元素之间的顺序。
动规五部曲:
- 确定dp数组的下标及含义
定义二维dp数值 dp[i][j]:使用 下标为[0, i]的coins[i]能够凑满j(包括j)这么大容量的包,有dp[i][j]种组合方法。 - 确定递推公式
在目标和中详解讲解了装满背包有几种方法,二维DP数组的递推公式: dp[i][j] = dp[i - 1][j] + dp[i - 1][j - nums[i]],即不装当前物品的方法+腾出背包装当前物品的方法,这个跟爬楼梯有点像。
所以本题递推公式:dp[i][j] = dp[i - 1][j] + dp[i][j - nums[i]] ,区别依然是 dp[i - 1][j - nums[i]] 和 dp[i][j - nums[i]]。 - dp数组初始化
- 最上行dp[0][j] 如何初始化呢?dp[0][j]的含义:用「物品0」(即coins[0]) 装满 背包容量为j的背包,有几种组合方法。
如果 j 可以整除 物品0,那么装满背包就有1种组合方法。
for (int j = 0; j <= bagSize; j++) {
if (j % coins[0] == 0) dp[0][j] = 1;
}
- 最左列如何初始化呢?dp[i][0] 的含义:用物品i(即coins[i]) 装满容量为0的背包 有几种组合方法。
都有一种方法,即不装。所以 dp[i][0] 都初始化为1
#4. 确定遍历顺序
二维数组的遍历,先物品还是先背包都可以。
2.3 代码
- 二维数组
class Solution {
public:
int change(int amount, vector<int>& coins) {
int bagSize = amount;
vector<vector<uint64_t>> dp(coins.size(), vector<uint64_t>(bagSize + 1, 0));
// 初始化最上行
for (int j = 0; j <= bagSize; j++) {
if (j % coins[0] == 0) dp[0][j] = 1;
}
// 初始化最左列
for (int i = 0; i < coins.size(); i++) {
dp[i][0] = 1;
}
// 以下遍历顺序行列可以颠倒
for (int i = 1; i < coins.size(); i++) { // 行,遍历物品
for (int j = 0; j <= bagSize; j++) { // 列,遍历背包
if (coins[i] > j) dp[i][j] = dp[i - 1][j];
else dp[i][j] = dp[i - 1][j] + dp[i][j - coins[i]];
}
}
return dp[coins.size() - 1][bagSize];
}
};
- 一维数组
注意:本题求的是组合,必须先遍历物品再遍历背包,否则会出现重复的方法(即排列)。
class Solution {
public:
int change(int amount, vector<int>& coins) {
vector<uint64_t> dp(amount + 1, 0); // 防止相加数据超int
dp[0] = 1; // 只有一种方式达到0
for (int i = 0; i < coins.size(); i++) { // 遍历物品
for (int j = coins[i]; j <= amount; j++) { // 遍历背包
dp[j] += dp[j - coins[i]];
}
}
return dp[amount]; // 返回组合数
}
};
- 时间复杂度: O(mn),其中 m 是amount,n 是 coins 的长度
- 空间复杂度: O(m)
3. 组合总和IV
3.1 题目
组合总和IV
给你一个由 不同 整数组成的数组 nums ,和一个目标整数 target 。请你从 nums 中找出并返回总和为 target 的元素组合的个数。
题目数据保证答案符合 32 位整数范围。
示例 1:
输入:nums = [1,2,3], target = 4
输出:7
解释:
所有可能的组合为:
(1, 1, 1, 1)
(1, 1, 2)
(1, 2, 1)
(1, 3)
(2, 1, 1)
(2, 2)
(3, 1)
请注意,顺序不同的序列被视作不同的组合。
示例 2:
输入:nums = [9], target = 3
输出:0
提示:
1 <= nums.length <= 200
1 <= nums[i] <= 1000
nums 中的所有元素 互不相同
1 <= target <= 1000
3.2 分析
题上说的是组合,实际上求的是排列,那我们书接上回,用先背包后物品的一维数组来解决。
动规五部曲分析如下:
-
确定dp数组以及下标的含义
dp[i]: 凑成目标正整数为i的排列个数为dp[i] -
确定递推公式
递推公式是dp[i] += dp[i - nums[j]];即不放入当前物品的排列方法和腾出背包放入当前物品的排列方法。 -
dp数组如何初始化
因为递推公式dp[i] += dp[i - nums[j]]的缘故,dp[0]要初始化为1,因为dp[0]反正是没有意义的,就拿它的值来给后面的dp[i]做初始的启动值,不然数组的所有值会一直为0。
至于非0下标的dp[i]应该初始为多少呢?初始化为0,这样才不会影响dp[i]累加所有的dp[i - nums[j]]。 -
确定遍历顺序
个数可以不限使用,说明这是一个完全背包。
本题要求的是排列,那么这个for循环嵌套的顺序可以有说法了。
- 如果求组合数就是外层for循环遍历物品,内层for遍历背包。
- 如果求排列数就是外层for遍历背包,内层for循环遍历物品。
所以本题遍历顺序最终遍历顺序:target(背包)放在外循环,将nums(物品)放在内循环,内循环从前到后遍历。
3.3 代码
class Solution {
public:
int combinationSum4(vector<int>& nums, int target) {
vector<int> dp(target + 1, 0);
dp[0] = 1;
for (int i = 0; i <= target; i++) { // 遍历背包
for (int j = 0; j < nums.size(); j++) { // 遍历物品
if (i - nums[j] >= 0 && dp[i] < INT_MAX - dp[i - nums[j]]) {//能装下当前物品且价值不会溢出
dp[i] += dp[i - nums[j]];
}
}
}
return dp[target];
}
};
- 时间复杂度: O(target * n),其中 n 为 nums 的长度
- 空间复杂度: O(target)
4. 爬楼梯(进阶版)
4.1 题目
爬楼梯(进阶版)
题目描述
假设你正在爬楼梯。需要 n 阶你才能到达楼顶。
每次你可以爬至多m (1 <= m < n)个台阶。你有多少种不同的方法可以爬到楼顶呢?
注意:给定 n 是一个正整数。
-
输入描述
输入共一行,包含两个正整数,分别表示n, m -
输出描述
输出一个整数,表示爬到楼顶的方法数。 -
输入示例
3 2 -
输出示例
3 -
提示信息
数据范围:
1 <= m < n <= 32;
当 m = 2,n = 3 时,n = 3 这表示一共有三个台阶,m = 2 代表你每次可以爬一个台阶或者两个台阶。
此时你有三种方法可以爬到楼顶。
1 阶 + 1 阶 + 1 阶段
1 阶 + 2 阶
2 阶 + 1 阶
4.2 分析
本题的区别在于,不再是只能爬一阶或两阶楼梯,而是可以一直爬到最顶端(m阶),求爬楼梯的方法数。本题跟上一题的原理类似,求的也是要排列的完全背包,只是递推公式有变化。
动规五部曲分析如下:
-
确定dp数组以及下标的含义
dp[i]:爬到有i个台阶的楼顶,有dp[i]种方法。 -
确定递推公式
递推公式一般都是dp[i] += dp[i - nums[j]];本题呢,dp[i]有几种来源,dp[i - 1],dp[i - 2],dp[i - 3] 等等,即:dp[i - j]
那么递推公式为:dp[i] += dp[i - j] -
dp数组如何初始化
既然递归公式是 dp[i] += dp[i - j],那么dp[0] 一定为1,dp[0]是递归中一切数值的基础所在,如果dp[0]是0的话,其他数值都是0了。
下标非0的dp[i]初始化为0,因为dp[i]是靠dp[i-j]累计上来的,dp[i]本身为0这样才不会影响结果。 -
确定遍历顺序
这是背包里求排列问题,即:1、2 步 和 2、1 步都是上三个台阶,但是这两种方法不一样!
所以需将target放在外循环,将nums放在内循环。
每一步可以走多次,这是完全背包,内循环需要从前向后遍历。
4.3 代码
#include <iostream>
#include <vector>
using namespace std;
int main() {
int n, m;
while (cin >> n >> m) {
vector<int> dp(n + 1, 0);
dp[0] = 1;
for (int i = 1; i <= n; i++) { // 遍历背包
for (int j = 1; j <= m; j++) { // 遍历物品
if (i - j >= 0) dp[i] += dp[i - j];
}
}
cout << dp[n] << endl;
}
}
- 时间复杂度: O(n * m)
- 空间复杂度: O(n)