对于一个有向图顶点的子集 S S S,如果在 S S S内任取两个顶点 u u u和 v v v,都能找到一条从 u u u到 v v v的路径,那么就称 S S S是强连通的。如果在强连通的顶点集合 S S S中加入其它任意顶点集合后,它都不再是强连通的,那么就称 S S S是原图的一个强连通分量( S C C : S t r o n g l y C o n n e c t e d C o m p o n e n t SCC:Strongly Connected Component SCC:StronglyConnectedComponent)。
任意有向图都可以分解成若干不相交的强连通分量,这就是强连通分量分解。把分解后的强连通分量缩成一个顶点,就得到了一个 D A G DAG DAG(有向无环图)。
虚线包围的部分构成一个强连通分量
int V; // 顶点数
vector<int> G[MAX_V]; // 图的邻接表表示
vector<int> rG[MAX_V]; // 把边反向后的图
vector<int> vs; // 后序遍历顺序的顶点列表
bool used[MAX_V]; // 访问标记
int cmp[MAX_V]; // 所属强连通分量的拓扑序
void add_edge(int from, int to) {
G[from].push_back(to);
rG[to].push_back(from);
}
void dfs(int v) {
used[v] = true;
for (int i = 0; i < G[v].size(); i++) {
if (!used[G[v][i]]) dfs(G[v][i]);
}
vs.push_back(v);
}
void rdfs(int v, int k) {
used[v] = true;
cmp[v] = k;
for (int i = 0; i < rG[v].size(); i++) {
if (!used[rG[v][i]]) rdfs(rG[v][i], k);
}
}
int scc() {
memset(used, 0, sizeof(used));
vs.clear();
for (int v = 0; v < V; v++) {
if (!used[v]) dfs(v);
}
memset(used, 0, sizeof(used));
int k = 0;
for (int i = vs.size() - 1; i >= 0; i--) {
if (!used[vs[i]]) rdfs(vs[i], k++);
}
return k;
}
算法时间复杂度:该算法只进行了两次DFS,因而总的复杂度是 O ( ∣ V ∣ + ∣ E ∣ ) O(|V|+|E|) O(∣V∣+∣E∣)。
Low(u)=Min
{
DFN(u),
Low(v),(u,v)为树枝边,u为v的父节点
DFN(v),(u,v)为指向栈中节点的后向边(非横叉边)
}
当 D F N ( u ) = L o w ( u ) DFN(u)=Low(u) DFN(u)=Low(u)时,以 u u u为根的搜索子树上所有节点是一个强连通分量。
算法流程:从节点 1 1 1开始 D F S DFS DFS,把遍历到的节点加入栈中。搜索到节点 u = 6 u=6 u=6时, D F N [ 6 ] = L O W [ 6 ] DFN[6]=LOW[6] DFN[6]=LOW[6],找到了一个强连通分量。退栈到 u = v u=v u=v为止, { 6 } \{6\} {6}为一个强连通分量。
返回节点5,发现 D F N [ 5 ] = L O W [ 5 ] DFN[5]=LOW[5] DFN[5]=LOW[5],退栈后 { 5 } \{5\} {5}为一个强连通分量。
返回节点3,继续搜索到节点4,把4加入堆栈。发现节点4向节点1有后向边,节点1还在栈中,所以 L O W [ 4 ] = 1 LOW[4]=1 LOW[4]=1。节点6已经出栈, ( 4 , 6 ) (4,6) (4,6)是横叉边,返回3,(3,4)为树枝边,所以 L O W [ 3 ] = L O W [ 4 ] = 1 LOW[3]=LOW[4]=1 LOW[3]=LOW[4]=1。
继续回到节点1,最后访问节点2。访问边 ( 2 , 4 ) (2,4) (2,4),4还在栈中,所以 L O W [ 2 ] = D F N [ 4 ] = 5 LOW[2]=DFN[4]=5 LOW[2]=DFN[4]=5。返回1后,发现 D F N [ 1 ] = L O W [ 1 ] DFN[1]=LOW[1] DFN[1]=LOW[1],把栈中节点全部取出,组成一个连通分量 { 1 , 3 , 4 , 2 } \{1,3,4,2\} {1,3,4,2}。
至此,算法结束。经过该算法,求出了图中全部的三个强连通分量 { 1 , 3 , 4 , 2 } , { 5 } , { 6 } \{1,3,4,2\},\{5\},\{6\} {1,3,4,2},{5},{6}。
算法实现
c++代码:
void tarjan(int i)
{
int j;
DFN[i]=LOW[i]=++Dindex;
instack[i]=true;
Stap[++Stop]=i;
for (edge *e=V[i];e;e=e->next)
{
j=e->t;
if (!DFN[j])
{
tarjan(j);
if (LOW[j]<LOW[i])
LOW[i]=LOW[j];
}
else if (instack[j] && DFN[j]<LOW[i])
LOW[i]=DFN[j];
}
if (DFN[i]==LOW[i])
{
Bcnt++;
do
{
j=Stap[Stop--];
instack[j]=false;
Belong[j]=Bcnt;
}
while (j!=i);
}
}
void solve()
{
int i;
Stop=Bcnt=Dindex=0;
memset(DFN,0,sizeof(DFN));
for (i=1;i<=N;i++)
if (!DFN[i])
tarjan(i);
}
算法时间复杂度:可以发现,运行 T a r j a n Tarjan Tarjan算法的过程中,每个顶点都被访问了一次,且只进出了一次堆栈,每条边也只被访问了一次,所以该算法的时间复杂度为 O ( N + M ) O(N+M) O(N+M)。
多 B B BB BB一句:求有向图的强连通分量的 T a r j a n Tarjan Tarjan算法是以其发明者 R o b e r t T a r j a n Robert\;Tarjan RobertTarjan命名的。 R o b e r t T a r j a n Robert \;Tarjan RobertTarjan还发明了求双连通分量的 T a r j a n Tarjan Tarjan算法(下面要讲),以及求最近公共祖先的离线 T a r j a n Tarjan Tarjan算法,在此对 T a r j a n Tarjan Tarjan表示崇高的敬意(羡慕嫉妒恨 )。
洛谷P2341受欢迎的牛
基本思路:根据题意,只存在一个强连通分量出度为0(如果存在2个及以上,那么他的“喜欢”就不能传递出去,它也不能被所有奶牛喜欢),找出这个强连通分量并验证它是否被其它奶牛喜欢。
c++参考代码:
( K o s a r a j u Kosaraju Kosaraju版)
#include
using namespace std;
const int maxn=1e5+7;
int n,m,ans,cnt,cmp[maxn];
bool used[maxn];
vector<int> v[maxn];
vector<int> vr[maxn];
vector<int> vs;
void add(int from,int to){
v[from].push_back(to);
vr[to].push_back(from);
}
void dfs(int x){
used[x]=true;
for(int i=0;i<v[x].size();i++){
if(!used[v[x][i]]) dfs(v[x][i]);
}
vs.push_back(x);
}
void rdfs(int x,int k){
used[x]=true;
cmp[x]=k;
for(int i=0;i<vr[x].size();i++){
if(!used[vr[x][i]]) rdfs(vr[x][i],k);
}
}
int scc(){
for(int i=1;i<=n;i++){
if(!used[i]) dfs(i);
}
memset(used,0,sizeof(used));
int k=1;
for(int i=vs.size()-1;i>=0;i--){
if(!used[vs[i]]) rdfs(vs[i],k++);
}
return k;
}
int main(){
cin>>n>>m;
while(m--){
int x,y;
cin>>x>>y;
add(x,y);
}
int t=scc();
for(int i=1;i<=n;i++){
if(cmp[i]==t-1){
cnt=i;
ans++;
}
}
memset(used,0,sizeof(used));
rdfs(cnt,0);
for(int i=1;i<=n;i++){
if(cmp[i]){
ans=0;
break;
}
}
cout<<ans;
return 0;
}
( T a r j a n Tarjan Tarjan版)
#include
using namespace std;
const int maxn=1e5+7;
int n,m,idx,cnt,num,ans,head[maxn],dfn[maxn],low[maxn],id[maxn],tot[maxn],du[maxn];
bool vis[maxn];
stack<int> s;
struct node{
int to,next;
}e[maxn];
void add(int a,int b){
e[++cnt].to=b;
e[cnt].next=head[a];
head[a]=cnt;
}
void tarjan(int x){
dfn[x]=low[x]=++num;
vis[x]=true;
s.push(x);
for(int i=head[x];i;i=e[i].next){
int t=e[i].to;
if(!dfn[t]){
tarjan(t);
low[x]=min(low[x],low[t]);
}
else if(vis[t]) low[x]=min(low[x],dfn[t]);
}
if(dfn[x]==low[x]){
int t;
idx++;
do{
t=s.top();
s.pop();
vis[t]=false;
id[t]=idx;
tot[idx]++;
}while(t!=x);
}
}
int main(){
cin>>n>>m;
while(m--){
int x,y;
cin>>x>>y;
add(x,y);
}
for(int i=1;i<=n;i++){
if(!dfn[i]) tarjan(i);
}
for(int i=1;i<=n;i++){
for(int j=head[i];j;j=e[j].next){
int t=e[j].to;
if(id[i]!=id[t]) du[id[i]]++;
}
}
for(int i=1;i<=idx;i++){
if(!du[i]){
if(ans){
cout<<0;
return 0;
}
ans=i;
}
}
cout<<tot[ans];
return 0;
}
大家也可以比较一下两者实战的差别
K o s a r a j u Kosaraju Kosaraju:
T a r j a n Tarjan Tarjan:
在无向图中才有割边和割点的定义
割点:无向连通图中,去掉一个顶点及和它相邻的所有边,图中的连通分量数增加,则该顶点称为割点。
割边(桥):无向联通图中,去掉一条边,图中的连通分量数增加,则这条边,称为桥或者割边。
割点与桥(割边)的关系:
1)有割点不一定有桥,有桥一定存在割点
2)桥一定是割点依附的边。
判断一个顶点是不是割点除了从定义,还可以从 D F S DFS DFS(深度优先遍历)的角度出发。我们先通过 D F S DFS DFS定义两个概念。
假设 D F S DFS DFS中我们从顶点 U U U访问到了顶点 V V V(此时顶点 V V V还未被访问过),那么我们称顶点 U U U为顶点 V V V的父顶点, V V V为 U U U的孩子顶点。在顶点 U U U之前被访问过的顶点,我们就称之为 U U U的祖先顶点。
显然如果顶点 U U U的所有孩子顶点可以不通过父顶点 U U U而访问到 U U U的祖先顶点,那么说明此时去掉顶点 U U U不影响图的连通性, U U U就不是割点。相反,如果顶点 U U U至少存在一个孩子顶点,必须通过父顶点 U U U才能访问到 U U U的祖先顶点,那么去掉顶点 U U U后,顶点 U U U的祖先顶点和孩子顶点就不连通了,说明 U U U是一个割点。
上图中的箭头表示 D F S DFS DFS访问的顺序(而不表示有向图),对于顶点 D D D而言, D D D的孩子顶点可以通过连通区域 1 1 1红色的边回到 D D D的祖先顶点 C C C(此时 C C C已被访问过),所以此时 D D D不是割点。
上图中的连通区域 2 2 2中的顶点,必须通过 D D D才能访问到 D D D的祖先顶点,所以说此时 D D D为割点。再次强调一遍,箭头仅仅表示 D F S DFS DFS的访问顺序,而不是表示该图是有向图。
这里我们还需要考虑一个特殊情况,就是 D F S DFS DFS的根顶点(一般情况下是编号为 0 0 0的顶点),因为根顶点没有祖先顶点。其实根顶点是不是割点也很好判断,如果从根顶点出发,一次 D F S DFS DFS就能访问到所有的顶点,那么根顶点就不是割点。反之,如果回溯到根顶点后,还有未访问过的顶点,需要在邻接顶点上再次进行 D F S DFS DFS,根顶点就是割点。
以下 d f n dfn dfn和 l o w low low数组含义同求解强连通分量中的 T a r j a n Tarjan Tarjan算法
割点:判断顶点 U U U是否为割点,用 U U U顶点的 d n f dnf dnf值和它的所有的孩子顶点的 l o w low low值进行比较,如果存在至少一个孩子顶点 V V V满足 l o w [ v ] > = d n f [ u ] low[v] >= dnf[u] low[v]>=dnf[u],就说明顶点 V V V访问顶点 U U U的祖先顶点,必须通过顶点 U U U,而不存在顶点 V V V到顶点 U U U祖先顶点的其它路径,所以顶点 U U U就是一个割点。对于没有孩子顶点的顶点,显然不会是割点。
桥(割边): l o w [ v ] > d n f [ u ] low[v] > dnf[u] low[v]>dnf[u] 就说明 V − U V-U V−U是桥
需要说明的是, T a r j a n Tarjan Tarjan算法从图的任意顶点进行 D F S DFS DFS都可以得出割点集和割边集。
【模板】割点
c++代码:
#include
using namespace std;
const int maxn=2e5+7; //边要开两倍
int n,m,cnt,num,ans,coun,head[maxn],dfn[maxn],low[maxn];
struct node{
int to,next;
}e[maxn];
bool ge[maxn];
void add(int a,int b){
e[++cnt].to=b;
e[cnt].next=head[a];
head[a]=cnt;
}
void tarjan(int root,int x){
dfn[x]=low[x]=++num;
coun=0;
for(int i=head[x];i;i=e[i].next){
int t=e[i].to;
if(!dfn[t]){
tarjan(root,t);
low[x]=min(low[x],low[t]);
if(low[t]>=dfn[x]&&root!=x){
ge[x]=true;
}
if(root==x){
coun++;
}
}
low[x]=min(low[x],dfn[t]);
}
if(root==x&&coun>=2){ //单独处理根
ge[x]=true;
}
}
int main(){
cin>>n>>m;
while(m--){
int x,y;
cin>>x>>y;
add(x,y);
add(y,x);
}
for(int i=1;i<=n;i++){
if(!dfn[i]) tarjan(i,i);
}
for(int i=1;i<=n;i++)
if(ge[i]) ans++;
printf("%d\n",ans);
for(int i=1;i<=n;i++)
if(ge[i]) printf("%d ",i);
return 0;
}