最近公共祖先

公共祖先: 在一棵有根树上,若节点 F 是节点 x 的祖先,也是节点 y 的祖先,那么称 Fxy 的公共祖先。

最近公共祖先(LCA):xy 的所有公共祖先中,深度最大的称为最近公共祖先,记为 LCA(x,y)

image

LCA 显然有以下性质。

  1. 在所有公共祖先中,LCA(x,y)xy 的距离都是最短的。例如,在 eg 的所有祖先中,c 距离更短。
  2. xy 之间最短的路径经过 LCA(x,y)。例如,从 eg 的最短路径经过 c
  3. xy 本身也可以是它们自己的公共祖先。若 yx 的祖先,则有 LCA(x,y)=y,如图中 d=lca(d,h)

如何求 LCA?根据 LCA 的定义,很容易想到一个简单直接的方法:分别从 xy 出发,一直向根节点走,第一次相遇的节点就是 LCA(x,y)。具体实现时,可以用标记法:首先从 x 出发一直向根节点走,沿路标记所有经过的祖先节点;把 x 的祖先标记完之后,然后再从 y 出发向根节点走,走到第一个被 x 标记的节点,就是 LCA(x,y)。标记法的时间复杂度较高,在有 n 个节点的树上求一次 LCA(x,y) 的时间复杂度为 O(n)。若有 m 次查询,总的时间复杂度为 O(mn),效率太低。

倍增法求 LCA

可以把标记法换一种方式实现,分为以下两个步骤。

  1. 先把 xy 提到相同的深度。例如,xy 深,就把 x 提到 y 的高度(既让 x 走到 y 的同一高度),如果发现 x 直接就跳到 y 的位置上了,那么就停止查找,否则继续下一步。
  2. xy 继续同步向上走,每走一步就判断是否相遇,相遇点就是 LCA(x,y) 停止。

上面的两个步骤,如果 xy 都一步一步向上走,时间复杂度为 O(n)。如何改进?如果不是一步步走,而是跳着走,就能加快速度。如何跳?可以按 2 的倍数向上跳,即跳 1,2,4,8, 步,这就是倍增法,倍增法用“跳”的方法加快了上述两个步骤。

步骤 1

xy 提到相同的深度。具体任务是:给定两个节点 xy,设 xy 深,让 x “跳”到与 y 相同的深度。

因为已知条件是只知道每个节点的父节点,所以如果没有其他辅助条件,x 只能一步步向上走,没法“跳”。要实现“跳”的动作,必须提前计算出一些 x 的祖先节点,作为 x 的“跳板”。然而,应该提前计算出哪些祖先节点呢?如何通过这些预计算出的节点准确且高效地跳到一个任意给定的 y 的深度?这就是倍增法的精妙之处:预计算出每个节点的第 1,2,4,8,16, 个祖先,即按 2 倍增的那些祖先。

有了预计算出的这些祖先做跳板,能从 x 快速跳到任何一个给定的目标深度。以从 x 跳到它的第 27 个祖先为例:

  1. x16 步,到达 x 的第 16 个祖先 fa1
  2. fa18 步,到达 fa1 的第 8 个祖先 fa2
  3. fa22 步到达祖先 fa3
  4. fa31 步到达祖先 fa4

共跳了 16+8+2+1=27 步,这个方法利用了二进制的特征:任何一个数都可以由 2 的倍数相加得到。27 的二进制是 11011,其中的 41 的权值就是 16,8,2,1

显然,用倍增法从 x 跳到某个 y 的时间复杂度为 O(logn)

剩下的问题是如何快速预计算每个节点的“倍增”的祖先。定义 fax,ix 的第 2i 个祖先,有非常巧妙的递推关系:fax,i=fafax,i1,i1。分两步理解:fax,i1x 起跳,先跳 2i1 步,记这个点为 z;再从 z2i1 步,一共跳了 2i1+2i1=2i 步。

特别地,fax,0x 的第 20=1 个祖先,就是 x 的父节点。fax,0 是递推式的初始条件,从它开始递推出了所有的 fax,i。递推的计算量有多大?从任意节点 x 到根节点,最多只有 logn 个祖先,所以只需要递推 O(logn) 次。所以整个 fa 的计算时间复杂度为 O(nlogn)

步骤 2

经过上一个步骤,xy 现在位于同一深度,让它们同步向上跳,就能找到它们的公共祖先。xy 的公共祖先有很多,LCA(x, y) 是距离 xy 最近的那个,其他祖先都更远。

从一个节点跳到根节点,最多跳 logn 次。现在从 x,y 出发,从最大的 ilogn 开始,跳 2i 步,分别跳到 fax,i,fay,i,它们位于非常靠近根节点的位置(2in),有以下两种情况:

  1. fax,i=fay,i,这是一个公共祖先,它的深度小于或等于 LCA(x, y),这说明跳过头了,退回去换一个小的 i1 重新跳一次。
  2. fax,ifay,i,说明还没跳到公共祖先,那么更新 xfax,i,yfay,i,从新的起点 x,y 继续开始跳。由于新的 x,y 的深度比原来位置的深度减少超过一半,再跳时就不用跳 2i 步,跳 2i1 步就够了。

以上两种情况,分别是比 LCA(x, y) 浅和深的两种位置。用 i 循环判断以上两种情况,就是从深浅两侧逐渐逼近 LCA(x, y)。每循环一次,i 减一,当 i 减为 0 时,xy 正好位于 LCA(x,y) 的下一层,则 fax,0 就是 LCA(x,y)。

查询一次 LCA 的时间复杂度是多少?这里的 i 会从 logn 递减到 0,循环 O(logn) 次。

倍增法的总计算量包括预计算 fa 和查询 m 次 LCA,总时间复杂度为 O(nlogn+mlogn)

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

参考代码
#include <cstdio>
#include <vector>
#include <algorithm>
using namespace std;
const int N = 500005;
const int LOG = 19;
vector<int> tree[N];
int depth[N], fa[N][LOG];
void dfs(int cur, int pre) {
depth[cur] = depth[pre] + 1; // 深度比父节点深度多1
for (int nxt : tree[cur]) { // 遍历所有邻居节点
if (nxt != pre) { // 除了父节点以外都是子节点
fa[nxt][0] = cur; // 记录父节点fa[][0]
dfs(nxt, cur);
}
}
}
int lca(int x, int y) {
if (depth[x] < depth[y]) swap(x, y); // 保证x深度更大
// 将x和y提到相同高度
int delta = depth[x] - depth[y];
for (int i = LOG - 1; i >= 0; i--)
if (delta & (1 << i)) x = fa[x][i];
if (x == y) return x; // 如果提到相同深度后已经重合,则直接返回
// x和y同步往上跳
for (int i = LOG - 1; i >= 0; i--)
if (fa[x][i] != fa[y][i]) { // 如果祖先相等,说明跳过头了,换一个小的i继续尝试
x = fa[x][i]; y = fa[y][i]; // 如果祖先不相等,就更新x和y继续跳
}
// 最终x和y位于LCA的下一层,此时x或y的父节点就是LCA
return fa[x][0];
}
int main()
{
int n, m, s; scanf("%d%d%d", &n, &m, &s);
for (int i = 1; i < n; i++) {
int x, y; scanf("%d%d", &x, &y);
// 建树
tree[x].push_back(y); tree[y].push_back(x);
}
dfs(s, 0); // 预处理深度等信息
for (int i = 1; i < LOG; i++)
for (int j = 1; j <= n; j++)
fa[j][i] = fa[fa[j][i - 1]][i - 1]; // 从fa[][0]开始递推
while (m--) {
int a, b; scanf("%d%d", &a, &b); printf("%d\n", lca(a, b));
}
return 0;
}

常见的应用思想:

  • 对于 (x,y) 间的路径,拆成 xLCAyLCA 分别倍增。
  • 对于 (x,y) 间的路径,拆成 rootxrooty,去掉 rootLCA

例题:P1967 [NOIP2013 提高组] 货车运输

分析:假设询问的点是 uv

如果只有一次询问,可以按边权从大到小加边,直至 (u,v) 连通,那么拓展到多次询问,每次的路径一定在原图的最大生成树上。如果两个点在不同的树上就是无解,这个可以用并查集判断。

现在问题就放到树上了。如果把路径拆成 (root,u)(root,v) 再去掉 (root,lca) 会发现,对于取最小值的操作无法做去掉的那一部分计算,因此就只能把路径拆成 (u,lca)(v,lca) 了。

在倍增求祖先时同时维护这段路径上的边权最小值,进行询问的计算时,在找 LCA 的过程中找到目前边权的最小值,别忘了最后还要考虑 (x,fax,0)(y,fay,0) 这两条边的边权。

参考代码
#include <cstdio>
#include <utility>
#include <algorithm>
#include <vector>
using std::sort;
using std::min;
using std::swap;
using std::pair;
using std::vector;
using g_edge = pair<int, pair<int, int>>; // (边权,(x,y))
using t_edge = pair<int, int>; // (点,边权)
const int N = 10005;
const int M = 50005;
const int LOG = 14;
const int INF = 100000;
g_edge e[M];
vector<t_edge> tree[N];
bool vis[N];
int d[N], f[N][LOG], minw[N][LOG];
struct DSU {
int fa[N];
void init(int n) {
for (int i = 1; i <= n; i++) fa[i] = i;
}
int query(int x) {
return fa[x] == x ? x : fa[x] = query(fa[x]);
}
bool merge(int x, int y) {
int qx = query(x), qy = query(y);
if (qx != qy) fa[qx] = qy;
return qx != qy;
}
};
DSU dsu;
void dfs(int u, int fa) {
vis[u] = true;
for (t_edge e : tree[u]) {
int v = e.first, w = e.second;
if (v == fa) continue;
d[v] = d[u] + 1; f[v][0] = u; minw[v][0] = w;
dfs(v, u);
}
}
int lca(int x, int y) { // 这个lca函数求的是路径上的最小边权
if (d[x] < d[y]) swap(x, y);
int delta = d[x] - d[y];
int res = INF;
for (int i = LOG - 1; i >= 0; i--)
if (delta & (1 << i)) {
res = min(res, minw[x][i]);
x = f[x][i];
}
if (x == y) return res;
for (int i = LOG - 1; i >= 0; i--) {
if (f[x][i] != f[y][i]) {
res = min(res, min(minw[x][i], minw[y][i]));
x = f[x][i]; y = f[y][i];
}
}
// 别忘了最后两条边
res = min(res, min(minw[x][0], minw[y][0]));
return res;
}
int main()
{
int n, m; scanf("%d%d", &n, &m);
for (int i = 1; i <= m; i++) {
int x, y, z; scanf("%d%d%d", &x, &y, &z);
e[i] = {z, {x, y}};
}
// 构建最大生成树
sort(e + 1, e + m + 1);
dsu.init(n);
for (int i = m; i >= 1; i--) {
int w = e[i].first;
int x = e[i].second.first, y = e[i].second.second;
if (dsu.merge(x, y)) {
tree[x].push_back({y, w});
tree[y].push_back({x, w});
}
}
// 倍增预处理
for (int i = 1; i <= n; i++) if (!vis[i]) dfs(i, 0);
for (int j = 1; j < LOG; j++) {
for (int i = 1; i <= n; i++) {
f[i][j] = f[f[i][j - 1]][j - 1];
minw[i][j] = min(minw[i][j - 1], minw[f[i][j - 1]][j - 1]);
}
}
int q; scanf("%d", &q);
for (int i = 1; i <= q; i++) {
int x, y; scanf("%d%d", &x, &y);
printf("%d\n", dsu.query(x) != dsu.query(y) ? -1 : lca(x, y));
}
return 0;
}

例题:P4180 [BJWC2010] 严格次小生成树

设一张图的最小生成树边权之和为 S,则该图的严格次小生成树定义为该图所有边权之和大于 S 的生成树中边权之和最小者(可能不存在,也可能存在多棵)。
现给出一张 n 个点,m 条边的无向图,边权为 wi,求出该图的严格次小生成树边权之和。数据保证原图存在严格次小生成树。
数据范围:n105,m3×105,0wi109

分析:一种简单的思路是尝试找到原图的所有生成树,然后通过比较得出答案。但由于生成树数量过多,这样的算法显然效率很低。

由于严格次小生成树的边权和仅大于最小生成树边权和,因此可以猜测,严格次小生成树很可能就是在最小生成树上替换一条或几条边得到。事实上,可以证明,一定存在一棵严格次小生成树,使得它与某棵最小生成树仅有一条边的差距。

考虑一条不在原来的最小生成树上的边,如果把它加入最小生成树后会形成一个环,显然这个环上其他边的边权都小于等于刚加的这条边的边权(不然一开始的最小生成树就不成立了)。更进一步,这里可以把等于的情况去掉,因为如果存在等于的情况,说明是另一棵边权和相等但树的形态不同的最小生成树。所以如果严格次小生成树和最小生成树之间有两条以上的边不同,那么我们可以把这些不同的边中的其中一条改为在最小生成树上的边,剩下的不变,则此时得到的生成树边权和变小了,但还是比最小生成树的边权和要大。由此得知,最多只选 1 条边做替换。

有了这个性质,就可以考虑在建完最小生成树之后寻找那条不属于最小生成树,但属于严格次小生成树的边。枚举每一条非树边,在加入这条边之后,生成树上出现了一个环,再断掉环中其他边(环中其他的边实际上就是这条非树边的两点在最小生成树中的路径)里面边权最大的边(若该边边权与环内其他边权最大者相等,则断掉边权中严格次大的,注意有可能不存在这样的严格次大边),那么就得到了包含这条边的生成树中权值最小的。将所有这样的生成树权值和取 min 后,就可以得到最终的答案。

可以采用树上倍增的方法,定义 1 为根,并存储每个点向上 2i 条边的最大值与严格次大值。在寻找时,通过倍增取出这些最大值与严格次大值并依次进行更新,就可以得到需要断的边的权值。注意在存储和寻找严格次大值时的分类讨论条件。

整个算法的时间复杂度为 O(nlogn+mlogn)

#include <cstdio>
#include <algorithm>
#include <vector>
using std::sort;
using std::swap;
using std::min;
using std::max;
using std::vector;
typedef long long LL;
const int N = 1e5 + 5;
const int M = 3e5 + 5;
const int LOG = 17;
const LL INF = 1e15;
struct Edge {
int x, y, z;
};
Edge edges[M];
bool mst[M]; // 记录每条边是否是最小生成树上的边
vector<Edge> tree[N];
// root用于并查集
// depth存储节点深度
// fa/w1/w2[u][i]代表节点u向上2的i次方层祖先/边权最大值/边权次大值
int root[N], depth[N], fa[N][LOG], w1[N][LOG], w2[N][LOG];
int query(int x) {
return root[x] == x ? x : root[x] = query(root[x]);
}
// update函数实现对两组最大、次大值合并得到新的最大、次大值
void update(int& mx1, int& mx2, int a1, int a2, int b1, int b2) {
mx1 = max(a1, b1);
mx2 = a1 == b1 ? max(a2, b2) : max(min(a1, b1), max(a2, b2));
}
void dfs(int u, int pre) {
depth[u] = depth[pre] + 1;
fa[u][0] = pre;
for (Edge e : tree[u]) {
int v = e.y, w = e.z;
if (v == pre) continue;
w1[v][0] = w;
dfs(v, u);
}
}
LL lca(int x, int y, int w, LL sum) {
// 在倍增法求lca的过程中实现最大边权和次大边权的计算
if (depth[x] < depth[y]) swap(x, y);
int delta = depth[x] - depth[y];
int res1 = 0, res2 = 0; // 最大、严格次大
for (int i = LOG - 1; i >= 0; i--)
if (delta & (1 << i)) {
update(res1, res2, res1, res2, w1[x][i], w2[x][i]);
x = fa[x][i];
}
if (x == y) {
// 有可能加的非树边与环内其他最长边相等
// 也有可能环内不存在次长边
if (res1 == w) return res2 == 0 ? INF : sum + w - res2;
else return res1 == 0 ? INF : sum + w - res1;
}
int tmp1 = 0, tmp2 = 0;
for (int i = LOG - 1; i >= 0; i--)
if (fa[x][i] != fa[y][i]) {
update(tmp1, tmp2, w1[x][i], w2[x][i], w1[y][i], w2[y][i]);
update(res1, res2, res1, res2, tmp1, tmp2);
x = fa[x][i]; y = fa[y][i];
}
update(tmp1, tmp2, w1[x][0], w2[x][0], w1[y][0], w2[y][0]);
update(res1, res2, res1, res2, tmp1, tmp2);
// 有可能加的非树边与环内其他最长边相等
// 也有可能环内不存在次长边
if (res1 == w) return res2 == 0 ? INF : sum + w - res2;
else return res1 == 0 ? INF : sum + w - res1;
}
int main()
{
int n, m; scanf("%d%d", &n, &m);
for (int i = 1; i <= n; i++) root[i] = i;
for (int i = 1; i <= m; i++) {
scanf("%d%d%d", &edges[i].x, &edges[i].y, &edges[i].z);
}
// 先求一棵最小生成树
sort(edges + 1, edges + m + 1, [](Edge& e1, Edge& e2) {
return e1.z < e2.z;
});
LL sum = 0;
for (int i = 1; i <= m; i++) {
int x = edges[i].x, y = edges[i].y, z = edges[i].z;
int qx = query(x), qy = query(y);
if (qx != qy) {
root[qx] = qy; sum += z; mst[i] = true;
tree[x].push_back({x, y, z});
tree[y].push_back({y, x, z});
}
}
dfs(1, 0);
for (int i = 1; i < LOG; i++)
for (int j = 1; j <= n; j++) {
// 预处理倍增表
fa[j][i] = fa[fa[j][i - 1]][i - 1];
int a1 = w1[j][i - 1], a2 = w2[j][i - 1];
int b1 = w1[fa[j][i - 1]][i - 1], b2 = w2[fa[j][i - 1]][i - 1];
update(w1[j][i], w2[j][i], a1, a2, b1, b2);
}
LL ans = INF;
for (int i = 1; i <= m; i++) {
int x = edges[i].x, y = edges[i].y, z = edges[i].z;
if (x == y) continue;
if (!mst[i]) ans = min(ans, lca(x, y, z, sum));
}
printf("%lld\n", ans);
return 0;
}
posted @   RonChen  阅读(63)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· Docker 太简单,K8s 太复杂?w7panel 让容器管理更轻松!
点击右上角即可分享
微信分享提示