递归入门首选:用Python彻底理解汉诺塔问题与调用栈

发布时间:2026/10/13 18:53:15
递归入门首选:用Python彻底理解汉诺塔问题与调用栈 如果让我只推荐一个递归入门案例我会毫不犹豫地选汉诺塔。这个来自古老传说的益智游戏几乎每一个写过代码的人都在某个阶段遇见过它三根柱子、几个盘子、一次只动一个、大盘子永远不能压在小盘子上。看似是玩玩具但它在编程圈子里被反复提起的真正原因是它和递归之间那种几乎完美的匹配关系。我见过很多朋友第一次看到汉诺塔的 Python 解法时会愣住——明明只有几行代码为什么能把整个问题解决掉这恰恰是递归最值得体会的地方你不是在“从头到尾模拟每一步操作”而是把一个大问题不断拆成更小的同类问题然后让函数自己调用自己。这篇内容我想用相对啰嗦的方式把递归解汉诺塔这件事彻底讲透。不管你是刚接触递归的小白还是想重新理解递归本质的老手都可以从里面得到点东西。我会从问题本身讲起再逐步拆解递归的思维过程、代码实现、调用栈细节、复杂度最后聊一聊我实际调试汉诺塔递归时踩过的坑。你会发现递归没有想象中那么神秘它只是换了一种描述问题的方式。1. 汉诺塔问题为什么天生是递归的教材1.1 游戏规则与递归三要素的对应关系汉诺塔的规则非常简单有三根柱子其中一根从上到下按从小到大的顺序摞着若干个盘子目标是借助第三根柱子把这一摞盘子原样移动到另一根柱子上。整个过程中只有两条铁律一是每次只能移动最上面的一个盘子二是任何时刻都不能让大盘子压在小盘子上面。看起来限制不多但当盘子数量增加到几十个时你会发现手动操作会迅速变成噩梦。递归解决问题的核心有三个要素基线条件base case、递归调用recursive call、以及每一步都在向基线条件逼近。汉诺塔恰好把这三个要素都呈现得很直观。当只有一个盘子时任务简单到一步到位这就是天然基线当有 n 个盘子时你要处理的其实是“把 n-1 个盘子看成整体”这一层抽象而这层抽象又可以继续递归下去。所以理解汉诺塔的过程本质上是理解递归三要素如何在具体问题里落地。1.2 递归思维与汉诺塔的契合点在哪里很多人学递归时最大的障碍是“总想把每一层都看穿”。汉诺塔的价值恰恰在于它逼着你放弃这种控制欲。你可以只关注当前这一层如果我有 n 个盘子我只需要完成三步“粗粒度”的操作至于这三步里面具体发生了什么完全交给下一层递归去解决。举个直觉化的例子。如果你要把一摞文件从书桌左边移到右边但中间只有一块很小的空位并且文件越往上越小、大文件不能压在小文件上你会怎么操作你不会瞬间规划好每一张纸每一次的移动轨迹而是会想先把最上面的 n-1 张暂时挪到中间空位然后移动最底下那张最大的再把 n-1 张挪回右边。至于怎么挪这 n-1 张同样的策略再套一次即可。汉诺塔的递归代码写出来就是这个策略的直接翻译。2. 核心拆解三步法如何把 n 个盘子变成 n-1 个盘子2.1 逆向思考从目标状态反推操作解决汉诺塔最关键的一步不是“接下来该移动哪个”而是先想清楚最终要让局面变成什么样。假设三根柱子分别叫 A源柱、C目标柱、B辅助柱你想把 n 个盘子从 A 全部移到 C。那么在所有盘子到达 C 之前一定存在一个瞬间最大的那个盘子第一次离开 A移动到 C 的底部。但在这之前A 上方那 n-1 个盘子必须已经全部清走不能挡路而能够容纳它们的柱子只剩下 B。于是问题被强制拆成了三个阶段先把上面 n-1 个盘子从 A 借助 C 搬到 B再把第 n 个盘子从 A 直接搬到 C最后把 B 上的 n-1 个盘子借助 A 搬到 C。这样一来“移动 n 个盘子”就变成了“两次移动 n-1 个盘子”加“一次移动 1 个盘子”规模下降问题本质不变。只要你信任这个过程可以一直递归下去代码就会自己运转。2.2 递推公式汉诺塔背后的数学归纳法如果你把移动次数记为 T(n)那么三步法直接给出一条递推关系T(n) T(n-1) 1 T(n-1)也就是 T(n) 2T(n-1) 1。边界是 T(1) 1。数学归纳法可以证明这个递推式的解是 T(n) 2^n - 1。不要小看这个公式。它揭示了递归的“成本”规律问题规模每增加 1总工作量几乎翻倍。汉诺塔的移动次数增长极快下面这张表可以直观感受一下盘子数量 n最少移动次数 2^n - 11123374155318255166553532429496729564约 1.84 亿亿次这个公式也是递归解汉诺塔的最好验证工具只要你实现正确n3 时输出一定是 7 行移动记录n4 时一定是 15 行。我平时写完递归代码第一件事就是数输出行数对不上就说明某个递归调用的参数顺序写错了。公式不是用来背的是用来校验代码的。2.3 从 n1 到 n3 的完整手推先从最简单的说起。n1一个盘子直接从 A 移动到 C结束。这是整个递归的地基也是很多初学者最容易忘记写清楚的部分没有终止条件的递归就像没有出口的迷宫。n2 时按照三步法先把最上面的 1 个盘子从 A 借助 C 搬到 B也就是 A 到 B再把第 2 个盘子从 A 搬到 C最后把 B 上的 1 个盘子搬到 C。完整路径是 A→B、A→C、B→C共 3 步。n3 时递归开始真正发力先把上面 2 个盘子从 A 借助 C 搬到 B这个“搬 2 个盘子”的操作本身又是一个三步法然后第 3 个盘子 A→C最后把 B 上的 2 个盘子借助 A 搬到 C。完整输出是一条有点绕的路径A→C、A→B、C→B、A→C、B→A、B→C、A→C。你会发现前 3 步就是“把 2 个盘子从 A 搬到 B”后 3 步是“把 2 个盘子从 B 搬到 C”中间夹一步最大的盘子 A→C。这就是递归结构最直观的体现。3. Python 实现与逐行解读代码背后的角色切换3.1 函数签名source、target、auxiliary 三根柱子的含义写汉诺塔递归时我强烈建议不要用 a、b、c 这种抽象命名而是用source、target、auxiliary来明确三根柱子的角色。函数签名可以是def hanoi(n, source, target, auxiliary): pass这里的source表示“当前这层递归里盘子从哪来”target表示“最终要放到哪去”auxiliary表示“另外那根可以临时放盘子的柱子”。关键在于这三根柱子的角色在每一层递归里都在变化而代码本身不会去“记住”哪根是 A、哪根是 B它只按参数角色工作。很多初学者写着写着就迷糊是因为脑子里把 A、B、C 固定死了一会觉得应该传 A 一会觉得应该传 B。解决这个问题的唯一办法就是放弃字母身份只认角色。你可以把递归调用想象成一套流水线每次调用都把三个柱子重新分配一次分工分配完成后该干嘛干嘛。3.2 三步法的代码骨架与逐行解读下面是完整的 Python 版汉诺塔递归实现也是我推荐给别人的标准写法def hanoi(n, source, target, auxiliary): if n 1: print(fMove disk 1 from {source} to {target}) return hanoi(n - 1, source, auxiliary, target) print(fMove disk {n} from {source} to {target}) hanoi(n - 1, auxiliary, target, source) if __name__ __main__: hanoi(3, A, C, B)逐行看。第一层判断if n 1是基线条件如果只剩一个盘子直接打印从源柱到目标柱的移动然后return。这个return别丢它表示当前这层递归到此结束不再往深递归。接下来第一个递归调用hanoi(n - 1, source, auxiliary, target)对应“把上面 n-1 个盘子从 source 借助 target 挪到 auxiliary”。注意这里把原来的auxiliary当作新目标原来的target当作新辅助——因为 n-1 个盘子的临时归宿是辅助柱。然后是print输出最大的那个盘子的移动方向就是从source到target没有任何其他柱子参与。最后第二个递归调用hanoi(n - 1, auxiliary, target, source)把刚才暂存在 auxiliary 的 n-1 个盘子再搬回 target此时辅助柱变成了原来的 source。3.3 实际运行n3 时到底会打印什么用上面的代码跑hanoi(3, A, C, B)输出如下Move disk 1 from A to C Move disk 2 from A to B Move disk 1 from C to B Move disk 3 from A to C Move disk 1 from B to A Move disk 2 from B to C Move disk 1 from A to C你对比一下之前的手推结果就会发现代码完全按照三步法在推进。前两步打印了“把 2 个盘子从 A 暂时搬到 B”的双层递归第三步输出最大盘 A→C后三步输出“把 2 个盘子从 B 搬回 C”。这里有一个特别容易忽略的细节disk 2在第一次递归里是从 A 移到 B到了第二次递归里又是从 B 移到 C而disk 1甚至出现了三次移动。原因就是递归的“目标”在不断变化同一块盘子在不同阶段扮演的角色完全不同。4. 递归调用栈可视化计算机究竟在执行什么4.1 调用栈规则后进先出是递归的发动机递归程序能运行靠的是程序调用栈。每一次函数调用都会把当前状态压入栈中等子调用返回后再恢复现场。栈的特点是后进先出最后压入的帧最先弹出。这意味着当你执行hanoi(3)时它会一头扎进hanoi(2)然后再扎进hanoi(1)直达基线条件后才一层层回溯。很多人只看到递归代码“巧妙”的一面却忽略它在执行层面的朴素计算机根本不会通盘理解汉诺塔问题它只是忠实地执行函数调用和返回。递归能优雅工作本质上是因为栈天然适合保存“做到一半的状态”。这就像你看一本套娃小说一样每打开一层套娃程序就得先记住外面这层还没读完等里面那层读完后再继续外层的下一句。4.2 n3 时递归树的完整展开我们以hanoi(3, A, C, B)为例把它展开成完整调用链。下面这段文本树是我用来理解递归最顺手的工具比任何画图软件都直观hanoi(3, A, C, B) ├── hanoi(2, A, B, C) │ ├── hanoi(1, A, C, B) │ │ └── 打印 A - C │ ├── 打印 A - B │ └── hanoi(1, C, B, A) │ └── 打印 C - B ├── 打印 A - C └── hanoi(2, B, C, A) ├── hanoi(1, B, A, C) │ └── 打印 B - A ├── 打印 B - C └── hanoi(1, A, C, B) └── 打印 A - C观察这棵树你会看清两件事。第一“移动 n-1 个盘子”的子调用永远是两个且各自又带着两个更小的子调用直到规模降到 1。第二真正的print动作只发生在三处位置递归出口里、第一个递归调用之后、第二个递归调用之前。这个结构其实正是二叉树的形状汉诺塔递归本质上就是在遍历一棵递归树树的每个内部节点都是一次大盘子的移动树的叶子都是小盘子的移动。4.3 “递”与“归”到底指什么递归这个词拆开就是“递进”和“回归”。以hanoi(3, A, C, B)为例程序先不断向深层调用从 n3 递进到 n1这就是“递”到达基线条件并打印后每一层子调用依次结束控制权逐层交还给上一层这就是“归”。每归还一层程序就会继续执行该层在子调用之后的剩余语句也就是那一次大盘子的移动打印。理解了“递”和“归”之后你就明白一个关键点print语句执行的时间点受递归顺序深刻影响。第一个递归调用之前的打印永远不会发生因为程序一旦执行到递归调用就会立刻跳走。汉诺塔的打印顺序看起来错落有致其实是“深度优先遍历”的必然结果。想清楚这一点你再回去看第 3.3 节的输出就不会觉得它神秘了。5. 复杂度与性能为什么 64 层盘子能把世界耗到终结5.1 移动次数递推式与求解过程前面已经给出递推式 T(n) 2T(n-1) 1初值 T(1) 1。它的求解可以这样走把递推式两边都加 1得到 T(n) 1 2(T(n-1) 1)。如果你令 S(n) T(n) 1那么 S(n) 2S(n-1)且 S(1) 2。一眼就能看出 S(n) 2^n所以 T(n) 2^n - 1。这个过程本身就是数学归纳法与递归思维的同构体现你想证明 n 成立先假设 n-1 成立然后跨出那一步。相比于时间复杂度空间复杂度经常被忽略。汉诺塔递归的最大调用深度是 n因为递归树中最长路径是从根一路深入叶子所以栈空间占用是 O(n)。这意味着算法虽然操作次数是指数级但内存消耗非常温和。可惜指数级的时间已经把一切可能性堵死了大 n 面前没有任何工程优化能救场。5.2 指数爆炸的现实含义2^64 - 1 到底有多大约等于 1.84 × 10^19 次移动。如果按每秒钟移动一次来算需要约 5840 亿年才能完成哪怕你优化到每秒移动一百万次仍然需要约 58 万年。这个数字已经远远超出了人类文明的时间尺度所以传说中“64 层金盘全部移完那天就是世界末日”虽然是个故事但从算法角度讲这个故事说得非常严谨。指数级复杂度是算法设计里最需要警惕的东西。汉诺塔的递归写法并没有“做错什么”它是求解这个问题的天然方案问题本身的信息量就决定了操作次数不可能低于 2^n - 1因为每一次移动都改变状态而最少步数就是这么多。你要记住的教训是递归可以让代码写得优雅但优雅不能消除问题固有的计算复杂度。写递归前先估算规模这比优化代码重要一百倍。5.3 Python 递归深度限制与实际观察Python 默认的递归深度限制在 1000 层左右超过之后会抛出RecursionError。对汉诺塔来说n 超过几百就会出现这个异常不是算法逻辑出错而是解释器为了保护宿主环境而设的防线。你可以用sys.setrecursionlimit(10000)临时调高但调高后依然受操作系统栈空间限制尤其在某些容器环境里过深的递归可能直接让进程崩溃而不是抛出普通异常。我在自己的环境里用 n995 做了一次压测程序能正常跑完但输出文件已经非常庞大。再把 n 调到 1000 以上立刻看到RecursionError: maximum recursion depth exceeded in comparison。这提醒我们递归解汉诺塔适合做思维训练和小规模演示真要处理几十上百个盘子建议改写成显式栈迭代版本或者只关心移动次数而不打印每一步。但改写后的逻辑会复杂得多这也反过来证明了递归表达力的价值。6. 新手最容易踩的坑与我的实际调试经验6.1 参数顺序错乱最常见的“移植翻车”现场我见过不止一个朋友把汉诺塔代码从网上抄下来改一改然后发现输出顺序完全不对。问题几乎都出在递归调用的参数顺序上。比如把第一个递归调用写成hanoi(n - 1, source, target, auxiliary)三个参数跟外层完全一致结果 n-1 个盘子被直接“试图”搬到最终目标柱整个过程就会乱套。我个人的规避方法是每层递归前都口头念一遍角色本次的source、target、auxiliary分别是谁下一步要让 n-1 个盘子去哪根柱子目标柱在下一步里必须让位成辅助角色。如果一段代码超过十分钟还绕不清楚我唯一建议就是把参数全部改成明确角色名不要贪图简洁用a、b、c。命名越清晰人脑负担越小。6.2 想“看懂”每一层是新手最大的执念写递归代码最常见的思维误区是想用循环的顺序思维去模拟所有递归层。实话说我自己早期学递归时也这么干过结果就是大脑直接宕机。递归的正确使用方式是只看当前这一层和三步法的契约相信子调用能完成它的任务。这个“相信”不是盲目而是基于基线条件的保证。一个很有效的心理练习假设已经有人帮你写好了一个能正确移动 n-1 个盘子的黑盒函数你只需要在 4 行代码里组合它。你不需要知道黑盒内部怎么走只需要知道它按参数约定工作。等你把 4 行代码写顺了再把黑盒想象成递归调用你会发现原来的心理障碍凭空消失了。汉诺塔递归就是这么一套黑盒套黑盒的结构。6.3 调试技巧用缩进打印让递归过程变得可见如果你实在想亲眼看到递归的每一步我推荐这个简单实用的小技巧在函数里加一个depth参数每次递归前打印带缩进的调用信息def hanoi_debug(n, source, target, auxiliary, depth0): indent * depth print(f{indent}hanoi(n{n}, {source}-{target}, aux{auxiliary})) if n 1: print(f{indent}base: move disk 1 from {source} to {target}) return hanoi_debug(n - 1, source, auxiliary, target, depth 1) print(f{indent}move disk {n} from {source} to {target}) hanoi_debug(n - 1, auxiliary, target, source, depth 1)跑hanoi_debug(3, A, C, B)时你会看到每一层缩进自动呈现递归深度调用树的展开和回退顺序一目了然。这个技巧最大的好处是让你建立“递归深度”的直觉而递归深度就是理解汉诺塔乃至所有递归问题的关键钥匙。等你习惯了用缩进辅助脑内调试就可以回到干净版本继续用。6.4 从一个状态列表说起递归看法的最后一次升华还有一种训练方式是用三个列表模拟柱子把递归过程改成真正操作数据。比如在递归出口把盘子从source列表弹出、压入target列表最后打印三根柱子的状态。这样你会更直观感受到递归不只是打印字符序列而是在真正搬运数据。很多人把这个练习当作理解汉诺塔的最后一步做过之后再看递归会有种“原来如此”的踏实感。如果让我总结整个排查过程最核心的一句话就是先把最小规模跑通再用缩进看结构最后才试图理解整体。n1 跑通是整个程序可信的地基n2 验证三步法的正确性n3 开始能看出递归树的形状之后就别再往上加盘子了因为增加的只是重复的树展开不是新的认知。这套“小步验证”的思路其实也适用于任何领域的新知识学习。我在实际带人学递归时收到过的最多反馈是“代码我是看懂了但让我自己写就写不出来”。如果你也有这种感觉建议你从 n2 开始亲手在白板上把三步法写一遍再翻译成代码最后去掉 println 恢复成纯净递归。这个过程重复三五次之后你是真的会体会到汉诺塔并不是一个需要记住答案的经典题它只是一个让你学会“拆解问题”的练习场。总有一天当你在真实项目里遇到树形结构、分治算法、回溯搜索时你会想起这个三根柱子的游戏然后发现递归不再是需要用胆子去相信的黑魔法而是你已经拿捏得很稳的基本功。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询