zoukankan      html  css  js  c++  java
  • 【原创】最近公共祖先

    【概念与定义】

    给定一颗有根树,若节点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
    ",ans[i]);
        return 0;
    }


     

  • 相关阅读:
    XML中<beans>中属性概述
    (转)深入理解Java:注解(Annotation)自定义注解入门
    maven 配置参数详解!
    maven setting.xml文件配置详情
    hashMap与 hashTable , ArrayList与linkedList 的区别(详细)
    jdbc参数
    linux下ftp命令的安装与使用
    java中的Iterator与增强for循环的效率比较
    命令行窗口常用的一些小技巧
    在eclispe的类中快速打出main方法
  • 原文地址:https://www.cnblogs.com/DarkValkyrie/p/10990500.html
Copyright © 2011-2022 走看看