[Python] 借助 Turtle 来展示汉诺塔

50 阅读1分钟

背景

turtle — Turtle graphics 中提供了关于 turtle\text{turtle} 模块的介绍。我想到可以用 turtle\text{turtle} 来展示汉诺塔 (Tower of Hanoi\text{Tower of Hanoi}) 的移动过程。于是写了本文。本文有两个主要部分

  • 提供展示汉诺塔操作的完整 Python\text{Python} 代码
  • 提供汉诺塔最小移动次数的分析

说明

本文所提到的汉诺塔,其规则如下

  • A,B,CA,B,C 三个柱子
  • 最初 AA 柱子上有 nn 个盘子(位置越低的盘子越大),B,CB,C 两个柱子上没有盘子
  • 我们每次可以将一个盘子从一个柱子顶部移动到另一个柱子上(例如 AB,CA,ABA\to B, C\to A,A\to B 这样的移动都是允许的)
  • 不允许将较大的盘子放在较小盘子的上面

我们的目标是将 nn 个盘子从 AA 移动到 BB 或者 CC

正文

完整的代码

我让 trae 帮我生成了相关的代码(我做了些调整),完整的代码如下 (代码中涉及4个盘子)

import turtle

# ===================== 游戏配置 =====================
NUM_DISKS = 4
TOWER_COUNT = 3

SCREEN_WIDTH = 700
SCREEN_HEIGHT = 500

TOWER_HEIGHT = 180
TOWER_WIDTH = 8
DISK_HEIGHT = 25
DISK_WIDTH_BASE = 50

TOWER_X = [-200, 0, 200]
TOWER_Y = -150

# ===================== 游戏数据 =====================
towers = [[4, 3, 2, 1], [], []]
selected = None
win = False
move_count = 0

# ===================== 初始化屏幕和画笔 =====================
screen = turtle.Screen()
screen.setup(SCREEN_WIDTH, SCREEN_HEIGHT)
screen.title("汉诺塔 - Tower of Hanoi")
screen.tracer(0)

pen = turtle.Turtle()
pen.hideturtle()

# ===================== 绘制函数 =====================


def draw_tower(x, y, height):
    pen.penup()
    pen.goto(x - TOWER_WIDTH // 2, y)
    pen.pendown()
    pen.fillcolor("gray")
    pen.begin_fill()
    pen.goto(x + TOWER_WIDTH // 2, y)
    pen.goto(x + TOWER_WIDTH // 2, y + height)
    pen.goto(x - TOWER_WIDTH // 2, y + height)
    pen.goto(x - TOWER_WIDTH // 2, y)
    pen.end_fill()


def draw_disk(x, y, disk_size, is_selected=False):
    width = DISK_WIDTH_BASE + disk_size * 25
    pen.penup()
    pen.goto(x - width // 2, y)
    pen.pendown()

    if is_selected:
        pen.fillcolor("yellow")
    else:
        pen.fillcolor("orange" if disk_size % 2 == 0 else "skyblue")

    pen.begin_fill()
    pen.goto(x + width // 2, y)
    pen.goto(x + width // 2, y + DISK_HEIGHT)
    pen.goto(x - width // 2, y + DISK_HEIGHT)
    pen.goto(x - width // 2, y)
    pen.end_fill()

    pen.pencolor("gray")
    pen.pensize(2)
    pen.goto(x + width // 2, y)
    pen.goto(x + width // 2, y + DISK_HEIGHT)
    pen.goto(x - width // 2, y + DISK_HEIGHT)
    pen.goto(x - width // 2, y)
    pen.pensize(1)
    pen.pencolor("black")


def draw_text(msg, y_offset=150):
    pen.penup()
    pen.goto(0, y_offset)
    pen.pendown()
    pen.write(msg, align="center", font=("Arial", 18, "normal"))


def redraw():
    pen.clear()

    for i in range(TOWER_COUNT):
        draw_tower(TOWER_X[i], TOWER_Y, TOWER_HEIGHT)

    for tower_idx in range(TOWER_COUNT):
        tower = towers[tower_idx]
        x = TOWER_X[tower_idx]
        for disk_idx, disk_size in enumerate(tower):
            y = TOWER_Y + disk_idx * DISK_HEIGHT
            is_selected = (selected == tower_idx)
            draw_disk(x, y, disk_size, is_selected)

    if win:
        draw_text(f"🎉 You Win! Moves: {move_count} 🎉")
    elif selected is not None:
        draw_text(
            f"Selected Tower {selected + 1} - Moves: {move_count}"
        )
    else:
        draw_text(
            f"Click a tower to pick a disk - Moves: {move_count}"
        )

    screen.update()


# ===================== 游戏逻辑 =====================


def move_disk(from_tower, to_tower):
    if not towers[from_tower]:
        return False
    if not towers[to_tower] or towers[from_tower][-1] < towers[to_tower][-1]:
        towers[to_tower].append(towers[from_tower].pop())
        return True
    return False


def check_win():
    return len(towers[1]) == NUM_DISKS or len(towers[2]) == NUM_DISKS


# ===================== 点击事件处理 =====================


def on_click(x, y):
    global selected, win, move_count

    if win:
        return

    click_tower = None
    for i in range(TOWER_COUNT):
        if abs(x - TOWER_X[i]) < 80:
            click_tower = i
            break

    if click_tower is not None:
        if selected is None:
            if towers[click_tower]:
                selected = click_tower
        else:
            if move_disk(selected, click_tower):
                move_count += 1
                win = check_win()
            selected = None

    redraw()


# ===================== 启动游戏 =====================
screen.onclick(on_click)
redraw()
screen.mainloop()

请将上述代码保存为 hanoi_turtle.py\text{hanoi\_turtle.py}

运行效果

用以下命令可以运行 hanoi_turtle.py\text{hanoi\_turtle.py}

python3 hanoi_turtle.py
开始时

开始时的效果如下图所示 ⬇️

image.png
运行中

运行中的效果如下图所示 ⬇️

image.png
胜利时

胜利的情况有两种

  • 将全部的盘子都移动到 BB 柱子上
  • 将全部的盘子都移动到 CC 柱子上

胜利时的效果如下方两张图所示 ⬇️

image.png

(上面这张图中的移动步数=25,没有达到最小移动次数 15,下文会有相关介绍)

image.png

最小移动次数分析

我们来看一下汉诺塔背后的数学知识。

我在 具体数学(英文版第2版) 一书第 11Recurrent Problems\text{Recurrent Problems} 中的 1.11.1 小节 THE TOWER OF HANOI\text{THE TOWER OF HANOI} 里,看到了相关的分析。按照我的理解,转述如下(有兴趣的读者朋友,可以看看原文)。

我们的目标是把 nn 个盘子从 AA 柱子移动到另一个柱子 BB 或者 CC 上。我们用 TnT_n 来表示将 nn 个盘子从 AA 移动到 CC 所需要的最小的移动次数。

由对称性可知,将 nn 个盘子从 AA 移动到 BB 所需要的最小的移动次数也是 TnT_n。继续利用对称性,将 nn 个盘子从任意一个柱子移动到另一个柱子,所需要的最小移动次数也是 TnT_n。也就是说,对 nn 个盘子执行如下的任意一个移动操作,它们所需要的最小移动步数都是一样的,都是 TnT_n

  • ABA\to B
  • ACA\to C
  • BAB\to A
  • BCB\to C
  • CAC\to A
  • CBC\to B

我们来看看如何计算 TnT_n为了便于描述,我们将 CC 作为目标柱子。先观察最简单的情况。

  • n=1n=1 时,对这个盘子执行 ACA\to C 的移动操作即可,所以 T1=1T_1=1
  • n=2n=2
    • 只操作一次,肯定不行(因为两个盘子至少都得移动一次)
    • 只操作两次,也做不到(暴力枚举即可,因为可行的操作非常有限)
    • 操作三次是可以的(目标柱子是 CC,可以这样操作:较小的盘子 ABA\to B,然后较大的盘子 ACA\to C,然后较小的盘子 BCB\to C),所以 T2=3T_2=3

nn 更大时,我们总是可以使用如下的“三步走”策略(目标柱子是 CC)。

  1. 将除了最大盘子之外,其他 n1n-1 个盘子从 AA 移动到 BB (通过 Tn1T_{n-1} 次移动可以做到)
  2. 将最大的盘子从 AA 移动到 CC (通过 11 次移动可以做到)
  3. 将除了最大盘子之外,其他 n1n-1 个盘子从 BB 移动到 CC (通过 Tn1T_{n-1} 次移动可以做到)

因为这个策略只是可行策略之一(也许存在更优的做法),所以可以得出以下不等式 ⬇️

TnTn1+1+Tn1T_n \le T_{n-1} + 1 + T_{n-1}

Tn2Tn1+1T_n \le 2 T_{n-1} + 1

至此,我们得到了 TnT_n 的上界。但这个策略所需要的移动步数是最少的吗?我们来分析一下。

对任意一个成功将 nn 个盘子从 AA 移动到 CC 的完整流程而言,这个流程一定需要移动最大的那个盘子 PbiggestP_{biggest}。那么以下推断都成立 ⬇️

  1. 在第一次移动 PbiggestP_{biggest} 之前,我们已经将其余 n1n-1 个盘子移动到 BB 或者 CC 上了(按照规则,一个盘子在移动时,其上方不能有其他盘子)
  2. 我们至少需要移动 PbiggestP_{biggest} 一次(否则这个盘子不可能到目标柱子上)
  3. 在最后一次移动 PbiggestP_{biggest} (一定是移到了 CC 上)之后,我们一定需要将其余 n1n-1 个盘子移动到 CC

所以,对这个完整流程而言,它的移动次数至少是 Tn1+1+Tn1=2Tn1+1T_{n-1} + 1 + T_{n-1}=2T_{n-1}+1

于是我们得到 Tn2Tn1+1T_n \ge 2 T_{n-1} + 1 (即,nn 个盘子时的最小操作数至少是 2Tn1+12T_{n-1}+1

既然以下两个不等式都成立

Tn2Tn1+1T_n \le 2 T_{n-1} + 1
Tn2Tn1+1T_n \ge 2 T_{n-1} + 1

那么,可以得出 Tn=2Tn1+1T_n = 2 T_{n-1} + 1

利用高中时学的数列小技巧,我们可以推出 TnT_n 的通项公式。具体过程如下 ⬇️

Tn=2Tn1+1T_n = 2 T_{n-1} + 1 这个等式两边各自加 11,得到

Tn+1=2(Tn1+1)T_n + 1 = 2 (T_{n-1} + 1)

Un=Tn+1U_n=T_n+1,那么 UnU_n 是以 22 为首项,22 为公比的等比数列,所以 UnU_n 的通项公式为

Un=2nU_n=2^n

那么 Tn=Un1=2n1T_n=U_n-1=2^n-1。也就是说,当 AA 柱子上有 nn 个盘子时,我们需要 2n12^n-1 次移动操作,才能将这 nn 个盘子移动到 CC 柱子上。对较小的 nn 值,我在下表中列举了对应的数据 ⬇️

开始时 AA 柱子上盘子的数量 nn将盘子全都移动到 CC 柱子上
所需要的最小移动次数
1111
2233
3377
441515
553131
666363
77127127
88255255
99511511