一、强连通分量分解
1.什么是强连通分量
对于一个有向图顶点的子集
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(有向无环图)。
虚线包围的部分构成一个强连通分量
2.求解强连通分量方法
-
K
o
s
a
r
a
j
u
Kosaraju
Kosaraju算法
基本思路:通过两次简单的 D F S DFS DFS实现。
第一次 D F S DFS DFS时,选取任意顶点作为起点,遍历所有尚未访问过的顶点,并在回溯前给顶点标号( p o s t o r d e r post\;order postorder,后序遍历)。对剩余的未访问过的顶点,不断重复上述过程。完成标号后,越接近图的尾部(搜索树的叶子),顶点的标号越小。
第二次 D F S DFS DFS时,先将所有边反向,然后以标号最大的顶点为起点进行 D F S DFS DFS。这样 D F S DFS DFS所遍历的顶点集合就构成了一个强连通分量。之后,只要还有尚未访问的顶点,就从中选取标号最大的顶点不断重复上述过程。
后续遍历的例子。根据搜索顺序的不同,标号结果也可能不同
反向后的图
算法的正确性:正如前文所述,我们可以将强连通分量缩点并得到DAG。此时可以发现,标号最大的节点就属于DAG头部(搜索树的根)的强连通分量。因此,将边反向后,就不能沿边访问到这个强连通分量以外的顶点。而对于强连通分量内的其他顶点,其可达性不受边反向的影响,因此在第二次DFS时,我们可以遍历一个强连通分量里的所有顶点。
边反向后,从8、9、10号顶点只能到达其头部方向的顶点11和12。
算法实现
c++代码:
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∣)。
-
T
a
r
j
a
n
Tarjan
Tarjan算法
基本思路: T a r j a n Tarjan Tarjan算法是基于对图深度优先搜索的算法,每个强连通分量为搜索树中的一棵子树。搜索时,把当前搜索树中未处理的节点加入一个堆栈,回溯时可以判断栈顶到栈中的节点是否为一个强连通分量。
定义 D F N ( u ) DFN(u) DFN(u)为节点 u u u搜索的次序编号(时间戳), L o w ( u ) Low(u) Low(u)为 u u u或 u u u的子树能够追溯到的最早的栈中节点的次序号。由定义可以得出:
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)。
- 两种算法的比较
K o s a r a j u Kosaraju Kosaraju算法和 T a r j a n Tarjan Tarjan算法的时间复杂度都是 O ( n + m ) O(n+m) O(n+m)。与 T r a j a n Trajan Trajan算法相比, K o s a r a j u Kosaraju Kosaraju算法可能会稍微更直观一些。但是 T a r j a n Tarjan Tarjan只用对原图进行一次 D F S DFS DFS,不用建立逆图,更简洁,空间复杂度更低。在实际的测试中, T a r j a n Tarjan Tarjan算法的运行效率也比 K o s a r a j u Kosaraju Kosaraju算法高 30 % 30\% 30%左右。此外,该 T a r j a n Tarjan Tarjan算法与求无向图的双连通分量(割点、桥)的 T a r j a n Tarjan Tarjan算法也有着很深的联系。学习该 T a r j a n Tarjan Tarjan算法,也有助于深入理解求双连通分量的 T a r j a n Tarjan Tarjan算法,两者可以类比、组合理解。
多
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表示崇高的敬意(羡慕嫉妒恨 )。
3.强连通分量的应用
洛谷P2341受欢迎的牛
基本思路:根据题意,只存在一个强连通分量出度为0(如果存在2个及以上,那么他的“喜欢”就不能传递出去,它也不能被所有奶牛喜欢),找出这个强连通分量并验证它是否被其它奶牛喜欢。
c++参考代码:
(
K
o
s
a
r
a
j
u
Kosaraju
Kosaraju版)
#include <bits/stdc++.h>
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 <bits/stdc++.h>
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.定义
在无向图中才有割边和割点的定义
割点:无向连通图中,去掉一个顶点及和它相邻的所有边,图中的连通分量数增加,则该顶点称为割点。
割边(桥):无向联通图中,去掉一条边,图中的连通分量数增加,则这条边,称为桥或者割边。
割点与桥(割边)的关系:
1)有割点不一定有桥,有桥一定存在割点
2)桥一定是割点依附的边。
下图中顶点C为割点,但和C相连的边都不是桥。
2. T a r j a n Tarjan Tarjan算法求解图的割点、割边
判断一个顶点是不是割点除了从定义,还可以从 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,根顶点就是割点。
3.割点及桥的判定方法
以下
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都可以得出割点集和割边集。
4.代码实现
【模板】割点
c++代码:
#include <bits/stdc++.h>
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;
}