最近公共祖先 LCA

Part 1. 定义 Definition \text{Definition} Definition

在一棵有根树中,一个节点的祖先节点是指它本身或者它父节点的祖先。给定两个节点,两个点共同的祖先中距离两者最近的节点就是这两个节点的最近公共祖先 Lowest Common Ancestors \text{Lowest Common Ancestors} Lowest Common Ancestors,简称 LCA \text{LCA} LCA)。需要注意的是最近公共祖先可能是这两个节点中的某一个。

形式化地,在一个有根树中,给定两个结点 u u u v v v,最近公共祖先 LCA ( u , v ) \text{LCA}(u,v) LCA(u,v) 表示一个结点 x x x,满足 x x x u u u v v v 的祖先且 x x x 的在树中的深度尽可能得大。在这里,一个节点也可以是它自己的祖先。

Part 2. 解法 Solution \text{Solution} Solution

最近公共祖先问题大致可以分为 3 3 3 种常见解法,分别为:

  • 暴力上提:计算每个点的深度,先将 u u u v v v 提升到同一层,然后将二者一起向上提升。单次查询的时间复杂度为 O ( n ) \mathcal {O}(n) O(n)

  • 路径标记:先让 u u u 不断向上找它的祖先,使用一个 bool 型数组,将经过的节点统一标记为 True \text{True} True,然后让 v v v 不断向上找它的祖先,当 v v v 走到一个点,如果发现这个点已经被标记过了,那么这个点就是 u u u v v v 的最近公共祖先。单次查询的时间复杂度为 O ( n ) \mathcal {O}(n) O(n)

  • 倍增算法,运用进制方法,将 u u u v v v 提升到同一层后,每次将 u u u v v v 同时向上提升 2 k 2^k 2k 个深度,直到提升至二者最近公共祖先的下一层(即 u u u v v v 均变为它们最近公共祖先的子节点),预处理的时间复杂度为 O ( n log ⁡ n ) \mathcal {O}(n\log n) O(nlogn),查询复杂度为 O ( log ⁡ n ) \mathcal {O}(\log n) O(logn)

本部分所有的代码,均以 洛谷 P3379 为例题,题目详情可以参见链接。

解法一:暴力上提

  • 先用 DFS \text{DFS} DFS 处理一下每个节点的深度和它的父亲是哪个节点;
  • 在求 LCA \text{LCA} LCA 的过程中,先行判断一下 u u u v v v 哪个深度更大,把两个点提升到一个同深度。接下来,当 u u u v v v 不重合(即没有相遇在它们的最近公共祖先)时,同时 u u u v v v 向上提升至它的父节点(也就是向上提升 1 1 1 层),直到二者重合为止。
//提交时间:2024-06-07 13:13:39
//记录链接:https://www.luogu.com.cn/record/161483633
//程序得分:Unaccept 100pts TLE in Subtask #1
#include<bits/stdc++.h>
using namespace std;
const int N = 1e6 + 10;
int fa[N], d[N], n, m, S;
int h[N], to[N], nxt[N], cnt;

//链式前向星加边
void add(int a, int b) {
	to[++cnt] = b;
	nxt[cnt] = h[a];
	h[a] = cnt;
}

//用 DFS 预处理一下每个节点的父亲是哪个节点和它的深度
void DFS(int u) {
	for (int i = h[u]; i != -1; i = nxt[i]) {//遍历这个节点的所有出边
		int v = to[i];
		if (v == fa[u]) continue;
		fa[v] = u;//将 v 的父亲记为 u,因为存在一条 u -> v 的直接连边
		d[v] = d[u] + 1;//根据树的性质,子节点的深度 = 父节点深度 + 1
		DFS(v);//搜索这个点的子节点
	}
}

//查询两个节点的最近公共祖先,时间复杂度 O(n)
int LCA(int u, int v) {
	if (d[u] < d[v]) swap(u, v);//先行判断两个节点的深度关系
	while (d[u] != d[v]) u = fa[u];//将两个节点提升至同一个高度
	while (u != v) u = fa[u], v = fa[v];//如果两个节点没有相遇在他们的最近公共祖先,那么就把他们同时上提一层
	return u;
}

int main() {
//	freopen("LCA.in", "r", stdin);
//	freopen("LCA.out", "w", stdout);
	ios::sync_with_stdio(false);
	cin.tie(0), cout.tie(0);
	cin >> n >> m >> S;
	memset(h, -1, sizeof h);//使用链式前向星存边的必备语句
	for (int i = 1; i < n; i++) {
		int u, v;
		cin >> u >> v;
		add(u, v), add(v, u);//题目中的边是双向的
	}
	DFS(S);//预处理
	for (int i = 1; i <= m; i++) {
		int u, v;
		cin >> u >> v;
		cout << LCA(u, v) << "\n";
	}
	return 0;
}

这样提交的话,我们发现只能通过 Subtask 0 \text{Subtask 0} Subtask 0,没有办法通过所有的测试点。这是因为我们在把二者上提的过程中每次只提升了 1 1 1 层。不妨想象一个极端的数据,假设这个数据是一条链的话,那么最坏的情况下,我们需要跑完整条链才能给出答案,其复杂度是十分惊人的。我们不由地想到,是否能够有一种方法,使得在向上提升的过程中,能够最大程度地减少移动的次数呢?答案是肯定的,具体细节将会在第三个解法里详细讨论。

解法二:路径标记

根据我在上面所说的,我们不难概括出如下的步骤:

  • 定义一个 bool 型数组 vis \text{vis} vis vis u \text{vis}_u visu 表示编号为 u u u 的节点是否已经被访问过;
  • 让节点 u u u 不断向上寻找它的祖先,并对走过的节点标记为 True \text{True} True
  • 让节点 v v v 不断向上寻找它的祖先,每次经过一个点,先看这个点是否被标记了。如果已经被标记了,那么这个点就是 u u u v v v 的最近公共祖先。
//提交时间:2024-06-07 14:57:59
//记录链接:https://www.luogu.com.cn/record/161489351
//程序得分:Unaccept 70pts TLE in Subtask #0 & Subtask #1 
#include<bits/stdc++.h>
using namespace std;
const int N = 1e6 + 10;
int fa[N], n, m, S;
int h[N], to[N], nxt[N], cnt;
bool vis[N];

//链式前向星加边 
void add(int a, int b) {
	to[++cnt] = b;
	nxt[cnt] = h[a];
	h[a] = cnt;
}

//用 DFS 预处理一下每个节点的父亲是哪个节点和它的深度 
void DFS(int u) {
	for (int i = h[u]; i != -1; i = nxt[i]) {//遍历这个节点的所有出边 
		int v = to[i];
		if (v == fa[u]) continue;
		fa[v] = u;//将 v 的父亲记为 u,因为存在一条 u -> v 的直接连边 
		DFS(v);//搜索这个点的子节点 
	}
}

//查询两个节点的最近公共祖先,时间复杂度 O(n)
int LCA(int u, int v) {
	if(u == v) return u;//特判:u 和 v 重合 
	vis[u] = 1;
	while(fa[u] != u) u = fa[u], vis[u] = true;
	if(vis[v] == true) return v;//特判:v 就是 u 的祖先 
	while(1) {
		v = fa[v];
		if(vis[v] == true) return v;
	}
	return 0;
}

int main() {
//	freopen("LCA.in", "r", stdin);
//	freopen("LCA.out", "w", stdout);
	ios::sync_with_stdio(false);
	cin.tie(0), cout.tie(0);
	cin >> n >> m >> S;
	memset(h, -1, sizeof h);//使用链式前向星存边的必备语句 
	for (int i = 1; i < n; i++) {
		int u, v;
		cin >> u >> v;
		add(u, v), add(v, u);//题目中的边是双向的 
	}
	DFS(S);//预处理 
	for (int i = 1; i <= m; i++) {
		int u, v;
		cin >> u >> v;
		memset(vis, false, sizeof vis);//记得每次要清空 
		cout << LCA(u, v) << "\n";
	}
	return 0;
}

这个算法提交上去结果比第一次的还要差,这是因为我们的代码中让 u u u 一直往上走,直到到达树根。这样子的话可能会浪费大量的时间,造成超时。

解法三:倍增算法

还记得我们在 ST 表中的那行代码吗?我们将 ST 表的存储数组 F i , j F_{i,j} Fi,j 定义为以 i i i 为起点,长度为 2 j 2^j 2j 的序列中的某种信息。那么我们是不是可以举一反三,把这个思想运用到求最近公共祖先的过程中?答案是肯定的。

首先,先给出一个结论, ∀ x ∈ N ∗ , x  可以被分解为若干个 2 的非负整数幂的和 \forall x \in \mathbb{N}^*,x \ \text{可以被分解为若干个 2 的非负整数幂的和} xN,x 可以被分解为若干个 2 的非负整数幂的和。例如, 11 = 2 3 + 2 1 + 2 0 11 = 2^3+2^1+2^0 11=23+21+20 22 = 2 4 + 2 2 + 2 1 22 = 2^4+2^2+2^1 22=24+22+21,我们不难使用二进制的思想对其进行证明,在此不过多解释。

那么我们是不是就可以将向上走的距离用若干个 2 2 2 的非负整数幂来进行表示?假设一棵树有 n n n 个节点,那么它在极端情况(一条链)时,其深度为 n n n,那么,我们就可以最多用 log ⁡ 2 n \log_{2} n log2n 个可能的幂次来表示所有的情况。所以这个时候我们只需要在初始化的时候额外进行一项初始化:

定义数组 ANC \text{ANC} ANC A N C i , j ANC_{i,j} ANCi,j 表示从节点 i i i 出发,向上走 2 j 2^j 2j 层所能够到达的节点编号。这样子的话,我们只需要在往上提的过程中,从大到小枚举 j j j,如果可以走的话,就将二者同时向上提升 2 j 2^j 2j 层,直到到达它们的最近公共祖先的下一层。主要思路如下:

  • 预处理数组 d \text{d} d 记录深度, ANC \text{ANC} ANC 储存提升的信息;
  • u u u v v v 先提升至同一高度;
  • u u u v v v 的父节点不相同时,从大到小枚举 k k k,如果可以提升,就将 u u u v v v 同时上提 2 k 2^k 2k 层。
//提交时间:2024-06-07 19:48:46
//记录链接:https://www.luogu.com.cn/record/161517361
//程序得分:Accept 
#include<bits/stdc++.h>
using namespace std;
const int N = 1e6 + 10;
int n, m, S;
int d[N];
int anc[N][20];
int h[N], to[N], nxt[N], cnt;
int a, b;

//链式前向星加边
void add(int a, int b) {
	to[++cnt] = b;
	nxt[cnt] = h[a];
	h[a] = cnt;
}

//预处理 anc 数组
void prepare() {
	for(int j = 1; j <= 18; j++)
		for (int i = 1; i <= n; i++)
			anc[i][j] = anc[anc[i][j - 1]][j - 1];//使用递推式求出每个节点向上走 2 的 k 次方所能到达的距离
}

void DFS(int u, int fa) {
	for (int i = h[u]; i != -1; i = nxt[i]) {//遍历这个节点的所有出边
		int v = to[i];//储存一下边 u -> v 的终点
		if (v == fa) continue;
		d[v] = d[u] + 1;//一个节点的深度 = 它父节点的深度 + 1
		anc[v][0] = u;//一个节点的 2 的 0 次方节点就是它的父节点
		DFS(v, u);//搜索这个点的子节点
	}
}

int LCA(int u, int v) {
	if (d[u] < d[v]) swap(u, v);
	for (int i = 18; i >= 0; i--) if (d[anc[u][i]] >= d[v]) u = anc[u][i];//先将较深的节点提高至一个高度 
	if (u == v) return u;
	for (int i = 18; i >= 0; i--) if (anc[u][i] != anc[v][i]) u = anc[u][i], v = anc[v][i];//将二者提升到最近公共祖先的下一层 
	return anc[u][0];
}

int main() {
//	freopen("LCA.in", "r", stdin);
//	freopen("LCA.out", "w", stdout);
	ios::sync_with_stdio(0);
	cin.tie(0), cout.tie(0);
	memset(h, -1, sizeof h);
	cin >> n >> m >> S;
	for(int i = 1; i < n; i++) {
		int u, v;
		cin >> u >> v;
		add(u, v), add(v, u);//双向图加边
	}
	d[S] = 1;
	DFS(S, 0);
	prepare();
    //预处理
	while (m--) {
		cin >> a >> b;
		cout << LCA(a, b) << "\n";
	}
	return 0;
}

以上就是最常见的三种解法,但这篇文章日后可能还会有所更新,敬请期待!

  • 23
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值