资讯详情

汉诺塔与递归:Python实现及递归调用栈逐层拆解

📅 2026/10/13 12:27:25 | 华诺云谱 👁 阅读
汉诺塔与递归:Python实现及递归调用栈逐层拆解
先说我自己的感受汉诺塔这道题几乎每个学编程的人都碰过但真正能一次把它讲清楚的资料我翻来覆去找了很久发现大多数讲解要么停留在“代码能跑”的层面要么只贴一段递归然后说“自己想”。作为当年在这个问题上卡了整整两天的过来人我太知道一个问题卡住时那种难受劲了。所以这篇解读我不打算给你扔一段代码然后让你自己悟而是要把递归拆开揉碎从“为什么要这么想”到“代码到底怎么一步步执行”全部过一遍。这套解读适配的场景是你刚开始学Python学到函数和递归时被汉诺塔难住了或者你把代码背下来了但完全没理解面试时被问到就露馅又或者你理解了但想确认自己的思路对不对。这篇就是写给你的。1. 汉诺塔和递归先把这两件事彻底说清楚1.1 汉诺塔问题到底在问什么汉诺塔源于一个古老的传说说有座寺庙里有一根柱子上面穿了64个大小递增的金盘僧侣们要把所有盘子移到另一根柱子上规则只有两条一次只能移动一个盘子而且大盘子永远不能压在小盘子上面。编程实现时我们把问题抽象成更干净的形式有三根柱子分别叫A、B、C最初所有盘子都在A柱上按从下到上递增的顺序叠放。目标是把所有盘子从A移到CB作为中转。你要写一个函数输出每一步的移动方案。这里有个关键点你要记住程序不需要真的去“模拟”移动珠子它只需要输出类似“把盘子1从A移动到C”这样的指令序列。很多刚开始学的人总想着要建几个列表来模拟柱子然后在这个列表上做增删操作这就是方向搞偏了。1.2 递归到底是什么为什么它天生适合汉诺塔递归的核心思想只有一句话一个函数直接或间接地调用自身。但光记住这句话没用你得理解它背后的思维模式——在处理一个规模为n的问题时你先假设自己能解决规模为n-1的问题然后把n的问题转化成对n-1问题的调用再加上一小步额外操作。我用一个生活化的例子来类比。假设你要整理一个套娃式的文件柜最外层抽屉有10个文件夹其中第10个是你要的文件。这时候你可以说我要打开第10个抽屉但必须先清空前9个抽屉里的东西。怎么清空前9个方法其实和清空前10个一模一样只是规模小了1。于是你递归地把这个任务分解下去直到问题小到可以直接手拿——这就是边界条件。汉诺塔和套娃的区别在于汉诺塔的递归不仅仅是“往里套一层”它还需要借助中转柱子调整位置所以初看起来比普通递归绕。但这恰恰是它作为递归入门题的价值它逼你放弃“跟着代码一步步执行”的执念转而相信宏观逻辑的正确性。你能证明“如果n-1个盘子的移动方案是合理的那么n个盘子的方案也合理”加上“1个盘子时方案显然合理”数学归纳法就保证了整个问题有解。1.3 这篇解读的核心目标我希望你读完这篇文章后能独立写出一份不参考任何资料的汉诺塔递归代码并且能口算出n3时每一步的移动顺序。要做到这一点光看代码是没用的后面我会一步步带你推演包括递归调用栈的内部变化。2. 递归解法的思路拆解从“移动3个盘子”推到“移动n个盘子”2.1 从小规模开始找规律先动手推一下最简单的情况。只有1个盘子时直接让它从A到C就完了这是边界条件。2个盘子呢不能直接把大盘子从A搬到C因为小盘子还压在上面。所以要先让小盘子从A到B腾位置然后大盘子从A到C最后小盘子从B到C。3个盘子稍微复杂一点但你可以按同样的逻辑推先把上面2个小盘子作为一个整体从A挪到B借助C然后第3个盘子最大的那个从A到C最后把2个盘子的整体从B挪到C借助A。你有没有发现这里出现了非常清晰的模式移动n个盘子的任务被拆成了三步——移动上面的n-1个盘子从起点柱到中转柱。移动最大的第n个盘子从起点柱直接到目标柱。移动那n-1个盘子从中转柱到目标柱。而且第1步和第3步本质上都是“移动n-1个盘子”的子问题只是柱子的角色发生了轮换。这就是递归的核心形态每一步都缩小问题规模直到缩小到1。2.2 为什么递归解法里“柱子角色”会变来变去这是初学者最容易懵的地方。很多人看了网上的代码发现调用参数里A、B、C的位置换来换去直接就晕了。其实核心逻辑非常朴素在函数hanoi(n, source, target, auxiliary)中source是这轮任务的起点target是这轮任务的目标auxiliary是辅助柱子。当你处理子任务时上一轮的辅助柱可能变成下一轮的起点或目标柱。我拿“把n-1个盘子从A移到B”举个例子。这一步的目标柱是B起点是A剩下的C是辅助。但这个动作本质上是“把一个规模为n-1的汉诺塔问题从A搬到B”它完全可以用同一个函数来描述只是传入参数不同。参数位置的变换不是在炫技而是在表达“角色轮换”这件事。你要克服的心理障碍就是不要死盯着A、B、C这三个字母而要看清楚每一轮是谁在当起点、谁在当终点、谁在打下手。2.3 递归的出口和调用关系类比成传话游戏递归的执行顺序其实很像一个传话游戏。老大调用第一层函数说“我要把10个盘子从A移到C但我懒得细想让我的下属去处理前9个。”下属又把这个任务拆解继续往下传。直到传到最底层的小弟他发现手上只有1个盘子不用再往下传了直接执行移动动作。执行完返回上一层上一层再执行自己那一步也就是移动最大的那个盘子然后命令下一层继续。你不需要关心每层“具体怎么移动”你只需要让每一层严格按“先移上面再移自己最后把上面移回来”的流程走。只要这个流程正确所有层都按它执行整体结果必然正确。这就是递归的信仰之跃你在写n层时可以完全信任n-1层的正确性。3. Python代码实现与逐行解读3.1 一份可以直接跑的完整代码下面是经典的汉诺塔递归代码配合计数器统计移动次数逻辑非常直观。count 0 def hanoi(n, source, target, auxiliary): global count if n 1: count 1 print(f第{count}步将盘子1从{source}移动到{target}) return hanoi(n - 1, source, auxiliary, target) count 1 print(f第{count}步将盘子{n}从{source}移动到{target}) hanoi(n - 1, auxiliary, target, source) if __name__ __main__: n 3 print(f---- 移动{n}个盘子的完整步骤 ----) hanoi(n, A, C, B) print(f总计需要{count}步)运行这段代码输出如下---- 移动3个盘子的完整步骤 ---- 第1步将盘子1从A移动到C 第2步将盘子2从A移动到B 第3步将盘子1从C移动到B 第4步将盘子3从A移动到C 第5步将盘子1从B移动到A 第6步将盘子2从B移动到C 第7步将盘子1从A移动到C 总计需要7步看起来很简单对吧但很多人会问为什么第一步不是“盘子3从A到C”原因很简单因为代码里第一条递归调用hanoi(n - 1, source, auxiliary, target)它会一直向下递归直到n1才执行第一次实际移动。也就是说程序运行时不是按人类“先最大化”的思维来而是按递归层层下探的顺序来。理解这一点你就成功了一大半。3.2 逐行拆解代码的每一个细节先看函数定义hanoi(n, source, target, auxiliary)。n表示本轮要移动的盘子数量。source是起点柱。target是目标柱。auxiliary是辅助柱。注意这里的“起点、目标、辅助”是相对当前这一轮递归而言的不是固定的A、B、C。第一轮调用时起点是A目标是C辅助是B。但递归进入深一层后这些角色会被重新分配。边界条件if n 1是整个递归的锚点。没有这个判断函数会无限调用自己最终栈溢出程序直接崩溃。它代表“最小子问题”一个盘子不需要借助任何中转直接从起点挪到目标。我实际测试过如果你把n 1误写成n 0程序会在传参为1时直接跳过打印然后继续递归负数最终爆栈。函数体的核心是三条语句hanoi(n - 1, source, auxiliary, target)把上面n-1个盘子从source移到auxiliary借助target。print把最大的第n个盘子从source直接移到target。hanoi(n - 1, auxiliary, target, source)把刚才暂存在auxiliary上的n-1个盘子移到target借助source。这三条语句的顺序绝对不能变。如果先打印再递归逻辑立刻就不对了。很多人调试时喜欢顺手改顺序看效果我劝你别这么干因为大概率会把自己绕晕。正确的顺序意味着先把压在底下的盘子上面的所有盘子搬开再动最大的盘子再把搬开的盘子归一。3.3 递归调用栈函数到底是怎么“记事儿”的程序在运行递归时每调用一次自己当前函数的所有局部变量就会压入调用栈等子调用返回后再从栈顶恢复现场继续执行。这个概念比较抽象我举个具体例子。假设n3函数第一次进入hanoi(3, A, C, B)。系统会记录当前帧n3, sourceA, targetC, auxiliaryB然后进入第一条递归调用hanoi(2, A, B, C)。进入n2的这帧系统又记录n2, sourceA, targetB, auxiliaryC然后再次进入递归调用hanoi(1, A, C, B)。此时n1边界条件触发打印“盘子1从A到C”然后逐层返回。返回过程很关键。返回到n2那帧后函数继续往下执行第二行代码——打印“盘子2从A到B”。接着进入第三行递归hanoi(1, C, B, A)内部打印“盘子1从C到B”然后返回n2这一帧执行完毕再返回到n3那一帧。这时n3的帧才继续执行它的第二行代码——打印“盘子3从A到C”。你可以看到每一帧的执行都是以“递归进去→回来→继续下一句”的方式推进。这就是为什么结果里最大盘子的移动会出现在第4步而不是第1步。理解调用栈是这个教程里最值得花时间的部分你可以自己在纸上写一遍帧的进出过程写通了递归就不再神秘。3.4 关于计数器和全局变量的补充上面代码里用了一个全局变量count来统计步数。这在实际工程里不算优雅因为全局变量在多人协作或重复调用时容易出问题。如果你在写LeetCode或面试题更推荐把步数作为返回值返回。但作为教学演示全局变量是最直观的它能让你一眼看到总共移动了多少次。如果你不想用全局变量可以这样改造def hanoi(n, source, target, auxiliary): if n 1: return 1 steps hanoi(n - 1, source, auxiliary, target) steps 1 steps hanoi(n - 1, auxiliary, target, source) return steps这个版本完全没有打印只返回移动次数。它的逻辑和打印版一模一样只是把“移动”这一动作抽象成“计数”。两种写法各有适用场景打印版适合初学者观察路径计数版适合算法题。4. 递归执行过程可视化三步推演法彻底搞懂执行顺序4.1 用“n3”手动推演一遍完整过程纸上推演是最好的学习方法超过看视频。你拿支笔画三根柱子然后跟着下面的步骤走一次。初始状态A柱从上到下是1、2、3B空C空。第一步进入hanoi(3, A, C, B)。第一步调用hanoi(2, A, B, C)里面又调用hanoi(1, A, C, B)。此时n1执行打印“盘子1从A到C”。移动后A柱还有2、3C柱有1B空。返回后hanoi(2, A, B, C)继续执行打印“盘子2从A到B”。移动后A柱有3B柱有2C柱有1。然后hanoi(2, A, B, C)调用hanoi(1, C, B, A)。这个调用里n1执行打印“盘子1从C到B”。移动后A柱有3B柱从上到下是1、2C空。到这里hanoi(2, A, B, C)执行完毕返回到hanoi(3, A, C, B)。继续执行打印“盘子3从A到C”。移动后A柱空B柱从上到下是1、2C柱有3。接着hanoi(3, A, C, B)调用hanoi(2, B, C, A)。这个调用内部先hanoi(1, B, A, C)打印“盘子1从B到A”。移动后A柱有1B柱有2C柱有3。返回后hanoi(2, B, C, A)打印“盘子2从B到C”。移动后A柱有1B柱空C柱从上到下是2、3。最后hanoi(2, B, C, A)调用hanoi(1, A, C, B)打印“盘子1从A到C”。移动后A柱空B柱空C柱从上到下是1、2、3。你发现没有整个推演过程其实就是在重复“把上面n-1个盘子搬走→搬最大的→把n-1个搬回来”这三板斧只是柱子角色在不断互换。你自己推一遍之后再看代码的感觉会完全不一样。4.2 让代码帮我们可视化加缩进观察递归深度如果你觉得手动推演还是费力可以在代码里加一层缩进参数让程序把递归深度打印出来。这样做的好处是你能直观看到“每一层递归到底压了多深”。def hanoi_visual(n, source, target, auxiliary, depth0): prefix * depth print(f{prefix}进入 hanoi(n{n}, {source} - {target}, aux{auxiliary})) if n 1: print(f{prefix} [移动] 盘子1: {source} - {target}) return hanoi_visual(n - 1, source, auxiliary, target, depth 1) print(f{prefix} [移动] 盘子{n}: {source} - {target}) hanoi_visual(n - 1, auxiliary, target, source, depth 1)运行后你将看到类似这样的输出。这种“进入→移动→进入→移动→返回”的结构其实就是递归调用的真实行为。你可以在本地跑一下把每一次缩进当作一层调用栈相信我跑完你对递归的理解会上一个台阶。4.3 如果你想看到动态过程可以试试动画如果你实在想看盘子动起来Python里有两个选择用turtle画板或者把状态打印成柱状图。turtle的代码略长但对建立直觉很有帮助。我简单说一下思路把三根柱子画在画布上每根柱子用一个列表存盘子编号用turtle的移动事件把盘子从一个列表移到另一个列表。实现动态模拟时你需要操作的数据结构其实和递归流程是对应的递归函数负责决定“哪个盘子从哪移到哪”动画层负责执行这个指令和刷新画面。这两层分离后代码反而更清晰。如果你暂时不想看动画完全不影响理解递归本身毕竟动画只是锦上添花。5. 常见错误与排查递归入门最容易踩的坑5.1 场景一n1时函数直接报错或什么都没打印如果边界条件写错了比如写成if n 0程序就会跳过n1时的打印继续带着n0往下递归最终因为n变成负数而无限递归。Python对递归深度有保护默认最大递归深度约1000层超过会抛RecursionError。但这个报错信息比较隐蔽很多人看到RecursionError第一反应是“代码死循环了”其实更常见的原因是边界条件没写对。排查方法很简单把n设为1、2、3分别跑一遍观察输出。n1必须只打印一步如果一步都没有或打印了多步边界条件多半有问题。5.2 场景二盘子顺序打印对了但步骤数不对如果你发现步骤数比预期多通常是把第三步的递归写错了比如把hanoi(n - 1, auxiliary, target, source)写成了hanoi(n - 1, auxiliary, source, target)。这两者看似差别很小但实际结果会完全不同。你可以这样检验n3时一共需要7步如果你算出来是9步或更多就把三个参数仔细对照一下重点看最后一个source参数是不是被错写成target。5.3 场景三试图“优化”递归结果越改越乱我见过不少人觉得递归太绕坚持要用非递归方式硬解汉诺塔。实现起来也不是不行但思路通常是用栈模拟递归或者用迭代算法规律。这么做确实能跑但作为初学阶段我强烈建议你先彻底吃透递归写法。因为汉诺塔的本质就是递归的教科书例证你绕开递归去学迭代等于放弃了这道题最大的价值。5.4 场景四大n时跑得很慢是不是代码写错了不是这是正常现象。汉诺塔的移动次数是2^n - 1n64时需要约1.8×10^19步这个数字大到任何计算机都无法在短时间内跑完。如果你用n64去测试程序会进入漫长等待然后你可能怀疑是死循环。实际上这就是指数爆炸的威力。初学者看到这里就懂了这类递归算法的时间复杂度是O(2^n)它注定只能用于演示小规模问题和理解递归思想不能用于大规模实际问题。5.5 我的独门排查小技巧如果你还是不确定自己的递归参数写对了没可以在每个递归调用前打印一行“即将调用hanoi(n-1, source, auxiliary, target)”这样的日志。跑完一次后对比标准答案所有不一致的地方都会暴露出来。另一个技巧是给每个盘子编号并打印它当前的柱子状态虽然这样代码会多几行但对“这步到底移动了谁”的确认非常有帮助。实测下来这两个技巧能解决九成以上初学者在汉诺塔上的困惑。6. 从汉诺塔出发看递归在真实项目中的样子6.1 为什么汉诺塔是最经典的递归入门题一个算法题能活这么多年不只是因为它有趣更因为它把递归的三大要素——边界条件、递归调用、问题分解——全部浓缩在一个极其简洁的问题里。你一旦真正理解了汉诺塔再看经典的阶乘、斐波那契、二叉树遍历、归并排序都会觉得一目了然。因为它们本质上共享同一套思维模板。我记得自己当年第一次写出汉诺塔代码并跑通时激动得不亚于解出一道竞赛题。那种“原来递归是这样运作的”的感觉会成为你编程思维的一个重要拐点。很多后续学习中的递归场景比如目录树的遍历、JSON的深层解析、分治算法的实现都建立在对汉诺塔这种递归模型的理解之上。6.2 汉诺塔在工程应用中的直接案例有人觉得汉诺塔这种纯数学问题只存在于课本里其实不然。在计算机科学中汉诺塔的移动策略和“递归下降”的过程控制逻辑高度相似。比如在文件系统操作中你要递归地删除一个目录思路就是先递归处理子目录再删除当前目录这和汉诺塔里“先移动上层再移动底层”如出一辙。再比如在图形学中的场景树渲染也是先递归渲染子节点再渲染当前节点。掌握了汉诺塔的递归思维你会在很多工程模块里看到它的影子。6.3 汉诺塔的变体问题先把基础打牢再拓展网上还有四柱汉诺塔、彩色汉诺塔、双色汉诺塔等变体这些题目在一些算法竞赛中出现过。不过我的建议是在你彻底理清三柱问题之前不要急着碰变体。三柱问题是其他所有变体的基础参数传递、边界条件、递归分解的逻辑完全一致。等你把基础的代码写到“闭着眼都能默写”的程度再去研究变体才有意义。我在实际教学中总结出一个经验能自己画图推演一遍n3的人比直接看10遍视频教程的人理解更深。因为推演的过程迫使你直面递归调用顺序而不是被动接受别人的结论。这个习惯在你以后学更复杂的算法时会一直受益。6.4 理解递归后再回头看“栈溢出”问题递归虽然优雅但不是没有代价。每层递归都会消耗栈空间深度太大时程序会崩溃。汉诺塔问题本身极少遇到栈溢出因为盘子数不可能太大但它很好地帮助你理解“递归深度”这个限制的本质。在实际工程中遇到递归深度可能很大的场景时你应该考虑换成迭代或显式栈实现或者使用尾递归优化在部分语言中有效。Python本身不支持尾递归优化所以深度大的递归更要谨慎设计。但这不意味着你要害怕递归。绝大多数业务场景中的递归深度都在几十层以内比如一个嵌套的JSON对象、一个目录树完全在安全范围内。用递归来表达这类问题代码的可读性远高于手写栈的迭代版本。回顾我个人的实操经验学汉诺塔最忌讳的就是“死背代码”。你背下这一段把柱子名字改一改可能就晕了。真正有效的方法是忘掉代码拿一张纸画三根柱子从n2开始手动推推完n2推n3然后再看代码。这个流程我向很多人推荐过无一例外都反馈说“一下子通了”。最后再分享一个小技巧如果你在某个递归函数里觉得参数绕得头晕试着把所有柱子命名为source、target、auxiliary而不是A、B、C。因为前者表达的是“角色”后者只是“名字”——理解角色你就掌握了递归的精髓。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑