背景
turtle — Turtle graphics 中提供了关于 模块的介绍。我想到可以用 来展示汉诺塔 () 的移动过程。于是写了本文。本文有两个主要部分
- 提供展示汉诺塔操作的完整 代码
- 提供汉诺塔最小移动次数的分析
说明
本文所提到的汉诺塔,其规则如下
- 有 三个柱子
- 最初 柱子上有 个盘子(位置越低的盘子越大), 两个柱子上没有盘子
- 我们每次可以将一个盘子从一个柱子顶部移动到另一个柱子上(例如 这样的移动都是允许的)
- 不允许将较大的盘子放在较小盘子的上面
我们的目标是将 个盘子从 移动到 或者 。
正文
完整的代码
我让 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()
请将上述代码保存为 。
运行效果
用以下命令可以运行
python3 hanoi_turtle.py
开始时
开始时的效果如下图所示 ⬇️
运行中
运行中的效果如下图所示 ⬇️
胜利时
胜利的情况有两种
- 将全部的盘子都移动到 柱子上
- 将全部的盘子都移动到 柱子上
胜利时的效果如下方两张图所示 ⬇️
(上面这张图中的移动步数=25,没有达到最小移动次数 15,下文会有相关介绍)
最小移动次数分析
我们来看一下汉诺塔背后的数学知识。
我在 具体数学(英文版第2版) 一书第 章 中的 小节 里,看到了相关的分析。按照我的理解,转述如下(有兴趣的读者朋友,可以看看原文)。
我们的目标是把 个盘子从 柱子移动到另一个柱子 或者 上。我们用 来表示将 个盘子从 移动到 所需要的最小的移动次数。
由对称性可知,将 个盘子从 移动到 所需要的最小的移动次数也是 。继续利用对称性,将 个盘子从任意一个柱子移动到另一个柱子,所需要的最小移动次数也是 。也就是说,对 个盘子执行如下的任意一个移动操作,它们所需要的最小移动步数都是一样的,都是 。
我们来看看如何计算 。为了便于描述,我们将 作为目标柱子。先观察最简单的情况。
- 时,对这个盘子执行 的移动操作即可,所以 。
- 时
- 只操作一次,肯定不行(因为两个盘子至少都得移动一次)
- 只操作两次,也做不到(暴力枚举即可,因为可行的操作非常有限)
- 操作三次是可以的(目标柱子是 ,可以这样操作:较小的盘子 ,然后较大的盘子 ,然后较小的盘子 ),所以 。
当 更大时,我们总是可以使用如下的“三步走”策略(目标柱子是 )。
- 将除了最大盘子之外,其他 个盘子从 移动到 (通过 次移动可以做到)
- 将最大的盘子从 移动到 (通过 次移动可以做到)
- 将除了最大盘子之外,其他 个盘子从 移动到 (通过 次移动可以做到)
因为这个策略只是可行策略之一(也许存在更优的做法),所以可以得出以下不等式 ⬇️
即
至此,我们得到了 的上界。但这个策略所需要的移动步数是最少的吗?我们来分析一下。
对任意一个成功将 个盘子从 移动到 的完整流程而言,这个流程一定需要移动最大的那个盘子 。那么以下推断都成立 ⬇️
- 在第一次移动 之前,我们已经将其余 个盘子移动到 或者 上了(按照规则,一个盘子在移动时,其上方不能有其他盘子)
- 我们至少需要移动 一次(否则这个盘子不可能到目标柱子上)
- 在最后一次移动 (一定是移到了 上)之后,我们一定需要将其余 个盘子移动到 上
所以,对这个完整流程而言,它的移动次数至少是 。
于是我们得到 (即, 个盘子时的最小操作数至少是 )
既然以下两个不等式都成立
那么,可以得出 。
利用高中时学的数列小技巧,我们可以推出 的通项公式。具体过程如下 ⬇️
在 这个等式两边各自加 ,得到
记 ,那么 是以 为首项, 为公比的等比数列,所以 的通项公式为
那么 。也就是说,当 柱子上有 个盘子时,我们需要 次移动操作,才能将这 个盘子移动到 柱子上。对较小的 值,我在下表中列举了对应的数据 ⬇️
| 开始时 柱子上盘子的数量 | 将盘子全都移动到 柱子上 所需要的最小移动次数 |
|---|---|