【树论】最近公共祖先(LCA)

LCA算法详解与实现

一、什么是最近公共祖先(LCA)

1.1 基本定义

在一棵有根树中,给定两个节点 uv,它们的最近公共祖先(LCA)是指:

  • 同时是 uv 的祖先的节点中,深度最大的那个。
  • 也就是说,它是离 uv 最近的共同祖先。

📌 祖先:从根节点到该节点路径上的所有节点(包括父节点、祖父节点等)都称为它的祖先。

1.2 举例说明

        1
       / \
      2   3
     / \
    4   5
   /
  6
  • LCA(4, 5) = 2
  • LCA(6, 5) = 2
  • LCA(6, 3) = 1
  • LCA(4, 4) = 4

二、LCA 的应用场景

  1. 求树上两点之间的路径长度
    距离公式:dist(u, v) = dep[u] + dep[v] - 2 * dep[LCA(u, v)]

  2. 网络路由中的共同源点查找

  3. 生物信息学中系统发育树分析

  4. 在线查询树中任意两点的路径信息


三、LCA 的常见解法

方法预处理时间查询时间空间复杂度是否支持在线
暴力法O(1)O(n)O(n)
倍增法(Binary Lifting)O(n log n)O(log n)O(n log n)✅ 是
Tarjan(离线)O(n + m)O(α(n))O(n)❌ 否
树链剖分O(n)O(log n)O(n)✅ 是
RMQ(欧拉序+ST表)O(n log n)O(1)O(n log n)✅ 是

本文重点讲解最常用、易理解、适合竞赛的 倍增法(Binary Lifting)


四、倍增法详解(Binary Lifting)

4.1 核心思想

利用二进制跳跃的思想,对每个节点预处理出其向上跳 2^0, 2^1, 2^2, …, 2^k 步的祖先节点。

这样在查询时就可以像“快速幂”一样,快速将两个节点提到同一高度,并逐步逼近 LCA。

4.2 关键数组定义

  • dep[u]:节点 u 的深度(根节点深度为 1 或 0,本文设为 1)
  • fa[u][i]:节点 u 向上跳 2^i 步所到达的节点(即第 2^i 级祖先)

例如:

  • fa[u][0]u 的父节点(跳 1 步)
  • fa[u][1]u 的祖父节点(跳 2 步)
  • fa[u][2]u 的曾祖父节点(跳 4 步)

4.3 动态规划转移方程

fa[u][i] = fa[ fa[u][i-1] ][i-1]

解释:从 u2^i 步 = 先跳 2^(i-1) 步到中间点,再从中间点跳 2^(i-1) 步。

4.4 查询步骤

给定两个节点 uv

  1. 调整深度一致
    假设 dep[u] > dep[v],让 u 向上跳,直到与 v 深度相同。

  2. 同步向上跳跃
    从最大的步长开始(如 i = 200),如果 fa[u][i] != fa[v][i],说明跳 2^i 步还没到 LCA,可以跳。否则不跳。

  3. 返回结果
    跳完后,uv 的父节点就是 LCA。

✅ 为什么最后返回 fa[u][0]?因为此时 uv 都在 LCA 的下一层。


五、C++ 完整实现(倍增法)

#include <bits/stdc++.h>
using namespace std;

// ============ 常量定义 ============
const int MAXN = 5e5 + 5;  // 最大节点数
const int LOG = 20;        // 2^20 > 1e6,足够覆盖深度

// ============ 全局变量 ============
int n, m, s;                        // 节点数、查询数、根节点
vector<int> tree[MAXN];             // 邻接表存储树
int dep[MAXN];                      // 每个节点的深度
int fa[MAXN][LOG];                  // fa[u][i] 表示 u 的第 2^i 级祖先

// ============ DFS 预处理深度和一级祖先 ============
void dfs(int u, int parent) {
    dep[u] = dep[parent] + 1;  // 根节点的 parent 是 0,dep[0] = 0
    fa[u][0] = parent;

    // 预处理 2^1 到 2^(LOG-1) 级祖先
    for (int i = 1; i < LOG; ++i) {
        fa[u][i] = fa[fa[u][i-1]][i-1];
        // 如果已经跳到根以上,fa[u][i] 会是 0,不影响
    }

    // 遍历子节点
    for (int v : tree[u]) {
        if (v == parent) continue;
        dfs(v, u);
    }
}

// ============ LCA 查询函数 ============
int lca(int u, int v) {
    // 保证 u 的深度 >= v 的深度
    if (dep[u] < dep[v]) swap(u, v);

    // Step 1: 将 u 提升到与 v 相同深度
    int diff = dep[u] - dep[v];
    for (int i = 0; i < LOG; ++i) {
        if (diff & (1 << i)) {  // 如果第 i 位是 1,就跳 2^i 步
            u = fa[u][i];
        }
    }

    // 如果此时 u == v,说明 v 就是 LCA
    if (u == v) return u;

    // Step 2: 同时向上跳,寻找 LCA
    for (int i = LOG - 1; i >= 0; --i) {
        if (fa[u][i] != fa[v][i]) {
            u = fa[u][i];
            v = fa[v][i];
        }
    }

    // 此时 u 和 v 的父节点就是 LCA
    return fa[u][0];
}

// ============ 主函数 ============
int main() {
    ios::sync_with_stdio(false);
    cin.tie(0); cout.tie(0);

    // 输入节点数、查询数、根节点
    cin >> n >> m >> s;

    // 构建无向树
    for (int i = 1; i <= n - 1; ++i) {
        int u, v;
        cin >> u >> v;
        tree[u].push_back(v);
        tree[v].push_back(u);
    }

    // 从根节点开始 DFS 预处理
    dfs(s, 0);

    // 处理 m 个查询
    for (int i = 1; i <= m; ++i) {
        int u, v;
        cin >> u >> v;
        cout << lca(u, v) << '\n';
    }

    return 0;
}

六、代码详细解析

6.1 输入格式

n m s
u1 v1
u2 v2
...
um-1 vm-1
q1 q2
q3 q4
...
qm
  • n:节点数
  • m:查询次数
  • s:根节点编号
  • 接下来 n-1 行是边
  • 最后 m 行是查询对

6.2 DFS 的作用

  • 计算每个节点的深度 dep[]
  • 初始化 fa[u][0](父节点)
  • 利用 DP 递推计算 fa[u][i](i ≥ 1)

6.3 LCA 查询逻辑

if (dep[u] < dep[v]) swap(u, v);

确保 u 是更深的那个,方便统一处理。

for (int i = 0; i < LOG; ++i) {
    if (diff & (1 << i)) {
        u = fa[u][i];
    }
}

u 向上跳 diff 步,使其与 v 同深度。利用二进制拆分,每次跳 2^i 步。

if (u == v) return u;

说明 vu 的祖先,直接返回。

for (int i = LOG - 1; i >= 0; --i) {
    if (fa[u][i] != fa[v][i]) {
        u = fa[u][i];
        v = fa[v][i];
    }
}

从大步长开始尝试跳跃。只要 fa[u][i] != fa[v][i],说明还没到 LCA,可以跳;否则跳过。

return fa[u][0];

最终 uv 都在 LCA 的正下方,其父节点即为答案。


七、复杂度分析

项目复杂度说明
预处理(DFS)O(n log n)每个节点处理 log n
单次查询O(log n)最多跳 log n
空间复杂度O(n log n)fa[MAXN][LOG] 数组

对于 n = 5e5log n ≈ 19,完全可接受。


八、测试样例

输入:

5 3 1
1 2
1 3
2 4
2 5
4 5
2 3
1 4

输出:

2
1
1

九、注意事项

  1. 树是无向图,建图时要双向加边。
  2. 根节点的父节点设为 0dep[0] = 0
  3. LOG 的值根据 n 确定,一般取 20 足够(支持 n ≤ 1e6)。
  4. 使用 ios::sync_with_stdio(false) 加速输入输出。

十、拓展:其他 LCA 方法简介

1. Tarjan 算法(离线)

  • 基于并查集 + DFS 回溯
  • 时间复杂度:O(n + m α(n))
  • 缺点:必须一次性知道所有查询,不能在线

2. 树链剖分

  • 将树剖分为若干条链
  • 每次跳链头,O(log n) 查询
  • 代码较长,但常数小,适合重链明显的树

3. RMQ + 欧拉序

  • DFS 一遍生成欧拉序和深度序列
  • 用 ST 表预处理区间最小值
  • 查询 O(1),预处理 O(n log n)
<think> 根据用户需求,我们需要完成以下任务: 1. 使用带空指针信息的先根序列构建的左孩子-右兄弟存储结构。 2. 在构建的中查找两个给定结点的最近公共祖先LCA)。 3. 输入格式:多组数据,每组数据包括两行,第一行是带空指针信息的二叉先根序列(用于构建左孩子-右兄弟存储结构的),第二行是两个结点值(用空格分隔)。 4. 输出格式:对于每组数据,输出一行,即两个结点的最近公共祖先的值(如果找到的话),注意非空。 实现思路: 步骤1:构建左孩子-右兄弟存储结构的 - 根据引用[1]:带空指针信息的先根序列构建二叉的方法同样适用于构建的左孩子-右兄弟存储结构。序列中的0表示空指针。 - 我们使用递归方法构建二叉采用左孩子-右兄弟表示法,实际上是一棵二叉)。每个结点有三个信息:值、左孩子(第一个孩子)、右兄弟(下一个兄弟)。 步骤2:在构建的二叉(左孩子-右兄弟表示法)中查找两个结点的最近公共祖先 - 由于已经转化为二叉,我们可以采用二叉中找最近公共祖先的方法。 - 根据引用[2][3][4][5],有多种方法,这里我们选择递归方法(类似引用[3]的方法): BiTree getLCA(BiTree T, BiTNode *p, BiTNode *q) { if (T == NULL) return NULL; if (T == p || T == q) return T; BiTree left = getLCA(T->lchild, p, q); // 在左子(第一个孩子)中查找 BiTree right = getLCA(T->rchild, p, q); // 在右子(右兄弟)中查找 if (left && right) return T; // 如果p和q分别在左右子,则当前结点是LCA else if (left) return left; // 如果只在左子,则返回左子的结果 else return right; // 否则返回右子的结果 } - 注意:在左孩子-右兄弟表示法中,左子代表第一个孩子,右子代表下一个兄弟。因此,在递归时,我们同时遍历左孩子(第一个孩子)和右兄弟(同一层下一个兄弟)分支。 步骤3:处理输入输出 - 输入多组数据,每组数据第一行是一个字符串序列(由数字和0组成,数字表示结点值,0表示空指针),第二行是两个整数(结点值)。 - 我们需要根据序列构建(左孩子-右兄弟存储结构)。 - 然后根据第二行给出的两个结点值,在中找到这两个结点(注意:中结点值可能重复吗?题目没有说明,但一般结点值不重复,如果有重复,则根据序列顺序,我们可能需要找到第一个出现的结点?但题目没有说明,这里假设结点值唯一。如果结点值不唯一,则我们需要根据序列中的结点顺序来定位,但这样比较复杂。根据问题描述,我们按结点值唯一来处理,因为输入只给结点值,没有给其他标识)。 - 因此,在构建之后,我们需要一个函数根据结点值在中查找结点指针(注意:中可能有多个相同值的结点?题目没有明确。但题目要求输出最近公共祖先,如果结点值重复,则结果可能不唯一。所以这里我们假设结点值唯一)。 步骤4:实现 - 定义结点结构:left_child(第一个孩子), right_sibling(右兄弟), value。 - 构建二叉:使用递归方法,根据先根序列(带空指针信息)构建二叉。递归构建时,先构建根结点,然后递归构建左子(即第一个孩子),再递归构建右子(即右兄弟)。 - 注意:序列是带空指针信息的先根序列,即按照先根顺序,遇到空指针用0表示。例如:序列"8 5 1 0 6 0 2 0 0 3 4 0 0 7 0 0 0"。 构建的递归函数: - 函数原型:Node* buildTree(vector<int>& seq, int& index) // 使用引用index来跟踪当前序列位置 - 步骤: if (index >= seq.size() || seq[index] == 0) { index++; // 跳过这个0(空结点) return NULL; } Node* node = new Node(seq[index++]); node->left_child = buildTree(seq, index); // 左子(第一个孩子) node->right_sibling = buildTree(seq, index); // 右子(右兄弟) return node; 注意:这里构建的二叉结构,其中左指针表示第一个孩子,右指针表示右兄弟。所以这棵二叉就是左孩子-右兄弟表示法的。 步骤5:查找结点函数 - Node* findNode(Node* root, int value) 递归查找中值为value的结点(假设值唯一,找到即返回)。 步骤6:查找最近公共祖先 - 使用上述递归函数,但注意:我们的结构是左孩子-右兄弟表示,所以在递归时,左子是第一个孩子(left_child),右子是右兄弟(right_sibling)。 - 函数:Node* findLCA(Node* root, Node* p, Node* q) 步骤7:主程序 - 多组输入:使用while(cin>>...)等方式读取,直到没有数据。 - 读入第一行:可以按字符串读入然后按空格分割成整数序列,或者直接逐个整数读入(因为序列以空格分隔)。 - 读入第二行:两个整数(结点值)。 注意:题目要求非空,所以序列至少有一个非0值。 代码结构: #include <iostream> #include <vector> #include <string> #include <sstream> using namespace std; struct Node { int value; Node* left_child; // 指向第一个孩子 Node* right_sibling; // 指向下一个兄弟(右兄弟) Node(int v) : value(v), left_child(NULL), right_sibling(NULL) {} }; // 根据序列构建 Node* buildTree(vector<int>& seq, int& index) { if (index >= seq.size() || seq[index] == 0) { index++; return NULL; } Node* node = new Node(seq[index]); index++; node->left_child = buildTree(seq, index); node->right_sibling = buildTree(seq, index); return node; } // 在中查找值为val的结点(假设唯一) Node* findNode(Node* root, int val) { if (root == NULL) return NULL; if (root->value == val) return root; // 先在第一个孩子中查找 Node* p = findNode(root->left_child, val); if (p) return p; // 再在右兄弟中查找 return findNode(root->right_sibling, val); } // 查找最近公共祖先 Node* findLCA(Node* root, Node* p, Node* q) { if (root == NULL) return NULL; if (root == p || root == q) return root; // 注意:这里递归的范围是:第一个孩子和右兄弟(都是当前结点的后代,但属于不同的分支) // 在左孩子(第一个孩子)分支中查找 Node* left_lca = findLCA(root->left_child, p, q); // 在右兄弟分支中查找(注意:右兄弟和当前结点是兄弟关系,所以右兄弟分支不包括当前结点的其他孩子?) // 但是,在左孩子-右兄弟表示法中,当前结点的第一个孩子(left_child)和它的右兄弟(right_sibling)是当前结点的孩子(同一层兄弟关系)。 // 因此,我们需要同时搜索第一个孩子分支和右兄弟分支。 // 然而,这里有一个问题:右兄弟分支和当前结点是什么关系?当前结点的右兄弟实际上是和当前结点同层,它们的父亲是同一个。所以当前结点的右兄弟分支并不属于当前结点的子,而是属于当前结点父亲的子(当前结点和右兄弟是兄弟关系,都是父亲的孩子)。因此,我们不能这样直接递归右兄弟。 // 重新思考:在左孩子-右兄弟表示法中,被转化为二叉,每个结点的左子是它的第一个孩子,右子是它的下一个兄弟。那么,在递归查找LCA时,我们只应该考虑当前结点的第一个孩子分支,因为右兄弟分支和当前结点是并列的,它们有共同的父亲,但是当前结点并不是右兄弟分支的祖先(除了根结点,其他结点的右兄弟分支和自己是同辈)。 // 因此,正确的递归方式应该是: // - 当前结点的子包括:以当前结点为根的,它的后代只包括它的第一个孩子以及第一个孩子的后代,以及它的右兄弟以及右兄弟的后代?不对,实际上,在左孩子-右兄弟表示法中,一个结点的后代只有它的第一个孩子以及第一个孩子的后代(通过左指针),而右指针是它的兄弟,不属于它的后代。 // 所以,在左孩子-右兄弟表示法中,一个结点的后代只有:左孩子(第一个孩子)和左孩子的右兄弟(即同一个父亲的其他孩子)?不对,注意:左孩子的右兄弟(即当前结点的第一个孩子的右兄弟)也是当前结点的孩子,但是它们被表示成左孩子的右兄弟,所以在二叉中,这些孩子都是通过左孩子的右指针链连接的。 // 因此,我们递归时,应该: // - 递归左子(第一个孩子)时,可以访问当前结点的所有孩子(因为第一个孩子的右兄弟链就是其他孩子)。 // - 递归右子(右兄弟)时,访问的是当前结点的兄弟(同层),而兄弟不是当前结点的后代。 // 所以,在查找LCA时,我们只需要在第一个孩子分支(左子)中递归查找,而右兄弟分支(右子)不是当前结点的后代,所以不能像普通二叉那样递归右子。那么,我们如何访问当前结点的所有孩子呢?实际上,我们可以通过递归左子(第一个孩子)和左子的右兄弟链(即递归右子)?但是,在递归函数中,我们不应该直接去递归右兄弟链,因为右兄弟链是并列关系,它们和当前结点的关系是相同的(都是当前结点的孩子),所以我们可以将当前结点的所有孩子视为一个整体,然后在这个整体中查找。 // 然而,上述递归方法(同时递归左子和右子)在普通二叉(每个结点有两个指针)中适用,但在这里,我们构建的二叉中,左指针是第一个孩子,右指针是右兄弟(即同一个父亲的下一个孩子)。那么,当前结点的孩子包括:左子(第一个孩子)以及左子的所有右兄弟(即左子的右子,右子的右子...),直到右子为NULL。所以,在递归查找时,我们不仅要递归左子(第一个孩子),还要递归右子(同一个父亲的下一个孩子)?但是注意,右子(右兄弟)并不是当前结点的后代?实际上,在左孩子-右兄弟表示法中,当前结点的孩子只有第一个孩子(左子),而其他孩子是第一个孩子的右兄弟(即左子的右子,左子的右子的右子...)。所以,当前结点的所有孩子形成了一个链表:第一个孩子(左子)-> 右兄弟(右子)-> 右兄弟的右兄弟...。因此,我们需要遍历这个链表(所有孩子),在每个孩子身上递归调用LCA函数。 // 但是,这样递归的复杂度会变高。我们需要换一种思路。 // 实际上,在左孩子-右兄弟表示法的二叉中,每个结点的子包括: // - 左子(第一个孩子)及其后代(包括第一个孩子的左子和右子,即第一个孩子的后代和它的兄弟) // - 右子(下一个兄弟)及其后代(即兄弟的子) // 但是,注意:当前结点的右子(右兄弟)并不是当前结点的后代,而是和当前结点同辈。所以,当我们在当前结点的子中查找p和q时,我们应该: // - 在第一个孩子(左子)中查找(包括第一个孩子以及它的所有右兄弟链,因为第一个孩子的右兄弟也是当前结点的孩子,所以应该递归第一个孩子,然后在递归第一个孩子时,会递归到它的右兄弟,这样就能覆盖所有孩子) // - 但是,当前结点的右子(右兄弟)是当前结点父亲的孩子,与当前结点同辈,所以当前结点子不包括右子(右兄弟)?不对,当前结点子包括当前结点、当前结点的所有孩子(左子以及左子的右子链)以及当前结点的右子(即右兄弟)以及右兄弟的孩子。也就是说,整个是:根结点(没有右兄弟)的左子链(每个左子结点有它自己的左子(它的第一个孩子)和右子(它的下一个兄弟))。所以,当前结点的子范围应该是:当前结点、当前结点的左子(第一个孩子)和当前结点的右子(右兄弟)都是当前结点子的一部分?不对,因为右子(右兄弟)是当前结点的兄弟,它们属于当前结点父亲的子。 // 这个关系比较复杂。实际上,我们构建的二叉,从根结点开始: // - 根结点没有右兄弟(因为它是根),它的左子是它的第一个孩子。 // - 然后,第一个孩子的右子是根结点的第二个孩子,第一个孩子的左子是第一个孩子的第一个孩子(即根的第一个孩子的第一个孩子)。 // - 根结点的第二个孩子(即第一个孩子的右子)的右子是根结点的第三个孩子,以此类推。 // 因此,整个的结构是:根结点(没有右兄弟)只有左子(第一个孩子),然后第一个孩子有左子(第一个孩子的第一个孩子)和右子(第一个孩子的右兄弟,即根结点的第二个孩子)。而根结点的第二个孩子(即第一个孩子的右子)又有左子(第二个孩子的第一个孩子)和右子(第二个孩子的右兄弟,即根结点的第三个孩子)... // 所以,当我们递归一个结点时,我们只考虑当前结点以及它的左子(第一个孩子)和右子(右兄弟)?不,当前结点子包括:当前结点、当前结点的所有孩子(通过左子以及左子的右子链访问)以及当前结点的右子(即右兄弟)以及右兄弟的所有孩子。实际上,整个二叉就是整个。 // 因此,我们可以将整个二叉视为一棵普通的二叉,然后使用普通二叉LCA递归方法。但是,普通二叉的递归方法中,左子和右子是互斥的,而这里左子和右子分别表示第一个孩子和右兄弟,它们不是互斥的,因为右兄弟并不是当前结点的孩子(而是兄弟)?不对,在左孩子-右兄弟表示法中,当前结点的右兄弟被表示成当前结点的右子,而当前结点的孩子被表示成左子。那么,当前结点的左子和右子分别是什么?左子是当前结点的第一个孩子,右子是当前结点的下一个兄弟(同一个父亲的下一个孩子)。所以,当前结点的左子和右子是平级的吗?不,它们是不同的关系:左子是孩子,右子是兄弟(和当前结点同辈,属于父亲的孩子)。 // 因此,在递归查找LCA时,当前结点的子应该包括: // - 当前结点自身 // - 当前结点的左子(第一个孩子)及其所有后代(包括第一个孩子的左子和右子) // - 当前结点的右子(右兄弟)及其所有后代(包括右兄弟的左子和右子) // 但是,注意:当前结点的右子(右兄弟)与当前结点有共同的父亲,而当前结点并不是右兄弟的祖先(除了根结点,根结点没有兄弟)。所以,在递归时,如果p和q分别位于当前结点的左子和右子,那么当前结点并不是它们的公共祖先,因为它们一个在当前结点的孩子分支,一个在当前结点的兄弟分支,它们的公共祖先应该是当前结点的父亲。 // 因此,我们修改递归方法:不递归右子(因为右子是兄弟,与当前结点同辈,不属于当前结点的后代),只递归左子(孩子分支)。然后在当前结点的孩子分支中查找p和q。同时,我们还要考虑当前结点自身。 // 具体做法: // - 如果当前结点是p或q,则返回当前结点。 // - 然后递归在左子(第一个孩子)中查找p和q。注意:左子(第一个孩子)分支包括第一个孩子以及它的所有兄弟(即第一个孩子的右子链)?不对,递归左子时,我们只递归到第一个孩子,而第一个孩子的右兄弟(即当前结点的第二个孩子)在左子的右子中,所以我们需要在左子递归时,递归左子的左右子?这样会自然递归到所有孩子。 // 实际上,在左子(第一个孩子)的递归中,由于第一个孩子结点的右子是当前结点的第二个孩子,所以递归第一个孩子结点时,会递归到它的左子(第一个孩子的第一个孩子)和右子(当前结点的第二个孩子)。这样,递归第一个孩子结点时,就会遍历当前结点的所有孩子(因为第二个孩子是第一个孩子的右子,第三个孩子是第二个孩子的右子,等等)。因此,我们只需要递归左子(第一个孩子)即可覆盖当前结点的所有孩子。 // 但是,这样递归下去,我们并没有在递归函数中考虑当前结点的右子(即右兄弟)?因为右兄弟不属于当前结点的子。那么,我们如何在整个中查找?实际上,递归是从根开始的,根结点没有右兄弟(因为根结点没有父亲,所以根结点的右子一定是NULL?不对,根结点可能有兄弟吗?在左孩子-右兄弟表示法中,根结点没有兄弟,所以根结点的右子为NULL。而根结点的左子就是根的第一个孩子。所以,从根开始,我们递归左子(第一个孩子)就能覆盖整棵。 // 因此,递归函数这样设计: // - 当前结点为NULL,返回NULL。 // - 当前结点是p或q,返回当前结点。 // - 在左子(第一个孩子)中递归查找,结果记为left。 // - 在右子(右兄弟)中递归查找,结果记为right?但是,如果我们在当前结点的右子(兄弟分支)中递归查找,那么就会查找到当前结点的兄弟以及兄弟的后代,这是允许的吗?因为整个是一个整体,我们需要在整个中查找。然而,在递归调用时,我们传入的root是整个的根,所以根结点的右子为NULL,而根结点的左子就是整个的孩子。当递归到某个结点时,它的右子(兄弟)也需要被搜索,因为兄弟结点也可能包含p或q。但是,注意:当前结点的右子(兄弟)和当前结点是平级的,它们的父亲是同一个。所以,在递归查找LCA时,我们不应该在递归当前结点时跳过右子(兄弟分支),因为整个就是一棵二叉,我们需要遍历整棵二叉。 // 然而,如果我们同时递归左子和右子,那么当p和q分别位于当前结点的左子和右子时,我们就会返回当前结点。但是,当前结点的左子(孩子)和右子(兄弟)的公共祖先不应该是当前结点,而应该是当前结点的父亲(因为孩子和兄弟都是父亲的孩子,所以父亲才是它们的公共祖先)。所以,这样递归是错误的。 // 我们需要重新设计:在左孩子-右兄弟表示法中,中两个结点的最近公共祖先应该怎么找? // - 方法1:将左孩子-右兄弟表示的二叉转换回普通(多叉),然后在普通中找最近公共祖先(即向上找直到第一个公共祖先)。 // - 方法2:利用二叉的后序遍历(非递归)记录祖先栈(如引用[4][5])。 // - 方法3:我们可以在左孩子-右兄弟表示的二叉中,每个结点增加一个父指针,然后记录从p和q到根结点的路径,然后找最后一个公共结点。 // 考虑到时间,我们采用方法3(增加父指针)可能更简单。但是,我们构建的时候没有父指针,所以需要先添加父指针。 // 方法3步骤: // 1. 遍历,为每个结点设置父指针(注意:左孩子-右兄弟表示法中,一个结点的孩子只有左子(第一个孩子),其他孩子是第一个孩子的右兄弟。所以,一个结点的所有孩子(第一个孩子以及第一个孩子的右兄弟链)的父指针都指向该结点。 // 2. 然后,从结点p开始向上遍历到根,得到路径1;从结点q向上遍历到根,得到路径2。 // 3. 然后求这两条路径的最后一个公共结点,即为最近公共祖先。 // 但是,题目要求我们使用递归方法(类似引用[3])?用户引用中提供了递归方法,但那是针对普通二叉的。而我们的二叉是左孩子-右兄弟表示,结构不同。 // 权衡后,我决定采用父指针的方法: // 修改结点结构:增加parent指针。 // 在构建时,我们无法直接设置parent指针,所以构建后进行一次遍历设置父指针。 // 设置父指针的函数: // void setParent(Node* root, Node* parent) { // if (root == NULL) return; // root->parent = parent; // // 设置左孩子(第一个孩子)的父指针为当前结点 // setParent(root->left_child, root); // // 设置右兄弟(右子)的父指针为当前结点的父指针(因为右兄弟和当前结点是同一个父亲的孩子)?不对,右兄弟的父结点也是当前结点的父亲。 // // 但是,在设置右兄弟时,我们无法直接知道它的父亲,因为当前结点的父亲也是右兄弟的父亲。所以,我们可以这样: // setParent(root->right_sibling, parent); // 这里parent是当前结点的父亲 // } // 但是,这样递归右兄弟时,传入的parent是当前结点的父亲,这是正确的。 // 但是,注意:根结点的parent为NULL。 // 然后,我们可以通过父指针向上遍历,记录从p到根结点的路径(结点指针或者值),然后同样记录q的路径,然后比较路径上最后一个相同的结点。 // 步骤: // - 从p向上走到根,将经过的结点存入一个向量。 // - 从q向上走到根,将经过的结点存入另一个向量。 // - 然后,从根结点开始比较两个向量,直到遇到第一个分叉点,那么上一个相同的结点就是最近公共祖先。 // 但是,我们也可以逆序比较:将两个向量倒序(即从根到结点),然后找最后一个相同的结点。 // 具体:将p的路径倒序(变成从根到p),q的路径也倒序(从根到q),然后顺序比较,最后一个相同的结点即为最近公共祖先。 // 然而,更简单的方法是:从p开始向上,将路径上的结点放入一个集合,然后q向上走,第一个在集合中出现的结点就是最近公共祖先。 // 由于结点指针唯一,我们可以使用指针集合。 // 步骤: // set<Node*> path; // while (p != NULL) { // path.insert(p); // p = p->parent; // } // while (q != NULL) { // if (path.find(q) != path.end()) { // return q; // 找到最近公共祖先 // } // q = q->parent; // } // return NULL; // 没有找到,但是非空,且p、q在中,所以一定会有 // 因此,我们选择用父指针的方法。 步骤8:修改计划 - 定义结点时增加parent指针。 - 在构建后,进行父指针的设置(使用递归遍历,注意设置右兄弟时,传入的parent是当前结点的parent,因为右兄弟和当前结点同一个父亲。 - 然后,根据用户输入的两个结点值,找到这两个结点(使用findNode函数)。 - 然后,用上述方法(使用父指针和集合)找到最近公共祖先。 具体步骤: 1. 结点定义: struct Node { int value; Node* left_child; Node* right_sibling; Node* parent; // 新增 Node(int v) : value(v), left_child(NULL), right_sibling(NULL), parent(NULL) {} }; 2. 构建函数(不设置parent): Node* buildTree(vector<int>& seq, int& index) { if (index >= seq.size() || seq[index] == 0) { index++; return NULL; } Node* node = new Node(seq[index]); index++; node->left_child = buildTree(seq, index); node->right_sibling = buildTree(seq, index); return node; } 3. 设置父指针的函数: void setParent(Node* root, Node* parent) { if (root == NULL) return; root->parent = parent; // 设置左孩子:左孩子的父亲是当前结点 setParent(root->left_child, root); // 设置右兄弟:右兄弟的父亲是当前结点的父亲(因为右兄弟和当前结点是同一个父亲的孩子) setParent(root->right_sibling, parent); // 注意:右兄弟的parent应该和当前结点一样 } 但是,这样设置右兄弟时,传入的parent是当前结点的parent(即父亲),这是正确的。 4. 但是,注意:在设置右兄弟时,我们传入的parent是当前结点的parent,而当前结点的parent已经设置过。例如:当前结点是A,它的父亲是F,那么A的右兄弟B的父亲也应该是F。所以,在递归设置A的右兄弟B时,传入的parent就是F(即A->parent)。 5. 然后,编写函数:Node* findLCA(Node* p, Node* q) - 使用父指针的方法。 6. 注意:如果p或q有一个是根结点,那么根结点就是公共祖先。 7. 主程序: while (读取一组数据) { 读取第一行:带空指针信息的先根序列(字符串,按空格分割成整数数组) 读取第二行:两个整数a, b 构建:Node* root = buildTree(seq, index); // index初始化为0 设置父指针:setParent(root, NULL); // 根结点的parent为NULL Node* nodeA = findNode(root, a); Node* nodeB = findNode(root, b); if (nodeA == NULL || nodeB == NULL) { // 题目说非空,且要查找两个结点,但可能有结点不存在?题目没有明确,如果不存在,按照题目要求,非空中两个给定结点,所以假设两个结点都存在。 // 如果题目没有保证存在,则输出一个错误信息,但题目没有说明,我们假设存在。 } Node* lca = findLCA(nodeA, nodeB); // 这里findLCA使用父指针的方法 if (lca != NULL) { cout << lca->value << endl; } else { // 理论上非空且结点存在,应该有公共祖先,但若两个结点中有一个是根结点,则根结点就是祖先,所以不会为NULL。但有可能两个结点没有公共祖先?不可能,因为根结点是公共祖先。 cout << "error" << endl; // 或者输出根结点的值?但不会发生。 } } 8. 释放内存(题目没有要求,可以省略,但多组数据要注意内存)。 9. 注意:在设置父指针时,我们的递归函数可能会递归到整个,所以没问题。 但是,设置父指针的递归函数中,递归右兄弟时传入的parent是当前结点的parent,而当前结点的parent已经设置。例如:根结点root,setParent(root, NULL) -> 然后递归左孩子(第一个孩子)setParen </think> 以下是一个完整的C++程序,用于根据带空指针信息的二叉先根序列构建的左孩子-右兄弟存储结构,并查找两个结点的最近公共祖先: ```cpp #include <iostream> #include <vector> #include <string> #include <sstream> #include <unordered_set> using namespace std; struct TreeNode { int val; TreeNode* firstChild; // 左孩子(第一个孩子) TreeNode* nextSibling; // 右兄弟(下一个兄弟) TreeNode* parent; // 父指针 TreeNode(int x) : val(x), firstChild(nullptr), nextSibling(nullptr), parent(nullptr) {} }; // 根据带空指针信息的先根序列构建 TreeNode* buildTree(vector<int>& seq, int& index) { if (index >= seq.size() || seq[index] == 0) { index++; return nullptr; } TreeNode* node = new TreeNode(seq[index]); index++; node->firstChild = buildTree(seq, index); // 递归构建第一个孩子 node->nextSibling = buildTree(seq, index); // 递归构建右兄弟 return node; } // 设置父指针 void setParent(TreeNode* node, TreeNode* parent) { if (!node) return; node->parent = parent; setParent(node->firstChild, node); // 设置孩子的父指针 setParent(node->nextSibling, parent); // 兄弟的父指针相同 } // 查找中值为target的结点 TreeNode* findNode(TreeNode* root, int target) { if (!root) return nullptr; if (root->val == target) return root; TreeNode* found = findNode(root->firstChild, target); if (found) return found; return findNode(root->nextSibling, target); } // 查找最近公共祖先 TreeNode* findLCA(TreeNode* p, TreeNode* q) { unordered_set<TreeNode*> path; // 记录p的祖先路径 while (p) { path.insert(p); p = p->parent; } // 查找q的路径中第一个公共祖先 while (q) { if (path.find(q) != path.end()) { return q; } q = q->parent; } return nullptr; } int main() { string line; while (getline(cin, line)) { // 解析先根序列 stringstream ss(line); vector<int> seq; int num; while (ss >> num) { seq.push_back(num); } // 读取两个目标值 int a, b; cin >> a >> b; cin.ignore(); // 忽略换行符 // 构建 int index = 0; TreeNode* root = buildTree(seq, index); setParent(root, nullptr); // 设置父指针 // 查找目标结点 TreeNode* nodeA = findNode(root, a); TreeNode* nodeB = findNode(root, b); // 计算并输出LCA TreeNode* lca = findLCA(nodeA, nodeB); if (lca) { cout << lca->val << endl; } } return 0; } ``` ### 输入格式: ``` [先根序列] [结点1] [结点2] ``` 例如: ``` 8 5 1 0 6 0 2 0 0 3 4 0 0 7 0 0 0 1 6 ``` ### 输出格式: ``` [最近公共祖先的值] ``` 例如对应上述输入的输出: ``` 5 ``` ### 算法说明: 1. **结构**: - `firstChild`:指向第一个孩子(左孩子) - `nextSibling`:指向下一个兄弟(右兄弟) - `parent`:指向父结点 2. **构建**: - 递归解析先根序列($0$ 表示空指针) - 示例序列 `8 5 1 0 6 0 2 0 0 3 4 0 0 7 0 0 0` 对应图1(a)结构[^1] 3. **设置父指针**: - 后序遍历,为每个结点设置父指针 - 兄弟结点共享相同的父指针 4. **查找LCA**: - 记录结点A的祖先路径(使用哈希集合) - 沿结点B的路径向上查找,第一个公共祖先即为LCA[^3][^4] 5. **时间复杂度**: - 构建:$O(n)$ - 查找结点:$O(n)$ - 查找LCA:$O(h)$($h$为高)
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值