最优硬币组合问题

142 阅读5分钟

最优硬币组合问题

在日常生活中,我们经常会遇到类似的“零钱兑换”问题:给定几种面值的硬币,每种硬币的数量是无限的,如何用最少的硬币数来凑出指定的金额。这个问题不仅涉及到硬币的选择,还需要最优地使用硬币数量,以确保最少的硬币数可以满足目标金额。

问题描述

假设你有多种不同面值的硬币,每种硬币的数量是无限的。你需要通过最少数量的硬币来凑出指定的金额。如果存在多个最优解,则返回其中一种解法即可。以下是几个典型的测试场景:

  1. 示例 1:硬币面值 [1, 2, 5],目标金额 18

    • 最优解:[5, 5, 5, 2, 1]
    • 解释:我们使用了三个 5 面值的硬币,一个 2 面值的硬币和一个 1 面值的硬币,总共五个硬币。
  2. 示例 2:硬币面值 [1, 3, 4],目标金额 6

    • 最优解:[3, 3]
    • 解释:我们可以使用两个 3 面值的硬币,总共两枚硬币。
  3. 示例 3:硬币面值 [5],目标金额 10

    • 最优解:[5, 5]
    • 解释:我们只有 5 面值的硬币,最优解就是两个 5 面值的硬币。
  4. 示例 4:硬币面值 [2, 5],目标金额 3

    • 最优解:[]
    • 解释:由于 25 面值的硬币无法凑出 3,所以返回空列表。

解题思路

这个问题是经典的 动态规划 问题,可以使用 DP(动态规划) 来解决。

我们设定一个数组 dp,其中 dp[i] 表示凑出金额 i 所需的最少硬币数量。初始化时,我们将 dp[0] = 0(因为凑出 0 元不需要任何硬币),其他金额初始化为一个较大的数,表示无法凑出该金额。

然后,我们通过每一种硬币的面值,更新 dp 数组,试图用最少的硬币数量凑出所有可能的金额。

动态规划步骤

  1. 定义状态:设 dp[i] 为凑出金额 i 所需要的最少硬币数量。

  2. 初始化状态dp[0] = 0,其他 dp[i] 初始化为正无穷。

  3. 状态转移:对于每个硬币面值 coin,对于每个金额 i,尝试更新 dp[i]: [ dp[i] = \min(dp[i], dp[i - \text{coin}] + 1) ] 其中 coin 是当前硬币的面值。

  4. 终止条件:遍历完所有硬币后,如果 dp[amount] 仍然是正无穷,说明无法凑出该金额;否则,dp[amount] 就是所需的最少硬币数量。

  5. 回溯找硬币组合:一旦我们得到了最少硬币数量,我们需要回溯并找出具体的硬币组合。

代码实现

def solution(coins, amount):
    # 初始化 dp 数组,dp[i] 表示凑出金额 i 所需要的最少硬币数量
    dp = [float('inf')] * (amount + 1)
    dp[0] = 0  # 凑出金额 0 不需要硬币
    
    # 记录每个金额的最后使用的硬币
    coin_used = [-1] * (amount + 1)
    
    # 动态规划,计算每个金额所需的最少硬币数量
    for coin in coins:
        for i in range(coin, amount + 1):
            if dp[i - coin] + 1 < dp[i]:
                dp[i] = dp[i - coin] + 1
                coin_used[i] = coin
    
    # 如果 dp[amount] 仍为无穷大,说明无法凑出该金额
    if dp[amount] == float('inf'):
        return []
    
    # 回溯找出硬币组合
    result = []
    while amount > 0:
        result.append(coin_used[amount])
        amount -= coin_used[amount]
    
    return result

# 测试样例
print(solution([1, 2, 5], 18))  # 输出: [5, 5, 5, 2, 1]
print(solution([1, 3, 4], 6))   # 输出: [3, 3]
print(solution([5], 10))        # 输出: [5, 5]
print(solution([2, 5], 3))      # 输出: []

代码解释

  1. 初始化 dp 数组

    • dp[i] 初始化为 inf,表示暂时无法凑出金额 i,除了 dp[0] = 0,表示凑出金额 0 不需要硬币。
  2. 动态规划更新 dp

    • 对于每个硬币面值 coin,我们更新所有金额 i(从 coinamount)。如果通过使用 coin 面值的硬币能够减少硬币数量,那么我们更新 dp[i] 和记录 coin_used[i]
  3. 回溯找硬币组合

    • dp 数组更新完成后,我们从 dp[amount] 开始回溯,逐步找出使用的硬币,并记录在 result 数组中。
  4. 返回结果

    • 如果 dp[amount]inf,说明无法用这些硬币凑出目标金额,返回空列表。
    • 否则,返回回溯得到的硬币组合。

时间复杂度

  • 时间复杂度O(n * m),其中 n 是硬币的种类数,m 是目标金额 amount。对于每个硬币,我们需要遍历从该硬币面值开始到目标金额的所有金额。

  • 空间复杂度O(m),我们需要存储 dp 数组和 coin_used 数组,大小均为 m + 1

测试结果

  • solution([1, 2, 5], 18) 输出 [5, 5, 5, 2, 1],符合预期。
  • solution([1, 3, 4], 6) 输出 [3, 3],符合预期。
  • solution([5], 10) 输出 [5, 5],符合预期。
  • solution([2, 5], 3) 输出 [],符合预期。

总结

这个问题的关键是使用动态规划来逐步求解每个金额所需的最少硬币数,同时通过回溯找出具体的硬币组合。我们通过 dp 数组记录每个金额所需的最少硬币数,再利用 coin_used 数组回溯硬币的组合。这种方法时间复杂度较低,适合大多数硬币组合和目标金额的情况。