## [$P3379$ 【模板】最近公共祖先($LCA$)](https://www.luogu.com.cn/problem/P3379) #### $LCA$常见的四种求法 ![](https://dsideal.obs.cn-north-1.myhuaweicloud.com/HuangHai/BlogImages/%7Byear%7D/%7Bmonth%7D/%7Bmd5%7D.%7BextName%7D/20230725082008.png) ### 一、暴力 > 操作步骤: > ① 求出每个结点的深度 > ② 询问两个结点是否重合,若重合,则$LCA$已经求出 > ③ 否则,选择两个点中深度较大的一个,并移动到它的父亲 ```cpp {.line-numbers} int depth[N]; int fa[N]; void bfs(int root) { queue q; q.push(root); depth[root] = 1; fa[root] = -1; while (q.size()) { int u = q.front(); q.pop(); for (int i = h[u]; ~i; i = ne[i]) { int v = e[i]; if (!depth[v]) { depth[v] = depth[u] + 1; fa[v] = u; q.push(v); } } } } int lca(int a, int b) { if (depth[a] > depth[b]) swap(a, b); while (depth[a] < depth[b]) b = fa[b]; while (a != b) a = fa[a], b = fa[b]; return a; } ``` ![](https://dsideal.obs.cn-north-1.myhuaweicloud.com/HuangHai/BlogImages/%7Byear%7D/%7Bmonth%7D/%7Bmd5%7D.%7BextName%7D/20230725084122.png) ### 二、倍增求$LCA$ ```cpp {.line-numbers} #include using namespace std; const int N = 500010, M = N << 1; int e[M], h[N], idx, w[M], ne[M]; void add(int a, int b, int c = 0) { e[idx] = b, ne[idx] = h[a], w[idx] = c, h[a] = idx++; } int depth[N]; int f[N][20]; void bfs(int root) { queue q; q.push(root); depth[root] = 1; while (q.size()) { int u = q.front(); q.pop(); for (int i = h[u]; ~i; i = ne[i]) { int v = e[i]; if (!depth[v]) { depth[v] = depth[u] + 1; f[v][0] = u; q.push(v); for (int k = 1; k <= 19; k++) f[v][k] = f[f[v][k - 1]][k - 1]; } } } } int lca(int a, int b) { if (depth[a] < depth[b]) swap(a, b); for (int k = 19; k >= 0; k--) if (depth[f[a][k]] >= depth[b]) a = f[a][k]; if (a == b) return a; for (int k = 19; k >= 0; k--) if (f[a][k] != f[b][k]) a = f[a][k], b = f[b][k]; return f[a][0]; } int main() { #ifndef ONLINE_JUDGE freopen("P3379.in", "r", stdin); #endif memset(h, -1, sizeof h); // Q:为什么上面的数组开20,每次k循环到19? // 答: cout << log2(500000) << endl; int n, m, s; scanf("%d %d %d", &n, &m, &s); int a, b; for (int i = 1; i < n; i++) { scanf("%d %d", &a, &b); add(a, b), add(b, a); } bfs(s); while (m--) { scanf("%d %d", &a, &b); cout << lca(a, b) << endl; } return 0; } ``` ### 三、$Tarjan$算法求$LCA$ 什么是$Tarjan$(离线)算法呢?顾名思义,就是在一次遍历中把所有询问一次性解决,所以其时间复杂度是$O(n+q)$。 $Tarjan$算法的优点在于 **相对稳定**,时间 **复杂度也比较居中**,也很 **容易理解**。 下面详细介绍一下$Tarjan$算法的基本思路: > 1. **任选一个点为根节点**,从根节点开始 > 2. 记$u$已被访问过 > 3. 遍历该点$u$所有子节点$j$ > 4. 若是$j$还有子节点,返回$3$,否则下一步 > 5. 合并$j$到$u$家族 > 6. 寻找与当前点$u$有询问关系的点$v,id$, > 7. 若是$v$已经被访问过了,$lca[id]=find(v)$ 遍历的话需要用到$dfs$来遍历(我相信来看的人都懂吧...),至于合并,最优化的方式就是利用 **并查集** 来合并两个节点。 下面上伪代码: ```cpp {.line-numbers} //merge和find为并查集合并函数和查找函数 tarjan(u){ st[u] = true; for each(u,v){ //访问所有u子节点v tarjan(v); //继续往下遍历 p[v] = find(u);//合并v到u上 } for each(u,v){ //访问所有和u有询问关系的v 如果v被访问过; u,v的最近公共祖先为find(v); } } ``` 个人感觉这样还是有很多人不太理解,所以我打算模拟一遍给大家看。 建议拿着纸和笔跟着我的描述一起模拟!! 假设我们有一组数据 $9$个节点 $8$条边 联通情况如下: $1-2,1-3,2-4,2-5,3-6,5-7,5-8,7-9$ 即下图所示的树 设我们要查找最近公共祖先的点为$9-8,4-6,7-5,5-3$ 设$f[]$数组为并查集的父亲节点数组,初始化$f[i]=i,vis[]$数组为是否访问过的数组,初始为$0$;
下面开始模拟过程: 取$1$为 **根节点**,**往下搜索** 发现有两个儿子$2$和$3$; 先搜$2$,发现$2$有两个儿子$4$和$5$,先搜索$4$,发现$4$没有子节点,则寻找与其有关系的点; 发现$6$与$4$有关系,但是$vis[6]=0$,即$6$还没被搜过,所以 **不操作**; 发现没有和$4$有询问关系的点了,返回此前一次搜索,**更新$vis[4]=1$**;
表示$4$已经被搜完,更新$f[4]=2$,继续搜$5$,发现$5$有两个儿子$7$和$8$; 先搜$7$,发现$7$有一个子节点$9$,搜索$9$,发现没有子节点,寻找与其有关系的点; 发现$8$和$9$有关系,但是$vis[8]=0$,即$8$没被搜到过,所以**不操作**; 发现没有和$9$有询问关系的点了,返回此前一次搜索,更新$vis[9]=1$; 表示$9$已经被搜完,更新$f[9]=7$,发现$7$没有没被搜过的子节点了,寻找与其有关系的点; 发现$5$和$7$有关系,但是$vis[5]=0$,所以 **不操作**; 发现没有和$7$有关系的点了,返回此前一次搜索,更新$vis[7]=1$;
表示$7$已经被搜完,更新$f[7]=5$,继续搜$8$,发现$8$没有子节点,则寻找与其有关系的点; 发现$9$与$8$有关系,此时$vis[9]=1$,则他们的最近公共祖先为$find(9)=5$; (`find(9)`的顺序为`f[9]=7-->f[7]=5-->f[5]=5 return 5;`) 发现没有与$8$有关系的点了,返回此前一次搜索,更新$vis[8]=1$; 表示$8$已经被搜完,更新$f[8]=5$,发现$5$没有没搜过的子节点了,寻找与其有关系的点;
发现$7$和$5$有关系,此时$vis[7]=1$,所以他们的最近公共祖先为$find(7)=5$; ($find(7)$的顺序为`f[7]=5-->f[5]=5 return 5;`) 又发现$5$和$3$有关系,但是$vis[3]=0$,所以不操作,此时$5$的子节点全部搜完了; 返回此前一次搜索,更新$vis[5]=1$,表示$5$已经被搜完,更新$f[5]=2$; 发现$2$没有未被搜完的子节点,寻找与其有关系的点; 又发现没有和$2$有关系的点,则此前一次搜索,更新$vis[2]=1$;
表示$2$已经被搜完,更新$f[2]=1$,继续搜$3$,发现$3$有一个子节点$6$; 搜索$6$,发现$6$没有子节点,则寻找与$6$有关系的点,发现$4$和$6$有关系; 此时$vis[4]=1$,所以它们的最近公共祖先为$find(4)=1$; ($find(4)$的顺序为`f[4]=2-->f[2]=2-->f[1]=1 return 1;`) 发现没有与$6$有关系的点了,返回此前一次搜索,更新$vis[6]=1$,表示$6$已经被搜完了;
更新$f[6]=3$,发现$3$没有没被搜过的子节点了,则寻找与$3$有关系的点; 发现$5$和$3$有关系,此时$vis[5]=1$,则它们的最近公共祖先为$find(5)=1$; ($find(5)$的顺序为`f[5]=2-->f[2]=1-->f[1]=1 return 1;`) 发现没有和$3$有关系的点了,返回此前一次搜索,更新$vis[3]=1$;
更新$f[3]=1$,发现$1$没有被搜过的子节点也没有有关系的点,此时可以退出整个$dfs$了。 经过这次$dfs$我们得出了所有的答案,有没有觉得很神奇呢?是否对$Tarjan$算法有更深层次的理解了呢? #### $Code$ ```cpp {.line-numbers} #include using namespace std; typedef pair PII; const int N = 500010, M = N << 1; //链式前向星 int e[M], h[N], idx, w[M], ne[M]; void add(int a, int b, int c = 0) { e[idx] = b, ne[idx] = h[a], w[idx] = c, h[a] = idx++; } vector query[N]; // query[u]: first:询问的另一个顶点; second:询问的编号 int n, m, s; int p[N]; // 并查集数组 bool st[N]; // tarjan算法求lca用到的是否完成访问的标识 int lca[N]; // 结果数组 int find(int x) { if (p[x] != x) p[x] = find(p[x]); //路径压缩 return p[x]; } void tarjan(int u) { // ① 标识u已访问 st[u] = true; //② 枚举u的临边,tarjan没有访问过的点 for (int i = h[u]; ~i; i = ne[i]) { int j = e[i]; if (!st[j]) { tarjan(j); //③ 完成访问后,j点加入u家族 p[j] = u; } } //④ 每个已完成访问的点,记录结果 for (auto q : query[u]) { int v = q.first, id = q.second; if (st[v]) lca[id] = find(v); } } int main() { memset(h, -1, sizeof h); scanf("%d %d %d", &n, &m, &s); int a, b; for (int i = 1; i < n; i++) { scanf("%d %d", &a, &b); add(a, b), add(b, a); } for (int i = 1; i <= m; i++) { scanf("%d %d", &a, &b); query[a].push_back({b, i}); query[b].push_back({a, i}); } //初始化并查集 for (int i = 1; i <= n; i++) p[i] = i; tarjan(s); //输出答案 for (int i = 1; i <= m; i++) printf("%d\n", lca[i]); return 0; } ``` ### 四、RMQ+ST 挖坑待填 https://blog.csdn.net/m0_60630928/article/details/123160718?share_token=94367124-164f-4ecb-bddb-be496df1a2cd     ```cpp {.line-numbers} //3.95s / 227.49MB / 1.67KB C++14 (GCC 9) O2 #include using namespace std; const int N=500005; vectorvec[N]; //记录每个结点可以走向哪些结点 int f[N*2][21],mem[N*2][21],depth[N*2],first[N],vis[N*2],lg[N]; //f:记录深度序列区间中的最小深度 //mem:记录 找到深度序列区间中的最小深度 时的对应结点(在欧拉序列中) //depth:在dfs过程中记录遍历到每个点时的对应深度 //first:记录每个结点第一次出现时在欧拉序列中的位置 //vis:欧拉序列 //lg;lg[i]==log_{2}{i}+1 int cnt=0,n,m,s; //cnt:每走到一个点计一次数 inline int read() { int x=0,f=1;char ch=getchar(); while (ch<'0'||ch>'9'){if (ch=='-') f=-1;ch=getchar();} while (ch>='0'&&ch<='9'){x=x*10+ch-48;ch=getchar();} return x*f; } void dfs(int now,int dep) { if(!first[now]) first[now]=++cnt;//第一次遍历到该点 depth[cnt]=dep,vis[cnt]=now; for(int i=0;ir) swap(l,r); int k=lg[r-l+1]-1; if(f[l][k]