news 2026/10/21 19:27:56

二叉树学习精要:四种遍历、递归套路与经典操作详解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
二叉树学习精要:四种遍历、递归套路与经典操作详解

如果你也是从数组、链表、栈、队列一路刷到这里的,应该能感觉到,算法学习day16的二叉树专题是一个真正的分水岭。前面那些线性结构靠循环和一批固定套路就能啃下来,但树一出现,递归思维就不得不立起来了。二叉树解决的是什么问题?凡是需要表达层级关系、快速查找、决策路径的场景,比如文件目录、表达式解析、搜索引擎的索引骨架,背后基本都是树。这篇就把我第16天的二叉树学习内容完整整理出来,把四种遍历、常见操作的套路,以及我踩过的坑一次说透,给正在刷树的新手一份能直接上手的参考。

1. 二叉树到底在学什么:先把结构和核心思路理顺

1.1 一棵树为什么一定要先学“二叉树”

树形结构里,二叉树是约束最严格也最基础的一种:每个节点最多只有左右两个子节点。这个约束看起来是限制,实际上帮了大忙。正因为每个节点最多两条分支,很多操作才能保持简单的递归结构和清晰的分治法思路。真正的多叉树、森林结构,在算法处理时也经常先转成二叉树再操作,所以把二叉树吃透,等于拿到了整个树形体系的钥匙。

理解二叉树,先咬死这几个基础概念。根节点是整棵树的起点,叶子节点是没有孩子的末端节点,深度代表从根到某节点的层数。子树这个概念很重要,一棵节点,它的左孩子和右孩子各自也会挂出一棵完整的树,这种“大问题能拆成同构的小问题”的特性,直接决定了后面所有递归写法的合法性。可以想象成每个经理最多管两个下属,下属又各自管两个下属,整张组织架构图就是一棵二叉树。

刷二叉树题目时我强烈建议先把下面这张概念表记牢,做题时直接对照:

概念含义典型场景
根节点树的起始节点递归入口
叶子节点没有子节点的节点最小深度、路径终点判断
子树某个节点及其后代构成的树几乎所有递归的原子单位
深度从根到该节点的边数用于递归深度控制
满二叉树除叶子外都有两个子节点理解完全二叉树的基础
完全二叉树除最后一层,其他层节点全满堆的底层结构

1.2 递归在树上的天然适配性:一个“自相似”结构

二叉树最大的特点就是自相似:你随便指一个节点,以它为根的部分,仍然是一棵完整的二叉树。这种结构让递归成为最顺理成章的解法。写树相关的递归,永远盯住三件事:对当前节点做什么操作,对左子树递归做什么,对右子树递归做什么。这三件事摆清楚,代码基本就出来了。

很多人刚学递归时觉得难,是因为脑子里在模拟一层层调用栈,其实不需要。你只需要相信函数本身就是对的:它处理一棵树,当前节点做一件事,左右子树交给递归去处理,剩下的交给函数自己的定义。比如统计节点总数,对每个节点来说就是“1个自身节点 + 左子树节点数 + 右子树节点数”,写成代码就是:

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

行数很少,但信息量很大。终止条件是“空节点返回0”,递归操作是“处理当前节点”,返回值是“子树节点总数”。这种思维模式在第16天要反复训练,因为后面翻转二叉树、求深度、判断对称,全都建立在同样的递归认知上。如果你能自己推导出这个函数,说明递归思路初步建立了;如果还需要在纸上模拟完整过程,也别急,画图多推几遍,递归会慢慢内化成直觉。

2. 四种遍历方式:从前序到层序,递归与迭代到底怎么选

2.1 递归遍历:前序、中序、后序的本质区别

二叉树的遍历是后续所有操作的基础。前序、中序、后序这三种叫法,按的是根节点的访问时机。前序是“根左右”,先处理当前节点,再处理左子树和右子树;中序是“左根右”,先处理左子树,再到当前节点,最后处理右子树;后序是“左右根”,左右子树都搞定,最后处理当前节点。别看只是调换顺序,实际造成的结果完全不同。

前序遍历最常见的用途是做树的“序列化”,比如把一棵树打印出来,或者在搜索时先处理父节点信息。中序遍历在二叉搜索树里威力很大,因为对BST做中序,得到的正好是一个递增序列。后序遍历则常用于“先处理孩子再处理父亲”的场景,比如计算树的高度、释放子树资源等。三种递归代码结构一模一样,只是res.append的位置不同:

def preorder(root): res = [] def dfs(node): if not node: return res.append(node.val) # 前序:先加根 dfs(node.left) # 再左 dfs(node.right) # 后右 dfs(root) return res def inorder(root): res = [] def dfs(node): if not node: return dfs(node.left) # 先左 res.append(node.val) # 再加根 dfs(node.right) # 后右 dfs(root) return res def postorder(root): res = [] def dfs(node): if not node: return dfs(node.left) # 先左 dfs(node.right) # 再右 res.append(node.val) # 最后加根 dfs(root) return res

掌握这三个模板,后面遇到“按某种顺序输出节点”的题目,直接套模板就行。我在实际操作中发现,很多初学者会在“要不要返回res”这里纠结。我的习惯是外层函数创建一个res列表,内部递归函数只负责往列表里填值,外面最后统一返回。这样递归函数不需要返回值,逻辑会清爽很多。

2.2 迭代遍历:用栈模拟递归的底层体验

递归写起来爽,但存在一个现实问题:递归深度过大时,Python会栈溢出。生产环境里,如果二叉树退化成了一条链表,深度可能是上万甚至几十万,这时必须用迭代。迭代的底层思路是用显式的栈来模拟系统调用栈,规则只有一条:栈是后进先出的,所以压栈顺序要和遍历顺序相反。

前序遍历“根左右”,出栈时先出根,然后要让左子树先出,就必须先压右子树、再压左子树。用代码展示就是:

def preorder_iter(root): if not root: return [] res = [] stack = [root] while stack: node = stack.pop() res.append(node.val) if node.right: # 先压右,因为栈后进先出 stack.append(node.right) if node.left: # 后压左,左子树先被处理 stack.append(node.left) return res

中序迭代则复杂一点,因为要先走完左子树,才能处理根,再用同样的方式处理右子树。经典的写法是用一个cur指针一路向左压栈,走到空就弹出处理,再转向右子树:

def inorder_iter(root): res = [] stack = [] cur = root while cur or stack: while cur: stack.append(cur) # 一直压左孩子 cur = cur.left cur = stack.pop() # 弹出最左节点 res.append(cur.val) cur = cur.right # 转向右子树 return res

后序迭代用“先根右左,再反转”的思路最省心。先按“根右左”的顺序收集节点,最后把结果反转成“左右根”,这样只需在前序迭代的代码上改两行:

def postorder_iter(root): if not root: return [] res = [] stack = [root] while stack: node = stack.pop() res.append(node.val) if node.left: # 注意:和前序相反,先压左 stack.append(node.left) if node.right: # 再压右,先处理右 stack.append(node.right) return res[::-1] # 反转即得后序

我第一次看这个反转法时觉得是投机取巧,但实际写了一遍后发现它非常稳定,不用维护复杂的栈状态,出错率远低于硬写后序的迭代版本。如果你的目标是把题做对、把代码写稳,这个方法强烈推荐。如果面试官追问原理,你还能讲清楚“为什么反转后得到的是后序”:前序是根左右,反过来变成“根右左”,再整体反转就是“左右根”。

2.3 层序遍历:队列 BFS 的正确使用姿势

前中后序遍历本质上是深度优先的思路,而层序遍历是广度优先。层序的特点是一层一层地输出,处理完第一层,再处理第二层。这时候栈就派不上用场了,需要队列先进先出的特性。

层序的难点不在于“知道要用队列”,而在于“如何确定每一层的边界”。如果只是单纯地循环出队入队,最终只能得到一个平铺的节点序列,无法区分哪些属于同一层。解决办法是在每一轮循环开始前,先记录当前队列的长度size,这个size就是当前层的节点个数。然后用一个for循环处理掉这一层,期间新加入的左右孩子会排在队尾,正好是下一层的节点,不会影响本轮的处理:

from collections import deque def levelorder(root): if not root: return [] res = [] q = deque([root]) while q: size = len(q) # 当前层节点数 level = [] for _ in range(size): node = q.popleft() level.append(node.val) if node.left: q.append(node.left) if node.right: q.append(node.right) res.append(level) return res

这个模板能应对很多变体题。要找二叉树最大宽度时,可以在队列里同时保存节点和它的位置编号;要找每层最大值时,在层内循环里做一个 max 比较;要判断一棵树是否是完全二叉树,也可以用层序遍历,碰到第一个空节点后再出现非空节点就返回假。层序遍历是整个二叉树内容里使用频率极高的一个模板,必须熟练到能闭眼默写。

3. 从遍历延伸开的经典操作:翻转、对称、深度与路径问题

3.1 翻转二叉树:最简单也最容易写错的递归应用

翻转二叉树的题目描述很直白:把每个节点的左右子树交换。这道题的解法看起来只有几行,但很多新手会在“递归函数返回什么”上卡住。翻转完一棵子树后,它应该返回的是这棵子树的根节点,这样上层节点才能接住结果。代码如下:

def invert_tree(root): if not root: return None # 交换当前节点的左右子树 root.left, root.right = root.right, root.left # 递归翻转左右子树 invert_tree(root.left) invert_tree(root.right) return root

这里有一个关键认知:翻转二叉树时,交换的是节点引用,而不是节点携带的值。如果你只是交换root.left.val和root.right.val,那么左子树的整棵结构会丢失,因为原本挂载在左孩子下面的孙子节点全都还在原来的位置。彻底理解“引用”和“值”的区别,是二叉树题目做对的前提。

我在最开始练习时,总是习惯在纸上画完递归过程才开始写代码,后来发现这样太慢。更好的方式是:对于这种自相似的问题,只验证最小的两步。当前节点交换左右引用,然后调用递归函数处理两个子树,假设它已经完成,整体就完成了。这种信任递归的思维方式,是刷树题必须锻炼的。

3.2 判断对称二叉树:递归检查两棵镜像子树

对称二叉树的判断,考验的其实是“镜像”思维。一棵树对称,意味着左子树和右子树互为镜像。什么叫互为镜像?就是左子树的左孩子对应右子树的右孩子,左子树的右孩子对应右子树的左孩子,并且值相等。

这道题的递归函数需要接收两个节点,分别代表左子树的某个位置和右子树的某个镜像位置:

def is_symmetric(root): if not root: return True def check(left, right): if not left and not right: return True if not left or not right: 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)

递归终止条件的顺序很重要。先判断“两边都为空”,返回True;再判断“只有一边为空”,返回False;最后才比较值并递归检查。这个顺序不能乱。如果先写if not left or not right,就会把两边都为空的情况错误地判成False。这种细节需要在实际调试中一点点体会,题目过了不算完,把边界条件的顺序理顺才算真正掌握。

3.3 最大深度和最小深度:代码看起来像,边界问题差很多

二叉树的最大深度,用递归就是一行经典代码。因为整棵树的深度等于“左右子树深度的最大值 + 1”,而空节点深度为0:

def max_depth(root): if not root: return 0 return 1 + max(max_depth(root.left), max_depth(root.right))

但最小深度就没那么简单。如果你照葫芦画瓢写成1 + min(max_depth(root.left), max_depth(root.right)),在一些特殊树上会得到错误答案。最小深度的定义是“从根节点到最近叶子节点的最短路径上的节点数”,注意关键词是“叶子节点”。左子树为空、右子树不为空时,左边其实没有叶子节点,不能把深度0参与计算,否则结果就会被带偏。正确的写法是:

def min_depth(root): if not root: return 0 if not root.left: return 1 + min_depth(root.right) if not root.right: return 1 + min_depth(root.left) return 1 + min(min_depth(root.left), min_depth(root.right))

这个差异是我第16天实际踩过的坑。第一次提交类似题目时,报错样例是一棵只有右链的树,我当时的解直接返回了1,而正确答案是整条链的长度。所以遇到“最小”相关的树问题,一定要多问一句:这里的边界条件会不会让空子树把结果拉偏。

3.4 路径总和:把目标值一级一级传下去

路径总和这道经典题要求判断是否存在一条从根到叶子节点的路径,使路径上所有节点值之和等于目标值。这道题最有启发性的地方是它的递归参数设计:每次递归不必重新求和,而是用“目标值减去当前节点值”,把剩余需要的值传到下一层。代码如下:

def has_path_sum(root, target): if not root: return False # 到达叶子节点时,直接判断剩余值是否等于当前节点值 if not root.left and not root.right: return root.val == target return (has_path_sum(root.left, target - root.val) or has_path_sum(root.right, target - root.val))

这里有个容易忽略的细节:递归到叶子节点时,要判断的是“剩余目标值是否等于叶子节点的值”,而不能写成target == 0。比如树只有一个节点且值为5,目标也是5,进入叶子节点时 target 还是5,直接比较root.val == target才能返回True。还有,路径和的定义要求终点必须是叶子节点,不能停在中间节点上,所以递归处理中间节点时不能直接判断是否等于target,必须等到左右孩子都为空时再判断。

这种把参数逐级“缩小”的递归方式,在树形结构中非常常用。比如找从根到叶子的最大路径和、判断二叉树中是否存在某条路径和为指定值的变体,都用同样的传参套路。

4. 刷题实录:常见的坑和排查方法

4.1 递归栈溢出:终止条件写错导致无限循环

递归最怕的就是没有终止条件或者终止条件永远不触发。写二叉树递归时,我见过很多人在画完递归树后忘了写空节点判断,导致函数一直往None的左右孩子上递归,结果直接报“maximum recursion depth exceeded”。这类错误在本地运行时能暴露出来,但在刷题平台上有时候会显示“RecursionError”或者内存超限。排查方法很简单,先检查递归函数开头的终止条件是不是if not node: return或类似写法,然后在代码里随便构造一个空树的测试用例,如果能正常返回结果,说明终止条件基本是好的。

4.2 层序遍历没记录size:输出结果全被压成一行

层序遍历最常见的问题是把整个队列当成一根管道,从头走到尾,最后得到的是一个平铺序列而不是二维分层序列。我之前写过一段错误代码,在while循环里直接node = q.popleft(),然后往结果里 add,看起来没什么问题,但运行后输出却完全没有层级结构。问题就出在少了size = len(q)这一步。每层的边界把握不住,层序就失真。这里给出一个自查方法:在纸上按两层三层的二叉树手动走一遍队列过程,如果发现第二层的元素混进了第一层的输出,那一定是size记录的时机不对。

4.3 在空指针上直接访问属性:最常见的运行时错误

二叉树题目里,空指针异常频率极高。典型写法root.left.left在 root.left 为空时直接崩溃。刷这类题时,一个很有用的习惯是:把对节点的属性访问全部放在终止条件判断之后。递归函数里先问“这个节点是不是空”,空就直接返回;非空才敢放心访问left、right、val。层序遍历里则是入队前检查孩子是否为空,不为空才入队。这个习惯几乎能防住90%的运行时错误。

4.4 没有先测空树:很多边界用例都挂在这

根节点为None是每道二叉树题的默认测试点。有些题空树的答案很明确,比如最大深度是0,空树对称返回True;但有些题就要仔细想。比如路径总和,空树且目标和为0,标准答案一般是False,因为不存在路径。第16天我给自己定了个规矩:每写一道树题,先拿空树跑一遍,再拿只有一个节点的树跑一遍,再拿一条链和一棵完全二叉树各跑一遍。这四组用例覆盖了大部分边界情况,能帮你省下大量调试时间。

4.5 如何快速构造测试二叉树:用一个辅助函数省时间

刷题平台不会让你手工输入一棵树的完整结构,但本地调试时需要自己构造用例。我常用一个基于层序遍历数组的构造函数:数组[3,9,20,None,None,15,7]表示一棵树,None代表空节点。构造思路是用队列,每次从数组里取两个元素作为当前节点的左右孩子,非空就入队继续连接。虽然代码有点长,但实际调试效率极高。拿到树之后,我会写一个简单的打印函数,输出前序或层序序列,用来检查构造结果和程序输出对不对。

这个辅助函数建议当作模板保存下来,后面所有树相关的题目调试都能用上。我自己的习惯是放在单独的模块里,需要时直接复制到代码开头,题目写完后删掉再提交。

第16天学下来,我最深的体会是:二叉树这块,光看代码是学不会的。一定要亲手在纸上画一棵三层以上的树,然后把递归的每一层调用是什么参数、返回什么结果都写出来一遍。这个过程看起来很笨,却是把递归和树的“自相似”结构真正刻进脑子的最快路径。我在画了三棵树的递归展开过程后,后面写翻转、对称、深度的代码几乎就是水到渠成。强烈建议你也试试。

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

2024数学建模国赛C题代码与数据:快速求解种植策略

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/16 14:35:44

用4款AI工具搞定学位论文:从框架、扩写到降重的完整实战

研究生阶段最折磨人的一件事,就是把手里的资料和想法变成一篇像样的学位论文。我当年写硕士论文的时候,白天要跑实验,晚上要整理数据,真正安静坐下来写文字的时间少得可怜。最要命的是,初稿写完之后还有一轮又一轮的降…

作者头像 李华
网站建设 2026/10/20 2:18:57

ARP攻击原理与实战演示:从断网到流量劫持的中间人攻击

前阵儿办公室里那台老电脑又闹“离奇断网”:WiFi信号满格,微信收不到消息,网页一片白,其他同事却都正常。IT同事排查了半天,最后查ARP缓存表才发现——网关的MAC地址被人替换了。这就是典型的ARP攻击,一种只…

作者头像 李华
网站建设 2026/10/16 9:02:41

ASP.NET GridView无刷新实现:UpdatePanel与AJAX局部更新实战

简介:这份资源是一套面向 ASP.NET 开发者的 GridView 实用实例,基于 ASP.NET 4.0 与 SQL Server 2008 环境,针对 Visual Studio 自带 GridView 控件在信息管理系统中操作繁琐、体验欠佳的问题,提供了一套可移植性强的替代方案。实…

作者头像 李华
网站建设 2026/10/15 10:45:15

Python+Vue健身房管理系统实战:Django与Flask选型及部署全记录

前段时间帮朋友做了一套健身房管理系统,技术栈锁定在 Python Vue,后端用的 Django,IDE 用的是 Pycharm,中间也仔细对比过 Flask。整套系统从会员建档、课程表安排、私教预约到月末营收报表,全部跑通上线,现…

作者头像 李华
网站建设 2026/10/19 11:59:54

sqldeveloper-4.0.3.16.84-x64 安装配置与避坑指南

简介:Oracle SQL Developer 4.0.3.16.84-x64 是 Oracle 官方推出的免费数据库管理工具,面向开发人员与数据库管理员,尤其适合刚接触 Oracle 的新手快速上手。它提供图形化集成环境,可完成数据库连接配置、SQL 编写与执行、表与视图…

作者头像 李华