用Tarjan算法解决图连通性问题

1. 割点与割边

给定一张连通图,求此图的割点集合、割边集合。

  • 割点:在连通图中,删除了连通图的某个点以及与这个点相连的边后,图不再连通。
  • 割边:在连通图中,删除了连通图的某条边之后,图不再连通。

e.g 点3和点4为割点,边3-4为割边(也称作桥)。

其中:

  • 树边/父子边:图中蓝色线段所示,表示DFS过程中访问未访问节点时经过的边。
  • 回边:图中黄色线段所示,表示DFS过程中访问已访问节点时所经过的边,也称为返祖边、后向边。

显然,暴力地枚举点、边,然后DFS判断图的连通性,复杂度一定不允许。

通过观察我们发现在两种情况下,节点可以成为割点:

  • 若节点u为根节点,此时如果根节点有两颗以上的子树,那么根节点必为割点。
  • 若节点u为非根节点,如果u的某棵子树(所以,u当然也是非叶子节点,即非根非叶子节点)所有节点均没有指向u祖先节点的回边,说明删除u后,该棵子树所有节点和根节点不再连通,此时u为割点。

Tarjan算法则通过三个数组记录额外信息来解决此类连通性问题。

  • 第一种节点u为根节点的情况,只要判断u的孩子节点个数是否大于1。所以通过children[u]数组记录每个节点的dfs过程中的孩子节点个数即可。
  • 第二种节点u为非根非叶子节点,判断回边是个难题。如果我们用dfn[u]记录节点u在DFS过程中被访问的次序,low[u]记录节点u或其子树能通过回边追溯到的最早的祖先节点(即DFS次序号最小)。那么此时如果(u,v)为树边,且low[v] >= dfs[u],说明v及其子树通过回边追溯的最早祖先节点是大于等于u的,也就是说u之前的祖先们,都追溯不到,那么切除节点u,v及其子树就和u之前的祖先们“分开”了,所以u为割点。

对于割边:

  • 当(u,v)为树边,且low[v] > dfn[u],表示v节点只能通过该边(u,v)与u连通,那么(u,v)即为割边。

所以,从节点1开始DFS过程,更新三个数组并根据上面规则判断就好了。

children数组的更新简单的,dfn数组也很简单。low数组更新规则如下,对着例子里的图片理解,很容易理解:

void tarjan(unsigned u) {
    low[u] = dfn[u] = ++ord;
    bool flag = false;
    for (size_t i = 0; i < edges[u].size(); i++) {
        unsigned v = edges[u][i];
        // 树边/父子边
        if (!dfn[v]) {
            parent[v] = u;
            children[u]++;
            tarjan(v);
            low[u] = min(low[u], low[v]);
            if (low[v] >= dfn[u]) {
                flag = true;
            }
            if (low[v] > dfn[u]) {
                cutEdges.push_back(make_pair(min(u,v), max(u,v)));
            }
        } else {
            // 回边,且因为是无向图所以需要判断v是否为parent[u]
            if (v != parent[u]) {
                low[u] = min(low[u], dfn[v]);
            }
        }
    }
    // 前面情况是根节点(子树含有两个以上), 后面是非根非叶子节点
    if ((parent[u] == 0 && children[u] > 1) || (flag && parent[u])) {
        cutPoints.push_back(u);
    }
}

借助割点和割边的基础,我们可以改造基础的Tarjan算法求图的边双连通分量、点双连通分量和强连通分量。前两种属于无向图,后一种属于有向图。

2. 边的双连通分量

边的双连通分量:

对于一个无向图的子图,当删除其中任意一条边后,不改变图内点的连通性,这样的子图叫做边的双连通子图。 而当子图的边数达到最大时,叫做边的双连通分量。

图中{1,2,3},{4,5,6},{7}各为一个组,就满足要求。

计算分组的直观做法就是算出图中的所有割边,把割边去除后,剩下的每个区域就是要求的边的双连通分量。这样的做法不免也有些繁琐,先求割边,然后删除所有割边,再DFS求边的双连通分量。

其实只要在Tarjan算法DFS过程中加入一个栈,就能巧妙地解决。

每次,在DFS时将当前访问的节点加入栈,之后DFS过程中我们需要额外关注low[u] == dfn[u]的节点u。

若节点u满足low[u] == dfn[u]。对于边(parent[u], u)来说,一定有dfn[u] > dfn[parent[u]],可以推导出low[u] > dfn[parent[u]],所以(parent[u], u)一定是个割边咯。

在u之前入栈的节点和u被割边(parent[u], u)分开,所以u和u之后入栈的节点一定是同一组,全部从弹出来当成一组就可以了。所以求解边双连通分量只需要DFS求割边,利用栈保存相应分量的节点即可。

3. 点的双连通分量

点的双连通分量:

对于一个无向图的子图,当删除其中任意一个点后,不改变图内点的连通性,这样的子图叫做点的双连通子图。而当子图的边数达到最大时,叫做点的双连通分量。

图中共有三组点双连通分量:{(1,2),(2,3),(3,1)}、{(4,5),(5,6),(4,6)}、{(3,4)},两组边双连通分量:{1,2,3}、{4,5,6}。需要注意下二者的区别,点的双连通分量保存边的分组,边的双联通分量保存点的分组。

再比如,图中存在两组点双连通分量,分别是{(1,2),(2,3),(3,1)}和{(3,4),(4,5),(3,5)}。如果是边双连通分量的话,就是{1,2,3,4,5}这一组。

边双连通分量算法寻找割边,点双连通分量算法则寻找割点:每存在一个割点,将区域一分为二,点的双连通分量就增加一个,点的双连通分量个数就等于割点数量加1。

在求边的双连通分量时,我们DFS求割边并用栈保存相应分量的节点。然而,在求点的双连通分量时,我们dfs求割点时用栈保存相应分量的边就可。遇到割点,从栈中弹出所有在当前边之后入栈的边就可以了。

void tarjan(int u) {
    int children = 0;
    low[u] = dfn[u] = ++ord;
    for (int i = to[u]; i != 0; i = next_edge[i]) {
        if (visitedEdge[edge[i].id]) {
            continue;
        }
        stacks[top++] = edge[i];
        visitedEdge[edge[i].id] = true;
        if (!dfn[edge[i].v]) {
            ++children;
            parent[edge[i].v] = u;
            tarjan(edge[i].v);
            low[u] = min(low[u], low[edge[i].v]);
            if (children > 1 && parent[u] == 0) {
                popStack((edge[i]));
            }
            if (parent[u] != 0 && dfn[u] <= low[edge[i].v]) {
                popStack(edge[i]);
            }
        } else {
            low[u] = min(low[u], dfn[edge[i].v]);
        }
    }
}

4. 强连通分量

强连通分量:

对于有向图上的2个点a,b,若存在一条从a到b的路径,也存在一条从b到a的路径,那么称a,b是强连通的。对于有向图上的一个子图,若子图内任意点对(a,b)都满足强连通,则称该子图为强连通子图。非强连通图有向图的极大强连通子图,称为强连通分量。

3和6形成的子图组成一个强连通分量。

Tarjan算法利用dfn和low数组以及一个栈就可以求出有向图的所有强连通分量了。和求边的双连通分量算法一样,每次DFS访问时将当前节点加入栈。但,有向图中Low(u)表示u或u的子树能够追溯到的最早的栈中节点的次序号,由定义可得更新规则:

for (int i = 0; i < edges[u].size(); i++) {
    int v = edges[u][i];
    if (!dfn[v]) {
        tarjan(v);
        low[u] = min(low[u], low[v]);
    } else if (visited[v]) {
        // visited[v]记录v是否还在栈中,未出栈
        // 如果v不在栈内,说明v属于另一个连通分量了
        low[u] = min(low[u], dfn[v]);
    }
}

得到dfn数组和low数组的值,我们仍然去关注这类特殊节点:low[u] == dfn[u],说明节点u是强连通分量的根,将u和u之后入栈的节点全部弹出作为一组强连通分量。

References

Kai Su /
Published under (CC) BY-NC-SA in categories Algorithms  tagged with graph