【学习笔记】图的连通性

Page Views Count

无向图连通性#

一些定义#

若无向图 G 中任意不同两点 (u,v) 都有路径可达,称 G 为一个 连通图,若 G 的一个子集 H 满足不存在连通子图 G 满足 HG,则 H 为一个 连通块/连通分量,也即极大连通子图。

若无向图 G 中存在一个点集 V 满足删去这些点以及连边后,原图的连通分量个数会增加,称点集 V 为一个 点割集,特别地,当 V 中只含一个节点 u 时,称节点 u割点

若无向图 G 中存在一个边集 E 满足删去这些边后,原图的连通分量个数会增加,称边集 E 为一个 边割集,特别地,当 E 中只含一条边 (u,v) 时,称边 (u,v)割边/桥

若一个连通图中不含割点,则该连通图 点双连通;若一个连通图中不含割边,则该连通图 边双连通。类比连通分量的定义,同样有 点双连通分量边双连通分量

割点#

使用 Tarjan 算法。

在无向图 DFS 树中,横叉边是不存在的,因此每个 u 的每个儿子 vi 的子树 T(vi) 都是两两独立的,这个性质在后续会不断用到。

割点不为 DFS 树根时#

对非树根 u 讨论,其作为割点的充要条件是删去 u 后存在一棵子树 T(vi) 成为连通块,也就是存在一棵子树 T(vi),其内部的任意节点是无法在不经过 u 的前提下去到子树外。

思考可以不经过 u 去到子树外的一颗子树 T(vi),这条路径应当是若干 T(vi) 内部的边与一条返祖边 (w,f) 组成,由于 DFS 树中不含横叉边,则 f 应当是 u 的一个祖先,于是 dfn(f)<dfn(u)

因此只需要维护 low(vi) 表示子树 T(vi) 内所有节点中 只通过一条非树边 可以去到的最小 dfn 值。

关于“只经过一条非树边”的设定

这样的设定足以满足我们对割点的探究,思考为什么更广泛的定义会出现错误。

原因在于一个割点可能对应多个点双连通分量,因此存在一个节点先后通过两条非树边到达割点再走到割点的祖先,此时就违背了刚才“不经过 u”的前提。

low(u) 的计算方法如下:

  • 初始设置为 dfn(u)

  • v 未被访问时,是一条树边,更新 low(u)=min(low(u),low(v))

  • v 已被访问时,是一条返祖边或前向边,后者对答案无影响,根据对 low(u) 的定义,更新 low(u)=min(low(u),dfn(v))

于是当 u 存在一个子树 T(vi) 满足 low(vi)dfn(u),说明 u 是割点。

割点为 DFS 树根时#

容易发现,当树根出现不只一棵子树时,两棵子树间唯一路径为经过根的路径,即此时根是割点。

点击查看代码
struct Graph{
    vector<int> E[maxn];
    inline void add_edge(int u,int v){
        E[u].push_back(v);
        E[v].push_back(u);
    }
    int rt;
    int dfn[maxn],low[maxn],dfncnt;
    bool vcut[maxn];
    int cnt_vcut;
    void Tarjan(int u){
        dfn[u]=++dfncnt,low[u]=dfn[u];
        int cnt_son=0;
        for(int v:E[u]){
            if(!dfn[v]){
                ++cnt_son;
                Tarjan(v);
                low[u]=min(low[u],low[v]);
                if(u!=rt&&low[v]>=dfn[u]) vcut[u]=1;
            }
            else{
                low[u]=min(low[u],dfn[v]);
            }
        }
        if(u==rt&&cnt_son>1) vcut[u]=1; 
        if(vcut[u]) ++cnt_vcut;
    }
    inline void solve(){
        for(int u=1;u<=n;++u){
            if(!dfn[u]){
                rt=u;
                Tarjan(u);
            }
        }
        printf("%d\n",cnt_vcut);
        for(int u=1;u<=n;++u){
            if(vcut[u]) printf("%d ",u);
        }
        printf("\n");
    }
}G;

割边#

割边一定是 DFS 树的非树边,在割点的基础上修改算法流程,注意到 T(vi) 可以经过非树边返回 u(u,vi) 并不属于割边,因此判断条件修改为:low(vi)>dfn(u)

且求割边不需要讨论根的问题。

有重边时用邻接表存图,cnt2 开始记录,这样正反两条边的编号是异或关系,判断是不是同一条边即可。

点击查看代码
struct Graph{
    struct edge{
        int to,nxt;
    }e[maxm<<1];
    int head[maxn],cnt;
    Graph(){
        cnt=1;
    }
    inline void add_edge(int u,int v){
        e[++cnt].to=v,e[cnt].nxt=head[u],head[u]=cnt;
        e[++cnt].to=u,e[cnt].nxt=head[v],head[v]=cnt;
    }
    int fa[maxn];
    int dfn[maxn],low[maxn],dfncnt;
    bool ecut[maxn];
    int cnt_ecut;
    void Tarjan(int u,int f,int id){
        dfn[u]=++dfncnt,low[u]=dfn[u];
        for(int i=head[u];i;i=e[i].nxt){
            if(i==id) continue;
            int v=e[i].to;
            if(!dfn[v]){
                Tarjan(v,u,i^1);
                low[u]=min(low[u],low[v]);
                if(low[v]>dfn[u]) ecut[v]=1;
            }
            else{
                low[u]=min(low[u],dfn[v]);
            }
        }
    }
    inline void solve(){
        for(int u=1;u<=n;++u){
            if(!dfn[u]){
                Tarjan(u,0,0);
            }
        }
        for(int u=1;u<=n;++u){
            if(ecut[u]) ++cnt_ecut;
        }
        printf("%d\n",cnt_ecut);
        for(int u=1;u<=n;++u){
            if(ecut[u]) printf("%d %d\n",u,fa[u]);
        }
    }
}G;

边双连通分量#

考虑一个简单粗暴的做法,先 Tarjan 求出所有割边之后暴力 DFS,但是很不优美。

我们使用一个同时求出割边并缩点的做法,具体利用一个栈,这个方法将在各种缩点中一直用到。

在访问到一个节点时将其加入栈,当 (u,v) 为割边时(此时 v 内遍历完回溯至 u),设将其分成了 Gu,Gv 两个子图,考虑到子树间独立,Gv 内部的点都在 v 之前入栈,而在 Gv 内部其他边双连通分量都已经被从栈求出(设另一割边 (x,y),则 y 遍历完回溯 x 一定在这之前),因此栈中 v 到栈顶的节点都属于 v 的边双连通分量。

于是暴力弹栈即可。

点击查看代码
struct Graph{
    struct edge{
        int to,nxt;
    }e[maxm<<1];
    int head[maxn],cnt;
    Graph(){
        cnt=1;
    }
    inline void add_edge(int u,int v){
        e[++cnt].to=v,e[cnt].nxt=head[u],head[u]=cnt;
        e[++cnt].to=u,e[cnt].nxt=head[v],head[v]=cnt;
    }
    int dfn[maxn],low[maxn],dfncnt;
    int st[maxn],top;
    int ebcc[maxn],cnt_ebcc;
    vector<int> EBCC[maxn];
    void Tarjan(int u,int id){
        dfn[u]=++dfncnt,low[u]=dfn[u];
        st[++top]=u;
        for(int i=head[u];i;i=e[i].nxt){
            if(i==id) continue;
            int v=e[i].to;
            if(!dfn[v]){
                Tarjan(v,i^1);
                low[u]=min(low[u],low[v]);
                if(low[v]>dfn[u]){
                    ++cnt_ebcc;
                    while(st[top]!=v){
                        ebcc[st[top]]=cnt_ebcc;
                        EBCC[cnt_ebcc].push_back(st[top]);
                        --top;
                    }
                    ebcc[v]=cnt_ebcc;
                    EBCC[cnt_ebcc].push_back(v);
                    --top;
                }
            }
            else{
                low[u]=min(low[u],dfn[v]);
            }
        }
    }
    inline void solve(){
        for(int u=1;u<=n;++u){
            if(!dfn[u]){
                Tarjan(u,0);
                ++cnt_ebcc;
                while(top){
                    ebcc[st[top]]=cnt_ebcc;
                    EBCC[cnt_ebcc].push_back(st[top]);
                    --top;
                }
            }
        }
        printf("%d\n",cnt_ebcc);
        for(int i=1;i<=cnt_ebcc;++i){
            printf("%ld ",EBCC[i].size());
            for(int u:EBCC[i]){
                printf("%d ",u);
            }
            printf("\n");
        }
    }
}G;

点双连通分量#

与边双连通分量类似,在 u 为割点时弹栈,要注意以下四点:

  • 弹栈时栈顶到 v 都属于这个点双连通分量,同时割点 u 也属于这个点双连通分量但不一定在栈中与 v 相邻。

  • u 可能在多个点双连通分量中,不能弹栈;但 v 的子树内所有点双连通分量都已经处理,可以弹栈。

  • 每次 DFS 后的栈中剩余一个元素是 DFS 树的根。

  • 孤立点也是一个点双连通分量。

点击查看代码
struct Graph{
    vector<int> E[maxn];
    inline void add_edge(int u,int v){
        E[u].push_back(v);
        E[v].push_back(u);
    }
    int dfn[maxn],low[maxn],dfncnt;
    int st[maxn],top;
    int cnt_vbcc;
    bool vbcc[maxn];
    vector<int> VBCC[maxn];
    void Tarjan(int u){
        dfn[u]=++dfncnt,low[u]=dfn[u];
        st[++top]=u;
        for(int v:E[u]){
            if(!dfn[v]){
                Tarjan(v);
                low[u]=min(low[u],low[v]);
                if(low[v]>=dfn[u]){
                    ++cnt_vbcc;
                    while(st[top]!=v){
                        vbcc[st[top]]=1;
                        VBCC[cnt_vbcc].push_back(st[top]);
                        --top;
                    }
                    vbcc[v]=1;
                    VBCC[cnt_vbcc].push_back(v);
                    --top;
                    vbcc[u]=1;
                    VBCC[cnt_vbcc].push_back(u);
                } 
            }
            else{
                low[u]=min(low[u],dfn[v]);
            }
        }
    }  
    inline void solve(){
        for(int u=1;u<=n;++u){
            if(!dfn[u]){
                Tarjan(u);
                if(!vbcc[u]){
                    ++cnt_vbcc;
                    VBCC[cnt_vbcc].push_back(st[top]);
                }
                --top;
            }
        }
        printf("%d\n",cnt_vbcc);
        for(int i=1;i<=cnt_vbcc;++i){
            printf("%ld ",VBCC[i].size());
            for(int u:VBCC[i]){
                printf("%d ",u);
            }
            printf("\n");
        }
    }
}G;

然而点双连通分量的“缩点”是有矛盾的(一个点出现在多个点双连通分量中),我们想要达成的效果实际上是“缩边”,这一点会在下文的圆方树中提到。

有向图连通性#

一些定义#

若有向图 G 中任意不同两点 (u,v) 都有路径可达,称 G 为一个 强连通图,若 G 的一个子集 H 满足不存在连通子图 G 满足 HG,则 H 为一个 强连通分量

若将有向图 G 中所有有向边替换成无向边后 G 是一无向连通图,则称 G 为一个 弱连通图,同样也有 弱联通分量 的定义。

若有向图 G 中所有无序点对 (u,v) 至少满足 u 可达 vv 可达 u,则称 G 为一个 单向连通图

强连通分量#

下面的介绍将类比无向图中边双连通分量缩点的算法流程。

有向图 DFS 的子树虽然不保证完全独立,依然满足所有的横叉边 (u,v) 都有 dfn(u)>dfn(v),这一性质会广泛用到。

下面来讨论三种非树边对连通性的影响:

  • 前向边:没有任何影响,等价于树上路径。

  • 返祖边:边的两端点之间形成一个简单的强连通分量,同时可以与其他强连通分量结合。

  • 横叉边:可以到达时间戳更小的节点。

也就说有必要好好研究的就是横叉边,给出一个性质:若存在横叉边 (u,v)v 可达 u 当且仅当 v 可达 LCA(u,v)

充分性显然。

必要性考虑如何在不经过 LCA(u,v) 的情况下到达 u,此时返祖边结合树边是无用的,似乎只能借助与横叉边,而显然横叉边 (u,v) 的存在就代表着 u 所在子树的时间戳都大于 v 所在子树,且横叉边只能从较大时间戳到较小时间戳,因此横叉边也无法使用,于是得证。

u,v 同时存在一个强连通分量里,则这个强连通分量树上深度最小的点至少是 LCA(u,v),因此一个强连通分量深度最小的点只有一个。我们希望在这个点记录强连通分量。

一个点如果不是强连通分量中深度最小的点,则一定可以通过一系列的横叉边以及返祖边去到自己的祖先位置,而这个祖先位置就是强连通分量中最小的点。

于是我们继续定义 low(u)u 通过非树边可以去到的最小时间戳。

下面是更新 low(u) 的方式:

  • 初始设置为 dfn(u)

  • v 未被访问时,是一条树边,更新 low(u)=min(low(u),low(v))

  • v 已被访问时且已经弹栈,不再更新

  • v 已经被访问且未弹栈,更新 low(u)=min(low(u),dfn(v))

关于已经弹栈后不更新

v 已经弹栈说明 v 不可达 LCA(u,v)(当前还处于 LCA(u,v) 子树中,已经弹栈说明不在统一强连通分量),于是到达 vu 的连通性是没有意义的。

这样当 dfn(u)=low(u) 时,说明 u 是当前强连通分量的最小深度节点,类比上文边双连通分量的证明,此时栈顶到 u 的所有节点都属于该强连通分量。

关于未弹栈时更新的转移

这里写作 dfn(v)low(v) 均可,不同与割点割边时对一次非树边的限制,这里更新主要目的是检验 u 是否是最小深度节点,只需要让 low(u)<dfn(u) 就可以。

点击查看代码
struct Graph{
    vector<int> E[maxn];
    inline void add_edge(int u,int v){
        E[u].push_back(v);
        E[v].push_back(u);
    }
    int dfn[maxn],low[maxn],dfncnt;
    int st[maxn],top;
    int scc[maxn],cnt_scc;
    vector<int> SCC[maxn];
    void Tarjan(int u){
        dfn[u]=++dfncnt,low[u]=dfncnt;
        st[++top]=u;
        for(int v:E[u]){
            if(!dfn[v]){
                Tarjan(v);
                low[u]=min(low[u],low[v]);
            }
            else if(!scc[v]){
                low[u]=min(low[u],dfn[v]);// or low[u]=min(low[u],low[v])
            }
        }
        if(dfn[u]==low[u]){
            ++cnt_scc;
            while(st[top]!=u){
                scc[st[top]]=cnt_scc;
                SCC[cnt_scc].push_back(st[top]);
                --top;
            }
            scc[u]=cnt_scc;
            SCC[cnt_scc].push_back(u);
            --top;
        }
    }
    inline void solve(){
        for(int u=1;u<=n;++u){
            if(!dfn[u]) Tarjan(u);
        }
    }
}G;

圆方树#

构建#

狭义圆方树处理仙人掌(每条边最多只出现在一个简单环中),将环视作方点,点视作圆点;广义圆方树处理点双连通分量的缩点,将点双视作方点,点视作圆点。环是点双的简单情况,因此本部分讲解广义圆方树。

容易发现一条边两端点应为一圆一方,且两个点双最多只有一个公共点(割点),因此形成一棵树。

圆方树的构建实际上就是求点双的过程。

点击查看代码
struct Graph{
    vector<int> E[maxn];
    inline void add_edge(int u,int v){
        E[u].push_back(v);
        E[v].push_back(u);
    }
    int dfn[maxn],low[maxn],dfncnt;
    int st[maxn],top;
    void Tarjan(int u){
        dfn[u]=++dfncnt,low[u]=dfn[u];
        st[++top]=u;
        for(int v:E[u]){
            if(!dfn[v]){
                Tarjan(v);
                low[u]=min(low[u],low[v]);
                if(low[v]>=dfn[u]){
                    ++tot;
                    while(st[top]!=v){
                        T.add_edge(tot,st[top]);
                        --top;
                    }
                    T.add_edge(tot,v);
                    --top;
                    T.add_edge(tot,u);
                }
            }
            else{
                low[u]=min(low[u],dfn[v]);
            }
        }
    }
    inline void solve(){
        tot=n;
        Tarjan(1);
    }
}G;

例题#

Luogu-P4630 APIO 2018 铁人两项#

建出圆方树,考虑在 (u,v,w) 的中间点 v 处统计答案。

一个样例(加粗为方点):

先不考虑点双的影响,圆点 v 不同子树内的节点可以构成简单的 (u,v,w),例如路径 (2,4,5),此类只需要记录子树大小 sum 即可。

点双内部任意选三个点都可以构成答案,即 siz×(siz1)×(siz2),例如路径 (1,2,3)

最后一步是考虑点双的作用,刚刚在圆点处统计答案中 u,w 来自不同的子树,而借助点双,可以在圆点的同一个子树中得到一条路径,例如路径 (1,7,8) 是圆点 7 同一子树中的,但他们来自方点 11 的不同子树。

考虑子树 uw,枚举圆点 v,在方点处记录答案。其中 v 应当与 u,w 子树的根不同,因此有 siz2 种方案,而 u,w 子树同时选择根时等价于前面计算的点双内部,需要去除,因此考虑方点两棵子树的答案是:

(siz2)×(sumu×sumv1)×2

可以把 1 拆出来在最后统一计算。

Luogu-P4606 SDOI 2018 战略游戏#

删去的点一定是割点,建出圆方树,每次把选中的节点的虚树建出,虚树中非选中的圆点个数即为答案。

CodeForces-487E Tourists *3200#

能够想出圆方树+树链剖分,并用 multiset 维护方点对应的所有圆点最小值。

暴力修改一个圆点对应的所有方点肯定是不行的,考虑对于每个方点只维护其所有儿子的最小值,这样每次修改圆点只需要修改父亲。而当方点不是路径 LCA 时,其父亲本身已经被统计过了,反之额外统计一下父亲的贡献。

Luogu-P4334 COI 2007 Policija#

本意是求给定的点/边是否是给定路径的割点/边。

比较无脑的做法是分别建出点双的圆方树和边双缩点后的树。

对于查询点的操作,只需要判断该点是否在给定路径上即可;对于查询边的操作,排除点对在同一边双的情况后,判断该边(也就是两个点)是否在给定路径上。

后者的操作也可以在圆方树上进行,是割边的充要条件为二者共同的点双大小为 2,这也保证了该方点至于这两个圆点相连,于是再判断方点是否再给定路径上。

参考资料#

无向图连通性#

有向图连通性#

圆方树#

作者:SoyTony

出处:https://www.cnblogs.com/SoyTony/p/Learing_Notes_about_Connectivity.html

版权:本作品采用「署名-非商业性使用-相同方式共享 4.0 国际」许可协议进行许可。

posted @   SoyTony  阅读(412)  评论(1编辑  收藏  举报
相关博文:
阅读排行:
· 单线程的Redis速度为什么快?
· 展开说说关于C#中ORM框架的用法!
· Pantheons:用 TypeScript 打造主流大模型对话的一站式集成库
· SQL Server 2025 AI相关能力初探
· 为什么 退出登录 或 修改密码 无法使 token 失效
more_horiz
keyboard_arrow_up light_mode palette
选择主题
menu
点击右上角即可分享
微信分享提示