图的连通性——Tarjan算法&割边&割点
tarjan算法
update:2021.7.14 更换更好理解的例题,补充前置知识
前置知识:
括号化定理:如果一个点在dfs中的起始时间和终结时间被另一个点包括,这个点肯定是另一个点的子节点。
原理:
我们考虑 DFS 搜索树与强连通分量之间的关系。
如果结点 是某个强连通分量在搜索树中遇到的第⼀个结点,那么这个强连通分量的其余结点肯定 是在搜索树中以为根的⼦树中。被称为这个强连通分量的根。
反证法:假设有个结点 在该强连通分量中但是不在以 为根的⼦树中,那么 到 的路径中肯 定有⼀条离开⼦树的边。但是这样的边只可能是横叉边或者反祖边,然⽽这两条边都要求指向的结点已 经被访问过了,这就和 是第⼀个访问的结点⽭盾了。得证。
思路:
在 Tarjan 算法中为每个结点 维护了以下⼏个变量:
1:dfn[u]深度优先搜索遍历时结点 的DFS序。
2:low[u]设以u为根的⼦树为Subtree[u]。low[u]定义为以下结点的dfn的最⼩值:
Subtree(u)中的结点;从 Subtree(u)通过⼀条不在搜索树上的边能到达的结点。
遍历时维护栈,⽤于求解强连通分量。 ⼀个结点的⼦树内结点的 dfn 都⼤于该结点的 dfn。 从根开始的⼀条路径上的 dfn 严格递增,low 严格⾮降。 按照深度优先搜索算法搜索的次序对图中所有的结点进⾏搜索。
在搜索过程中,对于结点u和与其v相邻的结点 考虑 3 种情况:
1. v未被访问:继续对v进⾏深度搜索。在回溯过程中,low[v]⽤low[u]更新 。因为存在从u到v的直接路径,所以v 能够回溯到的已经在栈中的结点,u也⼀定能够回溯到。
2. v被访问过,已经在栈中:即已经被访问过,根据low值的定义(能够回溯到的最早的已经在栈中的结点),则⽤dfn[u]更新low[v] 。
3. v被访问过,已不在在栈中:说明v已经被另一结点搜索完毕,其所在连通分量已被处理,所以不⽤对其做操作。
代码实现:
我们以P2341 [USACO03FALL][HAOI2006]受欢迎的牛 G为例:
如果这个图中只有一个强连通分量,那么这个图才可能有“明星”
所以受欢迎的奶牛只有可能是图中唯一的出度为零的强连通分量中的所有奶牛
缩点后,整张图就变成了一个DAG,所以tarjan常常和拓扑排序一同使用。
code:
拓扑排序:
void topu(int x,int fa) { for(int i=head[x]; i; i=edge[i].nex) { int y=edge[i].to; if(y==fa) continue; in[y]--; if(!in[y]) topu(y,x); } }
AC代码:
1 #include<bits/stdc++.h> 2 using namespace std; 3 const int N=1e5+5; 4 int n,m; 5 int tim; 6 int top; 7 int idx,head[N]; 8 int low[N],dfn[N]; 9 int sd[N]; 10 int st[N]; 11 int vis[N]; 12 int ou[N]; 13 int size[N]; 14 int ans=0,res=0; 15 int fg; 16 struct node { 17 int from; 18 int to; 19 int nex; 20 } edge[N],eg[N]; 21 22 void add1(int x,int y) {//第一次建图 23 eg[++idx].from=x; 24 eg[idx].to=y; 25 eg[idx].nex=head[x]; 26 head[x]=idx; 27 } 28 29 void add2(int x,int y) {//缩点后第二次建图,与第一次不同的是,第二次要记录每个点的出度 30 ou[x]++; 31 edge[++idx].from=x; 32 edge[idx].to=y; 33 edge[idx].nex=head[x]; 34 head[x]=idx; 35 } 36 37 void tarjan(int x) { 38 dfn[x]=low[x]=++tim;//对于没有访问过的点,打上时间戳 39 st[++top]=x;//入栈 40 sd[x]=x;//缩点数组初始化 41 vis[x]=1; 42 size[x]=1;//缩点大小初始化 43 for(int i=head[x];i;i=eg[i].nex){ 44 int y=eg[i].to; 45 if(!dfn[y]) {//y没有被访问过 46 tarjan(y); 47 low[x]=min(low[x],low[y]); 48 } else if(vis[y]) low[x]=min(low[x],dfn[y]);//y仍然在栈中 49 } 50 if(low[x]==dfn[x]) {//找到一个强连通分量的代表 51 int y; 52 while(y=st[top--]) {//出栈,缩点 53 if(x==y)break; 54 sd[y]=x; 55 size[x]++; 56 size[y]--; 57 vis[y]=0; 58 } 59 } 60 } 61 62 63 64 int main() { 65 //freopen("popular.in","r",stdin); 66 //freopen("popular.out","w",stdout); 67 cin>>n>>m; 68 for(int i=1; i<=m; i++) { 69 int a,b; 70 cin>>a>>b; 71 add1(a,b); 72 } 73 for(int i=1; i<=n; i++) { 74 if(!dfn[i]) { 75 tarjan(i); 76 } 77 } 78 idx=0; 79 memset(head,0,sizeof(head)); 80 81 for(int i=1; i<=m; i++) { 82 int x=eg[i].from; 83 int y=eg[i].to; 84 if(sd[x]!=sd[y]) add2(sd[x],sd[y]);//第二次建图,整张图已经变成了一个DAG 85 } 86 int cnt=0; 87 fg=0; 88 for(int i=1;i<=n;i++){ 89 if(ou[i]==0&&sd[i]==i){ 90 cnt+=size[i]; 91 fg++; 92 } 93 } 94 if(fg==1) cout<<cnt;//受欢迎的奶牛只有可能是图中唯一出度为零的强连通分量中的所有奶牛 95 else cout<<0; 96 return 0; 97 }
但!
还有一个问题!
目光聚焦到这几行代码:
if(!dfn[y]){ tarjan(y); low[x]=min(low[x],low[y]); } /////////////////////////////////////////////////// else{ if(vis[y]){ low[x]=min(low[x],dfn[y]); } }
为什么一个括号里是low[y],一个是dfn[y]?
在这里,其实都写low[y]也是正确的,但是在割点割边的时候便是有问题的了。
原因:未出现的邻居,可能会连到之前出现过的点,所以是LOW;已经出现的邻居再次出现,就必然是强连通分量图中的一个点,可能是最小时序最小根,取它的DFN,继续计算LOW
割点
定义:
对于⼀个⽆向图,如果把⼀个点删除后这个图的极⼤连通分量数增加了,那么这个点就是这个图的割点(⼜称割顶)。
通俗理解,如果去掉割点能将这个图割成更多小块,这个点就是割点
原理&实现:
⾸先,我们按照 DFS 序给他打上时间戳(访问的顺序)。
这些信息被我们保存在⼀个叫做 dfn 的数组中。 还需要另外⼀个数组 low ,⽤它来存储不经过其⽗亲能到达的最⼩的时间戳。 例如 low[2] 的话是 1, low[5] 和 low[6] 是 3。 然后我们开始 DFS,我们判断某个点是否是割点的根据是:对于某个顶点 ,如果存在⾄少⼀个顶 点 ( 的⼉⼦),使得low[v]>=dfs[u] ,即不能回到祖先,那么u点为割点。 另外,如果搜到了⾃⼰(在环中),如果他有两个及以上的⼉⼦,那么他⼀定是割点了,如果只有 ⼀个⼉⼦,那么把它删掉,不会有任何的影响。
code:
#include<bits/stdc++.h> using namespace std; const int N=2e5+5; int n,m; int idx=0; int tim; int root; struct node {//邻接表建图 int to; int from; int nex; } eg[N]; int head[N]; int low[N],dfn[N]; int vis[N]; int cnt[N]; void add(int x,int y) {//建图 eg[++idx].from=x; eg[idx].to=y; eg[idx].nex=head[x]; head[x]=idx; } void tarjan(int x) { low[x]=dfn[x]=++tim; vis[x]=1; int flag=0; for(int i=head[x];i;i=eg[i].nex){ int y=eg[i].to; if(!vis[y]) { tarjan(y); low[x]=min(low[y],low[x]); if(low[y]>=dfn[x]) {//回不到更早的祖先节点 flag++;//统计儿子数 if(x!=root||flag>1) {//如果是根节点,要有2个儿子回不到祖先 cnt[x]=1;//标记割点 } } } else low[x]=min(low[x],dfn[y]);//如果y已经被遍历,则low[x]直接更新 } } int main() { cin>>n>>m; for(int i=1; i<=m; i++) { int x,y; cin>>x>>y; add(x,y); add(y,x);//建图 } for(int i=1; i<=n; i++) { if(!vis[i]) { root=i; tarjan(root); } } int ans=0; for(int i=1; i<=n; i++) { ans+=cnt[i]; } cout<<ans<<endl; for(int i=1;i<=n;i++) { if(cnt[i]) cout<<i<<" "; } return 0; }
割边
定义:
对于⼀个⽆向图,如果删掉⼀条边后图中的连通分量数增加了,则称这条边为桥或者割边。严谨 来说,就是:假设有连通图 G=V,E, 是其中⼀条边(即 e∈E),如果 G-e是不连通 的,则边 e是图 G的⼀条割边(桥)。
通俗来讲,如果去掉边能将这个图割成更多小块,这条边就是割边(桥)
实现:
实现 和割点差不多,只要改⼀处: low[v]>dfn[u]就可以了,⽽且不需要考虑根节点的问题。 原来我们求割点的时候,发现点 v不经过⽗节点 u就⽆法回到祖先节点,所以顶点u 是割点。
对于边来说,如果⼦节点 只能通过当前这条边到达⽗亲节点 ,则说明当前这条边是割边。但是要 注意的是,我们只改上⾯说的那⼀处,会有问题。我们⽤两条边来表示⽆向图的⼀条边,在搜索 的时 候会通过反边往⽗亲 ⾛,如果这个时候更新 的话,将会导致错误。所以需要特判反边(注意,如 果⽤特判⽗亲节点的⽅法规避反边,会在重边图出错)。
代码就懒得打了(其实是不会)