在 Kelvintaph 冰川最高峰的僧侣中,流传着一个既能取暖又能锻炼头脑的古老游戏:三根柱子,一根上按从大到小叠着一摞圆盘,要把整摞盘子搬到另一根柱子上——一次只能搬一个,而且永远不能把大盘压在小盘上面。你的冒险队没有真正的柱子和圆盘,那就用这些身材各异的农民和三个树桩来玩吧!这个看似简单的搬运游戏,正是计算机科学中最经典的递归范例:汉诺塔(Tower of Hanoi)。作为封神冰川的终极试炼,它会让你真正领悟"递归"这一优雅而强大的思想。
pickUpItem(出栈/pop)和 dropItem(入栈/push)模拟圆盘移动,用 peekItem/viewStack 查看栈;掌握递归思想——把移动 n 个盘拆成移动 n-1 个盘的子问题;写出经典的三步递归解法并理解参数角色的轮换。
汉诺塔是一个数学谜题:有三根柱子(记作起点、目标、中转),起点柱上叠着 n 个大小不一的圆盘,大的在下、小的在上。目标是把这 n 个圆盘全部搬到目标柱,搬完后依然保持"大在下、小在上"的顺序。它之所以在计算机科学中如此重要,是因为它用递归解决极其简洁,用非递归(循环)却相当繁琐——是理解递归思想的绝佳教材。
汉诺塔的规则只有三条,却让问题充满智慧:① 一次只能移动一个圆盘;② 每次只能移动某根柱子最顶端的圆盘;③ 任何时候都不能把大盘放在小盘之上。在本关里,这三条对应:一次只能带一个农民、只能取树桩顶端的农民、不能把大农民压在小农民身上。
每根柱子(树桩)本质上就是一个栈(stack)——一种只能从顶端存取的数据结构。你只能看到、取走最顶端的圆盘,底下的都被压住无法直接触及。栈遵循后进先出(LIFO, Last In First Out)规则:最后放上去的,最先被取下来。汉诺塔的每根柱子,都完美体现了栈的这一特性。
后进先出(LIFO)。只能操作顶端。最后压入的最先弹出。汉诺塔的每根柱子都是栈。
先进先出(FIFO)。一端进、另一端出。像排队。与栈的存取方向正好相反。
由于规则限制"大不压小",每根柱子(栈)在任意时刻,从下往上都是严格递减的:越靠底越大、越靠顶越小,栈顶永远是这根柱子上最小的圆盘。这条约束保证了移动的合法性——你 dropItem 放盘时,放上去的必须比目标柱顶端的盘更小。理解这一点,就明白为什么递归要"先把小盘们清走"。
关卡提供的 hero.pickUpItem(container) 会拾取容器顶端(最后一个)的物品——这正是栈的 pop 操作:它把目标柱的栈顶元素弹出,并压入英雄自己的手(英雄可看作一个只能拿一个盘的临时栈)。一次只能拿一个,对应"一次只移动一个圆盘"的规则。
# pickUpItem = pop:从目标柱弹出顶端圆盘,拿在手上 hero.pickUpItem(stump1) # 取走 stump1 栈顶的农民
hero.dropItem(container) 则把英雄手上的圆盘放到目标容器顶端——这是栈的 push 操作:从英雄的手弹出,压入目标柱的栈顶。pickUpItem 与 dropItem 成对出现,一取一放,就完成了"把一个圆盘从一根柱子移到另一根柱子"的完整动作。
# 一次完整的圆盘移动 = pop + push hero.pickUpItem(who) # pop:从 who 柱取顶端盘 hero.dropItem(whom) # push:放到 whom 柱顶端
关卡还提供两个"只读"工具:peekItem() 返回栈顶(最后一个)物品但不移除它;viewStack() 返回整个栈的物品数组(索引 length-1 是栈顶)。农民还有 size 属性表示其规模。这些用于观察当前局面、调试或验证——比如查看某柱顶端农民的尺寸,确认移动是否合法。
# 观察工具:peek 看栈顶, viewStack 看整个栈 hero.say("第1柱有这些农民:" + stump1.viewStack()) hero.say("第2柱顶端农民尺寸:" + stump2.peekItem().size)
| 栈操作 | 关卡 API | 作用 | 栈是否改变 |
|---|---|---|---|
| pop 出栈 | pickUpItem(c) | 取走顶端圆盘 | 变短 |
| push 入栈 | dropItem(c) | 放到顶端 | 变长 |
| peek 查看 | c.peekItem() | 看栈顶不移除 | 不变 |
| view 全览 | c.viewStack() | 看整个栈数组 | 不变 |
递归(recursion)是"函数调用自己"来解决问题的方法。它的核心是分而治之:把一个大问题,转化成一个或多个结构相同、规模更小的子问题。汉诺塔就是递归的完美化身——"移动 n 个盘"可以借助"移动 n-1 个盘"来完成。只要你能把 n-1 个盘搬来搬去,搬 n 个盘就迎刃而解。
每个递归都必须包含两部分:基线条件(base case)——问题小到可以直接解决、停止递归的情形;递归条件(recursive case)——把问题缩小、调用自身的情形。在汉诺塔里,基线条件是 n == 0(没有盘要搬,直接 return);递归条件则是 n ≥ 1 时的三步搬运。没有基线条件,递归会无限调用下去直到崩溃。
# 递归的两大要素 def hanoi(n, who, whom, via): if n == 0: # 基线条件:没有盘,停止 return # 递归条件:调用自己解决更小的问题(见 15.5)
if n == 0: return,函数会带着越来越小的 n 一直调用自己(n 变负数也停不下来),最终导致栈溢出(RecursionError)。写任何递归,第一件事就是想清楚基线条件。汉诺塔的完整递归解法只有优雅的三步:① 把上面 n-1 个盘从起点(who)借助目标(whom)当中转,移到中转柱(via);② 把最底下那个最大的盘,从起点(who)直接搬到目标(whom)——这一步就是 pickUpItem + dropItem;③ 把那 n-1 个盘从中转柱(via)借助起点(who)当中转,移到目标柱(whom)。三步之后,n 个盘全部就位。
# 汉诺塔的经典三步递归 def hanoi(n, who, whom, via): if n == 0: return hanoi(n - 1, who, via, whom) # ① 上面 n-1 个:起点 → 中转 hero.pickUpItem(who) # ② 最大盘: hero.dropItem(whom) # 起点 → 目标 hanoi(n - 1, via, whom, who) # ③ 那 n-1 个:中转 → 目标
递归解法最精妙、也最烧脑的地方,是三根柱子的角色在每层递归里互相轮换。函数签名 hanoi(n, who, whom, via) 里,who 是当前的起点、whom 是目标、via 是中转。第一步递归 hanoi(n-1, who, via, whom) 里,原来的"目标 whom"临时充当了"中转";第三步 hanoi(n-1, via, whom, who) 里,原来的"中转 via"变成了新的"起点"。正是这种角色轮换,让同一个函数能处理每一层不同的搬运方向。
| 递归调用 | who 起点 | whom 目标 | via 中转 |
|---|---|---|---|
| hanoi(n, who, whom, via) | who | whom | via |
| ① hanoi(n-1, who, via, whom) | who | via | whom |
| ③ hanoi(n-1, via, whom, who) | via | whom | who |
hanoi(n-1, ...) 一定能正确搬好 n-1 个盘",再把最大盘搬好,整体就对了。这种"信任递归"的思维方式,是掌握递归的关键心法。纠结于展开每一层,反而会迷失。汉诺塔搬 n 个盘的最少移动次数是 2ⁿ − 1 步。因为搬 n 个盘 = 搬 n-1 个 + 搬 1 个 + 再搬 n-1 个,即 T(n) = 2·T(n-1) + 1,展开正好是 2ⁿ − 1。本关搬 5 个农民,就需要 2⁵ − 1 = 31 步。这个指数增长非常惊人:传说中 64 个金盘的"梵天之塔",需要约 1800 亿亿步,即使每秒一步也要几千亿年。
| 盘数 n | 移动次数 2ⁿ − 1 |
|---|---|
| 1 | 1 |
| 3 | 7 |
| 5(本关) | 31 |
| 10 | 1023 |
| 64(传说) | 约 1.8 × 10¹⁹ |
这一节聚焦关卡的策略、思路与算法设计,只展示用于说明的关键片段,完整通关程序请你自己动手完成。
三个树桩 stump1/2/3 就是三根柱子,身材从大到小叠放的农民就是圆盘。规则完全对应:一次只带一个农民、只能取树桩顶端的、不能把大农民压在小农民上。策略非常清晰:识别出这是汉诺塔问题,写一个递归函数 hanoi,再用起点、目标、中转三个树桩调用它。递归会自动生成合法的 31 步搬运顺序。
函数 hanoi(n, who, whom, via) 表示"把 who 上的 n 个农民搬到 whom,via 作中转"。先写基线条件 if n == 0: return(没有农民要搬就结束),这是防止无限递归的关键。然后是三步递归主体。
# 关键片段:递归函数骨架 + 基线条件 def hanoi(n, who, whom, via): if n == 0: # 基线:没有农民要搬,直接返回 return # 递归主体(见下)…
主体严格按"移上面 n-1、移最大、再移上面 n-1"三步展开。中间那步用 pickUpItem(who) 取起点树桩顶端农民、dropItem(whom) 放到目标树桩,正是"移动一个圆盘"的实现。两侧的递归调用注意参数角色的轮换——中转与目标/起点互换。
# 关键片段:三步递归主体 hanoi(n - 1, who, via, whom) # ① 上面 n-1 个 → 中转 hero.pickUpItem(who) # ② 取最大农民 hero.dropItem(whom) # 搬到目标 hanoi(n - 1, via, whom, who) # ③ 中转的 n-1 个 → 目标
最后一行是"点火"——hanoi(5, stump1, stump2, stump3),意思是"把 stump1 上的 5 个农民搬到 stump2,用 stump3 作中转"。传入 5 是因为有 5 个农民;三个树桩的顺序决定了起点、目标、中转。一句调用,整个 31 步的搬运便自动展开。
# 关键片段:发起递归(起点, 目标, 中转) hanoi(5, stump1, stump2, stump3)
dropItem 时目标树桩顶端要么为空、要么是更大的农民,大压小的非法情况绝不会发生。这是汉诺塔递归解法数学上被证明的正确性,你只管信任它。hanoi(n-1, …) 里三个树桩参数的顺序。第一步是 (who, via, whom)、第三步是 (via, whom, who)——中转与目标/起点的轮换必须精确,写反一个,搬运顺序就乱了、会触发非法移动。对照 15.5.2 的角色轮换表逐一核对。
if n == 0: return 会导致无限递归、栈溢出,这是递归的头号错误。hanoi(n-1, …) 里 who/whom/via 的轮换必须精确,写反会触发非法移动。who(起点)取、往 whom(目标)放,方向弄反会把盘搬错柱子。恭喜你登顶封神冰川,征服了计算机科学中最优雅的思想——递归!在 Kelvintaph 的收官之作里,你理解了汉诺塔问题的抽象与三条规则;认识到每根柱子(树桩)就是一个栈,遵循后进先出(LIFO)、大盘不能压小盘;学会用 pickUpItem(pop 出栈)与 dropItem(push 入栈)成对模拟圆盘移动,用 peekItem/viewStack 观察栈;更重要的是,你掌握了递归思想——用基线条件终止、用"移上面 n-1、移最大、再移上面 n-1"的三步把大问题化为同类的小问题,并领会了三根柱子参数角色轮换的精妙。你还认识了 2ⁿ − 1 的指数增长规律。"信任递归、分而治之"的智慧,将伴随你未来所有的算法探索。来回顾一下核心概念:
| 核心概念 | 关键点 | 对应写法 |
|---|---|---|
| 汉诺塔问题 | 三柱搬盘, 大不压小 | hanoi(n, who, whom, via) |
| 柱子=栈 | 后进先出 LIFO | 只操作顶端 |
| pop 出栈 | 取走顶端圆盘 | hero.pickUpItem(who) |
| push 入栈 | 放到顶端 | hero.dropItem(whom) |
| peek/view | 只看不取 | peekItem / viewStack |
| 基线条件 | 递归的终点 | if n == 0: return |
| 三步递归 | 移上、移最大、移上 | hanoi(n-1)…pick+drop…hanoi(n-1) |
| 移动次数 | 指数级增长 | 2ⁿ − 1 |
懂得把庞大的问题层层拆解成同类的小问题、写下终止的基线、然后信任递归自会完成其余——这份"分而治之"的思维,是程序员最珍贵的智慧之一。
封神冰川的汉诺塔试炼圆满完成,计算机科学 6 的全部征途至此功德圆满。恭喜你,冰川之巅的勇者!🧊❄️🗼
扣哒世界 编程知识 · 计算机科学 6(封神冰川):第十五单元
扣哒世界教研团队