[算法学习笔记] 强连通分量&割点&割边&点双&边双(图的连通性)

UPD on 2024/07/29 更新了部分内容。

本文理论内容较多,可能有些无聊,建议读者配合画图理解。

有向图

DFS生成树

在介绍下面内容前,我们先来了解一下DFS生成树,这是学习 tarjan 全家桶的基础。

下面的 DFS 生成树基于有向图。后面再介绍无向图。

一棵DFS生成树分为树边,前向边,返祖边(一说反向边),横叉边。我们来画图解释一下:

image

在这棵DFS生成树中,黑色为树边,它是在DFS遍历时获得的,红色为返祖边,顾名思义,从儿子指向父亲或祖先。蓝色为横叉边,它是在搜索的时候遇到子树中的节点的时候形成的。粉色是前向边,它是在搜索的时候遇到子树中的结点的时候形成的。

强连通分量

强连通分量,具有强连通性,极大性。强连通性顾名思义,在一个强连通分量内的节点都是可以直接或间接互相可达的。极大性指一个强连通分量内不能再加入任何一个节点,再加入任何一个节点都不满足强连通性。

强连通分量一般在有向图中讨论,若在无向图中强连通分量就退化成了连通块。

为了更好的理解强连通分量,我们画图举例:

image

在这张图中,有两个强连通分量,分别是\({1,2,3}\)\({1,5,6}\)。由此我们发现一个点可以同属于两个不同的强连通分量。

Tarjan

求强连通分量的方法有很多,参照OI-wiki,这里主要介绍最常见的Tarjan算法。

在讲解Tarjan 算法求强连通分量前,我们定义:

  • \(dfn_u\) 表示节点\(u\)被dfs遍历时的顺序编号
  • \(low_u\) 表示节点\(u\)经过若干条树边,最多经过一条返祖边能到达的\(dfn\)值最小的点

我们发现,在一个强连通分量中,有且只有一个节点的\(dfn\)值等于\(low\)值,这个节点同时也是一个强连通分量中被最先dfs遍历到的点,也就是\(dfn\)值最小的点。我们不妨将这个节点定义为一个强连通分量中的祖先。在接下来的Tarjan过程中会用到这个重要的性质。

\(low\)值可以在dfs的同时得到,对于一条边\(u-v\),如果:

  • \(v\)还未访问,则\(low\)值还不能确定,所以先dfs \(v\),再用\(low_v\)更新\(low_u\),因为\(u,v\)是一条边,\(v\)能到达的点\(u\)也一定可以。

  • \(v\)已经访问,且\(v\)能到达\(u\),我们发现如果出现这种情况若可以更新\(low_u\)\(u-v\)是返祖边,故用\(dfn_v\)更新\(low_u\)

  • \(v\)已经访问,但\(v\)不能到达\(u\),不做处理。

那么我们如何确定\(v\)能否到达\(u\)呢?

容易发现需要判定\(v\)能否到达\(u\)的时候属于上文第二种情况,即出现返祖边。我们可以在dfs的时候维护一个栈,每次dfs将当前节点压入栈,这样判定\(v\)能否到达\(u\)的时候我们只需要判定\(u\)是否在栈中就可以了。

我们现在求出了每个节点的\(low\)值,如何求强连通分量呢?

这里就用到了前面所提到的性质,在一个强连通分量中,有且仅有一个节点的\(dfn\)值等于\(low\)值,这个节点也是一个强连通分量中\(dfn\)值最小的点,我们称之为一个强连通分量的祖先。因此,在回溯的时候只需要判断该节点的\(dfn\)值是否等于\(low\)值,如果相等则证明该节点是一个强连通分量的祖先,那么在栈中祖先节点以上的点都属于该强连通分量,循环出栈,统计答案+1即可。


强连通分量一般和缩点连用,由于强连通分量的极大性,若对强连通分量进行缩点,显然变成了有向无环图。(若有环则不满足极大性,也就是说两个强连通分量还能合并成一个新的强连通分量,显然不可)。

缩完点后的强连通分量由于变成了DAG,所以可以dp,可以最短路。

这里提供一下我的板子,仅供参考。

有向图中 tarjan 求强连通分量
void Tarjan(int pos) 
{
    dfn[pos] = low[pos] = ++cnt;
    in_stack[pos] = 1;
    s.push(pos);
    for(int i=0;i<Edge[pos].size();i++)
    {
        if(!dfn[Edge[pos][i]])
        {
            Tarjan(Edge[pos][i]);
            low[pos] = min(low[pos],low[Edge[pos][i]]);
        }
        else if(in_stack[Edge[pos][i]])
        {
            low[pos] = min(low[pos],dfn[Edge[pos][i]]);
        }
    }
    if(dfn[pos] == low[pos])
    {
        fa_cnt ++;
        while(s.top() != pos)
        {
            fa_num[fa_cnt] ++;
            v[s.top()] = 0;
            fa[s.top()] = fa_cnt;
            in_stack[s.top()] = 0;
            s.pop();
        }
        s.pop();
        fa_num[fa_cnt] ++;
        fa[pos] = fa_cnt;
        in_stack[pos] = 0;
        new_map[fa_cnt][fa_cnt] = 1;
    }
}

练习题

A

先放一个板子:Luogu P3387 [模板]缩点

Description

给定一个 \(n\) 个点 \(m\) 条边有向图,每个点有一个权值,求一条路径,使路径经过的点权值之和最大。你只需要求出这个权值和。
允许多次经过一条边或者一个点,但是,重复经过的点,权值只计算一次

Analysis

首先,题目明确可以多次经过一条边或一个点,只是只进行一个权值计算,这一点非常重要。

我们想想如果重复走更优,是什么情况?没错,,如果有环的存在我们可以走完一个环,把环上的值全都算上,显然更优。其他情况多次走无意义。

如果没有环,直接dp即可,有环也很容易,我们可以Tarjan 缩点!缩点后原图就变成了一个DAG(有向无环图),然后跑dp记搜就很容易了。

题目分析比较简单,主要考察代码熟练度。

Code

tarjan 求强连通分量模板
#include <iostream>
#include <cstdio>
#include <algorithm>
#include <stack>
#include <vector>
#define N 10010
using namespace std;
int n,m;
int a[N],sum[N];
vector <int> Edge[N];
vector <int> new_Edge[N];
int dfn[N],low[N],fa[N];
int cnt = 0;
int in_stack[N];
stack <int> s;
int fa_cnt = 0;
int in[N];
int maxn = -1;
int f[N];
void Tarjan(int pos)
{
    dfn[pos] = low[pos] = ++cnt;
    in_stack[pos] = 1;
    s.push(pos);
    for(int i=0;i<Edge[pos].size();i++)
    {
        int noww = Edge[pos][i];
        if(!dfn[noww])
        {
            Tarjan(noww);
            low[pos] = min(low[pos],low[noww]);
        }
        else if(in_stack[noww])
        {
            low[pos] = min(low[pos],dfn[noww]);
        }
    }
    if(dfn[pos] == low[pos])
    {
        fa_cnt ++;
        while(s.top() != pos)
        {
            sum[fa_cnt] += a[s.top()];
            fa[s.top()] = fa_cnt;
            in_stack[s.top()] = 0;
            s.pop();
        }
        s.pop();
        sum[fa_cnt] += a[pos];
        fa[pos] = fa_cnt;
        in_stack[pos] = 0;
    }
}
int topsort(int pos)
{
    if(f[pos]) return f[pos];
    int maxnn = 0;
    for(int i=0;i<new_Edge[pos].size();i++)
    {
        maxnn = max(maxnn,f[new_Edge[pos][i]]);
    }
    f[pos] = maxnn + sum[pos];
    maxn = max(maxn,f[pos]);
    return f[pos];
}
int main()
{
    ios::sync_with_stdio(false);
    cin.tie(0);
    cout.tie(0);
    cin>>n>>m;
    for(int i=1;i<=n;i++) cin>>a[i];
    for(int i=1;i<=m;i++)
    {
        int u,v;
        cin>>u>>v;
        Edge[u].push_back(v);
    }
    for(int i=1;i<=n;i++) 
    {
        if(!dfn[i]) Tarjan(i);
    }
   for(int i=1;i<=n;i++)
    {
       for(int j=0;j<Edge[i].size();j++)
       {
            if(fa[i] != fa[Edge[i][j]]) 
            {
                bool can_push = true;
                for(int k=0;k<new_Edge[fa[i]].size();k++)
                {
                    if(new_Edge[fa[i]][k] == fa[Edge[i][j]]) 
                    {
                        can_push = false;
                        break;
                    }
                }
                if(can_push)
                {
                    new_Edge[fa[i]].push_back(fa[Edge[i][j]]);
                    in[fa[Edge[i][j]]] ++;
                }
            }
       }
    }
    for(int i=1;i<=fa_cnt;i++)
    {
        if(!f[i]) 
        {
            topsort(i);
        }
    }
    cout<<maxn<<endl;
    return 0;
}

笔者实现较复杂。当然有更简单的实现方式。仅供参考。

B

Link

上述所有内容都基于有向图。下文我们将对无向图进行探讨。

无向图

dfs 生成树

对于一个连通无向图 \(G=(V,E)\),我们通过 dfs 算法得到的一棵生成树称作这个连通无向图的 dfs 树。

和有向图同理,我们将生成树上的边称作树边,但非生成树上的边统称为环边。

在无向图中,我们不再有“返祖边,横插边,前向边”之类说法,显然无向图中只存在直上直下的边,我们统一分为树边和环边。

下文令 \(dfn_x\) 表示 \(x\)\(dfn_x\) 个被遍历到的点,即 dfs 序。

\(low_x\) 表示 \(x\) 及其子树通过一条环边能回到的 \(dfn\) 最小的点。

上述定义和 有向图 是类似的。

割点&割边

割点:如果将无向图中的某个点 \(u\) (以及其连接的边)删除,得到一个非连通图,就称点 \(u\) 为割点。

割边:如果将无向图中的一条边删除,得到一个非连通图,就称该边为割边。

我们接下来考虑边 \((u,v)\) 何时为割边。

定理:

  • \((x,y)\) 是环边,则该边一定不是割边。

  • \((x,y)\) 是树边,设在 dfs 树中, \(x\)\(y\) 的父亲,则该边是割边当且仅当 \(low_y>dfn_x\)

对于定理 1,若 \((x,y)\) 为环边,删去该边后我们可以通过树边实现连通。

对于定理 2,当 \(low_y<dfn_x\) 时,意味着 \(y\) 向上无法跳过 \(x\),即 \(y\) 无法和 \(x\) 上层联系,进一步的, \(y\) 及下面的子树都无法到达 \(x\) 以上的节点,故为割边。

同理,我们也可以得到点 \(x\) 是割点的条件。

下文 ”树“ 若无特殊说明,均为 dfs 生成树。

  • 若点 \(x\) 是根节点,若其有两个儿子,则为割点。

  • 若点 \(x\) 非根节点,\(x\) 为割点当且仅当存在一个儿子 \(y\) 使得 \(low_y > dfn_x\)

对于定理 1,显然在树中删去一个拥有多个儿子的点,都会导致剩下的点不联通。

对于定理 2,和割边是类似的。

点双连通分量 & 边双连通分量

在割点和割边的基础上,我们再给出点双连通分量和边双连通分量的概念。

  • 点双连通图:若一个无向连通图不存在割点,则该图称为点双连通图。

  • 边双连通图:若一个无向连通图不存在割边,则该图称为边双连通图。

前文提到,“分量” 意味着极大,即点双连通分量和边双连通分量分别为 极大的点双连通图/边双连通图。

常见的,边双连通图的等价定义为 图中任意两点不存在边不相交的路径,点双连通图等价定义为 图中任意两点不存在边不相交的路径。

通过上述定义,不难得出 割边是分割两个边双连通分量的 边,但割点是不同点双之间的交集点。

即每条边最多属于一个边双,但每个点可以属于多个点双。(因此边双缩点后可转化为树,但点双缩点后转化为 圆方树。)

img

(引用一张信友队讲义截图。)

应用 tarjan 求解点双/边双 时,我们首先回顾如下结论。

割边是分割两个边双连通分量的边,割点是不同点双之间的交集点。

求解边双的时候,我们还是应用一个栈,如果遇到一个割边,我们就弹栈。取出边双中的所有点。

求解点双同理,如果遇到一个割点,我们就取出点双中的所有点。

练习

CF1000E We need more booses

Description

Source

给定一个 \(n\) 个点 \(m\) 条边的无向图,保证图连通。找到两个点\(s,t\),使得\(s\)\(t\)必须经过的边最多(一条边无论走哪条路线都经过ta,这条边就是必须经过的边),\(2<=n<=3*10^5,1<=m<=3*10^5\)

Analysis

“必须经过” 即为割边。

因此,我们边双连通分量缩点后求直径即可。

P2341 [USACO03FALL / HAOI2006] 受欢迎的牛 G

Source

Description

每头奶牛都梦想成为牛棚里的明星。被所有奶牛喜欢的奶牛就是一头明星奶牛。所有奶牛都是自恋狂,每头奶牛总是喜欢自己的。奶牛之间的“喜欢”是可以传递的——如果 \(A\) 喜欢 \(B\)\(B\) 喜欢 \(C\),那么 \(A\) 也喜欢 \(C\)。牛栏里共有 \(N\) 头奶牛,给定一些奶牛之间的爱慕关系,请你算出有多少头奶牛可以当明星。

Analysis

有趣。

注意到 “喜欢” 关系满足传递性,且为单向传递,考虑点双连通分量缩点。缩完点后,每个点集内的点都互相喜欢。

缩完点后,图会变成一个树。由于点双连通分量的极大性,此时的树不满足互相喜欢的性质。因此,若点集 \(A\) 喜欢 \(B\),则点集 \(B\) 不喜欢 \(A\)

考虑满足何条件的集合是被所有点喜欢的。

显然,出度为 \(0\) 的点符合条件。因为若点集 \(A\) 出度不为 \(0\),即它喜欢别的点,那么它就不可能被所有点喜欢,原因显然。

这还有一个问题,若有两个点及以上的出度为 \(0\) 呢?那就无解。

P2860 [USACO06JAN] Redundant Paths G

Description

给定无向图 \(G=(V,E)\),求至少添加多少边,使得无向图 \(G\) 边双连通。

Analysis

在图上考虑比较复杂,我们先考虑在树上的操作。

我们重定义 “叶子”为度数为 \(1\) 的点。该定义在 \(\text{tarjan}\)
算法中非常常见。

事实上,边双连通分量满足性质:每个点的度数至少为 \(2\)。这很显然,若一个点的度数只为 \(1\),则删除该边后,该点不连通。即该边是割边。

因此,我们统计树上有多少个点的度数为 \(1\),然后两两连边即可。

如何连边是最优的呢?

我们将所有的叶子从左到右编号,最大的连最小的,以此类推,这样操作完后整张图是连通的。

设叶子数量为 \(m\),则至少连边 \(\lceil \dfrac{m}{2}\rceil\) 条(多出来的叶子随便连一个即可。)

CF487E Tourists

Source

Description

有一个无向图 \(G=(V,E)\),请支持如下两种操作。

  • C a w 表示点 \(a\) 点权修改为 \(w\)

  • A a b 表示查询从 \(a\) 走到 \(b\),所有路径中的最小值最小。

需要注意,一个路径不得重复经过一个点。

Analysis

路径不可以重复经过一个点,考虑点双缩点。

点双缩点后变成了圆方树。圆方树上方点的儿子都可以互相到达且不会重复经过一个点。

因此,对于每个方点,我们维护它的儿子权值最小值,路径上的每个圆点是割点,我们一定要算贡献。这个树链剖分即可维护。

但这样有个弊端,对于每个圆点更新,我们可能要更新好几个方点的贡献,因为每个点可能属于多个点双。

事实上,我们只需要更新每个圆点父亲方点的权值即可。因为路径上的权值我们都算过贡献了。不必重复操作。

这样复杂度就是对的。

笔者还没写。太菜了。

posted @ 2023-07-30 22:32  SXqwq  阅读(141)  评论(0编辑  收藏  举报