硬币找零问题的动态规划解法与实现思考笔记

硬币找零问题的动态规划解法与实现思考笔记

一、问题背景回顾

给定不同面额的硬币数组 coins 和总金额 amount,要求计算凑成该金额所需的最小硬币个数,若无法凑出则返回 -1,且每种硬币可无限使用。这一问题属于典型的"完全背包问题",核心是在"物品可重复选取"的约束下,寻找满足目标的最优解,动态规划是解决此类问题的高效思路。

二、基础解法:常规动态规划思路

2.1 状态定义与核心逻辑

首先明确动态规划的核心是"用子问题的最优解推导原问题解",针对本题:

  • 定义 dp[i]:表示凑成金额 i 所需的最小硬币个数;
  • 初始条件:dp[0] = 0(凑金额0无需硬币),其余 dp[i] 初始化为一个"足够大的数"(需避免后续计算溢出),代表初始状态下无法凑出该金额;
  • 状态转移:对于每个金额 i 和每种硬币 coincoin ≤ i),若使用该硬币,则 dp[i] 可由 dp[i - coin] + 1 推导(凑 i - coin 的最小硬币数加当前这枚硬币),最终取所有可能中的最小值,即 dp[i] = min(dp[i], dp[i - coin] + 1)

2.2 基础实现

cpp 复制代码
#include <vector>
#include <climits>
#include <algorithm>
using namespace std;

class Solution {
public:
    int coinChange(vector<int>& coins, int amount) {
        // 初始化dp数组,INT_MAX-1避免后续+1溢出
        vector<int> dp(amount + 1, INT_MAX - 1);
        dp[0] = 0; // 金额0的基准条件
        
        // 遍历每种硬币(完全背包:物品可重复选,先遍历物品再遍历容量)
        for (auto coin : coins) {
            // 遍历从硬币面额到目标金额的所有金额
            for (int i = coin; i <= amount; i++) {
                // 状态转移:尝试用当前硬币更新最小硬币数
                dp[i] = min(dp[i], dp[i - coin] + 1);
            }
        }
        
        // 判断是否能凑出目标金额
        return dp[amount] == INT_MAX - 1 ? -1 : dp[amount];
    }
};

2.3 基础解法分析

  • 时间复杂度:O(n * m),其中 n 为硬币种类数,m 为目标金额。需遍历每种硬币,且每种硬币需遍历从其面额到目标金额的所有值,属于该问题的常规时间复杂度;
  • 空间复杂度:O(m),依赖长度为 amount + 1dp 数组存储子问题解,空间开销与目标金额线性相关。

三、实现细节的深度思考

3.1 初始值的选择逻辑

初始化 dp 数组时选用 INT_MAX - 1 而非 INT_MAX,核心是规避整数溢出:若使用 INT_MAX,当执行 dp[i - coin] + 1 时,INT_MAX + 1 会超出int类型的取值范围,导致数值溢出变为负数,破坏状态转移的正确性。而 INT_MAX - 1 既满足"初始为不可达的大数"的逻辑,又能保证后续加法运算的安全性。

在工程实现中,这类"边界值处理"是保证代码鲁棒性的关键------看似微小的初始值选择,直接影响结果的正确性,尤其在大规模金额计算时,溢出问题会直接导致程序出错。

3.2 遍历顺序的合理性

本题采用"先遍历硬币、再遍历金额"的顺序,是完全背包问题的标准写法:

  • 完全背包的核心特征是"物品可重复选取",先固定硬币(物品),再从小到大遍历金额(背包容量),能保证同一硬币被多次使用;
  • 若颠倒遍历顺序(先金额后硬币),则变为"01背包"(每个物品仅能选一次),不符合本题"硬币数量无限"的约束,会导致结果错误。

这一遍历顺序的选择,本质是对问题类型的精准匹配------理解"完全背包"与"01背包"的核心差异,才能选择正确的遍历逻辑。

3.3 边界条件的补充验证

除了 dp[0] = 0 的基准条件,实际应用中还需考虑以下边界场景:

  1. 目标金额为0:直接返回0(无需硬币);
  2. 硬币数组为空:若金额大于0则返回-1;
  3. 硬币面额大于目标金额:该硬币不参与该金额的状态转移(代码中通过 i >= coin 自然规避)。

这些边界场景的处理,无需额外编写大量代码,而是通过状态定义和遍历逻辑自然覆盖,体现了动态规划思路的简洁性。

四、优化方向与工程权衡

4.1 空间优化的可能性

基础解法的空间复杂度为 O(m),理论上可进一步优化:由于状态转移仅依赖 dp[i - coin](即更小金额的解),无需保存完整的 dp 数组,可尝试用变量压缩空间。但实际中,硬币面额不固定,不同硬币对应的 i - coin 差值不同,压缩空间会导致逻辑复杂度大幅提升,且 O(m) 的空间开销在多数场景下(如金额不超过10^4)是可接受的。

因此工程上更倾向于保留完整的 dp 数组------以可接受的空间开销,换取代码的可读性和可维护性,这是"性能与可读性"的典型权衡。

4.2 时间优化的尝试

时间复杂度 O(n * m) 是动态规划解法的常规上限,但若硬币数组存在明显特征(如面额有序),可做小幅优化:

  1. 先对硬币数组排序,遍历金额时若 i - coin < 0 可提前终止内层循环;
  2. 剔除硬币数组中大于目标金额的元素,减少无效遍历。

这类优化不会改变时间复杂度的量级,但能减少实际运行的循环次数,在大规模输入下可提升执行效率,属于"工程层面的细节优化"。

4.3 其他解法的对比

除动态规划外,硬币找零问题也可尝试贪心算法,但贪心仅适用于"硬币面额满足贪心选择性质"的场景(如人民币面额1、5、10、20等),对于任意面额的硬币,贪心无法保证得到最优解。例如,硬币面额为 [25, 10, 1] 时,凑金额30用贪心(25+15,共6枚)与最优解(10 3,共3枚)一致;但面额为 [25, 20, 1] 时,凑金额30用贪心(25+15,6枚),最优解却是 20+10(无10则为20+1 10,11枚?此处修正:面额[25,20,1]凑30,最优解是20+110?不,面额只有25、20、1,最优解应为20+1 10(11枚),而贪心是25+15(6枚),反而更优------换个例子:面额[18,10,1]凑28,贪心选18+1 10(11枚),最优解是102+18(10枚),此时贪心失效)。

因此,动态规划是解决"任意面额硬币找零"的通用解法,贪心仅为特定场景的补充,工程上需根据硬币面额的实际情况选择解法。

五、总结

  1. 硬币找零问题的核心是完全背包模型,动态规划的关键是定义 dp[i] 为凑金额 i 的最小硬币数,通过 dp[i] = min(dp[i], dp[i - coin] + 1) 完成状态转移;
  2. 实现时需注意初始值选择(避免溢出)、遍历顺序(匹配完全背包特性),这些细节直接影响代码的正确性;
  3. 工程层面需权衡性能与可读性,空间优化虽可行但性价比低,时间优化可通过细节调整实现,贪心算法仅适用于特定面额场景。
相关推荐
Renhao-Wan1 小时前
Java 算法实践(三):双指针与滑动窗口
java·数据结构·算法
Pluchon1 小时前
硅基计划4.0 算法 图的存储&图的深度广度搜索&最小生成树&单源多源最短路径
java·算法·贪心算法·深度优先·动态规划·广度优先·图搜索算法
凉、介1 小时前
文件系统(一)——分区表
笔记·学习·嵌入式
日更嵌入式的打工仔2 小时前
【无标题】
笔记·原文翻译
今儿敲了吗2 小时前
19| 海底高铁
c++·笔记·学习·算法
冰暮流星2 小时前
javascript之字符串索引数组
开发语言·前端·javascript·算法
Hag_202 小时前
LeetCode Hot100 3.无重复字符的最长子串
算法·leetcode·职场和发展
好学且牛逼的马2 小时前
【Hot100|23-LeetCode 234. 回文链表 - 完整解法详解】
算法·leetcode·链表
小冻梨6662 小时前
ABC444 C - Atcoder Riko题解
c++·算法·双指针