面试题68:树中两个节点的最低公共祖先
先按二叉搜索树、父指针树与普通多叉树澄清结构,再用根到目标的回溯路径忠实还原作者的严格公共父节点算法。
学习目标
- 能根据树的结构(BST/父指针/普通多叉树)选择对应 LCA 算法
- 能用路径回溯法在普通树中找到两个节点的最低公共祖先
- 能处理缺失节点、根节点与相同节点等边界
从“这棵树到底有什么信息”开始
“树中两个节点的最低公共祖先”看似是固定算法题,实际第一句话应是询问树的结构。它是二叉搜索树吗?节点有父指针吗?只有从根到孩子的单向连接吗?目标节点保证在树中吗?“祖先”是否允许节点自身?
这些答案会改变可用信息和最优路径。直接写常见的二叉树递归LCA,只覆盖了其中一种定义,也完全没有还原作者源码中的普通多叉树与路径列表。原章的核心面试能力是↡。
| 树结构 | 可用信息 | 转化 | 答案位置 | 复杂度 |
|---|---|---|---|---|
| 二叉搜索树 | 左右孩子 + 有序键 | 比较两个目标值与当前值 | 首个落在两值区间内的节点 | O(h) / O(1) |
| 有父指针的树 | 每个节点可向父亲移动 | 把两条上行链视为链表 | 对齐深度后首个相同节点 | O(h) / O(1) |
| 普通树,无父指针 | 只有根和孩子列表 | 分别保存根到目标的路径 | 两条路径最后公共节点 | O(n) / O(h) |
先预测作者的链式测试:1连到2,2连到3,3连到4,4连到5,查询节点5与4。常见“祖先可包含自身”的LCA会返回4,但作者期望3。原因不是源码偶然出错,而是作者保存的路径不包含目标节点,求的是严格位于两个目标上方的公共父节点。
三种结构,三条解题路线
↡利用大小关系
若是二叉搜索树,设两个目标键的较小者为low、较大者为high。从根向下:
- 当前键同时大于high,两个目标都在左子树,向左。
- 当前键同时小于low,两个目标都在右子树,向右。
- 当前键落在low与high之间,两个目标从这里分居两侧,当前节点就是汇合点。
这就是“二叉搜索树利用大小关系”。不必遍历无关分支,时间O(h),迭代额外空间O(1)。但它依赖有序性、键的比较规则和目标确实属于同一棵树;普通树不能套用。
const BstNode* lowestCommonAncestor(
const BstNode* root,
const BstNode* first,
const BstNode* second) {
if (!root || !first || !second)
return nullptr;
const int low =
std::min(first->value, second->value);
const int high =
std::max(first->value, second->value);
const BstNode* current = root;
while (current) {
if (current->value > high) {
current = current->left;
} else if (current->value < low) {
current = current->right;
} else {
return current;
}
}
return nullptr;
}若题目要求作者式“严格父节点”,且一个目标恰好是另一个的祖先,上面的含自身版本还需调整。面试中不能只说LCA三个字,必须用祖先与后代样例固定定义。
有父指针时转化为链表公共节点
若每个节点都有parent,目标到根分别形成两条单链。“有父指针时转化为链表公共节点”:可以记录两条上行路径再比较,也可以先计算深度,让较深节点先上移深度差,然后两个指针同步向父亲移动,首次相同处就是答案。
const ParentNode* commonAncestor(
const ParentNode* first,
const ParentNode* second) {
if (!first || !second)
return nullptr;
auto depth = [](const ParentNode* node) {
std::size_t result = 0;
for (; node; node = node->parent)
++result;
return result;
};
std::size_t leftDepth = depth(first);
std::size_t rightDepth = depth(second);
while (leftDepth > rightDepth) {
first = first->parent;
--leftDepth;
}
while (rightDepth > leftDepth) {
second = second->parent;
--rightDepth;
}
while (first != second) {
first = first->parent;
second = second->parent;
}
return first;
}两条父链的尾部是共同的根侧后缀,和链表第一个公共节点同构。时间O(h),额外空间O(1)。若两节点来自不同树,两个指针最终同时成为nullptr;若要严格父节点,可从first->parent与second->parent开始。
普通树保存根到节点的路径
作者面对的是没有parent的普通树:TreeNode只有m_nValue和m_vChildren,可有任意数量孩子。无法从目标向上走,只能从根分别找到目标,保存两条祖先路径,再取最后一个相同节点。这就是“普通树保存根到节点的路径”。
Test1中根1的孩子是2、3;2的孩子是4、5;4的孩子是6、7;5的孩子是8、9、10。查询6与8时,路径分别是1、2、4和1、2、5,最后公共节点为2。
↡如何只保留成功分支
GetNodePath进行深度优先搜索。当前根就是目标时直接返回true;否则先把当前节点压入path,再依次递归孩子。某个孩子找到目标就停止继续搜索;所有孩子都失败时,弹出刚压入的当前节点并返回false。
这种“选择节点、递归尝试、失败撤销”的方式称为。函数返回true时,path里只留下根到目标之前的节点,不含目标自身。
bool GetNodePath(
const TreeNode* root,
const TreeNode* target,
std::list<const TreeNode*>& path) {
if (root == target)
return true;
path.push_back(root);
bool found = false;
auto child = root->m_vChildren.begin();
while (!found
&& child < root->m_vChildren.end()) {
found = GetNodePath(
*child, target, path);
++child;
}
if (!found)
path.pop_back();
return found;
}
const TreeNode* GetLastCommonNode(
const std::list<const TreeNode*>& path1,
const std::list<const TreeNode*>& path2) {
auto first = path1.begin();
auto second = path2.begin();
const TreeNode* last = nullptr;
while (first != path1.end()
&& second != path2.end()) {
if (*first == *second)
last = *first;
++first;
++second;
}
return last;
}
const TreeNode* GetLastCommonParent(
const TreeNode* root,
const TreeNode* node1,
const TreeNode* node2) {
if (!root || !node1 || !node2)
return nullptr;
std::list<const TreeNode*> path1;
GetNodePath(root, node1, path1);
std::list<const TreeNode*> path2;
GetNodePath(root, node2, path2);
return GetLastCommonNode(path1, path2);
}上面保持作者逻辑和接口。它假设递归收到的孩子指针非空,且结构确实是无环树;如果m_vChildren含nullptr会解引用空指针,如果输入是有环图则可能无限递归。工程版本应在递归入口检查root,并对非树结构使用visited集合。
两条路径为什么只需同步扫描
根到任意节点的路径唯一,因此两条路径的公共部分必然是从根开始的一段。同步从path1和path2开头比较,每次相同就更新pLast;首次不同之后,在合法树中不会再次出现同一节点。
作者循环在不同时没有break,而是继续前进。对合法树不会影响答案,因为两条根路径一旦分叉便不能重新汇合;若能重新汇合,输入实际是共享子节点的有向无环图,不再满足树的唯一父节点性质。现代实现可以在首次不同时break,让这个不变式更清楚。
比较使用节点指针而不是m_nValue,这是。两个不同节点即使都存数字2,也不是公共节点。作者的算法比较指针是正确的,但测试辅助函数只比较结果值;若树中允许重复值,测试可能把错误节点误判为通过,应改为直接比较pResult与pExpected。
最低公共祖先与严格公共父节点
作者文件名和函数名使用CommonParent,GetNodePath又在命中目标前返回,明确排除了目标自身。由此形成语义。
| 对比项 | 常见含自身 LCA | 作者源码的严格父节点 |
|---|---|---|
| 查询节点 | 5 与 4 | 5 与 4 |
| 路径是否含目标 | 包含:1,2,3,4,5 / 1,2,3,4 | 排除:1,2,3,4 / 1,2,3 |
| 答案 | 4 | 3 |
| 问题定义 | 最低公共祖先:节点可作自身祖先 | 最低公共父节点:必须严格位于目标上方 |
| 作者 Test2 | 不会采用 | 期望节点 3 |
链1到2到3到4到5中,查询5与4:
- 节点5的祖先路径是1、2、3、4。
- 节点4的祖先路径是1、2、3。
- 最后公共节点是3,正是作者Test2的期望。
常见LCA定义允许节点作为自身祖先,则答案会是4。现有旧页面采用“root等于p或q就返回root”的二叉递归,得到的是含自身LCA,既改变了约定,也改变了树结构。重建后的页面同时讲清两种定义,但作者代码与官方测试以严格父节点为准。
缺失节点、根节点与相同节点
GetLastCommonParent先拒绝空根或空目标。目标不在树中时,GetNodePath会逐层回溯,把path恢复为空;空路径与任何路径都没有公共节点,因此返回nullptr。作者虽然忽略两个布尔返回值,最终结果仍是nullptr,但显式检查found会更清楚。
如果查询目标之一就是根,命中时路径为空。由于作者不把目标放入路径,根不可能成为自己的严格父节点,结果为nullptr。查询两个相同的非根节点时,两条路径相同,返回该节点的父亲;查询根与根仍返回nullptr。
const TreeNode* strictCommonParent(
const TreeNode* root,
const TreeNode* first,
const TreeNode* second) {
if (!root || !first || !second)
return nullptr;
std::vector<const TreeNode*> left;
std::vector<const TreeNode*> right;
if (!getAncestorPath(
root, first, left)
|| !getAncestorPath(
root, second, right)) {
return nullptr;
}
const TreeNode* last = nullptr;
const std::size_t common =
std::min(left.size(), right.size());
for (std::size_t i = 0;
i < common && left[i] == right[i];
++i) {
last = left[i];
}
return last;
}现代版本应让getAncestorPath在入口处理空指针,并明确它在命中目标前返回、不把目标压入vector。两次搜索最坏各访问整棵树,时间O(n);两条路径和递归栈都至多O(h)。在极度退化的链上h等于n,递归可能栈溢出,可改为显式栈DFS。
四组官方测试逐项复现
Test1检查普通分叉多叉树中不同子树的公共父节点2。Test2把树退化成单链,专门锁定“节点4不能作为自身父节点”,所以5与4返回3。Test3创建独立节点6但不连接进树,验证树外目标返回nullptr。Test4把根和两个目标都设为nullptr,验证入口防御。
测试仍有两个局限。第一,Test函数用节点值比较结果与期望,重复值时不可靠;第二,各测试创建节点后没有调用DestroyTree,示例进程结束时由操作系统回收,但长期测试应释放内存。它们不改变算法答案,却是代码审查时应指出的工程问题。
验证还应增加:两节点相同、其中一个是根、孩子vector含空指针、重复值但不同身份、极深链,以及非法共享子节点结构。测试的目的不仅是得到一个值,还要证明输入契约和算法假设一致。
先澄清结构再选择算法
“先澄清树的结构再选择算法”可以整理为固定面试流程:
本章练习
练习
问题 1: 找最低公共祖先前必须先澄清什么?
问题 2: 普通多叉树用什么方法找 LCA?
问题 3: 节点不在树中时如何处理?
概念说明
本章核心概念:先澄清树的结构再选择算法。理解这些概念是掌握解题方法的关键,具体实现与边界条件请参考上文对应小节。
本章回顾
- 最低公共祖先没有脱离结构的唯一模板,首先要询问有序性、父指针、孩子数量和成员保证。
- 二叉搜索树可利用大小关系单路下行,首个落在两目标值区间的节点就是汇合点。
- 有父指针时可转化为两条链表,先对齐深度再同步向根移动。
- 作者处理没有父指针的普通多叉树,分别保存根到两个目标之前的祖先路径。
- GetNodePath通过压入、递归和失败弹出实现路径回溯。
- 两条根路径的公共部分必为公共前缀,最后相同节点就是最低公共父节点。
- 作者路径排除目标自身,因此5与4在链上返回3,而常见含自身LCA会返回4。
- 算法按节点身份比较正确,作者测试按值比较则可能在重复值树中误报。
- 两次搜索时间O(n),路径与递归栈空间O(h);空输入或树外节点返回nullptr。
名词解释
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 澄清结构
- 编写 LCA 算法前先确认树的具体类型与约束。
- 二叉搜索树
- 左子树值 < 根值 < 右子树值,可利用大小关系找 LCA。
- 路径回溯
- 保存根到节点的路径,逐层回溯找公共祖先。