《Hello 算法》分治专题:汉诺塔问题如何用"分而治之"优雅求解
本篇以《Hello 算法》分治与递归章节的汉诺塔问题为核心,从问题建模出发,演示如何把规模为 的汉诺塔问题递归分解为 与 两档子问题,并结合仓库中 16 种语言的源码实现分析其"分解—求解—合并"的完整流程。读完你将掌握汉诺塔问题的分治推演方法、dfs(i, src, buf, tar) 递归函数的设计思路,以及 时间、 空间的复杂度来源。
汉诺塔问题:一个与归并排序不同的分解策略
在前面的章节中,无论是归并排序还是构建二叉树,我们习惯性地把原问题平均分成两个规模为原问题一半的子问题。然而汉诺塔(Tower of Hanoi)问题采用的是一种迥然不同的分解策略,它为理解分治法的"非对称拆分"提供了绝佳案例。
问题定义与三条规则
问题:给定三根柱子
A、B、C,初始状态下柱A上自下而上叠放着 个圆盘(大的在下、小的在上)。任务是把 个圆盘整体移动到柱C,并保持原有顺序不变。移动时必须遵守:
- 圆盘只能从一根柱子的顶部取出,放入另一根柱子的顶部;
- 每次只能移动一个圆盘;
- 小圆盘必须始终位于大圆盘之上。
为了后续表述简洁,约定把规模为 的汉诺塔问题记作 。例如 表示"把 个圆盘从 A 移动到 C"。可以看到,虽然问题规则看似简单,但圆盘数量的增长会令状态空间爆炸式膨胀,这正是它值得用分治思想求解的原因。
从最简单的情形入手: 与
:无需借用任何柱子
当只有 个圆盘时,它既是最大的也是最小的圆盘,直接从 A 顶部移动到 C 顶部即可完成,一次移动就得到最优解。 由此成为递归的"基本情形(base case)",它是整个分治过程的天然出口。
:首次引入"缓冲柱"
当有 个圆盘时,如果直接把上面的小盘放到 C,随后的大盘会违反"小盘必须在大盘之上"的约束。因此必须借助柱子 B 完成中转,标准三步走为:
- 先将上面的小圆盘从
A移动到B; - 再将下面的大圆盘从
A直接移动到C; - 最后将小圆盘从
B移动到C。
这个求解过程可概括为:把两个圆盘借助 B 从 A 移至 C。其中 C 称为目标柱,B 称为缓冲柱。至此我们掌握了一个关键的"操作原型"——用一根空闲柱子做缓冲,就能把一个圆盘整体搬到目标柱上。
由 到 :子问题分解初现
的情况略复杂,但由于 、 的解已知,可以把 A 顶部两个圆盘视为一个整体(这个整体恰好对应一个 子问题),从而把 化解为三步:
- 令
B为目标柱、C为缓冲柱,把 个圆盘从A移到B(即求解 ); - 把
A中剩余的最大圆盘直接移到C(即求解 ); - 令
C为目标柱、A为缓冲柱,把 个圆盘从B移到C(再次求解 )。
从本质上说, 被划分为两个 子问题与一个 子问题。依次解决这三个子问题,原问题即告解决——这正说明汉诺塔的子问题彼此独立、解可以合并,完全满足分治法的适用条件。
通用分治策略:
将上述推演推广到一般情形,得到解决汉诺塔问题的通用分治策略:把原问题 划分为两个 和一个 ,并按下述顺序求解:
- 把 个圆盘借助
C从A移到B(子问题 ); - 把剩余 个圆盘从
A直接移到C(子问题 ); - 把 个圆盘借助
A从B移到C(子问题 )。
两个 子问题又可以用完全相同的方式递归拆分,直到抵达最小子问题 。换句话说,求解过程中真正会变化的是三根柱子的角色(源柱/缓冲柱/目标柱)不断轮换,而"把 个盘搬到缓冲柱、把最大的 1 个盘送到目标柱、再把 个盘搬回目标柱"这一模式自始至终不变。
代码实现:dfs(i, src, buf, tar) 与 move() 的分工
《Hello 算法》在代码中把上述策略实现为递归函数 dfs(i, src, buf, tar)——将柱 src 顶部的 个圆盘借助缓冲柱 buf 移到目标柱 tar。以 codes/python/chapter_divide_and_conquer/hanota.py 为例,完整实现如下:
def move(src: list[int], tar: list[int]):
"""移动一个圆盘"""
# 从 src 顶部拿出一个圆盘
pan = src.pop()
# 将圆盘放入 tar 顶部
tar.append(pan)
def dfs(i: int, src: list[int], buf: list[int], tar: list[int]):
"""求解汉诺塔问题 f(i)"""
# 若 src 只剩下一个圆盘,则直接将其移到 tar
if i == 1:
move(src, tar)
return
# 子问题 f(i-1) :将 src 顶部 i-1 个圆盘借助 tar 移到 buf
dfs(i - 1, src, tar, buf)
# 子问题 f(1) :将 src 剩余一个圆盘移到 tar
move(src, tar)
# 子问题 f(i-1) :将 buf 顶部 i-1 个圆盘借助 src 移到 tar
dfs(i - 1, buf, src, tar)
def solve_hanota(A: list[int], B: list[int], C: list[int]):
"""求解汉诺塔问题"""
n = len(A)
# 将 A 顶部 n 个圆盘借助 B 移到 C
dfs(n, A, B, C)
逐行拆解代码的巧妙之处
这段代码虽然短小,却浓缩了整个分治思想:
move(src, tar)是原子操作:只负责"从源柱顶部弹出、压入目标柱顶部"这一单盘移动,确保每条规则都成立。i == 1是递归出口:只剩一个圆盘时无需缓冲,直接move(src, tar),对应 。- 角色轮换靠交换实参实现:子问题 中,
tar变成了"缓冲柱"、buf变成"目标柱",因此调用写作dfs(i - 1, src, tar, buf);第三个子问题则写作dfs(i - 1, buf, src, tar)。递归到不同深度时,三根柱子的身份由调用参数动态决定,这正是分治"统一模式、不同参数"的体现。 - 入口函数
solve_hanota屏蔽细节:它从A的大小读出 ,调用一次dfs(n, A, B, C)即完成"借助B从A移到C"的整体目标。
用列表表示柱子:Python / Java / C++ 的通用手法
仓库中使用了一种巧妙的建模方式——用列表/向量表示柱子,列表尾部视为柱顶。盘面按 初始化的含义是:5 在最底部、1 在最顶部,与"尾部即顶部"的约定一致。这样 src.pop()(Python)、src.back(); src.pop_back()(C++)或 src.remove(src.size() - 1)(Java)就能天然模拟"从顶部取盘"。
Java 版见 codes/java/chapter_divide_and_conquer/hanota.java:
/* 移动一个圆盘 */
static void move(List<Integer> src, List<Integer> tar) {
Integer pan = src.remove(src.size() - 1); // 从 src 顶部拿出
tar.add(pan); // 放入 tar 顶部
}
/* 求解汉诺塔问题 f(i) */
static void dfs(int i, List<Integer> src, List<Integer> buf, List<Integer> tar) {
if (i == 1) { move(src, tar); return; }
dfs(i - 1, src, tar, buf); // 子问题 f(i-1):借助 tar 从 src 移到 buf
move(src, tar); // 子问题 f(1)
dfs(i - 1, buf, src, tar); // 子问题 f(i-1):借助 src 从 buf 移到 tar
}
C++ 版(codes/cpp/chapter_divide_and_conquer/hanota.cpp)与 Java 结构完全一致;而 C 语言因不支持动态容器,改用"数组 + 大小指针"的组合(srcSize、tarSize)维护栈顶位置,实现在 codes/c/chapter_divide_and_conquer/hanota.c。Go 版则借助标准库 container/list 双向链表模拟柱子(codes/go/chapter_divide_and_conquer/hanota.go)。仓库中共提供了 Python、Java、C++、C、C#、JS、TS、Go、Swift、Rust、Ruby、Kotlin、Dart、Zig 等语言的同构实现,核心递归逻辑均一致。
运行与验证
- Python 直接执行即可观察结果:初始化
A = [5,4,3,2,1],调用solve_hanota后打印三根柱子的最终状态,可见 、 变空、,顺序保持完整。 - Go 语言提供了标准测试入口 codes/go/chapter_divide_and_conquer/hanota_test.go:
TestHanota构造 5 个圆盘后调用solveHanota并打印验证结果,可作为自动化回归验证的参考模板。
复杂度分析: 时间与 空间
从递归结构上看,汉诺塔问题会形成一棵高度为 的递归树,每个节点对应一个被开启的 dfs() 调用(即一个子问题)。
- 时间复杂度 :每个 展开为两个 和一个 。设 为移动次数,有递推式 且 ,解得 。也就是说,n 个圆盘最少需要 次移动,这正对应递归树的 个节点,因此时间复杂度为指数级 。
- 空间复杂度 :递归深度等于递归树高度 ,沿途每一层调用栈上只需要常数空间保存参数,故空间复杂度为 。
由此可见,汉诺塔问题虽然结构清晰、代码优雅,但本质上是一个指数级代价的问题——这正是其传说能"耗光宇宙时间"的数学根源。
数学趣闻: 个圆盘与"世界末日"传说
汉诺塔问题源自古老的传说:古印度一座寺庙中,僧侣们拥有三根高大的钻石柱和 个大小不一的黄金圆盘,他们不停地搬运圆盘,并相信当最后一片圆盘被正确放好之时,世界便将终结。
由上面的递推式可知,搬运 个圆盘需要 次移动。即便僧侣们每秒钟移动一次,也需要约 亿年——这远超当前对宇宙年龄的估算(约 138 亿年)。因此,即使传说属实,我们也完全无需为世界末日担忧。
小结
汉诺塔问题向读者展示了分治法更一般的一面:
| 对比维度 | 归并排序 / 建树 | 汉诺塔 |
|---|---|---|
| 分解方式 | 均匀拆成两个 子问题 | 拆成两个 与一个 |
| 子问题独立性 | 独立 | 独立(可依次求解并合并) |
| 递归出口 | 子数组/区间长度 1 | |
| 复杂度 | 时间、 空间 |
其核心启示是:分治法并不要求"均分",关键在于把大问题转化为结构相同、可独立求解、解可合并的小问题。读者可对照分治法总论中的判断准则,或查阅本章习题进行巩固,并亲自运行 codes/python/chapter_divide_and_conquer/hanota.py 等代码观察每一步移动,直观体会递归的"角色轮换"之美。
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 StartedRust0627
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


