LCA
作为一个数据结构学傻,终于要开始入这个坑了……
之前一直不写有关数据结构的东西,是因为不会画那种很高端的图,所以一直不写,现在看来似乎没有办法避免了……
LCA,全称Least Common Ancestor,即最近公共祖先,顾名思义,LCA(u,v)就是u和v的所有共同祖先中深度最小的,很多算法对于树上一根链的处理通常都需要围绕LCA展开,因此快速求得树上两个节点的LCA是树上算法的基础知识。
先给出一个求LCA的朴素算法,事实上部分求LCA的真正算法就是以其为基础改进而来:
预先遍历整棵树,求得除根节点之外的所有节点的父亲,在询问LCA(u,v)时,先将深度较大的点(不妨设为u)每次向上跳(形象比喻),直至与v处于同一深度,此时判断u,v是否重合,如果重合u(或者v)就是答案,否则将u和v一起向上跳,直至u和v重合。
朴素算法就不贴代码了......
分析算法后便会发现,这个算法的时间复杂度取决于树高,如果树的形态退化成链的话,复杂度将退化为\(O(n)\),无法接受。
注意:对LCA原理的讲解中,涉及的所有代码均来自Luogu P3379 【模板】最近公共祖先(LCA)
倍增LCA
倍增LCA最接近朴素算法。它引入了一个“k级父亲”的概念,下面是递归定义:
(1)0级父亲是一个节点的直接父亲。
(2)k级父亲是一个节点的k-1级父亲的k-1级父亲。
换句话说,一个节点的k级父亲是与该节点深度差为2k 的祖先。这样定义之后,我们往上跳可以从每次跳1级变成每次跳\(2^k\)级,如果把每次跳的步数由1拓展到\(2^k\),就可以加速这个过程,具体操作如下:
先将u确定为深度较大的点,然后k从大到小逐个尝试上跳到u的k级父亲,k的上界为\(log_{2}^{dep_{u}}\),下界显然为0,如果上跳之后u的深度小于等于v的深度,就执行上跳操作,然后尝试距离更小的上跳操作。因为这种跳法相当于将u与v的深度差转换为了二进制数,因此能够保证在循环结束时u的深度一定与v相同。这样就完成了第一步操作。
接下来,将u和v一起上跳,也是k从大到小逐个尝试上跳到u,v分别的k级父亲,如果两者的k级父亲存在且不相同,就执行上跳操作,跟第一步操作原理相同,这样做能保证在结束时u和v都是LCA(u,v)的直接儿子,这样u(或者v)的0级父亲就是LCA(u,v)了。
这样做预处理复杂度为\(O(n)\),单次查询的复杂度是\(O(log_n)\)的,显然它是一个在线算法,如果q次查询的话总复杂度为\(O(q\cdot log_{n}+n)\)。
代码
#include<bits/stdc++.h>
using namespace std;
const int maxn=5e5+10,maxm=1e6+10,maxj=20;
int heade[maxn],ev[maxm],nexte[maxm];
int fa[maxn][maxj],dep[maxn];
int n,m,tot=0,root;
void add_edge(int u,int v){ev[++tot]=v;nexte[tot]=heade[u];heade[u]=tot;}
void dfs(int ui)//确定0级父亲和各节点深度
{
int i,vi;
for(i=heade[ui];~i;i=nexte[i])
{
vi=ev[i];if(vi==fa[ui][0]){continue;}
fa[vi][0]=ui;dep[vi]=dep[ui]+1;
dfs(vi);
}
}
void init()//倍增预处理
{
int i,j;
for(j=1;(1<<j)<=n;j++){for(i=1;i<=n;i++){if(fa[i][j-1]!=-1){fa[i][j]=fa[fa[i][j-1]][j-1];}}}
}
int lca(int u,int v)
{
int i,j,t;
if(dep[u]<dep[v]){swap(u,v);}
t=(int)(log(dep[u])/log(2));
for(j=t;j>=0;j--){if(dep[u]-(1<<j)>=dep[v]){u=fa[u][j];}}//先跳至同一深度
if(u==v){return u;}
for(j=t;j>=0;j--){if(fa[u][j]!=-1&&fa[u][j]!=fa[v][j]){u=fa[u][j];v=fa[v][j];}}//再一起向上跳
return fa[u][0];
}
int main()
{
int i,j,u,v;
cin>>n>>m>>root;
memset(heade,-1,sizeof(heade));
memset(fa,-1,sizeof(fa));
memset(nexte,-1,sizeof(nexte));
for(i=1;i<n;i++){scanf("%d%d",&u,&v);add_edge(u,v);add_edge(v,u);}
dfs(root);init();
for(i=1;i<=m;i++)
{
scanf("%d%d",&u,&v);
printf("%d\n",lca(u,v));
}
return 0;
}
树剖LCA
树剖LCA跟朴素算法在细节上有一些差别,但总体上还是加速向上跳的过程,只不过加速的方法变成了剖分。(感觉要把树剖事先在这里写掉了……)
树链剖分,顾名思义,就是将一棵树剖分成若干条链,链上的点互为祖先和子孙的关系,这样上跳从点到点可以变为从一条链上跳到另一条链上,从而实现加速。容易发现,这样加速的效率取决于怎样剖分,链的条数应尽可能少,下面给出一种剖分方法,又称轻重树链剖分。
介绍轻重树链剖分前,先引入“轻重儿子”的概念。所谓u的重儿子,是指在u的所有儿子中子树大小(v的子树大小指以v为根节点的子树的总节点数)最大的一个儿子(如果有多个儿子达到最大值则在其中随机选取一个),u的轻儿子则是除u的重儿子外的其他所有的儿子。然后我们把所有相邻的重儿子连起来,形成了若干条链,我们称之为重链。如果稍稍扩展一下重链的定义,我们可以将单独的一个轻儿子看成一条单独的链,这样就完成了对这棵树的剖分。
剖分的具体操作是对一个树进行两次DFS,第一次需要求出每个节点的深度,父亲,子树大小和重儿子。子树大小可以在回溯时累加得到,重儿子同样可以在回溯时进行判断。
代码:
void fdfs(int ui)//第一次DFS
{
int i,vi;
size[ui]=1;son[ui]=0;//初始化size和son
for(i=heade[ui];i!=-1;i=nexte[i])
{
vi=ev[i];
if(fa[ui]==vi){continue;}
dep[vi]=dep[ui]+1;fa[vi]=ui;
fdfs(vi);size[ui]+=size[vi];//回溯统计size
if(size[son[ui]]<size[vi]){son[ui]=vi;}//维护son
}
}
第二次则需要求出重链,此次DFS函数的状态中需要记录访问的节点和所在链的顶端节点,并在函数中通过一个数组来保存所有节点所在链的顶端节点,从而实现链与链之间的上跳。根据轻重剖分的定义,如果节点u存在重儿子\(son_u\),则对\(son_u\)进行DFS,由于u和\(son_u\)在一条重链上,因此\(top_{u}=top_{son_{u}}\)。然后对u的轻儿子v进行DFS,此时\(top_{v}=v\)。这样就完成了树链剖分。我们可以证明这样进行剖分最多只会剖出\(log_n\)条重链,从而能保证剖分的效率。
代码:
void sdfs(int ui,int pre)//第二次DFS
{
int i,vi;
top[ui]=pre;//该节点所在重链的顶端节点
if(son[ui]){sdfs(son[ui],pre);}//继续向下拉重链
for(i=heade[ui];i!=-1;i=nexte[i])
{
vi=ev[i];
if(vi==fa[ui]||vi==son[ui]){continue;}
sdfs(vi,vi);//以轻儿子为顶端节点向下拉重链
}
}
在求LCA(u,v)时,是每次从一条链跳到上面的一条链,为了确保不会跳过LCA,需要设置一个合理的方法。若u和v在同一条重链中,则LCA(u,v)显然为u和v中深度较小的点。若u和v不在同一重链中,则先将u确定为深度较大的点,然后将u赋为u所在重链顶端的节点的父亲,然后继续判断u和v是否在同一重链中,直至两点在同一重链中。
代码:
int lca(int u,int v)
{
int x=top[u],y=top[v];
while(x!=y)
{
if(dep[x]<dep[y]){swap(x,y);swap(u,v);}
u=fa[x];x=top[u];//链与链之间的转换
}
return dep[u]<dep[v]?u:v;
}
完整代码:
#include<bits/stdc++.h>
using namespace std;
const int maxn=5e5+10,maxm=1e6+10;
int heade[maxn],ev[maxm],nexte[maxm];
int size[maxn],son[maxn],dep[maxn],fa[maxn];
int top[maxn];
int n,m,s,tot=0;
void add_edge(int u,int v){ev[++tot]=v;nexte[tot]=heade[u];heade[u]=tot;}
void fdfs(int ui)//第一次DFS
{
int i,vi;
size[ui]=1;son[ui]=0;//初始化size和son
for(i=heade[ui];i!=-1;i=nexte[i])
{
vi=ev[i];
if(fa[ui]==vi){continue;}
dep[vi]=dep[ui]+1;fa[vi]=ui;
fdfs(vi);size[ui]+=size[vi];//回溯统计size
if(size[son[ui]]<size[vi]){son[ui]=vi;}//维护son
}
}
void sdfs(int ui,int pre)//第二次DFS
{
int i,vi;
top[ui]=pre;//该节点所在重链的顶端节点
if(son[ui]){sdfs(son[ui],pre);}//继续向下拉重链
for(i=heade[ui];i!=-1;i=nexte[i])
{
vi=ev[i];
if(vi==fa[ui]||vi==son[ui]){continue;}
sdfs(vi,vi);//以轻儿子为顶端节点向下拉重链
}
}
int lca(int u,int v)
{
int x=top[u],y=top[v];
while(x!=y)
{
if(dep[x]<dep[y]){swap(x,y);swap(u,v);}
u=fa[x];x=top[u];//链与链之间的转换
}
return dep[u]<dep[v]?u:v;
}
int main()
{
int i,j,u,v;
memset(heade,-1,sizeof(heade));
cin>>n>>m>>s;
for(i=1;i<n;i++){scanf("%d%d",&u,&v);add_edge(u,v);add_edge(v,u);}
fdfs(s);sdfs(s,s);
for(i=1;i<=m;i++){scanf("%d%d",&u,&v);printf("%d\n",lca(u,v));}
return 0;
}
以上过程预处理复杂度为\(O(n)\),单次查询复杂度同样为\(O(log_n)\),为在线算法,q次查询复杂度为\(O(q\cdot log_n+n)\)。
Tarjan算法
以Tarjan命名的算法有很多,如求强连通分量、双连通分量的算法等,这里介绍的是通过DFS和并查集求LCA的离线算法。
在线算法和离线算法的区别,就是在线算法可以即时处理每一个询问,询问之间互相独立,不需要依赖询问与询问之间的关系。通俗地说就是“来一个打一个”,而离线算法则会利用询问之间的关系进行处理,所以它需要事先知道全部询问,最后进行统一处理。通俗地说就是“等人来齐了再放大招”。
由于本人蒟蒻,Tarjan算法我至今无法清楚地讲出算法的原理,只能复述一遍算法过程(全靠背代码……):
在访问到节点u时,新增一个元素只有u的集合,并将u设为已访问,然后遍历u的未访问过的邻接点vi,对其进行访问,在回溯时将vj所在集合与u所在集合进行合并。访问完所有邻接点后,遍历所有询问中与u有关的询问,判断询问中另一个点vj是否已被访问,若已被访问,则vj所在集合的代表元就是两者的LCA(并不知道为什么……)。
由于只要对整棵树和所有询问遍历一遍就能解决问题,Tarjan算法在解决n个节点,q个询问的LCA问题时复杂度为\(O(n+q)\),相比在线算法实现了复杂度级别的优化。
代码:
#include<bits/stdc++.h>
using namespace std;
const int maxn=5e5+10,maxm=1e6+10;
int heade[maxn],ev[maxm],nexte[maxm];//原图
int head[maxn],ed[maxm],ep[maxm],nextt[maxm];//询问图
int father[maxn],rec[maxn],isvis[maxn];//rec用来记录LCA输出顺序
int n,m,s,tot=0,num=0;
void add_edge(int u,int v){ev[++tot]=v;nexte[tot]=heade[u];heade[u]=tot;}//原图添边
void add(int u,int v,int p){ed[++num]=v;ep[num]=p;nextt[num]=head[u];head[u]=num;}//询问图添边
int find(int x){if(father[x]==x){return x;}return father[x]=find(father[x]);}
void dfs(int ui)
{
int i,vi,pi;
father[ui]=ui;isvis[ui]=1;//新增集合
for(i=heade[ui];i!=-1;i=nexte[i])//遍历原图中邻接点
{
vi=ev[i];if(isvis[vi]){continue;}
dfs(vi);father[vi]=find(ui);
}
for(i=head[ui];i!=-1;i=nextt[i]){vi=ed[i];pi=ep[i];if(!isvis[vi]){continue;}rec[pi]=find(vi);}//遍历询问图中邻接点
}
int main()
{
int i,j,u,v;
cin>>n>>m>>s;memset(heade,-1,sizeof(heade));memset(head,-1,sizeof(head));
for(i=1;i<n;i++){scanf("%d%d",&u,&v);add_edge(u,v);add_edge(v,u);}
for(i=1;i<=m;i++){scanf("%d%d",&u,&v);add(u,v,i);add(v,u,i);}//以链表的形式存下所有与u相关的询问(形式上相当于另一张图)
dfs(s);
for(i=1;i<=m;i++){printf("%d\n",rec[i]);}
return 0;
}
欧拉序+RMQ
这个算法内存占用大思维又复杂,一般会这个算法的都会倍增(然而教练NOIP2016前就给我讲了这个算法……),然而,这个算法由于将LCA转化为RMQ,在某些情况下有着无可替代的优势(Tarjan你给我靠边站……)
欧拉序是基于DFS时间戳的,具体操作是当开始访问节点u时,时间戳++,对u的邻接点v访问结束回溯时时间戳再次++,将时间戳为i时访问到的节点设为ai,就形成了一个序列,称为欧拉序。
记欧拉序中第i个节点在树中的编号为ver[i],欧拉序中第i个节点的深度为dep[i],树中编号为i的节点在欧拉序中第一次出现的位置为first[i],则可以证明(其实是我自己不会证明……)LCA(u,v)为first[u]到first[v]之间dep最小的点,这样LCA问题就转化为RMQ问题,RMQ问题可以用ST表或者线段树解决。
若采用ST表,则预处理复杂度\(O(n\cdot log_n)\),单次查询复杂度则为诱人的\(O(1)\)(咳咳),总复杂度为\(O(n\cdot log_n+q)\),在q极大的情况下优于其他任何在线算法。
若采用线段树,则预处理复杂度为\(O(n)\),单次查询复杂度为\(O(log_n)\),总复杂度为\(O(n+q\codt log_n)\)。与其他在线算法相同。
代码(ST表):
#include<bits/stdc++.h>
using namespace std;
const int maxn=500000+10;
int heade[maxn],ev[2*maxn],nexte[2*maxn],isvis[maxn];
int ver[2*maxn],a[2*maxn],first[maxn],rmq[2*maxn][21],pos[2*maxn][21];
int n,m,s,tot=0,num=1;
void add(int u,int v)
{
tot++;ev[tot]=v;
nexte[tot]=heade[u];
heade[u]=tot;
}
void dfs(int u,int cur)
{
int i,j,ui;
ver[num]=u;a[num]=cur;isvis[u]=1;
if(!first[u]){first[u]=num;}
num++;
for(i=heade[u];i!=-1;i=nexte[i])
{
ui=ev[i];
if(!isvis[ui])
{
dfs(ui,cur+1);
ver[num]=u;a[num]=cur;
num++;
}
}
}
void rmq_list()
{
int i,j,m;
m=(int)(log(num)/log(2));
for(i=1;i<=num;i++){rmq[i][0]=a[i];pos[i][0]=i;}
for(j=1;j<=m;j++)
{
for(i=1;i+(1<<j)-1<=num;i++)
{
if(rmq[i][j-1]<rmq[i+(1<<(j-1))][j-1])
{
rmq[i][j]=rmq[i][j-1];
pos[i][j]=pos[i][j-1];
}
else
{
rmq[i][j]=rmq[i+(1<<(j-1))][j-1];
pos[i][j]=pos[i+(1<<(j-1))][j-1];
}
}
}
}
void rmq_query(int x,int y)
{
int i,j,m,p;
m=(int)(log(y-x+1)/log(2));
if(rmq[x][m]<rmq[y-(1<<m)+1][m])
{
p=pos[x][m];
printf("%d\n",ver[p]);
}
else
{
p=pos[y-(1<<m)+1][m];
printf("%d\n",ver[p]);
}
}
void lca(int u,int v)
{
int x,y;
x=first[u];y=first[v];
if(x>y){swap(x,y);}
rmq_query(x,y);
}
int main()
{
int i,j,u,v;
cin>>n>>m>>s;
memset(heade,-1,sizeof(heade));
memset(nexte,-1,sizeof(nexte));
memset(first,0,sizeof(first));
memset(a,127,sizeof(a));
for(i=1;i<n;i++)
{
scanf("%d%d",&u,&v);
add(u,v);add(v,u);
}
dfs(s,1);num--;
rmq_list();
//cout<<"first:";for(i=1;i<=n;i++){cout<<first[i]<<" ";}cout<<endl;
//cout<<"ver:";for(i=1;i<=num;i++){cout<<ver[i]<<" ";}cout<<endl;
//cout<<"a:";for(i=1;i<=num;i++){cout<<a[i]<<" ";}cout<<endl;
for(i=1;i<=m;i++)
{
scanf("%d%d",&u,&v);
lca(u,v);
}
return 0;
}
LCA的应用:
一般树上关于链的问题都会涉及LCA,但上面给出的几种LCA的算法并不能解决所有问题。上述算法仅能解决一些查询问题,如查询一条链上所有点点权之和、点权最值等等。部分查询如链上点权众数,点权大于等于一给定值的点的个数等问题仍然无法解决。此外,涉及链上动态修改的问题同样无法解决,动态修改的问题需要用完整的树链剖分来解决。
最小瓶颈路问题:
详见博文最小瓶颈路。