news 2026/8/16 9:02:47

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
二叉树遍历全解析:前序、中序、后序与层序遍历的核心原理与代码实现

1. 从“遍历”说起:为什么二叉树的操作离不开它

如果你刚开始接触数据结构,或者正在准备技术面试,那么“二叉树”这个词你肯定不陌生。而提到二叉树,几乎绕不开的就是它的“遍历”。你可能已经看过很多定义:前序遍历、中序遍历、后序遍历。但你是否想过,为什么我们要如此执着于研究这几种遍历方式?它们到底解决了什么问题?

简单来说,遍历就是系统地访问树中每一个节点,并且每个节点只访问一次的过程。这听起来很简单,但却是二叉树几乎所有高级操作的基础。想象一下,你要在一棵家族树里统计总人数,或者在一棵文件系统树里搜索某个特定文件,又或者在一棵表达式树里计算整个表达式的值,这些操作的第一步,都是“走遍”这棵树。而“怎么走”——也就是遍历的顺序——直接决定了你访问和处理数据的逻辑。

前、中、后序遍历,就是三种最经典、最基础的“走法”。它们之所以重要,不仅仅是因为面试常考,更是因为它们在解决实际问题时各有妙用。比如,前序遍历天然适合复制一棵树的结构;中序遍历对于二叉搜索树来说,能按升序输出所有节点;后序遍历则在释放树的内存或计算目录大小等场景下非常高效。

很多人刚开始学的时候,容易把这三种遍历的顺序搞混,或者只能死记硬背。这篇内容,我们就来彻底拆解这三种遍历。我不会只给你干巴巴的递归代码,而是会带你理解每一种遍历的核心视角实际应用场景,并给出递归与非递归(迭代)两种实现方式。更重要的是,我会分享在真正编码和面试中,如何清晰无误地推导出遍历顺序,以及那些容易踩坑的细节。无论你是初学者想打牢基础,还是面试者想巩固要点,相信这篇内容都能给你带来实实在在的帮助。

2. 理解三种遍历:核心差异在于“根”的位置

在深入代码之前,我们必须从本质上理解这三种遍历命名的由来和它们之间的区别。这能帮你摆脱死记硬背,真正掌握其逻辑。

这三种遍历的名称——前序(Pre-order)、中序(In-order)、后序(Post-order)——其实描述的是在访问一个节点的左子树和右子树时,何时访问这个节点本身(根节点)

我们可以把一个节点的访问操作记作V(Visit),处理左子树记作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:访问B(V),遍历B的左子树(D),遍历B的右子树(E)。
  • 中序遍历:D -> B -> E -> A -> C -> F
    • 先遍历A的左子树(以B为根的树),然后访问A(V),最后遍历A的右子树(以C为根的树)。遍历左子树时,遵循LVR:先遍历B的左子树(D),访问B(V),再遍历B的右子树(E)。
  • 后序遍历:D -> E -> B -> F -> C -> A
    • 先遍历A的左子树(以B为根的树),然后遍历A的右子树(以C为根的树),最后访问A(V)。遍历左子树时,遵循LRV:先遍历B的左子树(D),再遍历B的右子树(E),最后访问B(V)。

注意:这里说的“处理左/右子树”,指的是递归地以同样的遍历规则去访问那棵子树。理解这个递归过程是掌握遍历的关键。

2.1 一个帮你永不记混的“可视化”技巧

我刚开始学的时候也总记混。后来我发现一个非常有效的技巧:在脑子里“走”过节点时,想象自己站在每个节点上,并且把每个节点“路过”三次

  1. 第一次路过:从父节点过来,准备进入左子树。此时如果执行访问操作,就是前序
  2. 第二次路过:从左子树返回,准备进入右子树。此时如果执行访问操作,就是中序
  3. 第三次路过:从右子树返回,准备回到父节点。此时如果执行访问操作,就是后序

对于上面树的节点A:

  • 前序访问A:发生在第一次“路过”A时(从虚拟的根上来)。
  • 中序访问A:发生在从左子树(B, D, E)返回后,即将进入右子树(C, F)时。
  • 后序访问A:发生在从右子树(C, F)返回后,即将回到虚拟的根时。

这个技巧能帮你从递归调用的堆栈角度理解访问时机,对于后续理解非递归实现也大有裨益。

3. 递归实现:最直观的表达方式

递归实现是描述树遍历最自然、最简洁的方式,因为它直接对应了树的递归定义(一棵树由根节点、左子树和右子树构成)。我们先定义一个简单的二叉树节点类,这是所有后续代码的基础。

class TreeNode: def __init__(self, val=0, left=None, right=None): self.val = val self.left = left self.right = right

3.1 前序遍历的递归实现

前序遍历的顺序是VLR。递归函数preorderTraversal接收一个根节点root。如果节点为空,直接返回(递归基)。否则,执行以下三步:

  1. 访问当前节点(例如,将节点值加入结果列表)。
  2. 递归遍历左子树。
  3. 递归遍历右子树。
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

  1. 递归遍历左子树。
  2. 访问当前节点。
  3. 递归遍历右子树。
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

  1. 递归遍历左子树。
  2. 递归遍历右子树。
  3. 访问当前节点。
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 递归实现的优缺点与注意事项

优点

  • 代码简洁:逻辑清晰,几乎是对遍历定义的直接翻译。
  • 易于理解:非常适合教学和快速原型实现。

缺点与坑点

  1. 栈溢出风险:对于深度非常大的树(例如退化成链表的树),递归层级过深可能导致调用栈溢出。这是递归方法的固有缺陷。
  2. 结果传递:注意上面代码中,我们使用了一个外层列表result和一个内层递归函数dfsresult作为闭包变量被内层函数修改。这是一种常见且清晰的写法。你也可以选择将result作为参数在递归函数中传递,但那样代码会稍显冗余。
  3. 空节点判断:递归基if not node: return至关重要。它确保了递归能在叶子节点处正确终止,而不会对None调用.left.right属性导致错误。

实操心得:在面试中,如果你被要求写遍历,先写出递归版本通常是稳妥且快速的。这展示了你对问题本质的理解。但最好能主动提及“递归版本可能存在栈溢出问题,也可以用迭代+栈的方式实现”,这能体现你的知识广度。

4. 迭代实现:用栈模拟递归过程

递归的本质是函数调用栈。因此,所有递归算法都可以用栈(Stack)这种数据结构来模拟实现迭代版本。迭代版本没有栈溢出的风险,但逻辑上通常比递归版本更复杂一些。理解迭代实现,能让你对遍历过程有更深刻的把握。

我们需要显式地使用一个栈来存储待处理的节点。核心问题是:节点入栈和出栈的时机,以及何时访问节点值

4.1 前序遍历的迭代实现

前序遍历的迭代是相对简单的。我们遵循VLR的顺序。

  1. 先把根节点压入栈。
  2. 循环(栈不为空):
    • 弹出栈顶节点并访问它。
    • 因为栈是后进先出(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来表示当前遍历到的节点,并用栈来保存“已经路过但还未访问”的节点(这些节点可以看作是“递归调用路径上的父节点”)。

  1. 从根节点开始,curr指向当前节点。
  2. 循环(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 根据遍历序列还原二叉树

这是一个经典问题。通常,需要中序遍历序列搭配前序或后序遍历序列之一,才能唯一确定一棵二叉树。为什么?

  • 前序/后序提供了根节点的信息(前序第一个是根,后序最后一个是根)。
  • 中序提供了左右子树的分界信息(根节点左边是左子树中序,右边是右子树中序)。

以前序+中序还原为例

  1. 前序数组preorder的第一个元素preorder[0]是根节点。
  2. 在中序数组inorder中找到这个根节点的位置index
  3. inorderindex左边的部分inorder[:index]是左子树的中序序列,右边的部分inorder[index+1:]是右子树的中序序列。
  4. 根据左子树中序序列的长度,可以在preorder中划分出左子树的前序序列preorder[1:1+len(left_inorder)]和右子树的前序序列preorder[1+len(left_inorder):]
  5. 递归地对左子树和右子树进行步骤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_inorder+1:] # 划分前序序列 (关键:左子树前序长度等于左子树中序长度) left_preorder = preorder[1:1+len(left_inorder)] right_preorder = preorder[1+len(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个访问的节点即为所求。可以通过迭代中序遍历提前终止来优化。
  • 恢复错误的BST:BST中两个节点被意外交换,会导致中序序列中出现两处“逆序”。找到这两个节点并交换回来即可。

6.3 二叉树深度与遍历

求二叉树的深度(最大深度)是后序遍历的典型应用,代码已在3.3节展示。求二叉树的最小深度也可以用BFS(层序遍历)更高效地解决,遇到第一个叶子节点即可返回当前深度。

6.4 关于线索二叉树

线索二叉树是一种优化存储结构,它利用二叉树中的空指针域,按照某种遍历顺序(前序、中序、后序)将节点“线索化”,指向其前驱或后继节点。这样可以实现不需要栈或递归的遍历。虽然在实际工程中直接使用较少,但它是理解二叉树存储结构和遍历关系的一个很好深化知识点。其核心思想是:在遍历过程中,如果当前节点的左/右孩子为空,则将其指向遍历顺序下的前驱/后继节点,并增加一个标志位区分指针指向的是孩子还是线索。

7. 总结与高阶思考

遍历是二叉树操作的基石。前、中、后序是深度优先思想的体现,而层序遍历是广度优先思想的体现。递归实现简洁,迭代实现稳健,各有适用场景。

在实际开发或面试中,关于遍历,你可能会遇到以下变体或深入问题:

  • Morris遍历:一种时间复杂度O(n),但空间复杂度只有O(1)的遍历算法。它通过临时修改树的结构(利用叶子节点的空指针)来实现遍历,完成后恢复树的结构。这是对迭代遍历空间优化的极致体现。
  • N叉树的遍历:原理相通,只是每个节点可能有多个孩子。前序和后序遍历很容易推广,中序遍历对于多叉树没有普遍定义。
  • 迭代遍历的统一写法:有一种巧妙的迭代写法,将访问节点和待处理节点都压入栈,并通过一个空节点作为“已访问”的标记,可以用一套非常相似的代码框架实现三种遍历。这种写法有助于理解和记忆,但可能不如专用写法直观。

我个人在学习和教学过程中最大的体会是:不要孤立地记忆代码,而要理解每种遍历对应的“访问时机”和“问题场景”。当你遇到一个二叉树问题时,先问自己:解决这个问题,需要在什么时机(第一次路过、从左子树返回后、从右子树返回后、按层)访问或处理节点?想清楚了这一点,该用哪种遍历方式,以及是递归还是迭代实现,就变得一目了然了。

最后,多动手画图。拿一张纸,画一棵树,用笔模拟递归调用栈或迭代用的栈/队列,一步步走完遍历过程。这是理解二叉树遍历最有效、最扎实的方法。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/16 9:02:31

微服务拆分复盘:边界、调用成本与回滚路径

微服务拆分复盘:边界、调用成本与回滚路径 微服务拆分后,如果调用链更长、职责仍重叠,就只是把复杂度搬到了网络上。复盘应对照业务边界、失败隔离和回滚路径,决定合并还是继续拆分。 1. 级联崩溃与证据链缺失陷阱 单体架构拆成微…

作者头像 李华
网站建设 2026/8/16 8:59:29

Python进阶核心:从工程化到并发编程的实战能力提升

1. 从“会用”到“精通”:Python进阶路上的核心分水岭 很多朋友学Python,照着教程敲完“Hello World”,跟着视频做完几个小项目,就觉得自己“会了”。但真到了工作中,面对一个稍复杂的业务需求,或者接手一个…

作者头像 李华
网站建设 2026/8/16 8:59:12

Git核心操作与实战技巧全解析

1. Git核心操作全景指南 作为分布式版本控制系统的实际行业标准,Git已经渗透到现代开发的每个环节。但很多开发者仅仅停留在 git add 、 git commit 、 git push 的基础使用层面,遇到分支冲突、历史回退等复杂场景时往往束手无策。本文将系统梳理G…

作者头像 李华
网站建设 2026/8/16 8:58:20

彻底解决CUDA与PyTorch版本不兼容:从原理到实战的完整指南

1. 问题引入:一个让无数开发者头疼的“版本地狱” 如果你在深度学习或者高性能计算领域摸爬滚打过一段时间,那么对“CUDA与PyTorch版本不兼容”这个报错信息一定不会陌生。它就像一个幽灵,总是在你最不想看到它的时候出现——可能是在你刚配好…

作者头像 李华
网站建设 2026/8/16 8:56:03

Stablebaselines3实战:解决PPO、SAC算法数据格式与训练不收敛难题

1. 从求助到自救:一个强化学习实践者的必经之路 “使用Stablebaselines3遇到的问题,求助”——这个标题我太熟悉了,几乎是我自己早期接触强化学习(RL)开源库时的真实写照。Stablebaselines3(简称SB3&#x…

作者头像 李华