LCA 即最近公共祖先,是指在有根树中,找出某两个结点u和v最近的公共祖先。
在一棵没有环的树上,每个节点肯定有其父亲节点和祖先节点,而最近公共祖先,就是两个节点在这棵树上深度最大的公共的祖先节点。
换句话说,就是两个点在这棵树上距离最近的公共祖先节点。
所以LCA主要是用来处理当两个点仅有唯一一条确定的最短路径时的路径。
有人可能会问:那他本身或者其父亲节点是否可以作为祖先节点呢?
答案是肯定的,很简单,按照人的亲戚观念来说,你的父亲也是你的祖先,而LCA还可以将自己视为祖先节点。
常用的求LCA的算法有:Tarjan/DFS+ST/倍增
后两个算法都是在线算法,也很相似,时间复杂度在O(logn)~O(nlogn)之间,我个人认为较难理解。
有的题目是可以用线段树来做的,但是其代码量很大,时间复杂度也偏高,在O(n)~O(nlogn)之间,优点在于也是简单粗暴。
即:结点 i 的第 2 j 个父亲是结点 i ii 的第 2j-1个父亲的第 2j-1 个父亲。
算法对于每一个询问 (u,v),首先将 u 和 v 调整到同一个深度,然后同时向上跳,第一个一样的就是它们的 LCA 。
oid dfs(int u) {
for(int i=head[u]; i!=-1; i=edge[i].next) {
int to=edge[i].to;
if(to==p[u][0])continue;
d[to]=d[u]+1;
dist[to]=dist[u]+edge[i].w;
p[to][0]=u; //p[i][0]存i的父节点
dfs(to);
}
}
void init()//i的2^j祖先就是i的(2^(j-1))祖先的2^(j-1)祖先
{
for(int j=1; (1<<j)<=n; j++)
for(int i=1; i<=n; i++)
p[i][j]=p[p[i][j-1]][j-1];
}
int lca(int a,int b) {
if(d[a]>d[b])swap(a,b); //b在下面
int f=d[b]-d[a]; //f是高度差
for(int i=0; (1<<i)<=f; i++)
//(1<<i)&f找到f化为2进制后1的位置,移动到相应的位置
if((1<<i)&f)b=p[b][i]; //比如f=5,二进制就是101,所以首先移动2^0祖先,然后再移动2^2祖先
if(a!=b) {
for(int i=(int)log2(N); i>=0; i--)
if(p[a][i]!=p[b][i]) //从最大祖先开始,判断a,b祖先,是否相同
a=p[a][i], b=p[b][i]; //如不相同,a b同时向上移动2^j
a=p[a][0]; //这时a的father就是LCA
}
return a;
}
该算法的思想为,对于任意一个结点 r ,处于 r r的不同子树上的两个结点 u ,v ,一定有 LCA(u,v)=r 。这个结论非常显然。首先, r 是 u ,v 的共同祖先;其次,任何深度大于 r 的结点都只会至多存在于 r 的一棵子树中,不可能是既是 u 的祖先又是 v 的祖先,因而 r 是 u , v 的最近公共祖先。
因此,我们可以暂时忽略询问中 u 和 v 的具体位置,而只是关心它们是否在某结点的不同子树中。我们使用并查集存储不同的子树,对于不在同一个并查集中的两个结点,它们的 LCA 即为这两个并查集的根的 LCA 。需注意,并查集是动态变化的,因为子树的划分有多种,子树也有大小之分。我们应该从下往上、由小到大地将子树一棵棵合并,并在同时更新答案。
从具体的实现方式来说,算法按照 dfs 序访问和合并结点,当访问到叶子结点 u 时,对于询问 LCA ( u , v ) ,如果 vv 已经被访问过,则用 v 所在并查集的根的祖先更新 LCA ( u , v ) ,然后一边回溯一边合并一边更新。先不着急给出代码,我们使用一开始给出的那张图作为例子,假设我们要询问 (1,2) , (2,5) ,(7,8) , (6,14) , (10,15) 。算法首先遍历 1 → 2 → 5 ,一路上设置 root [ u ] = u 。当发现到叶子了,看询问,发现有一个 (5,2) ,则更新答案 LCA ( 5 , 2 ) = root ( 2 ) = 2 然后回溯,合并 5 和 2 ,更新答案 LCA ( 2 , 5 ) = root ( 5 ) = 2 和 LCA ( 2 , 1 ) = root ( 1 ) = 1
接着是下一棵子树 3 → 6 → 10 ,同样的把 root 一路设过去,到了 10 。看询问,有个 ( 10 , 15 ) ,但 15 没被访问过,直接跳过。回溯,合并 10 和 6,发现有个询问(6,14) ,但 14 也没被访问过,同样跳过。
然后是子树 11 → 15 , root 设过去,发现询问有 (15,10) ,更新答案 LCA ( 15 , 10 ) = root ( 10 ) = 6 。回溯,合并 15 和 11 。然后, 6 的子树都访问完了,合并 6 , 11 ,不需要更新答案,因为 14 仍然没有访问到。往上, 再合并 6 , 3 ,也没有更新操作。
接着就是 7 → 12 ,有个 (7,8) 但还没访问,合并。13 ,合并。到了 14 ,终于更新了,答案为 LCA ( 14 , 6 ) = root ( 6 ) = 3 。回溯,合并合并再合并,目前为止, 3的子树和 2 的子树全和 1在同一个并查集里了。
最后是子树 4 → 8 和 9 ,需要更新的就是 LCA ( 8 , 7 ) = root ( 7 ) = 1 。
Tarjan(u)//marge和find为并查集合并函数和查找函数
{
for each(u,v) //访问所有u子节点v
{
Tarjan(v); //继续往下遍历
marge(u,v); //合并v到u上
标记v被访问过;
}
for each(u,e) //访问所有和u有询问关系的e
{
如果e被访问过;
u,e的最近公共祖先为find(e);
}
}
模拟一遍
假设我们有一组数据 9个节点 8条边 联通情况如下:
1--2,1--3,2--4,2--5,3--6,5--7,5--8,7--9 即下图所示的树
设我们要查找最近公共祖先的点为9--8,4--6,7--5,5--3;
设f[]数组为并查集的父亲节点数组,初始化f[i]=i,vis[]数组为是否访问过的数组,初始为0;
下面开始模拟过程:
取1为根节点,往下搜索发现有两个儿子2和3;
先搜2,发现2有两个儿子4和5,先搜索4,发现4没有子节点,则寻找与其有关系的点;
发现6与4有关系,但是vis[6]=0,即6还没被搜过,所以不操作;
发现没有和4有询问关系的点了,返回此前一次搜索,更新vis[4]=1;
表示4已经被搜完,更新f[4]=2,继续搜5,发现5有两个儿子7和8;
先搜7,发现7有一个子节点9,搜索9,发现没有子节点,寻找与其有关系的点;
发现8和9有关系,但是vis[8]=0,即8没被搜到过,所以不操作;
发现没有和9有询问关系的点了,返回此前一次搜索,更新vis[9]=1;
表示9已经被搜完,更新f[9]=7,发现7没有没被搜过的子节点了,寻找与其有关系的点;
发现5和7有关系,但是vis[5]=0,所以不操作;
发现没有和7有关系的点了,返回此前一次搜索,更新vis[7]=1;
表示7已经被搜完,更新f[7]=5,继续搜8,发现8没有子节点,则寻找与其有关系的点;
发现9与8有关系,此时vis[9]=1,则他们的最近公共祖先为find(9)=5;
(find(9)的顺序为f[9]=7-->f[7]=5-->f[5]=5 return 5;)
发现没有与8有关系的点了,返回此前一次搜索,更新vis[8]=1;
表示8已经被搜完,更新f[8]=5,发现5没有没搜过的子节点了,寻找与其有关系的点;
发现7和5有关系,此时vis[7]=1,所以他们的最近公共祖先为find(7)=5;
(find(7)的顺序为f[7]=5-->f[5]=5 return 5;)
又发现5和3有关系,但是vis[3]=0,所以不操作,此时5的子节点全部搜完了;
返回此前一次搜索,更新vis[5]=1,表示5已经被搜完,更新f[5]=2;
发现2没有未被搜完的子节点,寻找与其有关系的点;
又发现没有和2有关系的点,则此前一次搜索,更新vis[2]=1;
表示2已经被搜完,更新f[2]=1,继续搜3,发现3有一个子节点6;
搜索6,发现6没有子节点,则寻找与6有关系的点,发现4和6有关系;
此时vis[4]=1,所以它们的最近公共祖先为find(4)=1;
(find(4)的顺序为f[4]=2-->f[2]=2-->f[1]=1 return 1;)
发现没有与6有关系的点了,返回此前一次搜索,更新vis[6]=1,表示6已经被搜完了;
更新f[6]=3,发现3没有没被搜过的子节点了,则寻找与3有关系的点;
发现5和3有关系,此时vis[5]=1,则它们的最近公共祖先为find(5)=1;
(find(5)的顺序为f[5]=2-->f[2]=1-->f[1]=1 return 1;)
发现没有和3有关系的点了,返回此前一次搜索,更新vis[3]=1;
更新f[3]=1,发现1没有被搜过的子节点也没有有关系的点,此时可以退出整个dfs了。