update on 2020.4.5 新增 tarjan 算法。
前言
LCA(Lowest Common Ancestor),即最近公共祖先。在一张图中,要找到两个节点的最近公共祖先,最朴素的算法就是分别从两个节点一层一层向上寻找,直到跳到一个相同的点,这个点就是两者的最近公共祖先。这样查找花费的时间代价太大,我们难以承受,于是就有了几种求LCA的算法。
倍增
关于倍增入门
倍增刚开始学习真的是个很令人讨厌的东西,当我第一次看倍增相关的知识时,我很难理解,但是仔细一想其实不难。我们想象自己就是那只小白兔,尝试跳 1,2,4,8,……($2^0,2^1,2^2,2^3,……,2^n$)步,如果这一步跳跃比目标大,就换用更小的一步,如果换了之后小于目标,就跳这么一步,从新位置开始重复上述过程直至到达目标。
LCA(倍增)
我们注意到,倍增可以看作是“跳步”,而我们之前所采用的朴素算法也类似于跳步(只不过每次只跳一位),我们能不能将倍增的思想用于寻找最近公共祖先呢?
答案显然是可以的。
预处理
我们既然采用倍增,就需要知道对于每一个点跳 $2^k$ 步会到达何方,这里我们不妨用 f[i][j]
来表示从 $i$ 号点出发,跳跃 $2^j$ 步将会到达的点。
同时我们有 $2^2=2^1 \times2^1$ ,以此类推又有 $2^k=2^{k-1}\times2^{k-1}$ ,那么跨越 $2^j$ 个格子也就是先跨越 $2^{j-1}$ 个格子 (f[i][j-1]
) 再跨越 $2^{j-1}$ 个格子 (f[f[i][j-1]][j-1]
) 。于是有了如下公式
于是,我们预处理 f 数组,并按照上述朴素算法执行向上跳的操作。
可是,这样的算法我们很快能举出反例
我们选择16,17两个节点,在尝试跳 $2^2$ 步时共同跳到了1号点,然而1号点并不是最近公共祖先。
看来步子确实不能迈得太大。
我们这里采取的方法是实现判断要跳的点是否为同一个点,如果是,则不跳,那么最终会发现,两个点一直没有跳,那么16,17的父亲也就是自己的最近公共祖先。
代码实现
我们可以通过一个dfs来实现每一个节点的深度表示,儿子节点深度为父亲节点深度+1。同时我们还能够完成对于 f 数组的初始化,不过这里并不是向后跳,而是向着根节点跳(也可以认为是在向上跳)。
给出代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| void dfs(int u,int father) { dep[u]=dep[father]+1; for(int i=1;(1<<i)<=dep[u];i++) { f[u][i]=f[f[u][i-1]][i-1]; } for(int i=first[u];i;i=nxt[i]) { int p=v[i]; if(p==father) continue; f[p][0]=u; dfs(p,u); } }
|
有了这一步,lca 的思维难度便一点都不大了
给出代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| int lca(int x,int y) { if(dep[x]<dep[y]) swap(x,y); for(int i=20;i>=0;i--) { if(dep[f[x][i]]>=dep[y]) x=f[x][i]; if(x==y) return x; } for(int i=20;i>=0;i--) { if(f[x][i]!=f[y][i]) { x=f[x][i]; y=f[y][i]; } } return f[x][0]; }
|
使用方法
本题就被我们顺利地解决了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75
| #include <bits/stdc++.h> using namespace std; const int N = 500005,M = 500005 * 2; int n,m,s; int v[M],first[N],nxt[M],dep[N],idx,f[N][25]; void add(int x,int y) { idx++; v[idx]=y; nxt[idx]=first[x]; first[x]=idx; } void dfs(int u,int father) { dep[u]=dep[father]+1; for(int i=1;(1<<i)<=dep[u];i++) { f[u][i]=f[f[u][i-1]][i-1]; } for(int i=first[u];i;i=nxt[i]) { int p=v[i]; if(p==father) continue; f[p][0]=u; dfs(p,u); } } int lca(int x,int y) { if(dep[x]<dep[y]) swap(x,y); for(int i=20;i>=0;i--) { if(dep[f[x][i]]>=dep[y]) x=f[x][i]; if(x==y) return x; } for(int i=20;i>=0;i--) { if(f[x][i]!=f[y][i]) { x=f[x][i]; y=f[y][i]; } } return f[x][0]; }
int main() {
scanf("%d%d%d",&n,&m,&s); for(int i=1;i<n;i++) { int a,b; scanf("%d%d",&a,&b); add(a,b);add(b,a); } dfs(s,0); for(int i=1;i<=m;i++) { int a,b; scanf("%d%d",&a,&b); printf("%d\n",lca(a,b)); } return 0; }
|
LCA(tarjan)
Tarjan 这个大神犇又来造福大家了,全新的炫酷 dfs 神奇应用 LCA。
这是一个离线算法,即最终一次性跑完所有查询,原理是通过一次 dfs 实现。
tarjan 算法基本思路:
任选一个点为根节点,从根节点开始进行DFS。
标记当前访问到的点 u 已经被访问过。
寻找与当前点 u 有询问关系的点 v。
若是 v 已经被访问过了,则可以确认 u 和 v 的最近公共祖先为 v 被合并到的父亲节点 a。
遍历点 u 的所有子节点 v。
若 v 还有子节点,返回2,否则下一步。
合并 v 到 u 上。
具体思路先 gugugu。
给出代码如下
基于 vector 实现(速度较慢,明明是线性算法愣是比倍增慢还 T 了3个点,只能O2)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54
| #include <bits/stdc++.h> using namespace std; #define N 1000005 #define pb push_back #define VI vector<int> struct edge { int u,v,lca; } qe[N]; int n,m,f[N],book[N],a,s; VI e[N],q[N]; int getf(int x){return x==f[x]?x:f[x]=getf(f[x]);} void tarjan(int x) { book[x]=1; for(int i=0;i<q[x].size();i++) { int idx=q[x][i]; if(qe[idx].u!=x) a=qe[idx].u; else a=qe[idx].v; if(book[a]) qe[idx].lca=getf(a); } for(int i=0;i<e[x].size();i++) { int y=e[x][i]; if(!book[y]) { tarjan(y); f[y]=x; } } } inline char getcha() { static char buf[100000],*p1=buf,*p2=buf; return p1==p2&&(p2=(p1=buf)+fread(buf,1,100000,stdin),p1==p2)?EOF:*p1++; } inline int read() { char ch=getcha();int ret=0,f=1; while(!isdigit(ch)){if(ch=='-')f=-f;ch=getcha();} while(isdigit(ch)){ret=(ret<<1)+(ret<<3)+ch-'0';ch=getcha();} return ret*f; } int main() { n=read(),m=read(),s=read(); for(int i=1;i<=n-1;i++) { int x=read(),y=read(); e[x].pb(y);e[y].pb(x); } for(int i=1;i<=m;i++) { qe[i].u=read(),qe[i].v=read(); q[qe[i].u].pb(i);q[qe[i].v].pb(i); } for(int i=1;i<=n;i++) f[i]=i; tarjan(s); for(int i=1;i<=m;i++) { printf("%d\n",qe[i].lca); } return 0; }
|
基于链式前向星实现(常数小,但是实现比较复杂)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66
| #include <bits/stdc++.h> using namespace std; const int N = 1e6+7,M= 2 * N; int idx,first[N],nxt[M],v[M],book[N],f[N],a,n,m,s; int idx1,q_first[N],q_nxt[M],q_v[M]; struct edge { int u,v,lca; } qe[N]; void add(int x,int y) { idx++; v[idx]=y; nxt[idx]=first[x]; first[x]=idx; } void add_q(int x,int y) { idx1++; q_v[idx1]=y; q_nxt[idx1]=q_first[x]; q_first[x]=idx1; } int getf(int x) {return x==f[x]?x:f[x]=getf(f[x]);} void tarjan(int x) { book[x]=1; for(int i=q_first[x];i;i=q_nxt[i]) { int qv=q_v[i]; if(book[qv]) { qe[i].lca=getf(qv); if(i%2) qe[i+1].lca=qe[i].lca; else qe[i-1].lca=qe[i].lca; } } for(int i=first[x];i;i=nxt[i]) { int y=v[i]; if(!book[y]) { tarjan(y); f[y]=x; } } } inline char getcha() { static char buf[100000],*p1=buf,*p2=buf; return p1==p2&&(p2=(p1=buf)+fread(buf,1,100000,stdin),p1==p2)?EOF:*p1++; } inline int read() { char ch=getcha();int ret=0,f=1; while(!isdigit(ch)){if(ch=='-')f=-f;ch=getcha();} while(isdigit(ch)){ret=(ret<<1)+(ret<<3)+ch-'0';ch=getcha();} return ret*f; } int main() { n=read(),m=read(),s=read(); for(int i=1;i<=n-1;i++) { int x=read(),y=read(); add(x,y);add(y,x); } for(int i=1;i<=m;i++) { qe[i].u=read(),qe[i].v=read(); add_q(qe[i].u,qe[i].v);add_q(qe[i].v,qe[i].u); } for(int i=1;i<=n;i++) f[i]=i; tarjan(s); for(int i=1;i<=m;i++) { printf("%d\n",qe[i*2].lca); } return 0; }
|
后记
作者很久之前就想学习 LCA ,并多次查阅 OI Wiki 但实属能力不足,一直没有看懂,非常感谢
这个 教学视频 。可以说是非常直观了。
参考
最近公共祖先 OI Wiki
【manim | 算法】7分钟学会倍增法求解LCA
【白话系列】倍增算法