1.5 汉诺塔
本题共涉及3根立柱(以下称为“塔”),不妨将其标为A、B和C。塔A外面套有几个环形的圆盘。最大的圆盘位于底部,不妨将其称为圆盘1。圆盘1上方的其他圆盘标记为不断增大的数字,圆盘尺寸则不断减小。假定要移动3个圆盘,最大也是底部的圆盘就是圆盘1。第二大的圆盘2将放在圆盘1的上方。最小的圆盘3则放在圆盘2的上方。本题的目标是按以下规则把所有圆盘从塔A移动到塔C:
● 每次只能移动一个圆盘;
● 只有塔顶的圆盘才能被移动;
● 绝不能把大圆盘放在小圆盘的上面。
图1-7对本题给出了总体说明。
图1-7 本题的挑战是把3个圆盘从塔A移到塔C,每次移动一个圆盘,不允许把大圆盘压在小圆盘之上
1.5.1 对塔进行建模
栈是按照后进先出(LIFO)理念建模的数据结构。最后入栈的数据项会最先出栈。栈的两个最基本操作是压入(push)和弹出(pop)。压入操作是把一个新数据项放入栈中,而弹出操作则是移除并返回最后一次放入的数据项。在Python中用list类型作为底层存储,即可轻松对栈进行建模。具体代码如代码清单1-20所示。
代码清单1-20 hanoi.py
from typing import TypeVar, Generic, List T = TypeVar('T') class Stack(Generic[T]): def __init__(self) -> None: self._container: List[T] = [] def push(self, item: T) -> None: self._container.append(item) def pop(self) -> T: return self._container.pop() def __repr__(self) -> str: return repr(self._container)
注意 上述Stack类实现了__repr__()方法,这样想要查看某个塔的状况就比较容易了。对Stack类调用print()时,输出的就是__repr__()的结果。
注意 正如本书引言中所述,本书通篇都会使用类型提示。从typing模块导入Generic,就能让Stack在类型提示时泛型化为某种类型。T = TypeVar('T')定义了任意类型T。T可以是任何类型。后续在求解汉诺塔问题时使用的Stack,就用到了类型提示,类型提示为Stack[int]类型,表示T应该填入int类型的数据。换句话说,该栈是一个整数栈。如果对类型提示还存在困惑,不妨阅读一下附录C。
栈是汉诺塔的完美表现。要把圆盘放到塔上,可以进行压入操作。要把圆盘从一个塔移到另一个塔,就可以先从第一个塔弹出再压入第二个塔上。
下面将塔定义为Stack,并把圆盘码放在第一个塔上,具体代码如代码清单1-21所示。
代码清单1-21 hanoi.py(续)
num_discs: int = 3 tower_a: Stack[int] = Stack() tower_b: Stack[int] = Stack() tower_c: Stack[int] = Stack() for i in range(1, num_discs + 1): tower_a.push(i)
1.5.2 求解汉诺塔问题
汉诺塔问题该如何求解呢?不妨想象一下只需移动一个圆盘的情况。做法大家都该知道吧!实际上,移动一个圆盘正是汉诺塔递归解决方案的基线条件。需要递归完成的是移动多个圆盘的情况。因此,要点就是有两种情况需要编写代码:移动一个圆盘(基线条件)和移动多个圆盘(递归情况)。
为了理解需要递归完成的情况,不妨看一个具体的例子。假设塔A上套有上、中、下3个圆盘,这3个圆盘最终都要被移到塔C上去。遍历一遍全过程或许有助于把问题讲清楚。首先可以把顶部圆盘移到塔C。再将中间圆盘移到塔B。然后可以将顶部圆盘从塔C移到塔B。现在底部圆盘仍在塔A,上面的两个圆盘则在塔B。现在已大致成功将两个圆盘从一个塔(A)移到了另一个塔(B)。把底部圆盘从A移到C其实就是基线条件(移动单个圆盘)。现在可以按照从A到B的相同步骤把两个上面的圆盘从B移到C。将顶部圆盘移到A,将中间圆盘移到C,最后将顶部圆盘从A移到C。
提示 在讲述计算机科学的课堂上,常常可以见到用木柱和塑料圈制作的塔的小模型。大家可以用3支铅笔和3张纸制作自己的模型。这或许有助于将解决方案直观地呈现出来。
在上述3个圆盘的示例中,包含一种简单的移动单个圆盘的基线条件,以及一种移动其他所有圆盘(这里为两个)的递归情况,这里用到了第3个塔作为暂存塔。递归的情况可以被拆分为以下3步。
(1)将上层n−1个圆盘从塔A移到塔B(暂存塔),用塔C作为中转塔。
(2)将底层的圆盘从塔A移到塔C。
(3)将n−1个圆盘从塔B移到塔C,用塔A作为中转塔。
令人惊奇的是,这种递归算法不仅适用于3个圆盘的情况,还适用于任意数量的圆盘。下面将此算法编码成名为hanoi()的函数,该函数负责将圆盘从一个塔移到另一个塔,参数中给出第3个暂存塔。具体代码如代码清单1-22所示。
代码清单1-22 hanoi.py(续)
def hanoi(begin: Stack[int], end: Stack[int], temp: Stack[int], n: int) -> None: if n == 1: end.push(begin.pop()) else: hanoi(begin, temp, end, n - 1) hanoi(begin, end, temp, 1) hanoi(temp, end, begin, n - 1)
调用hanoi()完成后,应该检查一下塔A、B和C的内容,验证是否所有圆盘都已移动成功。具体代码如代码清单1-23所示。
代码清单1-23 hanoi.py(续)
if __name__ == "__main__": hanoi(tower_a, tower_c, tower_b, num_discs) print(tower_a) print(tower_b) print(tower_c)
我们会发现应该已经成功了。在为汉诺塔解法编写代码时,不一定非要对将多个圆盘从塔A移到塔C所需的每一步都能理解。但逐渐弄懂移动任意数量圆盘的通用递归算法并完成编码后,剩下的工作就交给计算机去完成吧。这就是构想递归解法的威力:往往可以用抽象方式思考解法,而不用枯燥地在脑子里把每一步都搞定。
顺便提一下,随着圆盘数量的增加,hanoi()函数的执行次数将会呈指数级增加,因此连64个圆盘的解法都会算不出来。可以修改一下num_disc变量,多试几个不同的圆盘数。随着圆盘数量的增加,所需移动步数将呈指数级增加,这正是汉诺塔的传奇之处。关于汉诺塔的传说的更详细信息在很多地方都能找到。读者若有兴趣了解有关此递归解法背后的数学原理,可参阅Carl Burch在“关于汉诺塔”(About the Towers of Hanoi)中的解释。