题目传送门
一、题目分析
本题考察\(LCA\)的\(Tarjan\)算法。\(Tarjan\)算法是一个离线算法,统一读入输出,计算后再统一输出,算法的时间复杂度是\(O(n + m)\)。
设$x$和$y$的$LCA$是$r$(暂时假设$r$、$x$、$y$是三个不同的节点),则$x$和$y$一定处于以$r$为根的不同子树中,并且可以得出:处在$r$不同子树中的任意两个节点的$LCA$都是$r$,所以在遍历以$r$为根的子树时,只要能够判断出两个节点分处于$r$的不同子树中,就可以将这两个节点的$LCA$标记为$r$。这其实就是$LCA$的$tarjan$算法的基本原理:**遍历以$r$为根的子树时,处在$r$不同子树的任意两点的$LCA$一定是$r$**。
原理很简单,但是原理演化出的算法未必那么简单了。既然\(x\)和\(y\)处在\(r\)不同的子树中,就可以说明\(r\)就是\(x\)和\(y\)的\(LCA\),那么,如何判断\(x\)和\(y\)是否处在\(r\)的不同子树中呢?在对以\(r\)为根的子树做\(dfs\)的过程中,如果\(y\)所在的子树已经遍历完了,之后又遍历到\(x\)时,就可以说明\(x\)和\(y\)不在同一棵子树了。
将处在遍历不同阶段的节点分为了三种不同的状态:\(undiscovered\)、\(discovered\)和\(visited\)。
\(0\)类节点,即\(undiscovered\)状态的节点,也就是还未遍历到的节点;
\(1\)类节点:即\(discovered\)状态的节点,表示该节点已经遍历到了,但是其子树还没有完成遍历回溯完;
\(2\)类节点:即\(visited\)状态的节点,表示该节点以及其子树均已遍历回溯完了。
在\(dfs\)过程中,第一次遍历到\(r\)时,\(r\)的状态转化为\(1\),并且\(r\)的祖先节点的状态也都是\(1\)。当\(y\)所在的子树全部遍历回溯完后,\(y\)到\(r\)的路径中,除了\(r\)以外的其他节点的状态均是\(2\).换而言之,\(x\)和\(y\)的\(LCA\)就是\(y\)向上回溯到第一个状态为\(1\)的节点。
$dfs$遍历完$y$所在的子树并且遍历完$x$及其子树时各节点的状态如上图所示。此时,$x$的子树刚刚全部遍历回溯完成,然后发现$y$的状态是$2$,于是$y$向上回溯(因为已标识到并查集中,不是动态回溯),发现了第一个标记为状态$1$的$r$节点,也就是$x$和$y$的$LCA$节点。原理也就是之前所说的,$y$所在的子树遍历完了,但是$LCA$节点$r$状态肯定还是$1$,因为$r$还有其他子树没有遍历完,后面再遍历到$x$所在的子树时,一方面就说明了$x$和$y$在$r$的不同子树中,另一方面也定位到了$x$和$y$分属不同子树的根节点$r$。
为了提高回溯查找\(LCA\)的效率,可以使用并查集优化,即一个节点状态转化为\(2\)时,就可以将其合并到其父节点所在的集合中,这样一来,当\(y\)所在的子树全部变为状态\(2\)时,他们也都被合并到\(r\)所在的集合了,就有了\(y\)所在的并查集的根结点就是\(r\),也就是\(x\)和\(y\)的\(LCA\)节点。
特殊情况:\(r\)和\(x\)重合,即\(x\)与\(y\)的\(LCA\)就是\(x\),此时在遍历完\(x\)的所有子树后,\(x\)的状态即将转化为\(2\)时,\(y\)也被合并到以\(x\)为根的并查集中了,此时\(x\)就是\(LCA\)节点。所以我们可以在\(x\)的子树均已遍历回溯完成之际,对\(x\)与状态为\(2\)的\(y\)节点求\(LCA\)。
综上所述,\(lca(x,y)=find(y)\),其中\(find\)函数就是并查集的查找当前集合根节点的函数。并且如果要求\(x\)与\(y\)之间的距离,可以在\(tarjan\)算法作\(dfs\)的过程中记录下树中每个节点的深度,\(r\)是\(x\)和\(y\)的\(LCA\),\(dx\)表示\(x\)的深度,\(dis(x,y)\)表示\(x\)与\(y\)之间的距离,有:
\(dx = dr + dis(x,r), dy = dr + dis(y,r);\)
\(dis(x,y) = dis(x,r) + dis(y,r);\)
\(dis(x,y) = dis(x,r) + dis(y,r) = dx + dy - 2 * dr\)
最后需要注意并查集的合并操作一定要在当前节点的所有子树都已经遍历回溯完成的情况下,所以要写在\(tarjan\)函数调用的后面,否则像\(r\)节点还没有遍历回溯完就被合并到了\(r\)的父节点所在的集合,后面再对\(y\)求并查集的根节点时就不会返回\(r\)节点了,就会引起错误。
当然,本题代码的实现细节,需要推敲的地方还有不少,全部写出来会显得冗余。只有自己看懂了代码,之后自己去写代码时才能明白算法的一些细节的合理性和巧妙性。
二、tarjan算法实现
#include <bits/stdc++.h>
using namespace std;
const int N = 20010, M = 40010;
int n, m; // n个节点,m条边
int dist[N]; //每个点到起点的距离
int p[N]; //并查集数组
int st[N]; // tarjan算法的专用状态标识数组,0,1,2
int res[N]; //距离结果数组
struct Node {
int v; //到哪个节点
int qId; //查询的次序号
};
vector<Node> q[N]; //查询的数组
//邻接表
int e[M],
h[N], idx, w[M], ne[M];
void add(int a, int b, int c) {
e[idx] = b, ne[idx] = h[a], w[idx] = c, h[a] = idx++;
}
//深搜
void dfs(int u, int fa) {
for (int i = h[u]; ~i; i = ne[i]) {
int j = e[i];
if (j == fa) continue; //不走回头路
dist[j] = dist[u] + w[i]; //记录j到根的距离
dfs(j, u); // 一搜到底
}
}
//带路径压缩的并查集
int find(int x) {
if (p[x] != x) p[x] = find(p[x]);
return p[x];
}
/*
1.任选一个点为根节点,从根节点开始。
2.遍历该点u所有子节点v,并标记这些子节点v已被访问过。
3.若是v还有子节点,返回2,否则下一步。
4.合并v到u上。
5.寻找与当前点u有询问关系的点v。
6.若是v已经被访问过了,则可以确认u和v的最近公共祖先为v被合并到的父亲节点a。
*/
void tarjan(int u) {
st[u] = 1; //标识以u为根的子树正在进行搜索
for (int i = h[u]; ~i; i = ne[i]) { //枚举u点的每一条出边
int j = e[i];
if (!st[j]) { //如果没有访问过,不走回头路
tarjan(j);
p[j] = u; //一定要写在tarjan(j)的后面
}
}
for (auto item : q[u]) { //与u节点相关的所有询问
int v = item.v; //对面的节点号
int qId = item.qId; // 查询的编号
if (st[v] == 2) { //如果对面的节点已经完成了搜索回溯,而u正在进行中
int x = find(v); // x的祖先,就是uv的最近公共祖先
res[qId] = dist[u] + dist[v] - dist[x] * 2; //按公式计算距离
}
}
st[u] = 2; //以u为根的子树搜索完毕
}
int main() {
cin >> n >> m;
memset(h, -1, sizeof h); //初始化邻接表
//初始化并查集
for (int i = 1; i <= n; i++) p[i] = i;
int a, b, c;
for (int i = 0; i < n - 1; i++) { // n-1条边
cin >> a >> b >> c;
add(a, b, c), add(b, a, c); //无向图
}
//这里不能用 while(m--),下面m还有用
for (int i = 0; i < m; i++) {
cin >> a >> b;
//双向建边
q[a].push_back({b, i}), q[b].push_back({a, i});
}
//以任意一个点出发,深搜整棵树,将每个节点到起点的距离记录下来
dfs(1, -1);
// tarjan离线算法
tarjan(1);
//输出结果
for (int i = 0; i < m; i++) cout << res[i] << endl;
return 0;
}
三、倍增算法实现
#include <bits/stdc++.h>
using namespace std;
const int N = 20010, M = 40010;
int n, m;
int f[N][20], depth[N], dis[N];
//邻接表
int e[M], h[N], idx, w[M], ne[M];
void add(int a, int b, int c) {
e[idx] = b, ne[idx] = h[a], w[idx] = c, h[a] = idx++;
}
void bfs() {
// 1号点是源点
depth[1] = 1;
queue<int> q;
q.push(1);
while (q.size()) {
int u = q.front();
q.pop();
for (int i = h[u]; ~i; i = ne[i]) {
int j = e[i];
if (!depth[j]) {
q.push(j);
depth[j] = depth[u] + 1;
dis[j] = dis[u] + w[i];
f[j][0] = u;
for (int k = 1; k <= 15; k++)
f[j][k] = f[f[j][k - 1]][k - 1];
}
}
}
}
//最近公共祖先
int lca(int a, int b) {
if (depth[a] < depth[b]) swap(a, b);
for (int k = 15; k >= 0; k--)
if (depth[f[a][k]] >= depth[b]) a = f[a][k];
if (a == b) return a;
for (int k = 15; k >= 0; k--)
if (f[a][k] != f[b][k])
a = f[a][k], b = f[b][k];
return f[a][0];
}
int main() {
memset(h, -1, sizeof h);
cin >> n >> m;
int a, b, c;
// n-1条边
for (int i = 0; i < n - 1; i++) {
cin >> a >> b >> c;
add(a, b, c), add(b, a, c);
}
bfs();
while (m--) {
cin >> a >> b;
int t = lca(a, b);
int ans = dis[a] + dis[b] - dis[t] * 2;
printf("%d\n", ans);
}
return 0;
}