分而治之以及递归是算法设计里非常强大的技术
。但是,并不是所有问题都有很高效的解决方案。一个可以被递归非常优雅地解决的应用问题是,通常被称为汉诺塔的数学难题
。这个难题通常被认为是法国数学家爱德华·卢卡斯(ÉdouardLucas)发现的,他在1883年发表了一篇关于这个问题的文章。这个问题是从下面这个传说中产生的。
传说,在世界偏远地区的某个地方,有一个非常虔诚且遵守宗教秩序的寺院。在这个寺院里,僧侣们被赋予了一个神圣的任务:为宇宙计时。一开始的时候,僧侣有一个上面有3根垂直银棒的桌子。其中一根银棒上串着64个同心的金盘。每个金盘都具有不同的半径,并且它们以漂亮的金字塔形状堆叠在一起。僧侣们被告知,需要把金盘从第一根银棒移动到第三根银棒。当僧侣完成这个任务的时候,所有的世间万物都会分崩离析,世界就会灭亡。
当然,如果问题就是这样的话,那么宇宙很久以前就已经结束了。为了维持神圣的秩序,僧侣必须遵守这样一些规则。
1.一次只能移动一个金盘。
2.金盘不能被“放在一边”。它只能堆叠在3根银棒中的一根上。
3.大金盘永远不能放在小金盘之上。
基于这个难题的各个版本一度非常流行,在今天,你仍然可以在玩具或者拼图商店里找到这个主题的变体游戏。图6.4所示为一个包含8个金盘的小版本。游戏的目标是使用中心银棒作为临时存放点,将整个塔从第一根银棒移动到第三根银棒上。当然,你必须遵循上面给出的3个神圣规则。
我们想为这个难题开发一个算法来解决它。你可以把我们的算法视为僧侣需要执行的一组操作,或者是用来生成一组指令的程序。例如,假设我们标记3根银棒为A、B和C,那么这些指令可能会像这样来开始:
对于大多数人来说,这都是一个非常难以解决的难题。当然,因为大多数人都没有接受过算法设计方面的培训,所以这并不奇怪。但是,一旦你了解了递归,找到解决方案的过程实际上非常简单。
让我们首先考虑一些非常简单的案例。
假设我们有一个只有一个金盘的难题版本。移动只有一个金盘所构成的塔就很简单了,我们只需要从银棒A中移除它,然后把它放在银棒C上,问题就解决了。
很好,那么接下来,如果有两个金盘该怎么办?在这个情况下,我们需要将两个金盘中较大的那个金盘先放到银棒C上,较小的那一个在之后,放在它的上面。解决方案是,先把小的那个金盘移开就行了,我们可以通过把它移动到银棒B上来实现。现在银棒A上的大金盘就没有阻挡了,我们可以把它移动到银棒C上,然后再把小金盘从银棒B移到银棒C就行了。
接下来,让我们考虑一下有3个金盘的塔的情况。为了将最大的金盘移动到银棒C上,我们首先必须将两个较小的金盘移开。而这两个较小的金盘就形成了一个2号塔。使用上面描述的过程,我们可以将这个有两个金盘的塔移动到银棒B上。这样,我们就能够移动最大的金盘了,可以把最大的金盘移动到银棒C上。然后,我们只需要再把两个金盘的塔从银棒B移到银棒C上就行了。解决3个金盘的情况可以被总结为以下3个步骤。
1.将包含两个金盘的塔从A移到B。
2.将一个金盘从A移动到C。
3.将包含两个金盘的塔从B移到C。
第1步和第3步涉及应该如何移动包含两个金盘的塔。好在我们已经找到了应该如何做到这一点。就像在解决两个金盘的难题的时候一样,我们使用银棒C作为临时安置的位置将塔从银棒A移动到银棒B,然后使用银棒A作为临时位置将塔从银棒B到银棒C移动。
于是,我们刚刚开发出了一个简单的递归算法的概述,这个算法是一个可以被用来将任意大小的塔从一个柱子移动到另一个柱子的通用过程:
所以,这个递归过程的基本情况
是什么?可以看到,移动n个金盘会导致两次n − 1个金盘的递归移动。由于我们每次都将n减1,因此塔的最终尺寸就是1。只需要移动一个金盘就可直接移动包含一个元素的塔。这种情况下,我们不需要任何的递归调用来移走它上面的金盘。
修复我们的通用算法,让它包含一个基本情况
。于是,我们就可以得到一个可以工作的moveTower算法。那么,我们使用Python来编写它。我们的moveTower函数将需要一个用来表示塔的大小的参数n、初始的银棒source、需要用到的银棒dest以及临时存放的银棒temp。我们可以使用数字(int)来代表n,使用字符串A、B和C来表示银棒。这就是moveTower的代码:
def moveTower(n,source,dest,temp):
if n==1:
print('move dis from',source,'to',dest +'.')
else:
moveTower(n-1,source,temp,dest)
moveTower(1,source,dest,temp)
moveTower(n-1,temp,dest,source)
看看这段代码有多简单!有时候,使用递归可以把一些无比困难的问题变得轻而易举。为了让程序开始,我们只需要为这4个参数提供一个合适的值。让我们编写一个能够将大小为n的塔从银棒A移动到银棒C的所有指令都输出的小方法:
def hanoi(n):
moveTower(n,'A','B','C')
hanoi(4)
move dis from A to C.
move dis from A to B.
move dis from C to B.
move dis from A to C.
move dis from B to A.
move dis from B to C.
move dis from A to C.
move dis from A to B.
move dis from C to B.
move dis from C to A.
move dis from B to A.
move dis from C to B.
move dis from A to C.
move dis from A to B.
move dis from C to B.
所以,我们对汉诺塔的解决方案是一个“举重若轻”的算法,它只需要9行代码。那么,为什么我们要在标题里把这个问题定义为“一个难题”呢?要回答这个问题,我们必须要先去看看这个解决方案的效率
。对于我们的算法来说,问题的难度取决于塔里的金盘数量。因此,我们首先需要回答的问题是:移动一个大小为n的塔需要多少步骤?
只要看一下我们算法的结构,你就可以看出移动一个大小为n的塔需要我们移动一个大小为n − 1的塔两次
:第一次将它从最大的金盘上移开,第二次把它放回到最大的金盘上面。如果我们再添加另外一个金盘到塔里的话,我们解决它所需要的步骤数基本上将增加一倍。如果你尝试过了不断增加难题大小的程序的话,这种关系就会更加清晰,如表6.1所示。
所以,一般来讲,解决大小为n的难题需要2^n − 1
步。这很明显是一个Θ(2^n)算法,这就意味着它需要指数时间
(exponential time)来完成任务。这是因为问题大小的度量n出现在了这个公式的指数里。指数算法增长得非常迅速,实际生活中,即使在最快的计算机上,也只有相对较小的问题尺寸才能够被解决
。为了说明这一点,如果有一个包含64个金盘的塔,在不犯错的情况下,每一天的24小时里的每一秒钟都去移动一个金盘的话,仍然需要超过5800亿年来完成这个任务。
尽管汉诺塔的算法很容易被写出来,但它属于一类被称为难解型(intractable)的问题。对于这类问题,除了最简单的情况,通常在实践中会需要过多的计算能力(不论是时间还是内存)。从这个意义上来说,我们的玩具店确实给了我们一个难题。
总结
- 汉诺塔问题 可以用递归解决
- 时间复杂度为指数级,所以n太大了计算机也无能为力