最近几个群里不少刚转行或者刚毕业的朋友问我:二叉树到底该怎么学?刷题从哪里入手?说看了一堆遍历、深度、平衡的文章,感觉每个字都认识,真到写代码就懵。这个问题我太有感触了。我刚开始啃二叉树那阵子,也走了不少弯路——跟着教程抄了一遍递归,以为自己懂了,结果换一道题就卡住。后来带过新人,也帮人改过代码,才慢慢想明白:二叉树这个板块,其实有它特别清晰的框架,一旦把“节点怎么定义、递归怎么进入和返回、边界在哪里”这几件事搞透了,后面根本不用靠死记硬背,很多题都是同一套思路的变体。
这篇不是那种面面俱到的教材,我就挑入门阶段最值得吃透的几类经典问题来讲:遍历的递归与非递归写法、由先序中序还原一棵树、求深度与判断平衡、搜索二叉树和 AVL 的基础认知、再到线索二叉树提个神。每块都会讲清楚“为什么这么做”,也会把我写代码时踩过的坑一并说出来。适合刚学完基本语法、想认真过一遍二叉树的朋友,也适合面试前想快速找回状态的人。
1. 二叉树核心思路:先把“节点”和“递归”这两件事想透
1.1 二叉树到底在考什么:结构、顺序、递归返回值
很多初学者把二叉树当成一堆离散的知识点,今天背遍历模板,明天背求深度模板,后天背镜像翻转模板。结果每道题都见过,每道题都写不利索。问题就出在,大家没意识到二叉树最底层的两个东西:
一个是结构上的自相似性。一棵二叉树的左子树和右子树,本身还是一棵二叉树。这决定了递归几乎是最自然的解法——“处理完当前节点,剩下的交给子问题,只是别忘了汇总结果”。
另一个是节点的抽象定义。不管题目怎么变,绝大多数代码都长这样:
struct TreeNode { int val; TreeNode *left; TreeNode *right; TreeNode(int x) : val(x), left(nullptr), right(nullptr) {} };这个定义虽然简单,但里面的信息密度很高:每个节点持有数据、一个左孩子指针、一个右孩子指针。空指针表示“没有子树”。所有二叉树题目,本质上都是在操作这个结构。
所以我的建议是,入门阶段不要把注意力放在“背模板”上,而要放在两个问题上:递归函数想清楚返回什么、走到空节点时应该怎么办。这两个问题想清楚了,遍历、深度、判断类题目都能迎刃而解。
1.2 深入理解三个要点:根节点、叶子节点、空节点
有三个概念特别容易混淆,我单独拎出来讲。
第一,根节点是整个树的入口。任何递归操作都从根开始。判空、处理、递归左右子树,这三步基本就是所有二叉树递归代码的骨架。
第二,叶子节点是左右孩子都为空的节点。很多题目跟它有关,比如求所有根到叶子的路径。不过要注意,很多递归代码里我们不一定显式判断叶子,而是在递归到空的时候返回,这样更统一、更好写。
第三,空节点是整个递归的“出口”。写递归先写出口,这个习惯能避免一半的报错。比如一个节点没有左孩子,递归调用左子树时,传入的就是nullptr。如果函数一进来没有判空,直接node->val,程序就会崩溃。
有个常用的说法叫“空位置也是位置”。无论是判断树为空、递归到空节点返回某个值、还是构建树时把空指针当成中序序列的结束标识,本质都是在利用空节点来简化问题。
这个思维一旦建立,后续几乎所有二叉树题,你都能在一分钟内写出递归框架。
2. 遍历是二叉树的地基:先序、中序、后序、层序
2.1 三种经典 DFS 遍历:概念、代码、执行过程
二叉树的深度优先遍历有三种:先序遍历、中序遍历、后序遍历。这里的前中后,指的是根节点被访问的时机。
- 先序:根 → 左 → 右
- 中序:左 → 根 → 右
- 后序:左 → 右 → 根
我见过很多人死记这三个顺序,背的时候会背,代码一写就乱。后来我发现一个特别直观的方法:每个节点都可以看成三个时机——第一次经过它、中间从左子树回来时经过它、从右子树回来时经过它。先序就是第一次到达时打印,中序是左子树回来时打印,后序是右子树回来时打印。这样做题的时候不用背,画一条递归路径,看到底是“什么时候打印”即可。
递归代码本身非常整齐,先序为例子:
void preorder(TreeNode* root) { if (!root) return; cout << root->val << " "; // 第一次到达时访问 preorder(root->left); preorder(root->right); }中序就是把cout放到递归左子树之后、右子树之前;后序则是放在两个递归之后。这个规律只要理解一次,三个代码都能顺手写出来。
我一直建议初学者亲手模拟一遍,至少画一棵三层二叉树,手动跑一遍递归。尤其是中序,你会发现打印出来的结果,正好是把树的节点按“左中右”的顺序排开。二叉搜索树的中序是升序,这个性质后面还会用到。
2.2 迭代遍历怎么理解:手动用栈模拟递归过程
面试和实际工作中,递归不是万能的,树特别深时有栈溢出风险,所以非递归(迭代)遍历也是经典考点。先序和中序的迭代写法可以共用一套思路,用栈模拟递归的调用过程:
先序版:
vector<int> preorderTraversal(TreeNode* root) { vector<int> res; stack<TreeNode*> st; if (root) st.push(root); while (!st.empty()) { TreeNode* node = st.top(); st.pop(); res.push_back(node->val); if (node->right) st.push(node->right); if (node->left) st.push(node->left); } return res; }这里有一个容易写反的细节:栈是后进先出,所以想让左子树先被处理,就要先把右子树压栈、再压左子树。
中序迭代稍微绕一些,核心是“一路向左走到底,再回头处理”:
vector<int> inorderTraversal(TreeNode* root) { vector<int> res; stack<TreeNode*> st; TreeNode* cur = root; while (cur || !st.empty()) { while (cur) { st.push(cur); cur = cur->left; } cur = st.top(); st.pop(); res.push_back(cur->val); cur = cur->right; } return res; }很多新手看不懂这个写法,我解释一下:第一个while循环不断往左走,把路上的节点都压栈,这一步对应“递归到最左”;然后取栈顶元素,表示“从最左开始返回”;访问之后,把cur指向右孩子,相当于进入右子树。整个过程正好模拟了递归栈的进入和返回。
而后序遍历的迭代写法有几种,最简单实用的是用两个栈或者“先序变体”,即先得到“根→右→左”,再反转。面试时能写出来即可,不一定要追求最精简的版本。
2.3 层序遍历:BFS 的典型应用
层序遍历也叫广度优先遍历,是按层从上到下、每层从左到右。常见需求是返回一个二维数组,每一层单独放一组。
vector<vector<int>> levelOrder(TreeNode* root) { vector<vector<int>> res; if (!root) return res; queue<TreeNode*> q; q.push(root); while (!q.empty()) { int size = q.size(); vector<int> level; for (int i = 0; i < size; i++) { TreeNode* node = q.front(); q.pop(); level.push_back(node->val); if (node->left) q.push(node->left); if (node->right) q.push(node->right); } res.push_back(level); } return res; }这里的核心技巧是每次循环先用size = q.size()固定当前层节点数,再一次性弹出整层。如果不固定,队列里会混入下一层节点,导致层分不清。这个写法在很多 BFS 题目里都能复用,比如二叉树右视图、找每层最大值、Z 字形遍历,都是在这个框架上加一点变化。
在实际工程中,层序遍历还常用于树的可视化。调试一棵树的时候,把层序遍历结果打出来,比看递归过程直观得多。
3. 经典大题:已知先序和中序,如何确定一棵二叉树
3.1 为什么能确定:两个序列互相约束
这是二叉树入门里非常经典的一道题,也是很多新手觉得难度陡增的节点。先序 + 中序怎么就能确定一棵树了?原因在于两个序列提供了互补信息:
先序第一个节点一定是根节点,但光有先序,你分不清左右子树边界。中序能告诉你根节点的左边是左子树、右边是右子树。于是先序提供“根是谁”,中序提供“左右范围”,两者结合起来,就能递归地还原整棵树。
注意一个前提:树中节点的值不能有重复。如果有重复值,中序里同一个值可能出现多个,边界就分不清了。面试和习题里默认树节点值唯一,遇到实际业务中可能有重复时,就要考虑改用下标或唯一ID来定位。
3.2 手动推导流程:从一棵示例树走一遍
举个例子,这棵树是这样的先序序列[3, 9, 20, 15, 7],中序序列[9, 3, 15, 20, 7]。
第一步,先序的第一个元素是 3,所以根节点是 3。
第二步,在中序里找到 3 的位置,左边是[9],右边是[15, 20, 7]。可以确定左子树只有节点 9,右子树资源是中序的[15, 20, 7]。
第三步,右子树在先序里的范围怎么算?先序左子树部分有 1 个元素,所以剩下的[20, 15, 7]属于右子树。继续对右子树递归:先序[20, 15, 7]的第一个是 20,说明右子树的根是 20;再看中序[15, 20, 7],20 左边是 15、右边是 7,所以 20 的左孩子是 15,右孩子是 7。
最终还原出的树就是:根 3,左孩子 9,右孩子 20,20 的左孩子 15,右孩子 7。
这里最关键的一点是“区间对应关系”:先序区间和中序区间描述的是同一棵子树,只是顺序不同。写代码时,必须同时维护四个索引,分别指向先序和中序区间的起止位置,否则很容易错乱。
3.3 完整代码实现与边界分析
这道题在 LeetCode 上是 105 题,经典解法如下:
TreeNode* buildTree(vector<int>& preorder, vector<int>& inorder) { unordered_map<int, int> pos; for (int i = 0; i < inorder.size(); i++) { pos[inorder[i]] = i; } return build(preorder, 0, preorder.size() - 1, inorder, 0, inorder.size() - 1, pos); } TreeNode* build(vector<int>& preorder, int preL, int preR, vector<int>& inorder, int inL, int inR, unordered_map<int, int>& pos) { if (preL > preR || inL > inR) return nullptr; int rootVal = preorder[preL]; TreeNode* root = new TreeNode(rootVal); int rootIndexInorder = pos[rootVal]; int leftSize = rootIndexInorder - inL; root->left = build(preorder, preL + 1, preL + leftSize, inorder, inL, rootIndexInorder - 1, pos); root->right = build(preorder, preL + leftSize + 1, preR, inorder, rootIndexInorder + 1, inR, pos); return root; }为什么要用哈希表存中序的位置?因为每次都要在中序里找根的位置,如果每次都线性扫描,整体复杂度会变成 O(n²),数据量大了会超时。用哈希表记录值和下标的映射,每次查找 O(1),总复杂度降到 O(n)。
还有一个细节:leftSize表示左子树有多少个节点。它用来切分先序区间。先序中根后面紧跟着的是整个左子树,长度为leftSize,然后才是右子树。这个切分逻辑是整个算法的灵魂,初学者很容易在这块写错,建议在纸上多推演几遍。
类似地,如果给定中序和后序,也可以还原二叉树。思路完全一样,只是根变成了后序序列的最后一个元素。感兴趣的可以把上面代码改成后序版本,加深理解。
4. 二叉树的深度与平衡判断:递归返回值的经典应用
4.1 求最大深度:最小可行递归案例
二叉树的最大深度,指从根节点到最远叶子节点的最长路径上的节点数。这是递归返回值的经典入门题。
int maxDepth(TreeNode* root) { if (!root) return 0; return 1 + max(maxDepth(root->left), maxDepth(root->right)); }这段代码很短,但信息量很大。它至少做了三件事:空节点返回 0,作为递归的终点;当前节点的高度等于左右子树较高者加 1;递归过程自动处理了所有节点的统计。理解这段代码,比背十道模板题都有用,因为后续很多题都会用到“递归返回一个数值,父节点根据子节点的返回值决定结果”这种思路。
比如求最小深度,很多新手直接写成1 + min(minDepth(root->left), minDepth(root->right)),但这样写有一个坑:如果某个节点只有一个孩子,空子树返回 0,会被当成最小深度计入,结果就错了。正确的做法是分开判断左右孩子是否为空,或者用更稳妥的写法。这个例子很好地说明了一个道理:边界情况才是二叉树题目的真正考点。
4.2 判断平衡二叉树:递归既要返回值又要做判断
判断一棵树是不是高度平衡的,也就是任意节点的左右子树高度差不超过 1。这道题比单纯求深度进阶了一点点,但本质还是深度递归的变体。
暴力做法是每个节点都求一次左右子树高度,这样会有大量重复计算,复杂度 O(n²)。更优做法是在求高度的过程中,同时检查是否平衡,一旦发现不平衡就提前终止。
int checkHeight(TreeNode* root) { if (!root) return 0; int leftH = checkHeight(root->left); if (leftH == -1) return -1; int rightH = checkHeight(root->right); if (rightH == -1) return -1; if (abs(leftH - rightH) > 1) return -1; return 1 + max(leftH, rightH); } bool isBalanced(TreeNode* root) { return checkHeight(root) != -1; }这里的巧妙之处在于复用返回值:正常情况返回高度,异常情况返回 -1 作为“这棵树已经不平衡了”的标记。父节点一看到子节点返回 -1,立刻知道自己也不用算了。这种“用特殊返回值携带状态”的手法,在很多二叉树题目里都会出现。
我写这道题时第一次犯的错误是,只检查根节点的左右子树高度差,没有递归检查子树内部的平衡性。题目要求任一节点的左右子树高度差都不超过 1,所以必须让每个节点都参与检查。上面的代码在递归过程中天然实现了这一点。
4.3 与 AVL 树的联系:面试里被追问怎么办
聊到平衡二叉树,面试官很自然就会问 AVL 树。AVL 树是带有平衡条件的二叉搜索树,它要求每个节点的左右子树高度差绝对值不超过 1。上面判断平衡二叉树的代码,算的就是 AVL 树的平衡条件。
AVL 树的核心价值在于:二叉搜索树在极端情况下会退化成链表,比如依次插入有序序列,树的高度变成 O(n),查找效率退化为线性。AVL 通过旋转操作保持平衡,使树高始终为 O(log n),查找、插入、删除都能保持对数级别的时间复杂度。
对于入门阶段,不需要手写 AVL 的旋转,但至少要理解四个概念:LL 型右旋、RR 型左旋、LR 型先左旋再右旋、RL 型先右旋再左旋。比如某个节点的左子树比右子树高出 2,且左子树的左孩子比较高,这就是 LL 型,对根做一次右旋即可。
这个知识点常常出现在高阶面试里,但如果你能把 AVL 和前面判断平衡二叉树的代码联系起来,讲清楚“怎么检查平衡、失衡怎么处理”,已经比大多数候选人显得扎实了。
5. 搜索二叉树与线索二叉树:从结构到查询优化
5.1 二叉搜索树(BST)的性质与应用
二叉搜索树是另一种高频考点。它的定义很简洁:对于每个节点,左子树所有节点的值都小于它,右子树所有节点的值都大于它,且左右子树自身也满足这个条件。
这个性质带来一个巨大优势:查找某个值时,每次都能排除一半的子树。理想情况下,BST 的查找效率是 O(log n),与二分查找相当。它非常适合需要频繁查找、插入、删除的动态数据集。
有一个非常经典的判断题:如何验证一棵树是不是二叉搜索树?很多新手会写成“只判断左孩子小于根、右孩子大于根”,这是不完整的。因为 BST 要求的是左子树所有节点都小于根,而不是只有左孩子。比如一棵树:根 10,右孩子 15,右孩子的左孩子 6。按照简单判断它是“合法”的,但 6 比 10 小,却出现在了右子树里,这就不满足 BST 的定义了。
正确做法是递归时维护一个范围。根节点的范围是(-∞, +∞);对左子树,更新上限为根节点的值;对右子树,更新下限为根节点的值。一旦任何节点超出范围,就说明不是 BST。
bool isValidBST(TreeNode* root) { return helper(root, LONG_MIN, LONG_MAX); } bool helper(TreeNode* root, long long low, long long high) { if (!root) return true; if (root->val <= low || root->val >= high) return false; return helper(root->left, low, root->val) && helper(root->right, root->val, high); }这个“范围收缩”的思路理解后,很多 BST 相关题目都能用,比如判断某个值存不存在、求 BST 的第 K 小元素等。
另外还有一个很好用的性质不得不提:BST 的中序遍历结果是递增序列。利用这一点,可以在 O(n) 时间内完成有序输出;同时,如果题目要求“把二叉搜索树变成有序链表”,本质就是做一次中序遍历。
5.2 线索二叉树:把空指针利用起来
线索二叉树是经典教材里常见但面试相对少考的概念,但我还是建议入门时了解一下,因为它能帮你打开思路:既然那么多空指针白白浪费着,能不能让它们指向某种遍历序列中的前驱或后继?
线索二叉树的思路是,如果某个节点没有左孩子,就让它的左指针指向中序遍历序列中的前驱;如果没有右孩子,就让右指针指向中序的后继。为了避免和真正的孩子指针混淆,每个节点还要加两个布尔标志,比如leftTag和rightTag,用来区分指针是指向孩子还是线索。
这样做的好处是,遍历不需要借助栈或递归,只靠线索就能顺着中序序列一路走下去,空间占用更少,访问效率更高。代价是插入和删除时需要维护线索,代码复杂度明显上升。
在实际工程中,线索二叉树的使用不算多,但它很好地展示了“利用数据结构自身闲置空间来优化性能”的思想。值得一提,但不必花太多时间深究。
5.3 从遍历序到形态推断的实战应用
前面讲先序+中序还原树,很多人觉得这只是面试题,工作里没啥用。但它在一些场景下很实用,比如编译器里构建语法树、在序列化和反序列化场景中恢复传输过的二叉树、在图形学中从渲染树重建场景结构等。
实际做序列化时,通常会额外加入空节点标记。比如用一个特殊值#表示空节点,先序序列化成"3,9,#,#,20,15,#,#,7,#,#",反序列化时按照先序顺序读入,遇到#就返回空节点。这个方案比“先序+中序”更直接,也是 LeetCode 297 题“二叉树的序列化与反序列化”的核心解法。
写反序列化代码时,一个常见的坑是处理多个空节点时的顺序。比如节点 9 没有左右孩子,先序记录就是9,#,#,读到 9 后读#,构建左孩子为空;再读#,构建右孩子为空;然后回到父节点继续构建。如果读入和构建的顺序不对齐,树就歪了。
这类题的共同点,是“已经知道顺序规则,反过来用顺序规则构建结构”。理解了上一节的数组区间划分,再看序列化相关内容,会轻松很多。
6. 二叉树刷题进阶指南:从入门到举一反三
6.1 经典题目练习路径:按题型而非难度刷
很多新手刷题喜欢按难度排序,从 Easy 一路刷到 Hard。这个策略对二叉树来说效率不高。更适合的方式是按题型分组,比如:
第一类是“遍历与基础”,包括前序遍历、中序遍历、后序遍历、层序遍历、求最大深度、求节点个数。这个阶段的核心目标是吃透递归和迭代两种写法,练到手能自己写出来,而不是看完题解会默写。
第二类是“路径与构造”,包括二叉树的所有路径、路径总和、从先序与中序构造二叉树、从后序与中序构造二叉树。这个阶段是递归返回值和区间划分的训练。
第三类是“性质判断”,包括验证二叉搜索树、判断平衡二叉树、判断对称二叉树、判断两棵树是否相同。核心是“递归返回什么、如何把子结果汇总”。
第四类是“修改与扩展”,包括翻转二叉树、将有序数组转化为平衡二叉搜索树、把二叉树展开为链表、二叉树的最近公共祖先。这个阶段综合运用前面所有技巧。
我见过不少人刷了好几周二叉树,每天随机抽题,今天简单题明天难题,结果进度很慢。按题型分组之后,你会发现很多题其实就是同一道题换了层皮。比如翻转二叉树和对称二叉树,核心都是“先交换左右子树,再递归处理子树”;路径总和和二叉树所有路径,核心都是“向下传递当前路径,到底部判断”。
6.2 调试二叉树:用最小复现用例和打印大法
二叉树题出 bug 时,最怕的是对着屏幕发呆,想不通哪一步错了。我的经验是两个方法。
第一个方法是最小复现。不要在一棵大树上调试,手动构造一棵只有三到五个节点的小树,比如:
TreeNode* root = new TreeNode(1); root->left = new TreeNode(2); root->right = new TreeNode(3); root->left->right = new TreeNode(4);然后用你写的函数跑一遍,在纸上画出这棵树,手动执行一遍算法逻辑,对比代码输出。很多递归错误,手动跑两三个节点就能发现,根本不需要大型测试用例。
第二个方法是打印大法。在递归函数的入口和出口各打印一次当前节点值和当前层数,比如:
int dfs(TreeNode* node, int depth) { cout << "enter: " << node->val << " depth " << depth << endl; ... cout << "exit: " << node->val << " depth " << depth << endl; }这样你能清楚地看到递归的进入顺序和返回顺序。如果你预期先序却打出了中序的效果,一眼就能发现问题。很多同学不敢打印,觉得刷题就得用 IDE 断点。其实对于递归,打印输出往往比断点更好用,因为你能看到完整的调用轨迹。
6.3 常见递归错误:栈溢出、空指针、返回值错误
写二叉树代码,最常见的错误大概有三个。
第一个是忘记判空,直接访问node->val。比如求树高度的代码里,递归函数入口没有写if (!node) return 0;,一旦遇到空节点就崩溃。这是新手最容易犯的错误,也是面试里最尴尬的翻车点。
第二个是返回值放错位置,导致逻辑不正确。比如验证 BST 时,有人的代码在每个节点只比较了左孩子和右孩子的值,没有更新上下界,结果把一棵不合法的树判断成合法。这种错误的隐蔽性很强,因为输出不会报错,只会悄悄给出错误答案。解决办法就是回到定义,把递归语义想清楚再写。
第三个是递归深度过大导致栈溢出。在处理极不平衡的树时,递归深度可能达到数万层,程序会直接崩溃。在工程中,如果树高可能很大,建议使用迭代写法;考试和刷题中,题目一般会给合理的数据范围,但如果你练习的是用递归写前序遍历,再遇到超深数据时可以顺便想想迭代写法。
这三个错误并不难规避,关键是有意识地对自己写下的每一行递归代码进行“边界检查”和“语义推演”。在纸上多画几遍,比多刷几十道题都管用。
7. 写在最后的一些实际体会
说实话,二叉树这部分内容,我到现在写代码时仍然会偶尔出错。不是不理解,而是递归这种东西,大脑的“调用栈”容量有限,一旦情况复杂,光靠脑子里跑容易漏掉某条路径。
我的做法是:遇到稍微复杂的二叉树题,先在草稿纸上画出节点的结构和递归出口,再把代码填进去。这一步看似笨拙,实际上效率极高。特别是求深度、判断平衡这类题,写代码前先想清楚“空节点返回什么、非空节点怎么汇总子结果”,基本就不会有大问题。
如果你刚开始学二叉树,不用焦虑一次消化完所有知识。第一遍先把遍历的递归写法吃透,然后自己独立写一遍迭代版本,再去做求深度、判断平衡、翻转二叉树这几道经典基础题。把这些题全部弄明白,你再看由先序和中序还原树、验证 BST、最近公共祖先这些进阶题,会发现思路是连贯的,并没有想象中那么难。
另外想提醒一句:不要追求“我刷了多少道二叉树题”,而要追求“我能不能不看题解,独立把思路讲清楚、把代码写出来”。这个标准听起来不高,但能做到的人并不多。等你发现自己能轻松地跟别人解释“为什么中序 + 先序能确定一棵树”的时候,二叉树这关就算真正过了。