最近公共祖先LCA

最近公共祖先LCA

定义

最近公共祖先简称 LCA(Lowest Common Ancestor)。两个节点的最近公共祖先,就是这两个点的公共祖先里面,离根最远的那个。

例题

这里先举例具体题目,下面根据不同方法给出不用题解代码。

洛谷 【模板】最近公共祖先(LCA)
题目描述
如题,给定一棵有根多叉树,请求出指定两个点直接最近的公共祖先。

输入格式
第一行包含三个正整数 N,M,S 分别表示树的结点个数、询问的个数和树根结点的序号。

接下来 N-1 行每行包含两个正整数 x, y表示 x 结点和 y 结点之间有一条直接连接的边(数据保证可以构成树)。

接下来 M 行每行包含两个正整数 a, b 表示询问 a 结点和 b 结点的最近公共祖先。

输出格式
输出包含 M 行,每行包含一个正整数,依次为每一个询问的结果。

说明/提示
对于 30% 的数据,N ≤10,M ≤10 。

对于 70% 的数据,N≤10000,M≤10000。

对于 100% 的数据,N≤500000,M≤500000。

朴素算法 (暴力求解)

在树中,每个节点都有其的深度,深度是相对于根节点而言的,而要找到x和y的LCA深度是唯一的,当x与y的深度相同时,每一次上跳父节点可保证深度是相同的,这时候只需要保证父节点重合了则找到了LCA。

所以在存储的时候,需要存储每个节点的深度,同时要存储每个节点的父节点。

需要预处理整棵树,时间复杂度O(n),单次查询复杂度O(n),算法简单、耗时。

#include<bits/stdc++.h>
using namespace std;

const int MAX_N = 500000+7, MAX_M = 500000+7;

int n,m,s;
int d[MAX_N]; //保存节点深度
int f[MAX_N]; //保存节点的父节点

int head[MAX_N], cnt=-1;
struct Edge{
    int to , next;
}e[MAX_N*2];

void add(int u,int v){
    e[++cnt].to = v;
    e[cnt].next = head[u];
    head[u] = cnt;
}

void dfs(int u,int fa) // u为当前节点,fa为父节点
{
    f[u] = fa;
    d[u] = d[fa]+1;
    for(int i=head[u];i!=-1;i=e[i].next){
        int v = e[i].to;
        if(v!=fa)
            dfs(v,u);
    }
}

int lca(int x,int y)
{
    if(d[x]>d[y])  //令y为更深节点
    	swap(x,y);
    	
    while(d[x]<d[y])
        y = f[y];
    while(x!=y)
        x = f[x],y = f[y];
        
    if(x==0)
        x=s;
    return x;
}

int main()
{
    memset(head,-1,sizeof(head));
    int a,b;
    scanf("%d%d%d",&n,&m,&s);
    for(int i=1;i<n;i++)
    {
        scanf("%d%d",&a,&b);
        add(a,b);
        add(b,a);                      //无向图,要加两次
    }
    dfs(s,0);

    for(int i=1;i<=m;i++)
    {
        scanf("%d%d",&a,&b);
        printf("%d\n",lca(a,b));
    }

    return 0;
} 

倍增算法(在线)

倍增算法是朴素算法的改进算法,通过引入二维的 f 数组,来减少节点上跳次数。倍增思路和ST表思路差不多。
使用 f[x][i] 来表示 点x 的第 2i个祖先,而 f[x][i] 数组可以通过dfs预处理出来。f[u][i] = f[f[u][i-1]][i-1] , u 的第2i个祖先,就是 u 的2i-1个祖先的2i-1个祖先。 这里你细品。

 for(int i=1;(1<<i)<=d[u];i++)
        f[u][i] = f[f[u][i-1]][i-1];    //预处理f数组 

进行初始化后,需要查询lca,思想与朴素算法相同,不过其上跳不再是线性的,而是倍增的。
预处理复杂度0(nlogn),单次查询O(logn) , 总复杂度 O(nlogn+mlogn)

#include<bits/stdc++.h>
using namespace std;

const int MAX_N = 500000+7, MAX_M = 500000+7;

int n,m,s;
int d[MAX_N]; //保存节点深度
int f[MAX_N][21]; //保存节点的不同倍数的父节点

int head[MAX_N], cnt=-1;
struct Edge{
    int to , next;
}e[MAX_N*2];

void add(int u,int v){
    e[++cnt].to = v;
    e[cnt].next = head[u];
    head[u] = cnt;
}

void dfs(int u,int fa) // u为当前节点,fa为父节点
{
    f[u][0] = fa;
    d[u] = d[fa]+1;

    for(int i=1;(1<<i)<=d[u];i++)
        f[u][i] = f[f[u][i-1]][i-1];    //预处理f数组

    for(int i=head[u];i!=-1;i=e[i].next){
        int v = e[i].to;
        if(v!=fa)
            dfs(v,u);
    }
}

int lca(int x,int y)
{
    if(d[x]>d[y])  //令y为更深节点
    	swap(x,y);
    
    for(int i=20;i>=0;i--){   //这里使得x与y处于同一深度
        if(d[x]<=d[y]-(1<<i))
            y=f[y][i];
    }

    //特判
    if(x==y)
        return x;
    
    for(int i=20;i>=0;i--)
    {
        if(f[x][i]==f[y][i])
            continue;              //这里是从最远的公共祖先开始找,所以需要continue
        else
            x=f[x][i],y=f[y][i];   //上跳
    }

    return f[x][0];
}

int main()
{
    memset(head,-1,sizeof(head));
    int a,b;
    scanf("%d%d%d",&n,&m,&s);
    for(int i=1;i<n;i++)
    {
        scanf("%d%d",&a,&b);
        add(a,b);
        add(b,a);                      //无向图,要加两次
    }
    dfs(s,0);

    for(int i=1;i<=m;i++)
    {
        scanf("%d%d",&a,&b);
        printf("%d\n",lca(a,b));
    }

    return 0;
} 

ST+RMQ算法(在线)

使用这种算法前需要先去知道ST算法,用于求区间最值问题。

那么LCA又该如何与RMQ扯上关系呢?

因为LCA是属于树上问题,而对树的遍历可以使用一个桥梁,dfs序,让树的遍历成为了一个区间序列。
最近公共祖先LCA
给出这个dfs序(其实是欧拉序):
1 2 4 2 5 7 5 8 5 2 1 3 1
再加上深度的话,可以制出下表:
最近公共祖先LCA
得到这张表后,你就能在表中发现有意思的东西,当你想求lca(4,7)时,只需要找到4号节点到7号节点的随便一个区间,再找出区间中深度最小的节点,该点即为lca(4,7),序号(1~13)为整个区间,找的区间可以是(3 ~ 6),其中最小深度为1,就是2号节点。wow ~ 是不是感觉特别神奇。

那这个方法的科学性呢?

其实在给出欧拉序时,连续的区间其实是可以表示一颗子树的,这在上篇博文中有介绍。所以通过欧拉序得出这样的序列后,就不必再去通过原树来进行查找,而转换为查找这张表。因为一段连续区间表示了一颗子树,那么在找两个节点的lca时,其实就是在找包含这两个点的子树的根节点,结合深度来判断,那么一颗子树中,深度最小的节点那不就是该子树的根节点吗。
如此一个树上问题就转换成了区间问题,这就是LCA与RMQ扯上的关系。

那后面的操作就显得很简单了。
上面这张表使用三个数组: fir[] 节点在序列中首次出现的序号、 d[] 节点深度表 、 dfn[] dfs序 , 需要注意的是欧拉序会重复加入节点,所以数组大小需要扩大一倍。

预处理复杂度0(nlogn),ST表单次查询O(1) ,总时间复杂度 O(nlogn+m),不过其在空间上的开销要显得比倍增大一些。

#include<bits/stdc++.h>
using namespace std;

const int MAX_N = 500000+7, MAX_M = 500000+7;

int n,m,s;

//dfs序数组
int tot = 0;
int fir[MAX_N];
int dfn[MAX_N*2];
int d[MAX_N*2];

//ST表数组
int st[20][MAX_N*2];   //st表
int ist[20][MAX_N*2];  //记录最值下标
int Log[MAX_N*2];

void getLog()
{
	Log[1] = 0;
	for(int i=2;i<=MAX_N*2;i++)
		Log[i] = Log[i/2]+1;
}

void init()
{
    for(int i=1;i<=tot;i++){
        st[0][i]=d[i];
        ist[0][i] = dfn[i];
    }
    for(int j=1;(1<<j)<=tot;j++){
		for(int i=1;i+(1<<(j-1))<=tot;i++){
            if(st[j-1][i]<st[j-1][i+(1<<(j-1))]){
                st[j][i] = st[j-1][i];
                ist[j][i] = ist[j-1][i];
            }else{
                st[j][i] = st[j-1][i+(1<<(j-1))];
                ist[j][i] = ist[j-1][i+(1<<(j-1))];
            }
        }
    }

    getLog();
}

int Query(int l,int r)
{
	int k=Log[r-l+1];
	int ans;

    if(st[k][l]<st[k][r-(1<<k)+1]) 
        ans = ist[k][l];
    else 
        ans = ist[k][r-(1<<k)+1];

    return ans;
}

int head[MAX_N], cnt=-1;
struct Edge{
    int to , next;
}e[MAX_N*2];

void add(int u,int v){
    e[++cnt].to = v;
    e[cnt].next = head[u];
    head[u] = cnt;
}

void dfs(int u,int dep) // u为当前节点,dep为深度
{
    fir[u] = ++tot;
    dfn[tot] = u;
    d[tot] = dep;

    for(int i=head[u];i!=-1;i=e[i].next){
        int v = e[i].to;
        if(!fir[v]){
            dfs(v,dep+1);
            dfn[++tot]=u;
            d[tot]=dep;
        }
    }
}

int lca(int x,int y)
{
    int l = fir[x] , r = fir[y];
    if(l>r) swap(l,r);
    return Query(l,r);
}

int main()
{
    memset(head,-1,sizeof(head));
    int a,b;
    scanf("%d%d%d",&n,&m,&s);
    for(int i=1;i<n;i++)
    {
        scanf("%d%d",&a,&b);
        add(a,b);
        add(b,a);                      //无向图,要加两次
    }
    dfs(s,0);
    init();

    for(int i=1;i<=m;i++)
    {
        scanf("%d%d",&a,&b);
        printf("%d\n",lca(a,b));
    }

    return 0;
} 

Tarjan算法(离线)

上面介绍的两种算法都是在线算法,所以这里再讲解一种离线算法。
Tarjan算法相较于上面的算法要更稳定一些,时间复杂度也比较稳定居中,结合并查集使用也很容易理解。

基本思路

  1. 任选一个点为根节点,从根节点开始。
  2. 遍历该点u所有子节点v,并标记这些子节点v已被访问过。
  3. 若是v还有子节点,返回2,否则下一步。
  4. 合并v到u上。
  5. 寻找与当前点u有询问关系的点v。
  6. 若是v已经被访问过了,则可以确认u和v的最近公共祖先为v被合并到的父亲节点a。

算法结构很清晰,这里给出伪代码:

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);
    }
} 

最近公共祖先LCA

原理
Tarjan算法也是使用dfs对树进行遍历,可以发现在遍历过程中,每颗子树节点的遍历顺序都是连续的(从左子树开始遍历的话,要先遍历完5,才能遍历完2,最后才能遍历完3),找lca其实就是找子树的根节点对吧,所以由于其遍历过程中其实隐藏包含了子树信息,那只需要利用这种遍历信息就可以找到节点的lca。也正是因为需要在遍历过程中找lca,所以要事先知道需要找哪些lca,所以其是离线的算法。
那么在dfs中的子树信息怎么使用呢,并查集。 每当遍历完一颗子树后,都使用并查集记录该子树的根节点,然后查看是否有询问该子树的lca,若有,则输出该子树根节点即可。而并查集恰好能简单的实现这一逻辑,并查集的祖先则改成原树深度较低的节点即可(遍历就是深度越深,实际上不需要判断)。

算法执行过程图解可参考:Tarjan(离线)算法的基本实现

由于该算法对于每个点和每个查询仅遍历一次,所以其时间复杂度为O(n+m)

下面是给出题解代码:

#include<bits/stdc++.h>
using namespace std;

const int MAX_N = 500000+7, MAX_M = 500000+7;

int n,m,s;

int head[MAX_N], cnte=0;
struct Edge{
    int to , next;
}e[MAX_N*2];

void add_e(int u,int v)
{
    e[++cnte].to = v;
    e[cnte].next = head[u];
    head[u] = cnte;
}

int que[MAX_M*2],cntq=0;
struct questions
{
    int to,next,same,num;
    bool flag;
    questions(){flag=false;}
}q[MAX_M*2];//询问的储存,flag=false表示还没回答,num表示是第几个询问,same储存与这个询问相同的询问序号。

void add_q(int x,int y,int k)
{
    q[++cntq].to=y;
    q[cntq].same = cntq+1;
    q[cntq].next = que[x];
    q[cntq].num = k;
    que[x] = cntq;

    //查询正反都需加入
    q[++cntq].to=x;
    q[cntq].same = cntq-1;
    q[cntq].next = que[y];
    q[cntq].num = k;
    que[y] = cntq;
}

int father[MAX_N],ans[MAX_M];// father为并查集父节点,ans记录各查询答案
bool vis[MAX_N]; //vis记录访问情况

//并查集
int find(int x)
{
    if(father[x]!=x)
        father[x]=find(father[x]);
    return father[x];
}
void unionn(int x,int y)
{
    father[find(y)]=find(x);
}

void Tarjan(int u,int fa) // u为当前节点,fa为父节点
{

    for(int i=head[u];i!=0;i=e[i].next){
        int v = e[i].to;
        if(v!=fa&&!vis[v]){
            Tarjan(v,u);
            unionn(u,v);   // 注意这里位置不能交换, 原树上u为v的父节点
            vis[v]=true;   // 标记访问
        }
    }

    for(int i=que[u];i!=0;i=q[i].next){
        int v = q[i].to;
        if(!q[i].flag&&vis[v]){
            ans[q[i].num] = find(v);
            q[i].flag = true;           //完成询问则把镜像问题也打上标记
            q[q[i].same].flag = true;
        }
    }
}

int main()
{

    int a,b;
    scanf("%d%d%d",&n,&m,&s);
    for(int i=1;i<n;i++)
    {
        father[i] = i;
        scanf("%d%d",&a,&b);
        add_e(a,b);
        add_e(b,a);                      //无向图,要加两次
    }
    father[n] = n;

    for(int i=1;i<=m;i++)
    {
        scanf("%d%d",&a,&b);
        add_q(a,b,i);
    }

    Tarjan(s,0);

    for(int i=1;i<=m;i++)
        printf("%d\n",ans[i]);
    return 0;
} 

本文转自 https://blog.csdn.net/qq_43332980/article/details/107437070?utm_medium=distribute.pc_relevant.none-task-blog-BlogCommendFromMachineLearnPai2-8.control&depth_1-utm_source=distribute.pc_relevant.none-task-blog-BlogCommendFromMachineLearnPai2-8.control,如有侵权,请联系删除。

上一篇:BZOJ2286: [Sdoi2011]消耗战(虚树/树形DP)


下一篇:参天大树[题解]