1. 从“一笔画”到“树遍历”:一个被误解的起点
最近在社区里看到一个挺有意思的问题,大意是“一个7*5的格子,如何遍历所有格子一笔联通第二行左1格和第四行右1格”。这个问题本质上是一个图论中的“一笔画”或“哈密顿路径/欧拉路径”问题,和我们要聊的二叉树遍历,乍一看风马牛不相及。但恰恰是这种对比,能让我们更深刻地理解“遍历”这个概念在不同数据结构中的核心差异。
在网格(图)的遍历中,我们关心的是访问路径,目标是找到一条不重复地经过所有节点的通路,路径的形状和顺序是核心。而在二叉树的遍历中,我们关心的则是访问顺序,树的结构(父子、兄弟关系)是固定的,我们只是按照某种既定的规则(左根右、根左右等)去“读取”或“处理”每一个节点。这个“读取”的动作,就像我们按照目录翻阅一本书,书的结构(章节、段落)是固定的,但你可以选择从头读到尾(先序),也可以先看每一章的总结再看细节(后序)。
二叉树遍历,尤其是前、中、后序这三种深度优先遍历,是数据结构与算法中最基础、也最容易被轻视的部分。很多人背下了“根左右是先序,左根右是中序,左右根是后序”的口诀,也能在纸上画出遍历序列,但一到实际应用,比如在递归函数里该把处理逻辑放在哪里,或者面对非递归实现时,就感到迷茫。这背后,是对每种遍历方式所蕴含的“访问时机”哲学理解不透。
今天,我们就抛开那些枯燥的定义,从一个实践者的角度,重新拆解这三种遍历。我会用大量的代码示例(主要用Python和C++,因其表达清晰)、生活化的类比,以及最重要的——它们在真实场景中的应用(比如构建表达式树、序列化二叉树、搜索二叉树操作),来让你不仅记住,更能理解并运用这三种遍历。你会发现,它们不是三个孤立的考点,而是一套处理树形数据的强大思维工具。
2. 遍历的本质:访问时机与上下文传递
在深入三种具体遍历方式之前,我们必须先建立一个核心认知:二叉树的遍历,本质上是确定在递归过程中,何时“访问”当前节点。
这里的“访问”是一个抽象操作,可以是指打印节点值、将节点值加入列表、修改节点内容,或者任何针对当前节点的处理逻辑。二叉树本身是一个递归定义的结构(一个根节点,加上左子树和右子树),所以递归是描述其遍历最自然的方式。
想象一下你正在探索一个由房间(节点)和门(指针)组成的迷宫,每个房间最多有两扇门,分别通向左房间和右房间。你手里有一支笔和一个笔记本(用来记录“访问”结果)。递归遍历就像一套固定的探索协议:
- 进入一个房间(对应函数调用栈压入一个新的递归帧)。
- 根据协议决定:是先记录这个房间号(访问根),还是先去探索左门后的子迷宫(递归左子树),或是先探索右门后的子迷宫(递归右子树)。
- 完成对这个房间及其所有子迷宫的探索(对应函数返回,栈帧弹出)。
三种遍历方式的区别,完全体现在上述第2步中“记录房间号”这个动作的时机上。这个时机决定了遍历序列所携带的语义信息。
注意:我们讨论的二叉树节点通常定义为包含值(
val)、指向左子节点的指针(left)和指向右子节点的指针(right)的结构体或类。
为了后续讨论,我们先定义一个简单的二叉树节点(Python示例):
class TreeNode: def __init__(self, val=0, left=None, right=None): self.val = val self.left = left self.right = right以及一棵示例树:
1 / \ 2 3 / \ \ 4 5 6它的前序、中序、后序序列将是我们的参考。
3. 前序遍历:自上而下的“领导者”视角
前序遍历的规则是:访问根节点 -> 递归遍历左子树 -> 递归遍历右子树。即“根左右”。
3.1 递归实现与直观理解
递归实现直白地反映了定义:
def preorder_traversal(root): result = [] def dfs(node): if not node: return # 访问时机:在递归子节点之前 result.append(node.val) # “根” dfs(node.left) # “左” dfs(node.right) # “右” dfs(root) return result对于示例树,调用preorder_traversal(root)将返回[1, 2, 4, 5, 3, 6]。
如何理解这个顺序?你可以把自己想象成公司的CEO(根节点)。前序遍历就像CEO的巡视路线:
- 首先,CEO亲自到达一个部门(访问根节点,记录/处理)。
- 然后,CEO要求左副总监(左子树)按照同样的方式巡视其下属团队。
- 左副总监完成后,CEO再要求右副总监(右子树)做同样的事。
这是一种自上而下的视角。你总是先处理当前层面的“领导”,然后再让其下属去处理他们自己的领域。因此,前序遍历序列的一个关键特性是:序列的第一个元素永远是整棵树的根节点。这个特性在反序列化(从序列重建树)时极其有用。
3.2 非递归实现:显式栈模拟递归
递归调用隐式使用了系统调用栈。非递归实现则需要我们显式地用一个栈(Stack)来模拟这个过程。这是面试中的常考点,也是理解递归执行过程的好方法。
前序遍历的非递归算法是相对直观的:
- 将根节点压入栈。
- 循环,直到栈为空: a. 弹出栈顶节点并访问它。 b. 将其右子节点压入栈(如果存在)。 c. 将其左子节点压入栈(如果存在)。
注意:必须先右后左压栈,因为栈是“后进先出”的,这样才能保证下一次循环弹出处理的是左子节点。
def preorder_traversal_iterative(root): 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 result3.3 核心应用场景
前序遍历的“根在先”特性,使其在以下场景中成为自然选择:
- 树的复制或序列化:当你需要创建一棵树的结构化表示(如字符串)以便存储或传输时,前序遍历很方便。因为拿到序列后,你立刻知道第一个元素是根,可以据此开始重建。例如,LeetCode上经典的二叉树序列化问题。
- 打印目录结构:类似于Unix的
tree命令,前序遍历能自然地展示出从根到叶的路径缩进。
这种展示方式就是前序遍历的结果。/project /src main.py utils.py /docs README.md - 在搜索二叉树中创建已排序数据的副本:虽然中序遍历BST能得到有序序列,但如果你想用前序遍历序列重建一棵结构相同的BST,前序遍历是必要的。
4. 中序遍历:顺序输出的“整理者”视角
中序遍历的规则是:递归遍历左子树 -> 访问根节点 -> 递归遍历右子树。即“左根右”。
4.1 递归实现与“投影”理解
def inorder_traversal(root): result = [] def dfs(node): if not node: return dfs(node.left) # “左” # 访问时机:在递归左子树之后,递归右子树之前 result.append(node.val) # “根” dfs(node.right) # “右” dfs(root) return result对于示例树(注意,这不是二叉搜索树),中序遍历结果是[4, 2, 5, 1, 3, 6]。
中序遍历有一个极其著名的特性:对一棵二叉搜索树进行中序遍历,得到的是一个升序(或降序)序列。这是因为BST的定义是:左子树所有节点值 < 根节点值 < 右子树所有节点值。中序遍历的“左-根-右”顺序,恰好保证了先输出所有小的(左),再输出中间的(根),最后输出大的(右)。
你可以把中序遍历想象成“扁平化”一棵树。假设你把二叉树的所有节点垂直投影到一条水平线上,从左到右扫描这条线,你看到的节点顺序就是中序遍历结果。它像一个公正的整理者,不偏不倚地按照“左、自己、右”的顺序处理信息。
4.2 非递归实现:最需要技巧的一种
中序遍历的非递归实现是三者中最需要理解的,因为它访问节点的时机不在循环开头。 核心思路是:用一个栈来保存“尚未访问根节点”的节点路径,用一个指针(curr)来模拟递归中的当前节点。
算法步骤:
- 初始化一个空栈,
curr指针指向根节点。 - 当
curr不为空或栈不为空时循环: a.一路向左:如果curr不为空,将其压栈,然后curr指向其左子节点。这一步模拟了深度递归进入左子树的过程。 b.访问与转向:如果curr为空(意味着已经到达某条左路径的尽头),则从栈中弹出一个节点(这是最近一个未访问根节点的节点),访问它。 c.处理右子树:将curr指向刚刚弹出节点的右子节点,然后重复整个过程。
def inorder_traversal_iterative(root): result = [] stack = [] curr = root while curr or stack: # 步骤a: 一路向左到底,沿途节点入栈 while curr: stack.append(curr) curr = curr.left # 步骤b: 弹出栈顶并访问(这个节点已经没有左子节点或左子节点已处理) node = stack.pop() result.append(node.val) # 步骤c: 转向处理右子树 curr = node.right return result这个过程完美模拟了递归中“深入左子树 -> 返回并处理根 -> 再深入右子树”的调用链。
4.3 核心应用场景
中序遍历的核心价值在于其“顺序性”:
- 二叉搜索树的相关操作:这是中序遍历的“主场”。
- 验证BST:中序遍历BST,检查序列是否严格递增。
- BST中第K小的元素:中序遍历到第K个节点即可。
- 恢复错误的BST:BST中两个节点被错误交换,其中序遍历序列会出现两处“逆序”,利用这个特性可以找到并修复它们。
- 表达式树求值:对于表示算术表达式的二叉树(运算符是根,操作数是叶子),中序遍历能产生原始的中缀表达式(虽然可能需要加括号)。但更常用的是后序遍历来求值。
- 按顺序输出所有节点:当你只是需要所有节点值的一个有序列表时(对于BST就是排序列表)。
5. 后序遍历:自下而上的“建设者”视角
后序遍历的规则是:递归遍历左子树 -> 递归遍历右子树 -> 访问根节点。即“左右根”。
5.1 递归实现与“汇报”理解
def postorder_traversal(root): result = [] def dfs(node): if not node: return dfs(node.left) # “左” dfs(node.right) # “右” # 访问时机:在递归完所有子节点之后 result.append(node.val) # “根” dfs(root) return result对于示例树,后序遍历结果是[4, 5, 2, 6, 3, 1]。
后序遍历是“自下而上”或“先子后父”的。沿用公司比喻,它就像基层员工先完成工作,向经理汇报;经理汇总后再向总监汇报;最后总监向CEO汇报。CEO(根节点)是最后一个被“访问”或“处理”的。这意味着,当你访问一个节点时,它的所有后代节点都已经被处理过了。这个特性使得后序遍历非常适合处理那些需要子节点信息才能计算父节点信息的场景。
5.2 非递归实现:双栈法与标记法
后序遍历的非递归实现比前序和中序都更复杂一些,因为一个节点需要在它的左右子树都被访问后才能出栈访问。这里介绍两种常见方法。
方法一:双栈法(逆序输出)思路是利用前序遍历的变体(根->右->左),然后将结果逆序,就得到了后序遍历(左->右->根)。
- 栈1用于模拟遍历,按“根->右->左”的顺序压栈和访问。
- 将访问的节点压入栈2(一个结果栈)。
- 最后将栈2中的元素依次弹出,即为后序序列。
def postorder_traversal_iterative_two_stack(root): if not root: return [] stack1 = [root] stack2 = [] while stack1: node = stack1.pop() stack2.append(node.val) # 访问结果存入stack2 # 注意顺序:先左后右,这样在stack1中就是右先入后出,实现“根->右->左” if node.left: stack1.append(node.left) if node.right: stack1.append(node.right) # stack2中存储的是“根->右->左”的逆序,弹出即是“左->右->根” return stack2[::-1]方法二:标记法(推荐,更通用)这是更贴近递归本质的方法。我们用一个栈存储节点,同时用一个额外的集合(或通过给节点添加标记位)来记录某个节点的左右子树是否已被处理。 更优雅的实现是使用一个prev指针,记录上一个被访问的节点。
- 将根节点压栈。
- 循环,直到栈为空: a. 查看栈顶节点(
peek,不弹出)。 b. 如果栈顶节点是叶子节点,或者其右子节点刚被访问过(prev == node.right),或者其左子节点刚被访问过且右子节点为空,则说明其子树均已处理完毕,可以弹出并访问。 c. 否则,依次将其右子节点、左子节点压栈(保证左子节点在栈顶,下次循环先处理)。
def postorder_traversal_iterative(root): if not root: return [] result = [] stack = [] prev = None # 记录前一个被访问的节点 curr = root while curr or stack: # 一路向左下走,沿途节点入栈 while curr: stack.append(curr) curr = curr.left # 查看栈顶节点 node = stack[-1] # 如果右子树不存在或右子树已被访问,则访问当前节点 if not node.right or node.right == prev: stack.pop() result.append(node.val) prev = node # 记录刚访问的节点 curr = None # 当前子树已处理完,下一轮从栈中取新节点 else: # 否则,转向处理右子树 curr = node.right return result标记法理解起来稍难,但它能清晰地模拟递归回溯的过程。
5.3 核心应用场景
后序遍历“先子后父”的特性,使其成为解决许多树形DP(动态规划)和状态汇总问题的利器。
- 计算节点的高度或深度:树的高度 = max(左子树高度, 右子树高度) + 1。必须先知道左右子树的高度,才能计算根的高度。这是一个经典的后序遍历应用。
def tree_height(root): if not root: return -1 # 或0,取决于高度定义(边数还是节点数) left_height = tree_height(root.left) right_height = tree_height(root.right) return max(left_height, right_height) + 1 # 后序位置计算 - 判断二叉树是否平衡:平衡二叉树的定义是左右子树高度差不超过1。同样需要后序遍历自底向上返回高度信息并进行判断。
- 删除二叉树:在释放内存时,必须先删除左右子树,最后删除根节点,否则会导致内存泄漏或访问野指针。这是后序遍历在资源管理上的直接体现。
- 表达式树求值:对于表达式树,后序遍历(即逆波兰表达式)是无需括号且最容易用栈来求值的形式。遇到数字就压栈,遇到运算符就弹出栈顶两个数字运算,结果再压栈。
- 计算子树的和、平均值、最大值等统计信息:任何需要聚合子节点信息才能得到父节点信息的计算,都天然适合后序遍历。
6. 层序遍历:广度优先的“团队”视角
虽然标题聚焦于前中后序,但相关热词中提到了“层序遍历”和“按层遍历”,这同样是二叉树遍历中不可或缺的一部分,属于广度优先搜索的范畴。
层序遍历的规则是:从上到下,从左到右,逐层访问节点。它不使用递归的深度搜索,而是使用队列(Queue)进行广度搜索。
6.1 队列实现与“涟漪”理解
算法步骤非常直观:
- 将根节点放入队列。
- 循环,直到队列为空: a. 记录当前队列的长度
level_size(即当前层的节点数)。 b. 循环level_size次,每次从队列中取出一个节点并访问。 c. 将该节点的左子节点和右子节点(如果存在)依次加入队列。
from collections import deque def level_order_traversal(root): 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对于示例树,层序遍历结果是[[1], [2, 3], [4, 5, 6]]。
层序遍历就像在水面投入一颗石子,涟漪一圈圈扩散开来。它关注的是节点所在的“层级”或“深度”,常用于需要按层次处理节点的问题,如打印树形结构、寻找最短路径(在树中即最小深度)等。
6.2 与深度优先遍历的对比与应用选择
- 数据结构:深度优先(前中后序)通常使用栈(递归调用栈或显式栈),体现了“一条路走到黑再回头”的探索方式;广度优先(层序)使用队列,体现了“齐头并进”的探索方式。
- 访问顺序:深度优先先深入某一分支,广度优先先覆盖同一层。
- 典型应用:
- 深度优先:适合所有需要递归性质、探索路径、序列化/反序列化、需要利用子树信息的问题。
- 广度优先:适合求最短路径(在无权图中)、按层打印、寻找每层的最大值/平均值、进行拓扑排序(在有向无环图中)等问题。
选择哪种遍历方式,取决于你的问题需要什么样的节点访问顺序。如果需要“父节点信息决定子节点处理”或“序列化”,考虑前序;如果需要“有序输出”或BST相关,考虑中序;如果需要“子节点信息决定父节点结果”或“释放资源”,考虑后序;如果需要“按层次处理”,考虑层序。
7. 融会贯通:从遍历序列重建二叉树
一个经典的问题是:给定两种遍历序列,能否唯一确定一棵二叉树?这直接考察了对遍历序列含义的理解。
- 前序 + 中序:可以唯一确定。
- 原理:前序序列的第一个元素是根节点。在中序序列中找到这个根节点,其左侧就是左子树的中序序列,右侧就是右子树的中序序列。根据左右子树的节点数量,可以在前序序列中划分出左右子树的前序序列。然后递归处理。
- 这是最常用的组合,因为前序提供了根,中序提供了左右划分。
- 后序 + 中序:可以唯一确定。
- 原理:后序序列的最后一个元素是根节点。后续步骤与前序+中序类似,用根节点划分中序序列,再根据子树节点数划分后序序列,递归。
- 前序 + 后序:一般不能唯一确定,除非二叉树是真二叉树(每个节点都有0个或2个子节点)。
- 原因:前序是(根,左子树,右子树),后序是(左子树,右子树,根)。当只知道根和整体子树范围,而无法明确区分左子树和右子树的边界时,就会产生歧义。例如,根节点只有一个子节点时,无法判断该子节点是左还是右。
重建二叉树的过程,本身就是对遍历算法的一次深刻实践。你需要写一个递归函数,其核心逻辑正是基于你所选的遍历方式(前序或后序找根,中序划分左右)来进行的。
8. 实战中的陷阱与经验之谈
理解了原理和代码,在实际编码和调试中,还有一些细节容易出错。
陷阱一:递归中的“访问”操作位置这是最根本的混淆点。务必牢记:
- 前序的
visit(root)在两次递归调用之前。 - 中序的
visit(root)在两次递归调用之间。 - 后序的
visit(root)在两次递归调用之后。 写递归时,先想清楚你的处理逻辑应该在哪个时机执行,再下笔。
陷阱二:非递归实现的栈或队列状态
- 前序非递归:访问后立刻将子节点压栈,顺序是先右后左。
- 中序非递归:核心是
curr指针和栈的配合。curr用于向左下深入,栈用于存储“待访问根节点”。curr为空时,才从栈中取节点访问,然后转向右子树。 - 后序非递归(标记法):关键条件是判断右子树是否已被访问(
prev == node.right)。prev指针的维护是关键。 - 层序遍历:一定要在每一层开始前记录队列长度
level_size,并在内循环中使用这个固定值。如果在循环内直接判断while queue,会把下一层的节点也混进来。
经验:使用“空节点标记法”处理边界在序列化或处理一些特殊二叉树(如题目允许空节点)时,可以在遍历过程中将空节点也用一个特殊值(如null或#)表示。这能简化反序列化的逻辑,尤其是在处理非完全二叉树的时候。例如,前序遍历序列[1, 2, null, null, 3, 4, null, null, 5, null, null]可以明确无误地重建原树。
经验:遍历是框架,处理逻辑是灵魂不要孤立地学习遍历。遍历的代码框架(递归或迭代)是固定的,而真正变化的是在访问节点时执行的“处理逻辑”。这个逻辑可以很简单(如append(val)),也可以很复杂(如更新全局变量、修改树结构、进行条件判断等)。把遍历框架练熟,你就能解决一大类树形问题。例如,求二叉树直径、最大路径和等问题,都是在后序遍历框架中,在访问节点时计算并更新一些额外状态。
最后,理解二叉树遍历,最好的方式就是动手。找一棵简单的树,在白纸上一步步模拟递归调用栈和显式栈/队列的变化,画出每一步的节点访问顺序和数据结构状态。这个过程看似笨拙,却是将算法内化于心、不再需要死记硬背的不二法门。当你看到任何一棵树,都能在脑中清晰地浮现出不同遍历方式下的节点流动顺序时,这些知识就真正属于你了。