公司动态
二叉树遍历全解析:从递归到莫里斯,算法面试核心考点
1. 项目概述为什么二叉树遍历是算法面试的“定海神针”如果你正在准备技术面试尤其是那些以算法考察闻名的公司那么“二叉树”这三个字绝对是你绕不开的核心考点。而前序、中序、后序遍历则是打开二叉树所有问题大门的三把钥匙。我见过太多候选人在链表、数组问题上对答如流但一遇到稍微复杂点的二叉树问题思路就开始混乱。究其根本往往是对这三种基础遍历方式的理解不够透彻无法在它们的基础上进行灵活变通。这份笔记不是一份简单的代码罗列。它是我自己从零开始刷LeetCode上数百道二叉树相关题目后沉淀下来的系统性思考和实战心得。我们会彻底搞懂这三种遍历方式“是什么”、“为什么”以及“怎么用”。你会发现无论是求深度、找路径、验证性质还是进行序列化、构造二叉树其底层逻辑都离不开对这几种遍历的深刻理解。掌握了它们你就相当于掌握了二叉树问题的“元技能”面对再复杂的题目也能快速拆解找到解题的突破口。2. 核心概念与递归实现理解遍历的本质在深入代码之前我们必须先建立清晰的图景。所谓遍历就是按照某种规则不重复地访问树中的所有节点。这个“规则”就是访问“根节点”、“左子树”、“左子树”、“右子树”这三者的顺序。2.1 三种遍历的直观定义与记忆技巧你可以把每个二叉树节点看作一个需要完成的小任务这个任务包含三个子动作访问自己D、处理左子树L、处理右子树R。前序遍历Preorder根 - 左 - 右。口诀“先访问自己再处理孩子”。就像你进入一个部门先拜访经理根然后去他的左下属办公室最后去右下属办公室。在代码中“访问”通常意味着将节点的值加入结果列表。中序遍历Inorder左 - 根 - 右。口诀“先左孩子再自己最后右孩子”。这对二叉搜索树BST有特殊意义因为BST的中序遍历结果是一个升序数组。想象一下你只有拿到左下属的报告左子树才能向经理汇报访问根然后经理再根据你的汇报去处理右下属的事务右子树。后序遍历Postorder左 - 右 - 根。口诀“先处理完所有孩子再访问自己”。这常用于一些需要先子节点后父节点的计算比如计算子树的高度、释放二叉树内存。就像项目经理必须等左、右两个子项目都完成后才能进行整体的验收访问根。一个非常实用的记忆方法是“前、中、后”指的是“根节点”被访问的时机。前序就是最先访问根中序就是中间访问根后序就是最后访问根。记住这一点定义就永远不会混淆。2.2 递归实现最符合思维直觉的写法递归实现这三种遍历代码结构高度统一极其优雅也最符合我们对遍历过程的定义。# Definition for a binary tree node. class TreeNode: def __init__(self, val0, leftNone, rightNone): self.val val self.left left self.right right class Solution: def preorderTraversal(self, root: Optional[TreeNode]) - List[int]: result [] def dfs(node): if not node: return # 前序根 - 左 - 右 result.append(node.val) # 访问根 dfs(node.left) # 遍历左子树 dfs(node.right) # 遍历右子树 dfs(root) return result def inorderTraversal(self, root: Optional[TreeNode]) - List[int]: result [] def dfs(node): if not node: return # 中序左 - 根 - 右 dfs(node.left) # 遍历左子树 result.append(node.val) # 访问根 dfs(node.right) # 遍历右子树 dfs(root) return result def postorderTraversal(self, root: Optional[TreeNode]) - List[int]: result [] def dfs(node): if not node: return # 后序左 - 右 - 根 dfs(node.left) # 遍历左子树 dfs(node.right) # 遍历右子树 result.append(node.val) # 访问根 dfs(root) return result核心要点与避坑指南递归终止条件if not node: return这一行至关重要。它处理了空节点的情况是递归能够正确返回的保证。忘记它会导致无限递归和栈溢出。辅助函数与闭包我们在主函数内定义了一个dfs递归函数并利用闭包特性直接修改外层的result列表。这样做避免了在递归函数中频繁传递结果列表参数让代码更简洁。你也可以选择将result作为参数传递但闭包写法更常见。“访问”操作的位置仔细观察三种遍历的代码结构一模一样唯一的区别就是result.append(node.val)这一行代码的位置。这正是遍历定义的核心体现。在面试白板 coding 时你可以先写出递归框架然后根据题目要求像填空一样把“处理当前节点”的代码放到正确的位置。时间复杂度与空间复杂度递归遍历的时间复杂度是 O(N)因为每个节点恰好被访问一次。空间复杂度主要取决于递归调用栈的深度在最坏情况树退化成链表下为 O(N)平均情况下为 O(logN)。注意递归解法虽然直观但在面试中面试官往往会追问“能否用迭代非递归的方式实现” 这是因为递归调用栈可能很深在极端情况下有栈溢出的风险且迭代法更能体现你对遍历过程底层逻辑的掌控力。所以掌握迭代法是必须的。3. 迭代实现用栈模拟递归的调用过程迭代法的核心思想是用栈Stack这种数据结构来模拟系统递归调用栈的行为。我们需要手动管理节点的访问顺序。迭代法比递归法稍复杂但理解后对栈的应用会有质的提升。3.1 前序遍历的迭代实现前序遍历的迭代写法相对直接因为访问顺序和入栈出栈的顺序有很好的对应关系。def preorderTraversalIterative(root: Optional[TreeNode]) - List[int]: if not root: return [] result [] stack [root] # 初始化栈放入根节点 while stack: node stack.pop() # 弹出栈顶节点 result.append(node.val) # 访问它根 # 关键先右后左入栈保证出栈时是左先右后 if node.right: stack.append(node.right) if node.left: stack.append(node.left) return result为什么先右后左栈是“后进先出”LIFO的。我们希望访问顺序是“根-左-右”。所以当我们访问完根节点后下一个应该访问的是左孩子。为了能让左孩子先出栈就必须让它后入栈。因此先将右孩子入栈再将左孩子入栈。这样左孩子就在栈顶会先被弹出访问。3.2 中序遍历的迭代实现中序遍历的迭代法是面试常考点也是三者中最需要理解的一个。它的核心思路是用一个指针cur来模拟递归中的深入过程用栈来保存沿途需要“返回”时处理的节点。def inorderTraversalIterative(root: Optional[TreeNode]) - List[int]: result [] stack [] cur root # 当前考察节点 while cur or stack: # 注意循环条件节点未处理完或栈非空 # 一路向左将途径的所有节点入栈 while cur: stack.append(cur) cur cur.left # 此时cur为空说明已到达最左下方 # 弹出栈顶节点它就是当前应该访问的“根” node stack.pop() result.append(node.val) # 访问它 # 转向该节点的右子树开始新一轮“左探” cur node.right return result过程解析想象你拿着一根绳子指针cur从根节点开始拼命往左下方走每经过一个节点就用图钉栈把它钉在墙上。当你走到最左边无处可走时cur为None你回头拿下最近钉的那个图钉栈顶节点访问它这就是“左-根”的“根”。访问完后你看看这个节点的右边有没有路右子树如果有你就把绳子系到右边那个节点上重复“拼命往左走”的过程。这个过程完美模拟了递归的“深入左子树 - 返回处理根 - 深入右子树”。3.3 后序遍历的迭代实现后序遍历的迭代法有多种思路最经典的一种是利用前序遍历的变体。我们知道前序是“根-左-右”。如果我们能实现“根-右-左”的遍历然后将结果反转就得到了“左-右-根”也就是后序遍历。def postorderTraversalIterative(root: Optional[TreeNode]) - List[int]: if not root: return [] result [] stack [root] while stack: node stack.pop() result.append(node.val) # 将“访问”操作改为“收集”值 # 注意这里为了得到“根-右-左”入栈顺序是“先左后右” if node.left: stack.append(node.left) if node.right: stack.append(node.right) # 最后将结果反转 return result[::-1]另一种更通用的标记法上述取巧方法在面试时可能被要求写出更本质的解法。我们可以给每个节点附加一个状态标记其右子树是否已被访问。或者更简洁地采用双栈法或在节点入栈时同时入栈一个标记。这里介绍一种易于理解的“先右后左前序反转”法已足够应对大多数情况但知道有其他方法可以体现你的深度。实操心得在面试中如果被要求手写迭代遍历中序遍历是最高频的。务必理解其“左探-回溯-右转”的核心循环逻辑。对于前序和后序可以基于“修改前序顺序”的思路快速写出。向面试官说明不同方法的优缺点如后序的反转法空间复杂度仍是O(N)但代码简单能为你加分。4. 莫里斯遍历极致的空间优化无论是递归还是迭代我们都需要额外的空间栈来维护调用链或节点顺序。莫里斯遍历Morris Traversal的巧妙之处在于它利用树中大量的空指针实现了O(1) 额外空间复杂度的遍历真正做到了“原地”修改。其核心思想是在线索二叉树的概念基础上在遍历过程中将当前节点的前驱节点在中序遍历中前驱节点就是其左子树的最右节点的右孩子指针指向当前节点本身。这样就创造了一条从底层回溯到上层的“捷径”从而在遍历完左子树后可以不用栈就直接回到根节点。4.1 莫里斯中序遍历详解我们以中序遍历为例看看莫里斯算法如何工作。def inorderTraversalMorris(root: Optional[TreeNode]) - List[int]: result [] cur root # 当前节点 while cur: if not cur.left: # 情况1没有左孩子 result.append(cur.val) # 直接访问当前节点 cur cur.right # 转向右子树 else: # 情况2有左孩子 # 找到当前节点在中序遍历下的前驱节点 pre cur.left while pre.right and pre.right ! cur: # 关键走到最右且不能是自己 pre pre.right if not pre.right: # 情况2a第一次到达建立线索 pre.right cur # 将前驱的右指针指向当前节点建立线索 cur cur.left # 继续遍历左子树 else: # 情况2bpre.right cur说明左子树已遍历完线索已存在 pre.right None # 断开之前建立的线索恢复树的结构 result.append(cur.val) # 访问当前节点 cur cur.right # 转向右子树 return result步骤拆解与逻辑初始化cur指向根节点。如果cur没有左孩子那么它自己就是“最左”的节点直接访问它然后cur转向右孩子。如果cur有左孩子则找到cur在中序遍历下的前驱节点pre即cur左子树中最右边的那个节点。检查pre的右指针如果pre.right为空说明我们是第一次到达cur左子树还未被遍历。我们建立一条从pre回溯到cur的“线索”pre.right cur然后让cur深入其左子树cur cur.left。如果pre.right等于cur说明这条线索是我们之前建立的意味着cur的左子树已经被完整遍历过了。此时我们应该访问cur本身然后断开这条临时线索以恢复树的原始结构pre.right None最后让cur转向其右子树cur cur.right。这个过程就像是在树上拉了一条条临时的“绳梯”让你在深入左子树底部后能顺着绳梯爬回上一层节点而无需记住来时的路栈。4.2 莫里斯遍历的优缺点与应用场景优点空间复杂度O(1)这是最大的优势在内存严格受限的环境下非常有用。无需递归或栈避免了递归深度限制和栈空间开销。缺点修改了树的结构在遍历过程中会临时修改节点的右指针遍历结束后恢复。这是一个“只读”操作中的“写”操作在并发环境下需要加锁不够安全。逻辑复杂代码比递归和迭代法更难理解和调试。时间复杂度常数项较大虽然仍是O(N)但由于每个左孩子不为空的节点都会被访问两次一次建立线索一次断开线索并且寻找前驱节点需要额外的循环实际运行时间可能比简单的迭代法要长。应用场景在面试中通常不会要求手写莫里斯遍历但如果你能主动提及并解释其原理会是一个很大的亮点。它更适用于理论探讨、对空间有极端要求的嵌入式环境或者作为对二叉树遍历理解深度的考察。注意事项莫里斯遍历是一个“炫技”型的算法。在95%的日常编码和面试场景中使用清晰的递归或迭代法是完全足够且更可取的。除非面试官明确要求空间复杂度为O(1)否则优先选择更易读、易维护的写法。5. 遍历算法的实战应用与题目解析理解了遍历的“形”更要掌握其“神”。遍历不仅仅是输出一个序列更是一种强大的框架思维。许多二叉树问题都可以通过修改遍历算法来解决。5.1 应用一利用遍历特性解决问题前序遍历适合自上而下地处理问题或者需要在深入子树前获取根节点信息的场景。例题 LeetCode 226. 翻转二叉树 。在访问每个节点时交换其左右孩子即可。这天然适合前序或后序遍历。def invertTree(root: Optional[TreeNode]) - Optional[TreeNode]: if not root: return None # 前序位置交换左右孩子 root.left, root.right root.right, root.left invertTree(root.left) invertTree(root.right) return root中序遍历二叉搜索树BST相关问题的核心。BST的中序遍历是升序序列利用这个性质可以解决验证BST、在BST中寻找特定节点、恢复错误的BST等问题。例题 LeetCode 98. 验证二叉搜索树 。在中序遍历过程中记录前一个访问节点的值确保当前节点值大于前一个节点值。def isValidBST(root: Optional[TreeNode]) - bool: prev None # 记录中序遍历的前一个节点值 def inorder(node): nonlocal prev if not node: return True # 遍历左子树 if not inorder(node.left): return False # 访问当前节点检查是否大于前一个值 if prev is not None and node.val prev: return False prev node.val # 遍历右子树 return inorder(node.right) return inorder(root)后序遍历适合自下而上地处理问题需要子树的处理结果来计算父节点。很多涉及子树统计、状态汇总的问题都适合用后序遍历。例题 LeetCode 104. 二叉树的最大深度 。一个节点的深度等于其左右子树深度的最大值加1。这需要先知道子树的深度再计算当前节点深度是典型的后序遍历。def maxDepth(root: Optional[TreeNode]) - int: if not root: return 0 left_depth maxDepth(root.left) # 后序先获取左子树深度 right_depth maxDepth(root.right) # 后序再获取右子树深度 # 后序位置利用子树信息计算当前节点深度 return max(left_depth, right_depth) 15.2 应用二通过遍历序列构造二叉树这是一个经典且重要的题型考察你对遍历序列与二叉树结构的对应关系的理解。前序中序构造二叉树 LeetCode 105. 从前序与中序遍历序列构造二叉树 。核心思路前序遍历的第一个元素一定是根节点。在中序遍历中找到这个根节点其左侧序列就是左子树的中序遍历右侧序列就是右子树的中序遍历。根据左右子树的长度可以在前序遍历序列中划分出左右子树的前序遍历。然后递归构建。优化技巧为了快速在中序序列中定位根节点可以预先用哈希表存储值到索引的映射将查找时间从O(N)降到O(1)。中序后序构造二叉树 LeetCode 106. 从中序与后序遍历序列构造二叉树 。核心思路与上题镜像。后序遍历的最后一个元素是根节点。同样在中序中找到根节点划分左右子树然后递归。注意后序序列的划分依据。常见问题为什么前序后序不能唯一确定一棵二叉树 考虑一个简单的例子根节点为1只有一个孩子2。无论是左孩子还是右孩子其前序序列都是[1, 2]后序序列都是[2, 1]。因此无法区分。只有当二叉树是真二叉树每个节点有0个或2个子节点时前序后序才能唯一确定。5.3 应用三遍历框架解决复杂问题许多题目需要你在遍历过程中携带更多信息或进行更复杂的决策。这时可以将遍历框架作为一个回溯或DFS的框架来使用。例题 LeetCode 113. 路径总和 II 找出所有从根到叶子和为给定值的路径。思路采用前序遍历框架在访问节点时将节点值加入当前路径并更新剩余目标和。当到达叶子节点且目标和为0时记录路径。在递归返回前后序位置需要将当前节点从路径中移除以进行回溯。def pathSum(root: Optional[TreeNode], targetSum: int) - List[List[int]]: result [] path [] def dfs(node, remaining): if not node: return # 前序位置进入节点 path.append(node.val) remaining - node.val # 判断是否为叶子节点且满足条件 if not node.left and not node.right and remaining 0: result.append(path.copy()) # 注意添加副本 # 递归遍历左右子树 dfs(node.left, remaining) dfs(node.right, remaining) # 后序位置离开节点回溯 path.pop() dfs(root, targetSum) return result这里的dfs函数融合了前序记录路径和后序回溯的思想是遍历框架的灵活应用。6. 高频问题排查与性能优化技巧在实际刷题和工程中关于二叉树遍历你可能会遇到以下典型问题。6.1 递归导致的栈溢出当二叉树极度不平衡例如退化成一条链表且节点数量巨大时递归深度会达到O(N)可能引发“递归深度超过最大限制”的错误如Python的RecursionError。解决方案使用迭代法这是最根本的解决方法用显式的栈代替系统调用栈。尾递归优化某些语言如Scheme支持尾递归优化但Python官方解释器并不支持。所以对于Python而言此路不通。设置递归深度Python中可以用sys.setrecursionlimit(limit)提高递归深度限制但这只是权宜之计并不能解决深递归带来的性能隐患且可能引发系统不稳定。建议在LeetCode等平台做题时如果题目没有明确要求对于深度可能很大的树优先考虑迭代解法代码更健壮。6.2 迭代法中指针丢失与栈状态混乱在写迭代法中序遍历时一个常见的错误是循环条件或指针更新逻辑写错导致死循环或漏掉节点。调试技巧画图用一个小型二叉树如3个节点手动模拟代码执行过程画出每一步栈的状态和cur指针的位置。打印日志在循环关键点打印cur.val如果非空、栈内节点值帮助理解执行流程。牢记核心循环条件中序遍历迭代法的while cur or stack是精髓。cur非空意味着还有新的左分支可以探索stack非空意味着还有之前暂存的根节点需要回溯处理。两者缺一不可。6.3 空间复杂度分析与优化选择遍历方法时间复杂度空间复杂度优点缺点适用场景递归O(N)O(H)最坏O(N)代码简洁逻辑清晰递归深度受限可能栈溢出树深度不大代码可读性优先迭代显式栈O(N)O(H)最坏O(N)无递归深度限制更可控代码稍复杂通用场景推荐掌握莫里斯遍历O(N)O(1)常数额外空间修改树结构逻辑复杂空间极度受限或作为知识拓展选择建议面试与竞赛优先掌握递归快速实现和迭代中序常考。能口述莫里斯原理是加分项。生产环境如果树规模可控递归的简洁性是首选。如果树可能很深或不确定使用迭代法更安全。莫里斯遍历除非有明确的O(1)空间要求否则很少使用。6.4 处理空树与边界条件这是新手最容易出错的地方。永远记住在访问node.left或node.right之前检查node是否为空。在递归的基线条件if not node: return和迭代的初始判断if not root: return []中处理好空输入。二叉树遍历是算法大厦的基石之一其重要性怎么强调都不为过。它不仅仅是几行代码更是一种分治、递归和栈应用的经典范式。我个人的体会是初期要死记硬背三种遍历的递归和迭代写法做到肌肉记忆。中期要通过大量做题理解每种遍历适合解决什么问题比如前序适合“传递参数”后序适合“收集答案”。后期要能融会贯通看到问题就能识别出它本质上是哪种遍历的变体。最后别忘了多画画图把抽象的逻辑在纸上具象化这是理解一切树相关算法最有效的方法。当你不再害怕二叉树问题时你的算法能力就已经上了一个坚实的台阶。