PTA刷题笔记:用动态规划搞定‘凑零钱’,附C++代码逐行解析
动态规划实战:从零钱兑换到路径回溯的完整思维训练
在算法学习的道路上,动态规划(DP)始终是让许多学习者又爱又恨的话题。尤其是当面对PTA/LeetCode等平台上的变种背包问题时,理解状态转移方程只是第一步,如何设计辅助数组来记录决策路径,最终回溯出具体解,才是真正考验算法功底的环节。本文将以零钱兑换问题为切入点,带你走过从问题抽象到代码实现的完整思维历程。
1. 问题重述与初步分析
PTA上的"凑零钱"问题描述如下:给定N种不同面额的硬币和一个总金额M,编写一个函数来计算可以凑成总金额的硬币最小组合。如果没有任何一种硬币组合能组成总金额,返回"No Solution"。
这个问题看似简单,但有几个关键点需要注意:
- 硬币面额不重复 :每种硬币只能使用一次(0-1背包特性)
- 最小序列要求 :当存在多种组合时,选择字典序最小的方案
- 精确匹配 :硬币组合必须恰好等于目标金额,不能多也不能少
与标准背包问题不同,这里对解的"质量"有额外要求——不仅要求解存在,还要求特定性质的最优解。这促使我们需要在传统DP解法基础上增加 路径追踪 机制。
2. 动态规划框架设计
2.1 状态定义
我们定义 dp[i][j] 表示考虑前i种硬币时,能够凑成金额j的最大值。这个定义与标准背包问题一致:
i:考虑前i种硬币(1 ≤ i ≤ N)j:目标金额(1 ≤ j ≤ M)dp[i][j]:当前能凑到的最大金额(不超过j)
2.2 状态转移方程
对于每个硬币arr[i]和每个金额j,我们有两种选择:
- 不选当前硬币 :
dp[i][j] = dp[i-1][j] - 选当前硬币 :
dp[i][j] = dp[i-1][j-arr[i]] + arr[i]
取两者中的较大值:
if (j >= arr[i]) {
dp[i][j] = max(dp[i-1][j], dp[i-1][j-arr[i]] + arr[i]);
} else {
dp[i][j] = dp[i-1][j];
}
2.3 初始化边界
dp[0][j] = 0:0种硬币凑任何金额都是0dp[i][0] = 0:凑金额0不需要任何硬币
3. 路径回溯的关键技巧
仅仅求出能否凑出目标金额是不够的,我们需要记录决策过程以便回溯具体方案。这就是引入辅助数组 x[i][j] 的目的。
3.1 设计决策记录数组
我们定义 x[i][j] 为:
1:表示在凑金额j时选择了第i个硬币0:表示未选择
更新逻辑如下:
if (dp[i-1][j-arr[i]] + arr[i] >= dp[i-1][j]) {
x[i][j] = 1; // 记录选择
}
3.2 为什么需要排序?
原始代码中对硬币从大到小排序有一个关键作用: 确保回溯时优先选取小面额硬币 ,从而得到字典序最小的解。这是满足题目特殊要求的关键步骤。
bool cmp(int a, int b) { return a > b; }
sort(arr+1, arr+N+1, cmp); // 降序排列
排序后,在回溯阶段从后往前检查时,会优先考虑面额较小的硬币(因为它们在数组后面),从而保证结果的字典序最小。
3.3 回溯算法实现
当 dp[N][M] == M 时,说明有解,我们通过 x 数组回溯:
vector<int> ans;
int j = M;
for (int i = N; i >= 1; --i) {
if (x[i][j]) {
ans.push_back(arr[i]);
j -= arr[i];
}
}
4. 完整代码解析与优化
让我们整合上述思路,分析完整代码的实现细节:
4.1 输入处理与初始化
int N, M;
scanf("%d%d", &N, &M);
for (int i = 1; i <= N; ++i) {
scanf("%d", &arr[i]);
}
sort(arr+1, arr+N+1, cmp); // 关键排序
4.2 DP表填充过程
for (int i = 1; i <= N; ++i) {
for (int j = 1; j <= M; ++j) {
if (j >= arr[i]) {
dp[i][j] = max(dp[i-1][j], dp[i-1][j-arr[i]] + arr[i]);
if (dp[i-1][j-arr[i]] + arr[i] >= dp[i-1][j]) {
x[i][j] = 1; // 记录选择
}
} else {
dp[i][j] = dp[i-1][j];
}
}
}
4.3 结果判断与输出
if (dp[N][M] != M) {
puts("No Solution");
} else {
// 回溯路径
vector<int> ans;
int j = M;
for (int i = N; i >= 1; --i) {
if (x[i][j]) {
ans.push_back(arr[i]);
j -= arr[i];
}
}
// 输出结果
for (int i = 0; i < ans.size(); ++i) {
if (i != 0) printf(" ");
printf("%d", ans[i]);
}
}
4.4 空间优化技巧
我们可以将二维DP优化为一维数组,节省空间:
int dp[105] = {0}; // 一维数组
int x[10005][105]; // 决策数组仍需二维
for (int i = 1; i <= N; ++i) {
for (int j = M; j >= arr[i]; --j) { // 逆向遍历
if (dp[j - arr[i]] + arr[i] >= dp[j]) {
dp[j] = dp[j - arr[i]] + arr[i];
x[i][j] = 1; // 记录选择
}
}
}
注意决策数组 x 仍需保持二维,因为我们需要知道每个 (i,j) 位置的选择情况。
5. 常见错误与调试技巧
在实现这类DP问题时,有几个常见陷阱需要注意:
- 数组越界 :确保
j-arr[i]不小于0 - 初始化不全 :特别是
dp[0][j]和dp[i][0]的情况 - 排序方向错误 :升序还是降序取决于具体需求
- 回溯逻辑错误 :确保是从后往前回溯,且正确更新剩余金额
调试时可以打印中间DP表:
// 调试打印DP表
for (int i = 0; i <= N; ++i) {
for (int j = 0; j <= M; ++j) {
printf("%2d ", dp[i][j]);
}
printf("\n");
}
6. 变种问题与扩展思考
掌握了基本解法后,可以尝试以下变种问题:
- 硬币无限使用 :完全背包问题
- 所有可能组合数 :统计而非找出具体组合
- 最少硬币数量 :不同优化目标
- 特定面额限制 :每种硬币有使用上限
例如,求最少硬币数量的解法:
// 初始化
vector<int> dp(M + 1, INT_MAX);
dp[0] = 0;
// 状态转移
for (int coin : coins) {
for (int j = coin; j <= M; ++j) {
if (dp[j - coin] != INT_MAX) {
dp[j] = min(dp[j], dp[j - coin] + 1);
}
}
}
7. 实际应用与性能考量
虽然这个问题看起来是理论练习,但其核心思想在现实中有广泛应用:
- 金融系统 :现金组合优化
- 资源分配 :离散资源的最优配置
- 游戏开发 :道具合成系统
对于大规模数据(N>10000),需要考虑:
- 内存优化 :使用滚动数组或位压缩
- 剪枝策略 :提前终止不可能的分支
- 并行计算 :将DP表分割处理
// 并行计算示例(OpenMP)
#pragma omp parallel for
for (int i = 1; i <= N; ++i) {
for (int j = arr[i]; j <= M; ++j) {
if (dp[j - arr[i]] + arr[i] > dp[j]) {
dp[j] = dp[j - arr[i]] + arr[i];
}
}
}
8. 算法选择与比较
除了动态规划,这类问题还可以考虑:
| 方法 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 回溯 | O(2^N) | O(N) | 小规模数据 |
| DP | O(N*M) | O(N*M) | 精确解 |
| 贪心 | O(NlogN) | O(1) | 特定面额 |
贪心算法在某些特殊情况下(如面额为倍数关系)可能更高效:
sort(coins.rbegin(), coins.rend()); // 降序
vector<int> ans;
for (int coin : coins) {
while (M >= coin) {
ans.push_back(coin);
M -= coin;
}
}
if (M != 0) return "No Solution";
9. 测试用例设计
验证算法正确性需要设计全面的测试用例:
-
边界情况 :
- 空硬币列表
- 目标金额为0
- 单个硬币恰好等于目标
-
常规情况 :
- 存在唯一解
- 存在多个解
- 无解
-
极端数据 :
- 大金额小硬币
- 所有硬币都大于目标
- 硬币面额有重复
示例测试用例:
void test() {
// 测试用例1:常规情况
vector<int> coins1 = {1, 2, 5};
assert(coinChange(coins1, 11) == "5 5 1");
// 测试用例2:无解
vector<int> coins2 = {2};
assert(coinChange(coins2, 3) == "No Solution");
// 测试用例3:精确匹配
vector<int> coins3 = {3, 7, 10};
assert(coinChange(coins3, 17) == "10 7");
}
10. 进一步学习资源
想要深入掌握动态规划,推荐以下学习路径:
-
经典教材 :
- 《算法导论》动态规划章节
- 《算法竞赛入门经典》DP专题
-
在线练习平台 :
- PTA/LeetCode动态规划标签
- Codeforces DP专题比赛
-
可视化工具 :
- VisuAlgo动态规划演示
- Algorithm Visualizer
-
进阶话题 :
- 状态压缩DP
- 树形DP
- 数位DP
- 概率DP
// 状态压缩DP示例(旅行商问题)
int dp[1<<20][20];
memset(dp, 0x3f, sizeof(dp));
dp[1][0] = 0;
for (int mask = 1; mask < (1<<n); ++mask) {
for (int last = 0; last < n; ++last) {
if (!(mask & (1<<last))) continue;
for (int next = 0; next < n; ++next) {
if (mask & (1<<next)) continue;
dp[mask|(1<<next)][next] = min(dp[mask|(1<<next)][next],
dp[mask][last] + dist[last][next]);
}
}
}
更多推荐
所有评论(0)