• 从零开始的LCA(最近公共祖先)


    清明在机房听学长讲树上差分时提到了 (LCA) 这个东西,就顺带着学习了一波。由于本人是个蒟蒻,为了避免出现看不懂自己写的 (Blog) 这种情况,文中涉及到的知识和概念都会 概括性地 讲一下。

    先甩个定义

    (LCA) ((Lowest) (Common) (Ancestors))
    即最近公共祖先,是指在一个树或者有向无环图中同时拥有 (v)(w) 作为后代的最深的节点,。在这里,我们定义一个节点也是其自己的后代,因此如果 (v)(w) 的后代,那么 (w) 就是 (v)(w) 的最近公共祖先。

    ——参考自 (Wikipedia)

    再贴个板子题链接:LuoguP3379 【模板】最近公共祖先(LCA)

    题意:给定一颗有根多叉树的节点数、直连边、根节点,求节点 (a) 和节点 (b)(LCA)

    为方便,以下我们记节点 (a) 和节点 (b)(LCA)(lca(a,b))

    下面介绍四种求 (LCA) 的方法

    • 倍增法
    • (RMQ)
    • (Tarjan)
    • 树链剖分

    倍增法

    思路

    先考虑最基本的想法,我们让它们中深度较大的节点往上“爬”,直至两节点深度相同,然后让它们同时往上“爬” ,相遇的节点既是 (lca(a,b)) 。这个想法的正确性显而易见,但如果我们是一个节点一个节点地“爬”的话,效率上行不通,所以我们考虑改进——让它们“爬”得快一点。倍增法,顾名思义,就是通过倍增“爬”的节点数来加快“爬”的速度。

    实现

    定义

    • (dep[i]) :节点 (i) 的深度。
    • (far[i][j]) :节点 (i) 往上“爬” (2^j) 个节点后到达的祖先节点。

    这部分 (DFS) 一次预处理即可,比较简单,代码应该都看得懂,不讲。
    不妨设 (dep[a] geq dep[b]) ,下面分两步走:

    1. 为了让节点 (a) 往上“爬”到与节点 (b) 同一深度的位置,我们不断令 (a=far[a][log_2(dep[a]-dep[b])]) ,直至 (dep[a]=dep[b])。 如果此时 (a=b) ,说明 (a) 即为所求 (LCA) ,否则继续下一步。
       
    2. 每次取最大的 (j) 使得 (far[a][j] eq far[b][j]) ,令 (a=far[a][j]) , (b=far[b][j]) ,直至 (far[a][0]=far[b][0]) 。此时,(far[a][0]) 即为所求 (LCA)

    Code

    #include <bits/stdc++.h>
    using namespace std;
    
    const int n_size=500005;
    const int m_size=1e6+5;
    const int bit_size=25;
    int N,M,S,x,y,a,b;
    int cnt,head[n_size],to[m_size],nxt[m_size];
    int dep[n_size],far[n_size][bit_size],lg[n_size];
    
    void add(int pre,int pos);
    void dfs(int pre,int pos);
    void prelg(void);
    int lca(void);
    
    int main(void){
        scanf("%d%d%d",&N,&M,&S);
        for(int i=1;i<N;i++){
            scanf("%d%d",&x,&y);
            add(x,y);
            add(y,x);
        }
        dfs(0,S);
        prelg();
        for(int i=0;i<M;i++){
            scanf("%d%d",&a,&b);
            printf("%d
    ",lca());
        }
        return 0;
    }
    
    inline void add(int pre,int pos){
        to[++cnt]=pos;
        nxt[cnt]=head[pre];
        head[pre]=cnt;
    }
    
    void dfs(int pre,int pos){
        dep[pos]=dep[pre]+1;
        far[pos][0]=pre;
        for(int i=1;(1<<i)<=dep[pos];i++)   far[pos][i]=far[far[pos][i-1]][i-1];
        for(int i=head[pos];i>0;i=nxt[i])
            if(to[i]!=pre)    dfs(pos,to[i]);
    }
    
    void prelg(void){
        for(int i=1;i<=N;i++)   lg[i]=lg[i-1]+(1<<(lg[i-1]+1)==i);
    }
    
    int lca(void){
        if(dep[a]<dep[b])   swap(a,b);
        while(dep[a]>dep[b])    a=far[a][lg[dep[a]-dep[b]]];
        if(a==b)    return a;
        for(int i=lg[dep[a]];i>=0;i--)
            if(far[a][i]!=far[b][i]){
                a=far[a][i];
                b=far[b][i];
            }
        return far[a][0];
    }
    

    (RMQ)

    甩个定义

    (RMQ) ((Range) (Minimum) (Query))
    即范围最值查询,是针对数据集的一种条件查询。若给定一个数组 (A[1,n]) ,范围最值查询指定一个范围条件 (i)(j) ,要求取出 (A[i,j]) 中最大/小的元素。通常情况下,数组 (A) 是静态的,即元素不会变化,例如插入、删除和修改等,而所有的查询是以在线的方式给出的,即预先并不知道所有查询的参数。

    ——参考自 (Wikipedia)

    (RMQ) 算法主要借助于 (ST)

    再甩一个定义

    (ST) ((Sparse) (Table))
    设要查询的静态数组为 (A[1,n]) ,创建一个二维数组 (ST[1,N][0,log_2N]) 。在这个数组中 (ST[i][j]) 表示 (A[i,i+2^j-1]) 中的最大/小值。

    ——参考自 (Wikipedia)

    看完定义是不是感觉 (ST) 表和倍增法中的数组 (far) 很像?实际上 (ST) 表在这里所起的作用确实和数组 (far) 是类似的。

    前置知识

    • 树的 (DFS) 序:遍历一棵树时节点的访问顺序。
    • 树的欧拉序:遍历一棵树时途径节点所成的序列。

    树的 (DFS) 序和欧拉序的联系:(DFS) 序就是去重的欧拉序(每个节点只保留首次出现的那个)。

    思路

    遍历树时,某个时刻会首次访问 (lca(a,b)) ,接着会遍历子树,不妨设先遍历的是节点 (a) 所在的子树,那么从首次访问节点 (a) 开始算起,接下来访问完节点 (a) 的子节点后会一路回溯到 (lca(a,b)) ,遍历完其他无关子树后接着再遍历节点 (b) 所在的子树,最后一路往下访问直到首次访问节点 (b) 为止。
    记住这个过程:首次访问节点 (a) ( ightarrow) 回溯到 (lca(a,b)) ( ightarrow) 首次访问节点 (b)
    这个过程对应的欧拉序涉及的所有节点中,(lca(a,b)) 是深度最小的节点。那么,求 (lca(a,b)) ,只需求欧拉序的这一段中深度最小的节点即可。

    实现

    定义

    • (num[i]) :欧拉序中第 (i) 个节点。
    • (fap[i]) :欧拉序中节点 (i) 首次出现的位置。
    • (dep[i]) :欧拉序中第 (i) 个节点的深度。
    • (st[i][j])(dep[i]) 最小值 (ST) 表,为了方便,我们存的是值的索引而不是值本身,即 (st[i][j]) 表示 (dep[i,i+2^j-1]) 中最小值的位置。

    前面的三个数组 (DFS) 一次预处理,不讲。
    (st[i][j]) 需要单独 (DP) 预处理,这里给出状态转移方程:

    (x=st[i][j-1])(y=st[i+2^{j-1}-1]][j-1]) ,则有
    $$st[i][j]=egin{cases}
    x & dep[x]<dep[y]
    y & dep[x] geq dep[y]
    end{cases}$$

    边界条件是 (st[i][0]=i) ,比较好理解,不细讲。
    最后是 (lca(a,b)) 的求法:
    不妨设 (fap[a] leq fap[b]) ,我们只需找到 (ST) 表对 ([fap[a]),(fap[b]]) ,即我们所要的欧拉序段上的最小覆盖,即可利用 (ST) 表快速求出这一段中深度最小的节点。

    (bit=log_2(fap[b]-fap[a]+1)) ,最小覆盖显然就是 ([fap[a],fap[a]+2^{bit}-1])([fap[b]-2^{bit}+1,fap[b]]) ,即:

    (x=st[fap[a]][bit])(y=st[fap[b]-2^{bit}+1][bit]) ,则有

    $$lca(a,b)=egin{cases}
    x & dep[x]<dep[y]
    y & dep[x] geq dep[y]
    end{cases}$$

    Code

    #include <bits/stdc++.h>
    using namespace std;
    
    const int n_size=500005;
    const int m_size=1e6+5;
    const int bit_size=25;
    int N,M,S,x,y,a,b;
    int cnt,head[n_size],to[m_size],nxt[m_size];
    int node,num[m_size],fap[n_size],dep[m_size];
    int lg[m_size],st[m_size][bit_size];
    
    void add(int pre,int pos);
    void dfs_init(int pos,int predep);
    void st_init(void);
    int lca(void);
    void prelg(void);
    
    int main(void){
        scanf("%d%d%d",&N,&M,&S);
        for(int i=1;i<N;i++){
            scanf("%d%d",&x,&y);
            add(x,y);
            add(y,x);
        }
        dfs_init(S,0);
        st_init();
        for(int i=0;i<M;i++){
            scanf("%d%d",&a,&b);
            printf("%d
    ",lca());
        }
        return 0;
    }
    
    inline void add(int pre,int pos){
        to[++cnt]=pos;
        nxt[cnt]=head[pre];
        head[pre]=cnt;
    }
    
    void dfs_init(int pos,int predep){
        fap[pos]=++node;
        num[node]=pos;
        dep[node]=predep+1;
        for(int i=head[pos];i>0;i=nxt[i])
            if(fap[to[i]]==0){
                dfs_init(to[i],dep[node]);
                num[++node]=pos;
                dep[node]=predep+1;
            }
    }
    
    void st_init(void){
        prelg();
        for(int i=1;i<=node;i++)   st[i][0]=i;
        for(int i=1;i<=lg[node];i++){
            int upp=node-(1<<i)+1;
            for(int j=1;j<=upp;j++){
                int x=st[j][i-1],y=st[j+(1<<(i-1))][i-1];
                st[j][i]=dep[x]<dep[y]?x:y;
            }
        }
    }
    
    int lca(void){
        if(fap[a]>fap[b])   swap(a,b);
        int inc=fap[b]-fap[a]+1;
        int sta=st[fap[a]][lg[inc]];
        int stb=st[fap[b]-(1<<lg[inc])+1][lg[inc]];
        if(dep[sta]<dep[stb])   return num[sta];
        return num[stb];
    }
    
    void prelg(void){
        for(int i=1;i<=node;i++)    lg[i]=lg[i-1]+(1<<(lg[i-1]+1)==i);
    }
    

    (Tarjan)

    前置知识

    • 并查集 ((DSU))

    应该都会吧
    还是简单提一下吧~

    甩定义

    (DSU) ((Disjoint) (Sets) (Union))
    即并查集,是一种树型的数据结构,用于处理一些不交集 ((Disjoint) (Sets)) 的合并及查询问题。
    有一个联合-查找算法 ((Union) - (Find) (Algorithm)) 定义了两个用于此数据结构的操作:
    (Find):确定元素属于哪一个子集。它可以被用来确定两个元素是否属于同一子集。
    (Union):将两个子集合并成同一个集合。

    ——参考自 (Wikipedia)

    先看定义,知道 (Find)(Union) 操作是怎么回事再往下看,以下记 (find(x)) 为节点 (x) 所在集合的头,(far(x)) 为节点 (x) 在并查集中的父节点,(marge(a,b)) 为合并节点 (a) 和节点 (b) ,那么我们有

    $$find(x)=egin{cases}
    x & x=far(x)
    far(x)=find(far(x)) & x eq far(x)
    end{cases}$$

    这里实际上就是一个递归,画画图就懂了~

    $$marge(a,b):far(find(a))=find(b)$$

    这个也简单~

    思路

    先引用知乎上《如何理解 (Tarjan)(LCA) 算法?》这一问题下高赞回答的一段话:

    一个熊孩子 (Link) 从一棵有根树的最左边最底下的结点灌岩浆,(Link) 表示很讨厌这种倒着长的树。岩浆会不断的注入,直到注满整个树…如果岩浆灌满了一棵子树,(Link) 发现树的另一边有一棵更深的子树,(Link) 会先去将那棵子树灌满。岩浆只有在迫不得已的情况下才会向上升高,找到一个新的子树继续注入。机 ((yu))((chun))(Link) 发现了找 (LCA) 的好方法,即如果两个结点都被岩浆烧掉时,他们的 (LCA) 即为那棵子树上岩浆最高的位置。

    注意这段话中“如果两个结点都被岩浆烧掉时”这一句,实际上就是当节点 (a)(b) 都恰被访问过的时候。看完是不是想到了什么?是不是感觉和 (RMQ)(LCA) 的思路差不多?没错,思路本质上是一样的,但实现则很不一样,(Tarjan)(LCA) 的过程要更为巧妙。先明确一件事,当节点 (a)(b) 都恰被访问过时,我们还没有最终回溯到“岩浆最高的位置”,即 (lca(a,b)) ,记住这个这个性质。

    遍历树时,某个时刻会首次访问 (lca(a,b)) ,接着会遍历子树,不妨设先遍历的是节点 (a) 所在的子树,我们每访问完一个节点及其子树并最终回溯到这个节点时,将这个节点标记为已访问,并把它和它的父节点用 (Union) 操作合并起来,令这个集合的祖先是它的父节点。当首次访问节点 (a) ( ightarrow) 回溯到 (lca(a,b)) 这个过程结束后,节点 (a) 所在子树上所有节点都已经和 (lca(a,b)) 合并成了一个集合,因为 (lca(a,b)) 还没有最终回溯,集合的祖先就一定是是 (lca(a,b)) ,接着一直到首次访问节点 (b) ,此时我们注意到节点 (a) 标记为已访问,亦即已与 (lca(a,b)) 合并,那么就可以判断 (a) 所在集合的祖先即 (lca(a,b)) 。注意,从这里可以看出,(Tarjan)(lca(a,b)) 需要我们预先知道询问的节点 (a) 和节点 (b) ,也就是说这是一个离线算法,我们要预处理询问。

    实现

    定义

    • (ans[i]):第 (i) 个询问的答案。
    • (query[i]) :与节点 (i) 有关的询问,除了存储另一个节点以外还要存储这是第几个询问,且与节点 (i) 有关的询问可能不止一个,故最好用 (pair) 类型的 (vector) 实现,这里令 (first) 为另一个节点,(second) 为询问的编号。
    • (anc[i]):以节点 (i) 为头的集合的祖先。
    • (vis[i]):节点 (i) 是否已访问。

    (DFS) 遍历树,搜索到某一节点 (i) 时,先着搜索节点 (i) 的所有子节点,对于节点 (i) 的某一子节点 (j),搜索节点 (j)(marge(i,j)) ,并令 (anc[find(j)]=i),搜索完所有子节点回溯到节点 (i) 时,令 (vis[i]=true) ,接着遍历 (query[i]) 看有无已访问的节点,若有已访问的节点,则 (ans[query[i][j].second]=anc[find(query[i][j].first)])

    实际上,我们每次令集合的头为当前节点的父节点就可以不使用数组 (anc) ,但有些优化过的并查集(比如我在这里用的按树高合并的并查集)不支持这样操作,所以这里还是选择使用这个数组,这样代码也更清晰。

    Code

    #include <bits/stdc++.h>
    using namespace std;
    #define fir first
    #define sec second
    #define mp make_pair
    typedef pair<int,int> pii;
    
    const int n_size=500005;
    const int m_size=1e6+5;
    int N,M,S,x,y,a,b,ans[n_size];
    vector<pii> query[n_size];
    int cnt,head[n_size],to[m_size],nxt[m_size];
    int far[n_size],rk[n_size],anc[n_size];
    bool vis[n_size];
    
    void add(int pre,int pos);
    void lca(int pos,int pre);
    void dsu_init(void);
    void marge(int x,int y);
    int find(int x);
    
    int main(void){
        scanf("%d%d%d",&N,&M,&S);
        for(int i=1;i<N;i++){
            scanf("%d%d",&x,&y);
            add(x,y);
            add(y,x);
        }
        for(int i=1;i<=M;i++){
            scanf("%d%d",&a,&b);
            query[a].push_back(mp(b,i));
            query[b].push_back(mp(a,i));
        }
        dsu_init();
        lca(S,0);
        for(int i=1;i<=M;i++)   printf("%d
    ",ans[i]);
        return 0;
    }
    
    inline void add(int pre,int pos){
        to[++cnt]=pos;
        nxt[cnt]=head[pre];
        head[pre]=cnt;
    }
    
    void dsu_init(void){
        for(int i=1;i<=N;i++)   far[i]=i;
    }
    
    void lca(int pos,int pre){
        for(int i=head[pos];i>0;i=nxt[i])
            if(to[i]!=pre&&vis[to[i]]==false){
                lca(to[i],pos);
                marge(to[i],pos);
                anc[find(to[i])]=pos;
            }
        vis[pos]=true;
        int upp=query[pos].size();
        for(int i=0;i<upp;i++){
            int x=query[pos][i].fir,y=query[pos][i].sec;
            if(ans[y]==0&&vis[x]==true) ans[y]=anc[find(x)];
        }
    }
    
    void marge(int x,int y){
        x=find(x);
        y=find(y);
        if(x==y)    return;
        if(rk[x]<rk[y]) far[x]=y;
        else{
            far[y]=x;
            if(rk[x]==rk[y])    rk[x]++;
        }
    }
    
    int find(int x){
        if(x==far[x])   return x;
        return far[x]=find(far[x]);
    }
    

    树链剖分

    刚写完板子顺便温习一下!~
    其实没有想象中的那么难~
    这个人已经忘记了第一次写调了两三个小时还是写炸了的事实
    之前不会树剖的可以借这个问题学习一下~

    树链剖分
    指一种对树进行划分的算法,它先通过轻重边剖分将树分为多条链,保证每个点属于且只属于一条链,然后再通过数据结构(树状数组 ((BIT))、二叉搜索树 ((BST))、伸展树 ((Splay))、线段树 ((Segment) (Tree)) 等)来维护每一条链。

    ——参考自 (Baidupedia)

    先引入几个概念

    • 重儿子:父节点的节点数最多的子树的根节点。
    • 轻儿子:不是重儿子的子节点。
    • 重边:连接父节点和重儿子的边。
    • 轻边:连接父节点和轻儿子的边。
    • 重链:重边组成的链。
    • 轻链:轻边组成的链。

    思路

    我们要做的就是把数分成重链和轻链。然后就好办了,分两种情况:

    若节点 (a) 和节点 (b) 在同一条链上,那么深度较小的即是 (lca(a,b))
    否则,不断令链端深度较大的节点为它的链端的父节点,直至两节点在同一条链上,此时便转换为了第一种情况。

    实现

    定义

    • (dep[i]):节点 (i) 的深度。
    • (siz[i]):以节点 (i) 为根的树的节点数。
    • (far[i]): 节点 (i) 的父节点。
    • (son[i]):节点 (i) 的重儿子。
    • (top[i]):节点 (i) 所在链的链端。

    用两次 (DFS) 预处理这些东西,树剖就算做完了~第一次 (DFS) 除数组 (top) 外都预处理了,这部分正常 (DFS) 遍历树即可,不细讲。重点是 (top) 的预处理,我们搜索到一个节点时,如果该节点不为叶节点,那么重儿子要和轻儿子区分开来搜索!知道这一点后代码应该也能 很简单地 写出来叭qwq

    接着求 (lca(a,b)) ,把思路翻译过来,很简单~

    (top[a]=top[b]) ,设 (dep[a] leq dep[b]) ,则 (lca(a,b)=a)
    否则,设每次操作后两节点中链端深度较大的节点为 (k) ,则不断令 (k=far[top[k]]) ,直至 (top[a]=top[b]) ,此时便转换为了第一种情况。

    Code

    #include <bits/stdc++.h>
    using namespace std;
    
    const int n_size=500005;
    const int m_size=1e6+5;
    int N,M,S,x,y,a,b;
    int cnt,head[n_size],to[m_size],nxt[m_size];
    int dep[n_size],siz[n_size],far[n_size],son[n_size],top[n_size];
    
    void add(int pre,int pos);
    void dfs_init_fir(int pos);
    void dfs_init_sec(int pos,int tpos);
    int lca(void);
    
    
    int main(void){
        scanf("%d%d%d",&N,&M,&S);
        for(int i=1;i<N;i++){
            scanf("%d%d",&x,&y);
            add(x,y);
            add(y,x);
        }
        dfs_init_fir(S);
        dfs_init_sec(S,S);
        for(int i=0;i<M;i++){
            scanf("%d%d",&a,&b);
            printf("%d
    ",lca());
        }
        return 0;
    }
    
    inline void add(int pre,int pos){
        to[++cnt]=pos;
        nxt[cnt]=head[pre];
        head[pre]=cnt;
    }
    
    void dfs_init_fir(int pos){
        dep[pos]=dep[far[pos]]+1;
        siz[pos]=1;
        int son_siz=0;
        for(int i=head[pos];i>0;i=nxt[i])
            if(to[i]!=far[pos]){
                far[to[i]]=pos;
                dfs_init_fir(to[i]);
                siz[pos]+=siz[to[i]];
                if(siz[to[i]]<=son_siz) continue;
                son[pos]=to[i];
                son_siz=siz[to[i]];
            }
    }
    
    void dfs_init_sec(int pos,int tpos){
        top[pos]=tpos;
        if(son[pos]>0)  dfs_init_sec(son[pos],tpos);
        for(int i=head[pos];i>0;i=nxt[i])
            if(to[i]!=far[pos]&&to[i]!=son[pos])    dfs_init_sec(to[i],to[i]);
    }
    
    int lca(void){
        while(top[a]!=top[b])
            if(dep[top[a]]<dep[top[b]]) b=far[top[b]];
            else    a=far[top[a]];
        return dep[a]<dep[b]?a:b;
    }
    

    (LCA) 的解法就这么 愉快地 讲完了呢~
    我们小结一下四种解法的特点

    1. 倍增法:好理解,也好实现,较快~
    2. (RMQ) :就你最慢了,哼唧~ 可能是我写得菜
    3. (Tarjan) :意外地挺好写,嘤~
    4. 树链剖分:虽然很多人说没必要用这个写,但评测姬显示这个是最快的呢~ 就求 (LCA) 这个问题来说,代码量也只比倍增法稍多一点而已嘤~ 反正树剖天下第一!!!
  • 相关阅读:
    h5及c3新增的一些内容
    Ajax实现步骤和原理
    prototype和__proto__的关系是什么?
    深拷贝与浅拷贝
    promise与async和await的区别
    ph

    p
    python4
    python3
  • 原文地址:https://www.cnblogs.com/MakiseVon/p/10699322.html
Copyright © 2020-2023  润新知