强连通分量(Tarjan算法)和缩点
一些定义
给定一张有向图,对于图中任意两个节点 x x x 和 y y y ,存在从 x x x 到 y y y 的路径,也存在从 y y y 到 x x x 的路径,则称该有向图为强连通图。
有向图的强连通子图被称为强连通分量SCC
显然,环一定是强连通图。因为如果在有向图中存在 x x x 到 y y y 的路径,且存在 y y y 到 x x x 的路径,那么 x , y x,y x,y 一定在同一个环中。
对于一个有向图,如果从 r o o t root root 可以到达图中所有的点,则称其为“流图”,而 r o o t root root 为流图的源点。
以 r o o t root root 为原点对流图深度优先遍历,每个点只访问一次,在过程中,所有发生递归的边 ( x , y ) (x, y) (x,y) 构成的一棵树叫做这个流图的搜索树。
在深度优先遍历时,对每个访问到的节点分别进行整数 1... n 1...n 1...n 标记,该标记被称为时间戳,记为 d f n [ x ] dfn[x] dfn[x] 。
流图中的每条有向边 ( x , y ) (x, y) (x,y) 必然是以下四种中的一种:
- 前向边,指搜索树中 x x x 是 y y y 的祖先节点
- 后向边,指搜索树中 y y y 是 x x x 的祖先节点
- 树枝边,指搜索树里的边,满足 x x x 是 y y y 的父节点。
- 其他边(好像也叫横叉边),指除了上面三种情况的边。且一定满足 d f n [ y ] < d f n [ x ] dfn[y]<dfn[x] dfn[y]<dfn[x]
Tarjan算法之强连通分量
Tarjan 算法基于有向图的深度优先遍历,能够在线性时间中求出一张有向图的各个强连通分量。
其核心思想就是考虑两点之间是否存在路径可以实现往返。
我们在后文中,都会结合搜索树(本身就是深度优先遍历的产物)来考虑,这样就可以在深度优先遍历的同时完成我们的目标。
对于各种边的分析:
对于流图,前向边作用不大,因为当前搜索树中一定存在 x x x 到 y y y 的路径。
后向边就很重要了,因为它一定可以和搜索树中 x x x 到 y y y 的路径组成环。
横叉边需要判断一下,如果这条横叉边能到达搜索树上 x x x 的祖先(显然, x x x 的祖先一定可以到达 x x x)。记这个祖先为 z z z,则这条横叉边一定能和它到 z z z 的路径, z z z 到 x x x 的路径组成环。
强连通分量的维护和栈的引入:
为了找到横叉边与后向边组成的环,我们考虑在深度优先遍历的同时维护一个栈。
当遍历到 i i i 节点时,栈里一定有以下一些点:
- 搜索树上 i i i 的所有祖先集合 j j j。若此时存在后向边 ( i , j ) (i, j) (i,j),则 ( i , j ) (i, j) (i,j) 一定与 j j j 到 i i i 的路径形成环。(后向边组成的环)
- 已经访问过的点 k k k,且满足从 k k k 出发一定能找到到 j j j 的路径。此时, i i i 到 k k k 的路径, k k k 到 j j j 的路径, j j j 到 i i i 的路径一定会形成环。(横叉边和后向边组成的环)
于是我们引入回溯值的概念。回溯值 l o w [ x ] low[x] low[x] 表示以下节点的最小时间戳:
- 该点在栈中。
- 存在一条从流图的搜索树中以 x x x 为根的子树为起点出发的有向边,以该点为终点。(也就是以它为起点能继续往下遍历到的点)
如果当前的 l o w [ x ] low[x] low[x] 表示的最小时间戳代表的点集全是2类点,则易得 l o w [ x ] = d f n [ x ] low[x]=dfn[x] low[x]=dfn[x] 时强连通分量存在,且 x x x 是此强连通分量的根(整个强连通分量中时间戳最小的节点)。
如果表示的点集存在1类点。则当前点一定属于强连通分量,且该强连通分量的根为整个强连通分量中时间戳最小的节点。
当我们判断了存在以当前点为根的强连通分量后,从栈中不断取出点,直到取出的点与当前点相等,我们就得到了整个强连通分量的信息。
整理更新回溯值的方法:
如果当前点第一次被访问,入栈,且 l o w [ x ] = d f n [ x ] low[x]=dfn[x] low[x]=dfn[x] 。
遍历从 x x x为起点的每一条边 ( x , y ) (x,y) (x,y) 。若 y y y 被访问过,且 y y y 在栈中,那么 l o w [ x ] = m i n ( l o w [ x ] , d f n [ y ] ) low[x]=min(low[x],dfn[y]) low[x]=min(low[x],dfn[y])。若y没被访问过,先递归访问y,在回溯之后更新 l o w [ x ] = m i n ( l o w [ x ] , l o w [ y ] ) low[x]=min(low[x],low[y]) low[x]=min(low[x],low[y]) 。
具体实现:
1 邻接矩阵建图
#include<bits/stdc++.h>
using namespace std;
#define MAXN 200005
#define X 114514
int n;//一共有n个点
//强连通分量相关定义
int dfn[MAXN],low[MAXN];//时间戳以及回溯值(最小时间戳)
struct node
{
int mx;//集团中点权的最大值
int sum;//缩点后的边权和
//其他信息根据具体题目具体设置
}sccc[MAXN];//储存最后求出的各个强连通分量的信息
int key[MAXN];//key[i]表示i在编号为key[i]的强连通分量中
int num=0;//时间戳
int scc=0;//强连通分量个数
stack <int> s;
int visit[MAXN];//记录是否在栈中
//
vector <int> original_mp[MAXN];//原始图
void tarjan(int x)
{
low[x]=dfn[x]=++num;
s.push(x);visit[x]=1;
for(auto y:original_mp[x])//遍历原始图
{
if(dfn[y]==0)
{
tarjan(y);
low[x]=min(low[x],low[y]);
}
else if(visit[y]==1)
{
low[x]=min(low[x],dfn[y]);
}
}
if(dfn[x]==low[x])//维护这个强连通分量
{
scc++;
int now=-1;
while(x!=now)
{
now=s.top();s.pop();
visit[now]=0;
key[now]=scc;
sccc[scc].mx=X;
sccc[scc].sum=X;
//...其他信息根据具体题目具体分析,这里只是举例维护的方法
}
}
}
void init()
{
scc=0;num=0;
for(int i=1;i<=n;i++)
{
key[i]=i;
dfn[i]=0;low[i]=0;visit[i]=0;
//sccc[i].clear(); 要不要清除,怎么清除,根据具体题目来
}
}
void solve()
{
init();
for(int i=1;i<=n;i++)
{
if(dfn[i]==0)//如果当前点没被遍历过,跑一遍Tarjan
tarjan(i);
}
}
2 邻接表建图
#include<bits/stdc++.h>
using namespace std;
#define MAXN 200005//点数
#define MAXM 500005//边数
#define X 114514
int n,m;//点和边的数量
//邻接表相关定义
struct Edge
{
int to,next;
}edge[MAXM];
int head[MAXN],tot;
//
//强连通分量相关定义
int low[MAXN],dfn[MAXN],key[MAXN],visit[MAXN];
stack <int> s;
int scc=0;//强连通分量个数
int num=0;//时间戳个数
struct node
{
int mx;//集团中点权的最大值
int sum;//缩点后的边权和
//其他信息根据具体题目具体设置
}sccc[MAXN];//储存最后求出的各个强连通分量的信息
int siz[MAXN];//各个强连通分量包含点的个数,不一定需要
//
void init()
{
tot=0;
for(int i=0;i<=m;i++) head[i]=-1;
scc=0;num=0;
for(int i=1;i<=n;i++)
{
key[i]=i;
dfn[i]=0;low[i]=0;visit[i]=0;
//sccc[i].clear(); 要不要清除,怎么清除根据具体题目来
}
}
void tarjan(int x)
{
low[x]=dfn[x]=++num;//更新时间戳
s.push(x);visit[x]=1;//入栈
for(int i=head[x];i!=-1;i=edge[i].next)//遍历原始图
{
int y=edge[i].to;
if(dfn[y]==0)
{
tarjan(y);
low[x]=min(low[x],low[y]);
}
else if(visit[y]==1)
{
low[x]=min(low[x],dfn[y]);
}
}
if(low[x]==dfn[x])//维护当前强连通分量
{
scc++;
int now=-1;
while(now!=x)
{
now=s.top();s.pop();
visit[now]=0;//出栈标记
//维护当前强连通分量内节点信息
key[now]=scc;
sccc[scc].mx=X;
sccc[scc].sum=X;
}
}
}
void solve()
{
init();
for(int i=1;i<=n;i++)
{
if(dfn[i]==0)
tarjan(i);
}
}
缩点
缩点其实就是指的把环看成一个点来进行后面的图论算法。而把环看成的这个点的点权在题目中会具体说明。
比较常见的缩点后的点权是整个环路中所有点的点权和。
如下图:
1 -> 2 -> 4 -> 5 -> 2 -> 3
显然上图存在环路,在经过缩点后,我们可以将它变成这样:
1 -> 2(val[2]+val[4]+val[5]) -> 3
具体实现:
#include<bits/stdc++.h>
using namespace std;
#define MAXN 200005//点
int n;//点
int key[MAXN];//之前定义的,表示这个点位于哪个强连通分量
vector <int> original_mp[MAXN];//原图
vector <int> mp[MAXN];//缩点后的图
void solve()
{
for(int i=1;i<=n;i++)
{
for(auto j:original_mp[i])
{
if(key[i]==key[j]) continue;
//如果这条边的两端同属于一个强连通分量,放弃连边
mp[key[i]].emplace_back(key[j]);
//将两个点对应的强连通分量的编号相连,加入新图。
//显然,单个点也属于一个强连通分量
}
}
}