【LCA 最近公共祖先】学习笔记

最近公共祖先(LCA)

定义

最近公共祖先(LCA),是指一棵树上两个节点的公共祖先中,离根节点最远的节点。

为方便,我们记点集 \(S\{P_1,P_2,\dots,P_n\}\) 的最近公共祖先为 \(\operatorname{LCA}(S)\)\(\operatorname{LCA}(P_1,P_2,\dots,P_n)\)

求法

在求 \(\operatorname{LCA}(u,v)\) 时,有如下算法:

(1)朴素算法

我们可以每次找出深度最大的节点,往上跳一次,然后重复这个过程,直到两个节点相遇,则当前节点就是所有节点的 LCA 了。

让我们分析一下这个方法的时间复杂度,在最坏情况(树是一条链,求根节点和叶子节点的 LCA)下,时间复杂度为 \(\mathcal O(n)\)

(2)倍增算法

倍增算法是最经典的 LCA 算法,它由朴素算法改进而来。

我们分析朴素算法时间复杂度的瓶颈,发现是“一步步往上跳”这一步的时间复杂度过高,我们可以尝试优化这一步骤。不难发现,我们每次跳跃的步长不仅局限于 1,我们可以每次跳跃多个节点,而又需要保证一定可以跳到任何一个祖先(即我们跳跃的步长可以拼凑成 \(1,2,\dots,n\) 的所有整数)。这个拼凑的性质似乎十分熟悉。没错,我们可以使用二进制拆分优化跳跃步骤。

\(fa_{u,i}\) 表示节点 \(u\) 的第 \(2^i\) 近祖先,\(dep_u\) 表示节点 \(u\) 的深度。这两个数组均可以通过 DFS 初始化。

我们的倍增算法主要分为两个阶段:

  1. 将两个节点的深度调整至相等。在这一步中,我们需要将深度较大的节点深度调整至较小的深度。记两个节点的深度差为 \(\delta\),原本我们需要进行 \(\delta\) 次跳跃,每次跳一个节点;现在我们可以对 \(\delta\) 进行二进制拆分,设 \(\delta=(d_nd_{n-1}\dots d_1)_2\),则我们只需要对于每一个 \(d_i=1\),往上跳跃 \(2^{i-1}\) 个节点即可,这样我们总共就往上跳跃了 \(\delta\) 个节点,达到了目标,而我们一共只进行了 \(\log\delta\) 次跳跃。
  2. 将两个节点同步跳跃至 LCA 的下一层。经过第 1 步的跳跃后,两个节点的深度相同。此时我们可以对两个节点进行同步跳跃,直到它们跳跃至同一个节点,此时这个节点就是它们的 LCA。

经过上面 2 个步骤后,两个节点均位于 LCA 的下一层,此时它们的 LCA 就是 \(fa_{u,0}\)

void dfs(int x, int f) {
    for(int i = 1; i <= lg[x]; i++) {
        // u 的第 2 ^ i 个祖先,是 u 的第 2 ^ (i - 1) 个祖先的第 2 ^ (i - 1) 
        // 个祖先。
        // 即:2 ^ i = 2 ^ (i - 1) + 2 ^ (i - 1)。
        fa[x][i] = fa[fa[x][i - 1]][i - 1];
    }
    for(int i = pre[x]; i; i = e[i].nxt) {
        int y = e[i].to;
        if(y == f) continue; // 如果遍历到父节点,则跳过。
        // 向下传递深度、父节点信息
        fa[y][0] = x;
        dep[y] = dep[x] + 1;
        dfs(y, x); 
    }
}

int lca(int u, int v) {
    if(dep[u] < dep[v]) swap(u, v); // 保证 u 的深度较大,方便实现代码。
    // 1. 调整 u, v 至同一深度
    while(dep[u] > dep[v]) {
        u = fa[u][lg[dep[u] - dep[v]]]; // 往上跳,但不跳过 v
    }
    // 特判:若 v 本身就是 u 的祖先,则 LCA(u, v) = v。
    if(u == v) return v;
    // 2. 将 u, v 同步跳跃
    for(int i = lg[dep[u]]; i >= 0; i--) {
        // 若 fa[u][i] == fa[v][i],则代表:
        // ①已经跳出树的范围,即 fa[u][i] == fa[v][i] && fa[u][i] == 0,
        // 此时显然不符条件。
        // ②已经跳过 LCA 了。因为已经经过 LCA 了,所以两个“分支”汇聚成一个“主干”,
        // 自然 fa[u][i] == fa[v][i],显然也不符条件。
        // 因此,通过这一个 if 条件,我们能够保证 u, v 只且一定会跳到 LCA 的下一层。
        if(fa[u][i] != fa[v][i]) {
            u = fa[u][i], v = fa[v][i];
        }
    }
    // 由于 u, v 在 LCA 的下一层,所以 LCA 为 fa[u][0]。
    return fa[u][0];
}

我们分析一下这个方法的时空复杂度。初始化的时间复杂度为 \(\mathcal O(n\log n)\),单次查询 LCA 的时间复杂度为 \(\mathcal O(\log n)\),一共 \(m\) 次询问,所以查询时间复杂度为 \(\mathcal O(m\log n)\),总时间复杂度为 \(\mathcal O((n+m)\log n)\);我们一共使用了三个辅助数组:\(lg,fa\)\(dep\),其中 \(fa\) 的空间复杂度为 \(\mathcal O(n\log n)\)\(dep\) 的空间复杂度为 \(\mathcal O(n)\),所以空间复杂度为 \(\mathcal O(n\log n)\)

(3)Tarjan 算法

Tarjan 算法(塔扬算法)是一个使用并查集来求 LCA 的离线算法

离线算法:所有数据提前全部已知,拿到完整数据后再统一计算、规划(先输入所有数据,在统一求解)。

在线算法:数据逐个到来,无法预知后续数据,每一步必须即时做出决策,不能回溯、修改之前的选择(必须在输入数据的同时求解,再输入下一个数据)。常见情况:输入数据需要由前一组数据的答案计算。

\(fa_u\) 表示 \(u\) 的父节点,\(query_{u}=\{v,id\}\) 表示第 \(id\) 次询问以 \(u\) 为起点、\(v\) 为终点,\(ans_{id}\) 表示第 \(id\) 次询问的两个节点的 LCA,\(vis_u\) 为标记数组。

Tarjan 算法主要分为四个步骤,对单个节点的处理分为三个阶段(进入、回到、离开):

  1. 从根节点开始 DFS 遍历,进入节点 \(u\) 时将 \(vis_u\) 标记为 \(true\)
  2. 枚举 \(u\) 的子节点 \(v\),遍历完以 \(v\) 为根节点的子树后,回到节点 \(u\)\(fa_v\) 标记为 \(u\)
  3. 离开节点 \(u\) 时,遍历以 \(u\) 为起点的所有询问 \(\operatorname{LCA}(u,v)\),若 \(vis_v=true\),则 \(v\) 已被遍历过,所以 \(\operatorname{LCA}(u,v)=find(v)\)\(find(v)\) 表示查找并查集中 \(v\) 的祖先节点);
  4. 遍历完整棵树后,所有询问均处理完毕。

在上面的第 3 步中,为什么 \(v\) 被遍历过后,此时 \(\operatorname{LCA}(u,v)=find(v)\) 呢?

对此,我们可以建立模型来解答。如下图,当我们求 \(find(v)\) 时,实际上求的是 \(u\) 的第一个满足 \(fa_x=x\) 的祖先 \(x\),观察图片,容易发现第一个满足上述条件的祖先是 \(w\),即当前访问子树的根节点,而这个根节点 \(w\) 正好就是 \(\operatorname{LCA}(u,v)\)

image

例题选讲

P3379 【模板】最近公共祖先(LCA)

模板题,参考上面部分。

P8805 [蓝桥杯 2022 国 B] 机房

一道树上前缀和模板题。

我们可以参考序列上的前缀和,在树上进行求前缀和。设 \(s_{u}\) 表示节点 \(u\) 到根节点的路径上的点的度数之和,转移显然。现在需要考虑如何将 \(s_u\) 进行求差得到两点之间的距离。

假设我们需要求点 \(u,v\) 之间路径上的点的度数之和,如图所示。这里我们简化成只有根节点 \(root\)\(\operatorname{LCA}(u,v)=w\) 和点 \(u,v\) 的情况。我们不妨将 \(s_u,s_v\) 在图上表示出来,看每个点被计算了几次。不难发现,点 \(w\) 到根节点的点的度数都被计算了两次,而我们对于 \(w\) 上方的节点都不需要计算,\(w\) 也只需要计算一次。因此我们需要从 \(s_u+s_v\) 中减去 \(2\cdot s_w-deg_w\)\(deg\) 表示点的度数),这样我们就得到了点 \(u,v\) 之间的路径上的点的度数之和的计算方式为 \(s_u+s_v-2\cdot s_w+deg_w\)

这是点权的前缀和模板题,边权前缀和同理,此处不再赘述。

image

P3128 [USACO15DEC] Max Flow P

一道树上点差分模板题。

我们可以参照序列上的差分。设 \(c_u\) 表示点 \(u\) 与其父节点的奶量之差。若对两个节点 \(u,v\) 之间的路径上的每个奶量增加 \(1\)(即有一条从 \(u\)\(v\) 的管道),则我们可以求出 \(\operatorname{LCA}(u,v)=w\)\(w\) 的父亲为 \(p\),然后令 \(c_u+1\to c_u\)\(c_v+1\to c_v\)。此时我们发现,点 \(w\) 以上的部分均被多计算了 \(2\) 次,\(w\) 本身也被多计算了 \(1\) 次,因此我们可以令 \(c_w-1\to c_w\)\(c_p-1\to c_p\),这样就抵消了重复计算的部分。需要查询某个点的奶量时直接树上前缀和即可。

P6869 [COCI 2019/2020 #5] Putovanje

一道树上边差分模板题。

我们可以模仿点差分来推导边差分的解法。类似地,我们令 \(c_u\) 表示连接 \(u\) 与其父节点的边的使用次数和连接其父节点与其父节点的父节点的边的使用次数之差(其实就是上一条边的使用次数与上上条边的使用次数之差)。若对两个节点 \(u,u+1\) 之间的路径上的每条边的使用次数增加 \(1\)(即需要从节点 \(u\) 走到 \(u+1\)),则我们可以求出 \(\operatorname{LCA}(u,u+1)=v\),然后令 \(c_u+1\to c_u\)\(c_{u+1}+1\to c_{u+1}\)。此时我们发现,点 \(v\) 以上的边均被重复计算了 \(2\) 次,因此我们可以令 \(c_v-2\to c_v\)。最后求 \(c\) 的前缀和得到每条边的使用次数 \(t\)

最终答案即为 \(\sum_{i=1}^{n-1}{\min\left\{c_{i1}\cdot t_i,c_{i2}\right\}}\)

P1967 [NOIP 2013 提高组] 货车运输

由题可知:我们需要求图上任意两点之间的路径中最小边权的最大值。由于需要让最小值尽可能大,因此我们考虑在原图上跑最大生成树。

在这个森林中,任两点 \(u,v\) 之间:

  • 若它们不在同一联通块,则显然没有路径,输出 \(-1\)
  • 否则这两点之间的路径唯一。

我们需要考虑的是如何求两点路径上的最小边权。注意到:两点的路径一定会经过它们的 LCA,并且在 LCA 处转向(或抵达其中一点),于是我们考虑将问题分割成求出这两点到他们的 LCA 的各自最小边权,再合并起来得出答案。

我们可以模仿 LCA 的求解方式,用倍增的方式求解。设 \(mi_{u,i}\) 表示从节点 \(u\) 开始上面的 \(2^i\) 条边的最小边权。则可以在 DFS 时先预处理出 \(mi_{u,i}\),具体类似 \(fa_{u,i}\) 的转移方程:

\[mi_{u,i}=\min\left\{mi_{u,i},mi_{fa_{u,i-1},i-1}\right\} \]

随后在求解函数 \(query\) 中,在每次跳动时将答案取最小值即可。

posted @ 2026-07-18 11:06  EnjoySilence  阅读(9)  评论(0)    收藏  举报