图论笔记:直径,LCA 和 Tarjan SCC
一些简单的图论知识点。
树的直径
简单路径指图上任何一个节点访问至多一次的路径,而树的直径则是指一棵树上最长的简单路径。
图中所有最短路径的最大值即为「直径」,可以用两次
\texttt{DFS} 或者树形\texttt{DP} 的方法在O(n) 时间求出树的直径。
首先,我们需要对任意一个被选定的结点
首先证明
接下来我们所需要做的便是证明上述定理。
假设存在一条直径
设第一次
如果不存在交点,事情就更难办一些。定义
根据我们进行
综上所述,这两次
给定一道例题 POJ2631 Roads in the North/UVA10308 Roads in the North,翻译化简题面如下:
有一些北方的村庄,从一个村庄到另一个村庄只有一条路线,不会经过其他村庄两次。给出的是一个由若干村庄组成的区域,这些村庄之间的道路使任何村庄都可以通过公路到达其他任何村庄。你的任务是找出该地区两个最偏远村庄之间的公路距离。
实际上就是给定一个连通图求最长路径,我们简单地使用两次
#include<bits/stdc++.h>
#define N 20005
using namespace std;
int n,m,cnt;
struct Edge{
int v,w,nxt;
}e[N];
int h[N],ans,dis[N],pos;
void add(int u,int v,int w){
e[++cnt].v=v;e[cnt].w=w;
e[cnt].nxt=h[u];h[u]=cnt;
}void DFS(int x,int fa){
if(dis[x]>ans){
ans=dis[x];
pos=x;
}for(int i=h[x];i;i=e[i].nxt){
int v=e[i].v;
if(v==fa)continue;
dis[v]=dis[x]+e[i].w;
DFS(v,x);
}
}void Find(int x){
ans=dis[x]=0;
DFS(x,0);
}signed main(){
int u,v,w;
while(cin>>u>>v>>w){
add(u,v,w);
add(v,u,w);
}Find(1);Find(pos);
cout<<ans<<endl;
return 0;
}
与最长路不同的是,这题并没有指定从
我们对于一个根进行
首先我们设
给出代码:
void dfs(int u,int fa){
for(int i=head[u];i;i=e[i].nxt){
int v=e[i].v;
if(v==fa)continue;
dfs(v,u);
ans=max(ans,f[u]+f[v]+e[i].w);
f[u]=max(f[u],f[v]+e[i].w);
}
}
LCA 最近公共祖先
最近公共祖先简称 LCA,有 Least/Lowest Common Ancestor 两种说法,个人认为 Lowest 的表意是较之更为妥帖的,这还有待进一步讨论。顾名思义,最近公共祖先就是两个节点
- 单个节点的 LCA 就是它本身。
- 两个节点
u,v 的 LCA 如果是u ,那么u 是v 的祖先,反之亦然。 - 若
u,v 分别不是对方祖先,则他们分别处于两者 LCA 的两棵子树中。 - 一个点集
S 的 LCA 在前序遍历中出现在点集所有元素前。 - 一个点集
S 的 LCA 在后序遍历中出现在点集所有元素后。 - 点集
s_1 和点集s_2 并集的 LCA 等于两者分别 LCA 的 LCA。
上一条性质换言之就是
- 两点的 LCA 必定在两点的最短路径上。
- 树上两点距离等于两者深度之和减去二倍 LCA 深度。
最后一条很容易理解。我们在计算路径的时候显然不希望计算出从根节点到 LCA 的这一段路,而我们在计算两个节点深度的时候又恰巧将这一段路计算了两遍,所以我们能够写出
一个极端朴素的方法是每次找深度比较大的那个点,让它向上跳,等到他们相遇时就会处于 LCA 的位置上。一个类似的想法是先向上调整深度较大的点,令这两个结点深度相同
我们每一次需要让节点向上移动——这次移动的距离应该怎么确定?我们用二的整次幂来进行跳跃。但是我们如果使用
由于我们在接下来的过程中要频繁用到
我们按照这样实现可以求出来每一个数对应的
这个看似套娃的操作实际上让我们可以顺利在树上进行 DFS 预处理,因为我们对于每一次 DFS 找到的节点
void dfs(int u,int fa){
//记录各个点的深度以及他们 2^i 级的祖先
fa[u][0]=fa;dep[u]=dep[fa]+1;
for(int i=1;i<=lg[dep[u]];i++){
//对深度求对数,方便后文的 2^i 级祖先
fa[u][i]=fa[fa[u][i-1]][i-1];
}for(int i=h[u];i;i=e[i].nxt)
if(e[i].v!=fa)dfs(e[i].v,u);
}
接下来我们首先着手处理
先让
- 取出当前跳跃下标
k=\lg(dep_u) ,注意在代码实现中你得减一(因为数组中存储的是\lg(i)+1 )。 - 如果两者当前跳到的那个祖先不相等(那这个节点就还不是 LCA)就进行跳跃。
- 将
u,v 转移到f(u,k) 和f(v,k) 上,注意这里指他们的2^k 级祖先。
由于之前所说的,能够跳两步解决的一定能在上一次跳一步解决,因此我们对于每一个指数都只需要跳一遍皆可。这样就可以用简单的循环实现这种倍增了,与此同时为什么我们不能判断
因此我们可以实现这样的一个 LCA 代码:
int LCA(int x,int y){
if(dep[x]<dep[y])swap(x,y);//保证 x 的深度大于 y 的深度
while(dep[x]>dep[y])x=f[x][lg[dep[x]-dep[y]]-1];
if(x==y)return x;//如果跳到了是同一个节点 ,这就是他们的 LCA
for(int k=lg[dep[x]]-1;k>=0;--k)//两人一起跳
if(f[x][k]!=f[y][k])x=f[x][k],y=f[y][k];
return f[x][0];
}
在这之后我们就可以写出全文代码了:
#include<bits/stdc++.h>
#define N 500005
using namespace std;
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;
}int dep[N],f[N][22],lg[N],h[N],cnt,n,m,s;
struct node{int v,nxt;}e[N<<1];
void add(int u,int v){
e[++cnt].v=v;
e[cnt].nxt=h[u];
h[u]=cnt;
}void dfs(int u,int fa){
//记录各个点的深度以及他们 2^i 级的祖先
f[u][0]=fa;dep[u]=dep[fa]+1;
for(int i=1;i<=lg[dep[u]];i++)f[u][i]=f[f[u][i-1]][i-1];
for(int i=h[u];i;i=e[i].nxt)if(e[i].v!=fa)dfs(e[i].v,u);
}int LCA(int x,int y){
if(dep[x]<dep[y])swap(x,y);//保证 x 的深度大于 y 的深度
while(dep[x]>dep[y])x=f[x][lg[dep[x]-dep[y]]-1];
if(x==y)return x;//如果跳到了是同一个节点 ,这就是他们的 LCA
for(int k=lg[dep[x]]-1;k>=0;--k)//两人一起跳
if(f[x][k]!=f[y][k])x=f[x][k],y=f[y][k];
return f[x][0];
}signed main(){
n=read();m=read();s=read();
for(int i=1;i<n;i++){
int x=read(),y=read();
add(x,y);add(y,x);
}for(int i=1;i<=n;++i)//预处理 log_2(i)+1 的值
lg[i]=lg[i-1]+(1<<lg[i-1]==i);
dfs(s,0);
for(int i=1;i<=m;++i)printf("%lld\n",LCA(read(),read()));
return 0;
}
注意一些普通图论题应该注意的点,比如无向边应该开两倍数组之类的。这份代码可以通过模板题。
强连通分量
如果一个有向图任意两点
给出:
例题:B3609 [图论与代数结构 701] 强连通分量
前置知识 DFS 序:给定一个图,从随意一个节点开始 DFS 会形成一棵树。
图片来自 OI-Wiki。我们发现在正常搜索的过程中,会形成一些在图中绿色的边,这些边构成了一棵树——因此我们称之为树边。显然这个图在搜索过程中会形成环,因此我们要考虑另外三种边。
如果我们访问到一个已经访问过的节点,此时我们考虑三种情况:
- 该节点并不是当前节点及其祖先生成的:红色横叉边
- 该节点在当前节点子树中:蓝色的前向边
- 该节点是当前节点的祖先:黄色的回边(又名返祖边)
显然,如果一个节点
考虑使用 Tarjan 算法,首先定义一些变量:
以下结点之中
\text{dfn}(u) 最小的值:以u 为根节点所在子树的所有结点,和从其子树可以由一条不在搜索树中的边连向的结点。
同时,我们在代码实现中用
一个非常显然的事实是:节点
对于一个从
- 如果
v 尚未访问过,那么进一步深搜,在回溯过程中用\text{low}(v) 更新\text{low}(u) 。 - 如果
v 被访问过且在栈中,那就可以用\text{dfn}(v) 去更新\text{low}(u) 了(考虑定义)。 - 如果
v 被访问过且不在栈中,那忽略(已经确定的节点无需考虑)。
给出模板题代码实现:
#include<bits/stdc++.h>
#define N 10005
#define M 100005
using namespace std;
const int MAXN=10005;
const int MAXM=100005;
struct node{
int v,nxt;
}e[M<<1];
int h[N],cnt,low[N],dfn[N],tot;
int in[N],f[N],n,m,ans;
bool is[N];
stack<int>s;
vector<int>vec[N];
void add(int u,int v){
e[++cnt].v=v,e[cnt].nxt=h[u],h[u]=cnt;
}void dfs(int x){
dfn[x]=low[x]=++tot;
s.push(x);is[x]=1;
for(int i=h[x];i;i=e[i].nxt){
int v=e[i].v;
if(!dfn[v])dfs(v),low[x]=min(low[x],low[v]);
else if(is[v])low[x]=min(low[x],dfn[v]);
}if(dfn[x]==low[x]){
ans++;vec[ans].push_back(x);
while(s.top()!=x){
in[s.top()]=ans;
is[s.top()]=0;
vec[ans].push_back(s.top());
s.pop();
}s.pop();is[x]=0;in[x]=ans;
}
}signed main(){
cin>>n>>m;
for(int i=1;i<=m;++i){
int u,v;cin>>u>>v;add(u,v);
}for(int i=1;i<=n;++i)if(!dfn[i])dfs(i);
cout<<ans<<'\n';
for(int i=1;i<=ans;++i)sort(vec[i].begin(),vec[i].end());
for(int i=1;i<=n;++i){
if(f[in[i]])continue;
f[in[i]]=1;
for(int j=0;j<vec[in[i]].size();++j)
cout<<vec[in[i]][j]<<' ';
puts("");
}
return 0;
}