学算法这些年,我有一个很朴素的感受:凡是二叉树玩得明白的人,后面看递归、回溯、分治,基本都能秒懂;凡是二叉树概念还糊着的人,到图论、堆排序和平衡树那边,大概率要反复补课。二叉树看起来只是数据结构里的一块基础模块,但它其实是算法题里最常出现的“主战场”,也是面试官最习惯从深层开始追问的点。这篇笔记不打算讲太多花活,就从二叉树的理论基础说起,把定义、性质、存储、遍历、高频操作和最容易踩的坑全部拉通一遍。不管是刚接触算法的新手,还是正在刷题准备面试的人,只要你还想系统地再过一遍二叉树的地基,我建议你都把它看完。
1. 二叉树到底在算法里扮演什么角色
1.1 从线性结构到树形结构的思维拐点
在学习数组和链表的时候,我们处理的一直是“一对一”的逻辑:一个元素后面跟着另一个元素。这种线性结构写起来很顺手,可一旦数据之间出现“一对多”的关系,比如文件目录、组织架构、比赛晋级表,它就变得非常吃力。树形结构就是为了解决这类问题出现的,而二叉树又是树形结构里最精简、最好用的一种形态。
为什么偏偏是“二叉”?原因很简单:每个节点最多只有两个分支,分别是左孩子和右孩子。这个限制让数学性质变得清晰,也让递归定义变得干净。你想想,如果每个节点可以挂任意多个子节点,遍历和存储的实现复杂度会立刻上升一个级别。而把分支数固定成两个之后,左右子树的顺序、递归的划分、序列化的结构都有了明确的规则,代码模板也就容易统一。
很多初学者第一次接触二叉树时会觉得“树就是递归的延伸”,这个判断方向是对的,但还不够完整。二叉树真正的价值在于:它把“分和整”的关系用最直观的结构固定了下来。每棵子树本身就是一棵独立的二叉树,这种自相似结构天然契合递归思维。可以这么说,二叉树是思维从“线性推进”转向“分而治之”的拐点,这一步一旦迈过去,后面学图论、学动态规划、学回溯都会轻松很多。
1.2 为什么工程和面试都喜欢拿它做文章
在实际工程里,二叉树绝对不是一个只活在教科书里的概念。数据库的 B+ 树里有树,文件系统的目录有树,编译器解析表达式靠表达式树,堆排序里的堆是一棵完全二叉树,搜索引擎的倒排索引底层也经常出现树形结构。面试官喜欢考二叉树,并不只是因为它在教材里写着“重点内容”,而是因为它能同时考察代码实现、递归理解、复杂度和边界处理能力,一题就能看出一名候选人的基本功。
对面试者来说,二叉树题目还有个特点:题型固定、思路可总结。求最大深度、判断平衡树、找最近公共祖先、根据遍历序列重建树,整个题库再大,核心逻辑也就那么几类。比起动态规划的状态转移“玄学”,二叉树题更像是可训练、可复现的技能。正因如此,它成了很多人面试复习的第一站,也是值得花时间把基础打扎实的地方。
1.3 二叉树的理论基础,究竟是哪几条线
结合我自己刷题和看源码的经验,二叉树的理论基础可以概括为五条主线:概念定义、数学性质、存储结构、遍历方式、常见操作与复杂度分析。概念定义解决“它长什么样、有哪些术语”;数学性质解决“它满足哪些规律、能推导出什么结论”;存储结构解决“用代码怎么写出来、内存里怎么放”;遍历方式解决“怎么把每个节点按顺序访问一遍”;常见操作和高频题型则把前面四条主线串成能直接落地的能力。
这篇笔记的推进方式也是按这个顺序走的。读的时候不用急着记代码,先把每一条线的逻辑理解透。二叉树的代码模板不难背,真正难的是你不知道为什么这样写,也不知道边界条件为什么要这样处理。等你把理论基础拉通之后,再回头看那些题目,会有一种“原来如此”的通透感。
2. 先把概念掰开揉碎:节点、度、深度与两种特殊二叉树
2.1 一棵树是由什么组成的
二叉树的基本单位是节点,一个节点包含两部分:自己的数据,以及指向左右子树的引用。树的顶端叫根节点,没有父节点的节点就是根;最底层没有子节点的节点叫叶子节点;夹在中间、既有父节点又有子节点的叫内部节点。这里最容易被忽略的概念是“子树”:从任意节点往下看,它的左孩子和右孩子各自形成一棵独立的小树,这也是递归定义的基础。
还有几个术语值得一次说清楚。边是连接父节点和子节点的关系,n 个节点的树恰好有 n-1 条边。两个节点之间的路径长度是它们之间经过的边数。如果给节点排了序、有左右之分,那就叫有序树,二叉树就是典型的有序树;如果左右没有区分,很多算法就无法讨论了。比如中序遍历的“左根右”顺序,就是建立在左右孩子明确区分的前提上的。
2.2 深度、高度和层数,很多人在这里栽跟头
深度和高度是新手最容易混淆的一组概念。常见的教学约定是:根节点的深度为 0(有些教材定义为 1,建议做题时先看题目约定);节点深度是从根节点到该节点的唯一路径上的边数。高度则反过来,叶子节点的高度为 0(或 1),父节点的高度是它所有子节点高度的最大值加 1。简单来说,深度是“从上往下数边”,高度是“从下往上数边”。层数更直观,根节点在第一层,往下依次递增。
这组概念影响的可不只是名词解释。题目里问“二叉树的最大深度”和“二叉树的高度”,在很多约定下答案是同一个数,但如果约定不同,边界条件就会差 1。我自己的习惯是:在代码里统一用递归返回高度的视角去写深度,也就是空节点返回 0,每往上一层加 1。这个约定几乎在所有常见题库里都适用,能少踩很多坑。
2.3 满二叉树和完全二叉树,别再傻傻分不清
这两个名字看起来像,含义却完全不同,笔试里也经常被拿来考查概念题。
| 类型 | 核心定义 | 典型特点 |
|---|---|---|
| 满二叉树 | 国内教材常说的“完美二叉树”:每一层都填满,深度为 k 时有 2^k - 1 个节点 | 每个非叶子节点都有两个子节点,且所有叶子在同一层 |
| 完全二叉树 | 除最后一层外,每一层都是满的;最后一层节点从左到右连续排列,中间不能有空位 | 只允许最后一层右侧缺失节点,适合数组存储 |
值得提醒的是,“满二叉树”在不同教材里有细微差别。有些教材把“每个非叶子节点都有两个孩子,但不要求所有叶子在同一层”的树也叫满二叉树,这种定义在国外教材里通常叫 full binary tree。为了不混淆,我建议你记住一个更通用的叫法:所有层都填满的树叫完美二叉树,平时做题时如果题目没有特殊说明,讨论的重点往往在完全二叉树身上,因为它是堆和优先队列的底层结构。
2.4 搜索树、平衡树、斜树这些形态又是怎么回事
在基础概念的基础上,二叉树还有几个高频出现的形态变种。二叉搜索树要求左子树所有节点值小于根节点,右子树所有节点值大于根节点,这个约束让查找、插入、删除都能利用折半思路,平均复杂度做到 O(log n)。平衡二叉树更进一步,要求任意节点的左右子树高度差不超过 1,常见的 AVL 树和红黑树都是基于这类思想设计出来的。
斜树是最容易理解的反面形态:所有节点都只有左孩子或都只有右孩子,它退化成了一条链,二叉树在结构上相当于链表。这种退化是最坏的查找情况,也是为什么工程上需要旋转、变色等平衡操作的原因。理解斜树能帮你建立一种意识:二叉树的理论优势建立在结构“够不够均衡”之上,所有平衡算法本质上都是在对抗斜化。
3. 三条必背性质,以及它们背后的推导逻辑
3.1 第 i 层最多有 2 的 i-1 次方个节点
二叉树的每条边最多把节点数翻一倍:根节点有 1 个,第二层最多 2 个,第三层最多 4 个,每一层最多是上一层的两倍。用数学归纳法想一下,第 i 层最多就有 2^(i-1) 个节点。这个性质看起来简单,但它对所有二叉树都成立,是推导后面节点总数的基础。
做题的时候,这个性质经常被用在“判断一棵树是否可能满足某些条件”的题目中。比如给你一个节点总数,问二叉树的最大层数或者最小层数是多少,本质上就是在解一个与 2 的幂有关的方程。多花一分钟把推导过程写一遍,比自己死记公式要稳得多。
3.2 深度为 k 的二叉树最多有 2^k - 1 个节点
把每一层的最大节点数加起来,就是一个等比数列求和:1 + 2 + 4 + ... + 2^(k-1) = 2^k - 1。这里 k 按根节点深度为 1 来算,或者按层数来算。这个性质直接导出了一个重要结论:二叉树的节点数和深度之间存在对数关系。在平衡状态下,如果有 n 个节点,树的高度约等于 log2(n+1) - 1。
这个结论的分量非常重。它意味着二叉树能在“节点数量多”和“查询路径短”之间取得很好的平衡。二叉搜索树、堆、红黑树之所以能把大多数操作控制在 O(log n),理论基础就在这里。如果你在做复杂度分析时说不出这个推导,面试官很容易判断出你只是死记了结论。
3.3 叶子节点和度为 2 的节点之间满足 n0 = n2 + 1
这个性质是二叉树里最容易考到的推理题。设 n0 表示叶子节点数量,n1 表示度为 1 的节点数量,n2 表示度为 2 的节点数量,总节点数 n = n0 + n1 + n2。再看边数:n 个节点一共有 n-1 条边,同时边又可以用节点度数和来表示,即 n1 + 2n2。两者相等:n0 + n1 + n2 - 1 = n1 + 2n2,化简后得到 n0 = n2 + 1。
这个性质的实用场景很多。比如已知一棵满二叉树或者完全二叉树有 100 个叶子节点,想推内部节点数量,用这个公式就能直接算出来。记住结论很容易,但我建议你至少亲手推导一次,因为在面试中如果被追问“为什么”,现场画个简图、从边的角度推理,比背公式更有说服力。
3.4 完全二叉树的下标映射关系,堆结构的地基
完全二叉树之所以适合用数组存储,是因为它的节点可以按层序连续排列在数组中。如果把根节点放在数组下标 1 的位置,那么对任意下标为 i 的节点,它的左孩子下标是 2i,右孩子下标是 2i+1;反过来,它的父节点下标是 i/2 向下取整。如果根节点从下标 0 开始,则左孩子是 2i+1,右孩子是 2i+2,父节点是 (i-1)/2。
这个一维和二维之间的映射是堆排序、优先队列、线段树的实现基础。我们在数组里操作堆的“上浮”“下沉”时,看似一直在跟下标打交道,实际上是在完全二叉树的概念模型里进行父子节点比较。理解了这条性质,再看堆排序的建堆过程就会通透得多:建堆本质上就是在完全二叉树上调整局部有序关系。
4. 存储方式:链式与顺序,怎么选更合适
4.1 链式存储的节点定义与代码模板
链式存储是最直观、最常用的二叉树实现方式。每个节点里保存数据,再保存左右子树的引用。以 C++ 为例,常见的定义是:
struct TreeNode { int val; TreeNode *left; TreeNode *right; TreeNode(int x) : val(x), left(nullptr), right(nullptr) {} };Java 版本几乎一样,只是引用类型写法不同:
class TreeNode { int val; TreeNode left; TreeNode right; TreeNode(int x) { val = x; } }Python 新手最容易写错的地方是__init__里对子节点的默认值。注意不能把左右孩子的默认参数直接写成Node(),那样会无限递归,应该写成None:
class TreeNode: def __init__(self, val=0, left=None, right=None): self.val = val self.left = left self.right = right链式存储的优点是完全符合二叉树原有的结构形态,空节点不会占用额外空间,适合任意形态的二叉树。刷题时题目输入经常直接给一个符合这种定义的root,所以节点定义是必须形成肌肉记忆的部分。
4.2 链式存储常见的构造方式
刷题时题目会直接给树,但自己练习或者写测试用例时,常要手写造树。最简单的做法是从叶子节点开始,一层层向上拼接:
# 手动构造一棵二叉树 # 1 # / \ # 2 3 # / \ # 4 5 node4 = TreeNode(4) node5 = TreeNode(5) node2 = TreeNode(2, node4, node5) node3 = TreeNode(3) root = TreeNode(1, node2, node3)也有人喜欢按照层序序列一次性生成树,但那种写法需要先判断字符串里的空标记,处理起来更绕。我个人的建议是:调试阶段不要嫌麻烦,先用小规模数据手动构造;等核心逻辑跑通后,再写通用的数组转二叉树函数。这样排查问题时能把变量范围压缩得很小,效率更高。
4.3 顺序存储:一维数组里藏着一棵完全二叉树
顺序存储的核心思路就是把完全二叉树按照层序遍历的顺序塞进数组,然后通过下标计算父子关系。前面已经讲过下标映射:根节点在下标 0 时,左孩子是 2i+1,右孩子是 2i+2。这种存储方式额外空间少,连续内存对缓存更友好,在堆和线段树这些场景中使用频率极高。
比如用数组表示的堆:[10, 7, 8, 5, 6, 4],对应的树结构是根节点 10,左孩子 7,右孩子 8,7 的左孩子是 5,右孩子是 6,8 的左孩子是 4。判断两个节点是否相邻、是否需要交换,完全可以通过下标运算完成。你不需要真的创建一个带有left和right指针的对象,堆排序的效率优势有很大一部分来自这里。
顺序存储也有明显局限:它只对完全二叉树友好。如果一棵普通二叉树形态很稀疏,数组中会留下大量空位,反而浪费空间。所以它的适用场景很干脆——堆、优先队列、部分线段树的实现,以及其他明确以完全二叉树为结构的场景。
4.4 两种存储方式怎么选
| 维度 | 链式存储 | 顺序存储 |
|---|---|---|
| 空间利用 | 空节点不占空间 | 不完全二叉树可能浪费大量空位 |
| 访问速度 | 指针跳转,缓存不友好 | 连续内存,缓存更友好 |
| 实现复杂度 | 直观,适合任意形态 | 依赖下标计算,主要适合完全二叉树 |
| 典型场景 | 普通二叉树题解、搜索树、平衡树 | 堆排序、优先队列、线段树 |
从面试角度看,题目里给的树绝大多数是链式结构,所以链式存储的代码模板必须非常熟练。顺序存储要理解原理,尤其是堆排序部分,因为面试官很容易让你在数组里实现一个堆并解释上下标关系。
5. 遍历是二叉树的核心操作,必须形成肌肉记忆
5.1 前中后序遍历:递归写法是理解一切的基础
二叉树的遍历分为深度优先和层序两大类。深度优先里又分前序、中序、后序,它们之间的区别就是访问根节点的时机:前序是根左右,中序是左根右,后序是左右根。递归写法非常简洁,本质上就是换个输出位置:
void preorder(TreeNode* root) { if (!root) return; cout << root->val << " "; preorder(root->left); preorder(root->right); } void inorder(TreeNode* root) { if (!root) return; inorder(root->left); cout << root->val << " "; inorder(root->right); } void postorder(TreeNode* root) { if (!root) return; postorder(root->left); postorder(root->right); cout << root->val << " "; }代码几乎没有差别,真正的区别在于“什么时候处理当前节点”。很多初学者背代码时会纠结三行顺序,其实你只要记住一句话:前序在递归左子树之前处理,中序在左子树之后、右子树之前处理,后序在两次递归之后处理。想明白这一点,哪个顺序都不会写错。
5.2 迭代写法为什么要用栈
递归的写法虽然好懂,但系统栈有深度限制,二叉树如果很长,比如斜树有十万个节点,递归很可能栈溢出。这时候就要用栈手动模拟递归过程。以前序遍历为例,栈里先放入根节点,然后循环弹出节点访问,再先把右孩子入栈、后把左孩子入栈,这样左孩子会先被弹出,保证根左右的访问顺序:
def preorderTraversal(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 result中序的迭代就稍微反直觉一点:需要先用指针一路向左压栈,把左子树都压进去之后,弹出节点访问,再转向右子树继续循环。这个过程其实就是在模拟递归里“先一路走到底访问左子树”的行为。很多人卡在这一步,是因为没有意识到递归本质上是维护了一个调用栈,迭代只是把这个栈显式地写了出来。
5.3 层序遍历:BFS 的典型应用
前中后序都是深度优先,而层序遍历是广度优先,一层一层从左到右访问。实现时用队列:根节点先入队,循环中取出队头节点,把它的左右孩子依次入队。如果题目要求把每层单独输出成一个数组,可以在外层循环里先记录当前队列长度,再用内层循环处理这一层的所有节点:
from collections import deque def levelOrder(root): if not root: return [] res = [] q = deque([root]) while q: level = [] for _ in range(len(q)): 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关键细节是len(q)必须在内层循环之前记录下来,因为内层循环里队列长度会不断变化。很多人在写层序遍历时踩坑,就是因为循环条件用了动态变化的len(q),结果是左边刚弹出的节点和右边新入队的子节点混到了同一层。
5.4 遍历在实际场景中到底用在哪里
不同的遍历顺序对应不同的实际场景。前序遍历用来保存一棵树的整体结构非常合适,因为根节点先出现,反序列化时能直接从头部拿到根;中序遍历对二叉搜索树特别有意义,它输出的序列是一个升序排列;后序遍历常用于先处理子节点再处理父节点的场景,比如删除二叉树时需要先删除子树,计算树的大小时也要先拿到左右子树的结果再相加;层序遍历则天然适合解决“求树的最小深度、输出每层信息、判断是否完全二叉树”这类与层级相关的问题。
另一个经典应用是表达式树。比如表达式(a + b) * (c - d)可以表示成二叉树,叶子是操作数,内部节点是运算符。对这棵树做中序遍历,得到的是习惯的中缀表达式;做后序遍历,得到的是后缀表达式;做前序遍历,得到的是前缀表达式。编译器在设计表达式求值算法时,树和遍历就是底层基础。
5.5 从遍历序列恢复二叉树的基础思路
如果已知一棵二叉树的前序遍历序列和中序遍历序列,可以唯一恢复出这棵树。核心思路是:前序第一个元素一定是根节点,然后到中序序列里找到这个根的位置,中序左边就是左子树节点集合,右边就是右子树节点集合,再按节点数量切分前序序列,递归下去即可。后序加中序同理,只是根节点要从后序序列的末尾取。
这里有一个容易被忽略的考点:前序加后序不能唯一确定一棵普通二叉树。因为前序是根左右,后序是左右根,只知道根却不知道左子树和右子树的分界点,于是同一组前序和后序可能对应多种不同形态的树。如果题目里看到“根据前序和后序重建二叉树”,通常需要额外条件,比如这个树是满二叉树。把这个边界条件记住,面试时能帮你避免在错误方向上纠结。
6. 高频题型与复杂度分析:面试考这块,刷熟就对了
6.1 求二叉树的最大深度,递归一行就能写
最大深度就是根节点到最远叶子节点的距离。递归写法极其简洁:如果节点为空,深度为 0;否则返回左子树深度和右子树深度的较大值加 1。
int maxDepth(TreeNode* root) { if (!root) return 0; return max(maxDepth(root->left), maxDepth(root->right)) + 1; }这个代码能背下来很容易,但你要能说出为什么最终结果要加 1:因为从当前节点到它的子树之间还有一条边、一层节点。每一层递归返回的都是“当前子树的最大深度”,往上组合时自然要累加。理解了这一点,求最小深度时就不会直接照搬了。最小深度通常是根节点到最近叶子节点的距离,空节点不能算作叶子,所以终止条件需要更精确的判断。
6.2 统计节点数、叶子节点数
统计节点总数是递归模板题:空树返回 0,否则返回左子树节点数加右子树节点数加 1。统计叶子节点数时,终止条件要区分:如果当前节点是空返回 0;如果当前节点的左右孩子都为空,说明它是叶子,返回 1;否则继续递归累加左右子树里的叶子数量。
这类题目看着简单,但它是很多复杂树形动态规划的基础。二叉树的递归结构决定了“先求左右子树的信息,再合并成当前节点的结果”这一套路可以适用无数题目:比如求树中所有节点值之和、求最长路径、求完全二叉树的节点数,核心思路都是一样的。把这些基础题练熟,相当于给后续所有树形 DP 打了底。
6.3 判断平衡二叉树:两种写法的复杂度不一样
判断一棵树是否平衡,最直观的做法是自上而下:对每个节点计算左右子树高度,只要高度差超过 1 就返回 false。但这样做每个节点都有可能在计算高度时被重复访问,最坏情况下时间复杂度是 O(n^2)。更好的做法是自下而上,把高度计算和平衡判断融合在一次递归里:递归返回高度时,如果发现某个子树不平衡,就返回一个特殊标记,让上层直接结束判断。
int checkHeight(TreeNode* root) { if (!root) return 0; int left = checkHeight(root->left); if (left == -1) return -1; int right = checkHeight(root->right); if (right == -1) return -1; if (abs(left - right) > 1) return -1; return max(left, right) + 1; } bool isBalanced(TreeNode* root) { return checkHeight(root) != -1; }这个题非常值得多做几遍。它不像简单深度题那样有一条固定模板,而是考察你能不能想到“把判断和计算合并到一次遍历中”。理解和背下来的差距很大,建议亲手把树画出来,模拟一遍递归返回的顺序,你会对“自下而上”四个字有更深的体会。
6.4 最近公共祖先、路径总和,面试高频题型
最近公共祖先是一道信息量很大的题:两个节点的最近公共祖先,要么是其中一个节点本身,要么是在某个节点的左子树和右子树里各找到其中一个目标节点。递归思路可以写成:如果当前节点是空或等于两个目标节点之一,直接返回当前节点;否则搜左子树和右子树,如果两边都找到,说明当前节点就是最近公共祖先;如果只有一边找到,就返回那一边的结果。
路径总和类题目则是在遍历过程中维护一个剩余目标值。比如判断是否存在一条从根到叶子的路径,路径和等于给定值,递归时每走一个节点就从目标值里减掉当前节点值,到叶子时判断是否减到 0。这类题目考察的是“在递归过程中携带状态”的能力,也是后面做树形 DFS 和回溯题目的预演。
6.5 复杂度的核心口诀:时间 O(n),空间 O(h)
几乎所有二叉树遍历类题目,时间复杂度都是 O(n),因为每个节点只会被访问有限次。真正容易出错的是空间复杂度。递归版本的空间复杂度取决于递归栈的深度,而递归栈深度等于树的高度,记作 O(h)。如果树是完全平衡的,h = log n;如果树退化成斜树,h = n。
| 场景 | 时间复杂度 | 空间复杂度 |
|---|---|---|
| 递归遍历 | O(n) | O(h),最坏 O(n) |
| 迭代遍历(栈/队列) | O(n) | O(h),层序为 O(w),w 为最大层宽 |
| 求深度 / 节点数 | O(n) | O(h) |
| 平衡判断(自下而上) | O(n) | O(h) |
| 平衡判断(自上而下) | O(n²) | O(h) |
面试答复杂度分析时,把“n 是节点数,h 是树高”先说清楚,再补充说明最坏情况,会显得你思考严谨。很多人只说 O(n),忽略递归栈空间,在一线面试里容易被认为是基础不扎实。
7. 新手最容易翻车的几个误区与排查技巧
7.1 递归终止条件写错,整个逻辑全崩
二叉树递归最常见的错误是终止条件写得不准确。比如求最大深度时,直接把空节点当作返回 -1 或者返回当前高度,最后结果会整体偏移。我的排查经验是:先在纸上用一棵只有根节点的小树推演一遍递归过程,把每一层返回的值写下来,再和代码对照。如果根节点已经有三层子树,手动追踪会非常累;但只用一个节点的树来验证终止条件,几乎不需要思考时间。
还有一种情况是递归函数里提前返回但没有处理返回值。比如在求路径和时,左子树已经找到合法路径,就直接返回了,此时要确保后续的右子树搜索不会因为提前返回而被漏掉。二叉树的题一旦涉及布尔返回值,逻辑分支会比单纯累加复杂很多,建议把每个返回 true 或 false 的分支都单独列出来看一遍。
7.2 空指针和子节点为 null 的边界问题
链式二叉树里每个节点都有可能没有左孩子或右孩子,代码里访问node.left.val之前必须先保证node.left不为空。这类问题在层序遍历里尤其明显:把所有非空子节点入队其实就是在做空指针保护。迭代遍历时,还要小心栈或队列里被放入了空节点,导致后续访问node.val时直接报空指针异常。
一个比较稳的习惯是:在递归函数开头统一判断if (root == nullptr),然后返回相应默认值。这个判断同时承担了“空树”和“叶子节点的左右子树”两种情况,代码结构上干净不少。但也要注意,不是所有题目都适合在入口处统一判断空节点,比如判断最小深度时,空节点和叶子节点的处理方式完全不同,需要额外区分。
7.3 深度、高度、层数混为一谈
前面已经强调过深度的方向性,这里再说一个很容易踩坑的细节:不同教材和题目对根节点深度为 0 还是 1 有不同的约定。如果题目已经给了明确约定,就按题目的来;如果没给,尽量采用“空节点返回 0,下一层加 1”的写法。这样做的好处是代码可读性强,也跟多数在线评测系统的隐藏测试一致。
如果你发现自己的答案总是在正确结果附近差 1,大概率就是高度和深度的计数基准出了问题。这时候不要急着改代码逻辑,先确认自己的约定,再在纸上按这个约定重跑一遍小例子。多数情况下,问题不是算法错了,而是基准不统一。
7.4 构造树时的引用传递问题
自己写代码构造测试树时,最容易遇到的情况是:多个变量指向了同一个节点对象,结果打印出来发现树变成一个奇怪的环或者出现共享结构。比如你想让两棵子树结构类似,直接写left = right = TreeNode(x),结果左孩子和右孩子指向了同一个对象,修改其中一个就影响了另一个。正确做法是分别new两个节点。
在刷题环境里,这类问题不容易暴露,因为测试用例通常已经封装好了。但一旦你开始写自己的测试工具,或者实现树的反序列化,就会频繁遇到引用共享问题。建议在验证结果时不要只打印节点值,最好打印节点的内存地址(Python 里可以用id()),这样能快速发现两个地方是否引用了同一个对象。
7.5 调试二叉树的实用技巧:多画图、多打印
说到调试,我个人的一个经验是:不要在脑子里空想树结构,遇到复杂的递归,直接在纸上把树画出来,把每次递归调用的参数和返回值写在节点旁边。这比盯着代码硬想效率高得多。尤其是自下而上的递归题目,画图能直观看出哪一层返回的值不对。
还有一个实用技巧是打印“访问序列”。如果你实现了一个遍历或者某个树操作,可以先打印前序和中序遍历结果,看看是否符合预期。比如写完二叉树序列化逻辑后,通过打印序列化字符串再反序列化,若最终得到的前序序列和原始树一致,就说明大概率没有问题。树相关的 bug 往往不是单一逻辑错误,而是多层递归中某一层的返回值被错误吞掉,多画几层递归树比盲目加断点更有用。
我个人在实际操作中最大的体会是,不要死背二叉树的代码模板,要把每一个递归返回值当作“子树向父亲汇报的结果”来理解。二叉树的所有基础题,本质上都在做同一件事:把大问题拆成左子树和右子树的同构子问题,再把子问题的答案合并起来。这个过程理解到位了,遇到没见过的题也能现场推出思路。最后再分享一个小技巧:拿到一道二叉树题,先别急着写代码,花三十秒在纸上画出题目给的小例子,手动走一遍完整的递归流程,再动手写。这三十秒看着“浪费”,实际上能帮你避开大量边界条件的坑,尤其适合面试时稳定发挥。