连通性问题

· · 算法·理论

强连通分量

定义

强连通的定义是:有向图 G 强连通是指,G 中任意两个结点连通。
强连通分量(Strongly Connected ComponentsSCC)的定义是:极大的强连通子图。

Tarjan 算法

DFS 生成树

在介绍该算法之前,先来了解 DFS 生成树,我们以下面的有向图为例:

有向图的 DFS 生成树主要有 4 种边(不一定全部出现):

  1. 树边(tree edge):示意图中以黑色边表示,每次搜索找到一个还没有访问过的结点的时候就形成了一条树边。
  2. 反祖边(back edge):示意图中以红色边表示(即 71),也被叫做回边,即指向祖先结点的边。
  3. 横叉边(cross edge):示意图中以蓝色边表示(即 97),它主要是在搜索的时候遇到了一个已经访问过的结点,但是这个结点 并不是 当前结点的祖先。注意:起点的 dfn 大于终点的 dfn
  4. 前向边(forward edge):示意图中以绿色边表示(即 36),它是在搜索的时候遇到子树中的结点的时候形成的。

    我们考虑 DFS 生成树与强连通分量之间的关系。

如果结点 u 是某个强连通分量在搜索树中遇到的第一个结点,那么这个强连通分量的其余结点肯定是在搜索树中以 u 为根的子树中。结点 u 被称为这个强连通分量的根。

Tarjan 算法求强连通分量

在 $Tarjan$ 算法中为每个结点 $u$ 维护了以下几个变量: >$dfn_u$ : $u$ 的 $dfs$ 序。 > >$low_u$ : 在 $u$ 的子树中能够回溯到的最早的已经在栈中的结点。设以 u 为根的子树为 ${Subtree}_u$。${low}_u$ 定义为以下结点的 $dfn$ 的最小值:$Subtree_u$ 中的结点;从 $Subtree_u$ 通过一条不在搜索树上的边能到达的结点。不过可淡化其意义,变成辅助合并的标志。 显而易见,一个结点的子树内结点的 $dfn$ 都大于该结点的 $dfn$。 从根开始的一条路径上的 $dfn$ 严格递增,$low$ 严格非降。 --- 按照深度优先搜索算法搜索的次序对图中所有的结点进行搜索,维护每个结点的 $dfn$ 与 $low$ 变量,且让搜索到的结点入栈。每当找到一个强连通元素,就按照该元素包含结点数目让栈中元素出栈。在搜索过程中,对于结点 $u$ 和与其相邻的结点 $v$($v$ 不是 $u$ 的父节点)考虑 $3$ 种情况: 1. $v$ 未被访问:继续对 $v$ 进行深度搜索。在回溯过程中,用 $low_v$ 更新 $low_u$。因为存在从 $u$ 到 $v$ 的直接路径,所以 $v$ 能够回溯到的已经在栈中的结点,$u$ 也一定能够回溯到。 2. $v$ 被访问过,已经在栈中:根据 $low$ 值的定义,用 $dfn_v$ 更新 $low_u$。 3. $v$ 被访问过,已不在栈中:说明 $v$ 已搜索完毕,其所在连通分量已被处理,所以不用对其做操作。 --- ### Code ``` #include<iostream> #include<cstdio> #include<algorithm> #include<vector> #define N 10005 using namespace std; int n,m; vector<int> e[N]; int dfn[N],low[N],cnt;//节点信息 int st[N],top;//栈 bool ins[N];//是否在栈中 int idx,sz[N],bel[N];//sz = size , bel = belong ,字面意思 , 统计答案 int ans; inline void dfs(int u){ dfn[u]=low[u]=++cnt;//维护 dfn 和 low ins[st[++top]=u]=true;//维护栈和栈标记,学会适当压行,增加代码可读性 for(int i=0;i<e[u].size();i++){ int v=e[u][i];//枚举 if(!dfn[v]){//没访问过 dfs(v);//访问 low[u]=min(low[u],low[v]);//回溯后用 dfn[v] 更新 dfn[u] }else if(ins[v]){//访问过,在栈中,说明 v 属于一个未处理完的强连通分量 //一石二鸟,包含返祖边和前向边的更新 //返祖边: 更新为返祖边的 dfn 序 //前向边: 前向边的 dfn 序定大于 dfn[u] , 无影响,即为废物 low[u]=min(low[u],dfn[v]); } //横叉边:无需考虑, 原因: 若不在栈中,已被取出,在其他强连通分量中,若在栈中,已被上方 if 语句考虑了 ,无影响。 } if(dfn[u]==low[u]){//若相等,找到一组强连通分量 int v; ++idx;//序号加一 do{ v=st[top--];//取出 ins[v]=false;//出栈的标记 bel[v]=idx;//所属强连通分量序号 ++sz[idx];//大小加一 }while(v!=u);//取到自己出去为止 //do_while 真神奇,n 年没用了, 除了刚学打了几次模板,有发挥作用了 } } int main(){ scanf("%d%d",&n,&m); for(int i=1;i<=m;i++){ int u,v; scanf("%d%d",&u,&v); e[u].push_back(v); }//输入, 建边 for(int i=1;i<=n;i++){ if(!dfn[i]) dfs(i); }//一次 dfs 不一定遍历所有点,要跑多次 for(int i=1;i<=idx;i++) ans+=(sz[i]>1); printf("%d",ans);//题目要求 for(int i=1;i<=n;i++){ printf("%d%c",bel[i],"\n"[i==n]); }//输出每个节点的对应强连通分量编号 return 0;//56 行华丽结束 } ``` 时间复杂度: $O(n+m)$。 ### 拓扑排序 考察 $Tarjan$ 中被最优先处理的是什么样的点,发现显然就是那些没有出度的点,而这和拓扑排序反全是反过来的。而又因为我们的 $SCC$ 编号是按顺序排的,所以本质上相当于把拓扑序反了过来。 **因此,SCC 编号等于逆拓扑序。** **但DP 转移顺序应为拓扑序** ### 一些结论 一个有向图中,如果将所有强连通分量缩成一个点,则这个图一定无环。即为 $DAG$ 图。 ### 证明 反证,假设这个图中有环,环上的点代表的 $SCC$ 组成的集合为 $S$,则从 $S$ 中任取两个集合,从这两个集合中分别取一个元素 $x,y$,必然存在一个先从 $x$ 到环上,绕环一周,再到 $y$ 所在 SCC,最后到达 $y$ 的路径,因此 $S$ 应在同一个 $SCC$ 中,证毕。 ### 关于强连通分量的一些应用 >$Question$: >求最少选择几个点,使得信息可传递到所有点 > >$Answer$: >所有入度为 $0$ 的点,因为这些点无法通过其他点传递得到 >$Question$: >求最少添加几条边,使得原图变为一个强连通分量 > >$Answer$: >先缩点,因为强连通分量之间可相互到达。既然要把使得这个图成为一个强连通分量,那么对于一个强连通分量来说,每个节点出入度不应该为 $0$,应该至少为 $1$,他们才能够形成强连通分量(因为入度为 $0$ 其他边无法到达,出度为 $0$,无法到达其他边),所以就是找出入度为 $0$ 的节点的个数,然后比较最大值即可。**(原图是一个强连通分量就为 $0$)** # 双连通分量 ## 边双连通分量 ### 定义 在一张连通的无向图中,对于两个点 $u$ 和 $v$,如果无论删去哪条边(只能删去一条)都不能使它们不连通,我们就说 $u$ 和 $v$ 边双连通。 对于一个无向图中的 **极大** 边双连通的子图,我们称这个子图为一个 **边双连通分量**。 边双连通具有传递性,即,若 $x$,$y$ 边双连通,$y$,$z$ 边双连通,则 $x$,$z$ 边双连通。 ### Code ``` #include<iostream> #include<algorithm> #include<cstdio> #include<vector> #define N 500005 using namespace std; int n,m; vector<int> e[N]; int dfn[N],low[N],cnt,st[N],top; int idx,bel[N]; vector<int> ans[N]; inline void read(int &x){ int f=1; char ch=getchar(); x=0; while(ch<'0'||ch>'9'){ if(ch=='-') f=-1; ch=getchar(); } while(ch>='0'&&ch<='9'){ x=x*10+(ch-'0'); ch=getchar(); } x*=f; } inline void write(int x){ if(x<0){ putchar('-'); x=-x; } if(x>=10) write(x/10); putchar(x%10+'0'); } inline void dfs(int u,int fa){ dfn[u]=low[u]=++cnt; st[++top]=u;//维护新结点 u 的信息 for(int i=0;i<e[u].size();i++){ int v=e[u][i]; if(v==fa){//判重边与反向边技巧 fa=0; continue; } if(!dfn[v]){//树边 dfs(v,u);//访问 low[u]=min(low[u],low[v]);//更新 }else{//非树边(前向边,返祖边,横叉边) low[u]=min(low[u],dfn[v]); } } if(dfn[u]==low[u]){//找到一个边双 int v; idx++; do{ v=st[top--]; ans[idx].push_back(v); bel[v]=idx; }while(v!=u);//加入&记录 } } int main(){ read(n); read(m); for(int i=1;i<=m;i++){ int u,v; read(u); read(v); e[u].push_back(v); e[v].push_back(u); }//建边 for(int i=1;i<=n;i++) dfs(i,0);//Tarjan write(idx); putchar('\n'); for(int i=1;i<=idx;i++){ write(ans[i].size()); putchar(' '); for(int j=0;j<ans[i].size();j++){ write(ans[i][j]); putchar(' '); } putchar('\n'); }//输出 return 0; } ``` ### 应用 求一张图加几条无向边使得这张图变为一个边双连通分量。 $ans=\lceil\frac{Leaf}{2}\rceil $($Leaf$ 为叶子连通块数)。 ## 点双连通分量 ### 定义 在一张连通的无向图中,对于两个点 $u$ 和 $v$,如果无论删去哪个点(只能删去一个)都不能使它们不连通,我们就说 $u$ 和 $v$ 点双连通。 对于一个无向图中的 **极大** 点双连通的子图,我们称这个子图为一个 **点双连通分量**。 点双连通**不**具有传递性。 ### Code ``` #include<iostream> #include<algorithm> #include<cstdio> #include<vector> #define N 500005 using namespace std; int n,m; vector<int> e[N]; int dfn[N],low[N],cnt,st[N],top; int idx,bel[N]; vector<int> ans[N]; inline void read(int &x){ int f=1; char ch=getchar(); x=0; while(ch<'0'||ch>'9'){ if(ch=='-') f=-1; ch=getchar(); } while(ch>='0'&&ch<='9'){ x=x*10+(ch-'0'); ch=getchar(); } x*=f; } inline void write(int x){ if(x<0){ putchar('-'); x=-x; } if(x>=10) write(x/10); putchar(x%10+'0'); } inline void dfs(int u,int fa){ dfn[u]=low[u]=++cnt; st[++top]=u;//加入 int son=0;//儿子数 for(int i=0;i<e[u].size();i++){ int v=e[u][i]; if(v==fa){ fa=0; continue; }//判重边和反向边 if(!dfn[v]){ son++; dfs(v,u); low[u]=min(low[u],low[v]); if(low[v]>=dfn[u]){//找到一个点双连通分量 int vv; idx++; bel[u]++; ans[idx].push_back(u);//记录 u do{ vv=st[top--]; bel[vv]++; ans[idx].push_back(vv); }while(vv!=v);//记录其他点(u 未被弹出,因为点双没有传递性,一个点可能属于多个点双) } }else{ low[u]=min(low[u],dfn[v]); } } if(fa==-1&&son==0) ans[++idx].push_back(u);//判断孤立点带重边 } int main(){ read(n); read(m); for(int i=1;i<=m;i++){ int u,v; read(u); read(v); if(u==v) continue; e[u].push_back(v); e[v].push_back(u); } for(int i=1;i<=n;i++){ if(!dfn[i]){ dfs(i,-1); top=0;//根未被弹出,栈需清空 } } write(idx); putchar('\n'); for(int i=1;i<=idx;i++){ write(ans[i].size()); putchar(' '); for(int j=0;j<ans[i].size();j++){ write(ans[i][j]); putchar(' '); } putchar('\n'); } return 0; } ``` # 割点 ## 定义 对于一个无向图,如果把一个点删除后这个图的极大连通分量数增加了,那么这个点就是这个图的割点(又称割顶)。 一个点为割点当且仅当这个点属于一个以上的点双连通分量。 ## Code ``` #include<iostream> #include<algorithm> #include<cstdio> #include<vector> #define N 20005 using namespace std; int n,m; vector<int> e[N]; int dfn[N],low[N],cnt,st[N],top; int idx,bel[N]; int ans; inline void read(int &x){ int f=1; char ch=getchar(); x=0; while(ch<'0'||ch>'9'){ if(ch=='-') f=-1; ch=getchar(); } while(ch>='0'&&ch<='9'){ x=x*10+(ch-'0'); ch=getchar(); } x*=f; } inline void write(int x){ if(x<0){ putchar('-'); x=-x; } if(x>=10) write(x/10); putchar(x%10+'0'); } inline void dfs(int u,int fa){//同点双连通分量 dfn[u]=low[u]=++cnt; st[++top]=u; int son=0; for(int i=0;i<e[u].size();i++){ int v=e[u][i]; if(v==fa){ fa=0; continue; } if(!dfn[v]){ son++; dfs(v,u); low[u]=min(low[u],low[v]); if(low[v]>=dfn[u]){ int vv; idx++; bel[u]++;//记录所属点双连通分量数 do{ vv=st[top--]; bel[vv]++; }while(vv!=v); } }else{ low[u]=min(low[u],dfn[v]); } } if(fa==-1&&son==0) idx++,bel[u]++; } int main(){ read(n); read(m); for(int i=1;i<=m;i++){ int u,v; read(u); read(v); if(u==v) continue; e[u].push_back(v); e[v].push_back(u); } for(int i=1;i<=n;i++){ if(!dfn[i]) dfs(i,-1),top=0; } for(int i=1;i<=n;i++) ans+=(bel[i]>1);//记录答案 write(ans); putchar('\n'); for(int i=1;i<=n;i++){ if(bel[i]>1) write(i),putchar(' '); } return 0; } ``` ### 应用 >**例题**:[P3225 矿场搭建](https://www.luogu.com.cn/problem/P3225): 给定一张连通图,你可以设定 $k$ 个关键点,问: 要让删除一个任意点后,使得剩下的若干连通块都包含至少一个关键点,$k$ 的最小值是多少,和使得 $k$ 最小的方案数。 > >1. 如果这个点双连通分量包含了 $1$ 个以上的割点,那么无论哪一个割点被堵,都可以通过其他割点逃向其他救生点,没有贡献答案。 > >2. 如果这个点双连通分量只有 $1$ 个割点,如果割点被堵,就在点双连通分量内设立一个逃生点,如果逃生点被堵,就从割点出去。贡献为 $1$,方案数为 $size[i]-1$ (除去割点本身)。 >3. 如果这个点双连通分量没有割点。就要用两个逃生点互保。贡献为 $2$,方案数为 $size[i]*(size[i]-1)/2$。所有方案数乘起来就是答案。 # 后记 $update$ : $2025/7/30 $update$ : $2025/8/1 \hspace{0.18cm}$ 说明: 添加**关于强连通分量的一些应用**部分。 $update$ : $2025/8/6 \hspace{0.18cm}$ 说明: 添加**点/边双连通分量**和**割点**部分。