拓冰建站拓冰建站
首页 / 资讯中心 / 正文

汉诺塔与二叉树遍历:递归结构同构的深度解析

汉诺塔和二叉树遍历放在一起看才有意思。我最早是分别学的先是被汉诺塔那个“64片金盘”的故事带进门后来又在做树的题目时疯狂背三种遍历顺序。直到某天突然发现汉诺塔的递归过程展开以后画的递归树就是一棵二叉树而最优移动顺序恰好对应这棵树的中序遍历。那一刻我才真正理解什么叫“算法之间是相通的”。这篇报告我不打算只罗列代码和定义而是想踏踏实实把两件事讲透第一汉诺塔问题和二叉树遍历各自的原理、实现、细节第二也是最关键的它们为什么在递归结构上是同一件事。不管你是刚学数据结构的学生还是准备面试的开发者只要能把这两块内容串起来对递归和树的理解都会再上一个台阶。1. 汉诺塔与二叉树遍历先搞清楚它们分别是什么1.1 汉诺塔问题的起源与核心规则汉诺塔问题最早是法国数学家爱德华·卢卡斯在1883年提出的虽然经常被包装成“印度神庙里64片金盘”的故事但实际就是个经典的递归模型。三根柱子一根上面从小到大叠着n个圆盘目标是把所有圆盘从起始柱移动到目标柱过程中只能借助中间柱。这里有一个容易被忽略的关键规则大圆盘永远不能压在小圆盘上面。这意味着任意时刻每根柱子上的圆盘都必须保持“从下往上从小到大”的顺序。这个约束直接把问题的复杂度拉满——表面上是移动几个盘子实际上每一步的选择都受全局状态约束。我用最朴素的方式试过n4的情况手动移动了15次才完成。如果盘数增加到10已经需要1023次移动64片的话按每秒移动一次差不多要5849亿年。这个爆炸性增长本身就是递归深度和指数复杂度的直观体现。在算法题里汉诺塔几乎不会考你真实移动盘子而是考三点能否写出递归表达式、能否分析出时间复杂度、能否理解递归调用树的结构。1.2 二叉树的遍历方式概述二叉树的遍历指的就是按照某种顺序把树中每个节点访问一遍且每个节点恰好访问一次。四种主流方式是前序遍历根左右、中序遍历左根右、后序遍历左右根、层序遍历按层从左往右。前三个统称深度优先遍历区别只在访问根节点的时机先访问根就是前序左子树全部访问完再访问根就是中序左右子树都访问完才访问根就是后序。层序遍历则是广度优先先处理同一层的所有节点再处理下一层。这三种深度优先遍历都可以用递归实现而且代码结构几乎一模一样只是输出语句的位置不同。这个“同一段代码换个位置就是另一种遍历”的特点和后文汉诺塔的递归结构会产生一个非常巧妙的对应关系。如果不理解这个本质纯靠背代码很容易一换题目就懵。1.3 为什么要把两个问题放在一起分析单独看汉诺塔是递归分治的典型代表二叉树遍历是树结构的基础操作两者都能单独写出一大堆题目。但它们有一个共同的底层骨架递归。汉诺塔的递归函数每次调用都会“分裂”成两个规模更小的子问题加上一次当前盘子的移动二叉树的递归遍历每次处理节点时也“分裂”成左子树和右子树两个子问题加上一次根节点的访问。这种结构上的同构性决定了汉诺塔的递归过程可以自然映射到一棵二叉树的中序遍历上。弄懂这个关联最直接的好处有三个一是汉诺塔不用死记代码了理解了中序遍历自然就能写二是二叉树的遍历顺序不再靠背而是理解根节点的访问时机三是以后再遇到新问题会主动去找它底层是不是也长着同一棵“递归树”。2. 汉诺塔问题的递归拆解与算法实现2.1 分治思想如何一步步拆出递归式汉诺塔的核心拆法是这样要把n个盘子从A柱移到C柱借助B柱。先别想一次性搞定问自己一个问题最大的那个盘子怎么才能从A移到C答案是A柱上其余n-1个盘子必须全部先挪到B柱把A柱空出来。等最大盘到了C柱再把B柱上的n-1个盘子通过A柱挪回C柱。这个瞬间就把问题拆成了三步第一步把上面n-1个盘子从A挪到B以C为辅助第二步把第n个盘子从A挪到C这是单次操作第三步把n-1个盘子从B挪到C以A为辅助。注意第一步和第三步本质上还是“汉诺塔问题”只是盘的规模从n变成了n-1目标柱和辅助柱换了一下。这就符合递归成立的核心条件问题能用同一种方式不断缩小规模而且存在一个不需要递归的边界情况n1时直接移动。我用代码表示就是下面这样的结构。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)2.2 最小规模问题的手动推演拿n3来手动跑一遍整个递归过程你会发现规律特别清楚。三个盘子从小到大编号为1、2、3起始都在A柱目标C柱。hanoi(2, A, B, C)先处理两个盘子的子问题。hanoi(1, A, C, B)把1号盘从A移到B。把2号盘从A移到C。hanoi(1, B, A, C)把1号盘从B移到C。把3号盘从A移到C。hanoi(2, B, C, A)处理另外两个盘子的子问题。hanoi(1, B, C, A)把1号盘从B移到A。把2号盘从B移到C。hanoi(1, A, B, C)把1号盘从A移到C。总共7次移动顺序是1 A→B2 A→C1 B→C3 A→C1 B→A2 B→C1 A→C。把这个序列映射成树以后就是一棵高度为3的满二叉树的中序遍历。“在子树根节点输出”相当于“移动当前最大的盘”而递归进入左子树相当于“先移开上层盘子”递归进入右子树相当于“移完最大盘以后再安置上层盘子”。2.3 为什么递归在这里几乎是最优表达汉诺塔本质上是“无穷嵌套的子任务链”。如果用迭代来写要么需要自己维护一个栈来模拟递归调用要么借用二进制计数的规律第n个盘子移动到哪根柱子跟n位二进制的变化有关。说实话这两种都不如递归直观。递归版本能把“拆问题”的逻辑原样映射成代码移动n个盘子的方案就是先移动n-1个盘子的方案再移动当前盘再移动n-1个盘子的方案。这种表达方式不需要你显式记录状态函数调用栈天然帮你存好了每一步的现场。它最大的缺点是n稍微大一点栈的压力就上来了——但是汉诺塔这种问题本来就不可能追求“移动更少次数”所以栈深一点也无所谓关键是逻辑清晰。从算法分析的角度看每次递归调用都分出两个规模为n-1的子问题外加一次常数时间的移动操作所以递推式是T(n)2T(n-1)1解出来T(n)2^n-1时间复杂度O(2^n)。空间复杂度则是递归深度O(n)。我在面试时问过不少人他们能写出递归代码但答不上来为什么是2^n-1说明对递推式的推导还不够熟练这恰恰是考官最爱追问的点。2.4 移动次数公式与数学归纳法最少移动次数公式为2^n-1。为什么一定是最少可以用数学归纳法严格证明当n1时需要1次假设nk时最少需要2^k-1次那么nk1时最底下的第k1个盘子要动必须先移开上面k个盘子这至少需要2^k-1次最大的盘子从A到C需要1次然后再把k个盘子从辅助柱移到目标柱又是至少2^k-1次。合计2^(k1)-1次。每一步都是“至少”所以总次数就是下界而且上面的递归方案恰好达到这个下界。这个下界推导很有意义。它告诉我们在最优解的情况下移动总次数只取决于盘数跟具体移动策略无关。这也是为什么考试题经常问你“某个盘移动了多少次”而不是“所有盘怎么移动”——前者可以直接从递归结构推后者用程序跑反而简单。3. 二叉树遍历算法的核心细节解析3.1 三种深度优先遍历的本质区别前序遍历、中序遍历、后序遍历的递归实现只是输出位置不同。我用一个通用代码框架来说明def dfs(node): if node is None: return # 前序输出写在这里访问根节点 dfs(node.left) # 中序输出写在这里左子树访问完后访问根节点 dfs(node.right) # 后序输出写在这里左右子树都访问完后访问根节点很多初学者会疑惑为什么同样是递归输出位置变了整个序列就完全不一样关键在于递归的执行顺序调用dfs(node.left)会一口气把左子树全部处理完然后才返回。所以前序是“先看根再一口气看左子树最后看右子树”中序是“先看左子树再回头看根最后看右子树”后序则是“左子树、右子树都完事最后才看根”。这个顺序在计算表达式树、求树深度、做序列化时特别重要。比如后缀表达式求值用的就是后序遍历二叉搜索树的中序遍历结果是递增序列这个性质在判断BST合法性时很常用。我自己的经验是写递归遍历时脑子里要有一棵具体的树每走到一个节点就现实地问一句“现在输出它还是先往下走”。3.2 迭代遍历如何显式模拟递归栈递归的本质是函数调用栈。如果不想让递归层数太深或者面试官要求不用递归就需要手动维护一个栈。以前序遍历为例最直观的思路是先把根节点入栈然后循环弹出栈顶节点并访问再把右孩子先入栈、最后左孩子入栈因为栈是后进先出左孩子后进才能先出。中序遍历的迭代就稍微麻烦一点因为要先一路向左走到最左下角过程中把所有左孩子压栈直到左孩子为空再弹出栈顶节点访问然后把指针移到右子树继续这个过程。很多人在这一步写错了卡在“什么时候入栈、什么时候出栈”上。我的经验是记住一句话左路走到底弹栈即访问然后转向右子树。后序遍历的迭代更麻烦一个通用办法是用两个栈或者用“前序遍历的变体”先访问根再右孩子、左孩子入栈即前序遍历的镜像最后把结果反转。这个办法理解成本低我自己实战中更常用。3.3 层序遍历与队列的捆绑关系层序遍历是广度优先搜索在二叉树上的直接应用核心数据结构是队列。算法流程很固定根节点入队然后循环只要队列不空先记录当前队列长度这个长度就是这一层的节点数然后逐个出队访问再把它们的左右孩子依次入队。为什么要先记录当前队列长度因为队列尾部会不断加入下一层的节点如果不固定本轮要处理的节点数就会把下一层的节点也当成当前层一次性取出来层级就乱了。这个细节在普通二叉树层序遍历里表现得不明显但一旦涉及“按层输出二维数组”“求每层最大值”这类变形题不记录长度就很容易出错。from collections import deque def level_order(root): if not root: return [] res [] queue deque([root]) while queue: level_size len(queue) current_level [] for _ in range(level_size): node queue.popleft() current_level.append(node.val) if node.left: queue.append(node.left) if node.right: queue.append(node.right) res.append(current_level) return res层序遍历最典型的应用场景是求二叉树的最大宽度、判断完全二叉树、在序列化与反序列化时保持树的结构。它和DFS系列遍历形成互补DFS适合“沿一条路径深挖”的问题BFS层序适合“按层扩散”的问题。3.4 由遍历序列还原二叉树的方法给定前序序列和中序序列能够唯一确定一棵二叉树。思路是前序序列的第一个元素是根节点在中序序列中找到这个根节点左边是左子树的中序序列右边是右子树的中序序列再根据左右子树的长度把前序序列剩下的部分切成左子树的前序序列和右子树的前序序列然后递归还原。同理后序序列加中序序列也可以唯一确定二叉树区别只是根节点在后序序列的最后一位。但是仅给定前序序列和后序序列一般不能唯一确定一棵二叉树。因为这种情况下你只知道根节点无法区分左右子树边界。我遇到过不少面试题专门拿这个做文章问你“能否唯一确定”答案是否定的能举出反例即可。在动手写还原的时候最需要小心数组索引。我给的模板是左闭右开区间递归函数里维护四个索引pre_left、pre_right、in_left、in_right。中间还要用一个哈希表记录中序序列中每个值的下标这样找根节点位置只需O(1)。曾经我因为索引边界少减了1调试了一下午后来总结出来写这类递归时先在草稿纸上画清楚区间划分再下笔能省很多时间。4. 汉诺塔移动序列与二叉树遍历的深层同构4.1 汉诺塔递归调用展开后就是一棵二叉树前面第2节手动推演了n3的情况。如果把整个递归过程画成一棵树每个节点代表“移动第k个盘子”这个操作树的左分支是“移动k-1个盘子的子问题”右分支也是“移动k-1个盘子的子问题”这个结构自然就是一棵满二叉树高度为n。以n3为例根节点是“移动3号盘”左子树是“把1、2号盘从A挪到B”的完整过程右子树是“把1、2号盘从B挪到C”的过程。再看左子树的根节点是2号盘从A到C它的左孩子是1号盘从A到B右孩子是1号盘从B到C。整棵树的结构非常规整。这个递归调用树和普通二叉树遍历的递归调用树没有本质区别。每次函数调用进入一个“节点”然后分别进入左子问题和右子问题只不过二叉树遍历的节点是数据结构里真实存在的节点而汉诺塔的节点是“某一次移动操作”。这个映射是理解两者统一性的关键。4.2 中序遍历如何对应汉诺塔的最优移动路径把汉诺塔的递归调用树做中序遍历你会得到一个很神奇的序列每一个节点“被访问”的顺序恰好就是整个汉诺塔最优解中“移动对应盘子”的顺序。具体来说中序遍历的顺序是先遍历左子树也就是“先把上面n-1个盘子移到辅助柱”再访问根节点也就是“把最大的盘子移到目标柱”最后遍历右子树也就是“再把n-1个盘子移到目标柱”这和汉诺塔的算法步骤完全一致。这不是巧合而是因为两者的递归定义是一样的汉诺塔的“移动n个盘子”由“移动n-1个盘子 移动最大盘 移动n-1个盘子”组成二叉树中序遍历的“遍历一棵树”由“遍历左子树 访问根 遍历右子树”组成。两者都是典型的“左中右”三段式结构。如果你想用代码视角来看这一层联系可以这么理解。中序遍历访问节点的位置对应汉诺塔中“移动第k号盘”的位置。k号盘在整个递归结构中的位置越是靠根被访问得越晚除右子树外越是靠叶子被访问得越早。这也是为什么1号盘总在最早几步就移动而最大的n号盘恰好在中序遍历的正中间才移动——这个直觉用中序遍历一画一目了然。4.3 把这种同构关系用到实际学习中的方法既然知道汉诺塔的递归树等价于二叉树那我们可以做三件很实用的事情。第一用二叉树的遍历框架来写汉诺塔。你已经不需要死记汉诺塔的代码只要写一个带“左右子树”的递归框架然后在“根节点访问”的位置打印移动信息就是汉诺塔的最优解。第二用汉诺塔的递归树来加深对遍历顺序的理解。反过来看如果你想跟别人解释什么叫中序遍历完全可以用汉诺塔举例子三根柱子之间移动盘子时总有一个“最重要的、最底下的盘子”它被移动的时刻在所有子问题都完成、所有上层盘子都各就各位之后这正是“中序”的含义。第三在算法设计中遇到“规模为n的问题能拆成两个规模为n-1的子问题再加一步操作”的时候就直接画一棵二叉树来分析。这种结构不仅出现在汉诺塔也出现在快速排序的递归过程以pivot为根、归并排序的分治过程以merge为根等场景中。4.4 二叉树的深度与汉诺塔盘数二叉树的深度指的是一条从根节点到叶子节点的最长路径经过的节点数。汉诺塔递归调用树的高度恰好等于盘子的数量n。这对理解递归深度很有帮助汉诺塔的问题规模n每增加1递归树的高度就增加1最终移动次数却翻倍多1。这个“深度小时指数爆炸”的特性其实是递归算法的普遍规律。如果递归过程中每个问题都拆成两个子问题树的高度是n总节点数就是2^n级别。这能解释为什么很多递归算法在n30左右就明显变慢n50就直接卡死。做ACM题或者刷LeetCode时遇到O(2^n)的递归一定要敏感要考虑剪枝、记忆化或者换迭代动规方案。反过来二叉树的遍历之所以代价相对“温和”是因为每个节点只访问一次总复杂度是O(n)。递归深度可能达到树高h最坏情况下链状树hn这时同样可能出现栈溢出风险。5. 实操中的常见问题与排查技巧5.1 递归栈溢出问题怎么处理汉诺塔随着n的增长递归深度线性增长。n1000时递归深度就是1000很多语言默认栈大小并不够用。Python里我实测过默认递归深度限制在1000左右如果跑n1001以上的汉诺塔不手动改recursionlimit就会直接报RecursionError。二叉树同理如果一颗树退化成了链表形状比如只有右孩子递归深度就变成n也可能栈溢出。解决思路有三个层次第一个层次是调大递归限制比如Python的sys.setrecursionlimit(10000)但这只是延后问题治标不治本第二个层次是把递归改成显式栈迭代比如二叉树的迭代遍历汉诺塔较麻烦但也可以手动模拟递归栈第三个层次是如果问题本质允许不要用深度递归模型比如层序遍历天然就是迭代模型完全不会栈溢出。我个人的建议是面试和竞赛里优先说清楚递归版本再补一句“如果要避免栈溢出可以改成迭代”然后快速实现迭代版这样最能体现对递归的理解深度。5.2 遍历顺序记混了怎么办很多学习者会混淆前序、中序、后序的根节点位置。我给自己的口诀是“根在左、根在中、根在右”。前序的根在最左边中序的根在中间后序的根在最右边。这个“根的位置”决定了整个遍历序列的特征。还有一个更实用的验证方法拿一棵具体的完整二叉树手动走一遍。比如根是1左孩子是2右孩子是32的左孩子是43的右孩子是5。手动按定义写出前序12435、中序42135、后序42531再对照程序输出。只要验证时能卡住说明理解没问题。我还发现一个常见错误是把中序遍历理解成“左根右”但写成“根左右”的顺序。这个问题的根源在于混淆了“访问”和“递归调用”的关系。访问是输出当前节点递归是去处理子树。真正的执行顺序是先递归进左子树——这个过程可能包含很多次输出——然后输出当前节点再递归进右子树。不是“先输出再递归”。5.3 层序遍历代码的常见边界问题层序遍历最常见的问题有三个。第一个是没有记录level_size导致一次弹出了超过一层的节点。结果就是返回的“层”里混了多个层的数据。第二个是队列操作方向搞反。用Python的deque弹出左侧要用popleft从右侧入队用append。如果习惯用list模拟队列pop(0)的时间复杂度是O(n)性能很差deque.popleft()才是O(1)。面试时如果现场实现我建议直接用deque。第三个是没有提前处理空树。if not root: return []这一行必不可少很多新手的代码在空树上直接报错原因就是访问了None的val属性。5.4 还原二叉树时的索引边界问题根据前序中序序列还原二叉树时最容易错的是递归区间的划分。假设前序序列为pre中序序列为inorder中序序列中根节点位置为index。左子树的中序区间是[in_left, index)右子树中序区间是[index 1, in_right)。左子树的节点数量left_size index - in_left。前序序列中根节点在pre_left位置那么左子树的前序区间就是[pre_left 1, pre_left 1 left_size)右子树的前序区间是[pre_left 1 left_size, pre_right)。我之前在这里反复翻车最后养成了一个习惯每次递归前先手动算一个n3小例子的所有索引。确认了区间再写代码基本一次就能过。如果你想加强这类递归的熟练度可以把汉诺塔的第k盘移动、二叉树的还原、快速排序的partition三个题目放在同一天刷。它们共享同一个递归拆分的思维模型刷一遍顶三遍。5.5 如何验证算法实现的正确性算法写完之后除了靠眼睛看我还会做三类验证。第一类是边界验证。n0或空树时代码能不能正常返回n1或只有一个节点的树呢很多隐藏bug在边界情况下会立刻暴露出来。第二类是序列验证。汉诺塔可以用两个规则验证每根柱子上任意时刻大盘不能在盘上最终所有盘都在目标柱上且顺序正确。我给汉诺塔代码加过一段校验函数每次移动后检查所有柱子的状态如果有非法状态就立即报错。第三类是结果对照。二叉树遍历可以用已知小树的结果手动对照也可以把递归版本和迭代版本都写了拿随机生成的大树跑一遍比对两次输出是否一致。这个方法对排查迭代遍历里的逻辑错误特别有效。写在最后的实践经验上面这些内容我前前后后折腾了小半个月才真正消化。最大的体会是不要把汉诺塔和二叉树遍历当成两个孤立的题目去背它们背后那棵“递归树”才是真正的主角。当你看着汉诺塔的递归调用栈一层层展开再对比二叉树中序遍历的调用栈会发现它们几乎长得一模一样这种“Aha moment”比刷十道题都有价值。如果让我给一个学习顺序的建议先从n1、2、3的汉诺塔手动推演开始画出递归调用树然后写一个最基本的二叉树中序遍历递归代码把输出顺序和汉诺塔的移动顺序逐一对上接着尝试用队列实现层序遍历感受递归和迭代、DFS和BFS两种维度的区别最后再挑战根据遍历序列还原二叉树把索引边界的功力练扎实。这四步走完你在递归、树、搜索这些领域的底子就真正稳了。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门