问题描述
小C有多种不同面值的硬币,每种硬币的数量是无限的。他希望知道,如何使用最少数量的硬币,凑出给定的总金额N。小C对硬币的组合方式很感兴趣,但他更希望在满足总金额的同时,使用的硬币数量尽可能少。
例如:小C有三种硬币,面值分别为 1, 2, 5。他需要凑出总金额为 18。一种最优的方案是使用三个 5 面值的硬币,一个 2 面值的硬币和一个 1 面值的硬币,总共五个硬币。
思路解析
采用了动态规划(Dynamic Programming,简称 DP)的思想来解决找零问题,即如何用最少数量的给定面值硬币凑出特定的总金额。
动态规划基础概念
动态规划是一种用于解决优化问题的算法策略,它的核心原理是将一个复杂的问题分解为一系列相互关联的子问题,并通过避免重复计算子问题的解,来高效地求解原问题。其通常具备以下两个关键特点:
最优子结构:原问题的最优解可以由子问题的最优解组合而成。在硬币找零问题中,比如要凑出金额 N 的最少硬币数量,假如知道了凑出金额 N - coin(coin 是某一面值硬币)的最优解,那么在此基础上加上一枚面值为 coin 的硬币,就有可能是凑出金额 N 的最优解,整个问题的最优解是建立在子问题最优解基础之上的,这体现了最优子结构特性。
重叠子问题:在求解过程中,会多次遇到相同的子问题。例如在计算凑不同金额所需最少硬币数量时,凑出金额 i 和凑出金额 j(i 和 j 满足一定条件且使用相同硬币面值集合)时,可能都需要去计算凑出金额 i - coin 或者 j - coin(coin 为某个硬币面值)等情况,这些子问题会被重复计算,而动态规划会通过记录(如使用表格形式,在代码里通常就是数组等数据结构)已经求解过的子问题答案,避免重复计算,提高效率。
应用到硬币找零问题的具体分析
1. 定义状态
在硬币找零问题中,我们通常定义一个状态数组(在代码中往往就是 dp 数组),dp[i] 表示凑出金额 i 所需要的最少硬币数量。例如,dp[5] 就代表凑出金额为 5 时最少需要多少枚硬币,这样我们把原问题 “凑出给定总金额 N 的最少硬币数量” 转化为了一个个关于不同金额的子问题,也就是去求解从 dp[0] 到 dp[N] 这些状态的值。
2. 确定状态转移方程
这是动态规划里非常关键的一步,它描述了子问题之间是如何相互关联、如何从已知的子问题解推导出其他子问题解的。
对于硬币找零问题,假设我们有一系列硬币面值 coins = [c1, c2, c3,...] ,状态转移方程可以这样来理解:
对于任意金额 i(i > 0),我们去遍历所有的硬币面值 coin(即 c1、c2 等),如果 i - coin >= 0(意味着当前这个硬币面值可以用来凑金额 i),那么 dp[i] 的值就可以通过 min(dp[i], dp[i - coin] + 1) 来更新。
例如,有硬币面值 1、2、5 ,要计算 dp[7] :
当考虑硬币面值 1 时,7 - 1 = 6,此时需要看 dp[6] 的值(假设已经算出),如果 dp[6] 是某个值(比如 3 枚硬币能凑出 6 ),那么用 1 面值硬币凑 7 时就可能是 dp[6] + 1 = 4 枚硬币,然后和当前 dp[7] 的值(初始为无穷大,代表还未确定最少硬币数)比较取较小值来更新 dp[7]。
同样,再考虑硬币面值 2,7 - 2 = 5,看 dp[5] 的值(假设已知),按照同样逻辑更新 dp[7]。
对硬币面值 5 也做类似操作,最后 dp[7] 就存储了凑出金额 7 的最少硬币数量。
简单来说,状态转移方程表达的就是:凑出金额 i 的最少硬币数量,是在所有能用来凑 i 的硬币面值对应的子问题(即凑 i - coin 的最少硬币数量情况)基础上,加上这一枚硬币(所以加 1 )后取最小的那个情况。
3. 确定边界条件
边界条件就是那些可以直接确定状态值、不需要通过状态转移方程推导的基础情况。在硬币找零问题里,很明显 dp[0] = 0 ,因为凑出金额为 0 不需要任何硬币,这是整个动态规划计算的起点,其他的 dp[i](i > 0)值才是需要通过状态转移方程逐步推导出来的。
4. 计算顺序
按照动态规划的思想,我们需要按照一定顺序去依次计算各个子问题(也就是各个 dp[i] 的值),通常是从小的金额往大的金额依次计算。比如先计算 dp[1],然后基于 dp[1] 去计算 dp[2] 等等,一直到计算出 dp[N],因为计算 dp[i] 时往往需要依赖比 i 小的金额对应的 dp 值(从状态转移方程可以看出),这样按顺序计算可以保证在计算每个 dp[i] 时,所依赖的子问题都已经被求解过了。
5. 逆向推导硬币组合(可选步骤,取决于具体需求)
在计算出了凑出总金额 N 的最少硬币数量(也就是 dp[N] 的值)之后,如果还想知道具体是用了哪些硬币凑出来的,可以通过逆向推导的方式来获取。
从总金额 N 开始,逆序遍历硬币面值列表,去检查对于每一个硬币面值 coin,如果使用这个硬币面值后,凑出剩余金额(N - coin)所需的最少硬币数量正好比凑出当前金额 N 所需的最少硬币数量少 1(即 dp[N - coin] == dp[N] - 1),那就说明这个硬币面值是最优组合里的一枚硬币,将其加入结果列表,同时更新剩余金额(N 减去刚加入的硬币面值),不断重复这个过程,直到剩余金额为 0,此时结果列表里的硬币就是最终凑出总金额的最少硬币组合了。
通过这样完整的动态规划流程,就可以高效地解决用最少数量给定面值硬币凑出特定总金额的找零问题了。
代码演示
def solution(coins, amount):
dp = [float('inf')] * (amount + 1)
dp[0] = 0
for i in range(1, amount + 1):
for coin in coins:
if i - coin >= 0:
dp[i] = min(dp[i], dp[i - coin] + 1)
if dp[amount] == float('inf'):
return None
result = []
while amount > 0:
for coin in coins[::-1]:
if amount >= coin and dp[amount - coin] == dp[amount] - 1:
result.append(coin)
amount -= coin
break
return result
if __name__ == "__main__":
print(solution([1, 2, 5], 18) == [5, 5, 5, 2, 1])
优化思路
状态转移方程优化
在原代码中,状态转移方程的实现是通过两层循环嵌套来完成的,内层循环遍历所有硬币面值去更新 dp[i] 的值。可以考虑进行一定的剪枝优化,比如对硬币面值列表 coins 进行排序(升序或降序都可,这里以升序为例),然后在遍历硬币面值时,一旦发现当前硬币面值大于正在计算的金额 i,就可以直接跳出内层循环,因为更大面值的硬币肯定无法用来凑当前金额了,这样可以减少不必要的比较操作,提高效率。
优化后代码如下:
def solution(coins, amount):
dp = [float('inf')] * (amount + 1)
dp[0] = 0
coins.sort() # 先对硬币面值排序
for i in range(1, amount + 1):
for coin in coins:
if i - coin >= 0:
dp[i] = min(dp[i], dp[i - coin] + 1)
else:
break # 当硬币面值大于当前金额时,跳出循环,后面更大面值硬币无需再判断
result = []
while amount > 0:
for coin in coins[::-1]:
if amount >= coin and dp[amount - coin] == dp[amount] - 1:
result.append(coin)
amount -= coin
break
return result
if __name__ == "__main__":
print(solution([1, 2, 5], 18) == [5, 5, 5, 2, 1])