最近公共祖先LCA是在图中两个节点最近的公共祖先节点,关于一些专业名词和解释网上都有,本篇笔记主要面向初学者,绕开各种专业名词,重在理解算法逻辑同时做到代码实现。
一切的开始——朴素算法
询问复杂度O(n*q) n-节点数 q-询问数
朴素算法的逻辑是:将查询节点x和y先上跳到同一高度,再一起上跳,直到相遇,相遇点就是LCA。
逻辑是很容易理解的,本处主要根据算法逻辑讲解代码实现。
首先确认节点的所有接口:由于我们需要找到任意一个节点的根节点,并且可以从根节点遍历所有的子节点,且知晓节点的深度,我们需要一个结构体节点来存放这些信息:
struct node{
int parent; //父节点
vector<int> son; //子节点
int deep; //节点深度
}
有了节点之后,用node来定义数组p[],随后很自然地抛出初始化操作:
void RankInit(int n){ //初始化
for(int i=1;i<=n;i++){
p[i].parent = i;
p[i].son.clear();
p[i].deep = 0;
}
}
由于树在建立的时候,节点会发生多种变化,如果边建树边确认节点的深度是动态的,比较复杂,我们选择在树建立完毕后,遍历一次确认所有节点的深度,输入根节点,用DFS实现遍历与赋值:
void DFS(int x_i, int d){ //查询每个节点的深度
p[x_i].deep = d;
for(int i=0;i<p[x_i].son.size();i++)
DFS(p[x_i].son[i],d+1);
}
需要注意的是,除非树被修改,不然节点都是固定的,节点的深度自然也是固定的,因此不需要每次查询LCA都重新确认节点深度,在查询之前完成一次即可(除非树被修改!)。
现在有了最重要的节点深度信息,一切变得触手可得,一个简单的LCA函数来找到它们的最近公共祖先:
int LCA(int x,int y){
if(x == y) return x; //碰面,返回节点
if(p[x].deep==p[y].deep)
return LCA(p[x].parent,p[y].parent); //同高度,一起跳
else
return LCA(x,p[y].parent); //低的节点往上跳
}
最后放出完整模板:
#include<bits/stdc++.h>
using namespace std;
const int maxn = 1000;
struct node{
int parent; //父节点
vector<int> son; //子节点
int deep; //节点深度
}p[maxn];
void RankInit(int n){ //初始化
for(int i=1;i<=n;i++){
p[i].parent = i;
p[i].son.clear();
p[i].deep = 0;
}
}
int Find(int x){
if(x==p[x].parent) return x;
else return Find(p[x].parent);
}
void DFS(int x_i, int d){ //查询每个节点的深度
p[x_i].deep = d;
for(int i=0;i<p[x_i].son.size();i++)
DFS(p[x_i].son[i],d+1);
}
int LCA(int x,int y){
if(x == y) return x; //碰面,返回节点
if(p[x].deep==p[y].deep)
return LCA(p[x].parent,p[y].parent); //同高度,一起跳
else
return LCA(x,p[y].parent); //低的节点往上跳
}
int main(){
int n, m;
cin>>n>>m;
RankInit(n);
int x, y;
while(m--){ //输入树
cin>>x>>y; //这里不特别讨论x=y的情况了,看具体题目
p[y].parent = x;
p[x].son.push_back(y);
}
cin>>x>>y; //查询
DFS(Find(y),0);
int lca;
if(p[x].deep<=p[y].deep) lca = LCA(x,y);
else lca = LCA(y,x);
cout<<lca<<endl;
}
更快!——倍增算法
单次询问复杂度O(qlogn)
倍增算法的根本逻辑与朴素算法一样,事实上它是对朴素算法是时间优化,优化逻辑是:不再一层一层上跳,而是一次性上跳2^i层,且i取最大值(刚好不会跳过头),直到跳到x和y的父节点一样,且x和y不一样为止。
这个优化思路也很好理解,但对于初学者而言,想要做到实现它则需要一些额外的指导。事实上我们需要明白,在朴素算法中,我们只需要维护一个父节点即可,而并不关心爷爷节点或者太爷爷节点(事实上它们不这么叫),因为我们每次只上跳一层。因此如果我们想要上跳2^i层,我们就不得不维护第2^i个父节点,也就是储存它的下标。事实上倍增算法用一点空间牺牲换来的时间上的优化,但这又比让所有节点储存它们所有的祖先节点的暴力来得好
在进入代码实现之前,笔者需要做一些声明:网上大部分关于倍增算法的代码实现都采用多个数组储存多种信息,甚至缺少注释和易懂的命名,让笔者在初学阶段的理解增加了许多困难。因此本文将使用笔者自身的代码习惯并加以解释。
下面开始解释代码实现:
老规矩先放出基本单位元:我们先思考一下我们需要一个节点承担多少接口:
1、父节点(用于构造树)
2、子节点(用于在树建立好之后从根节点遍历赋予深度参数)
3、节点深度
4、第2^i个父节点的下标(用于倍增跳跃)
于是,给出基本单位元数组:
struct node{
int parent; //父节点
vector<int> son; //子节点
int deep; //节点深度
int fd[16]; //节点2^i个父节点的下标数组
}p[maxn];
根据基本单位元的内容给出初始化函数Init,这个问题不大:
void Init(int n){ //初始化
for(int i=1;i<=n;i++){
p[i].parent = i;
p[i].son.clear();
p[i].deep = 0;
memset(p[i].fd,0,sizeof(p[i].fd));
}
}
后面是和朴素算法几乎完全一样的深度查询赋值模块,不过将父节点储存进fd[0],也就是当前节点的第2^0=1个父节点,其余不再复述:
int Find(int x){
if(x==p[x].parent) return x;
else return Find(p[x].parent);
}
void DFS_deep_Init(int x, int d){ //当前节点和当前节点的深度
p[x].deep = d;
p[x].fd[0] = p[x].parent; //直接父节点下标可知
for(int i=0;i<p[x].son.size();i++)
DFS_deep_Init(p[x].son[i],d+1);
}
接下来是重头戏,我们已经完成了对单个节点的深度、父节点和子节点(主函数中输入)的确认,但在倍增算法中,核心是储存着第2^i个父节点的下标的内存。在这里需要明白一个指数算法的特性:
节点x的第2^i父节点就是x的2^i-1的父节点的2^i-1的父节点,举个例子,2^2 = 4 = 2^1 * 2^1。这个有趣的特性使得我们能过利用这种传递性来完成对所有节点信息的储存。从已有的fd[0],可以逐步推到出所有的fd,看代码:
void DFS_fd_Init(int x,int d){
for(int i=1;(1<<i)<=p[x].deep;i++){
p[x].fd[i] = p[p[x].fd[i-1]].fd[i-1];
}
for(int i=0;i<p[x].son.size();i++)
DFS_fd_Init(p[x].son[i],d+1);
}
其中(1<<i)<=p[x].deep表示当2^i大于节点的深度后,已经比根节点还高了,不存在更多的祖先节点,结束赋值。实在不能理解储存第2^i个父节点下标赋值的过程,可以自己用纸笔模拟一下。
有了宝贵的第2^i个父节点下标后,我们终于可以开始找lca了。找lca的核心逻辑和朴素算法是一样的,先跳到一个高度,再一起跳,那问题就是该怎么跳。由于倍增算法的跳跃不是等距的,因此我们需要专门讨论跳跃距离的值。因为同样的原因,递归会比较麻烦,这里直接选择循环型。
由于跳跃距离是2^i,因此跳跃距离的最大取值没必要大于log2(del),其中del是两个节点的高度差,因此i从log2(del)开始递减,如果跳过头了就i--,如果del=0了,就说明跳到了一样的高度,同时,记得跳跃成功后改变节点的下标:
int del = p[y].deep - p[x].deep; //高度差
if(del!=0){ //不在一个高度
for(int i=(int)log2(del);i>=0;i--){
if(p[p[y].fd[i]].deep<p[x].deep) continue; //跳过头了
if(del == 0) break; //跳到一个高度了
y = p[y].fd[i];
del = p[y].deep - p[x].deep; //更新高度差
i=(int)log2(del)+1; //更新i
}
}
跳到一个高度后,我们开始让x,y一起跳,跳到x!=y且x和y的直接父节点相等,则说明x和y到了最接近合并的临界位置,则此时x或y的直接父节点就是lca:
del = p[x].deep; //节点到根节点的高度差
for(int i=(int)log2(del);i>=0;i--){
if(x!=y && p[x].parent==p[y].parent) return p[x].parent; //碰面,返回lca
if(x==y) continue; //跳过头了
x = p[x].fd[i];
y = p[y].fd[i];
}
完整的LCA函数:
int LCA(int x,int y){
int del = p[y].deep - p[x].deep; //高度差
if(del!=0){ //不在一个高度
for(int i=(int)log2(del);i>=0;i--){
if(p[p[y].fd[i]].deep<p[x].deep) continue; //跳过头了
if(del == 0) break; //跳到一个高度了
y = p[y].fd[i];
del = p[y].deep - p[x].deep; //更新高度差
i=(int)log2(del)+1; //更新i
}
}
del = p[x].deep; //节点到根节点的高度差
for(int i=(int)log2(del);i>=0;i--){
if(x!=y && p[x].parent==p[y].parent) return p[x].parent; //碰面,返回lca
if(x==y) continue; //跳过头了
x = p[x].fd[i];
y = p[y].fd[i];
}
return 0; //返回0则没找到
}
最后,给出完整模板:
#include<bits/stdc++.h>
using namespace std;
const int maxn = 1000;
struct node{
int parent; //父节点
vector<int> son; //子节点
int deep; //节点深度
int fd[16]; //节点2^i个父节点的下标数组
}p[maxn];
void Init(int n){ //初始化
for(int i=1;i<=n;i++){
p[i].parent = i;
p[i].son.clear();
p[i].deep = 0;
memset(p[i].fd,0,sizeof(p[i].fd));
}
}
int Find(int x){
if(x==p[x].parent) return x;
else return Find(p[x].parent);
}
void DFS_deep_Init(int x, int d){ //当前节点和当前节点的深度
p[x].deep = d;
p[x].fd[0] = p[x].parent; //父节点下标可知
for(int i=0;i<p[x].son.size();i++)
DFS_deep_Init(p[x].son[i],d+1);
}
void DFS_fd_Init(int x,int d){
for(int i=1;(1<<i)<=p[x].deep;i++){
p[x].fd[i] = p[p[x].fd[i-1]].fd[i-1];
}
for(int i=0;i<p[x].son.size();i++)
DFS_fd_Init(p[x].son[i],d+1);
}
int LCA(int x,int y){
int del = p[y].deep - p[x].deep; //高度差
if(del!=0){ //不在一个高度
for(int i=(int)log2(del);i>=0;i--){
if(p[p[y].fd[i]].deep<p[x].deep) continue; //跳过头了
if(del == 0) break; //跳到一个高度了
y = p[y].fd[i];
del = p[y].deep - p[x].deep; //更新高度差
i=(int)log2(del)+1; //更新i
}
}
del = p[x].deep; //节点到根节点的高度差
for(int i=(int)log2(del);i>=0;i--){
if(x!=y && p[x].parent==p[y].parent) return p[x].parent; //碰面,返回lca
if(x==y) continue; //跳过头了
x = p[x].fd[i];
y = p[y].fd[i];
}
return 0; //返回0则没找到
}
int main(){
int n, m;
cin>>n>>m;
Init(n);
int x, y;
while(m--){ //输入树
cin>>x>>y; //这里不特别讨论x=y的情况了,看具体题目
p[y].parent = x;
p[x].son.push_back(y);
}
cin>>x>>y; //查询
if(p[x].deep>p[y].deep) swap(x,y);//默认y的deep更大
DFS_deep_Init(Find(y),1);
DFS_fd_Init(Find(y),1);
cout<<LCA(x,y)<<endl;
}
问完再一起找——Tarjan算法
询问复杂度O(n+q)
Tarjan算法的离奇之处在于,它是离线的——等你的询问全部结束后,通过一次遍历给出所有答案!因此当你的询问条数很多时,Tarjan将更有优势。Tarjan算法的思想不是一两句话能够解释的,如果你是初学者,且不喜欢强连通,搜索树,时间戳等各种专业名词,想先了解其算法思想,或者你对前两种算法的逻辑还不太明白,我都建议先观看保姆教程视频av93965143,然后再回来,我们来进行代码实现。
注:以下代码实现将基于上述视频给出的Tarjan的核心思想构建,不使用初学者不了解内容。
注注:一下Tarjan算法的代码不是表准的Tarjan模板,仅在此解决LCA问题,关于Tarjan的详细解读和内容补充会在另一篇笔记(待补)中给出。
重要的事情再说一遍,如果你对Tarjan解决LCA的思路一无所知,请观看av93965143!后文将不对思路进行解释。
老规矩,还是从树的最小单位元开始构建,思考需要的接口:
1、父节点(用于建树)
2、子节点(用于遍历子节点)
3、询问关系的节点下标(Tarjan特色)
那么给出树单元:
struct node{
int parent; //父节点
vector<int> son; //子节点
vector<int> question; //查询关系节点
}p[maxn]; //树
然后我们需要建立一些Tarjan算法需要的辅助容器:用于合并节点的并查集,用于记录节点是否被查询过的bool数组,用于储存查询结果的map(笔者用x,y来记录key),和储存查询条目的队列queue(用于最后打印结果):
int a[maxn]; //并查集,用于合并节点
bool vis[maxn] = {false};
map<string,int> ans; //储存查询结果
queue<string> que; //储存查询条目
如果你不明白为什么要建立上面4个容器,答应我,先看看视频av93965143好吗?
另外,为了满足用string储存查询条目的需要,我们构建一个辅助函数:
string Com(int x, int y){
string strx, stry;
stringstream sx, sy;
sx<<x; sy<<y;
sx>>strx; sy>>stry;
return strx+","+stry;
}
初始化,记得在初始化的时候带上并查集:
void Init(int n){ //初始化
for(int i=1;i<=n;i++){
a[i] = i;
p[i].parent = i;
p[i].son.clear();
}
}
由于在Tarjan算法中,我们不需要指导树的深度了,但我们仍需要Find函数来做一些工作,事实上在这它用来找到并查集的根节点:
int Find(int x){ //这个Find找的是并查集的根节点
if(x==a[x]) return x;
else return Find(a[x]);
}
接下来放出我们的核心Tarjan,注释已经尽可能详细:
void Tarjan(int x){
for(int i=0;i<p[x].son.size();i++){
Tarjan(p[x].son[i]); //找到最深的子节点
a[p[x].son[i]] = x; //回溯,将子节点合并到当前节点
vis[p[x].son[i]] = true; //记录合并过了
} //离开循环,说明没有子节点或子节点已经遍历完了,开始处理当前节点的查询
for(int i=0;i<p[x].question.size();i++){ //循环开始,说明有查询内容
if(vis[p[x].question[i]]){ //如果合并过了
ans[Com(p[x].question[i],x)] = Find(p[x].question[i]);
ans[Com(x,p[x].question[i])] = Find(p[x].question[i]);
//1,2和2,1的LCA是一回事,都存一下
}
} //离开循环,说明没有查询内容或者查询完毕
}
最后,给出模板,请格外注意一下main函数中对节点输入和查询输出的处理:
#include<bits/stdc++.h>
using namespace std;
const int maxn = 1000;
struct node{
int parent; //父节点
vector<int> son; //子节点
vector<int> question; //查询关系节点
}p[maxn]; //树
int a[maxn]; //并查集,用于合并节点
bool vis[maxn] = {false};
map<string,int> ans; //储存查询结果
queue<string> que; //储存查询条目
string Com(int x, int y){
string strx, stry;
stringstream sx, sy;
sx<<x; sy<<y;
sx>>strx; sy>>stry;
return strx+","+stry;
}
void Init(int n){ //初始化
for(int i=1;i<=n;i++){
a[i] = i;
p[i].parent = i;
p[i].son.clear();
}
}
int Find(int x){ //这个Find找的是并查集的根节点
if(x==a[x]) return x;
else return Find(a[x]);
}
void Tarjan(int x){
for(int i=0;i<p[x].son.size();i++){
Tarjan(p[x].son[i]); //找到最深的子节点
a[p[x].son[i]] = x; //回溯,将子节点合并到当前节点
vis[p[x].son[i]] = true; //记录合并过了
} //离开循环,说明没有子节点或子节点已经遍历完了,开始处理当前节点的查询
for(int i=0;i<p[x].question.size();i++){ //循环开始,说明有查询内容
if(vis[p[x].question[i]]){ //如果合并过了
ans[Com(p[x].question[i],x)] = Find(p[x].question[i]);
ans[Com(x,p[x].question[i])] = Find(p[x].question[i]);
//1,2和2,1的LCA是一回事,都存一下
}
}
}
int main(){
int n, m;
cin>>n>>m;
Init(n);
int x, y;
while(m--){ //输入树
cin>>x>>y; //这里不特别讨论x=y的情况了,看具体题目
p[y].parent = x;
p[x].son.push_back(y);
}
int q;
cin>>q;
while(q--){ //查询
cin>>x>>y;
p[y].question.push_back(x);
p[x].question.push_back(y);
que.push(Com(x,y));
}
int x_i = x;
while(x_i!=p[x_i].parent) x_i = p[x_i].parent; //找到树根节点
Tarjan(x_i);
while(!que.empty()){
cout<<ans[que.front()]<<endl; //按查询条目输出查询结果
que.pop();
}
}