零钱兑换

给你一个整数数组 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

提示:

  • 1 <= coins.length <= 12
  • 1 <= coins[i] <= 2^31 - 1
  • 0 <= amount <= 10^4

动态规划:

class Solution {
public:
    int coinChange(vector<int>& coins, int amount) {
        int INF = amount + 1;
        vector<int> dp(amount + 1, INF);
        dp[0] = 0;

        for (int i = 1; i <= amount; i++)
        {
            for (int coin : coins)
            {
                if (coin <= i && dp[i - coin] != INF)
                {
                    dp[i] = min(dp[i], dp[i - coin] + 1);
                }
            }
        }

        return dp[amount] == INF ? -1 : dp[amount];
    }
};

核心思想

这题要求的是:用最少数量的硬币凑出 amount

硬币可以无限次使用,所以它不是“每个硬币只能选一次”的问题,而是一个典型的完全背包问题。

不过这题不需要写成复杂的背包模板,可以直接从“最后一枚硬币是谁”这个角度理解。

假设现在要凑出金额 i

如果最后一枚硬币的面额是 coin,那么前面已经凑出的金额一定是:

i - coin

如果凑出 i - coin 的最少硬币数是:

dp[i - coin]

那么再加上最后这枚 coin,凑出 i 的硬币数就是:

dp[i - coin] + 1

所以我们枚举所有可以作为最后一枚硬币的 coin,取最小值即可。

这题最关键的观察是:

凑出金额 i 的最优方案,一定可以看成“凑出 i - coin 的最优方案 + 一枚面额为 coin 的硬币”。

状态定义

定义:

dp[i] 表示凑出金额 i 所需要的最少硬币个数。

题目要求的答案就是:

dp[amount]

如果某个金额无法凑出,就让它保持为一个很大的值 INF

在代码中:

int INF = amount + 1;

因为所有硬币面额都是正整数。

如果金额 i 可以被凑出,那么即使全部使用面额为 1 的硬币,最多也只需要 i 枚。

因此对于 amount 范围内的问题,真正的答案不可能超过 amount

所以 amount + 1 可以安全地表示“不可能凑出”。

递推公式推导

现在考虑如何计算 dp[i]

对于每一种硬币面额 coin,如果:

coin <= i

那么这枚硬币可以作为凑出金额 i 的最后一枚硬币。

选择这枚硬币以后,剩余金额是:

i - coin

如果 i - coin 可以凑出,那么凑出 i 的一种方案就是:

dp[i - coin] + 1

其中 + 1 表示当前选择的这枚 coin

由于最后一枚硬币可能是 coins 中的任意一种面额,所以要枚举所有硬币,取最小值:

dp[i] = min(dp[i], dp[i - coin] + 1)

完整条件是:

  • coin <= i
  • dp[i - coin] 不是不可能状态

所以代码中写成:

if (coin <= i && dp[i - coin] != INF)
{
    dp[i] = min(dp[i], dp[i - coin] + 1);
}

边界情况

amount = 0 时,不需要任何硬币就能凑出总金额 0

所以:

dp[0] = 0

这也是整个递推的起点。

例如:

coins = [1], amount = 0

答案就是 0

如果某个金额不能被任何组合凑出,它的 dp 值会一直保持为 INF

最后如果:

dp[amount] == INF

说明没有方案可以凑出 amount,返回 -1

为什么硬币可以重复使用

题目说每种硬币的数量是无限的。

在递推公式中:

dp[i] = min(dp[i], dp[i - coin] + 1)

dp[i - coin] 本身可能已经使用过面额为 coin 的硬币。

现在再额外加一枚 coin,仍然是合法的。

例如:

coins = [1, 2, 5], amount = 11

最优方案是:

11 = 5 + 5 + 1

其中面额 5 的硬币使用了两次。

这正是完全背包和普通 0-1 背包的区别:

  • 0-1 背包:每个物品最多只能用一次
  • 完全背包:每个物品可以使用无限次

本题属于完全背包。

为什么遍历顺序是正确的

代码中外层枚举金额:

for (int i = 1; i <= amount; i++)

内层枚举硬币:

for (int coin : coins)

当我们计算 dp[i] 时,只会用到:

dp[i - coin]

因为 coin >= 1,所以:

i - coin < i

也就是说,当前状态只依赖更小金额的状态。

而外层金额是从小到大遍历的,所以在计算 dp[i] 之前,所有可能用到的 dp[i - coin] 都已经计算完成。

因此这个递推顺序是正确的。

正确性证明

我们证明:动态规划算法返回的结果等于凑出 amount 所需的最少硬币个数;如果无法凑出,则返回 -1

归纳基础

当金额为 0 时,不选择任何硬币就可以凑出。

所需硬币数量为 0,所以:

dp[0] = 0

正确。

归纳假设

假设对于所有小于 i 的金额 xdp[x] 都已经正确表示凑出金额 x 所需的最少硬币数。

如果金额 x 无法凑出,那么 dp[x] 保持为 INF

归纳推导

现在考虑金额 i

任意一个凑出金额 i 的合法方案,最后一枚硬币一定来自数组 coins

假设最后一枚硬币的面额是 coin

那么最后一枚硬币之前,其余硬币凑出的金额一定是:

i - coin

并且必须满足:

coin <= i

根据归纳假设,dp[i - coin] 已经正确表示凑出 i - coin 的最少硬币数。

因此,在最后一枚硬币固定为 coin 的所有方案中,最少硬币数就是:

dp[i - coin] + 1

算法会枚举所有满足 coin <= i 的硬币,所以不会漏掉任何可能作为最后一枚硬币的选择。

因此,算法得到的最小值不会大于真实最优解。

另一方面,算法每次更新 dp[i] 时,使用的候选值都是:

dp[i - coin] + 1

这表示:

  1. 先用 dp[i - coin] 枚硬币凑出金额 i - coin
  2. 再加入一枚面额为 coin 的硬币

总金额就是:

i - coin + coin = i

所以算法构造出的每个候选方案都是真实合法的方案。

因此,算法得到的答案不会小于真实最优解。

综合两点可知,dp[i] 恰好等于凑出金额 i 所需的最少硬币数。

由数学归纳法可知,所有 0 <= i <= amountdp[i] 都正确。

如果最终 dp[amount] 仍然是 INF,说明所有可能方案都无法凑出 amount,返回 -1 正确。

否则返回 dp[amount],就是最少硬币数量。

因此算法正确。

举例理解

以:

coins = [1, 2, 5], amount = 11

为例。

初始化:

dp[0] = 0

逐步计算:

金额 i 最优拆法 dp[i]
1 1 1
2 2 1
3 2 + 1 2
4 2 + 2 2
5 5 1
6 5 + 1 2
7 5 + 2 2
8 5 + 2 + 1 3
9 5 + 2 + 2 3
10 5 + 5 2
11 5 + 5 + 1 3

所以:

dp[11] = 3

最终答案是 3

再看:

coins = [2], amount = 3

只能使用面额为 2 的硬币。

金额 1 无法凑出,金额 3 也无法由若干个 2 凑出。

所以 dp[3] 最终仍然是 INF,返回 -1

复杂度分析

设硬币种类数为 m,目标金额为 amount

外层循环枚举金额,从 1amount

内层循环枚举所有硬币,一共有 m 种。

所以时间复杂度是:

O(amount * m)

额外使用了一个长度为 amount + 1dp 数组。

所以空间复杂度是:

`O(amount)