图的连通性(tarjan) 学习笔记
本文可能含有:部分代码省略,部分资源来源于网络,极其模糊不清的语言表述,粗浅到浮于言表的对于此算法的认识。
本文仅用于个人学习与报告使用。若有侵权,请洛谷私信联系笔者要求删除。
就连上述文字都是抄袭大佬 @GClock_519 的,可以看得出笔者拙劣的语文水平(
图的连通性相关,顾名思义,可以理解为与「图是否连通」相关的性质。
具体来说,可以分为无向图的双连通性与有向图的强连通性。
有向图的强连通性
先说一下定义。一个有向图
我们不讨论无向图是否强连通,因为显然一个无向连通图任意两个点都互相可达。
引入强连通分量(简称 SCC)的概念:极大的强连通子图。「子图」很好理解,可「极大」是什么意思呢?我们可以感性理解为尽可能大。具体地,指该强连通图不是任何一个强连通图的子图。
例如有一个图:
我们发现
所以怎么求强连通分量呢?
Tarjan 算法
你说得对,但是 Robert E. Tarjan(罗伯特·塔扬,1948~),生于美国加州波莫纳,计算机科学家。Tarjan 发明了很多算法和数据结构。不少他发明的算法都以他的名字命名,以至于有时会让人混淆几种不同的算法。比如求各种连通分量的 Tarjan 算法,求 LCA(Lowest Common Ancestor,最近公共祖先)的 Tarjan 算法。并查集、Splay、Toptree 也是 Tarjan 发明的。
Tarjan 爷爷 Orz。
直接来看 Tarjan 算法的操作流程。我们考虑从任意一个结点开始 dfs,并记录每个点被搜到的时间(也称为「时间戳」),记为
同时,想象有一个栈,当结点刚被遍历到,即当前不属于任何一个强连通分量时塞入栈中。定义每个结点可以访问到的、时间戳最小的、在栈内的点的时间戳为
考虑怎么求
可以发现,我们遍历过的边会成为原图的一棵子树。遂把这些边成为树边。那么这个图显然还有一些非树边,我们这么定义:
- 指向祖先结点的边,称为返祖边;
- 指向孙子结点的边,称为前向边;
- 由时间戳大的点
指向时间戳小的且非 的祖先的点 的边,称为横叉边。
可能有点抽象,可以参考 OI Wiki 的具体例子理解。
接下来考虑怎么求
- 当这个结点刚被遍历到的时候,
。
然后对于一个点
- 我们通过「返祖边」,访问到了
更小的点 , 。【处理返祖边】 - 子结点
的 已经被更新得更小, 。
于是我们可以发现:对于栈内每一个
我们找到栈中一个
为什么是
发现并没有前向边什么戏份,的确前向边也不会影响(
总结一下 Tarjan 算法流程:
- 选个没被访问的点
,入栈,初始化 以及 ,进行 dfs。 - 如果连着的点
也没被访问,先递归更新 ,然后 。 - 如果是通过「返祖边」遍历到已在栈中的点
,则 。 - 遍历所有点后,若
,将同一强连通分量的点统统出栈并统计,直到 被弹出。 - 找没被访问过的点,重复步骤
。
Code:
int dfn[N], low[N], vis[N], col[N]; // low 表示每个点可以遍历到的最小的时间戳,dfn 表示每个点的时间戳。
// vis 表示每个点在不在栈里面。
// col 表示每个点所在的强连通分量的编号。
vector<int> st; // 栈。
int dfncnt, colcnt;
void tarjan(int u)
{
if(!dfn[u]) dfn[u] = low[u] = ++dfncnt;
vis[u] = 1;
st.push_back(u); // 放入栈。
for (int i = hd[u]; i; i = nxt[i]) {
int v = to[i];
if(!dfn[v]) tarjan(v), low[u] = min(low[u], low[v]); // 如果 v 没遍历过就求 v 然后更新。
else if (vis[v]) low[u] = min(low[u], low[v]); // 如果 v 遍历过了而且在栈中就直接更新。
// 否则 v 已经在某个强连通分量里面了,不用管。
}
if(dfn[u] == low[u]) { //u 是某个强连通分量的跟。
colcnt++;
while(st.back() != u) {
int v = st.back();
col[v] = colcnt;
vis[v] = 0;
st.pop_back();
}
col[u] = colcnt;
vis[u] = 0;
st.pop_back();
}
}
上面是我用语言描述的算法流程,可能抽象得只有我一个人能看懂。
推荐观看 此视频 以更加深刻理解 tarjan 求 SCC。
应用:缩点
可以发现:如果把一个强连通分量替换成一个点,不挪动与外界的入边和出边,可以形成一个有向无环图。「有向」是因为原图为有向图,「无环」是因为一个环显然属于某个强连通分量,会被缩掉。
然后就可以在这个 DAG 图上面乱搞进行一些操作了,例如记搜、拓扑排序、最短路 等。
看这题:【模板】缩点。我们需要找到经过点权之和最大的路径。想象一下,我们如果在一个强连通分量里面走,那么我们得把每个点都踩一遍(因为点权
不过「把每个点都踩一遍」有一个限制:允许多次经过一条边或者一个点,但是,重复经过的点,权值只计算一次。这句话很重要,看到这句话要优先想到 Tarjan 缩点这个模型。
不知道说啥了,贴个模板题代码吧:
#include <iostream>
#include <cstdio>
#include <vector>
#include <queue>
using namespace std;
const int N = 10010, M = 100010;
int n, m;
int V[N];
int nxt[M], to[M], fr[M], hd[N], idx;
void add(int u, int v) {
nxt[++idx] = hd[u], fr[idx] = u, to[idx] = v, hd[u] = idx;
}
int dfn[N], low[N], vis[N], dfnCnt;
vector<int> st;
int colCnt, col[N], colV[N];
void tarjan(int u) {
if(!dfn[u]) dfn[u] = low[u] = ++dfnCnt;
vis[u] = 1; st.push_back(u);
for (int i = hd[u]; i; i = nxt[i]) {
int v = to[i];
if(!dfn[v]) tarjan(v), low[u] = min(low[u], low[v]);
else if (vis[v]) low[u] = min(low[u], low[v]);
}
if(dfn[u] == low[u]) {
colCnt++;
while(st.back() != u){
int x = st.back();
col[x] = colCnt;
colV[colCnt] += V[x];
vis[x] = 0;
st.pop_back();
}
col[u] = colCnt; vis[u] = 0; st.pop_back(); colV[colCnt] += V[u];
}
}
int in[N], dis[N];
int topo() {
queue<int> q;
for (int i = 1; i <= n; i++) {
if(!in[i]) dis[i] = colV[i], q.push(i);
}
int ans = 0;
while(q.size()) {
int u = q.front(); q.pop();
ans = max(ans, dis[u]);
for (int i = hd[u]; i; i = nxt[i]) {
int v = to[i]; in[v]--;
if(!in[v]) q.push(v);
dis[v] = max(dis[v], dis[u] + colV[v]);
}
}
return ans;
}
int main() {
scanf("%d%d", &n, &m);
for (int i = 1; i <= n; i++) scanf("%d", &V[i]);
for (int i = 1; i <= m; i++) {
int u, v; scanf("%d%d", &u, &v);
add(u, v);
}
for (int i = 1; i <= n; i++) if (!dfn[i]) tarjan(i);
for (int i = 1; i <= n; i++) hd[i] = 0;
idx = 0;
for (int i = 1; i <= m; i++) {
int u = fr[i], v = to[i];
if(col[u] == col[v]) continue;
add(col[u], col[v]); in[col[v]]++;
}
n = colCnt, m = idx;
printf("%d", topo());
return 0;
}
一个比较好的 SCC & 缩点基础题题单:强连通分量 & 缩点。
例题
1. P2341 [USACO03FALL / HAOI2006] 受欢迎的牛 G
题意:一个有向图,输出所有点直接或直接或间接可达的点(称为「明星点」)的数量。
经典题。我们发现一个强连通分量的点互相连通,考虑缩点得到拓扑图,利用拓扑图性质简化思考。
接下来,对于每个点,如果他是明星点,那么显然它不能有出边(出边指向的点不能到达它自身,因为 DAG 无环)。也就是说,它得出度为
所以输出出度为
你先别急。假如新图有两个出度为
统计每个强连通分量的入度、所含点数即可。代码很好写就不放了。
2. P7251 [JSOI2014] 强连通图
题意很清晰就不放了。
第一问很好想,无向图上若干个点互相可达,就差把求强连通分量印在题面了。对于所有强连通分量点数取
考虑第二问。同样很经典。我们发现:把一个入度为
于是我们将所有入度为
3. P2169 正则表达式
看到题目先想到最短路。但是题目有一个限制:如果两点
互相可达,显然就是求强连通分量嘛。所以缩点后再跑最短路即可。
无向图的边双连通性
如果一个无向图
当然总有一些图(记作
换句话说,没有割边的无向图就是边双连通图。
如下图为一个边双连通图。
我们删去任意一条边,原图仍连通。
而下图不是一个边双连通图:
我们删去边
类比强连通分量的定义,极大的边双连通子图就是边双连通分量。一个无向图的边双连通分量不是任何该图的边双连通子图的子图。
特别地,一个点可能是一个边双连通分量。一个孤点必然为一个边双连通分量。
我们同样使用 Tarjan 算法求割边。
Tarjan 算法
我们同样选择一个点 dfs 整一个生成树。类比 Tarjan 求 SCC,定义
我们发现,此时不需要定义一个栈来避免已被更新过的边双被横叉边干扰。因为无向图 dfs 生成树中不存在横叉边。简略证明:
假设无向图的 dfs 树中存在边
(其中 不为 的祖先),那么当我们 dfs 搜到结点 时也可以直接搜到 ,让 成为树边。故不存在横叉边,命题得证。
先来看怎么求割边吧。
求割边
然后考虑求
若
那么怎么判断割边呢?对于一条边
,则通过 只走一条返祖边可以访问到 的祖先。也就是说 的祖先有两种走法可以到 。那它们是在一个边双里面的,显然 不为割边。 ,同理, 和 在一个边双里面,当前边不是割边。 ,说明 无法访问到时间戳 的边。如果我们将 切断, 就无法与 连通了。说明 为割边。
综上即为割边算法。代码与边双写一起了。
求边双
我们同样使用一个栈存结点,当结点刚被搜到时入栈。同样定义一个边双连通分量
类似地,我们发现这个边双连通分量的所有结点的
找到根后,我们弹栈,知道弹出
#include <iostream>
#include <cstdio>
#include <vector>
using namespace std;
const int N = 500010, M = 4000010;
int n, m;
struct Graph {
int nxt[M], to[M], hd[N], idx;
bool cut[M];
Graph() {
idx = 1; for (int i = 1; i < N; i++) hd[i] = 0;
for (int i = 1; i < M; i++) cut[i] = 0;
};
void add(int u, int v) {
nxt[++idx] = hd[u], to[idx] = v, hd[u] = idx;
}
} G;
int low[N], dfn[N], dc;
int id[N], idc;
vector<int> st;
vector<int> ans[N];
void tarjan(int u, int faE) {
dfn[u] = low[u] = ++dc;
st.push_back(u);
for (int i = G.hd[u]; i; i = G.nxt[i]) {
if(i == (faE ^ 1)) continue; //注意不要访问 子->父 边,但因为可能有重边所以得这么写
int v = G.to[i];
if(!dfn[v]) {
tarjan(v, i);
if(low[v] > dfn[u]) G.cut[i] = 1; // 是割边
low[u] = min(low[u], low[v]);
}
else low[u] = min(low[u], dfn[v]);
}
if(low[u] == dfn[u]) { // 弹栈统计
idc++;
while(st.back() != u) {
int v = st.back();
st.pop_back();
id[v] = idc;
ans[idc].push_back(v);
}
st.pop_back();
id[u] = idc;
ans[idc].push_back(u);
}
}
int main() {
scanf("%d%d", &n, &m);
for (int i = 1; i <= m; i++) {
int u, v;
scanf("%d%d", &u, &v);
G.add(u, v);
G.add(v, u);
}
for (int i = 1; i <= n; i++) if(!dfn[i]) tarjan(i, 0);
printf("%d\n", idc);
for (int i = 1; i <= idc; i++) {
printf("%d ", ans[i].size());
for (auto x : ans[i]) printf("%d ", x);
printf("\n");
}
return 0;
}
无向图的点双连通性
与边双连通性定义很像。我直接复制粘贴上面并加粗的不一样的地方了。
如果一个无向图
当然总有一些图(记作
极大的点双连通子图就是点双连通分量。一个无向图的点双连通分量不是任何该图的点双连通子图的子图。
特别的,两个点用一条边相连组成的图可能为一个点双连通分量。点双有两种表现形式:
- 孤点。
- 两个点用一条边相连组成。
个点。其中每两个点都在点双的一个简单环(就是我们平时认为的环)上。
同样,我们观察点双连通分量与割点的关系。不难发现两个点双可以相交,有且仅有一个交点,为割点。
两个点双相交的例子如下:
如上图相同颜色的所有结点组成一个点双。
简单证明:
假设原连通图
中的两个点双连通分量 。若 和 有多个交点,那么我们删去其中任意一个交点, 、 仍联通,与点双的定义矛盾。命题得证。
我们同样使用 Tarjan 算法。
Tarjan 算法
定义和边双完全一样,此处略。
不过求割点和求点双不尽相同。
求割点
求
-
对于一个 非 dfs 树根结点
,我们寻找 dfs 生成树上的 的子孙 。如果 ,说明 没法只通过一条返祖边访问到 以上的结点。那么我们此时把 断掉, 就上不去了!所以当存在
时, 为割点。需要注意的是当
为树根结点时, 是一定小于 的。而根结点不一定为割点。所以我们上述结论对根结点不适用。 -
当根结点连着多条树边时,我们断掉根结点,剩下的子树就不连通了。所以当根结点所连树边数量
时其为割点。
模板题(P3388 【模板】割点)代码如下:
#include <iostream>
#include <cstdio>
#include <vector>
using namespace std;
const int N = 100010;
int n, m;
vector<int> G[N];
int dfn[N], low[N], dc;
bool f[N];
void tarjan(int u, int fa, int ro) {
dfn[u] = low[u] = ++dc;
int ch = 0; // 儿子数量
for (int v : G[u]) {
if(v == fa) continue; // 不考虑子 -> 父
if(!dfn[v]) { // 没被遍历过,是儿子
ch++;
tarjan(v, u, ro);
low[u] = min(low[u], low[v]);
if(low[v] >= dfn[u] && u != ro) f[u] = 1; // 记得判断 u 是否为根结点
}
else low[u] = min(low[u], dfn[v]);
}
if(ch >= 2 && u == ro) f[u] = 1; // 特判跟结点
}
int main() {
scanf("%d%d", &n, &m);
for (int i = 1; i <= m; i++) {
int u, v;
scanf("%d%d", &u, &v);
G[u].push_back(v), G[v].push_back(u);
}
for (int i = 1; i <= n; i++) if (!dfn[i]) tarjan(i, -1, i);
int ans1 = 0;
for (int i = 1; i <= n; i++) if (f[i]) ans1++;
printf("%d\n", ans1);
for (int i = 1; i <= n; i++) if(f[i]) printf("%d ", i);
return 0;
}
求点双
咕咕咕~(并没有完全理解.jpg)
本文作者:Running-a-way
本文链接:https://www.cnblogs.com/Running-a-way/p/18150525
版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步