ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

二叉树遍历全解析:前序、中序、后序与层序遍历的核心原理与代码实现

二叉树遍历全解析:前序、中序、后序与层序遍历的核心原理与代码实现 1. 从“遍历”说起为什么二叉树的操作离不开它如果你刚开始接触数据结构或者正在准备技术面试那么“二叉树”这个词你肯定不陌生。而提到二叉树几乎绕不开的就是它的“遍历”。你可能已经看过很多定义前序遍历、中序遍历、后序遍历。但你是否想过为什么我们要如此执着于研究这几种遍历方式它们到底解决了什么问题简单来说遍历就是系统地访问树中每一个节点并且每个节点只访问一次的过程。这听起来很简单但却是二叉树几乎所有高级操作的基础。想象一下你要在一棵家族树里统计总人数或者在一棵文件系统树里搜索某个特定文件又或者在一棵表达式树里计算整个表达式的值这些操作的第一步都是“走遍”这棵树。而“怎么走”——也就是遍历的顺序——直接决定了你访问和处理数据的逻辑。前、中、后序遍历就是三种最经典、最基础的“走法”。它们之所以重要不仅仅是因为面试常考更是因为它们在解决实际问题时各有妙用。比如前序遍历天然适合复制一棵树的结构中序遍历对于二叉搜索树来说能按升序输出所有节点后序遍历则在释放树的内存或计算目录大小等场景下非常高效。很多人刚开始学的时候容易把这三种遍历的顺序搞混或者只能死记硬背。这篇内容我们就来彻底拆解这三种遍历。我不会只给你干巴巴的递归代码而是会带你理解每一种遍历的核心视角和实际应用场景并给出递归与非递归迭代两种实现方式。更重要的是我会分享在真正编码和面试中如何清晰无误地推导出遍历顺序以及那些容易踩坑的细节。无论你是初学者想打牢基础还是面试者想巩固要点相信这篇内容都能给你带来实实在在的帮助。2. 理解三种遍历核心差异在于“根”的位置在深入代码之前我们必须从本质上理解这三种遍历命名的由来和它们之间的区别。这能帮你摆脱死记硬背真正掌握其逻辑。这三种遍历的名称——前序Pre-order、中序In-order、后序Post-order——其实描述的是在访问一个节点的左子树和右子树时何时访问这个节点本身根节点。我们可以把一个节点的访问操作记作VVisit处理左子树记作L处理右子树记作R。那么前序遍历 (Pre-order):V - L - R。先访问根节点然后处理左子树最后处理右子树。“前”意味着“根”在“前”。中序遍历 (In-order):L - V - R。先处理左子树然后访问根节点最后处理右子树。“中”意味着“根”在“中间”。后序遍历 (Post-order):L - R - V。先处理左子树然后处理右子树最后访问根节点。“后”意味着“根”在“最后”。这个VLR的顺序是核心。为了让你有更直观的感受我们来看一棵简单的二叉树A / \ B C / \ \ D E F对于这棵树前序遍历A - B - D - E - C - F从根A开始V然后遍历左子树以B为根的树最后遍历右子树以C为根的树。遍历左子树时同样遵循VLR访问BV遍历B的左子树D遍历B的右子树E。中序遍历D - B - E - A - C - F先遍历A的左子树以B为根的树然后访问AV最后遍历A的右子树以C为根的树。遍历左子树时遵循LVR先遍历B的左子树D访问BV再遍历B的右子树E。后序遍历D - E - B - F - C - A先遍历A的左子树以B为根的树然后遍历A的右子树以C为根的树最后访问AV。遍历左子树时遵循LRV先遍历B的左子树D再遍历B的右子树E最后访问BV。注意这里说的“处理左/右子树”指的是递归地以同样的遍历规则去访问那棵子树。理解这个递归过程是掌握遍历的关键。2.1 一个帮你永不记混的“可视化”技巧我刚开始学的时候也总记混。后来我发现一个非常有效的技巧在脑子里“走”过节点时想象自己站在每个节点上并且把每个节点“路过”三次。第一次路过从父节点过来准备进入左子树。此时如果执行访问操作就是前序。第二次路过从左子树返回准备进入右子树。此时如果执行访问操作就是中序。第三次路过从右子树返回准备回到父节点。此时如果执行访问操作就是后序。对于上面树的节点A前序访问A发生在第一次“路过”A时从虚拟的根上来。中序访问A发生在从左子树(B, D, E)返回后即将进入右子树(C, F)时。后序访问A发生在从右子树(C, F)返回后即将回到虚拟的根时。这个技巧能帮你从递归调用的堆栈角度理解访问时机对于后续理解非递归实现也大有裨益。3. 递归实现最直观的表达方式递归实现是描述树遍历最自然、最简洁的方式因为它直接对应了树的递归定义一棵树由根节点、左子树和右子树构成。我们先定义一个简单的二叉树节点类这是所有后续代码的基础。class TreeNode: def __init__(self, val0, leftNone, rightNone): self.val val self.left left self.right right3.1 前序遍历的递归实现前序遍历的顺序是VLR。递归函数preorderTraversal接收一个根节点root。如果节点为空直接返回递归基。否则执行以下三步访问当前节点例如将节点值加入结果列表。递归遍历左子树。递归遍历右子树。def preorderTraversal(root: TreeNode): result [] def dfs(node): if not node: return # 访问根节点 result.append(node.val) # 遍历左子树 dfs(node.left) # 遍历右子树 dfs(node.right) dfs(root) return result为什么这样写代码结构完美对应了V-L-R的定义。dfs(node.left)和dfs(node.right)的调用意味着“我相信这个函数能正确遍历完左/右子树我只需要在它们之前、之后做我该做的事访问当前节点”。这是递归思想的精髓相信函数能完成子任务。3.2 中序遍历的递归实现中序遍历的顺序是LVR。递归遍历左子树。访问当前节点。递归遍历右子树。def inorderTraversal(root: TreeNode): result [] def dfs(node): if not node: return # 遍历左子树 dfs(node.left) # 访问根节点 result.append(node.val) # 遍历右子树 dfs(node.right) dfs(root) return result特别注意对于二叉搜索树BST中序遍历的结果是一个升序数组。这是BST一个极其重要的性质常用于验证BST的合法性、在BST中寻找第K小的元素等场景。如果你中序遍历BST得不到升序序列那这棵树肯定不是BST。3.3 后序遍历的递归实现后序遍历的顺序是LRV。递归遍历左子树。递归遍历右子树。访问当前节点。def postorderTraversal(root: TreeNode): result [] def dfs(node): if not node: return # 遍历左子树 dfs(node.left) # 遍历右子树 dfs(node.right) # 访问根节点 result.append(node.val) dfs(root) return result后序遍历的一个典型应用计算二叉树的高度深度。树的高度 1 max(左子树高度 右子树高度)。你必须先知道左右子树的高度才能计算当前节点的高度这正是一个后序遍历的过程。def maxDepth(root: TreeNode) - int: if not root: return 0 left_depth maxDepth(root.left) # 遍历左子树 right_depth maxDepth(root.right) # 遍历右子树 return max(left_depth, right_depth) 1 # 访问根节点计算高度3.4 递归实现的优缺点与注意事项优点代码简洁逻辑清晰几乎是对遍历定义的直接翻译。易于理解非常适合教学和快速原型实现。缺点与坑点栈溢出风险对于深度非常大的树例如退化成链表的树递归层级过深可能导致调用栈溢出。这是递归方法的固有缺陷。结果传递注意上面代码中我们使用了一个外层列表result和一个内层递归函数dfs。result作为闭包变量被内层函数修改。这是一种常见且清晰的写法。你也可以选择将result作为参数在递归函数中传递但那样代码会稍显冗余。空节点判断递归基if not node: return至关重要。它确保了递归能在叶子节点处正确终止而不会对None调用.left或.right属性导致错误。实操心得在面试中如果你被要求写遍历先写出递归版本通常是稳妥且快速的。这展示了你对问题本质的理解。但最好能主动提及“递归版本可能存在栈溢出问题也可以用迭代栈的方式实现”这能体现你的知识广度。4. 迭代实现用栈模拟递归过程递归的本质是函数调用栈。因此所有递归算法都可以用栈Stack这种数据结构来模拟实现迭代版本。迭代版本没有栈溢出的风险但逻辑上通常比递归版本更复杂一些。理解迭代实现能让你对遍历过程有更深刻的把握。我们需要显式地使用一个栈来存储待处理的节点。核心问题是节点入栈和出栈的时机以及何时访问节点值。4.1 前序遍历的迭代实现前序遍历的迭代是相对简单的。我们遵循VLR的顺序。先把根节点压入栈。循环栈不为空弹出栈顶节点并访问它。因为栈是后进先出LIFO为了保证访问顺序是V-L-R我们需要先将右子节点压栈再将左子节点压栈。这样下一次循环弹出处理的就是左子节点。def preorderTraversalIterative(root: TreeNode): 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为什么先右后左这是关键。我们希望接下来处理左子树所以左孩子应该后入栈、先弹出。右孩子先入栈、后弹出。这个顺序正好与递归调用dfs(node.left)在dfs(node.right)之前执行相反因为栈反转了顺序。4.2 中序遍历的迭代实现中序遍历LVR的迭代逻辑是三种中最需要技巧的。我们不能像前序那样在弹出时访问因为访问时机在遍历完左子树之后。核心思路使用一个指针curr来表示当前遍历到的节点并用栈来保存“已经路过但还未访问”的节点这些节点可以看作是“递归调用路径上的父节点”。从根节点开始curr指向当前节点。循环curr不为空或栈不为空如果curr不为空一直向左走将沿途节点压入栈中curr curr.left。这模拟了递归深入左子树的过程。如果curr为空意味着已经到达某条左路径的尽头。此时从栈中弹出一个节点这是最近一个未访问的“根”节点访问它。然后让curr指向该节点的右子节点开始处理右子树。def inorderTraversalIterative(root: TreeNode): result [] stack [] curr root while curr or stack: # 模拟递归深入左子树 while curr: stack.append(curr) curr curr.left # 左子树到头弹出“根”节点并访问 curr stack.pop() result.append(curr.val) # 转向右子树 curr curr.right return result这个算法非常精妙。外层while条件curr or stack确保了只要还有节点待处理就继续。内层的while curr完成了“深入左子树” (L)。stack.pop()和result.append完成了“访问根” (V)。curr curr.right则开启了“遍历右子树” (R) 的新一轮循环。4.3 后序遍历的迭代实现后序遍历LRV的迭代实现也有多种方法其中一种巧妙的方法是利用前序遍历的变种。我们知道前序是VLR后序是LRV。如果我们能实现一种VRL的遍历然后将结果反转不就得到LRV了吗因为(VRL)的逆序 LRV。如何实现VRL很简单模仿前序遍历但是调换左右子节点的入栈顺序改为先左后右。def postorderTraversalIterative(root: TreeNode): 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]这个方法非常取巧代码简洁。但它的缺点是需要额外的空间来存储整个结果列表用于反转并且访问节点的顺序与真正的后序逻辑不同可能不利于在遍历过程中进行某些即时操作。更符合逻辑的迭代后序遍历需要一个指针来记录上一个访问的节点以判断当前节点的右子树是否已被访问过。逻辑更复杂但空间效率更高不需要存储完整结果再反转。这里也给出实现供你对比理解def postorderTraversalIterative2(root: TreeNode): if not root: return [] result [] stack [] prev None # 记录前一个访问的节点 curr root while curr or stack: # 深入左子树 while curr: stack.append(curr) curr curr.left # 查看栈顶节点 curr stack[-1] # 如果右子树不存在或已被访问则访问当前节点 if not curr.right or curr.right prev: stack.pop() result.append(curr.val) prev curr curr None # 当前子树处理完毕强制弹出栈中下一个 else: # 否则转向右子树 curr curr.right return result这个版本中prev变量是关键。当从栈顶取出一个节点时如果它的右子节点为空或者右子节点刚刚被访问过prev说明它的左右子树都已处理完毕可以访问它自己了。避坑指南在面试或实际编码中如果你被要求写迭代后序我推荐先写“前序变种反转”的方法因为它不容易出错并且可以快速解释思路。如果面试官追问更高效或更正统的方法再阐述prev指针的方法。同时要能说清楚两种方法的时空复杂度都是 O(n)和差异。5. 层序遍历另一种重要的遍历维度虽然标题聚焦于前中后序但“层序遍历”作为热词被频繁提及它同样至关重要且实现思路完全不同。前中后序属于深度优先搜索DFS而层序遍历属于广度优先搜索BFS。层序遍历按树的层级从上到下、从左到右访问节点。它的实现通常借助队列Queue。from collections import deque def levelOrder(root: TreeNode): if not root: return [] result [] 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) result.append(current_level) return result为什么用队列队列先进先出FIFO的特性保证了我们先访问上一层的节点并将它们的子节点按顺序加入队尾从而自然实现了按层遍历。层序遍历的应用场景非常直观比如寻找二叉树的最大宽度、打印树的结构、在二叉树中找最短路径如从根到叶子的最小深度等。6. 核心应用场景与常见问题剖析理解了怎么遍历接下来就要看看它们能用来干什么。这里结合热词中的“常见问题”解析几个典型应用。6.1 根据遍历序列还原二叉树这是一个经典问题。通常需要中序遍历序列搭配前序或后序遍历序列之一才能唯一确定一棵二叉树。为什么前序/后序提供了根节点的信息前序第一个是根后序最后一个是根。中序提供了左右子树的分界信息根节点左边是左子树中序右边是右子树中序。以前序中序还原为例前序数组preorder的第一个元素preorder[0]是根节点。在中序数组inorder中找到这个根节点的位置index。inorder中index左边的部分inorder[:index]是左子树的中序序列右边的部分inorder[index1:]是右子树的中序序列。根据左子树中序序列的长度可以在preorder中划分出左子树的前序序列preorder[1:1len(left_inorder)]和右子树的前序序列preorder[1len(left_inorder):]。递归地对左子树和右子树进行步骤1-4。def buildTree(preorder, inorder): if not preorder or not inorder: return None root_val preorder[0] root TreeNode(root_val) # 找到根在中序中的位置 root_index_inorder inorder.index(root_val) # 划分中序序列 left_inorder inorder[:root_index_inorder] right_inorder inorder[root_index_inorder1:] # 划分前序序列 (关键左子树前序长度等于左子树中序长度) left_preorder preorder[1:1len(left_inorder)] right_preorder preorder[1len(left_inorder):] # 递归构建 root.left buildTree(left_preorder, left_inorder) root.right buildTree(right_preorder, right_inorder) return root注意上述代码中inorder.index(root_val)在每次递归中时间复杂度是 O(n)可以通过预先建立“值-索引”的哈希表来优化到 O(1)。这是面试中一个常见的优化点。6.2 二叉搜索树BST与中序遍历正如之前提到的对一棵二叉搜索树进行中序遍历会得到一个升序数组。这是BST的核心性质。基于这个性质我们可以解决很多问题验证BST中序遍历二叉树检查遍历结果是否严格递增。BST中第K小的元素中序遍历记录访问的节点个数第K个访问的节点即为所求。可以通过迭代中序遍历提前终止来优化。恢复错误的BSTBST中两个节点被意外交换会导致中序序列中出现两处“逆序”。找到这两个节点并交换回来即可。6.3 二叉树深度与遍历求二叉树的深度最大深度是后序遍历的典型应用代码已在3.3节展示。求二叉树的最小深度也可以用BFS层序遍历更高效地解决遇到第一个叶子节点即可返回当前深度。6.4 关于线索二叉树线索二叉树是一种优化存储结构它利用二叉树中的空指针域按照某种遍历顺序前序、中序、后序将节点“线索化”指向其前驱或后继节点。这样可以实现不需要栈或递归的遍历。虽然在实际工程中直接使用较少但它是理解二叉树存储结构和遍历关系的一个很好深化知识点。其核心思想是在遍历过程中如果当前节点的左/右孩子为空则将其指向遍历顺序下的前驱/后继节点并增加一个标志位区分指针指向的是孩子还是线索。7. 总结与高阶思考遍历是二叉树操作的基石。前、中、后序是深度优先思想的体现而层序遍历是广度优先思想的体现。递归实现简洁迭代实现稳健各有适用场景。在实际开发或面试中关于遍历你可能会遇到以下变体或深入问题Morris遍历一种时间复杂度O(n)但空间复杂度只有O(1)的遍历算法。它通过临时修改树的结构利用叶子节点的空指针来实现遍历完成后恢复树的结构。这是对迭代遍历空间优化的极致体现。N叉树的遍历原理相通只是每个节点可能有多个孩子。前序和后序遍历很容易推广中序遍历对于多叉树没有普遍定义。迭代遍历的统一写法有一种巧妙的迭代写法将访问节点和待处理节点都压入栈并通过一个空节点作为“已访问”的标记可以用一套非常相似的代码框架实现三种遍历。这种写法有助于理解和记忆但可能不如专用写法直观。我个人在学习和教学过程中最大的体会是不要孤立地记忆代码而要理解每种遍历对应的“访问时机”和“问题场景”。当你遇到一个二叉树问题时先问自己解决这个问题需要在什么时机第一次路过、从左子树返回后、从右子树返回后、按层访问或处理节点想清楚了这一点该用哪种遍历方式以及是递归还是迭代实现就变得一目了然了。最后多动手画图。拿一张纸画一棵树用笔模拟递归调用栈或迭代用的栈/队列一步步走完遍历过程。这是理解二叉树遍历最有效、最扎实的方法。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进