汉诺塔问题
本文最后更新于:2022年7月31日 下午
前言
最近两个月一直在看 SICP 的 Youtube 课程,作为一个半路出家的人,自觉基础欠缺,至今已是工作的第四个年头,正如年初计划所言,仅仅依赖于热情去折腾各种简单的玩具已经不再能有效地提升自己了,需要系统化的学习和组织知识。虽然之前也买过实体书,但由于看起来比较难而且也没有风趣的讲解(看的有点枯燥,看完视频后感觉里面的教授真的太有趣了!),所以一直没能看下去(另外一本《算法》也是同样的命运),但幸运的是,Youtube 上有现有的课程,而且还有人做了翻译的字幕,这对吾辈而言真的是方便太多了。汉诺塔是在 SICP 第二课计算过程讲的一个小游戏的例子,由于它真的很有趣,所以吾辈想要写一篇关于它的文章。
那么,为了避免有人还不知道汉诺塔是什么,这里引用一段 wiki 的简介
汉诺塔(港台:河内塔)(Tower of Hanoi)是根据一个传说形成的数学问题:
有三根杆子 A,B,C。A 杆上有 N 个 (N>1) 穿孔圆盘,盘的尺寸由下到上依次变小。要求按下列规则将所有圆盘移至 C 杆:
每次只能移动一个圆盘;
大盘不能叠在小盘上面。
提示:可将圆盘临时置于 B 杆,也可将从 A 杆移出的圆盘重新移回 A 杆,但都必须遵循上述两条规则。问:如何移?最少要移动多少次?
简单情况
这里是递归应用最强大的实证
首先来看最简单的情况,将一个初始状态的汉诺塔的所有圆盘移动到另一个柱子上。
课程中给出的解法很有趣
- 首先,我们如果需要移动 a(n) 柱子上的圆盘到 b
- 如果可以直接移动,则直接移动
- 否则,我们可以将之分解为以下 3 步
- 移动 a(n-1) 到柱子 c(临时柱)
- 然后移动 a(n) 到 b
- 最后将 a(n-1) 从移动到 b
然后呢?然后就没有然后了!这个例子真的非常强大,我们使用 4 个圆盘的柱子来演示这一点
下面这个表格是代表操作与状态的变化,如果无法操作,我们将按照上面的规则转换为 3 个子操作,直到可以直接移动为止
操作 | a | b | c |
---|---|---|---|
init |
[1,2,3,4] | [] | [] |
a4=>b |
|||
--a3=>c |
|||
----a2=>b |
|||
------a1=>c |
[2,3,4] | [] | [1] |
------a2=>b |
[3,4] | [2] | [1] |
------c1=>b |
[3,4] | [1,2] | |
----a3=>c |
[4] | [1,2] | [3] |
----b2=>c |
|||
------b1=>a |
[1,4] | [2] | [3] |
------b2=>c |
[1,4] | [] | [2,3] |
------b1=>c |
[4] | [] | [1,2,3] |
--a4=>b |
[] | [4] | [1,2,3] |
--c3=>b |
|||
----c2=>a |
|||
------c1=>b |
[] | [1,4] | [2,3] |
------c2=>a |
[2] | [1,4] | [3] |
------b1=>a |
[1,2] | [4] | [3] |
----c3=>b |
[1,2] | [3,4] | [] |
----a2=>b |
|||
------a1=>c |
[2] | [3,4] | [1] |
------a2=>b |
[] | [2,3,4] | [1] |
------c1=>b |
[] | [1,2,3,4] | [] |
可以看到,最终我们成功将圆盘从 a 移动到了 b,这里的关键点是将一个复杂操作分为几步,直到能够执行某些基本操作为止。下面是吾辈使用 ts 实现的最初版本
1 |
|
下面则使用更加函数式的风格来完成
改进:实际上汉诺塔是先移动上层的所有圆盘到临时柱,然后移动最下层的圆盘到目标柱,最终将临时圆盘上的所有圆盘移动到目标柱。
1 |
|
计算步数
计算步数更加简单,甚至可以写成一个数学函数
$$
f(1)=1 \
f(n)=2f(n-1)+1
$$
如果画成图,就是求一棵树的所有叶子节点
下面使用 ts 非常简单的实现了
1 |
|
如果使用缓存,则效率可以高得多
1 |
|
使用迭代的方式求解
看起来似乎无法使用迭代的方式去解决这个问题,核心是不知道前面还有多少步,或者说没有办法保存当前的移动信息
或许有办法,使用一个对象,通过三个字段的数组保存当前汉诺塔的状态,只要递归函数能智能移动一步,就可以以尾递归(迭代)的方式来解决这个问题,就像人的做法一样,能根据上下文做出合理的移动。
创建一个 HanoiGame 类型
1 |
|
初始想法
下面是想到的一种迭代式的非最佳走法
基本思路如下
- 从所有操作中过滤出可用的操作,过滤条件为
- 可移动
- 上一次移动的不是这个圆盘
- 从所有可选操作中选择移动来源与目标差距最小的步骤
- 移动它
- 记录当前步骤
- 传递当前状态进行下一次移动
1 |
|
这个实现有 bug,当 n = 10 时,会无限递归调用导致堆栈溢出
一些观察到的规律
- 如果按照最佳走法,那么第一步和汉诺塔的层级决定了最终的 target。具体规则是
n % 2 = 0, target != first step to; n % 2 =1, target = first step to
使用迭代的方式计算步数
换个思路求解步数也可以简化为尾递归的形式
1 |
|
使用迭代的方式计算移动步骤
下面是一个迭代实现,基本思路
- 基本操作,不需要临时柱的操作
- 复合操作,需要临时柱的操作,一般而言是需要移动两个以上的圆盘的操作
- 使用链表保存所有的操作,每次递归调用时,都替换其中一个复合操作为 3 个基本操作,并且修改当前操作为要替换的 3 个操作的第一个,直到遇到基本操作
- 如果还有下一个操作,则继续处理,否则终止迭代
1 |
|
最终,吾辈也是用迭代的方式计算得到了结果(虽然这种方式看起来非常不优雅)
处理任意状态的汉诺塔
以上都在处理从零开始移动汉诺塔,但实际上我们也可以处理任意状态的汉诺塔,以下是吾辈想到的一些处理任意汉诺塔的思路
- 优先考虑如何将最大的圆盘移动到合适的位置,必须要考虑无法移动的情况(在无法移动的情况下应该尝试合并两外两个柱子)
- 优先移动小的圆盘,合并为连续的便于视为一个复合操作,最终一定能合并到一个柱子上,但不是最优步骤
- 穷举所有可能的步骤,得到最佳的走法(或许可以改进为动态规划)– 性能存在问题,无法排除掉大量无效解
- 还有一种有趣的方法是算出一个汉诺塔从 0 开始移动到另一个柱子上的所有状态,然后匹配特定状态,进而得到后续所有的步骤 – 复杂度应该是
3*2^n-1
,这种方式需要证明一点就是某个状态一定被包含在从初始到结束的所有状态中
本质上汉诺塔的最佳走法可以转换为一棵树,然后按照深度优先的方式遍历获取所有的叶子节点即可。或者以任意方式获取所有的节点及其路径(包含所有 order 的数组),然后排除所有的非叶子节点,最后以 path 排序即可(path 的排序指的是从顶级依次往下比较,有点类似于字符串的左侧优先快速比较)
有什么简单的迭代方式去处理树结构么?
2. 优先移动小的圆盘,合并为连续的便于视为一个复合操作,最终一定能合并到一个柱子上,但不是最优步骤
下面是使用第二种方式实现处理任意状态的汉诺塔
这种方式最大的优点是比较简单,因为它可以像处理一个初始的汉诺塔一样处理任意状态的汉诺塔,只需要将一些有序的圆盘移动到另一个柱子上,而不需要考虑另一个柱子的状态。
1 |
|
以上方法都是指数增长 2^n-1
,或许有某种线性增长的方法?
1 |
|
3. 穷举所有可能的步骤,得到最佳的走法(或许可以改进为动态规划)– 性能存在问题,无法排除掉大量无效解
使用穷举法实现
1 |
|
1. 优先考虑如何将最大的圆盘移动到合适的位置,必须要考虑无法移动的情况(在无法移动的情况下应该尝试合并两外两个柱子)
使用第一种方法实现,但最终结果和穷举法相同,似乎这就是最优解
1 |
|
使用基本的单元测试验证它是有效的
1 |
|
大多数情况下,它计算得到的步数要比使用方法 2 得到的步骤少得多,我们可以验证这一点
upset
是一个将汉诺塔状态随机打乱的函数
1 |
|
下面是测试用例的结果的可视化图表
4. 算出一个汉诺塔从 0 开始移动到另一个柱子上最佳步骤的所有状态,然后匹配特定状态,进而得到后续所有的步骤
实际上不可能,事实上可以找到反例,例如以下状态就不会在最佳走法的任意一步状态中
1 |
|
验证它也很简单
1 |
|
结语
吾辈最终也实现了一个汉诺塔的在线小游戏,参考:https://rxliuli.github.io/hanoi/
后续还会写几篇吾辈觉得有意思的技术点,包括 cons 如何无中生有的构造数据结构、流与延迟计算、lisp 的解释器和运行时等。