## 无向图的双连通分量 ### 一、割点和割边 * **割点**:在无向连通图中,删除一个顶点以及和它相邻的所有边,图中的连通分量个数增加,则该顶点称为 **割点** * **割边**(**桥**):在无向连通图中,删除一条边,图中的连通分量个数增加,则该条边称为 **割边** 或者 **桥** **举栗子:** 割点 ![](https://img2022.cnblogs.com/blog/8562/202204/8562-20220402120325132-50312563.png) 割边 ![](https://img2022.cnblogs.com/blog/8562/202204/8562-20220402120426898-1365343677.png) ### 二、边双连通分量 和 点双连通分量 #### 1.边双连通分量 **边双连通图**:若一个无向图的点两两间都有两条不重合的路径,那么我们就称这个无向图是 **边-双连通** 的。 我们看看这个定义是什么意思,任意两点都有两条不重合的路径,就是说任意点都有两条边可以到达,那么任意去掉一条边,肯定还有另一条边连接,也就是说这个图中不存在割边,所以这个图是边双连通图。 我们画个图来理解:
这下来大家应该明白什么边双连通了,接下来讲边双连通分量(分支) 。 所谓分支就是一个子图,那么边双连通分支就是说原图中 **最大** 的一个 **双连通分支的子图**,一定是最大不然会影响结果。比较好理解,直接上图。
这个图有两个边双连通分量, 边双连通分量,就是这么多内容,我们再讲讲边双连通分量缩点。 如果将 **边双连通分量** 用一个点表示,那么就叫做$E-DCC$ **缩点** 。 经过缩点后建的图必然不存边双连通分量,图中存在的边都不在边双连通分量中,也就是说缩点后的边都是桥。
> **感悟:因为桥是一条割边,也就是唯一的一条通路,其它有多条通路的点都缩成了一个点,剩下的这条边,一定是割边。** #### 2.点双连通分量 定义:任意两条边都在一个简单环中。 就是说没有割点。还是画图吧!
这两个最大连通子图就是点双连通分支,类比边双连通分支。 也就是说 经过 **缩点** 后的图中的点除了只有一条边的的点都是割点。 比如$A,B$都是缩点后形成的新点,它们俩都是只有一条边连向割点,而割点肯定有最少两条边,$5$就是割点。
### 三、求割点和割边 $Tarjan$算法除了栈还引入了$2$个数组,分别是: * $dfn[N]$ 节点的 **时间戳**,用来 **标记节点访问的先后顺序** 以及 **是否被访问过**, 理解为自己的出生日期。 * $low[N]$ 当前 **环** 里最先被访问到的节点,相当于 **当前这个连通分量里的根**, 理解为你家老祖宗的出生日期。 #### 割点判定 ##### (1) $x$不是根节点 在搜索过程中发现存在$x$的一个子节点$y$,满足$low[y] ≥ dfn[x]$, 那么$x$是割点 ##### (2) $x$是根节点 在搜索过程中发现 **至少存在两个子节点** $y_i$,满足$low[y_i] ≥ dfn[x]$ ,那么$x$是割点 先来看 **第一种,$x$不是根节点** ,分为以下三个式子讨论: ① 如果$low[y] > dfn[x]$,如下图所示: ![](https://img2022.cnblogs.com/blog/8562/202204/8562-20220402124618859-396007554.png) ② 如果$low[y] = dfn[x]$,如下图所示: ![](https://img2022.cnblogs.com/blog/8562/202204/8562-20220402124707778-1881103697.png) ③ 如果$low[y] < dfn[x]$,如下图所示: ![](https://img2022.cnblogs.com/blog/8562/202204/8562-20220402124748055-1176186898.png) 综上可知,当 **$x$不是根节点** ,并且 $low[y] ≥ dfn[x]$时,说明节点$x$是割点。 再来看 **第二种: $x$是根节点**,分两种情况讨论: ① 如果$x$是根节点,但是它 **只有一个子节点**: ![](https://img2022.cnblogs.com/blog/8562/202204/8562-20220402124843687-760683236.png) ② 如果$x$是根节点,但是它 **至少有两个子节点**: ![](https://img2022.cnblogs.com/blog/8562/202204/8562-20220402124907333-1834494033.png) #### 割边判定 在搜索树上存在$x$的一个子节点$y$,满足$low[y] > dfn[x]$,说明 **无向边**$(x,y)$是 **桥 (割边)** 分为以下三个式子讨论: 当$low[y]>dfn[x]$时: ![](https://img2022.cnblogs.com/blog/8562/202204/8562-20220402125042972-160264392.png) 当$low[y]=dfn[x]$时: ![](https://img2022.cnblogs.com/blog/8562/202204/8562-20220402125110510-1611304938.png) 当$low[y]dfn[x]$时,才能说明边$(x,y)$是一条割边 ### 四、成对变换 > 在无向图中,求割边时,不需要考虑从子节点到父节点之间的边!! 原因如下: ![](https://img2022.cnblogs.com/blog/8562/202204/8562-20220402125212901-1426312211.png) 为了解决这样的问题,我们使用了成对变换的办法: 对于非负整数$n$: 当$n$是 **偶数**,$n$ ^ $1=n + 1$ 当$n$是 **奇数**,$n$ ^ $1=n − 1$ 这一性质经常用于图论邻接表中 **边集** 的存储。在具有无向边的图中把一条正反方向的边分别存储在邻接表数组的第$n$与$n + 1$位置(其中$n$是偶数),那么就可以通过 ^ $1$运算获得与当前边$(x,y)$所反向的边$(y,x)$的存储位置了(存储位置也就是这条边的编号) 如下图: ![](https://img2022.cnblogs.com/blog/8562/202204/8562-20220402151012994-1453691437.png) 从中我们发现 **成对变换** 可以 帮助我们在求割边时避免从子节点走回到父节点 。 $Q$:**如何理解割边的代码模板中的`i!=(from^1)`呢?** 答:就是为了不走回头路! ![](https://img2022.cnblogs.com/blog/8562/202204/8562-20220402151043267-1741512673.png) #### $Tarjan$ - 时间戳$dfn$:第几个搜到这个点 - 返祖边:搜索树上一个点连向其祖先节点的边 - 横插边:搜索树上一个点连向它另一条支链上的点的边(只存在于有向图 - 追溯值$low$:经过一次返祖边能到达的最上面的点(可以先往下走到有返祖边的地方再走返祖边 ![](https://dsideal.obs.cn-north-1.myhuaweicloud.com/HuangHai/BlogImages/%7Byear%7D/%7Bmonth%7D/%7Bmd5%7D.%7BextName%7D/20230726082723.png) ![](https://dsideal.obs.cn-north-1.myhuaweicloud.com/HuangHai/BlogImages/%7Byear%7D/%7Bmonth%7D/%7Bmd5%7D.%7BextName%7D/20230726082739.png) 左边是一个无向图,右边是经过$dfs$后形成的树,绿色的是反祖边,紫色的是时间戳$dfn$,蓝色的是追溯值$low$ **$u$是割点的条件是**: - $v$是$u$搜索树上的一个儿子:$dfn[u] <= low[v]$——$v$的子树中没有返祖边能跨越$u$点 - 有多个儿子的根结点 比如上述的十号节点 **$$是桥的条件是**: - 搜索树上$v$树$u$的儿子:$dfn[u] < low[v]$——$v$的子树中没有返祖边能跨越< $u-v$ >这条边 比如上述的$<8-10>$边 ### 五、【模板】割边 **[P1656 割边板子](https://www.luogu.com.cn/problem/P1656)** ```cpp {.line-numbers} #include using namespace std; const int N = 5e5 + 10, M = 4e6 + 10; typedef pair PII; #define x first #define y second // 链式前向星 int e[M], h[N], idx, w[M], ne[M]; void add(int a, int b, int c = 0) { e[idx] = b, ne[idx] = h[a], w[idx] = c, h[a] = idx++; } int dfn[N], low[N], ts, root; set _set; void tarjan(int u, int fa) { dfn[u] = low[u] = ++ts; // 对比有向图强连通分量的代码,这里没有入栈的操作,原因是本题只想求割边,并不是想缩点, // 怀疑缩点时就需要加入stk,in_stk等操作了,待验证 for (int i = h[u]; ~i; i = ne[i]) { int v = e[i]; if (v == fa) continue; if (!dfn[v]) { tarjan(v, u); low[u] = min(low[u], low[v]); // 记录割边,论证过的结论 if (low[v] > dfn[u]) _set.insert({u, v}); // SET天生会排序,按first由小到大 } else low[u] = min(low[u], dfn[v]); } } int n, m; int main() { #ifndef ONLINE_JUDGE freopen("P1656.in", "r", stdin); #endif memset(h, -1, sizeof h); scanf("%d %d", &n, &m); while (m--) { int a, b; scanf("%d %d", &a, &b); if (a != b) add(a, b), add(b, a); } for (root = 1; root <= n; root++) if (!dfn[root]) tarjan(root, -1); for (auto i : _set) printf("%d %d\n", i.x, i.y); return 0; } ``` ### 六、【模板】割点 **[P3388 【模板】割点(割顶)](https://www.luogu.com.cn/problem/P3388)** > **注意**:本题如果`low[u] = min(low[u], dfn[j]);`写成`low[u] = min(low[u], low[j]);`会有两个点的错误,一定要严格按算法模板来! ```cpp {.line-numbers} #include using namespace std; const int N = 5e5 + 10, M = 4e6 + 10; // 链式前向星 int e[M], h[N], idx, w[M], ne[M]; void add(int a, int b, int c = 0) { e[idx] = b, ne[idx] = h[a], w[idx] = c, h[a] = idx++; } int dfn[N], low[N], ts, root; set _set; void dfs(int u, int fa) { low[u] = dfn[u] = ++ts; int son = 0; for (int i = h[u]; ~i; i = ne[i]) { int v = e[i]; if (v == fa) continue; if (!dfn[v]) { son++; // 儿子数量在割点时有用 dfs(v, u); low[u] = min(low[u], low[v]); // 记录割点 if (u != root && low[v] >= dfn[u]) _set.insert(u); if (u == root && son >= 2) _set.insert(u); } else low[u] = min(low[u], dfn[v]); } } int n, m; int main(void) { scanf("%d %d", &n, &m); // 链式前向星初始化 memset(h, -1, sizeof h); while (m--) { int a, b; scanf("%d %d", &a, &b); add(a, b), add(b, a); } for (root = 1; root <= n; root++) if (!dfn[root]) dfs(root, -1); printf("%d\n", _set.size()); for (auto i : _set) printf("%d ", i); return 0; } ``` ### 七、【模板】边双连通分量 **[$P8436$ 【模板】边双连通分量](https://www.luogu.com.cn/problem/P8436)** ```cpp {.line-numbers} #include using namespace std; const int N = 5e5 + 10, M = 4e6 + 10; int e[M], h[N], idx, w[M], ne[M]; void add(int a, int b, int c = 0) { e[idx] = b, ne[idx] = h[a], w[idx] = c, h[a] = idx++; } int dfn[N], low[N], stk[N], top, ts, root; int n, m; // 边双模板 int bcnt; vector bcc[N]; // 这里与有向图的强连通分量有区别,那个是id[N]+sz[N]静态数组实现,记录的是某个点在哪个强连通分量中, // 而这里,记录的是某个边双连通分量中有哪些节点,是捋着的顺序不同,实现方式不同,无法追求统一,否则就会在某种场景下造成使用双重循环使得代码TLE掉, // 不要问我是怎么知道的~ void tarjan(int u, int from) { dfn[u] = low[u] = ++ts; stk[++top] = u; // 边双连通分量模板,对比有向图强连通分量,只用栈记录数据,并没有用状态数组in_stk标识是否某节点在栈中 for (int i = h[u]; ~i; i = ne[i]) { int v = e[i]; if (!dfn[v]) { tarjan(v, i); // v:点,i:哪条边 low[u] = min(low[u], low[v]); } else if (i != (from ^ 1)) low[u] = min(low[u], dfn[v]); } if (dfn[u] == low[u]) { ++bcnt; // 边双数量+1 int x; do { x = stk[top--]; bcc[bcnt].push_back(x); // 记录边双中有哪些点 } while (x != u); } } int main() { memset(h, -1, sizeof h); scanf("%d %d", &n, &m); while (m--) { int a, b; scanf("%d %d", &a, &b); if (a != b) add(a, b), add(b, a); } for (root = 1; root <= n; root++) if (!dfn[root]) tarjan(root, -1); // 个数 printf("%d\n", bcnt); for (int i = 1; i <= bcnt; i++) { printf("%d ", bcc[i].size()); for (auto j : bcc[i]) printf("%d ", j); printf("\n"); } return 0; } ``` ### 八、【模板】点双连通分量 **[$P8435$ 【模板】点双连通分量](https://www.luogu.com.cn/problem/P8435)** ```cpp {.line-numbers} #include using namespace std; const int N = 5e5 + 10, M = 4e6 + 10; int e[M], h[N], idx, w[M], ne[M]; void add(int a, int b, int c = 0) { e[idx] = b, ne[idx] = h[a], w[idx] = c, h[a] = idx++; } // 点双模板 int dfn[N], low[N], stk[N], ts, top, root; vector bcc[N]; int bcnt; void tarjan(int u, int fa) { low[u] = dfn[u] = ++ts; stk[++top] = u; int son = 0; for (int i = h[u]; ~i; i = ne[i]) { int v = e[i]; if (v == fa) continue; if (!dfn[v]) { son++; tarjan(v, u); low[u] = min(low[u], low[v]); if (low[v] >= dfn[u]) { int x; bcnt++; do { x = stk[top--]; bcc[bcnt].push_back(x); } while (x != v); // 将子树出栈 bcc[bcnt].push_back(u); // 把割点/树根也丢到点双里 } } else low[u] = min(low[u], dfn[v]); } // 特判独立点 if (fa == -1 && son == 0) bcc[++bcnt].push_back(u); } int n, m; int main() { memset(h, -1, sizeof h); scanf("%d %d", &n, &m); while (m--) { int a, b; scanf("%d %d", &a, &b); if (a != b) add(a, b), add(b, a); } for (root = 1; root <= n; root++) if (!dfn[root]) tarjan(root, -1); // 个数 printf("%d\n", bcnt); for (int i = 1; i <= bcnt; i++) { printf("%d ", bcc[i].size()); for (int j = 0; j < bcc[i].size(); j++) printf("%d ", bcc[i][j]); printf("\n"); } return 0; } ``` ### 九、常见问题 #### ① 割点和桥之间没有任何关系 * 两个割点之间的边一定是桥吗?不一定是,如下图:
* 一个桥的两个端点一定是割点吗?不一定是,如下图:
#### ② 边连通分量 和 点连通分量 之间也没有任何关系 * 一个图是边连通分量,则它一定是点连通分量吗?不一定是,如下图:
(2)一个图是点连通分量,则它一定是边连通分量吗?不一定是,如下图:
#### ③ 对于一棵树来说,所有的边都是桥,因此树中的每个点都是一个边连通分量 除叶节点外的所有节点都是割点,因此每条边以及边的两个端点构成的图都是点连通分量。 #### ④ 点双连通分量的缩点
再来一个
将($1,2,3,4,5$)命名为$1$号连通块 将($1,6$)命名为$2$号连通块 将($6,7$)命名为$3$号连通块 将($6,8,9$)命名为$4$号连通块 将割点$1$命名为$5$号 将割点$6$命名为$6$号 将每个割点与它从前所属于的连通块进行联边,形如下:
此图也可以参考 [这里](https://blog.csdn.net/hailiang70303/article/details/87553759) 一个更复杂的样例
缩点后成为:
### 十、关于点双和边双的思考 **点双** 的判定要求比 **边双** 严格。 点双和边双都是一系列点构成的环, **在不考虑两点一边是点双的特殊情况下**, * ① **点双一定是边双** * ② **边双不一定是点双** * ③ **边双是由一些点双组成的** * ④ **点双内的任意两条边都在同一个简单环中** 举个 **栗子** 方便理解:
**由两个点双组成的边双** ### 十一、答疑解惑 #### $Q$:为什么一定要用成对变换这种技巧,直接用邻接表的方法为什么不对呢? 重边对点双连通没有影响,但是对于边双连通有影响。 为什么重边对点双连通没有影响,因为在删除一个点之后,与它相连的点都会被删除,那么既然会被删除,即使再多重边对它也没有太大的影响。 而重边对于边双连通的影响很大,我举个例子: ![](https://dsideal.obs.cn-north-1.myhuaweicloud.com/HuangHai/BlogImages/%7Byear%7D/%7Bmonth%7D/%7Bmd5%7D.%7BextName%7D/20230721164521.png) 因为重边的出现,图的连通性发生了改变,然而这种改变恰巧是颠覆性的! 比如说现在我问的是图中的双连通分支的数量:第一个图是$0$,第二个图是$1$。 在求边双连通时,要求对于任意俩点至少存在两条 **边不重复** 的路径,所以这个时候表示图我们不能用$vector$了,而是用链式前向星 > **注:因为$vector$ 记录的是$a,b$之间有边,但没有为$a,b$之间的边编号!** 添加边的时候我们要一次添加正反俩条边,而且要相互可以索引查找,类似网络流里的反向弧,这样在我们$dfs$求割边时要以边的下标作为标记,在访问了一条边时,要把这条边和其反向边同时标记为访问,最后对所有的边进行遍历,发现$low[v] < dfn[u]$时,同样要把正反俩条边标记成割边,最后在不经过桥的情况下$dfs$求出边双连通分量即可。 > **感叹**:不得不说,$Tarjan$确实是大神,用一个链式前向星,就把复杂的无向图边双解决的清清楚楚,真$TeNiang$的是个人才! > **[参考资料](https://blog.csdn.net/zbq_tt5/article/details/103432551)** ### 十二、题单 [受欢迎的牛](https://www.cnblogs.com/littlehb/p/16085479.html)【**有向图,缩点,出度为零**】 [可达性](https://www.cnblogs.com/littlehb/p/17581532.html)【**有向图、缩点、入度为零**】 [$NC51267$ $Knights$ $of$ $the$ $Round$ $Table$ 圆桌骑士](https://www.cnblogs.com/littlehb/p/17577284.html) 【**点双,黑白染色找奇数环**】 [ $HNOI2012$ 矿场搭建](https://www.cnblogs.com/littlehb/p/16099243.html) 【**点双,割点,分类讨论,组合数学**】 [$POJ$ $3694$ $Network$](https://www.cnblogs.com/littlehb/p/17578803.html)【**边双,割边,暴力$LCA$**】 [冗余路径$Redundant$ $Paths$](https://www.cnblogs.com/littlehb/p/16091857.html) 【**边双,割边,统计度,度为1的是叶子,叶子的一半是答案**】 [$Caocao’s$ $Bridges$ 曹操的桥](https://www.cnblogs.com/littlehb/p/17580108.html) 【**割边板子**】 [$NC20603$ [$ZJOI2007$]最大半连通子图](https://www.cnblogs.com/littlehb/p/16087322.html) 【**有向图,缩点,建新图,创建$DAG$,逆序求$DAG$拓扑序,拓扑序递推,更新点权和最大路径,更新方案数量**】 [$NC20603$ [$ZJOI2007$]嗅探器](https://www.cnblogs.com/littlehb/p/17579964.html) 【**起点,终点,范围内割点**】 [$NC51269$ $Network$ $of$ $Schools$](https://www.cnblogs.com/littlehb/p/16086329.html)【**入度为零的点相当于根,添加几条边可以将DAG变成强连通分量,答案是$max(m,n)$**】 [$NC106972$ $Cow$ $Ski$ $Area$ 滑雪](https://www.cnblogs.com/littlehb/p/17582121.html)【**根据矩阵高低关系重新建图,然后就是上一题学校网络了**】 [$NC20099$ [$HNOI2012$]矿场搭建](https://www.cnblogs.com/littlehb/p/16099243.html)【**点双,割点,分类讨论**】 [$NC19981$ [$HAOI2010$]软件安装](https://www.cnblogs.com/littlehb/p/17582876.html)【**有向图,缩点,建新图,树形$DP$,预留根节点空间**】 [$NC106112$ $Street$ $Directions$](https://www.cnblogs.com/littlehb/p/17585032.html)【**割边板子,扩展,割边保留两条边,非割边保留一条边**】 [$NC51319$ $King$'$s$ $Quest$](https://www.cnblogs.com/littlehb/p/17586822.html)【**有向图,图中点的记录技巧,强连通分量**】