【原创】最近公共祖先

【概念与定义】

给定一颗有根树,若节点z既是节点x的祖先,也是节点y的祖先,则称z是x,y的公共祖先。在x,y的所有公共祖先中,深度最大的那个叫最近公共祖先,记为LCA(x,y)。
 

【算法实现】

  • 暴力

如果我们要求x和y的LCA,那我们就设置两个个指针分别指向他们两个,把这两个指针一个一个节点地往上挪动,直到它们交汇于一点,这一点就是LCA。
这个算法面对大规模数据妥妥的没救,于是我们可以用倍增将其优化到O((nlogn)级别(n为节点数)。
 
  • 向上标记法

对于x和y,我们知道它们到树根的路径只有唯一的一条,那么我们不妨先标记要查询的某一节点到树根的路径上的所有点,再对另一节点执行向树根的搜索(具体方法跟标记第一个点一样),直到找到第一个之前已经标记的那个节点,这个节点就是我们要求的LCA了。具体实现方法,我们可以使用DFS遍历整棵树。
 
  • 树上倍增

树上乱搞算法的一种,用途十分广泛,也可以用来求解LCA问题,是暴力的优化。对于m个询问,复杂度可以达到O((n+m)logn)。
思路跟暴力完全一致,只是用倍增优化了一下,将一个一个节点挪换成 2^0、2^1、2^2···2^k 步的形式向上挪动调整,直到它们挪动到同一个节点。
 
具体算法实现有点麻烦,详细讲一下。
首先是初始化,我们可以预处理出节点之间的继承关系。
由于要使用到每个节点的深度我们可以开一个数组d[]来记录。
 
设f[x][k]为x节点的2^k辈节点,如果该节点不存在,那么f[x][k]=0,否则对于任意的k∈[1,logn],都有f[x][k]=f[f[x][k-1]][k-1]。这相当于做一个动态规划,即不断更新每个节点的父节点。
 
然后是LCA算法的步骤。
我们知道,如果不事先将x和y的指针挪动到同一深度,倍增就比较麻烦。于是我们先把它们用倍增优化挪到同一深度,然后再同步挪动,直到它们处于同一点。
 
于是我们分几个步骤求解:
  1. 若x的深度小于y的,交换他们的值。
  2. 将x上调到与y同一深度。若此时x=y,则直接返回LCA=x,已经找到。
  3. 将x和y同步上调,直到它们交汇前最后一步(因为直接查询f[]数组比在树中找x要方便许多)。把它们依次尝试挪动 2^logn···2^2、2^1 步,保持它们在同一深度。每走一步,若f[x][k]!=f[y][k],就令x=f[x][k],y=f[y][k],即向上调整。
  4. LCA就是执行完第三步的的f[x][0]。
 
代码如下:
//树上倍增LCA 70pnts 复杂度O((n+m)logn) 
#include<cstdio>
#include<iostream>
#include<cmath>
#include<cstring>
#include<ctime>
#include<cstdlib>
#include<algorithm>
#include<queue>
#include<set>
#include<map>
#define N 500010
using namespace std;
struct tree{
    int next,ver;
}g[N<<2];
queue<int> q;
int head[N<<2],tot,t,n,m,s;//t树的深度 
int d[N<<2],f[N<<1][30];//d[]某结点处树的深度,f[x][i]第x个结点向根节点走2^i步的结点 
void add(int x,int y)
{
    g[++tot].ver=y;
    g[tot].next=head[x],head[x]=tot;
}
void reset(int x)
{
    memset(d,0,sizeof(d));
    q.push(x);
    d[x]=1;
    while(q.size())
    {
        int index=q.front();q.pop();//index是相对当前遍历节点的父节点 
        for(int i=head[index];i;i=g[i].next)
        {
            int y=g[i].ver;
            if(d[y]) continue;//如果已经处理过了就进入下一轮循环 
            d[y]=d[index]+1;//深度增加 
            f[y][0]=index;//你爸是我 
            for(int j=1;j<=t;j++)
                f[y][j]=f[f[y][j-1]][j-1];
            q.push(y);
        }
    }
}
int lca(int x,int y)
{
    if(d[x]<d[y]) swap(x,y);//使得x比y深度大 
    for(int i=t;i>=0;i--)
        if(d[f[x][i]]>=d[y]) x=f[x][i];//使得x与y深度相等 
    if(x==y) return x;
    for(int i=t;i>=0;i--)
        if(f[x][i]!=f[y][i]) x=f[x][i],y=f[y][i];
    return f[x][0];//此时x和y并未更新为它们的LCA,而是刚好在它下面一个结点,于是我们返回这个结点的爸爸 
}
int main()
{
    scanf("%d%d%d",&n,&m,&s);
    t=(int)(log(n)/log(2))+1;
    for(int i=1;i<=n-1;i++)
    {
        int x,y;
        scanf("%d%d",&x,&y);
        add(x,y);add(y,x);
    }
    reset(s);
    for(int i=1;i<=m;i++)
    {
        int x,y;
        scanf("%d%d",&x,&y);
        cout<<lca(x,y)<<endl;
    }
    return 0;
}

 

 

  • Tarjan

总之,是一个很重要的算法。
Tarjan算法是向上标记法的并查集优化版本,体现了“并查集维护节点之间的关系”的重要特性。它是一个离线算法,复杂度为O(n+m)。
 
在DFS时,我们对如下3种节点进行标记:
  1. 回溯完毕的节点,标记为1
  2. 递归完毕而未回溯的节点,标记为2
  3. 没被递归的节点,标记为0
 
如同向上标记法的思路,我们知道遍历完的节点都被标记为第一种状态,那么当我们递归到某两个要查询的节点x和y时,如果其中一个已经回溯完毕,比如x,那么我们这时的LCA就是y向根节点走遇到的第一个标记为1的节点。而这个过程,恰恰可以用并查集优化。当一个结点获得1的标记时,就把它所在的集合跟它的父节点集合合并,这样遍历完一条支链后x的集合的代表元就是树根,遍历完下一条支链时,代表元就会是两支链的最近交点!
 
所以这样我们可以用并查集记录下某两次遍历产生的交点,而这个节点就是这两次遍历经过的某些x和y的LCA。
 
代码如下(话说我写的这个代码的标记编号好像跟上面讲的是反的==):
//Tarjan O(n+m)
#include<cstdio>
#include<iostream>
#include<cmath>
#include<cstring>
#include<ctime>
#include<cstdlib>
#include<algorithm>
#include<queue>
#include<set>
#include<map>
#define N 500010
using namespace std;
struct tree{
    int next,ver;
}g[N<<2];
queue<int> q;
int head[N<<2],tot,t,n,m,s;//t树的深度 
int fa[N<<1],v[N],lca[N],ans[N];//d[]某结点处树的深度,f[x][i]第x个结点向根节点走2^i步的结点 
vector<int> query[N],query_id[N];
void add(int x,int y)
{
    g[++tot].ver=y;
    g[tot].next=head[x],head[x]=tot;
}
void add_query(int x,int y,int id)
{
    query[x].push_back(y),query_id[x].push_back(id);
    query[y].push_back(x),query_id[y].push_back(id);
}

int get(int x)
{
    if(fa[x]==x) return x;
    return fa[x]=get(fa[x]);
}
void tarjan(int x)
{
    v[x]=1;//标记这个结点被递归到 
    for(int i=head[x];i;i=g[i].next){//dfs整颗树 
        int y=g[i].ver;
        if(v[y]) continue;
        tarjan(y);
        fa[y]=x;//将这个结点和它的父节点合并 
    }
    for(int i=0;i<query[x].size();i++){//检查关于当前所在结点的询问 
        int y=query[x][i],id=query_id[x][i];//取出询问 
        if(v[y]==2){//如果该结点已经回溯 
            int lca=get(y);//当前x与y的LCA 
            ans[id]=lca;
        }
    }
    v[x]=2;//回溯,标记为2 
}
int main()
{
    scanf("%d%d%d",&n,&m,&s);
    t=(int)(log(n)/log(2))+1;//树的深度 
    for(int i=1;i<=n;i++){//初始化 
        head[i]=0,fa[i]=i,v[i]=0;
        query[i].clear(),query_id[i].clear();
    }
    for(int i=1;i<=n-1;i++)
    {
        int x,y;
        scanf("%d%d",&x,&y);
        add(x,y);add(y,x);
    }
    for(int i=1;i<=m;i++)
    {
        int x,y;
        scanf("%d%d",&x,&y);
        if(x==y) ans[i]=x;
        else{
            add_query(x,y,i);//输入询问 
        }
    }
    tarjan(s);
    for(int i=1;i<=m;i++) printf("%d\n",ans[i]);
    return 0;
}

 



 

posted @ 2019-06-08 13:20  DarkValkyrie  阅读(223)  评论(0编辑  收藏  举报