☰
深度优先遍历二叉树:递归与栈实现详解
2026/10/17 1:11:49 网站建设 项目流程

直接上手之前,我先说句实在话:深度优先遍历(DFS)是二叉树算法里最基础、也最容易“明明会背代码,却说不清原理”的一个知识点。代码就那么几行,递归版尤其短,可一旦面试官追问“为什么中序遍历的非递归实现要用两个循环”“后序为什么不能直接照搬前序的栈思路”,很多人就开始卡壳。这篇不是教你抄模板,而是带着你把递归、栈、遍历顺序这三件事彻底理顺,手动推演每一步,再配合代码、实测结果和排错经验,真正做到自己会写、会改、会讲。适合准备算法面试的开发者,也适合刚接触树结构、想一次性把DFS吃透的初学者。

1. 深度优先遍历算法:先解决“是什么”和“为什么”

1.1 核心思想:一条路走到黑,再回头

深度优先遍历这个“深度优先”四个字,说白了就是:从根节点出发,一直沿着某个方向往下探,能走多远走多远,直到碰到没有孩子的节点(叶子节点),再回头去探另一条没走过的分支。整个过程像你在一个迷宫里面,进了一条巷子就一路走到底,撞墙了才退回来走下一条。

二叉树本身就是递归定义的天然载体,每个节点最多有两个孩子,左孩子和右孩子。DFS套在二叉树上时,“分支”就是左右孩子,而“回头”这个动作在递归代码里其实就是函数返回。理解这一点非常关键,因为递归版和非递归版的所有差异,本质上都是在模拟“深入”和“回头”这一进一退的过程。

这里顺带说一下深度优先和广度优先的区别。广度优先(BFS)是按层往外扩散,先访问根,再访问第二层所有节点,再第三层,是一圈一圈往外看。而深度优先是垂直方向一路扎下去。我习惯用一个生活化类比:BFS像烧一锅开水,热量一层层往外传;DFS像一滴墨水滴在水里,沿着一条纹路缓缓下沉。两者面对同样一颗树,最终都能访问到所有节点,但访问顺序完全不同,而有些算法题的答案恰恰就是“顺序”本身。

1.2 为什么遍历顺序在DFS里如此重要

对于二叉树来说,DFS其实有三种不同的执行顺序:前序遍历、中序遍历、后序遍历。这三个名称看起来很抽象,其实记住一句话就够了——它们描述的核心只有一件事:根节点被“处理/访问”的时机。

  • 前序遍历:先读根,再走左子树,最后走右子树,顺序是根 -> 左 -> 右。
  • 中序遍历:先走左子树,再读根,最后走右子树,顺序是左 -> 根 -> 右。
  • 后序遍历:先走左子树,再走右子树,最后读根,顺序是左 -> 右 -> 根。

这三个顺序的名字不是随便起的,“前”“中”“后”指的就是根节点在从左到右的访问序列里所处的位置。左子树永远先于右子树被访问,这是DFS的固定前提,剩下的就是看根插在哪一步。

为什么顺序这么重要?因为很多高级算法题目靠的就是这个顺序。比如二叉搜索树的中序遍历结果一定是有序数组,这是判断一颗树是否满足搜索树性质最常用的方法;后序遍历天然适合做树的删除操作,因为你要先处理完孩子节点才能安全地释放/删除当前节点。也就是说,掌握三种顺序不是应试机械记忆,而是给后续学习打地基。

2. 准备工作:手写二叉树节点和测试用例

2.1 二叉树节点的定义与构造

既然要实操,第一步就是把二叉树这个结构用代码定义出来。在Python里,最常用的二叉树节点类长这样:

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

这是最常见的定义方式,val存当前节点的值,left指向左孩子,right指向右孩子,默认均为None。没有children数组,因为二叉树每个节点最多两个孩子,左右分开存是最清晰也最省内存的做法。

在实际刷题或面试场景中,这个类往往已经是环境给好的,你只需要专注实现遍历函数。但如果你是自己在本地练习,强烈建议把这个类单独放在一个模块里,比如命名为tree_node.py,方便后续其他demo统一复用。我见过很多新手在同一个文件里反复粘贴这个类,代码一多就显得非常杂乱。

单独的节点类还需要配一个构造器方法,方便快速构建测试树。因为很多二叉树算法题输入的是类似[1,None,2,3]这样的层序序列,写一个从层序列表建树的工具既能用于本地调试,也能帮你直观理解树的形态。但在这个项目的起步阶段,我们先用最简单的链式方式手工搭树,因为手工搭树更直观,你能清楚看到每个节点的位置。

2.2 一棵用于测试的样例树

为了后面验证三种遍历顺序,我构造一颗标准平衡树,结构如下:

1 / \ 2 3 / \ / \ 4 5 6 7

对应的构造代码:

def build_sample_tree(): root = TreeNode(1) root.left = TreeNode(2) root.right = TreeNode(3) root.left.left = TreeNode(4) root.left.right = TreeNode(5) root.right.left = TreeNode(6) root.right.right = TreeNode(7) return root

这颗树不偏不倚,左右子树都有,且每个路径长度相同,特别适合用来做遍历结果的验证。按前面说过的顺序,它的三种DFS遍历结果分别是:

  • 前序:1 -> 2 -> 4 -> 5 -> 3 -> 6 -> 7
  • 中序:4 -> 2 -> 5 -> 1 -> 6 -> 3 -> 7
  • 后序:4 -> 5 -> 2 -> 6 -> 7 -> 3 -> 1

我建议大家先别看代码,自己拿这张图手动推一遍这三个顺序。推完再对照代码,你会明显感觉递归版本的指针走向清晰很多。这个手动推演的过程我在面试前也会做,它能帮你快速验证自己是否真的理解了DFS的访问顺序,而不是只会机械地写代码。

3. 递归实现:最直观的三种遍历写法

3.1 前序遍历:根 -> 左 -> 右

前序遍历的递归写法是这个系列里最简单的,核心逻辑就是:先处理当前节点的值,然后递归处理左子树,再递归处理右子树。用Python写出来:

def preorder_traversal(root): result = [] def dfs(node): if node is None: return result.append(node.val) dfs(node.left) dfs(node.right) dfs(root) return result

很多教材会写成一行返回列表拼接的简洁形式:

def preorder_traversal_concise(root): if root is None: return [] return [root.val] + preorder_traversal_concise(root.left) + preorder_traversal_concise(root.right)

简洁版确实好看,我也曾经很喜欢这种写法。但需要提醒的是,这种写法在递归深入时会反复创建新列表并做拼接操作,当树的节点数达到几千、几万时,内存开销和耗时都比“用result数组记录”的版本差不少。而且在Python里递归本身有调用栈深度限制,我建议优先采用带辅助函数和累积结果列表的写法,它更贴近工程实践。

拿我们刚才构造的样例树走一遍:dfs访问节点1,加入result,然后走左子树;节点2入result,继续走它的左子树;节点4加入,左右孩子都是空,返回;回到节点2再走右子树节点5,返回;回到节点1再走右子树节点3……最终得到[1, 2, 4, 5, 3, 6, 7]。

这里有个新手容易混淆的点:前序遍历的“根左右”是指每个子树的根都先被访问。所以你看到节点3时,其实是已经处理完整棵左子树了,并不是DFS在偷懒或跳步。一颗树可以被递归地拆成无数个“根+左右子树”的小块,每一块都遵循同样的处理顺序,这就是递归的数学归纳式思维。

3.2 中序遍历:左 -> 根 -> 右

中序遍历的递归代码只调换了一行的位置,把“访问根节点”的动作放在左子树递归和右子树递归之间:

def inorder_traversal(root): result = [] def dfs(node): if node is None: return dfs(node.left) result.append(node.val) dfs(node.right) dfs(root) return result

别看只是把result.append(node.val)往下挪了一行,整棵树的访问顺序就完全变了。它体现的是“先把我左子树的所有节点全部处理完,再轮到我,再处理右子树”。如果你想验证自己有没有理解,可以试试在纸上对样例树逐步标注“访问”动作,观察根节点1确实是在节点4、2、5之后才被访问的。

中序遍历在二叉搜索树(BST)里特别有意义。BST的特点是左子树所有节点值小于根,右子树所有节点值大于根,所以中序遍历出来的结果天然就是从小到大排序的。这一点可以直接用来判断一棵树是否是有效的BST;也可以用来做BST的第K小节点查找,把中序遍历列表取第k个值即可。我第一次意识到这个性质的时候,觉得这个数据结构设计得非常精妙,树的形状本身就在“编码”顺序信息。

3.3 后序遍历:左 -> 右 -> 根

后序遍历的递归写法同样只是调整访问时机:先递归左子树,再递归右子树,最后再访问根节点。

def postorder_traversal(root): result = [] def dfs(node): if node is None: return dfs(node.left) dfs(node.right) result.append(node.val) dfs(root) return result

后序遍历是三种顺序里最容易让人记混的一个。很多人背口诀“左右根”能背出来,但一到递推时就开始迷茫:为什么根节点反而最后才访问?我建议这样理解:后序遍历隐含了一种“先交作业再走人”的逻辑。你想处理完当前节点,前提是它的两个孩子都已经处理完毕、不会再产生新的操作了。所以当前节点一定是“最后出场”的,无论它在整颗树中的位置是深是浅。

后序遍历有个非常经典的工程用途:二叉树的删除或内存释放。因为只有孩子节点都被删除后,当前节点才能安全地删除,否则你就丢掉了孩子节点的引用,产生内存泄漏。操作系统里进程树的回收顺序也有类似思想,子进程先退出,父进程再退出。理解了这一点,后序遍历就不再是简单的口诀,而是一种有明确意义的遍历策略。

4. 非递归实现:用栈模拟系统调用栈

4.1 前序遍历非递归版:为什么先压右孩子

解决了递归版本,非递归版本才是真正考验你是否理解DFS底层过程的地方。递归函数在系统层面会不断压栈、出栈,保存的是函数调用现场;非递归实现需要我们手动维护一个栈来模拟这个过程。

前序遍历的非递归代码很简短:

def preorder_traversal_iterative(root): if root is None: 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

这里有一个非常关键的动作:压栈时为什么先压右孩子再压左孩子?因为栈是后进先出(LIFO)的结构。我们要保证下一次出栈拿到的是左孩子,就必须让左孩子最后入栈、最先出栈。先压右后压左,正是为了保证访问顺序是“先左后右”。

这段代码的执行轨迹可以对样例树手动模拟:第一次弹出节点1,加入result,压入右孩子3,再压入左孩子2;接着栈顶是2,弹出加入result,压入5、4;再弹4,继续弹5,再到3……最终结果恰好是1、2、4、5、3、6、7,和递归前序输出一致。这个“先压右再压左”的细节,面试时经常被问到,是最常见的考点之一。

4.2 中序遍历非递归版:一路向左到底

中序遍历的非递归实现比前序曲折很多,因为根节点的访问时机被推迟到了左子树全部处理完之后。核心思路:从当前节点开始,不断把左孩子压入栈中,直到当前节点为None,然后出栈一个节点,这个节点就是当前子树最左的节点,访问它,再把指针转向它的右孩子。

def inorder_traversal_iterative(root): result = [] stack = [] cur = root while cur or stack: while cur: stack.append(cur) cur = cur.left cur = stack.pop() result.append(cur.val) cur = cur.right return result

我用“一路向左到底”来记这个逻辑:先把所有能走到的左孩子全压进栈,压到底之后再弹出来做访问,每弹一个,就立刻去看它的右孩子,把右孩子所在的整条左链路继续压栈。这个过程完美对应了中序“左 -> 根 -> 右”的访问序列。

举例验证:cur=1,压1,cur=2;压2,cur=4;压4,cur=None。此时栈是[1,2,4],弹出4并访问,cur=4.right=None,内层循环结束,外层循环继续,栈非空,弹出2并访问,cur=2.right=5;于是压5,再压5的左孩子None……最终访问顺序是4、2、5、1、6、3、7。手动走一遍一段代码,真的比反复看十遍讲解都更能建立起直觉。

4.3 后序遍历非递归版:双栈思路和反转技巧

后序非递归是最需要动脑子的一个。因为后序要求左右孩子都访问完了,最后才访问根,这导致“访问根”的动作发生得特别晚,直接模拟比较绕。网上的解法很多,我推荐两种主流的,一种是双栈,一种是反转前序结果。

双栈法的核心思路:把后序遍历看成前序遍历的镜像变体。先按“根 -> 右 -> 左”的顺序用栈收集结果,然后整体反转,得到“左 -> 右 -> 根”。实现如下:

def postorder_traversal_iterative(root): if root is None: 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]

对比前序非递归,这里只做了两处改动:压栈时先压左孩子再压右孩子;最后把结果反转。第一遍看可能觉得是个“奇技淫巧”,但我建议你手动模拟一遍:按上述代码,收集到的顺序是1,3,7,6,2,5,4,反转后是4,5,2,6,7,3,1,正好是后序遍历。这个技巧非常省代码,而且理解成本低,是工程里最实用的后序非递归写法。

另一种双栈法更“正宗”地模拟遍历状态,但代码量较大。实际刷题和工作中我几乎只用反转法,除非题目明确禁止使用列表反转或者在遍历过程中需要实时处理节点状态,否则没必要自己给自己增加心智负担。

4.4 统一迭代法:一套模板吃透三种顺序

最后分享一个我后来才真正掌握的写法——统一迭代法,它可以只用一套框架分别生成三种遍历顺序,区别只在于“何时标记null”或者说“何时下一个访问根节点”。

核心思想是:每个节点先压栈,但给它打上一个特殊标记(常用None)。当栈顶弹出的是标记,说明当前栈顶下一个元素应该被访问了。按不同的遍历顺序,调整压栈入栈顺序即可。

前序统一迭代版:

def preorder_traversal_unified(root): result = [] stack = [] if root: stack.append(root) while stack: node = stack.pop() if node: if node.right: stack.append(node.right) if node.left: stack.append(node.left) stack.append(node) stack.append(None) else: node = stack.pop() result.append(node.val) return result

中序统一迭代版只需要调整压栈顺序:

def inorder_traversal_unified(root): result = [] stack = [] if root: stack.append(root) while stack: node = stack.pop() if node: if node.right: stack.append(node.right) stack.append(node) stack.append(None) if node.left: stack.append(node.left) else: node = stack.pop() result.append(node.val) return result

后序同理,压栈顺序改成:先压根,再压右孩子,再压左孩子,中间嵌一个null标记。这套模板的逻辑核心是:把每个节点入栈两次,第一次真实入栈,第二次用null标记表示“我该被访问了”。由于孩子节点在标记之前入栈,出栈时孩子会先被处理,从而模拟了DFS回溯的过程。

统一迭代法代码量略大,但它避免了对每种遍历顺序记忆完全不同的写法,适合那些想“一套逻辑走天下”的开发者。递归转非递归时,受害者最多的问题就是“转完结果不一致”,统一迭代法能有效减少这类出错概率。

5. 复杂度分析与性能实测

5.1 时间复杂度和空间复杂度的完整推导

来分析一下递归和非递归的时间空间复杂度。无论是哪种遍历方式,每个节点都会被访问且仅访问一次,所以时间复杂度都是O(n),这点没有争议。

空间复杂度需要分情况讨论。递归版的空间不是O(1),因为递归调用会占用系统调用栈。最坏情况下,如果二叉树退化成一条链(每个节点只有一个孩子),递归深度是n,空间复杂度O(n)。平均情况下,也就是平衡树,递归深度是log n,空间复杂度O(log n)。非递归版本使用显式栈,同理:平衡树时栈的最大深度也是O(log n),退化成链时O(n)。

这里最容易被误解的一点:非递归一定比递归省内存吗?答案是不一定。非递归省的是“每次函数调用都要保存局部变量、返回地址、栈帧”的系统开销,但显式栈本身也要存节点引用。在平衡树上,两者空间复杂度同阶;在链表型树上,两者都是O(n)。所以选择非递归的主要理由不是“更省内存”,而是“避免Python递归深度上限”和“更可控”。

我一向建议在本地做一次简单的性能对照实验,用同样一棵10000节点的树分别跑递归和非递归版本,观察耗时和是否抛RecursionError。这种数据比理论分析更直观,也能帮你理解不同写法的实际差距。

5.2 递归深度的隐患:当树变成链表

Python解释器对函数递归嵌套深度有限制,默认上限通常在1000左右。如果二叉树退化成很深的链状结构,递归写法会直接报错:

RecursionError: maximum recursion depth exceeded

这是一个非常现实的坑。我在练习二叉树相关题目时,就曾构造了一棵高度为1200的树,递归前序遍历直接崩了,而非递归版本轻松跑完。解决方案有三种:

  • 使用非递归写法,手动用栈模拟。
  • 通过sys.setrecursionlimit()调高递归上限,但这属于临时手段,治标不治本,而且调太高可能导致程序崩溃而不是优雅报错。
  • 重构代码逻辑,用迭代替代深层递归。

在实际项目中,优先选择非递归版肯定更稳。刷题时如果题目限定节点数量不大,递归写起来更快更清晰,大家可以根据场景取舍。面试时如果被问“递归版有什么问题”,能答出递归深度限制这一点,会是一个很好的加分项。

6. 实战中的常见问题与排错心得

6.1 空节点处理和返回值约定

遍历函数对空树应当返回什么?多数情况下是空列表[]。很多新手的错误是返回None,然后调用方一遍历代码就报TypeError。这里我建议约定:所有遍历函数的入口统一先判断root is None就返回[],保证返回值类型始终是list,方便上层继续做索引、切片等操作。

对于非递归版尤其要小心一处:栈初始化时如果直接把root放进去而不做判空,遇到空树时循环里node = stack.pop()就会从空栈里取元素报错。我的习惯是开头直接判空返回,然后才能安全地初始化栈。空节点处理看似小事,却是二叉树代码里最容易出bug的地方。

还有一个容易被忽略的细节:遍历过程中遇到节点时,要判断的是node.left和node.right是否为None,而不是判断node本身。因为只要从栈中弹出的node不是None,就说明它是真实节点,真正需要判空的是它的左右孩子引用。

6.2 遍历结果校验:别相信肉眼看代码

写完一个遍历函数,最容易犯的错误就是“觉得代码没问题,直接略过测试”。我在这个项目里给自己定了一条规矩:每次实现完一个版本,立刻用样例树跑真实输出,并和手算结果核对。

校验方式可以很简单:

if __name__ == "__main__": root = build_sample_tree() print("前序:", preorder_traversal(root)) print("中序:", inorder_traversal(root)) print("后序:", postorder_traversal(root))

输出结果应该是:

前序: [1, 2, 4, 5, 3, 6, 7] 中序: [4, 2, 5, 1, 6, 3, 7] 后序: [4, 5, 2, 6, 7, 3, 1]

这一步看似多余,却是排查逻辑错误成本最低的方式。尤其是非递归后序反转法,如果压栈顺序写反,结果很可能就变成[3,7,6,2,5,4,1]这类明显错误顺序。这时候不要盲目改代码,先在纸上画出栈的变化过程,找出是哪一步偏离了预期,往往比自己盯着代码干瞪眼效率高得多。

我还习惯用单节点树和空树做边界测试,比如root = TreeNode(1)时,三种遍历都应该输出[1];root = None时,输出[]。边界测试成本极低,但能覆盖大量隐藏bug。

6.3 递归转迭代的关键转换思路

很多人在学习非递归时有个误区:试图把递归代码一行行“翻译”成迭代,结果越改越复杂。实际上,递归转迭代最核心的思路是:识别递归过程中需要“记住”的状态,然后显式地用栈维护。

以中序为例,递归过程的“状态”有两个:当前正在处理的节点、已经拿到但还没处理的父节点链。递归系统通过调用栈保存这些状态,非递归版本通过手工维护一个stack保存。每次访问完一个节点,你不需要立刻处理它的右子树,而是把右孩子重新作为“新的当前节点”继续相同的循环流程,这就是非递归代码中cur = cur.right那句的来历。

我建议练习时写下每个递归步骤的“栈状态快照”,比如:

  • 开始时栈 [1]
  • 访问1后压入右左孩子 -> 栈 [3, 2]
  • 弹出2访问后压入其右左孩子 -> 栈 [3, 5, 4]

这样的快照记录三次,你对“模拟系统栈”这个概念的理解就会自然形成。比背十个模板都好使。

7. 深度优先遍历的应用场景:从面试题到业务落地

7.1 二叉搜索树的中序有序特性

DFS三兄弟里,中序遍历在二叉搜索树上的应用可以说是“王牌场景”。BST是面试高频考点,中序遍历有序这一特性可以用来:

  • 校验一棵树是否为合法的BST。最简单的方式是中序遍历收集结果,判断列表是否严格递增。
  • 查找第K小的元素。中序遍历到第K个节点直接返回,不需要完整遍历整棵树。
  • 将BST转换成有序双向链表。本质就是在中序遍历过程中修改节点指针。

实现合法性校验时有个经典陷阱:只能判断“当前节点值比左孩子大、比右孩子小”,这是不够的。因为BST要求左子树的所有节点都小于根节点,右子树的所有节点都大于根节点。单纯比较孩子节点会漏掉“跨层级”的大小关系错乱。用中序遍历有序性做校验,能天然规避这个问题。

7.2 路径求和与回溯思想

DFS最常见的应用之一是路径类问题,比如“求二叉树中所有从根到叶子且总和等于target的路径”。这类问题就是前序遍历的变体:在前序过程中携带当前路径和,向左子树或右子树深入,到达叶子节点时做判断,然后回退。

核心代码模型大约是:

def path_sum(root, target): result = [] def dfs(node, current_sum, path): if node is None: return current_sum += node.val path.append(node.val) if node.left is None and node.right is None and current_sum == target: result.append(path[:]) else: dfs(node.left, current_sum, path) dfs(node.right, current_sum, path) path.pop() dfs(root, 0, []) return result

这个套路里藏着一个技术细节:path[:]是在把当前路径做拷贝之后放入result。如果不拷贝,后面path.pop()会污染已经保存的路径值,这是这类题目最经典的大坑。回溯思想也是从这里来的:递归进入时添加状态,递归返回前撤销状态,保证每层调用看到的路径状态独立。

7.3 序列化、克隆与最近公共祖先

DFS在后序场景里还有一个实用性很强的用途——二叉树的序列化与反序列化。前序序列化配合空节点标记是最常见的实现方式:遍历时遇到None就写一个特殊符号,反序列化时用递归重建。由于前序是先根后子树,重建时能比较自然地确定每个子树的范围。

克隆一棵二叉树也是后序思路的典型场景:要想克隆当前节点,必须先克隆它的左右子树,再把当前节点挂上去。这正好对应“左 -> 右 -> 根”的处理顺序,很多二叉树克隆实现都采用后序递归。

最近公共祖先(LCA)问题用DFS同样非常自然:分别递归寻找p和q,如果当前节点的左子树能找到p,右子树能找到q,那么当前节点就是它们的最近公共祖先。这也是DFS回溯过程中“边深入边汇总信息”的经典案例。

我在实际辅导别人做项目时发现,DFS三个顺序的应用场景往往嵌套在一起。前序负责“自上而下”地做决策,后序负责“自下而上”地汇总结果,中序负责处理需要顺序信息的结构。把这三个顺序打通之后,二叉树相关的面试题基本就打开了一大半。

说回我自己练这个题型的习惯:每次遇到二叉树DFS相关的问题,第一件事不是写代码,而是把样例树画出来,然后在纸上手动走一遍选定的遍历顺序。这个方法看起来慢,实际是排查理解偏差最有效的方式。代码出错了,也先回去看遍历顺序,很少是语法问题,更多是顺序逻辑没有想清楚。这套“先手推、再编码、后验证”的流程,我做了很多年,始终觉得比直接背模板可靠得多。希望这篇复盘能帮你在深入了解深度优先遍历的路上少走几步弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询