强连通分量

概念

(Graph)图 (Graph) 是一个二元组 G=(V(G),E(G))G=(V(G),E(G))。 其中 V(G)V(G) 是非空集,称为 点集(Vertex set)点集 (Vertex \ set),对于 VV 中的每个元素,我们称其为 顶点(Vertex)顶点 (Vertex)节点(Node)节点 (Node),简称 点;E(G)E(G)V(G)V(G) 各结点之间边的集合,称为 边集(Edge set)边集 (Edge \ set)。 则有一条边 eie_i 的两个端点分别为 vi1v_{i-1}viv_i

无向图

对于一张无向图 G=(V,E)G=(V,E),对于 u,vVu,v∈V,若存在一条途径使得 v0=uv_0=uvk=vv_k=v,则称 uuvv连通的(Connected)连通的 (Connected) 。由定义,任意一个顶点和自身连通,任意一条边的两个端点连通。

若无向图 G=(V,E)G=(V,E),满足其中任意两个顶点均连通,则称 GG连通图(Connected graph)连通图 (Connected \ graph) 或 环,GG 的这一性质称作 连通(Connectivity)连通 (Connectivity)

HHGG 的一个连通子图,且不存在 FF 满足 HFGH⊊F⊆GFF 为连通图,则 HHGG 的一个 连通块/连通分量(Connected component)连通块/连通分量 (Connected \ component)(极大连通子图)。

有向图

对于一张有向图 G=(V,E)G=(V,E),对于 u,vVu,v∈V,若存在一条途径使得 v0=uv_0=uvk=vv_k=v,则称 uu 可达 vv。由定义,任意一个顶点可达自身,任意一条边的起点可达终点。(无向图中的连通也可以视作双向可达。)

若有向图 G=(V,E)G=(V,E),满足其中任意两个节点两两互相可达,则称这张图是 强连通的(Strongly connected)强连通的 (Strongly \ connected) 或 环。

若一张有向图的边替换为无向边后可以得到一张连通图,则称原来这张有向图是 弱连通的(Weakly connected)弱连通的 (Weakly \ connected)

与连通分量类似,也有 弱连通分量(Weakly connected component)弱连通分量 (Weakly \ connected \ component)(极大弱连通子图)和 强连通分量(Strongly Connected component)(极大强连通子图)强连通分量 (Strongly \ Connected \ component)(极大强连通子图)

TarjanTarjan 算法

1. 什么是 TarjanTarjan

TarjanTarjan 算法是一种用于求解有向图的强连通分量的算法,时间复杂度为 O(n+m)O(n + m)。它可以求出每个强连通分量的大小、属于其的顶点和强连通分量的总数。

2. 认识 dfsdfs 生成树

dfs生成树处理强连通分量的一个有力的工具:在 dfsdfs 时,每当通过某条边 ee 访问到一个新节点 vv ,就加入这个点和这条边,最后得到的便是 dfsdfs 生成树。例如对于下面这张有向图: 在这里插入图片描述 它的 dfsdfs 生成树可能是这样(黑色实线):

在这里插入图片描述 有向图的 dfsdfs 生成树主要有 44 种边(不一定全部出现):

  1. 树枝边(tree edge树枝边(tree \ edge):黑色实线,每次搜索找到一个还没有访问过的结点的时候就形成了一条树边。
  2. 前向边(forward edge前向边(forward \ edge):灰色虚线,从某个点到它的某个子孙节点(注意不是子节点)的边。
  3. 后向边(back edge后向边(back \ edge)(也称反祖边):绿色虚线,也被叫做回边,即指向祖先结点的边。
  4. 横叉边(cross edge横叉边(cross \ edge):蓝色虚线,从某个点到一个以被访问过且既非它子孙节点、也非它祖先节点的边。

定理11:反向边和横叉边都有一个特点:起点的 dfsdfs 序必然大于终点的 dfsdfs 序。1

这可以导出一个有用的结论:对于每个强连通分量,存在一个点是其他所有点的祖先。若不然,则可以把强连通分量划成 nn 个分支,使各分支的祖先节点互相不为彼此的祖先。这些分支间不能通过树边相连,只能通过至少 nn 条横叉边相连,但这必然会违背上一段讲的性质。

在这里插入图片描述 我们把这个唯一的祖先节点称为强连通分量的根。显然,根是强连通分量中 dfsdfs 序最小的节点。

定理22:如果结点 uu 是某个强连通分量的根(也就是在在搜索树中遇到的第一个结点),那么这个强连通分量的其余结点肯定是在搜索树中以 uu 为根的子树中。2

3. TarjanTarjan 的基本思想

TarjanTarjan 算法中,每个结点 uu 维护了以下几个变量:

  • dfsn[u]dfsn[u]:深度优先搜索遍历时结点 uu 被搜索的次序(也称为 dfsdfs 序)。

  • low[u]low[u]:定义为 uu 所在子树的节点经过最多一条非树边 uvu \to v (其中 vv 必须可达 uu )能到达的节点中最小的 dfsdfs 序。 根据这样的定义,某个点 pp 是强连通分量的根,等价于 low[p]=dfsn[p]low[p]=dfsn[p]

    证明:如果 low(p)=dfsn(p)low(p)=dfsn(p) ,说明 pp 不能到达 dfsdfs 序比 pp 小的节点,或者说不存在一个强连通分量同时包含 pp 和某个 dfsdfs 序比 pp 小的节点。因此, pp 只能是某个强连通分量的根。

    我们这里必须强调 vv 可达 uu ,否则在下图中,会使 low[5]=2low[5]=2 ,但它是一个强连通分量的根。 在这里插入图片描述

枚举图中的顶点 vv,如果 dfsnv=0dfsn_{v} = 0,说明 vv 属于一个新的强连通分量,从 vv 开始搜索。

接下来,按照深度优先搜索算法搜索的次序对图中所有的结点进行搜索。在搜索过程中,对于以某个点 pp 为起点的边 pqp \to q

  • 如果 qq 未访问过,则 qqpp 所在的子树上,如果某节点 rrqq 起可以经过最多一条后向边到达,则从 pp 起也可以(先从 ppqq ,再到 rr ),于是先递归处理点 qq ,然后令 low[p]=min(low[p],low[q])low[p] = min(low[p], low[q])
  • 如果 qq 已访问过,且从 qq 可以到达 pp ,令 low[p]=min(low[p],dfsn[q])low[p] = min(low[p],dfsn[q])
  • 如果 qq 已访问过,且从 qq 不能到达 pp ,不做处理。(后两种情况都是非树边)

但是我们怎么确认一个点能不能到达另一个点呢?因为反向边和横叉边都指向 dfsdfs 序较小的节点,而前向边的存在又不影响状态转移方程,所以我们只需要确认比该点 dfsdfs 序小的哪些点能到达该点即可,这可以用一个栈动态地维护:

  • 每当搜索到新点,就令它入栈。

  • 如果发现点 pp 满足 low[p]=dfsn[p]low[p]=dfsn[p] ,则说明 pp 是某个强连通分量的根,它和栈中的子孙节点相互可达。但同时,它和栈中的子孙节点也无法到达 pp 的祖先节点,以及祖先节点其他尚未搜索的分支了,所以不断从栈顶弹出元素,直到弹出 pp (注意这样维护的栈中节点的 dfsdfs 序是单调增的),同时记录答案。

KosarajuKosaraju 算法

KosarajuKosaraju 简单描述

KosarajuKosaraju 算法依靠两次简单的 dfsdfs 实现。

它有一个重要的特点:求出的强连通分量是按拓扑序排列的。

第一次 dfsdfs,选取任意顶点作为起点,遍历所有未访问过的顶点,并在回溯之前给顶点编号,也就是后序遍历。

第二次 dfsdfs,对于反向后的图(反图),以标号最大的顶点作为起点开始 dfsdfs。这样遍历到的顶点集合就是一个强连通分量。对于所有未访问过的结点,选取标号最大的,重复上述过程。

两次 dfsdfs 结束后,强连通分量就找出来了,KosarajuKosaraju 算法的时间复杂度为 O(n+m)O(n+m)

算法证明

练习

例题

一道求强连通分量的裸题,要注意判断字典序最小。(然而数据太水)

可以用以下数据检测自己判断字典序的程序是否正确

输入

5 4
4 3 1
3 2 1
4 5 2
3 1 2

输出

2
1 3

AC codeAC \ code

TarjanTarjan

#include <bits/stdc++.h>
using namespace std;
const int maxn = 10005 * 2;

struct node
{
    int to, nxt;
} edge[maxn];

int n, m, mx = -1, cnt, cnt_node, cntn, head[maxn], dfn[maxn], low[maxn], siz[maxn], id[maxn];
bool vis[maxn];
stack<int> s;

inline void add_edge(int u, int v)
{
    edge[++cnt].to = v;
    edge[cnt].nxt = head[u];
    head[u] = cnt;
}

inline void tarjan(int u)
{
    cnt_node++;
    dfn[u] = low[u] = cnt_node;
    s.push(u);
    vis[u] = 1;
    for (int &e = head[u]; e; e = edge[e].nxt)
    {
        if (!dfn[edge[e].to])
        {
            tarjan(edge[e].to);
            low[u] = min(low[edge[e].to], low[u]);
        }
        else if (vis[edge[e].to])
            low[u] = min(low[u], dfn[edge[e].to]);
    }
    if (low[u] == dfn[u])
    {
        cntn++;
        while (1)
        {
            int now = s.top();
            s.pop();
            vis[now] = 0;
            id[now] = cntn;
            siz[cntn]++;
            if (now == u)
                break;
        }
    }
}

int main()
{
    scanf("%d%d", &n, &m);
    for (int i = 1, u, v, op; i <= m; i++)
    {
        scanf("%d%d%d", &u, &v, &op);
        add_edge(u, v);
        if (op == 2)
            add_edge(v, u);
    }
    for (int i = 1; i <= n; i++)
        if (!dfn[i])
            tarjan(i);
    for (int i = 1; i <= cntn; i++)
        mx = max(mx, siz[i]);
    cout << mx << endl;
    for (int i = 1; i <= n; i++)
        if (siz[id[i]] == mx)
            cout << i << " ";
    return 0;
}

KosarajuKosaraju

#include <bits/stdc++.h>
using namespace std;
#define maxn 10005
#define maxm 100005

struct node
{
    int to, nxt;
} edge1[maxm], edge2[maxm];

int n, m, cnt, res1, res2;
int head1[maxn], head2[maxn];
int color[maxn], siz[maxn];
int mx;
bool vis[maxn];
stack<int> s;

void add_edge1(int u, int v)
{
    edge1[++res1].to = v;
    edge1[res1].nxt = head1[u];
    head1[u] = res1;
}

void add_edge2(int u, int v)
{
    edge2[++res2].to = v;
    edge2[res2].nxt = head2[u];
    head2[u] = res2;
}

void dfs1(int u)
{
    vis[u] = true;
    for (int i = head1[u]; i; i = edge1[i].nxt)
        if (!vis[edge1[i].to])
            dfs1(edge1[i].to);
    s.push(u);
}

void dfs2(int u)
{
    color[u] = cnt;
    siz[cnt]++;
    for (int i = head2[u]; i; i = edge2[i].nxt)
        if (!color[edge2[i].to])
            dfs2(edge2[i].to);
}

void kosaraju()
{
    for (int i = 1; i <= n; i++)
        if (!vis[i])
            dfs1(i);
    while (!s.empty())
    {
        int v = s.top();
        s.pop();
        if (!color[v])
        {
            cnt++;
            dfs2(v);
        }
    }
}

int main()
{
    scanf("%d%d", &n, &m);
    for (int i = 1; i <= m; i++)
    {
        int u, v, op;
        scanf("%d%d%d", &u, &v, &op);
        add_edge1(u, v);
        add_edge2(v, u);
        if (op == 2)
        {
            add_edge1(v, u);
            add_edge2(u, v);
        }
    }
    kosaraju();
    for (int i = 1; i <= cnt; i++)
    {
        mx = max(mx, siz[i]);
    }
    cout << mx << endl;
    for (int i = 1; i <= n; i++)
        if (siz[color[i]] == mx)
        {
            int now = color[i];
            for (int j = i; j <= n; j++)
                if (color[j] == now)
                    cout << j << " ";
            break;
        }
    return 0;
}

TO BE CONTINUED\mathcal{TO \ BE \ CONTINUED}

Footnotes

  1. 证:对于反向边,由于祖先节点的 dfsdfs 序小于子孙节点,所以是显然的。对于横叉边 uvu→v ,由于 vv 既不是 uu 的祖先、也不是 uu 的子孙,所以必然存在一个不同于uuvv 的点 w=lca(u,v)w=lca(u,v)uuvv 分别位于两个分支上。 uvu→v 没有成为一条树边,这说明 vv 所在的分支一定在 uu 所在的分支之前被访问过,也就是说,分别在 uu 所在分支和 vv 所在分支上任取点 ppqq ,前者的 dfsdfs 序都一定比后者大,自然也有 dfsn(u)>dfsn(v)dfsn(u)>dfsn(v) 。得证。

  2. 反证法:假设有个结点 vv 在该强连通分量中但是不在以 uu 为根的子树中,那么 uuvv 的路径中肯定有一条离开子树的边。但是这样的边只可能是横叉边或者反祖边,然而这两条边都要求指向的结点已经被访问过了,这就和 uu 是第一个访问的结点矛盾了。得证。

posted @ 2021-06-22 21:49  蒟蒻orz  阅读(4)  评论(0编辑  收藏  举报  来源