在二叉树的算法面试中,有两个核心命题始终绕不开:一个是**"如何根据遍历序列还原一棵树"(构造),另一个是"如何在树中找到两个节点的交汇点"**(查找)。
这两道题分别代表了递归思维的两种极端模式:
-
构造二叉树:自顶向下,精准切割数组,类似"分治法"。
-
最近公共祖先:自底向上,汇聚搜索结果,类似"后序遍历"。
本文将结合代码,深度剖析这两道经典题目的底层逻辑与实现细节。
一、 构造的艺术:从前序与中序遍历序列构造二叉树
1. 题目解析与难点
题目给出了前序遍历(Preorder)和中序遍历(Inorder),要求我们还原整棵树。
-
前序遍历:[ 根节点 | 左子树区域 | 右子树区域 ]
-
中序遍历:[ 左子树区域 | 根节点 | 右子树区域 ]
难点在于:如何利用下标运算,将两个数组精准地"切割"成左右子树对应的部分,并递归处理。
2. 代码实现
C++代码实现:
cpp
class Solution {
// 3 9 20 15 7
// 9 3 15 20 7
unordered_map<int, int> mp;
// 注意:在实际工程或进一步优化中,建议将 inorder 改为引用传递 vector<int>& inorder
// 以避免递归过程中频繁的内存拷贝
TreeNode* build(vector<int>& preorder, vector<int> inorder, int pl, int pr, int il, int ir) {
if (pl > pr || il > ir) return nullptr;
// 1. 前序遍历的第一个元素就是当前的根节点
TreeNode* root = new TreeNode(preorder[pl]);
// 2. 计算左子树的节点数量 k
// mp[root->val] 得到根在中序中的下标,减去中序起始位置 il,就是左子树的长度
int k = mp[preorder[pl]] - il;
// 3. 递归构造左右子树
// 这里的区间是左闭右闭
// 构造左子树:
// 前序:跳过当前根(pl+1),长度为 k,结束点为 pl + k
// 中序:从 il 开始,长度为 k,结束点为 il + k - 1 (不包含根)
root->left = build(preorder, inorder, pl + 1, pl + k, il, il + k - 1);
// 构造右子树:
// 前序:跳过根和左子树(pl + k + 1),直到最后 pr
// 中序:跳过左子树和根(il + k + 1),直到最后 ir
root->right = build(preorder, inorder, pl + k + 1, pr, il + k + 1, ir);
return root;
}
public:
TreeNode* buildTree(vector<int>& preorder, vector<int>& inorder) {
// 思路: 先用哈希表记录下来中序的下标, 然后递归构造
int n = preorder.size();
for (int i = 0; i < n; ++i) {
mp[inorder[i]] = i;
}
return build(preorder, inorder, 0, n - 1, 0, n - 1);
}
};
3. 深度解析:区间计算的"加一"与"减一"
这段代码最容易出错的地方就是 build 函数中的下标计算。我们采用的是 左闭右闭区间 [Start, End] 的策略。
关键变量 k 的物理含义: k = mp[preorder[pl]] - il 这代表了 左子树一共有多少个节点。
为什么左子树的中序区间是 il + k - 1?
-
起点是
il。 -
长度是
k。 -
根据数组下标公式:
End = Start + Length - 1。 -
所以结尾必须减 1。这也是为了在中序序列中,把"根节点"排除在左子树范围之外。
为什么右子树的前序区间是 pl + k + 1?
-
起点是
pl(当前根)。 -
我们需要跳过 1个根节点 和 k个左子树节点 才能到达右子树。
-
所以
Start = pl + 1 + k。
通过哈希表 unordered_map 将查找根节点下标的时间复杂度从 O(N) 降为 O(1),使得整体算法的时间复杂度优化到了 O(N)。
二、 查找的智慧:二叉树的最近公共祖先 (LCA)
1. 题目解析与难点
这是一个分类讨论的题目:给定两个节点 p 和 q,找到它们在树中最近的公共祖先。 这道题的难点在于:如何让底层的节点把"找到了"这个信息一层层地向上传递,并在交汇点进行结算。

2. 代码实现
C++代码实现:
cpp
class Solution {
// 思路:
// 分类讨论,如果当前节点是p或q或空,直接返回
// 如果不是那么递归左右子树
// 如果左右都存在那么当前节点就是返回值, 如果都在左边,那么递归左子树得到的结果就是返回值。
public:
TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {
// 递归终止条件 / 找到目标
if (root == NULL || root == p || root == q) {
return root;
}
// 后序遍历:先去左右子树找
TreeNode* left = lowestCommonAncestor(root->left, p, q);
TreeNode* right = lowestCommonAncestor(root->right, p, q);
// 核心判断逻辑
if (left && right) {
// 左右各找到一个,说明当前 root 就是最近公共祖先
return root;
}
else if (left) {
// 只在左边找到了(可能找到了一个,也可能左边已经把 LCA 传上来了)
return left;
}
else {
// 只在右边找到了
return right;
}
}
};
3. 深度解析:递归返回值的"三重含义"
这个递归函数 lowestCommonAncestor 的返回值设计非常精妙,它承载了三种不同的含义:
-
返回 NULL:代表这棵子树里,既没有 p,也没有 q,是一片"荒原"。
-
返回 p 或 q:代表这棵子树里发现了目标人物。
-
返回 LCA (公共祖先):代表这棵子树里,p 和 q 都已经被找到了,并且已经合并出了结果。
为什么"都在左边"就直接返回左边的结果? 代码中的 else if (left) return left; 涵盖了两种情况:
-
情况 A :p 和 q 都在左子树中,且它们在左子树的某处已经"相遇"了,此时
left变量里存的就是算好的 LCA。我们只需要做一个"传声筒",把它继续往上传。 -
情况 B:p 和 q 是直系亲属关系(比如 p 是 q 的爸爸)。当我们在左边找到 p 时,直接返回 p。因为根据题目定义,如果一个节点是另一个节点的祖先,那么这个节点本身就是 LCA。我们不需要再去下面找 q 了。
最精彩的"交汇": if (left && right) return root; 这是整道题的灵魂。当当前节点发现:左手搜到了 p(或 q),右手搜到了 q(或 p)。 这意味着当前节点正是 p 和 q 分道扬镳的路口 ,也就是它们的最近公共祖先。于是,当前节点不再返回 left 或 right,而是挺身而出,返回 root(自己)。
三、 复杂度深度剖析:从理论到工程细节
写出正确的算法只是第一步,在面试和工程实践中,我们必须清楚每一行代码背后的资源消耗。以下是对上述两段代码的时空复杂度分析。
1. 构造二叉树 (Build Tree) 的复杂度
时间复杂度:O(N)
-
哈希表构建 :我们在开始递归前,遍历了一遍中序数组
inorder来构建unordered_map,耗时 O(N)。 -
递归构建 :构建整棵树需要递归调用
build函数 N 次(每个节点被创建一次)。在哈希表的帮助下,每次查找根节点位置的操作是 O(1) 的。 -
总计:O(N) + O(N) = O(N)。
-
注意:如果这里不使用哈希表,而是每次在 inorder 数组里 for 循环寻找根节点,时间复杂度会退化为 O(N^2)。
空间复杂度:O(N)
-
哈希表开销 :
mp存储了 N 个键值对,占用 O(N) 空间。 -
递归栈开销:这是递归算法隐形的内存消耗。
-
平均情况(树比较平衡):栈深度为 O(log N)。
-
最坏情况(树退化成链表):栈深度为 O(N)。
-
-
工程细节警示(关键点) : 在你的
build函数签名中:TreeNode* build(vector<int>& preorder, vector<int> inorder, ...)。 如果不加引用符号&(即写成vector<int> inorder),那么每次递归调用都会触发一次数组的深拷贝 。这会导致空间复杂度暴涨,且产生巨大的时间开销。务必确保传入大数组时使用引用传递 (vector<int>&)。
2. 最近公共祖先 (LCA) 的复杂度
时间复杂度:O(N)
-
在最坏的情况下(例如树退化为链表,或者 p 和 q 分别位于树的最底端),我们需要遍历整棵树的所有节点才能确定结果。
-
每个节点只会被访问一次,因此时间复杂度为线性的 O(N)。
空间复杂度:O(N)
-
此算法没有使用任何额外的辅助数据结构(如数组、哈希表等),仅依靠递归来实现。
-
因此,空间复杂度完全取决于递归调用栈的最大深度,也就是树的高度。
-
平均情况:O(log N)。
-
最坏情况:O(N)。
-
四、 总结
这两道题目展示了二叉树递归的两种核心心法:
-
构造树 (Build Tree):
-
思维 :前序定位,中序定长。
-
技巧 :利用
Map加速定位,利用k值精准进行区间切割。 -
本质:将大问题拆解为参数不同的独立子问题(分治)。
-
-
公共祖先 (LCA):
-
思维 :后序遍历,自底向上。
-
技巧 :利用返回值来传递"搜索状态"(是找到了一个?还是都找到了?)。
-
本质:在回溯过程中汇总信息,并在关键节点(分叉口)进行逻辑决策。
-