Tarjan(连通性相关) 笔记
概念有点多
点(vertex)、边(edge)
- 无向图中
若图中存在两点可以到达,则称这两个点是 连通的(connected)
若图中任意两点都连通,则称该无向图为 连通图(connected graph)
若图
若图中删去某一条边,会使这个连通图分裂成两个不相连的子图,则称这条边为 桥 / 割边,类似地,若删去某一个点(以及与它相连的边),使所在的连通图分裂,则称这个点为 割点
(有割点不一定有桥,有桥不一定有割点)
若图中不存在割边,则称该图为 边双连通图(edge double connected graph)
若图中不存在割点,则称该图为 点双连通图(vertex double connected graph)
无向图的极大边双连通子图称为 边双连通分量(edge double connected component,e-DCC)
无向图的极大点双连通子图称为 点双连通分量(vertex double connected component,v-DCC)
- 有向图中
若图中存在单向路径 u -> v,则称 u 可达 v
若图中任意两点都互相可达,则称该有向图为 强连通图(strongly connected graph)
若不强连通的有向图把有向边变为无向边的无向图为连通图,则称该图为 弱连通图(weakly connected graph)
与无向图的连通分量类似,有 强连通分量(strongly connected component,SCC)(极大强连通子图)、弱连通分量(weakly connected component)(极大弱连通子图)
dfs 搜索一个有向图
- 树枝边(tree edge),在搜索树中就有的边,即搜索过程经过的边
- 反祖边(back edge),指向祖先的边
- 前向边(forword edge),指向子树的边(实际上没屁用的概念 qwq,因为显然这种边放到搜索树中不可能形成环)
- 横叉边(cross edge),右子树的点指向左子树的点的边(左子树指向未访问过的右子树,实际上就是一条树枝边)
强连通分量 scc
若一个点在一个 scc 中,则它一定在一个环路中,若搜索树中要形成环,只有两种情况
- 一是直接有返祖边
- 二是经过横叉边,再向上经过反祖边
引入两个变量:
dfn[u] 表示 u 的时间戳;low[u] 表示 从 u 开始往下走,能达到的最小时间戳
判定:low[u] = dfn[u],
此时该点是所属 scc 的“最高点”,因为若还能往上走形成更大的环,则一定有 low[u] < dfn[u]
在这里也说明,在 dfs 时,我们一定要更新的是 low,避免 scc 的节点提前被判定(单个点是显然成立的)
时间复杂度
code
void tarjan(int u) { low[u] = dfn[u] = ++ idx; in_stk[u] = true; stk.push(u); for (re i = h[u]; i; i = e[i].next) { int v = e[i].to; if (!dfn[v]) // tree edge { tarjan(v); low[u] = min(low[u], low[v]); } else if (in_stk[v]) // back edge / cross edge low[u] = min(low[u], low[v]); } if (low[u] == dfn[u]) { int ver; do { ver = stk.top(); stk.pop(); in_stk[ver] = false; ... // id / size ... }while (ver != u); // (先执行再判断)多执行一次,方便将 u 也一同弹出 cnt ++; } }
scc 缩点
因为每个 scc 里的点都是可以互相到达的,所以很多时候都可以把环看成一个点
发现一般的有向图缩点后,会形成 DAG,在 DAG 上就可以进行很多别的操作啦
想要实现缩点也很 easy
只要再遍历一遍所有点以及它的邻点,若两点分属不同的 scc,则将两 scc 的 id 连一条边即可。
还有 DAG 上求拓扑序的问题,有一个结论:scc 的递减 id 序列就是 DAG 的拓扑序
简单理解一下就是,从上往下递归,到叶子节点赋予小的 scc id,往上回溯时 scc id 变大。
割边/桥 -> 边双连通分量 e-DCC
有性质:e-DCC 中的任意两点都存在有两条没有公共边的路径
要求 e-DCC,也就是把图中的割边求出来,剩下的连通块就是 e-DCC
那么,对于一条边 x -> y,若它是桥,则,桥判定:dfn[x] < low[y]
感性理解一下就是,y 这个点,除了 x -> y 这条边,没有别的边可以让 low[y] 变得更小了,也就是桥,那么 y 所在的子树也就是 e-DCC
注意:在跑无向图构成的搜索树中,显然是没有 横叉边 和 返祖边的存在的
求完桥,dfs 搜一遍即可。
tip:对于无向图,用边的编号判重边,用
^1
时,链式前向星存图边计数器idx
初始化idx = 1
,这样存双向边就能2, 3
4, 5
..... 成对编号
code 1
void tarjan(int u, int id) { low[u] = dfn[u] = ++ idx; for (re i = h[u]; i; i = e[i].next) { int v = e[i].to; if (!dfn[v]) { tarjan(v, i); low[u] = min(low[u], low[v]); if (dfn[u] < low[v]) bridge[i] = bridge[i ^ 1] = true; } else if (i != (id ^ 1)) low[u] = min(low[u], dfn[v]); } } void dfs(int u) { edcc[u] = cnt; for (re i = h[u]; i; i = e[i].next) { int v = e[i].to; if (edcc[v] || bridge[i]) continue; dfs(v); } }
同时,还有一种方法,其实在构成搜索树时,我们就把这个无向图看成了有向图,每条边至多访问一遍,被抽象为有向图的无向图中的一个强连通分量,在原图中是一定一个边双联通分量。那么可以仿照求 scc 的思路求 e-dcc
code 2
void tarjan(int u, int id) { low[u] = dfn[u] = ++ idx; stk.push(u); for (re i = h[u]; i; i = e[i].next) { int v = e[i].to; if (!dfn[v]) { tarjan(v, i); low[u] = min(low[u], low[v]); } else if (i != (id ^ 1)) low[u] = min(low[u], dfn[v]); } if (dfn[u] == low[u]) { ... } }
e-DCC 缩点
我们在求 e-DCC 时,标记了割边的编号
那么缩点就很容易了,只需便利一遍所有边,若为割边,则对连点所在的 e-DCC 连边
可以发现,缩完点后,图中只剩下割边,也就是形成了一棵树或森林
割点 -> 点双连通分量 v-DCC
对于图中某个点 x 及其儿子 y, 割点判定:
- 若
, - 若
,则应至少存在两组 ,使
因为在搜索树中,非根节点(且有儿子,非叶子节点)一定至少有两条边连着它,而根节点不一定有多个儿子节点。
tip:注意,这里求割点,对于已访问的入点,必须
low[u] = min(low[u], dfn[v])
,而不能用入点的low[v]
更新,因为如果出现了入点已访问过,那么说明出现了环,而注意到我们的判定的实际含义是入点在 不经过出点 的情况下,不能绕行其他节点到达更早访问的点,则可以判定出点为割点。 所以若用
low[v]
更新,则代表 穿过出点,不合法。
求割点 code
void tarjan(int u) // 每次传入 u = root { low[u] = dfn[u] = ++ idx; int cnt = 0; for (re i = h[u]; i; i = e[i].next) { int v = e[i].to; if (!dfn[v]) { tarjan(v); low[u] = min(low[u], low[v]); if (dfn[u] <= low[v]) { cnt ++; if (u != root || cnt > 1) cut[u] = true; } } else low[u] = min(low[u], dfn[v]); } }
求 v-dcc code
void tarjan(int u) { low[u] = dfn[u] = ++ idx; stk.push(u); // int kid = 0; if (u == root && h[u] == 0) { vdcc[++ cnt].push_back(u); return; } for (re i = h[u]; i; i = e[i].next) { int v = e[i].to; if (!dfn[v]) { tarjan(v); low[u] = min(low[u], low[v]); if (dfn[u] <= low[v]) { // kid ++; // if (u != root || kid > 1) cut[u] = true; cnt ++; int ver; do { ver = stk.top(); stk.pop(); vdcc[cnt].push_back(ver); }while (ver != v); // 注意这里是个易错点,不要打成 ver != u vdcc[cnt].push_back(u); } } else low[u] = min(low[u], dfn[v]); } }
v-DCC 缩点
对割点 裂点。
所以,我们其实可以得到一颗节点数为 点双数 + 割点数 的树或森林,
考虑遍历整个图,对于每个 v-DCC,把它与它包含的割点连边即可。
最后,如果一道题需要无向图缩点,而并不好确定是 点双缩点 还是 边双缩点,注意:
边双是定义在点上的,即每个点只属于一个边双;点双是定义在边上的,即每条边只属于一个点双
圆方树
这里我讨论的仅仅是用于处理一般图的广义圆方树,仙人掌?是什么,能吃吗 qwq
圆方树,可以说是一种思想,这里我看到主要是用来处理 有环无向图的必经点问题
首先,两点的必经点在无向图上,也就是割点,所以,圆方树就是定义在点双上的
具体地,把原图中的点叫作圆点,而对于每一个点双,我们可以增加一个代表点,叫做方点,
将图重新构造,令所有圆点都连向所在点双(可能多个)的方点上,可以构造出一颗树!而这颗树与原无向图是等价的,圆点维护原来点上的信息,而方点则可以维护该点双上的信息
有树就非常好了
这样要做必经点的问题,就相当于是求给定两点在圆方树上的简单路径上的圆点数量呗,lca 乱搞 ok
练习:
P3854 [TJOI2008] 通讯网破坏
P4320 道路相遇
P5058 [ZJOI2004] 嗅探器
P4606 [SDOI2018] 战略游戏(由两点拓展为 k 个点,可以用虚树做,但我不会,当然也可以维护一个 dfn 序做)
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 清华大学推出第四讲使用 DeepSeek + DeepResearch 让科研像聊天一样简单!
· 推荐几款开源且免费的 .NET MAUI 组件库
· 实操Deepseek接入个人知识库
· 易语言 —— 开山篇
· Trae初体验