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)。
- 从根A开始(V),然后遍历左子树(以B为根的树),最后遍历右子树(以C为根的树)。遍历左子树时,同样遵循
- 中序遍历:D -> B -> E -> A -> C -> F
- 先遍历A的左子树(以B为根的树),然后访问A(V),最后遍历A的右子树(以C为根的树)。遍历左子树时,遵循
LVR:先遍历B的左子树(D),访问B(V),再遍历B的右子树(E)。
- 先遍历A的左子树(以B为根的树),然后访问A(V),最后遍历A的右子树(以C为根的树)。遍历左子树时,遵循
- 后序遍历:D -> E -> B -> F -> C -> A
- 先遍历A的左子树(以B为根的树),然后遍历A的右子树(以C为根的树),最后访问A(V)。遍历左子树时,遵循
LRV:先遍历B的左子树(D),再遍历B的右子树(E),最后访问B(V)。
- 先遍历A的左子树(以B为根的树),然后遍历A的右子树(以C为根的树),最后访问A(V)。遍历左子树时,遵循
注意:这里说的“处理左/右子树”,指的是递归地以同样的遍历规则去访问那棵子树。理解这个递归过程是掌握遍历的关键。
2.1 一个帮你永不记混的“可视化”技巧
我刚开始学的时候也总记混。后来我发现一个非常有效的技巧:在脑子里“走”过节点时,想象自己站在每个节点上,并且把每个节点“路过”三次。
- 第一次路过:从父节点过来,准备进入左子树。此时如果执行访问操作,就是前序。
- 第二次路过:从左子树返回,准备进入右子树。此时如果执行访问操作,就是中序。
- 第三次路过:从右子树返回,准备回到父节点。此时如果执行访问操作,就是后序。
对于上面树的节点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 = 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[index+1:]是右子树的中序序列。- 根据左子树中序序列的长度,可以在
preorder中划分出左子树的前序序列preorder[1:1+len(left_inorder)]和右子树的前序序列preorder[1+len(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_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叉树的遍历:原理相通,只是每个节点可能有多个孩子。前序和后序遍历很容易推广,中序遍历对于多叉树没有普遍定义。
- 迭代遍历的统一写法:有一种巧妙的迭代写法,将访问节点和待处理节点都压入栈,并通过一个空节点作为“已访问”的标记,可以用一套非常相似的代码框架实现三种遍历。这种写法有助于理解和记忆,但可能不如专用写法直观。
我个人在学习和教学过程中最大的体会是:不要孤立地记忆代码,而要理解每种遍历对应的“访问时机”和“问题场景”。当你遇到一个二叉树问题时,先问自己:解决这个问题,需要在什么时机(第一次路过、从左子树返回后、从右子树返回后、按层)访问或处理节点?想清楚了这一点,该用哪种遍历方式,以及是递归还是迭代实现,就变得一目了然了。
最后,多动手画图。拿一张纸,画一棵树,用笔模拟递归调用栈或迭代用的栈/队列,一步步走完遍历过程。这是理解二叉树遍历最有效、最扎实的方法。