码迷,mamicode.com
首页 > 其他好文 > 详细

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

时间:2019-04-13 01:16:54      阅读:166      评论:0      收藏:0      [点我收藏+]

标签:最大的   简单   代码   子集合   algorithm   pre   概念   联合   type   

清明在机房听学长讲树上差分时提到了 \(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] \neq 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\n",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\) \(\rightarrow\) 回溯到 \(lca(a,b)\) \(\rightarrow\) 首次访问节点 \(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]=\begin{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)=\begin{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\n",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)=\begin{cases} x & x=far(x) \far(x)=find(far(x)) & x \neq 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\) \(\rightarrow\) 回溯到 \(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]\) 看有无已访问的节点,若有已访问的节点 \(query[i][j].first\) ,则 \(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\n",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\n",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\) 这个问题来说,代码量也只比倍增法稍多一点而已嘤~ 反正树剖天下第一!!!

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

标签:最大的   简单   代码   子集合   algorithm   pre   概念   联合   type   

原文地址:https://www.cnblogs.com/MakiseVon/p/10699322.html

踩
(0)
赞
(0)
   
举报
评论 一句话评论(0)
登录后才能评论!
© 2014 mamicode.com 版权所有  联系我们:gaon5@hotmail.com
迷上了代码!