码迷,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
迷上了代码!