【动态规划】【完全背包】力扣322. 零钱兑换
给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。
计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1 。
你可以认为每种硬币的数量是无限的。
示例 1:
输入:coins = [1, 2, 5], amount = 11
输出:3
解释:11 = 5 + 5 + 1
示例 2:
输入:coins = [2], amount = 3
输出:-1
示例 3:
输入:coins = [1], amount = 0
输出:0
法一:回溯+记忆化搜索
class Solution {
vector<int> count;
int dp(vector<int>& coins, int rem) {
if(rem == 0) return 0;
if(rem < 0) return -1;
if(count[rem-1] != 0) return count[rem-1];
int Min = INT_MAX;
for(int coin : coins){
int res = dp(coins, rem - coin);
if(res >= 0 && res < Min){
Min = res + 1;
}
}
count[rem-1] = Min == INT_MAX ? -1 : Min;
return count[rem-1];
}
public:
int coinChange(vector<int>& coins, int amount) {
if(amount == 0){
return 0;
}
count.resize(amount);
return dp(coins, amount);
}
};
时间复杂度:O(Sn),其中 S 是金额,n 是面额数。我们一共需要计算 S 个状态的答案,且每个状态 F(S) 由于上面的记忆化的措施只计算了一次,而计算一个状态的答案需要枚举 n 个面额值,所以一共需要 O(Sn) 的时间复杂度。
空间复杂度:O(S),我们需要额外开一个长为 S 的数组来存储计算出来的答案 F(S) 。
回溯部分:
int dp(vector<int>& coins, int rem) {
if(rem == 0) return 0;
if(rem < 0) return -1;
if(count[rem-1] != 0) return count[rem-1];
int Min = INT_MAX;
for(int coin : coins){
int res = dp(coins, rem - coin);
if(res >= 0 && res < Min){
Min = res + 1;
}
}
count[rem-1] = Min == INT_MAX ? -1 : Min;
return count[rem-1];
}
我们定义一个大小在主函数coinChange中定义为amount的数组count来记忆化,减少重复运算额。首先dp的定义是,coins也就是可选择的coins,rem也就是目标金额,也就是说dp(coins, rem)的含义就是,要凑成rem金额,最少需要多少枚硬币。
假设我们在主函数coinChange传入树的头结点dp(coins, amount)
,那么会发生什么过程?首先dp要计算最少需要多少枚硬币才能凑到目标金额,这时候他就会枚举如果拿了coin金额的一个硬币,那么只要再凑rem-coin金额即可。那么要计算dp(coins, rem),不就是dp(coins, rem - coin) + 1
吗,反过来想就是凑够rem - coin金额需要dp(coins, rem-coin)个硬币,那么再拿一个面值为coin的硬币,就可以凑成rem金额,然后硬币数量+1就是最小金额。但是由于dp(coins,rem-coin)由许多种情况构成,coin可以是不同面额,那么为了凑成价值为rem金额的最小硬币数量,就要求coin要满足dp(coins,rem-coin)必须是所有coin情况的最小的硬币数量,这时候再拿一个价值为coin的硬币,才是dp(coins,rem)的最小硬币组合数。这也就是为什么我们要遍历coins中每一个coin的情况,然后才能找到最小的res是多少,然后让他+1 = Min(注意:Min是局部变量,在每个递归中都存在,互不影响)。
有人会好奇那么不断找到最后,会发生什么,当rem == 0的时候,也就是末节点,实际上的含义也就是凑成金额0需要0种组合,这时候他的父节点就会计算res为0,然后记录Min为res+1 = 1。还有一种情况是rem最后小于0,那么说明这种组合是不合理的,这样子,这样一个路径上的coin金额加起来会大于你的目标amount,所以返回-1来说明这种情况不合理,这时候父节点的res由于小于0,则不会考虑记录到Min中。
最后也就是记忆每一种rem的金额的最小硬币数,下次计算金额为rem的dp,就会直接返回count中储存的结果。
法二:动态规划
class Solution {
public:
int coinChange(vector<int>& coins, int amount) {
vector<int>dp(amount+1, amount+1);
dp[0] = 0;
for(int i = 1; i <= amount; i++){
for(int j = 0; j < coins.size(); j++){
if(coins[j] <= i){
dp[i] = min(dp[i], dp[i-coins[j]]+1);
}
}
}
return dp[amount] == amount + 1 ? -1 : dp[amount];
}
};
时间复杂度:O(Sn),其中 S 是金额,n 是面额数。我们一共需要计算 O(S) 个状态,S 为题目所给的总金额。对于每个状态,每次需要枚举 n 个面额来转移状态,所以一共需要 O(Sn) 的时间复杂度。
空间复杂度:O(S)。数组 dp 需要开长度为总金额 S 的空间。
首先是初始化一个大小为amount+1的数组,并初始化所有元素为amount + 1,表示一个不可能的高值,用来初始化 dp 数组。因为不可能需要超过 amount 个硬币来凑成 amount,所以用 amount + 1 来初始化表示不可能的解。
然后遍历从 1 到 amount 的每个金额 i,试图找到每个金额 i 需要的最少硬币数。
内层循环:for (int j = 0; j < (int)coins.size(); ++j),遍历每个硬币面额 coins[j],如果 coins[j] <= i,就可以尝试使用这个硬币凑出金额 i。
dp[i - coins[j]] + 1
:表示在凑成金额 i - coins[j] 的基础上再加上一个面额为 coins[j] 的硬币,来凑出金额 i。
dp[i] = min(dp[i], dp[i - coins[j]] + 1)
:更新 dp[i],取当前 dp[i] 和 dp[i - coins[j]] + 1 的较小值,表示凑出金额 i 所需的最少硬币数。
最后如果dp[amount]没有被更新,返回-1,否则返回能组成该金额的最小硬币数。