常用两种算法: tarjan和korasaju算法。
学习资料:
https://www.byvoid.com/blog/scc-tarjan/
https://zh.wikipedia.org/wiki/Tarjan%E7%AE%97%E6%B3%95
挑战P320
定义:
如果一个有向图S,在图中任取两个点u,v,都存在一条u到v的路径,那么称这个图是强连通的。而有向图的一个强连通分量就是该图的一个极大强连通子图。任意的有向图都可以分解成若干个不相交的强连通分量,就是强连通分量的分解。将分解后的强连通分量缩成一个点,就可以得到一个DAG(有向无环图)
korasaju算法:
通过简单的两次dfs实现,一次对原图进行dfs,一次对反向图进行dfs。
首先选取任意节点作为起点,对原图进行dfs,遍历所有没有访问过的节点,并且在回溯前对顶点进行标号,(就是后序遍历)。对剩下的没有访问过的节点不断重复这个过程(因为这个有向图也有可能本身是不连通的)
完成第一次dfs后,可以知道越靠近图的尾部(也就是搜索树的叶子),顶点的编号就越小,这是由于后序遍历的特性。
进行第二次dfs,对反向图进行dfs,这时候选择标号最大的顶点作为起点进行dfs,这样dfs所遍历的顶点的集合就构成了一个强连通分量。同样,对于还有没有访问过的节点,再次将此时编号最大的顶点不断重复上述过程。
可以简单的理解这样做的理由。从编号大的开始进行搜索,每一个节点都属于一个强连通分量,将边反向过后,就不可以沿着边访问到这个强连通分量之外的顶点了,而对于强连通分量的其他顶点可达性不会受到影响。
void add_edge(int u,int v)
{
edge[u].push_back(v);
redge[v].push_back(u);
}
void dfs(int u)
{
used[u] = true;
for(int i = 0;i < edge[u].size();i++)
{
int v = edge[u][i];
if(!used[v]) dfs(v);
}
vs.push_back(u);
}
void rdfs(int u,int k)
{
used[u] = true;
topo[u] = k;
for(int i = 0;i < redge[u].size();i++)
{
int v = redge[u][i];
if(!used[v]) rdfs(v,k);
}
}
int scc()
{
for(int i = 1;i <= n;i++)
{
if(!used[i]) dfs(i);
}
memset(used,false,sizeof(used));
int k = 0;
for(int i = vs.size() - 1;i >= 0;i--)
{
int u = vs[i];
if(!used[u]) rdfs(u,k++);
}
return k;
}
tarjan算法:
tarjan算法也是基于dfs的,但是只需要对原图进行一次dfs就可以了。每个强连通分量都是dfs搜索树中的一颗子树。并且在dfs的时候,把当前搜索树中的还没有处理过的节点压入一个栈中,在回溯的时候就可以判断栈顶的节点到栈中的节点是否存在一个强连通分量。
定义DFN(u)为节点u搜索的次序标号,Low(u)为u或u的子树能够追溯到的最早的栈中节点的次序标号。
Low(u)=Min
{
DFN(u),
Low(v),(u,v)为树枝边,u为v的父节点(即v没有被访问过,不在栈中)
DFN(v),(u,v)为指向栈中节点的后向边(非横叉边)
}
伪代码:
这里引入一个强连通分量的根,只针对于这个算法,表示这个强连通分量中最早被访问到的节点。
当DFN(u)=Low(u)时,以u为根的搜索子树上所有节点是一个强连通分量。
tarjan(u)
{
DFN[u]=Low[u]=++Index // 为节点u设定次序编号和Low初值
Stack.push(u) // 将节点u压入栈中
for each (u, v) in E // 枚举每一条边
if (v is not visted) // 如果节点v未被访问过
tarjan(v) // 继续向下找
Low[u] = min(Low[u], Low[v])
else if (v in S) // 如果节点v还在栈内
Low[u] = min(Low[u], DFN[v])
if (DFN[u] == Low[u]) // 如果节点u是强连通分量的根
repeat
v = S.pop // 将v退栈,为该强连通分量中一个顶点
print v
until (u== v)
}
tarjan算法 cpp
void add_edge(int u,int v)
{
edge[u].push_back(v);
}
void tarjan(int u)
{
instack[u] = true;
low[u] = DFN[u] = ++tot;
ss.push(u); //将刚访问的节点入栈
for(int i = 0;i < edge[u].size();i++)
{
int v = edge[u][i];
if(!DFN[v]) //没有被访问过
{//不能写成不在栈中,因为在栈中的一定是访问过的,但是不在栈中的也可能访问过,只是已经划入之前的强连通分量
tarjan(v);
low[u] = min(low[u],low[v]);
}
else if(instack[v]) // 指向栈中节点的后向边
{
low[u] = min(low[u],DFN[v]);
}
}
if(DFN[u] == low[u]) // u 为一个强连通分量的根
{
scc++;//强连通分量的编号
int v;
do
{
v = ss.top();
ss.pop();
belong[v] = scc; //标记每个节点所在的强连通分量
num[scc]++; //每个强连通分量的节点个数
}while(u != v);
}
}
poj2186
一个有用的定理:
DAG中唯一出度为0的点一定可以由任何点出发均可达。(无环,从任一点出发必然往前走,一定终止于一个出度为0的点)
转换题目意思:
求出能被所有其他点可达的点的个数。
可以求出所有的强连通分量,进行缩点。形成一个DAG图。
1、如果DAG上有唯一一个出度为0的点,这个点就可以被其他所有点到达。也就是说这点代表的联通分量上的原图中的所有点都是能被原图中所有点到达的。那么这个强连通分量中点的个数就是答案。
2、如果不唯一,那么这些点直接是相互不可达的。无解。
tarjan 做法
#include <cstdio>
#include <iostream>
#include <cstring>
#include <algorithm>
#include <cmath>
#include <vector>
#include <stack>
using namespace std;
#define M 1000009
vector<int> edge[M];
stack<int> ss;
int n,m,tot,scc;
int low[M],DFN[M],belong[M];
int out[M],num[M];
bool instack[M];
void init()
{
for(int i = 0;i < n;i++)
{
edge[i].clear();
}
tot = 0;
scc = 0;
while(!ss.empty()) ss.pop();
memset(low,0,sizeof(low));
memset(DFN,0,sizeof(DFN));
memset(out,0,sizeof(out));
memset(belong,0,sizeof(belong));
}
void add_edge(int u,int v)
{
edge[u].push_back(v);
}
void tarjan(int u)
{
instack[u] = true;
low[u] = DFN[u] = ++tot;
ss.push(u); //将刚访问的节点入栈
for(int i = 0;i < edge[u].size();i++)
{
int v = edge[u][i];
if(!DFN[v]) //没有被访问过
{ // (不能写成不在栈中,因为在栈中的一定是访问过的,但是不在栈中的也可能访问过,只是已经划入之前的强连通分量了)
tarjan(v);
low[u] = min(low[u],low[v]);
}
else if(instack[v]) // 指向栈中节点的后向边
{
low[u] = min(low[u],DFN[v]);
}
}
if(DFN[u] == low[u]) // u 为一个强连通分量的根
{
scc++;//强连通分量的编号
int v;
do
{
v = ss.top();
ss.pop();
belong[v] = scc; //标记每个节点所在的强连通分量
num[scc]++; //每个强连通分量的节点个数
}while(u != v);
}
}
int main()
{
while(scanf("%d%d",&n,&m) == 2)
{
init();
for(int i = 0;i < m;i++)
{
int u,v;
scanf("%d%d",&u,&v);
add_edge(u-1,v-1);
}
for(int i = 0;i < n;i++)
{
if(!DFN[i]) tarjan(i);
}
for(int i = 0;i < n;i++)
{
for(int j = 0;j < edge[i].size();j++)
{
int v = edge[i][j];
if(belong[i] != belong[v]) out[belong[i]]++;
}
}
int sum = 0;
int ans = 0;
for(int i = 1;i <= scc;i++)
{
if(!out[i])
{
sum++;
ans = num[i];
}
}
if(sum == 1) printf("%d\n",ans);
else printf("0\n");
}
return 0;
}
还有一种做法就是挑战上的,利用korasaju算法的第二次dfs可以求出强连通分量的拓扑序,拓扑序最后的强连通分量就是出度为0的,但是不一定唯一,所以这时候检查一下这个强连通分量是否可以从所有顶点可达。
拓扑序:
这里涉及到拓扑序,当做复习一下,可以利用dfs后序遍历得到序列,之后将这个序列反过来就可以了。因为后序遍历的先得到的是出度为0的点,而在拓扑序中是先出现入度为零的点。所以也就有了bfs的那种方法,先找入度为零的点压入队列,删掉相邻的边再找入度为零的点压入队列。这样也是可以直接得到拓扑序。
#include <iostream>
#include <cstdio>
#include <cstring>
#include <algorithm>
#include <cmath>
#include <vector>
using namespace std;
#define M 100009
vector<int> edge[M],redge[M];
int topo[M];//所属联通分量的拓扑序
bool used[M];//访问标记
vector<int> vs; //后序遍历的顶点列表
int n,m;
void init()
{
for(int i = 0;i <= n;i++)
{
edge[i].clear();
redge[i].clear();
vs.clear();
}
memset(used,false,sizeof(used));
}
void add_edge(int u,int v)
{
edge[u].push_back(v);
redge[v].push_back(u);
}
void dfs(int u)
{
used[u] = true;
for(int i = 0;i < edge[u].size();i++)
{
int v = edge[u][i];
if(!used[v]) dfs(v);
}
vs.push_back(u);
}
void rdfs(int u,int k)
{
used[u] = true;
topo[u] = k; //标记拓扑序的编号,因为rdfs()的顺序是之前dfs()出来的后序遍历的序列
for(int i = 0;i < redge[u].size();i++)
{
int v = redge[u][i];
if(!used[v]) rdfs(v,k);
}
}
int scc()
{
for(int i = 1;i <= n;i++)
{
if(!used[i]) dfs(i);
}
memset(used,false,sizeof(used));
int k = 0;
for(int i = vs.size() - 1;i >= 0;i--)
{
int u = vs[i];
if(!used[u]) rdfs(u,k++);
}
return k;
}
int main()
{
while(scanf("%d %d",&n,&m) == 2)
{
init();
for(int i = 0;i < m;i++)
{
int u,v;
scanf("%d %d",&u,&v);
add_edge(u,v);
}
int k = scc();
int u;
int ans = 0;
for(int i = 1;i <= n;i++)
{
if(topo[i] == k-1) //最后一个拓扑序
{
u = i;
ans++;
}
}
memset(used,false,sizeof(used));
rdfs(u,0); //再跑一边dfs,利于之后判断可达
for(int i = 1;i <= n;i++)
{
if(!used[i]) //判断是否从所有顶点可达
{
ans = 0;
break;
}
}
printf("%d\n",ans);
}
return 0;
}