LCA 之 Tarjan(离线)算法
之 (离线)算法
什么是最近公共祖先?
在一棵没有环的树上,每个节点肯定有其父亲节点和祖先节点,而最近公共祖先,就是两个节点在这棵树上深度最大的公共的祖先节点。
换句话说,就是两个点在这棵树上距离最近的公共祖先节点。
所以主要是用来处理当两个点仅有唯一一条确定的最短路径时的路径。
有人可能会问:那他本身或者其父亲节点是否可以作为祖先节点呢?
答案是肯定的,很简单,按照人的亲戚观念来说,你的父亲也是你的祖先,而还可以将自己视为祖先节点。
举个例子吧,如下图所示和的最近公共祖先是,和的最近公共祖先是,和的最近公共祖先是。

这就是最近公共祖先的基本概念了,那么我们该如何去求这个最近公共祖先呢?
通常初学者都会想到最简单粗暴的一个办法:对于每个询问,遍历所有的点,时间复杂度为,很明显,和一般不会很小。
常见的四种求法
算法 | 倍增 | 线段树 | ||
---|---|---|---|---|
在线离线 | 在线算法 | 离线算法 | 在线算法 | 在线算法 |
时间复杂度 | ||||
优缺点 | 简单, |
算法复杂度最低,代码也很简单,不易出错,可以一次性处理多条查询请求! | 简单粗暴 |
这篇博客主要是要介绍一下算法
什么是(离线)算法呢?顾名思义,就是在一次遍历中把所有询问一次性解决,所以其时间复杂度是。
算法的优点在于 相对稳定,时间 复杂度也比较居中,也很 容易理解。
下面详细介绍一下算法的基本思路:
- 任选一个点为根节点,从根节点开始
- 记已被访问过
- 遍历该点所有子节点
- 若是还有子节点,返回,否则下一步
- 合并到家族
- 寻找与当前点有询问关系的点,
- 若是已经被访问过了,
遍历的话需要用到来遍历(我相信来看的人都懂吧...),至于合并,最优化的方式就是利用 并查集 来合并两个节点。
下面上伪代码:
//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);
}
}
个人感觉这样还是有很多人不太理解,所以我打算模拟一遍给大家看。
建议拿着纸和笔跟着我的描述一起模拟!!
假设我们有一组数据 个节点 条边 联通情况如下:
即下图所示的树
设我们要查找最近公共祖先的点为
设数组为并查集的父亲节点数组,初始化数组为是否访问过的数组,初始为;

下面开始模拟过程:
取为 根节点,往下搜索 发现有两个儿子和;
先搜,发现有两个儿子和,先搜索,发现没有子节点,则寻找与其有关系的点;
发现与有关系,但是,即还没被搜过,所以 不操作;
发现没有和有询问关系的点了,返回此前一次搜索,更新;

表示已经被搜完,更新,继续搜,发现有两个儿子和;
先搜,发现有一个子节点,搜索,发现没有子节点,寻找与其有关系的点;
发现和有关系,但是,即没被搜到过,所以不操作;
发现没有和有询问关系的点了,返回此前一次搜索,更新;
表示已经被搜完,更新,发现没有没被搜过的子节点了,寻找与其有关系的点;
发现和有关系,但是,所以 不操作;
发现没有和有关系的点了,返回此前一次搜索,更新;

表示已经被搜完,更新,继续搜,发现没有子节点,则寻找与其有关系的点;
发现与有关系,此时,则他们的最近公共祖先为;
(find(9)
的顺序为f[9]=7-->f[7]=5-->f[5]=5 return 5;
)
发现没有与有关系的点了,返回此前一次搜索,更新;
表示已经被搜完,更新,发现没有没搜过的子节点了,寻找与其有关系的点;

发现和有关系,此时,所以他们的最近公共祖先为;
(的顺序为f[7]=5-->f[5]=5 return 5;
)
又发现和有关系,但是,所以不操作,此时的子节点全部搜完了;
返回此前一次搜索,更新,表示已经被搜完,更新;
发现没有未被搜完的子节点,寻找与其有关系的点;
又发现没有和有关系的点,则此前一次搜索,更新;

表示已经被搜完,更新,继续搜,发现有一个子节点;
搜索,发现没有子节点,则寻找与有关系的点,发现和有关系;
此时,所以它们的最近公共祖先为;
(的顺序为f[4]=2-->f[2]=2-->f[1]=1 return 1;
)
发现没有与有关系的点了,返回此前一次搜索,更新,表示已经被搜完了;

更新,发现没有没被搜过的子节点了,则寻找与有关系的点;
发现和有关系,此时,则它们的最近公共祖先为;
(的顺序为f[5]=2-->f[2]=1-->f[1]=1 return 1;
)
发现没有和有关系的点了,返回此前一次搜索,更新;

更新,发现没有被搜过的子节点也没有有关系的点,此时可以退出整个了。
经过这次我们得出了所有的答案,有没有觉得很神奇呢?是否对算法有更深层次的理解了呢?
标准代码模板
#include <bits/stdc++.h>
using namespace std;
typedef pair<int, int> 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<PII> 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;
}
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· Docker 太简单,K8s 太复杂?w7panel 让容器管理更轻松!
2018-11-16 在java中获取URL的域名或IP与端口
2018-11-16 解决notepad++64位没有plugin manager的问题
2017-11-16 Python3通过汉字输出拼音