news 2026/10/17 3:24:33

Python二叉树深度优先遍历:递归与迭代写法及实战应用

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Python二叉树深度优先遍历:递归与迭代写法及实战应用

用递归去写树结构,代码往往只有几行,但真正想追每一步执行时,思维很容易打结。这篇文章属于 Python 算法系列的二叉树专题,核心聊的是深度优先遍历算法(DFS)的递归与迭代写法,以及它在实际问题里到底怎么用。适合三类人看:刚接触数据结构的学生、准备算法面试的朋友,以及在项目里被树形数据折腾过的后台开发。读完你能拿到一套能直接跑的遍历模板,还能避开一些只有真正跑过代码才会遇到的问题。

1. 树的遍历从什么开始:一棵二叉树的代码长相

1.1 用类把二叉树“造”出来

在 Python 里定义一棵二叉树,最常见的做法就是写一个节点类。每个节点只关心三件事:自己的值、左孩子、右孩子。孩子为空就用None表示,整棵树就是一层套一层的引用关系。

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

这段类代码几乎是所有树题答案的前三行。别看它简单,里面藏着两个容易忽略的细节。

第一,val默认给0,是为了创建节点时只传一个值也能正常工作。第二,left和right默认是None,意味着只传val时,构造出来的就是一个叶子节点。这两个默认值写在这里,不是拍脑袋决定的,而是为了保证“空子树”有明确的表示方式,后面的递归出口全靠它。

造一棵具体的树也很直接:

root = TreeNode(1) root.left = TreeNode(2) root.right = TreeNode(3) root.left.left = TreeNode(4) root.left.right = TreeNode(5)

这棵树长这样:

1 / \ 2 3 / \ 4 5

很多人在构造这一步没什么问题,问题出在“遍历”的时候。脑子里容易把树想成一串平铺的节点,却忽略了树的递归定义:一棵二叉树,要么是空树,要么是一个根节点加上左右两棵子树。这个递归定义直接决定了 DFS 的代码形状,也决定了为什么遍历算法的核心逻辑可以短到几行。

1.2 三种访问顺序的差异

深度优先遍历到了某个节点,总要面对三个动作:处理当前节点、深入左子树、深入右子树。三个动作排列组合一下,常见的只有三种:

遍历方式访问顺序一句话记忆典型用途
前序遍历中、左、右先处理自己,再管孩子复制树、序列化
中序遍历左、中、右先左到底,再处理自己二叉搜索树按序输出
后序遍历左、右、中孩子都处理完,再处理自己自底向上计算、释放子树

这三种顺序的差异,核心就是“当前节点的处理动作放在哪个位置”。放最前面就是前序,放中间就是中序,放最后就是后序。代码层面它们几乎一样,差别只是几行代码的顺序。可顺序不同,解决的实际问题完全不同。

举个例子,如果一棵树表示数学表达式,前序遍历得到的是前缀表达式,中序遍历得到的近似中缀表达式,后序遍历得到的是后缀表达式。同一棵树,只是改变访问顺序,输出含义就变了。这也是为什么很多算法题表面在考遍历,实际考的是“你知不知道在哪个时机处理节点”。

2. 递归版深度优先遍历:顺着函数调用理解前中后序

递归版 DFS 与其说是一个“算法”,不如说是在利用函数调用栈。你不需要手动维护栈,Python 会替你完成。理解递归版的关键,是明白每次函数调用钻进左子树或右子树时,当前函数并没有结束,它还在栈上等着,等左右子树全部返回后,才会继续执行后面的代码。

2.1 递归停止条件:None 节点是出口

写任何树的递归代码,第一步都是问自己:这个函数什么时候结束?二叉树的递归出口就是遇到空节点时返回。这一步虽然只有一行,但漏掉的话,程序通常不会立刻报错,而是会一直往下一层访问不存在的节点,直到触发AttributeError或者RecursionError。

这里的None节点就是整个递归的“刹车”。代码里常常这么写:

def dfs(node): if node is None: return # 在这里可以处理当前节点 dfs(node.left) # 也可以在这里处理当前节点 dfs(node.right) # 还可以在这里处理当前节点

if node is None: return是函数的统一出口。只要节点为空,就表示这棵子树不存在,没有必要继续递归。理解了这个出口,后面三种遍历的实现就只是“把处理当前节点的语句放到不同位置”而已。

2.2 先序遍历:先处理当前节点

先序遍历的顺序是中、左、右。代码框架非常简单:

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

用前面的树跑一遍,结果会是[1, 2, 4, 5, 3]。执行过程可以这样理解:进入节点 1,立刻把 1 记录下来,然后去左边;进入节点 2,立刻记录 2,然后去左边;进入节点 4,记录 4,左右为空,返回;回到节点 2,继续去右边;进入节点 5,记录 5,返回;回到节点 1,继续去右边。

这里有个很多人问过的点:为什么traverse是嵌套函数,却不需要nonlocal result?因为result.append(...)是在原地修改列表对象,并没有给result重新赋值。如果你写成result = result + [node.val],那确实需要nonlocal result。这个细节很细微,但在面试中很容易被追问。

2.3 中序和后序:挪一行代码,用途完全不同

中序遍历只需要把result.append放到两个递归调用之间:

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

输出是[4, 2, 5, 1, 3]。中序遍历在二叉搜索树里格外重要,因为一棵二叉搜索树的中序序列一定是递增的。判断一棵树是不是二叉搜索树,最常见的思路就是跑一次中序遍历,检查结果是否严格递增。

后序遍历则是把处理语句放到最后:

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

输出是[4, 5, 2, 3, 1]。后序的顺序意味着“先处理子树,再处理当前节点”,它特别适合自底向上的场景。比如统计一棵树有多少个节点:

def count_nodes(root): if root is None: return 0 return count_nodes(root.left) + count_nodes(root.right) + 1

这个函数先数左子树,再数右子树,最后加上自己。它的处理顺序就是后序。很多看起来和遍历无关的树形问题,底层都藏着这个结构。

3. 迭代版 DFS:把递归栈搬到明面上

递归代码很直观,但它不是万能的。首先,Python 默认递归层数只有 1000 左右,树一旦特别深,直接报RecursionError。其次,每次函数调用都有额外开销,在性能敏感的场景里,递归版本可能不是最优选择。更重要的是,迭代版本能让你真正体会到 DFS 的本质:后进先出,也就是栈。

3.1 前序遍历的迭代写法

前序遍历的顺序是中、左、右。既然要用栈模拟,就要想清楚“先压谁,后压谁”。因为栈是后进先出,所以希望先访问左子树,就得让左子树最后压进栈。换句话说是先压右孩子,再压左孩子。

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

第一次执行时,可以自己拿笔走一遍:先把根节点 1 弹出,记录 1;然后压入右孩子 3,再压入左孩子 2;下一次弹出的是 2,记录 2;接着压入 2 的右孩子 5,压入左孩子 4;弹出 4,记录;弹出 5,记录;弹出 3。最终结果和递归版完全一致。

这个版本有个好处:它不需要判断node是否为空,而是提前在入栈时检查None。两种防御方式都是对的,选择哪一种取决于你更习惯哪种代码风格。

3.2 中序遍历的迭代写法:一路压左,再回头

中序遍历就没有前序那么好写了,因为访问当前节点的时机,是左子树全部处理完之后。迭代时要模拟一个行为:沿着左子树一路往下压栈,直到空,然后弹出并访问,再转向右子树。

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

这个版本的边界条件while cur is not None or stack很容易写错。如果只写while stack,初始时栈为空,树不为空,循环根本进不去。如果只写while cur is not None,那弹出节点后转向右子树时,右子树为空就结束了,但栈里可能还剩下没处理的节点。

我每次写这个模板,都会在脑子里默念一句话:先走到最左下角,走不动了,弹一个,往右走一步,再继续往左下钻。掌握了这句话,中序遍历的迭代版就不会再丢边界条件。

3.3 后序遍历的迭代写法:双栈法和标记法

后序遍历的迭代版本是三种里面最容易绕晕的。这里分享一个取巧思路:前序遍历是“中、左、右”,如果改成“中、右、左”,再把结果反转,就得到了“左、右、中”,也就是后序遍历。

def postorder_iterative(root): if root is None: return [] stack = [root] reverse_result = [] while stack: node = stack.pop() reverse_result.append(node.val) if node.left is not None: stack.append(node.left) if node.right is not None: stack.append(node.right) return reverse_result[::-1]

注意这里和前序迭代版本的差异:前序是先压右再压左,这里恰恰相反,是先压左再压右。原因很简单,前序要“中左右”,所以要弹出左子树后马上处理;这里要“中右左”,所以要把右子树留在上面,因此先压左,后压右自然会先弹出右。

如果你不希望反转结果,也可以用“记录上一次访问节点”的标记法:

def postorder_iterative_mark(root): stack = [] cur = root prev = None result = [] while stack or cur: while cur is not None: stack.append(cur) cur = cur.left node = stack[-1] if node.right is not None and node.right != prev: cur = node.right else: result.append(node.val) prev = stack.pop() return result

这个代码的核心是:只有当右子树为空,或者右子树已经被访问过,才能访问当前节点。否则要先处理右子树。标记法虽然代码稍长,但它体现的“回溯”思想在后续做路径类问题时很有帮助。

4. 深度优先搜索能直接搞定的二叉树实战题

遍历模板背熟之后,真正的价值是拿它们去解决实际问题。这一节是我觉得最值得反复看的部分,因为很多题目看起来包装得很复杂,本质还是 DFS。

4.1 求二叉树最大深度

最大深度的定义是从根节点到最远叶子节点的最长路径上的节点数。这个问题用递归写非常自然:

def max_depth(root): if root is None: return 0 left_depth = max_depth(root.left) right_depth = max_depth(root.right) return max(left_depth, right_depth) + 1

理解这个代码的关键是把它当成后序遍历:先分别求出左子树和右子树的最大深度,然后取较大值,再加上当前节点这一层。递归的运行过程是从上往下调用,但结果是从下往上返回的。这一步理解了,很多“自底向上”的树形问题就都通了。

迭代写法也可以做,比如在层序遍历时记录层数,或者用带值栈模拟。不过求深度这个场景,递归版本是表达力最好的,所以优先推荐。

4.2 判断路径和是否等于目标值

给定一棵二叉树和一个目标值,判断是否存在从根节点到叶子节点的路径,使得路径上所有节点值相加等于目标值。题目看着复杂,其实只需要带着“还需要多少”这个信息不断向下搜索。

def has_path_sum(root, target_sum): if root is None: return False if root.left is None and root.right is None: return root.val == target_sum return has_path_sum(root.left, target_sum - root.val) or has_path_sum(root.right, target_sum - root.val)

这个代码值得细品。每次进入子节点之前,把目标值减去当前节点的值,这样问题就转化成了“子树里是否存在一条路径,使得剩余目标值成立”。递归的返回值是一个布尔值,两个子问题用or连接,只要其中一个为True,整条路径就存在。

我见过不少人一开始会写一个current_sum累加版本,也能做出来,但减法版本更简洁,也更符合“问题规模不断缩小”的递归思路。

4.3 序列化与反序列化:把二叉树存进字符串

序列化的目标是把一棵二叉树变成字符串,反序列化再把字符串还原成原来的树。最常见的方法是前序遍历,遇到空节点用特殊字符#表示。

def serialize(root): parts = [] def helper(node): if node is None: parts.append("#") return parts.append(str(node.val)) helper(node.left) helper(node.right) helper(root) return ",".join(parts)

反序列化时需要按顺序读取字符串,遇到#就返回None,否则创建节点,并继续用递归构建左右子树:

def deserialize(data): values = data.split(",") index = 0 def helper(): nonlocal index if values[index] == "#": index += 1 return None node = TreeNode(int(values[index])) index += 1 node.left = helper() node.right = helper() return helper()

这里有个非直观的点:反序列化函数的推进顺序和前序遍历完全一致,所以可以用同一个helper在进入左子树后自然接着构建右子树。只要严格按照“先根,再左,再右”的顺序读取,字符串里每一个值都能被正确消费掉。

这种写法的实用价值很大。缓存、存储、网络传输树结构时,字符串是一个极其朴素的中间格式。配合前序遍历模板,哪怕不能使用内置序列化工具,你也能手写一套。

4.4 判断二叉树是否对称

对称树的判断,可以看成是“两棵子树同时做镜像 DFS”。判断条件也顺理成章:左右两个节点的值相等,且左节点的左子树与右节点的右子树对称,左节点的右子树与右节点的左子树对称。

def is_symmetric(root): if root is None: return True def check(left, right): if left is None and right is None: return True if left is None or right is None: return False return (left.val == right.val and check(left.left, right.right) and check(left.right, right.left)) return check(root.left, root.right)

这个题很适合练“抽象的 DFS”:不是在一棵树上往深处走,而是同时在两棵树上按相反方向走。它锻炼的正是遍历顺序的变体意识。如果三种基础遍历你只是背会了模板,遇到这种题还是会卡壳,但如果你理解了“处理动作放在哪个位置决定了遍历含义”,这个题目其实一眼就能看穿。

5. 我踩过的坑:递归深度、空指针与共享变量

算法模板好看,但实战代码总会遇到一些模板之外的问题。只能说,这些坑都是我实际跑代码时踩过的,写下来给大家省点时间。

5.1 递归默认深度只有 1000 层

Python 为了防止栈溢出,设置了递归深度限制,通常默认是 1000。二叉树如果退化成链表形状,比如每个节点都只有右孩子,递归深度就约等于节点数量。节点一多,程序直接报RecursionError。

有人会第一时间想到调高限制:

import sys sys.setrecursionlimit(100000)

这个做法在本地调试时可以应急,但不是所有环境都允许你随便改。更深层的问题是:递归版 DFS 的空间复杂度本身是 O(h),h 是树高,极端情况下等于 O(n)。与其无脑调高递归限制,不如先评估这棵树可能有多深。如果深度确实可能极大,就老老实实换成迭代版的栈写法,这才是一劳永逸的方案。

实际工作中我遇到过的一个场景是解析某些嵌套配置结构,层级很深,递归版一跑就挂。换上迭代版之后,问题立刻消失。所以说,递归迭代两种版本并不是考试工具,而是两套都必须掌握的“手动挡”和“自动挡”。

5.2 可变对象作为默认参数的风险

Python 里最常见的坑之一,就是把可变对象放到函数默认参数里。写树遍历时,有人会图省事这样写:

# 需要避免的写法 def preorder(root, result=[]): if root is None: return result result.append(root.val) preorder(root.left, result) preorder(root.right, result) return result

第一次调用没问题,第二次调用同一个函数时,result会保留上一次的数据,导致结果累加。原因是 Python 的函数默认参数在定义时只创建一次,之后的每次调用如果不传新值,用的都是同一个列表对象。

安全的写法是把结果列表放在递归函数的外部,或者每次调用都显式传入新列表:

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

嵌套函数配合闭包变量,既避免了默认参数共享问题,也保持了函数的对外接口整洁。

5.3 返回值和全局变量,选一个就好,别混着用

这是我在做路径求和、最大深度这类题目时最深的体会。一个递归函数,既想通过返回值把结果抛上去,又想通过外部变量把过程记录下来,很容易把自己绕晕。

以“计算从根到叶子的最大路径和”为例,可以用带外部变量的写法:

def max_path_sum(root): best = float("-inf") def dfs(node, current_sum): nonlocal best if node is None: return current_sum += node.val if node.left is None and node.right is None: best = max(best, current_sum) dfs(node.left, current_sum) dfs(node.right, current_sum) dfs(root, 0) return best

也可以用纯递归返回值写法:

def max_path_sum(root): if root is None: return float("-inf") if root.left is None and root.right is None: return root.val return root.val + max(max_path_sum(root.left), max_path_sum(root.right))

两种方式都能得到正确答案,但思路完全不同。第一种是“从上往下累计,到叶子时更新答案”,第二种是“左右子树结果返回后,再与当前值合并”。混着用的时候,最常见的问题就是:递归函数明明该返回子树的合并结果,你却因为外部变量已经记过答案,就在某个分支里忘了处理返回值,结果拿到的值完全不对。

我的建议是,在写之前先问自己一个问题:这个问题适合“自顶向下”还是“自底向上”?如果是自顶向下,就大胆用外部变量传状态;如果是自底向上,就坚持使用返回值。两条路都通的时候,选一条自己最不容易写错的,别来回横跳。

5.4 调试 DFS 的小习惯

最后分享一个我自己的调试习惯:不要只在心里模拟递归,也不要一上来就打印全部调用过程。我通常会用三五个节点构造一棵最小树,然后手动记下“本轮访问的节点、当前栈或递归层、下一步去哪”。前序遍历调错了,大概率是入栈顺序反了;中序遍历调错了,大概率是cur指针和stack的关系没理清;后序遍历调错了,大概率是右子树的处理时机不对。

用这种方法调错,通常几分钟内就能定位到具体是哪一步的顺序问题。比起直接搜答案,这个排查过程对人的训练价值大得多。

深度优先遍历算法在工作中的影子远比想象中多。文件目录的递归遍历、表达式树的计算、配置树的前序遍历转格式,甚至很多搜索引擎的爬取策略,抽象到底层都离不开这套“沿着一条分支走到黑,再回头”的思维方式。把递归版和迭代版都吃透,面试题也好,实际项目也罢,你会发现自己能更快地识别出“这题本质就是个 DFS”。

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

海康H5player实现网页无插件直播回放实战指南

做监控项目这一行,最烦的就是网页上拉实时画面。早几年大家还在用ActiveX控件,插个控件还经常被浏览器拦,动不动就提示“控件已阻止”,客户那边弹窗一堆,体验极其糟糕。后来换WebSDK,虽然好一点&#xff0c…

作者头像 李华
网站建设 2026/10/17 3:23:53

PSO-CNN-RF-ABKDE:多变量时序预测与不确定性量化实践

很多人看到“PSO-CNN-RF-ABKDE”这个组合的第一反应,大概率是“又来一个算法拼接怪”。四个听起来风马牛不相及的模块焊在一起,很难不让人怀疑是为了凑工作量。但我自己把这个框架完整跑下来之后,确实改变了看法:这四个模块在一条…

作者头像 李华
网站建设 2026/10/17 3:23:27

MSCOMCTL.OCX文件丢失或未正确注册:从原理到修复的完整指南

Component MSCOMCTL.OCX not correctly registered: file is missing or invalid——这个弹窗我遇到的次数太多了,而且每次出现的地方都不太一样:有的是公司里那套用了十几年的进销存系统,有的是某位同事U盘里拷来的绿色小工具,还…

作者头像 李华
网站建设 2026/10/17 3:20:04

混合推荐算法在图书推荐系统中的设计与实现

毕设选题"基于混合推荐算法的个性化图书推荐系统的设计与实现",听起来似乎只是一个普通的图书管理系统加一个"猜你喜欢"模块。但等我把需求拆完、算法选完、代码跑通之后才意识到,这个题目真正的难点和价值,全在"混…

作者头像 李华
网站建设 2026/10/17 3:19:39

Nmap NSE脚本实战:从信息收集到漏洞探测的进阶指南

1. 为什么NSE脚本值得花时间深挖Nmap这个工具,做网络运维、安全评估、渗透测试的人都不陌生。大多数人用Nmap,停留在nmap -sV -T4 目标这个层面,扫端口、认服务、看版本,完事。但如果你只用到这一步,相当于买了一台顶配…

作者头像 李华
网站建设 2026/10/17 3:19:29

百度地图轨迹渐变色实现:拆线段与颜色插值实战指南

1. 项目缘起:为什么给轨迹线加一条渐变色先说说我为什么折腾这个。之前帮朋友做一个运动数据展示页面,需要在百度地图上把骑行轨迹画出来。最开始就是最简单的折线,用Polyline拉一条线出来,加点宽度和颜色,能看就行。但…

作者头像 李华