前言
%被某brz逼着问,觉得很有必要好好复习一下这些毒瘤东西。
定义
%连通如果有向图中的两点 uuu,vvv 间同时存在 uuu 到 vvv 的路径及 vvv 到 uuu 的路径,则称点 uuu 和点 vvv 是连通的(Connected)。
连通图对于无向图G,如果任意两点都是连通的,则称无向图G是连通图(Connected Graph)。
连通分量无向图G的极大连通子图称为G的连通分量(Connected Component)。
%割点在一个无向图中,若删去点 uuu 和点 uuu 连出的所有边后,连通分量的数量增加,则称点 uuu 为割点(Cut-vertex),也叫割顶。
%割边在一个无向图中,若删去一条边后,连通分量的数量增加,则称该边为割边(Cut-edge),也叫桥(Bridge)。
%点-双连通对于一个无向连通图,如果其中不存在割点,则说这个图是点-双连通的(Point Biconnected)。
点双连通分量对于一张无向图,点-双连通的极大子图称为点双连通分量(Point Biconnected Component,BCC)或块(Block)。
%边-双连通对于一个无向连通图,如果任意两点之间至少存在两条边不重复的路径,则说这个图是边-双连通的(Edge Biconnected)。
边双连通分量对于一张无向图,边-双连通的极大子图称为边双连通分量(Edge Biconnected Component)。
%强联通如果有向图中的两点 uuu,vvv 间同时存在 uuu 到 vvv 的路径及 vvv 到 uuu 的路径,则称点 uuu 和点 vvv 是强连通的(Strongly Connected)。
强联通图对于一张有向图G,如果任意两点都是强连通的,则称有向图G是强联通图(Strongly Connected Graph)。
强联通分量有向图G的极大强连通子图,称为强连通分量(strongly connected components)。
%说明本文中点双联通分量的定义可能与其他文章不符,但据我查到的所有文献中,都认为两个点一条边组成的图是点双联通分量,因此这里直接沿用更加严谨的定义。
基本结论/性质
一张 nnn 个点的简单连通图中至少有 n−1n-1n−1 条边。点-双连通的图中任意两条边都在同一个简单环中,即除了说明中提到的一种特殊情况之外,任意两点之间至少存在两条点不重复的路径,即内部无割点。证明假设一个点-双连通图中存在一个割点,则删除这个点之后图不再连通,与点-双连通图的定义矛盾,得证。边-双连通的图中任意每条边都至少在一个简单环中,即所有的边都不是桥。
证明假设一个边-双连通图中存在桥,则删除桥后,图不再连通,与边-双连通图的定义矛盾,得证。除了桥不属于任何边-双连通分量外,每条边恰好属于一个边-双连通分量。
证明设无向图 GGG 中存在一条边属于多个边-双连通分量,则这两个双联通分量互相连通,因此这两个双联通分量都不满足“极大”的定义。设另一条边 (u,v)(u,v)(u,v) 不存在于任何一个边-双连通分量中且 (u,v)(u,v)(u,v) 不是桥,则 uuu 和 vvv 至少存在两条 (u−v)(u-v)(u−v),则存在边-双连通分量包含了 uuu 和 vvv,则 (u,v)(u,v)(u,v) 在边-双连通分量中。不同的双连通分量最多只有一个公共点,且它一定是割顶。
证明假设有两个不同的双连通分量有多个公共点,则通过这些公共点,两个双连通分量之间可以互相到达,与“极大子图”的定义矛盾。设这个点不是割顶,则删除这个点之后,两个双联通分量仍然连通,与“极大子图”矛盾,得证。任意割顶都是至少两个不同双连通分量的公共点。
证明设一个割顶不是任何两个点双连通分量的公共点,根据割顶的定义,删除这个点之后,原图不连通,与点双连通分量的定义矛盾,得证。任意非割点只属于一个点双联通分量。
证明假设某个非割点属于至少两个双连通分量,则删除该非割点后,两双连通分量仍联通,则按照定义,两双连通分量其实为一个双连通分量,矛盾。把所有桥删除后,每个连通分量对应原图中的一个边-双连通分量。
证明设原结论不成立,即删除桥后连通分量中不存在至少两条“边不重复”的路径,则连通分量中存在桥,因此桥没有删完,与结论中删除所有桥的操作矛盾,得证。
扩展结论/性质
将一张有向图中的一个强联通分量的每条边变为无向的,则这个强连通分量变为一个边双联通分量。Tarjan算法
%这个算法的核心思想非常精简,考虑对于每个点维护两个元素
dfn[u]dfn[u]dfn[u] 表示到达点 uuu 之前已经到达了多少个点。low[u]low[u]low[u] 表示点 uuu 在不经过父亲/父子边的前提下能到达的最早的祖先的dfn值。
%第二条具体是父亲还是父子边由求解的内容不同而变化。
代码约定
head[i]head[i]head[i]:点 iii 的第1条出边的编号edges[i]edges[i]edges[i]:第 iii 条边的信息edges[i].nextedges[i].nextedges[i].next:从第 iii 条边出发点出发的下一条边edges[i].vedges[i].vedges[i].v:第 iii 条边的到达点ixor1i\ \text{xor}\ 1ixor1表示第 iii 条边的反向边的编号(无向图双向连边),这意味着边要从2开始编号,即 edges[0]edges[0]edges[0] 和 edges[1]edges[1]edges[1] 均不能用于存储真正的边的信息。割边
%考虑遍历到一个点时,定义不重复经过深度优先搜索时已经经过的边(这意味着可以走重边),能到达的最早的点为 low[u]low[u]low[u]。若点 uuu 满足 low[v]>dfn[u]low[v]>dfn[u]low[v]>dfn[u],则说明在不经过父子边的情况下,连父亲都到不了,则说明边 (u,v)(u,v)(u,v)为割边。
注意原图可能不连通,因此需要保证每个联通块都被遍历到。
int low[maxn],dfn[maxn];int Index,bridge;bool vis[maxn],cut[maxn];void dfs(int u,int from=-1){low[u]=dfn[u]=++Index;vis[u]=true;for(int i=head[u];i;i=edges[i].next){int v=edges[i].to;if(i==from||(i^1)==from) continue;if(!dfn[v]){dfs(v,i);if(low[u]>low[v])low[u]=low[v];if(low[v]>dfn[u]){bridge++;cut[i]=cut[i^1]=true;}} else if(vis[v])low[u]=min(low[u],dfn[v]);}}void Tarjan(int n){memset(dfn,0,sizeof dfn);for(int i=1;i<=n;i++)if(!dfn[i]) dfs(i);}
割点
%考虑遍历到一个点时,定义其不重复经过深度优先搜索时已经经过的点,能到达的最早的点为 low[u]。若点 uuu 满足 low[u]>dfn[u]low[u]>dfn[u]low[u]>dfn[u],则说明边 (u,v)(u,v)(u,v)为割点。
需要注意的是,若一个点是根节点,即其没有父亲,且仅有一个儿子,该点不是割点。
bool cut[maxn];int Index,low[maxn],dfn[maxn];void dfs(int u,int fa=-1){low[u]=dfn[u]=++Index; int child=0;for(int i=head[u]; i; i=edges[i].next) {int v=edges[i].v;if(!dfn[v]){child++;dfs(v,u);low[u]=min(low[u],low[v]);if(low[v]>=dfn[u])cut[u]=1;}else if(dfn[v]<dfn[u]&&v!=fa)low[u]=min(low[u],dfn[v]);} if(fa<0&&child==1) cut[u]=0;}void Tarjan(int n){Index=0;memset(dfn,0,sizeof dfn);for(int i=1;i<=n;i++)if(!dfn[i]) dfs(i);}
边双联通分量
%根据性质3,我们可以知道一个图是边双联通分量的必要条件是图中没有桥。为什么这里不是充要条件呢?因为这个没有桥的图很可能不是只是一个边双联通分量的子图而非极大子图。
因此,一种简单的方法是,先求出所有的桥,然后在不经过桥的基础上,一个点所能到达的所有点和这个点属于同一个边双联通分量。尽管这个过程需要两次遍历,但在很多情况下已经足够了。此处略去代码。
%事实上,我们只需要一次遍历,我们考虑两次遍历同时进行,用一个栈保存另一次遍历途中经过的所有点,具体地说,在遇到一个点时,将这个点的编号加入栈中。
当我们离开一个点时,对于点 uuu,若其不满足 dfn[u]=low[u]dfn[u]=low[u]dfn[u]=low[u],则说明这个点与其父亲的连边不是桥,可以直接离开,而不必将这个点从栈中弹出。
若满足 dfn[u]=low[u]dfn[u]=low[u]dfn[u]=low[u],则说明点 uuu 和其父亲的连边为桥,则说明 ∀p∈stack,dfn[p]<dfn[u]\forall p\in stack,dfn[p]<dfn[u]∀p∈stack,dfn[p]<dfn[u] 有 ppp 和点 uuu 属于同一个边双联通分量。
我们考虑将栈中的元素依次弹出,直到遇到第一个不满足上述条件的点,这样我们就完成了对一个边双联通分量的标记。理解这个过程需要对栈的所有操作整体理解,代码如下。
int low[maxn],dfn[maxn];int Index,ecc_cnt,bridge;int sta[maxn],top;bool vis[maxn],cut[maxn];int belong[maxn];void dfs(int u,int from=-1){low[u]=dfn[u]=++Index;sta[top++]=u;vis[u]=true;for(int i=head[u];i;i=edges[i].next){int v=edges[i].to;if(i==from||(i^1)==from) continue;if(!dfn[v]){dfs(v,i);if(low[u]>low[v]) low[u]=low[v];if(low[v]>dfn[u]){bridge++;cut[i]=true;cut[i^1]=true;}} else if(vis[v])low[u]=min(low[u],dfn[v]);}if(low[u]==dfn[u]){ecc_cnt++; int v;do{v=sta[--top];vis[v]=false;belong[v]=ecc_cnt;}while(v!=u);}}void solve(int n){memset(dfn,0,sizeof dfn);memset(cut,0,sizeof cut);memset(vis,0,sizeof vis);Index=top=ecc_cnt=bridge=0;for(int i=1;i<=n;i++)if(!dfn[i]) Tarjan(1);}
点双连通分量
%求点双的时候就不能两次遍历了,求点双联通分量也有一次遍历的方法,但相对边双联通分量显得更加复杂。因此在效率允许的前提下,上述方法更为简便。
求边双联通分量时,我们直接通过一个栈来保存边双联通分量中的所有点。能这么做是因为每个点只可能在一个边双联通分量中出现,尽管所有原图的非割点都只会在一个点双联通分量中出现,但原图的割点却必然在多个点双联通分量中出现,因而不能简单地将点入栈。
由于每条边最多只属于一个点双联通分量,我们考虑将边入栈。
在通过一条边时,将这条边入栈,若对于点 uuu 和其儿子 vvv,有 low[v]⩾low[u]low[v]\geqslant low[u]low[v]⩾low[u],则说明点 uuu 是割点,因此我们将栈内的边不断弹出,并记录边的两个端点,这些端点和 uuu 共属一个点双联通分量,直到弹出的边为 (u,v)(u,v)(u,v) 为止。
注意,在经过反向边更新low时,也需要将这条反向边入栈。
struct Edge{int u,v;};stack<Edge> sta;int dfn[maxn],low[maxn];int Index,bcc_cnt;bool cut[maxn];int belong[maxn];void dfs(int u,int fa=-1){low[u]=dfn[u]=++Index;int child=0;for(int i=head[u];i;i=edges[i].next){int v=edges[i].v;Edge len=(Edge){u,v};if(!dfn[v]){sta.push(len);child++; dfs(v,u);low[u]=min(low[u],low[v]);if(low[v]>=dfn[u]){cut[u]=1;bcc_cnt++;while(true){Edge x=sta.top(); sta.pop();if(belong[x.u]!=bcc_cnt) belong[x.u]=bcc_cnt;if(belong[x.v]!=bcc_cnt) belong[x.v]=bcc_cnt;if(x.u==u&&x.v==v) break;}}}else if(dfn[v]<dfn[u]&&v!=fa){sta.push(len);low[u]=min(low[u],dfn[v]);}} if(fa<0&&child==1) cut[u]=0;}