有向圖的強連通分量的分解 總結 poj2186例題舉例
常用兩種演算法: 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;
}