1. 从“背模板”到“懂逻辑”:二叉树遍历的实战心法
每次看到二叉树遍历的题目,很多人的第一反应就是去回忆那几句递归口诀:“前序是根左右,中序是左根右,后序是左右根”。背下来,套上去,代码好像也能跑通。但一旦题目稍微变形,比如让你根据遍历序列重建二叉树,或者处理线索二叉树,立刻就懵了。问题出在哪?在于我们只记住了操作的“顺序”,却没能理解这个顺序背后所代表的“访问时机”和“问题视角”。二叉树遍历,本质上是在遍历一棵树的过程中,选择在哪个“时机点”去处理当前节点。这个“时机点”的不同,就决定了遍历结果是用来解决什么样的问题。今天,我们不谈枯燥的定义,直接从解题的实战角度出发,拆解前、中、后、层次乃至线索化遍历的核心技巧,让你下次遇到相关题目时,能一眼看穿本质,从“记忆解题”升级为“逻辑解题”。
2. 三大基础遍历:不仅仅是顺序,更是解决问题的视角
很多人把前、中、后序遍历的区别简单归结为打印根节点的顺序不同。这没错,但太浅了。在解题时,我们需要建立更深层的认知:每一种遍历顺序,都为我们观察和解决树的问题提供了一个独特的“摄像机位”。
2.1 前序遍历:自上而下的“先遣侦察”
前序遍历(根->左->右)的访问时机是:第一次遇到某个节点时,就立刻处理它。你可以想象自己是一个探险家,进入一棵树形的迷宫,你的策略是每到达一个新的房间(节点),先在这个房间做标记(处理根),然后再依次探索它的左通道和右通道。
核心技巧与解题场景:
- 复制一棵树:你需要创建新树的根节点(处理当前根),然后递归创建左子树和右子树。这个过程天然就是前序的。
- 获取树的结构化表示(序列化):为了完整保存树的结构(包括空节点),通常采用前序遍历。在序列化字符串中,先记录当前节点值,再递归处理左右子树。反序列化时,读取的第一个值就是根节点,这与前序的顺序完美契合。
- 计算节点数、求树的高度(深度优先版本):虽然在这些问题中,前序、中序、后序都能完成计数或求最大深度,但前序遍历的代码写起来往往最直观,因为“进入节点就计数”符合直觉。
一个常见的“坑”:在求二叉树的最大深度时,初学者容易写出这样的前序代码:
def maxDepth(root): depth = 0 def traverse(node, current_depth): nonlocal depth if not node: return # 前序位置:进入节点时更新当前深度 current_depth += 1 depth = max(depth, current_depth) # 在这里更新最大深度 traverse(node.left, current_depth) traverse(node.right, current_depth) traverse(root, 0) return depth这段代码逻辑正确,但它揭示了前序遍历的一个特点:前序位置无法直接获取子树的处理结果。depth是一个全局变量,我们在遍历过程中不断更新它。相比之下,后序遍历的解法更优雅,因为它自底向上汇总信息。
2.2 中序遍历:有序世界的“投影仪”
中序遍历(左->根->右)的访问时机是:在遍历完左子树之后,即将开始遍历右子树之前,处理当前节点。这就像你按照页码顺序阅读一本书(左子树是前面的章节),读完一章后,你对这一章进行总结(处理根),然后再继续读下一章(右子树)。
核心技巧与解题场景:
- 二叉搜索树(BST)的相关操作:这是中序遍历的“主场”。BST的性质是“左子树所有节点值 < 根节点值 < 右子树所有节点值”。中序遍历BST,会得到一个严格递增的有序序列。利用这个特性,可以轻松解决:
- 验证BST:在中序遍历过程中,记录上一个访问的节点值,确保当前节点值始终大于上一个值。
- BST中第K小的元素:中序遍历访问的第K个节点即是。
- 恢复错误的BST:找到中序序列中顺序错误的那两个节点进行交换。
- 表达式树求值:对于存储算术表达式的二叉树,中序遍历恰好能产生原始的中缀表达式(虽然可能需要加括号)。但求值本身通常使用后序遍历更方便。
实战避坑点:在验证BST时,一个经典的错误是只检查当前节点与其左右子节点的值关系。例如,仅判断root.val > root.left.val and root.val < root.right.val。这无法发现下图中的错误,因为节点4虽然大于其左子节点3,但它位于节点5的左子树中,本应全部小于5,而4<5虽然成立,但节点6大于5却出现在了左子树,这是局部检查无法发现的。必须依靠中序遍历的全局有序性来校验。
5 / \ 4 7 / \ 3 6 // 节点6 > 节点5,违反了BST定义正确的做法是在中序遍历中维护一个prev指针或值,进行全局比较。
2.3 后序遍历:自底向上的“汇总报告”
后序遍历(左->右->根)的访问时机是:在左右子树都遍历完毕之后,再处理当前节点。这就像公司汇报工作,基层员工(叶子节点)先准备好自己的数据,汇报给经理(中间节点),经理汇总所有下属的数据后,再汇报给总监(根节点)。
核心技巧与解题场景:
- 需要利用子树信息的计算:这是后序遍历最强大的地方。当前节点的答案往往依赖于其左右子树的答案。
- 求二叉树的最大深度/高度:树的高度 = max(左子树高度, 右子树高度) + 1。必须先知道左右子树的高度,才能计算当前节点的高度,这是典型的后序逻辑。
- 判断平衡二叉树:需要同时获取子树的高度和平衡性信息。
- 计算二叉树直径:直径可能穿过根节点,路径长度为左子树深度 + 右子树深度。需要在后序位置汇总深度信息并更新全局最大直径。
- 删除二叉树:必须先删除左右子树,最后释放根节点内存,否则会导致内存泄漏或访问错误。
- 后缀表达式(逆波兰表达式)求值:表达式树的后序遍历结果就是后缀表达式,可以直接用栈来求值,无需考虑运算符优先级。
代码模式模板: 后序遍历解决子树依赖问题的代码结构非常清晰,几乎成为一个模板:
def postOrderTraversal(root): # 定义返回值类型,有时是一个值,有时是多个值(如高度+是否平衡) def dfs(node): if not node: return 0 # 或返回一个表示空树的基准值,如高度为0 left_info = dfs(node.left) # 获取左子树信息 right_info = dfs(node.right) # 获取右子树信息 # 后序位置:利用左右子树信息计算当前节点信息 current_info = some_operation(left_info, right_info, node.val) # 可能还需要更新一个全局变量(如最大直径) # global_max = max(global_max, some_calculation(left_info, right_info)) return current_info return dfs(root)3. 层次遍历:广度优先的“层层推进”
层次遍历(广度优先遍历,BFS)不再使用递归栈,而是使用队列。它的视角是“按层扫描”,同一层的节点在一起处理。这对于处理与“层”、“宽度”、“最短路径”相关的问题至关重要。
3.1 核心技巧:队列与分层标记
基本算法使用一个队列:
- 将根节点入队。
- 当队列不为空时: a. 记录当前队列长度
level_size(即当前层的节点数)。 b. 循环level_size次,每次出队一个节点并处理,然后将其非空子节点入队。 c. 完成循环后,意味着当前层所有节点已处理完毕,下一层所有节点已全部入队,可以开始新的一轮。
解题场景:
- 求二叉树的最大宽度:在每层遍历时,记录节点的位置编号(例如,根节点为1,左子节点为
2*i,右子节点为2*i+1),该层宽度即为最右编号减最左编号加1。 - 找树左下角的值:层次遍历时,每层记录第一个节点,最后一层的第一个节点即是。
- 二叉树的右视图/左视图:取每层的最后一个/第一个节点。
- 判断完全二叉树:层次遍历中,遇到第一个空节点后,之后不应该再出现非空节点。
3.2 与深度遍历的抉择:何时用BFS?
一个关键的经验是:当问题的答案与根节点到目标节点的“最短路径”或“最少步数”相关时,优先考虑层次遍历(BFS)。例如,“二叉树的最小深度”如果用DFS(后序)需要遍历所有节点,而BFS一旦遇到第一个叶子节点就可以立即返回,效率更高。
对比示例:求二叉树的最小深度
- DFS(后序)解法:需要递归到每个叶子节点,取左右子树最小深度中的较小值+1。时间复杂度O(N)。
def minDepth(root): if not root: return 0 if not root.left: return minDepth(root.right) + 1 if not root.right: return minDepth(root.left) + 1 return min(minDepth(root.left), minDepth(root.right)) + 1 - BFS解法:一层一层下去,第一次遇到左右孩子都为空的节点时,当前的深度就是最小深度。在最理想情况下(平衡树),不需要遍历所有节点。
from collections import deque def minDepth(root): if not root: return 0 queue = deque([root]) depth = 1 while queue: level_size = len(queue) for _ in range(level_size): node = queue.popleft() if not node.left and not node.right: return depth if node.left: queue.append(node.left) if node.right: queue.append(node.right) depth += 1 return depth
显然,对于求最小深度这类问题,BFS通常更优。
4. 线索二叉树:优化遍历的空间与时间
线索二叉树是对普通二叉树的一种改造,目的是利用空指针域来存放指向某种遍历次序下的前驱或后继节点的指针。这些指针称为“线索”。它的核心价值在于,可以在不使用递归栈或辅助栈的情况下,实现快速的中序(或其他次序)遍历,空间复杂度从O(H)(树高)降为O(1)。
4.1 为什么要线索化?一个实际场景
假设你有一个非常大的二叉树存储在磁盘或数据库中,你需要频繁地进行中序遍历。每次递归遍历,即便有尾递归优化,函数调用栈的深度也可能达到树高,对于斜树(退化成链表)就是O(N)的空间消耗。线索化是一种“空间换时间(或换便利性)”的预处理操作。一次线索化之后,后续的无数次遍历都可以在线性时间内用常数额外空间完成。
4.2 核心技巧:理解线索指针与标志位
线索化的关键是在节点结构中加入两个布尔标志位(例如ltag和rtag)来区分指针是指向孩子还是线索。
ltag == 0:lchild指向左孩子。ltag == 1:lchild指向前驱线索。rtag == 0:rchild指向右孩子。rtag == 1:rchild指向后继线索。
中序线索化的递归算法框架:算法需要一个全局变量pre来记录刚刚访问过的节点(即当前节点的前驱)。
class ThreadedNode: def __init__(self, val): self.val = val self.left = None self.right = None self.ltag = 0 # 0: child, 1: thread self.rtag = 0 pre = None def inThreading(node): global pre if not node: return # 递归线索化左子树 inThreading(node.left) # 处理当前节点:建立前驱线索 if not node.left: node.ltag = 1 node.left = pre # 左指针指向前驱 # 处理前驱节点:建立后继线索 if pre and not pre.right: pre.rtag = 1 pre.right = node # 前驱的右指针指向当前节点(后继) pre = node # 更新前驱 # 递归线索化右子树 inThreading(node.right)线索化过程本身就是一个中序遍历,只是在访问节点时,增加了修改空指针和标志位的操作。
4.3 遍历线索二叉树:非递归且无栈
线索化完成后,中序遍历变得异常高效。以中序线索二叉树为例,找第一个节点和最常用的求后继操作是关键。
1. 求中序下的第一个节点:从根出发,一直沿着左孩子(ltag==0)往下走,直到没有左孩子,那个节点就是中序第一个节点。2. 求节点p的中序后继: * 如果p.rtag == 1,则p.right直接就是后继。 * 如果p.rtag == 0,则后继是p的右子树的中序第一个节点(即右子树中最左下角的节点)。
非递归中序遍历代码:
def inOrderTraversal_threaded(root): result = [] # 1. 找到中序第一个节点 p = root while p and p.ltag == 0: # 有左孩子就一直向左 p = p.left # 2. 依次访问后继 while p: result.append(p.val) # 获取后继 if p.rtag == 1: # 有后继线索 p = p.right else: # 无后继线索,后继在右子树的最左下方 p = p.right if p: while p.ltag == 0: # 有左孩子就一直向左 p = p.left return result这个遍历过程没有递归调用,也没有显式地使用栈,空间复杂度是严格的O(1)。对于需要频繁遍历的静态二叉树,线索化的优势非常明显。
4.4 线索二叉树的局限与思考
线索二叉树并非银弹,它主要适用于遍历密集型且树结构不常变动的场景。因为一旦树的结构发生改变(插入、删除节点),维护线索的成本会非常高,几乎需要重新线索化。在现代计算机内存充足、递归栈开销不大的背景下,普通递归遍历因其编码简单、易于理解,在大多数日常算法问题中仍是首选。但理解线索二叉树,能让你深刻体会到数据结构设计是如何在时间、空间和代码复杂度之间进行精妙权衡的。
5. 综合应用:由遍历序列重建二叉树
这是考察对遍历顺序理解深度的经典问题。最常见的是**已知中序序列+另一种序列(前序或后序)**来唯一确定一棵二叉树。前提是树中节点值必须互不相同。
5.1 前序+中序重建二叉树
核心技巧:利用前序确定根,利用中序划分左右子树。
- 前序遍历的第一个节点一定是当前子树的根节点。
- 在中序遍历中找到这个根节点,其左侧序列即为左子树的中序,右侧序列即为右子树的中序。
- 根据左子树中序序列的长度,可以在前序序列中划分出左子树的前序和右子树的前序。
- 递归地对左子树和右子树进行上述操作。
代码实现与细节:
def buildTree(preorder, inorder): if not preorder or not inorder: return None # 1. 前序第一个是根 root_val = preorder[0] root = TreeNode(root_val) # 2. 在中序中找到根的位置 root_index_in_inorder = inorder.index(root_val) # 假设值唯一,可用哈希表优化 # 3. 划分中序序列 left_inorder = inorder[:root_index_in_inorder] right_inorder = inorder[root_index_in_inorder+1:] # 4. 划分前序序列。关键:左子树前序的长度等于左子树中序的长度 left_preorder = preorder[1:1+len(left_inorder)] right_preorder = preorder[1+len(left_inorder):] # 5. 递归构建 root.left = buildTree(left_preorder, left_inorder) root.right = buildTree(right_preorder, right_inorder) return root注意:这里直接使用
list.index()方法查找根节点在中序中的位置,时间复杂度为O(N)。在递归过程中,如果每次都这样线性查找,总复杂度会达到O(N²)。一个标准的优化是预处理中序序列,用一个哈希表(字典)记录每个值对应的索引,这样每次查找就是O(1)的时间。这是面试中常考的优化点。
5.2 后序+中序重建二叉树
核心技巧:与“前序+中序”对称,后序的最后一个节点是根。
- 后序遍历的最后一个节点是当前子树的根节点。
- 在中序中找到根节点,划分左右子树的中序序列。
- 根据左右子树中序序列的长度,在后序序列中划分出左右子树的后序序列(注意顺序)。
- 递归构建。
代码关键点:
def buildTree(inorder, postorder): if not inorder or not postorder: return None root_val = postorder[-1] root = TreeNode(root_val) root_index = inorder.index(root_val) left_inorder = inorder[:root_index] right_inorder = inorder[root_index+1:] # 划分后序序列:左子树后序长度 = 左子树中序长度 left_postorder = postorder[:len(left_inorder)] right_postorder = postorder[len(left_inorder):-1] # 排除最后一个根元素 root.left = buildTree(left_inorder, left_postorder) root.right = buildTree(right_inorder, right_postorder) return root5.3 为什么前序+后序不能唯一确定?
这是一个重要的知识点。已知前序和后序序列,可以确定根节点(前序第一个,后序最后一个),但无法确切地区分左右子树的边界。例如,对于根节点A,如果它只有一个孩子B,那么在前序中是[A, B],后序中是[B, A]。此时B可以是左孩子也可以是右孩子,树的结构不唯一。只有当二叉树是真二叉树(每个节点有0个或2个子节点)时,前序+后序才能唯一确定。
6. 迭代遍历:显式栈模拟递归过程
递归遍历代码简洁,但有时我们需要显式控制遍历过程,或者语言对递归深度有限制,这时就需要迭代法。迭代法的核心是用栈来模拟递归调用栈。
6.1 迭代前序遍历
思路最直接:因为访问顺序是“根左右”,我们可以在访问根之后,先将右孩子入栈,再将左孩子入栈。这样出栈顺序就是“左、右”,符合要求。
def preorderTraversal(root): if not root: return [] stack, result = [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 result6.2 迭代中序遍历
这是最需要技巧的一种。思路是:用一个指针cur表示当前节点,用栈保存还未处理的根节点。
- 一直向左走到底,沿途节点全部入栈(相当于递归压栈)。
- 弹出栈顶节点并访问(相当于处理当前根节点)。
- 将
cur指向弹出节点的右孩子,重复步骤1。
def inorderTraversal(root): stack, result = [], [] cur = root while cur or stack: # 1. 一直向左走到底 while cur: stack.append(cur) cur = cur.left # 2. 弹出并访问 node = stack.pop() result.append(node.val) # 3. 转向右子树 cur = node.right return result6.3 迭代后序遍历
后序的迭代写法有多种,一种巧妙的方法是:修改前序遍历的顺序为“根右左”,然后将结果反转,即得到“左右根”。因为前序是“根左右”,我们只需调换左右子节点的入栈顺序,得到“根右左”的遍历序列,其逆序恰好是后序“左右根”。
def postorderTraversal(root): if not root: return [] stack, result = [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] # 反转结果另一种更符合后序逻辑的方法是使用一个prev指针记录上一个访问的节点,来判断右子树是否已处理完毕,但代码稍复杂。上述“前序变体+反转”的方法在面试中非常实用且易于记忆。
7. 莫里斯遍历:极致的空间优化
莫里斯遍历利用叶子节点的空指针,在遍历过程中临时建立线索,实现空间复杂度O(1)的遍历,且不破坏原树结构(遍历结束后恢复)。它是对线索二叉树思想的一种动态应用。
以中序莫里斯遍历为例,其核心思想是:对于当前节点cur,
- 如果
cur无左孩子,则访问cur,并令cur = cur.right。 - 如果
cur有左孩子,则找到cur左子树上的最右节点pre(即中序下cur的前驱节点)。- 如果
pre.right为空,则将其指向cur(建立临时线索),然后cur = cur.left。 - 如果
pre.right指向cur(说明左子树已遍历完),则断开该线索(pre.right = None),访问cur,然后cur = cur.right。
- 如果
这个过程有点绕,但本质是利用左子树的最右节点的空右指针,来存储回溯到根节点的路径。代码实现如下:
def morrisInorder(root): result = [] cur = root while cur: if not cur.left: # 情况1:无左孩子,直接访问当前节点,转向右子树 result.append(cur.val) cur = cur.right else: # 情况2:有左孩子,找前驱节点pre 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: # 情况2b:线索已存在,说明左子树已遍历完 pre.right = None # 断开线索 result.append(cur.val) # 访问当前节点 cur = cur.right # 转向右子树 return result莫里斯遍历的优点是空间复杂度为O(1),缺点是实现复杂,且会修改树的结构(尽管是临时的)。在内存极度受限的嵌入式环境或处理超大规模树时,它是一个值得考虑的选项。对于日常算法面试,理解其思想比默写代码更重要。
遍历二叉树,从死记顺序到理解其访问时机和问题视角,是一个思维上的飞跃。前序是“行动派”,中序是“观察家”,后序是“总结者”,层次是“组织者”。不同的遍历方式,就是解决不同类型树形问题的不同武器。线索化和莫里斯遍历则展示了如何通过精巧的数据结构设计来突破常规遍历的空间限制。下次再面对二叉树问题时,不妨先问自己:解决这个问题,需要在哪个时机知道哪些信息?答案往往就藏在遍历顺序的选择里。