Hello 算法:零钱兑换 II 的动态规划与空间优化——Python Tutor 可视化版本源码解读
本文以 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”在 动态规划章节文档 中的表述是:给定 种硬币,第 种硬币面值为 ,目标金额为 ,每种硬币可重复选取,求凑出目标金额的硬币组合数量。它与“零钱兑换 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从 1 计到 ,故第 0 行表示“无硬币可用”,天然为 0,无需单独初始化。 - 首列初始化
dp[i][0] = 1:金额为 0 时无须选择任何硬币即已凑成,对任意 都恰好有 1 种方案(“空选择”)。这是计数类 DP 与求最值类 DP 的关键差异——coin_change 的 dp 表 首列是 0(0 元不需要硬币),而本题首列是 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][...],这正是“可重复选取”的体现:选过一次硬币 后,剩余金额仍允许继续选硬币 。这与 完全背包问题 的转移形式一致。
- 当
- 返回值:
dp[n][amt],即使用全部 种硬币凑出 的方案总数。
版本二:空间优化 coin_change_ii_dp_comp
pythontutor 文件第二条链接内嵌的是降维版本(对应 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]](当前行更早的列)。当外层按硬币种类 推进、内层对金额a正序遍历时,dp[a - coins[i-1]]恰好是本轮已经用上了硬币 的新值,dp[a]自身则是“尚未更新”的旧值(语义上等价于dp[i-1][a])。于是二维表可压缩为一维dp[a],空间从 降到 。 - 正序遍历不可颠倒:这里内层必须是正序。正序保证
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] 内置泛型语法,无第三方依赖)。
复杂度分析
- 两个版本的外层循环 次、内层循环 次,每次转移为 ,时间复杂度均为 ;
coin_change_ii_dp的空间复杂度为 ,coin_change_ii_dp_comp压缩为 ;- 适用前提:硬币面值为正整数、目标金额 非负。当 时,二维版首列初始化使
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 完整保留了 (n+1) × (amt+1) 状态表与首列置 1 的初始化,一维版 coin_change_ii_dp_comp 则通过“外层遍历硬币、内层金额正序”的循环顺序,在不改变计数语义的前提下把空间降到 。配合 Python 源码 与 章节文档 的推导,即可完整掌握“组合计数型完全背包”的状态定义、转移方程与空间优化三要素。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
