从PTA‘凑零钱’到面试真题:手把手教你用动态规划搞定找零问题(附C++代码)
从零钱兑换到动态规划:解密算法面试中的经典问题
在算法面试中,动态规划问题总是让许多求职者感到头疼。其中,零钱兑换问题堪称经典中的经典,它不仅考察了候选人对动态规划核心思想的理解,还能检验其将数学思维转化为代码实现的能力。无论是PTA平台上的"凑零钱"题目,还是LeetCode中的"Coin Change"问题,本质上都在测试同一个算法范式——如何用最优雅的方式解决看似复杂的组合优化难题。
1. 动态规划基础:为什么它能解决零钱问题
动态规划(Dynamic Programming,简称DP)之所以能高效解决零钱兑换这类问题,关键在于它避免了暴力搜索带来的指数级时间复杂度。想象一下,如果你有10种不同面额的硬币,要凑出100元的金额,暴力枚举所有可能的组合将是一个天文数字。
动态规划解决零钱问题的三个核心特征 :
- 最优子结构 :问题的最优解包含子问题的最优解。要凑出金额N的最优解,必然由凑出金额N-coin的最优解加上一个coin面额组成。
- 重叠子问题 :在递归求解过程中,相同的子问题会被反复计算。比如凑50元可能需要计算凑30元的解多次。
- 无后效性 :当前状态一旦确定,后续决策不受之前决策的影响。凑出某个金额的方式不影响后续如何凑剩余金额。
让我们用一个简单的例子来说明。假设硬币面额为[1, 2, 5],要凑出金额11:
def coinChange(coins, amount):
dp = [float('inf')] * (amount + 1)
dp[0] = 0
for coin in coins:
for i in range(coin, amount + 1):
dp[i] = min(dp[i], dp[i - coin] + 1)
return dp[amount] if dp[amount] != float('inf') else -1
这个基础实现展示了动态规划解决零钱问题的典型思路:初始化一个dp数组,其中dp[i]表示凑出金额i所需的最少硬币数,然后通过遍历每个硬币面额来更新这个数组。
2. 从理论到实践:零钱问题的四种解法对比
在实际面试中,面试官往往期待候选人能够展示对不同解法的理解和比较。让我们深入分析零钱兑换问题的多种解法及其适用场景。
2.1 暴力递归法
最直观的方法是尝试所有可能的硬币组合。对于每个金额,我们尝试每一种硬币面额,然后递归求解剩余金额。
def coinChange(coins, amount):
def helper(remaining):
if remaining < 0: return -1
if remaining == 0: return 0
min_coins = float('inf')
for coin in coins:
res = helper(remaining - coin)
if res >= 0 and res < min_coins:
min_coins = res + 1
return min_coins if min_coins != float('inf') else -1
return helper(amount)
时间复杂度 :O(S^n),其中S是金额,n是硬币种类数。这种方法在硬币面额较小时还能接受,但当amount变大时,性能急剧下降。
2.2 记忆化递归(自顶向下)
为了优化暴力递归,我们可以保存已经计算过的子问题的结果,避免重复计算。
def coinChange(coins, amount):
memo = {}
def helper(remaining):
if remaining in memo: return memo[remaining]
if remaining < 0: return -1
if remaining == 0: return 0
min_coins = float('inf')
for coin in coins:
res = helper(remaining - coin)
if res >= 0 and res < min_coins:
min_coins = res + 1
memo[remaining] = min_coins if min_coins != float('inf') else -1
return memo[remaining]
return helper(amount)
时间复杂度 :O(S*n),其中S是金额,n是硬币种类数。空间复杂度为O(S)用于存储memo。
2.3 动态规划(自底向上)
这是最常见的面试解法,也是PTA"凑零钱"题目采用的方法。它通过迭代方式填充DP表,避免了递归的开销。
def coinChange(coins, amount):
dp = [float('inf')] * (amount + 1)
dp[0] = 0
for coin in coins:
for i in range(coin, amount + 1):
dp[i] = min(dp[i], dp[i - coin] + 1)
return dp[amount] if dp[amount] != float('inf') else -1
时间复杂度 :O(S*n),空间复杂度O(S)。这种方法通常是最优选择,除非有特殊限制。
2.4 贪心算法(有限条件下适用)
在某些特定情况下(如硬币面额是倍数关系),贪心算法可以得到最优解:每次尽可能使用最大面额的硬币。
def coinChange(coins, amount):
coins.sort(reverse=True)
count = 0
for coin in coins:
if amount <= 0: break
count += amount // coin
amount %= coin
return count if amount == 0 else -1
时间复杂度 :O(n),当适用时效率极高,但大多数情况下无法得到最优解。
解法对比表 :
| 方法 | 时间复杂度 | 空间复杂度 | 适用场景 | 是否总能得到最优解 |
|---|---|---|---|---|
| 暴力递归 | O(S^n) | O(n) | 小规模问题 | 是 |
| 记忆化递归 | O(S*n) | O(S) | 通用 | 是 |
| 动态规划 | O(S*n) | O(S) | 通用 | 是 |
| 贪心算法 | O(n) | O(1) | 特定面额 | 否 |
3. 进阶技巧:路径回溯与优化
在面试中,仅仅给出最少硬币数量往往不够。面试官通常会追问如何得到具体的硬币组合,这正是PTA"凑零钱"题目中的难点所在。
3.1 记录选择路径
为了回溯出具体的硬币组合,我们需要在DP过程中额外记录选择。这与传统的0-1背包问题类似,但需要考虑硬币的顺序。
vector<int> coinChangeWithPath(vector<int>& coins, int amount) {
vector<int> dp(amount + 1, INT_MAX);
vector<vector<int>> path(amount + 1);
dp[0] = 0;
sort(coins.begin(), coins.end()); // 排序确保顺序一致性
for (int coin : coins) {
for (int i = coin; i <= amount; ++i) {
if (dp[i - coin] != INT_MAX && dp[i - coin] + 1 < dp[i]) {
dp[i] = dp[i - coin] + 1;
path[i] = path[i - coin];
path[i].push_back(coin);
}
}
}
return dp[amount] == INT_MAX ? vector<int>() : path[amount];
}
注意:在实际编码时,路径记录可能会消耗较多内存。对于大金额,需要考虑更高效的回溯方法。
3.2 空间优化技巧
标准的DP解法需要O(S)空间,但当硬币面额较大时,我们可以优化空间复杂度。
def coinChangeOptimized(coins, amount):
dp = [float('inf')] * (amount + 1)
dp[0] = 0
for i in range(1, amount + 1):
for coin in coins:
if i >= coin:
dp[i] = min(dp[i], dp[i - coin] + 1)
return dp[amount] if dp[amount] != float('inf') else -1
这种实现改变了循环顺序,有时可以更好地利用CPU缓存,但时间复杂度不变。
3.3 处理特殊边界条件
在实际面试中,处理好边界条件能展现你的编码严谨性:
- 金额为0时应该返回0(不需要任何硬币)
- 硬币数组为空时应返回-1(无法凑出任何正数金额)
- 硬币面额有负数或0时的处理
- 大数溢出问题
def coinChangeRobust(coins, amount):
if amount < 0: return -1
if amount == 0: return 0
if not coins: return -1
valid_coins = [c for c in coins if c > 0]
if not valid_coins: return -1
dp = [float('inf')] * (amount + 1)
dp[0] = 0
for coin in valid_coins:
for i in range(coin, amount + 1):
if dp[i - coin] + 1 < dp[i]:
dp[i] = dp[i - coin] + 1
return dp[amount] if dp[amount] != float('inf') else -1
4. 面试实战:如何应对变种问题
掌握了基础解法后,面试官往往会提出各种变种问题来考察你的灵活应用能力。以下是几种常见的变体及解决思路。
4.1 计算组合数而非最小硬币数
问题变体:不关心最少硬币数,而是计算凑出金额的所有可能组合数。
解法:将DP数组的含义改为组合数,初始化dp[0]=1(金额0有1种组合方式),递推公式变为累加。
def change(amount, coins):
dp = [0] * (amount + 1)
dp[0] = 1
for coin in coins:
for i in range(coin, amount + 1):
dp[i] += dp[i - coin]
return dp[amount]
4.2 限制硬币使用数量
问题变体:每种硬币有数量限制,如最多使用k次。
解法:将问题转化为多重背包问题,可以增加一维状态来记录使用次数,或者将每种硬币拆分成多个单独的硬币。
def coinChangeWithLimit(coins, limits, amount):
dp = [float('inf')] * (amount + 1)
dp[0] = 0
for i in range(len(coins)):
coin = coins[i]
limit = limits[i]
# 逆序更新避免重复使用
for j in range(amount, coin - 1, -1):
for k in range(1, limit + 1):
if j >= k * coin:
dp[j] = min(dp[j], dp[j - k * coin] + k)
return dp[amount] if dp[amount] != float('inf') else -1
4.3 打印所有可能的组合
问题变体:需要输出所有可能的硬币组合,而不仅仅是数量或一种组合。
解法:使用回溯法结合剪枝,虽然时间复杂度较高,但在硬币面额较大时可能是唯一选择。
def combinationSum(coins, amount):
def backtrack(start, target, path):
if target == 0:
res.append(path.copy())
return
for i in range(start, len(coins)):
coin = coins[i]
if coin > target: continue
path.append(coin)
backtrack(i, target - coin, path)
path.pop()
res = []
coins.sort()
backtrack(0, amount, [])
return res
4.4 面额无限与有限的对比
在面试中,明确问题是"无限硬币"还是"有限硬币"至关重要:
- 无限硬币 :每个面额的硬币可以使用无限次(完全背包问题)
- 有限硬币 :每个面额的硬币有固定数量(多重背包问题)
实现差异对比 :
| 方面 | 无限硬币 | 有限硬币 |
|---|---|---|
| 循环顺序 | 通常正序更新 | 通常逆序更新 |
| 状态转移 | dp[i] = min(dp[i], dp[i-coin]+1) | 需要额外限制条件 |
| 空间优化 | 可以一维数组 | 可能需要二维数组 |
5. 从算法到工程:实际应用中的考量
当我们将动态规划应用于实际工程问题时,还需要考虑许多算法本身之外的因素。这些思考往往能在面试中为你加分。
5.1 性能优化实践
对于大金额或大面额的情况,纯DP解法可能不够高效。可以考虑以下优化策略:
- 贪心预筛选 :先用贪心算法快速判断是否有解,再使用DP精确求解
- 面额预处理 :去除明显无用的硬币(如面额大于目标金额的)
- 数学性质利用 :对于特定面额组合(如倍数关系),可以使用数学方法简化
- 并行计算 :某些DP问题可以并行化处理不同面额的循环
def optimizedCoinChange(coins, amount):
# 预处理:去除无效硬币和排序
coins = [c for c in coins if c <= amount]
if not coins: return -1
coins.sort(reverse=True)
# 先用贪心快速检查下限
remaining = amount
greedy_count = 0
for coin in coins:
if remaining <= 0: break
greedy_count += remaining // coin
remaining %= coin
if remaining == 0: return greedy_count
# 标准DP解法
dp = [float('inf')] * (amount + 1)
dp[0] = 0
for coin in coins:
for i in range(coin, amount + 1):
dp[i] = min(dp[i], dp[i - coin] + 1)
return dp[amount] if dp[amount] != float('inf') else -1
5.2 测试用例设计
在面试中展示全面的测试思维也很重要。针对零钱问题,好的测试用例应该包括:
- 常规情况(多种面额,合理金额)
- 边界情况(金额为0,金额为1)
- 无法凑出的情况
- 大数测试(检测溢出和性能)
- 特殊面额(有1元和无1元的情况不同)
- 重复面额和非法输入
示例测试矩阵 :
| 测试类型 | 输入coins | 输入amount | 预期输出 |
|---|---|---|---|
| 常规 | [1,2,5] | 11 | 3 |
| 无解 | [2,5] | 3 | -1 |
| 边界 | [1] | 0 | 0 |
| 大数 | [1,5,10,25] | 1000000 | 40000 |
| 重复面额 | [1,1,2,5] | 8 | 3 |
| 非法输入 | [] | 10 | -1 |
5.3 语言特定实现细节
不同编程语言实现动态规划时有其特定注意事项:
C++实现要点 :
- 使用vector而非原生数组更安全
- 注意整数溢出问题,特别是当amount很大时
- 可以使用位操作优化某些情况
int coinChange(vector<int>& coins, int amount) {
vector<int> dp(amount + 1, amount + 1);
dp[0] = 0;
for (int coin : coins) {
for (int i = coin; i <= amount; ++i) {
dp[i] = min(dp[i], dp[i - coin] + 1);
}
}
return dp[amount] > amount ? -1 : dp[amount];
}
Java实现要点 :
- 使用Arrays.fill初始化DP数组
- 注意使用Integer.MAX_VALUE表示无穷大时的溢出问题
public int coinChange(int[] coins, int amount) {
int[] dp = new int[amount + 1];
Arrays.fill(dp, amount + 1);
dp[0] = 0;
for (int coin : coins) {
for (int i = coin; i <= amount; i++) {
dp[i] = Math.min(dp[i], dp[i - coin] + 1);
}
}
return dp[amount] > amount ? -1 : dp[amount];
}
Python实现要点 :
- 利用列表推导式简化代码
- 注意浮点数精度问题(使用math.inf代替float('inf'))
import math
def coinChange(coins, amount):
dp = [math.inf] * (amount + 1)
dp[0] = 0
for coin in coins:
for i in range(coin, amount + 1):
dp[i] = min(dp[i], dp[i - coin] + 1)
return dp[amount] if dp[amount] != math.inf else -1
更多推荐
所有评论(0)