题目:
给你一个整数数组 coins
表示不同面额的硬币,另给一个整数 amount
表示总金额。
请你计算并返回可以凑成总金额的硬币组合数。如果任何硬币组合都无法凑出总金额,返回 0
。
假设每一种面额的硬币有无限个。
题目数据保证结果符合 32 位带符号整数。
解法一(回溯-时间复杂度超限):
采用回溯+剪枝的方法计算枚举所有满足amout值情况下所有的coins数组内元素的组合(不限数量),剪值:设置最低遍历截止索引值,如果i=1,则回溯时i<1的索引不参与枚举。在递归(回溯)函数中进行for循环,会大大增加代码的时间复杂度,一般不予采用,if条件语句可以在递归函数中出现(不会增加时间复杂度)。得到的时间复杂度为O(n^2),超限,仅与解法二利用动态规划思想优化时间复杂度的方法作为对比,如下为笔者实现的代码:
class Solution {
public:
void getamount(int& amount, vector<int>& coins, vector<int>& combin, int& number, int j){
if(amount==0){
number++;
return;
}
int length = coins.size();
for(int i=j; i<length; i++){
if(coins[i]<=amount){
combin.push_back(coins[i]);
int a = amount-coins[i];
getamount(a, coins, combin, number, i);
combin.erase(combin.end());
}
else{
break;
}
}
}
int change(int amount, vector<int>& coins) {
sort(coins.begin(), coins.end());
int number=0;
vector<int> combin;
getamount(amount, coins, combin, number, 0);
return number;
}
};
解法二(动态规划-迭代I):
这道题中,给定总金额amount和数组coins,要求计算金额之和等于amoun的硬币组合数。其中,coins的每个元素可以选取多次,且不考虑选取元素的顺序,因此这道题需要计算的是选取硬币的组合数。
可以通过动态规划的方法计算可能的组合数。用dp[x]表示金额之和等于x的硬币组合数,目标是求dp[amount]。动态规划的边界是dp[0]=1。只有当不选取任何硬币时,金额之和才为0,因此只有1种硬币组合。对与面额为coin的硬币,当coin≤i≤amount时,如果存在一种硬币组合的金额之和等于i-coin,则该硬币组合中增加一个面额为coin的硬币,即可得到一种金额之和等于i的硬币组合。因此需要遍历coins,对于其中的每一种面额的硬币,更新数组dp中的每个大于或等于该面额的元素的值。由此可以得到动态规划的做法:
1、初始化dp[0]=1;
2、遍历coins,对于其中的每个元素coin,进行如下操作:遍历i从coin到amount,将dp[i-coin]的值加到dp[i]。
3、最终得到dp[amount]的值即为答案。
上述做法不会重复计算不同的排列。因为外层循环是遍历数组coins的值,内层循环是遍历不同的金额之和,在计算dp[i]的值时,可以确保金额之和等于i的硬币面额的顺序,由于顺序确定,因此不会重复计算不同的排列。
注意,虽然结果保证在32位带符号整数范围内,但当最后结果为0时,中间计算过程可能会溢出。所以,我们可以先使用相同的动态规划方法求出是否有有效解,如果没有则直接返回。如下为实现代码:
class Solution {
public:
int change(int amount, vector<int>& coins) {
vector<int> dp(amount + 1), valid(amount + 1);
dp[0] = 1;
valid[0] = 1;
for (int& coin : coins) {
for (int i = coin; i <= amount; i++) {
valid[i] |= valid[i - coin];
}
}
if (!valid[amount])
return 0;
for (int& coin : coins) {
for (int i = coin; i <= amount; i++) {
dp[i] += dp[i - coin];
}
}
return dp[amount];
}
};
时间复杂度:O(amount×n),其中 amount 是总金额,n 是数组 coins 的长度。需要使用数组 coins 中的每个元素遍历并更新数组 dp 中的每个元素的值。
空间复杂度:O(amount),其中 amount 是总金额。需要创建长度为 amount+1 的数组 dp。
解法三(动态规划-递归II):
定义dfs(i, c)表示用前i种硬币组成金额c的方案数,考虑【选或不选】,有:
1、不再继续选择第i钟硬币:dfs(i-1, c)。
2、继续选一枚第i钟硬币:dfs(i, c-coins[i])。
根据加法原理,二者相加得:dfs(i, c) = dfs(i-1, c) + dfs(i, c-coins[i]),如果事件A和事件B是互斥的(即不能同时发生,不再选硬币的同时,又继续选同一种硬币),那么发生事件A或事件B的总数等于事件A的数量加上事件B的数量。
递归边界:dfs(-1, 0)=1, dfs(-1, >0)=0;递归入口:dfs(n-1, amount)。如下为实现代码:
class Solution {
public:
int change(int amount, vector<int>& coins) {
int n = coins.size();
//memo为选取前i种硬币,可以达到0~amount数量和的组合
vector memo(n, vector<int>(amount + 1, -1)); // -1 表示没有计算过
auto dfs = [&](this auto&& dfs, int i, int c) -> int {
// 设置递归边界
if (i < 0) {
return c == 0 ? 1 : 0;
}
//
int& res = memo[i][c]; // 注意这里是引用
if (res != -1) { // 之前算过了
return res;
}
//如果c小于coins[i],则仅返回dfs(i - 1, c)
if (c < coins[i]) {
return res = dfs(i - 1, c);
}
//如果c大于coins[i],返回dfs(i - 1, c)与dfs(i, c - coins[i])的和
return res = dfs(i - 1, c) + dfs(i, c - coins[i]);
};
return dfs(n - 1, amount);
}
};
解法四(动态规划-迭代II):
将解法三采用递归自顶向下的方式实现动态规划思想转换成利用迭代(自底向上)的方式实现动态规划,如下为实现代码:
class Solution {
public:
int change(int amount, vector<int>& coins) {
int n = coins.size();
// 和答案无关的转移可能会溢出,从而报错
// 为了避免报错,使用 unsigned
//vector f矩阵表示通过n种硬币,实现总数额为amount的组合方案(子问题的最优解)
vector f(n + 1, vector<unsigned>(amount + 1));
//定义初始值
f[0][0] = 1;
//第一层for循环表示仅采用前i个硬币种类
//第二层for循环表示仅采用前i个硬币种类达到总数额为c的总排列组合数
for (int i = 0; i < n; i++) {
for (int c = 0; c <= amount; c++) {
if (c < coins[i]) {
f[i + 1][c] = f[i][c];
} else {
f[i + 1][c] = f[i][c] + f[i + 1][c - coins[i]];
}
}
}
return f[n][amount];
}
};
时间复杂度:O(n⋅amount),其中 n 为 coins 的长度。由于每个状态只会计算一次,动态规划的时间复杂度 = 状态个数 × 单个状态的计算时间。本题状态个数等于 O(n⋅amount),单个状态的计算时间为 O(1),所以动态规划的时间复杂度为 O(n⋅amount)。空间复杂度:O(n⋅amount)。
笔者小记:
1、动态规划思想可极大降低时间复杂度,需特别关注。
动态规划的核心思想:
-
最优子结构:一个问题的最优解包含了其子问题的最优解。
-
子问题重叠:子问题在求解过程中会重复出现,因此可以缓存结果避免重复计算(通过数据结构进行存储)。
动态规划常见的两种实现方式:
-
自顶向下(递归 + 记忆化搜索):先尝试解决整个问题,遇到子问题时进行递归计算并存储结果,以避免重复计算。
-
自底向上(迭代):通过先计算小的子问题,逐步解决较大的问题,最终得到整个问题的解。
2、“||”与“|”运算符号的区别:仅仅是函数调用时的区别,数值上并没有区别。
-
||
(逻辑或)运算符有短路行为,即如果左边的操作数为true
,右边的操作数就不会被计算,因为结果已经确定为true
。这是为了优化性能。 -
|
(按位或)运算符没有短路行为,无论左边和右边的操作数是什么,都会计算左右两边的每个操作数。 -
其中“|”运算符可写成a|=b的形式,而“||”运算符不能写成类似a||=b,只能写成a=a || b的形式。