
Hello 算法零钱兑换 II 的动态规划与空间优化——Python Tutor 可视化版本源码解读【免费下载链接】hello-algo《Hello 算法》动画图解、一键运行的数据结构与算法教程。支持简中、繁中、English、日本語提供 Python, Java, C, C, C#, JS, Go, Swift, Rust, Ruby, Kotlin, TS, Dart 等代码实现项目地址: https://gitcode.com/GitHub_Trending/he/hello-algo本文以 hello-algo 仓库中 codes/pythontutor/chapter_dynamic_programming/coin_change_ii.md 为主体完整还原该文件内嵌的两段 Python 实现——二维动态规划coin_change_ii_dp与空间优化后的coin_change_ii_dp_comp逐行讲清“零钱兑换 II”统计凑出目标金额的硬币组合数量的状态定义、初始化、状态转移与降维技巧并对照 Python 源码 与 动态规划章节文档 给出复杂度分析与多语言实现索引读完即可独立复现该算法并理解两种实现版本的全部差异。文件定位pythontutor 目录是算法代码的可视化伴生文件codes/pythontutor/目录按章节组织chapter_array_and_linkedlist、chapter_backtracking、chapter_computational_complexity、chapter_divide_and_conquer、chapter_dynamic_programming、chapter_graph、chapter_greedy、chapter_hashing、chapter_heap、chapter_searching、chapter_sorting、chapter_stack_and_queue、chapter_tree每个.md文件是仓库 Python 实现的“可视化伴生文件”。以本文的coin_change_ii.md为例其结构非常规整文件头注释标注文件名coin_change_ii.md、创建时间2024-01-05、作者 krahets两条 HTML 注释每条注释先以[file]{coin_change_ii}-[class]{}-[func]{coin_change_ii_dp}或coin_change_ii_dp_comp标识对应的源文件与函数随后跟随一条 Python Tutor 的render.html可视化链接。链接的code参数经 URL 编码解码后就是完整的可运行 Python 代码含函数与驱动代码可在 Python Tutor 在线环境中逐步查看变量与dp表的内存变化。可以确认文件中两段编码链接解码后的代码与 codes/python/chapter_dynamic_programming/coin_change_ii.py 逐行一致后者额外把两个函数合并在同一文件中。因此本文件承担的角色是把动态规划算法的每一次dp表更新过程“可视化”供读者观察状态转移的执行轨迹。问题定义从“最少硬币数”到“组合数量”“零钱兑换 II”在 动态规划章节文档 中的表述是给定 $n$ 种硬币第 $i$ 种硬币面值为 $coins[i-1]$目标金额为 $amt$每种硬币可重复选取求凑出目标金额的硬币组合数量。它与“零钱兑换 I”求最少硬币数同属完全背包类问题区别仅在于子问题从“最少数量”变为“方案计数”因此状态转移从取最小值变为求和。文档给出的状态转移方程为$$dp[i, a] dp[i-1, a] dp[i, a - coins[i-1]]$$即“前 $i$ 种硬币凑出金额 $a$ 的组合数 不选硬币 $i$ 的组合数 选硬币 $i$ 的组合数”。下文两段代码正是该方程的两种存储形式。版本一二维动态规划 coin_change_ii_dp这是 pythontutor 文件第一条链接内嵌的代码对应 coin_change_ii.py 第 8–25 行完整继承如下def coin_change_ii_dp(coins: list[int], amt: int) - int: 零钱兑换 II动态规划 n len(coins) # 初始化 dp 表 dp [[0] * (amt 1) for _ in range(n 1)] # 初始化首列 for i in range(n 1): dp[i][0] 1 # 状态转移 for i in range(1, n 1): for a in range(1, amt 1): if coins[i - 1] a: # 若超过目标金额则不选硬币 i dp[i][a] dp[i - 1][a] else: # 不选和选硬币 i 这两种方案之和 dp[i][a] dp[i - 1][a] dp[i][a - coins[i - 1]] return dp[n][amt]逐点解析dp 表规模(n 1) × (amt 1)的二维矩阵。dp[i][a]定义为“仅使用前 $i$ 种硬币凑出金额 $a$ 的组合数量”下标i从 1 计到 $n$故第 0 行表示“无硬币可用”天然为 0无需单独初始化。首列初始化dp[i][0] 1金额为 0 时无须选择任何硬币即已凑成对任意 $i$ 都恰好有 1 种方案“空选择”。这是计数类 DP 与求最值类 DP 的关键差异——coin_change 的 dp 表 首列是 00 元不需要硬币而本题首列是 1。转移的分支结构当coins[i-1] a当前硬币面额大于目标金额时选它必然超支只能继承上一行dp[i-1][a]即“不选硬币 i”否则执行完整转移dp[i-1][a] dp[i][a - coins[i-1]]。注意第二项使用的是当前行dp[i][...]而非dp[i-1][...]这正是“可重复选取”的体现选过一次硬币 $i$ 后剩余金额仍允许继续选硬币 $i$。这与 完全背包问题 的转移形式一致。返回值dp[n][amt]即使用全部 $n$ 种硬币凑出 $amt$ 的方案总数。版本二空间优化 coin_change_ii_dp_comppythontutor 文件第二条链接内嵌的是降维版本对应 coin_change_ii.py 第 28–44 行def coin_change_ii_dp_comp(coins: list[int], amt: int) - int: 零钱兑换 II空间优化后的动态规划 n len(coins) # 初始化 dp 表 dp [0] * (amt 1) dp[0] 1 # 状态转移 for i in range(1, n 1): # 正序遍历 for a in range(1, amt 1): if coins[i - 1] a: # 若超过目标金额则不选硬币 i dp[a] dp[a] else: # 不选和选硬币 i 这两种方案之和 dp[a] dp[a] dp[a - coins[i - 1]] return dp[amt]降维的原理与细节删除硬币维度观察二维转移dp[i][a]只依赖dp[i-1][a]上一行与dp[i][a - coins[i-1]]当前行更早的列。当外层按硬币种类 $i$ 推进、内层对金额a正序遍历时dp[a - coins[i-1]]恰好是本轮已经用上了硬币 $i$ 的新值dp[a]自身则是“尚未更新”的旧值语义上等价于dp[i-1][a]。于是二维表可压缩为一维dp[a]空间从 $O(n \cdot amt)$ 降到 $O(amt)$。正序遍历不可颠倒这里内层必须是正序。正序保证dp[a - coins[i-1]]包含当前硬币的贡献等价于“同一种硬币可以重复选”与二维版语义完全一致。若改成逆序dp[a - coins[i-1]]将保持旧值语义就变成“每种硬币最多选一次”0-1 背包形式计数结果会错误地减少。这一点在 完全背包问题的空间优化 一节中被明确为与本题相同的处理方式。dp[a] dp[a]这一分支当coins[i-1] a时二维版写dp[i][a] dp[i-1][a]继承旧值降维后旧值仍在dp[a]原位因此退化为一次自赋值。从源码结构看作者特意保留该分支而非直接pass是为了让一维代码与二维代码的分支结构逐行对应方便读者对照理解——这一结构在 Go 版本、Java 版本 等其他语言实现中同样存在。驱动代码与结果验证两段内嵌代码都附带相同的 Driver Code对应 coin_change_ii.py 第 47–58 行Driver Code if __name__ __main__: coins [1, 2, 5] amt 5 # 动态规划 res coin_change_ii_dp(coins, amt) print(f凑出目标金额的硬币组合数量为 {res}) # 空间优化后的动态规划 res coin_change_ii_dp_comp(coins, amt) print(f凑出目标金额的硬币组合数量为 {res})以coins [1, 2, 5]、amt 5手工验证凑法恰有 4 种不计顺序的组合52 2 12 1 1 11 1 1 1 1因此两个函数都应输出“凑出目标金额的硬币组合数量为 4”可直接运行 codes/python/chapter_dynamic_programming/coin_change_ii.py 验证仅需 Python 3.9使用了list[int]内置泛型语法无第三方依赖。复杂度分析两个版本的外层循环 $n$ 次、内层循环 $amt$ 次每次转移为 $O(1)$时间复杂度均为 $O(n \cdot amt)$coin_change_ii_dp的空间复杂度为 $O((n1) \cdot (amt1))$coin_change_ii_dp_comp压缩为 $O(amt)$适用前提硬币面值为正整数、目标金额 $amt$ 非负。当 $amt 0$ 时二维版首列初始化使dp[n][0] 1一维版dp[0] 1均正确返回 1 种“空组合”。仓库中的配套资源文档讲解docs/chapter_dynamic_programming/unbounded_knapsack_problem.md 的“零钱兑换问题 II”小节约第 175–207 行给出了本文完整推导——子问题定义、转移方程、首列初始化为 1 的原因并引用了[file]{coin_change_ii}-[func]{coin_change_ii_dp}与[func]{coin_change_ii_dp_comp}两段代码与 pythontutor 文件中的[file]{}-[func]{}标识一一对应多语言实现同一题面在仓库内提供了 14 种语言版本便于横向对照状态转移的写法差异C、C、C#、Dart、Go、Java、JavaScript、Kotlin、Python、Ruby、Rust、Swift、TypeScript、Zig同目录姊妹篇coin_change.md 对应“零钱兑换 I”求最少硬币数其二维版首行用MAX amt 1表示“不可达”、转移取min与本文计数版的求和转移形成鲜明对照建议对照阅读。小结codes/pythontutor/chapter_dynamic_programming/coin_change_ii.md以“函数标识 编码链接”的形式把零钱兑换 II 的两种动态规划实现送进了 Python Tutor 的逐步可视化环境二维版coin_change_ii_dp完整保留了(n1) × (amt1)状态表与首列置 1 的初始化一维版coin_change_ii_dp_comp则通过“外层遍历硬币、内层金额正序”的循环顺序在不改变计数语义的前提下把空间降到 $O(amt)$。配合 Python 源码 与 章节文档 的推导即可完整掌握“组合计数型完全背包”的状态定义、转移方程与空间优化三要素。【免费下载链接】hello-algo《Hello 算法》动画图解、一键运行的数据结构与算法教程。支持简中、繁中、English、日本語提供 Python, Java, C, C, C#, JS, Go, Swift, Rust, Ruby, Kotlin, TS, Dart 等代码实现项目地址: https://gitcode.com/GitHub_Trending/he/hello-algo创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考