距离
题目描述
前置知识
tarjan算法
由于tarjan发明了很多算法,特别是在图论领域,因此很多算法都直接被称为了tarjan算法,需要注意区分的是,这里的tarjan算法是用来解决LCA问题的离线算法。
所谓的在线做法和离线做法都是针对有询问这种类型的题目。
- 在线算法:每读入一个询问,都需要运行一次程序立即得到本次查询的答案。若一次查询需要 O ( l o g n ) O(logn) O(logn),则 m m m次查询需要 O ( m l o g n ) O(mlogn) O(mlogn)
- 离线算法:读入所有询问,然后运行一次程序就可以得到所有查询的答案。
tarjan算法利用并查集优越的时空复杂性,可以在 O ( n + m ) O(n+m) O(n+m)时间内解决LCA问题。
问题:为什么tarjan算法利用并查集就可以求出LCA呢?
如下图:
从图中可以看出,节点 u u u与子树 A A A中的节点,它俩的最近公共祖先是根节点 r o o t root root,而且我们发现一个很强的性质就是:子树 A A A中的每个节点的祖宗节点刚好就是 r o o t root root;节点 u u u与子树 B B B中的每个节点,它俩最近公共祖先是那个被涂色的节点,而这个节点LCA刚好就是节点子树 B B B中每个节点的祖宗节点,也就是说 v v v节点的祖宗节点是那个被涂色节点,而 从图中易知 u u u和 v v v的最近公共祖先恰好就是那个被涂色的节点。
因此,当我们遍历完 u u u时,查询有关 u u u的询问,比如 v v v,然后使用并查集找到节点 v v v的集合号,那么就是 u u u和 v v v的最近公共祖先LCA了。
下面是tarjan算法求解LCA的过程:
- 初始化集合号数组和访问数组,即
fa[i]=i
,vis[i]=0
,这里 v i s [ i ] = 0 vis[i]=0 vis[i]=0表示 i i i这个节点还没有被访问过, v i s [ i ] = 1 vis[i]=1 vis[i]=1表示 i i i这个节点已经被访问过了 - 从节点 u u u出发进行深度优先遍历,标记 v i s [ u ] = 1 vis[u]=1 vis[u]=1,深搜 u u u所有还未被访问的邻接点,在遍历的过程中更新距离,回退时更新节点的集合号(祖宗节点)
- 当节点 u u u的所有邻接点全部遍历完毕时,检查关于 u u u的所有询问,若存在一个查询 u , v u,v u,v,并且 v i s [ v ] = 1 vis[v]=1 vis[v]=1,那么就利用并查集查找节点 v v v的集合号,找到它的祖宗节点,那么这个祖宗节点就是 u u u和 v v v的最近公共祖先节点。
举个栗子:
在树中求 5 、 6 5、6 5、6的最近公共祖先,求解过程如下:
【1】初始化所有节点的集合号等于自己,访问数组都设置为还没有被访问过,即 f a [ i ] = i fa[i]=i fa[i]=i, v i s [ i ] = 0 vis[i]=0 vis[i]=0
如下图所示:
【2】随意选择一个节点进行深搜,这里选择从根节点开始深度优先遍历,在遍历的过程中,将访问过的节点都设置为 v i s [ i ] = 1 vis[i]=1 vis[i]=1
如下图所示:
【3】 8 8 8号节点的所有邻接点都已经访问完毕了,没有与 8 8 8相关的查询,则回退到 6 6 6,更新 f a [ 8 ] = 6 fa[8]=6 fa[8]=6
如下图所示:
【4】遍历 6 6 6号节点的下一个邻接点 9 9 9,标记 v i s [ 9 ] = 1 vis[9]=1 vis[9]=1, 9 9 9号节点的所有邻接点都已经访问完毕了,没有与 9 9 9相关的查询,则回退到 6 6 6,更新 f a [ 9 ] = 6 fa[9]=6 fa[9]=6
如下图所示:
【5】 6 6 6号节点的所有邻接点都已经访问完毕了,有与 6 6 6相关的查询(查询 5 5 5和 6 6 6),但是由于此时 v i s [ 5 ] ≠ 1 vis[5]\neq1 vis[5]=1,并不满足 v i s [ u ] = 1 vis[u]=1 vis[u]=1并且 v i s [ v ] = 1 vis[v]=1 vis[v]=1(这里 u = 6 , v = 5 u=6,v=5 u=6,v=5),所以啥也不做,然后回退到 4 4 4,更新 f a [ 6 ] = 4 fa[6]=4 fa[6]=4
如下图所示:
【6】 4 4 4号节点的所有邻接点都已经访问完毕了,没有与 4 4 4相关的查询,回退到 2 2 2,更新 f a [ 4 ] = 2 fa[4]=2 fa[4]=2
如下图所示:
【7】遍历 2 2 2号节点的下一个邻接点 5 5 5,标记 v i s [ 5 ] = 1 vis[5]=1 vis[5]=1,继续深度优先遍历到 7 7 7,标记 v i s [ 7 ] = 1 vis[7]=1 vis[7]=1, 7 7 7号节点的所有邻接点都已经访问完毕了,没有与 7 7 7相关的查询,回退到 5 5 5,更新 f a [ 7 ] = 5 fa[7]=5 fa[7]=5
如下图所示:
【8】 5 5 5号节点的所有邻接点都已经访问完毕了,有与 5 5 5相关的查询(查询 5 5 5和 6 6 6),并且 v i s [ 6 ] = 1 vis[6]=1 vis[6]=1,满足 v i s [ u ] = 1 vis[u]=1 vis[u]=1并且 v i s [ v ] = 1 vis[v]=1 vis[v]=1(这里 u = 5 , v = 6 u=5,v=6 u=5,v=6),那么就需要执行并查集,找到节点 v v v的集合号。回退到 2 2 2,更新 f a [ 5 ] = 2 fa[5]=2 fa[5]=2。设找到节点 v v v的祖宗为 x x x,那么 x x x就是节点 u u u和节点 v v v的最近公共祖先
如下图所示:
【9】从 6 6 6号节点开始利用并查集查找祖宗的过程如下:首先判断 6 6 6的集合号 f a [ 6 ] = 4 fa[6]=4 fa[6]=4,找到 4 4 4的集合号 f a [ 4 ] = 2 fa[4]=2 fa[4]=2,找到 2 2 2的集合号 f a [ 2 ] = 2 fa[2]=2 fa[2]=2,所以找到祖宗(集合号为其自身)后返回,在回溯过程中更新祖宗到当前节点路径上所有节点的集合号,即更新 6 、 4 6、4 6、4的父节点为 f a [ 4 ] = 2 fa[4]=2 fa[4]=2, f a [ 6 ] = 2 fa[6]=2 fa[6]=2。那么此时 f a [ 6 ] = 2 fa[6]=2 fa[6]=2,即 2 2 2号节点就是 5 5 5号节点和 6 6 6号节点的最近公共祖先
如下图所示:
总结:
在当前节点 u u u的所有邻接点都已经访问完毕时,检查与 u u u相关的查询 v v v,注意此时 v i s [ u ] vis[u] vis[u]已经是1了,如果 v i s [ v ] ≠ 1 vis[v]\neq1 vis[v]=1,那么什么也不做;如果 v i s [ v ] = 1 vis[v]=1 vis[v]=1,则利用并查集查找 v v v的祖宗节点。最终 L C A ( u , v ) = f a [ v ] LCA(u,v)=fa[v] LCA(u,v)=fa[v]。实际上, u u u的祖宗就是 u u u向上查找第1个邻接点未访问完的节点,因为它的 f a [ ] fa[] fa[]还没有更新,仍满足 f a [ i ] = i fa[i]=i fa[i]=i,它就是 v v v的祖宗。
算法实现:
并查集算法:
int find(int x)
{
if(x!=p[x])
p[x]=find(p[x]);
return p[x];
}
tarjan算法:
void tarjan(int u)
{
vis[u]=1;
for(int i=h[u];~i;i=ne[i])
{
int j=e[i];
if(vis[j])
continue;
dist[j]=dist[u]+w[i];
tarjan(j);
fa[j]=u;
}
for(int i=0;i<query[u].size();i++)
{
//query[u][i]存储的是第i次询问时,与u相关的查询号 将其赋值给v
int v=query[u][i];
//query_id[u][i]存储的是第i次查询
int id=query_id[u][i];
if(vis[v])
{
int lca=find(v);
ans[id]=dist[u]+dist[v]-2*dist[lca];
}
}
}
核心思路
这道题就直接利用上面所讲述的方法,使用tarjan+并查集算法来解决LCA的离线算法。
树上任意两点之间的距离公式: L = d i s t [ u ] + d i v t [ v ] − 2 × d i s t [ l c a ] L=dist[u]+divt[v]-2\times dist[lca] L=dist[u]+divt[v]−2×dist[lca]
d i s t [ u ] dist[u] dist[u]表示节点 u u u到整棵树的根节点的距离;
d i s t [ v ] dist[v] dist[v]表示节点 v v v到整棵树的根节点的距离;
d i s t [ l c a ] dist[lca] dist[lca],其中 l c a lca lca是节点 u u u和节点 v v v的最近公共祖先,表示节点 l c a lca lca到整棵树的根节点的距离;
代码
#include<iostream>
#include<cstring>
#include<algorithm>
#include<vector>
using namespace std;
//这是一棵树 有N个节点 则有N-1条边 由于是无向边 所以要开2倍
const int N=10010,M=2*N;
typedef pair<int,int>PII;
int n,m; //n是点数 m是询问次数
int h[N],e[M],ne[M],w[M],idx;
int dist[N]; //距离
int p[N]; //并查集的集合号数组
int res[M]; //存储询问的结果
int st[N]; //标记当前节点是否已经被访问过 st[i]=1表示i节点被访问过了
//query[u]是存放关于节点u的查询信息
//query[u].first存放的是与u相关的那个查询是啥(比如说查询5 6 那么u就是5 query[5].first就是6)
//query[u].second存放的就是这次询问是第几次
vector<PII>query[N]; //存放查询数据
//并查集查找x的祖宗节点
int find(int x)
{
if(x!=p[x])
p[x]=find(p[x]);
return p[x];
}
//建边
void add(int a,int b,int c)
{
e[idx]=b;
w[idx]=c;
ne[idx]=h[a];
h[a]=idx++;
}
//tarjan算法解决LCA的离线查询
void tarjan(int u)
{
//标记节点u已经被访问过了
st[u]=1;
//遍历节点u的所有邻接点
for(int i=h[u];~i;i=ne[i])
{
//j是u的邻接节点
int j=e[i];
//如果节点j还没有被访问过
if(!st[j])
{
//更新顶点j的距离
dist[j]=dist[u]+w[i];
//以下两句不能调换 因为调换后 经过find函数会进行路径压缩 更新了节点 导致错误
//然后对顶点j进行深度优先遍历
tarjan(j);
//回退时更新顶点j的父节点为u
p[j]=u;
}
}
//如果有查询的话
for(auto item:query[u])
{
int v=item.first; //取出与u相关的查询v
int id=item.second; //这是第几次查询
//如果节点v之前已经遍历过了
//那么满足st[u]=1并且st[v]=1
if(st[v])
{
//对节点v进行并查集操作 找到它的祖宗节点
//那么这个祖宗节点就是节点u和节点v的最近公共祖先
int lca=find(v);
//记录这次查询的结果 也就是树上节点u和节点v之间的距离
res[id]=dist[u]+dist[v]-2*dist[lca];
}
}
}
int main()
{
memset(h,-1,sizeof h);
scanf("%d%d",&n,&m);
//读入n-1个数据
for(int i=0;i<n-1;i++)
{
int a,b,c;
scanf("%d%d%d",&a,&b,&c);
//建立无向边
add(a,b,c);
add(b,a,c);
}
//读入m次询问
for(int i=0;i<m;i++)
{
int a,b;
scanf("%d%d",&a,&b);
//如果a==b 即查询自身到自身的距离 那肯定是0 那就直接使用全局res数组的0就好了
//因此当a!=b时 才记录
if(a!=b)
{
query[a].push_back({b,i});
query[b].push_back({a,i});
}
}
//初始化每个点都是独立的集合
for(int i=1;i<=n;i++)
p[i]=i;
//这里选择从根节点1进行深度优先遍历,进行tarjan算法
tarjan(1);
//输出这m次查询的结果
for(int i=0;i<m;i++)
printf("%d\n",res[i]);
return 0;
}