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

建议结合板题食用

【1】暴力做法

我们先用 DFS 预处理所有节点的深度,每次查询,将深度大的节点向上提到和另一节点深度相同的位置,然后同时向上提,直到两节点碰面。

在极端情况下,树只有两条链构成,那么查询链末端的节点的最近公共祖先复杂度可达到 O(n),不可通过本题,所以我们需要更高效的解法。

【2】倍增解法:

在暴力做法中,每次向上提一个节点速度太慢,可以考虑倍增做法。

每次向上提一个节点很慢,我们可以预处理出每个节点的 2k 级祖先(如果没有设为 0),用 anci,k 数组存下。

具体来说,anci,0 就是 i 的父节点,这个在 DFS 中顺便处理。

对于其它 anci,k 它就为 anci,k1k1 级祖先,即 ancanci,k1,k1。因为 2k=2k1×2=2k1+2k1,所以这样就可以转移了。

对于每次查询,首先,还是将深度较深的节点往上提。从 log2n0 枚举,如果它的 2i 级祖先不比另一节点深度小,则把该节点提升到它的 2i 级祖先,直到两节点深度相同。

接下来同时提两节点,如果两节点已经重合,直接返回。否则,按照刚才的思路,如果它们的 2i 级祖先不一样,则把两节点提升到它的 2i 级祖先,直到不能向上提为止。

但此时两个节点应该在它们最近公共祖先的子节点位置,所以返回其中一节点的父节点(即 20 级祖先)。

显然倍增的做法为 O(mlogn)

#include<bits/stdc++.h>
using namespace std;
const int N = 5e5 + 9,LOGN = 18;
struct egde{
	int to,nex;
} e[N << 1];
int ecnt,head[N];
int depth[N];
int anc[N][LOGN + 9];
int n,m,s;
void addegde(int u,int v){
	ecnt++;
	e[ecnt] = (egde){v,head[u]};
	head[u] = ecnt;
}
void dfs(int u,int fa){
	for(int i = head[u];i;i = e[i].nex){
		int v = e[i].to;
		if(v == fa)
			continue;
		depth[v] = depth[u] + 1;
		anc[v][0] = u;
		dfs(v,u);
	}
}
void init(){
	for(int j = 1;j <= LOGN;j++)
		for(int i = 1;i <= n;i++)
			anc[i][j] = anc[anc[i][j - 1]][j - 1];
}
int LCA(int u,int v){
	if(depth[u] < depth[v])
		swap(u,v);
	for(int i = LOGN;i >= 0;i--)
		if(depth[anc[u][i]] >= depth[v])
			u = anc[u][i];
	if(u == v)
		return u;
	for(int i = LOGN;i >= 0;i--){
		if(anc[u][i] != anc[v][i]){
			u = anc[u][i];
			v = anc[v][i];
		}
	}
	return anc[u][0];
}
int main(){
	scanf("%d%d%d", &n, &m, &s);
	for(int i = 1;i <= n - 1;i++){
		int x,y;
		scanf("%d%d", &x, &y);
		addegde(x,y);
		addegde(y,x);
	}
	depth[s] = 1;
	dfs(s,0);
	init();
	for(int i = 1;i <= m;i++){
		int a,b;
		scanf("%d%d",&a ,&b);
		printf("%d\n",LCA(a,b));
	}
	return 0;
}

【3】树剖做法:

重链剖分中,我浅浅的提到了用树剖做最近公共祖先的做法。每次把当前节点所在重链链头深度较大的点跳到链头的父节点,直到两节点链头相同,此时一个肯定是另一个的祖先节点,返回深度较小的节点即为两节点的 LCA。

根据重链剖分中的分析,这种做法的复杂度也是 O(mlogn)。但是跳到根节点 O(logn) 的复杂度是一个很松的上界,即使到了这个上界,树剖求 LCA 的常数也远小于倍增算法。并且可以发现,树剖求 LCA 的核心代码比倍增还短。

#include<bits/stdc++.h>
using namespace std;
const int N = 5e5 + 9;
struct egde{
	int to,nex;
} e[N << 1];
int ecnt,head[N];
int top[N],dep[N];
int fa[N],weight_child[N],siz[N];
int n,m,s;
void addegde(int u,int v){
	ecnt++;
	e[ecnt] = (egde){v,head[u]};
	head[u] = ecnt;
}
void dfs1(int cur,int father){
	fa[cur] = father;
	siz[cur] = 1;
	dep[cur] = dep[father] + 1;
	for(int i = head[cur];i;i = e[i].nex){
		int v = e[i].to;
		if(v != father){
			dfs1(v,cur);
			siz[cur] += siz[v];
			if(siz[v] > siz[weight_child[cur]])
				weight_child[cur] = v;
		}
	}
}
void dfs2(int cur,int link_top){
	top[cur] = link_top;
	if(weight_child[cur]){
		dfs2(weight_child[cur],link_top);
		for(int i = head[cur];i;i = e[i].nex){
			int v = e[i].to;
			if(v != fa[cur] && v != weight_child[cur])
				dfs2(v,v);
		}
	}
}
int LCA(int u,int v){
	while(top[u] != top[v]){
		if(dep[top[u]] < dep[top[v]])
			swap(u,v);
		u = fa[top[u]];
	}
	return (dep[u] < dep[v]) ? u : v;
}
int main(){
	scanf("%d%d%d", &n, &m, &s);
	for(int i = 1;i <= n - 1;i++){
		int x,y;
		scanf("%d%d", &x, &y);
		addegde(x,y);
		addegde(y,x);
	}
	dep[s] = 1;
	dfs1(s,0);
	dfs2(s,0);
	for(int i = 1;i <= m;i++){
		int a,b;
		scanf("%d%d",&a ,&b);
		printf("%d\n",LCA(a,b));
	}
	return 0;
}

【4】Tarjan算法:

我们知道,暴力提节点的速度很慢,上述两种方法都是在加速“提节点”方面作文章,Tarjan 算法的不同之处在于,先读入所有查询,对求 LCA 之前的整棵树的 DFS 进行修改,然后在 DFS 中完成所有查询。

首先,显然,如果有两个节点 x,yu 的不同分支上,那么 u 就是 x,y 的 LCA。

同时,这个问题具有比较突出的递归特性:一个节点的子树一定是它所有祖先的子树的一部分。也就是说,当递归处理完一个节点的询问并返回,可以将已经访问的该子树直接划归为父节点的一部分。以此类推,所有节点最终都会变成根节点的子树。

上述“划归”做法,可以考虑用并查集。

所以整个算法流程如下:

  1. 建树并存储全部查询。

  2. 从根节点开始 DFS。

  3. 在遍历到一个节点后,首先把它在并查集中的父节点设为其在树上的父节点。如果它还有子节点没有遍历,按照同样的方式遍历子节点。

  4. 遍历完所有子节点,回到这个节点后,处理与该节点相关的所有询问。如果询问中另一节点已被访问,那么另一节点此时在并查集中的祖先节点即为两节点的 LCA。

  5. 将该节点与其父节点合并。

#include<bits/stdc++.h>
using namespace std;
const int N = 5e5 + 9,M = 5e5 + 9;
int n,m,s;
struct egde{
	int to,nex;
} e[N << 1];
int head[N],ecnt;
bool vis[N];
struct query{
	int to,id;
};
vector<query>Q[M];
int ans[M];
void addegde(int u,int v){
	ecnt++;
	e[ecnt] = (egde){v,head[u]};
	head[u] = ecnt;
}
int fa[N];
int father(int x){
	return x == fa[x] ? x : fa[x] = father(fa[x]);
}
void join(int x,int y){
	int fa_x = father(x),fa_y = father(y);
	fa[fa_x] = fa_y;
}
void dfs(int cur) {
	fa[cur] = cur;
	vis[cur] = true;
	for(int i = head[cur]; i; i = e[i].nex){
		int v = e[i].to;
    	if(!vis[v]){
			dfs(v);
			//join(cur,v);
			fa[v] = cur;
		}
	}
	for(int i = 0;i < (int)Q[cur].size();i++){
		int v = Q[cur][i].to;
		if(vis[v])
			ans[Q[cur][i].id] = father(v);
	}
}
int main(){
	scanf("%d%d%d", &n, &m, &s);
	for(int i = 1;i < n;i++){
		int u,v;
		scanf("%d%d", &u, &v);
		addegde(u,v);
		addegde(v,u);
	}
	for(int i = 1;i <= m;i++){
		int a,b;
		scanf("%d%d", &a, &b);
		Q[a].push_back((query){b,i});
		Q[b].push_back((query){a,i});
	}
	dfs(s);
	for(int i = 1;i <= m;i++)
		printf("%d\n",ans[i]);
	return 0;
}

不难发现,Tarjan 是一个离线算法,因为路径压缩并查集均摊复杂度 O(1)(严格来说应该是 O(α(n)),但我们一般忽略其影响),所以整个算法复杂度为 O(n+m)

posted @   5t0_0r2  阅读(18)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· winform 绘制太阳,地球,月球 运作规律
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· AI 智能体引爆开源社区「GitHub 热点速览」
· Manus的开源复刻OpenManus初探
· 写一个简单的SQL生成工具
点击右上角即可分享
微信分享提示