强连通分量、割点、割边

一、强连通分量分解

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
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 VU是桥

需要说明的是, 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;
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值