汉诺塔问题深度解析:递归算法的优雅实现与可视化演示

作者:高玉涵
时间:2025.08.19 11:10
博客:blog.csdn.net/cg_i
语言:Python

一、汉诺塔问题

汉诺塔(Tower of Hanoi,又称河内塔 1 ^1 1)问题由法国数学家爱德华·卢卡斯于 1883 年提出。他的灵感是一个与印度寺庙有关的传说,相传这座寺庙里的年轻修行者试图解决这个难题。起初,修行者有 3 根柱子和 64 个依次叠好的金盘子,下面的盘子大,上面的盘子小。修行者的任务是将 64 个叠好的盘子从一根柱子移动到另一根柱子,同时有两个重要的限制条件:每次只能移动一个盘子,并且大盘子不能放在小盘子之上。修行者夜以继日地移动盘子(每一秒移动一个盘子),试图完成任务。根据传说,如果他们完成这项任务,整座寺庙将倒塌,整个世界也将消失。

尽管这个传说非常有意思,但是并不需要担心世界会因此而毁灭。要正确移动 64 个盘子,所需的步数是 2 64 − 1 = 18446744073709551615 2^{64-1} = 18446744073709551615 2641=18446744073709551615。根据每秒移动一次的速度,整个过程大约需要 584942417355 年!显然,这个谜题并不像听上去那么简单。图 1-1 展示了木制的汉诺塔。

在这里插入图片描述

图 1-1 木制汉诺塔

1 ^1 1汉诺塔的名称 “Tower of Hanoi” 并非源于越南首都河内(Hanoi),而是出自法国数学家爱德华・卢卡斯(Édouard Lucas) 的创造。他给这个谜题命名为 “Tour d'Hanoï”(法语,直译为 “哈诺伊塔”)—— 这里的 “Hanoï” 是卢卡斯虚构的名称,并非指向现实中的越南河内,只是发音恰好与 “河内” 的法语拼写(Hanoï)一致。

不妨现在就访问 在线汉诺塔游戏 ,亲自尝试求解这个问题。为了解开谜题,玩家必须遵照以下 3 条规则,把这些圆盘全移动到某根柱子上面。

  • 每次只能移动一个圆盘。
  • 每次只能移动柱子顶部的圆盘。
  • 不能把大的圆盘放在小的上面。

二、递归算法思考

用来解决汉诺塔问题的这个递归算法思考起来可能有些复杂。先看最简单的情况,也就是只有一个圆盘的汉诺塔。在这种情况下,解决办法很简单,只需要把这个圆盘移动到另一个柱子上,整个问题就算解决了。两个圆盘的汉诺塔问题解决起来稍微有点复杂:先把小圆盘移动到某根柱子(我们把这根柱子叫作临时柱)上,然后把大圆盘移动到另一根柱子(我们把这根柱子叫作目标柱)上,最后,再把小圆盘从临时柱移动到目标柱上。于是,两个圆盘就按照正确的顺序出现在目标柱上面了。

等到解决3个圆盘的汉诺塔问题时,你就会发现,这个问题的解法有一个规律。为了把n个圆盘从起始柱移动到目标柱,我们必须分这样3步。

  1. 将顶部的 n−1 个圆盘从起始柱移动到临时柱上。
  2. 将第 n 个圆盘从起始柱移动到目标柱上。
  3. 将第 (1) 步中的那 n−1 个圆盘从临时柱移动到目标柱上。

递归式的汉诺塔算法要做两次递归调用。把解决 4 个圆盘的汉诺塔问题时执行的操作绘制成树状图,看起来会是图 2-1 这个样子。由该图可见,想解决 4 个圆盘的汉诺塔问题,首先要按照解决 3 个圆盘的汉诺塔问题时所用的那套步骤,把前 3 个圆盘移动到临时柱上,然后移动第 4 个圆盘,最后把解决 3 个圆盘的汉诺塔问题时所用的那套步骤重复一遍,以便将那 3 个圆盘从临时柱移到目标柱上。同理,为了解决 3 个圆盘的汉诺塔问题,首先要按照解决两个圆盘的汉诺塔问题时所用的那套步骤,把前两个圆盘移动到临时柱上,然后移动第 3个圆盘,最后把解决两个圆盘的汉诺塔问题时所用的那些步骤重复一遍。以此类推,直至遇到需要解决一个圆盘的汉诺塔问题,在这种情况下,只需要直接移动这个圆盘。

从图 2-1 所示的树状结构中可以看出,汉诺塔这样的问题应该比较适合用递归来解决。在这个树状结构中,程序会从顶部的根节点开始,按照从左至右的顺序,处理该节点下面的 3 个分叉点,而对于每一个分叉点来说,程序又会按照从左至右的顺序,依次处理该节点下面的3 个分叉点,某节点的 3 个分支必须全处理完,该节点才算处理完毕。

手动解决 3 个或 4 个圆盘的汉诺塔问题还比较容易,但如果圆盘数变多,那么需要移动的总次数就会呈指数级增长。n 个盘子的汉诺塔问题至少需要移动 2 n − 1 2^n-1 2n1 次。这意味着,解决 31 个圆盘的汉诺塔问题,需要移动的次数超过 10 亿。

在这里插入图片描述

图 2-1 解决 4 个圆盘的汉诺塔问题时所要执行的一系列移动操作

三、Python 语言解决汉诺塔问题

我们回答编写递归算法时需要考虑的 3 个问题。

  • 什么是基本情况?这指的是整个汉诺塔只有1个圆盘的情况。
  • 在递归函数调用中应该传入什么样的参数?传入这样一组参数,让它们表示的那个汉诺塔问题中的圆盘总数比原来少1。
  • 在递归函数调用中传入的参数是如何向基本情况靠近的?由于每次递归调用时传入的参数表示的那个汉诺塔问题中的圆盘总数都比原来少1,因此最后肯定能够变成仅含1个圆盘的汉诺塔问题。

下面这个 hanoi_tower.py 程序来解决汉诺塔问题,并用示意图表示每一步的状态。

import sys

# 用3个列表来实现A、B、C这3根柱子
TOTAL_DISKS = 4

# 把圆盘都放在A柱上面
TOWERS = {'A': list(reversed(range(1, TOTAL_DISKS + 1))), 
          'B': [],
          'C': []}

def printDisk(diskNum):
    # 当 diskNum 为 0 时,表示柱子顶部的空白,只绘制柱子本身
    emptySpace = ' ' * (TOTAL_DISKS - diskNum)
    if diskNum == 0:
        sys.stdout.write(emptySpace + '||' + emptySpace)
    else:
        diskSpace = '@' * diskNum
        diskNumLabel = str(diskNum).rjust(2, '_')
        sys.stdout.write(emptySpace + diskSpace + diskNumLabel + diskSpace + emptySpace)

def printTowers():
    # 把3根柱子及其圆盘全绘制出来
    for level in range(TOTAL_DISKS, -1, -1):
        for tower in (TOWERS['A'], TOWERS['B'], TOWERS['C']):
            if level >= len(tower):
                printDisk(0)
            else:
                printDisk(tower[level])
        sys.stdout.write('\n')
    # 显示3根柱子的标签
    emptySpace = ' ' * (TOTAL_DISKS)
    print('%s A%s%s B%s%s C\n' % (emptySpace, emptySpace, emptySpace, emptySpace, emptySpace))

def moveOneDisk(startTower, endTower):
    # 把startTower顶部的圆盘移动到endTower上
    disk = TOWERS[startTower].pop()
    TOWERS[endTower].append(disk)

def solve(numberOfDisks, startTower, tempTower, endTower):
    """递归解决汉诺塔问题

    Args:
        numberOfDisks: 当前要移动的圆盘数量
        startTower: 起始柱名称(如'A')
        tempTower: 临时柱名称(如'B')
        endTower: 目标柱名称(如'C')
    """
    if numberOfDisks == 1:
        moveOneDisk(startTower, endTower)
        printTowers()  # 每次移动后打印状态
        return
    # 第一步:将n-1个圆盘从起始柱移到临时柱
    solve(numberOfDisks - 1, startTower, endTower, tempTower)
    # 第二步:移动第n个圆盘到目标柱
    moveOneDisk(startTower, endTower)
    printTowers()
    # 第三步:将n-1个圆盘从临时柱移到目标柱
    solve(numberOfDisks - 1, tempTower, startTower, endTower)
    
# 解决汉诺塔问题
printTowers()
solve(TOTAL_DISKS, 'A', 'B', 'C')

运行这段代码,程序会将这些圆盘从 A 柱移动到 C 柱上的,并显示每一步的图解。

    ||        ||        ||    
   @_1@       ||        ||    
  @@_2@@      ||        ||    
 @@@_3@@@     ||        ||    
@@@@_4@@@@    ||        ||    
     A         B         C

    ||        ||        ||    
    ||        ||        ||    
  @@_2@@      ||        ||    
 @@@_3@@@     ||        ||    
@@@@_4@@@@   @_1@       ||    
     A         B         C

    ||        ||        ||    
    ||        ||        ||    
    ||        ||        ||    
 @@@_3@@@     ||        ||    
@@@@_4@@@@   @_1@     @@_2@@  
     A         B         C

    ||        ||        ||    
    ||        ||        ||    
    ||        ||        ||    
 @@@_3@@@     ||       @_1@   
@@@@_4@@@@    ||      @@_2@@  
     A         B         C

    ||        ||        ||    
    ||        ||        ||    
    ||        ||        ||    
    ||        ||       @_1@   
@@@@_4@@@@ @@@_3@@@   @@_2@@  
     A         B         C

    ||        ||        ||    
    ||        ||        ||    
    ||        ||        ||    
   @_1@       ||        ||    
@@@@_4@@@@ @@@_3@@@   @@_2@@  
     A         B         C

    ||        ||        ||    
    ||        ||        ||    
    ||        ||        ||    
   @_1@     @@_2@@      ||    
@@@@_4@@@@ @@@_3@@@     ||    
     A         B         C

    ||        ||        ||
    ||        ||        ||
    ||       @_1@       ||
    ||      @@_2@@      ||
@@@@_4@@@@ @@@_3@@@     ||
     A         B         C

    ||        ||        ||
    ||        ||        ||
    ||       @_1@       ||
    ||      @@_2@@      ||
    ||     @@@_3@@@ @@@@_4@@@@
     A         B         C

    ||        ||        ||
    ||        ||        ||
    ||        ||        ||
    ||      @@_2@@     @_1@
    ||     @@@_3@@@ @@@@_4@@@@
     A         B         C

    ||        ||        ||
    ||        ||        ||
    ||        ||        ||
    ||        ||       @_1@
  @@_2@@   @@@_3@@@ @@@@_4@@@@
     A         B         C

    ||        ||        ||
    ||        ||        ||
    ||        ||        ||
   @_1@       ||        ||
  @@_2@@   @@@_3@@@ @@@@_4@@@@
     A         B         C

    ||        ||        ||
    ||        ||        ||
    ||        ||        ||
   @_1@       ||     @@@_3@@@
  @@_2@@      ||    @@@@_4@@@@
     A         B         C

    ||        ||        ||
    ||        ||        ||
    ||        ||        ||
    ||        ||     @@@_3@@@
  @@_2@@     @_1@   @@@@_4@@@@
     A         B         C

    ||        ||        ||
    ||        ||        ||
    ||        ||      @@_2@@
    ||        ||     @@@_3@@@
    ||       @_1@   @@@@_4@@@@
     A         B         C

    ||        ||        ||
    ||        ||       @_1@
    ||        ||      @@_2@@
    ||        ||     @@@_3@@@
    ||        ||    @@@@_4@@@@
     A         B         C

四、完整过程解析

初始状态(第 0 步)
  • A 柱: [4, 3, 2, 1](4 个圆盘,大的在下面)
  • B 柱: [](空)
  • C 柱: [](空)
第 1-7 步:将 3 个圆盘从 A 移到 B(临时柱)

这一步对应代码中 solve(3, 'A', 'C', 'B'),即先把 A 柱上的 3 个小圆盘(1-3 号)移到 B 柱,给最大的 4 号圆盘让路。

  1. 第 1 步:移动 1 号盘从 A→C
    solve(1, 'A', 'B', 'C')
    状态:A[4,3,2],B[],C[1]
  2. 第 2 步:移动 2 号盘从 A→B
    solve(1, 'A', 'C', 'B')
    状态:A[4,3],B[2],C[1]
  3. 第 3 步:移动 1 号盘从 C→B
    solve(1, 'C', 'A', 'B')
    状态:A[4,3],B[2,1],C[]
  4. 第 4 步:移动 3 号盘从 A→C
    solve(1, 'A', 'B', 'C')
    状态:A[4],B[2,1],C[3]
  5. 第 5 步:移动 1 号盘从 B→A
    solve(1, 'B', 'C', 'A')
    状态:A[4,1],B[2],C[3]
  6. 第 6 步:移动 2 号盘从 B→C
    solve(1, 'B', 'A', 'C')
    状态:A[4,1],B[],C[3,2]
  7. 第 7 步:移动 1 号盘从 A→C
    solve(1, 'A', 'B', 'C')
    状态:A[4],B[],C[3,2,1]
第 8 步:移动最大的 4 号盘从 A→C

对应代码中 moveOneDisk('A', 'C'),此时最大的圆盘终于可以移到目标柱:
状态:A[],B[],C[3,2,1,4](4 号盘在 C 柱最底部,符合 “大盘在下” 规则)

第 9-15 步:将 C 柱的 3 个小圆盘移到 C 柱的 4 号盘上方

这一步通过两次递归完成:先将 C 柱上的 3 个小圆盘(1-3 号)经 A 柱移到 B 柱,再移回 C 柱的 4 号盘上方,对应代码 solve(3, 'C', 'A', 'B')solve(3, 'B', 'A', 'C')

  1. 第 9 步:移动 1 号盘从 C→A
    对应代码 solve(1, 'C', 'B', 'A')
    状态:A[1],B[],C[3,2,4]
  2. 第 10 步:移动 2 号盘从 C→B
    对应代码 solve(1, 'C', 'A', 'B')
    状态:A[1],B[2],C[3,4]
  3. 第 11 步:移动 1 号盘从 A→B
    对应代码 solve(1, 'A', 'C', 'B')
    状态:A[],B[2,1],C[3,4]
  4. 第 12 步:移动 3 号盘从 C→A
    对应代码 solve(1, 'C', 'B', 'A')
    状态:A[3],B[2,1],C[4]
  5. 第 13 步:移动 1 号盘从 B→C
    对应代码 solve(1, 'B', 'A', 'C')
    状态:A[3],B[2],C[1,4]
  6. 第 14 步:移动 2 号盘从 B→A
    对应代码 solve(1, 'B', 'C', 'A')
    状态:A[3,2],B[],C[1,4]
  7. 第 15 步:移动 1 号盘从 C→A,再通过递归将 3 个圆盘从 A 移到 C
    最终状态:A[],B[],C[4,3,2,1]

五、总结

整个过程总共需要 2 n − 1 2^n−1 2n1 步完成,当 n=4 时,共需 15 步,每一步移动后,代码都会通过printTowers()函数将当前状态可视化展示出来,方便我们理解整个移动过程。

这种递归方法完美体现了 “分而治之” 的算法思想,将复杂问题分解为相似的小问题,直到小问题可以被直接解决。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值