【图论】tarjan
注意:这篇文章是超级远古的时候写的,只是应同学要求搬过来,以后不做修改。
On 2024.10.25 本文内容有大量细节错误,作者现在没有时间修,所以如果读起来觉得有问题,请先自行思考一下qwq
什么是强连通分量 ?
算法竞赛是很讲究效率的。
我们知道,对于一般的图,我们可以将每一个节点视为最小单位。
但,某些时候,当一个子图中的任意两个点都可以互相到达后,我们就可以将其看做一个“点”了。
举例:
很明显,此图中的
总结一下:强连通分量是满足“任意两节点都能相互到达”这一条件的 最大子图 ,我们一般称之为
前置概念
- 关于节点的基本概念
对于上附图,它的结构非常复杂,于是,我们就可以把它简化。
怎样简化呢?首先,我们忽略图中大部分的边,仅仅保留图的
显然,最后原先的图就变成了一个森林。
经过处理后,原先的节点并不一定能保留它原先在图中的位置。为了在森林中更好地处理每个节点的信息,我们选择保留每个节点的
我们习惯性地将
示例:
对于上图,经处理后的图是这样子的:
现在,我们就能轻松地在图上进行操作了。
我们知道,对于一个点,要是它能到达一个在它之前的点,那它很有可能就在一个强连通分量里。
由此可以看出 一个点能到达的最小时间戳
为 追溯值 ,它的名称是
由此,我们就可以了解
- 关于边的基本概念
我们虽然有了一个简化后的图,但曾经存在的边还是要加的。我们一般把边分为以下四类:
1. 树边
我们一开始建的森林,它里面的边全都是树边。
树边,是最先加入的边
树边的性质:树边是构成整个图的基石
举例:
此图中,加粗的两点
2. 返祖边(回边)
返祖边,即是回到祖先节点的边
返祖边的性质:只要出现返祖边,就一定有强连通
很好理解罢
注意:这个强连通并不一定是强连通分量。
此图中,从
此时,由于返祖边的出现,
3. 横叉边
横叉边,即是从一颗子树连接到另一颗子树的边。
横叉边的性质:无
注:“无”的意思是,一旦出现了一条横叉边,那它可能意味着强连通,也可能是一条废边。
横叉边举例:
此图中,
4. 前向边
前向边,即是跨越了儿子节点,连向其余的子孙节点的边
前向边的性质:前向边是废边(
废边这个应该很好理解罢,向本来就能走到的子节点连了一条边,并不能改变这个子节点原本就能被到达,以及目前并没有产生新的强连通分量的事实。
举例:
此图中,由
注意:这条前向边必定是在
给几个关于边容易犯的错误吧:
1. 如下图,这种不在同一层上的边是什么边?
注:所描述的边为:从
根据定义,不用多想,是横叉边(
2. 如下图,这种从前一颗子树连到后一颗子树的边是什么边?
注:所描述的边为:从
这种边经常会被误认为是横叉边,其实不然。因为在遍历到
换句话说,一旦有这条边的话,此图是这样的:
显然,这条边本质上是一条树边,而原本从
搞明白了前置芝士,我们再来康康
的思想
那么,
首先,我们照常地
其次,一旦发现一个点
利用第二个性质,这个
的时间复杂度
显然,在
中经典的优化
通过对边的分析,我们知道类似于前向边这种边是没有用处的,那么我们如何处理掉这些废边呢?
我们可以使用一个数组
一般来说,对于一个点
我们可以在
有了这个数组,我们就好判断多了:对于一条边,只要它的
有了如上的分析,我们对
模板
//#pragma GCC optimize (2)
#include <bits/stdc++.h>
#define int long long
using namespace std;
const int N = 1e4 + 5;
const int M = 5e4 + 5;
int n, m, cnt;
int head[N];
// 从此以下皆为寻找 scc 所需的变量
int vis[N], low[N], dfn[N];
int scc[N]; // scc[i] 代表点 i 所对应的 scc 的编号
int scc_count, pnt; // scc_count 记录 scc 的数量,pnt (point)用来更新节点的 dfn
stack <int> st; // st 为我们所说的栈
// 从此以上皆为寻找 scc 所需的变量
struct node {
int nxt, u, v;
};
node a[M];
void add (int x, int y) {
// 这部分是纯纯的板子,在这里不随题目的变化而变化
a[ ++ cnt].nxt = head[x];
a[cnt].u = x;
a[cnt].v = y;
head[x] = cnt;
}
void tarjan (int u) {
// tarjan 模板,参数 u 为目前搜索到的点
vis[u] = 1, low[u] = dfn[u] = ++ pnt;
st.push (u);
// 初始化,别忘记将 u 放入栈中
for (int i = head[u]; i; i = a[i].nxt) {
// 遍历所有的边
int v = a[i].v;
if (!dfn[v]) {
// 如果一条边的终点没被搜索过,就对它进行搜索,并将 low[u] 赋值为 low[u] 和 low[v] 的最小值,即:u 目前能到达的最小点是“u 曾经能到达的最小点”和“v 能到达的最小点”中最小的点(
tarjan (v);
low[u] = min (low[u], low[v]);
} else if (vis[v]) {
// 如果 vis[v] 为一,则代表该点的 scc 未确定
low[u] = min (low[u], dfn[v]);
// 这里写 dfn[v] 或 low[v] 都行,毕竟只是打个标记,不论如何都不影响后面的操作,毕竟要是 u 并不是我们所要寻找的那种点那这一步肯定能更新掉的
// 但是在求割点等情况下只能写 dfn[v] 呢qwq ,所以以后还是用 dfn[v] 吧
}
}
if (low[u] == dfn[u]) {
// 一旦这个点是要找的,那就求出它所在的 scc 中的所有点
// 下面的代码应该比较好理解罢
++ scc_count;
while (!st.empty () && dfn[st.top ()] >= dfn[u]) {
int k = st.top ();
scc[k] = scc_count;
vis[k] = 0;
// 给自己的小提示:这里 vis 必须写到 while 里面!毕竟按照定义来就是该且只能写到这儿的
st.pop ();
}
}
}
signed main () {
scanf ("%lld%lld", &n, &m);
for (int i = 1; i <= m; i ++ ) {
int u, v;
scanf ("%lld%lld", &u, &v);
add (u, v);
}
for (int i = 1; i <= n; i ++ ) // 要是这个图不连通怎么办?所以我们要对每一个需要 tarjan 的点都进行 tarjan !
if (!dfn[i])
tarjan (i);
for (int i = 1; i <= n; i ++ )
printf ("%lld ", scc[i]);
puts ("");
return 0;
}
样例输入:
10 12
1 2
2 4
4 1
1 3
3 4
1 5
5 6
6 8
8 7
7 1
9 10
10 9
样例输出:
1 1 1 1 1 1 1 1 2 2
经典例题
洛谷 P2863 [USACO06JAN]The Cow Prom S
题目分析:
裸的求强连通分量。唯一的更改仅仅是需要记录
洛谷 P3387 【模板】缩点
题目分析:
显然在这个图里的强连通分量之间都可以互相到达,那么我们可以将一个
在得到一张被缩完的图后,我们就可以进行
首先,现在的图必定是一个有向无环图
最后,在所有的
P3388 【模板】割点(割顶)
1. 什么是割点?
在一个无向图中,存在着一种点。设点
2. 如何求割点?
考虑在一个 用人话来说即
但这里,我们要考虑一种特殊情况:根节点。
为什么我们要特判根节点呢?因为根节点是没有祖先节点的,那么我们上面的分析就不成立了。
那么如何判断根节点是否为割点呢?显然,一旦根节点是割点,那它就必定至少有两个不相交的子树。那么,我们只需要判断它的子树数量就可以了。
由于求割点与求强连通分量不同,所以此时我们并不需要栈存储节点等操作。
接着,就是我们上文提到过的问题:
low[u] = min (low[u], dfn[v]);
为什么这里要写
-
为什么不成立?我们知道,回溯值的定义是一个点能到达的,时间戳最小的点的时间戳。
但这个定义,在求割点的时候就会出现问题:整张图的边都是无向的,所以一旦写成了
,所有节点的回溯值就会被更新为根节点的时间戳。 -
为什么正确?考虑点
与当前节点 的关系。如果通过
是 的祖先节点,那不管 是那个点, 的回溯值都会被更新,使得其小于 的时间戳。如果
不是 的祖先,但通过 可以到达 的祖先,那因为此时 已经被遍历过了( 不为 ),所以先前遍历到 时,一定可以通过某条路径遍历到 ,因此 就成为了 的子孙节点,又回到了第一种情况。如果
既不是 的祖先,也无法通过某条路径连接到 的祖先,那不管写 ,还是写 ,效果都是一样的,因此 不影响正确性。
模板:
//#pragma GCC optimize (2)
#include <bits/stdc++.h>
#define int long long
using namespace std;
const int N = 1e5 + 5;
const int M = 5e5 + 5;
int n, m;
int low[N], dfn[N], head[N];
int scc[N];
int cnt, ans, scc_count, pnt;
struct node {
int nxt, u, v;
};
node a[M];
int ans_pnt[N]; // 记录一个点是否为割点。设 ans_pnt[i] = 1 ,则 i 号点为割点
int rt; // rt 标记根节点
void add (int x, int y) {
a[ ++ cnt].nxt = head[x];
a[cnt].u = x;
a[cnt].v = y;
head[x] = cnt;
}
void tarjan (int u) {
low[u] = dfn[u] = ++ pnt;
int son = 0; // son 记录子树个数
for (int i = head[u]; i; i = a[i].nxt) {
int v = a[i].v;
if (!dfn[v]) {
son ++ ; // v 号点没被标记过,则代表目前没有别的到 v 号点的路径,则 v 号点对应着一个新的子树
tarjan (v);
low[u] = min (low[u], low[v]);
if (low[v] >= dfn[u] && u != rt && !ans_pnt[u]) // 若 low[v] >= dfn[u] 且 u 并不是根节点且 u 没被标记过,则进行标记
ans ++ , ans_pnt[u] = 1;
} else {
low[u] = min (low[u], dfn[v]);
}
}
if (son > 1 && u == rt && !ans_pnt[u]) // 特判根节点
ans ++ , ans_pnt[u] = 1;
}
signed main () {
scanf ("%lld%lld", &n, &m);
for (int i = 1; i <= m; i ++ ) {
int u, v;
scanf ("%lld%lld", &u, &v);
add (u, v), add (v, u); // 无向图两次建边
}
for (int i = 1; i <= n; i ++ )
if (!dfn[i])
rt = i, tarjan (i); // 别忘了标记 rt
printf ("%lld\n", ans);
for (int i = 1; i <= n; i ++ )
if (ans_pnt[i]) printf ("%lld ", i);
puts ("");
return 0;
}
洛谷 P1656 炸铁路
1. 什么是割边?
类似于割点,即无向图中的一些边。设边
2. 如何求割边?
与割点类似。
考虑到,一旦一个边是割边,则将它删除后,它的终点必定无法返祖,即
为什么呢?因为一旦
有了割点的铺垫,割边就好写多了。
模板:
//#pragma GCC optimize (2)
#include <bits/stdc++.h>
#define int long long
using namespace std;
const int N = 1e3 + 5;
const int M = 1e4 + 5;
int n, m, pnt, cnt, cnt2;
int dfn[N], low[N], head[N];
int fa[N]; // 在无向图中,标记是否为父亲
struct node {
int u, v;
int nxt;
};
node a[M], Edge[M]; // Edge 存割边
void add (int x, int y) {
a[ ++ cnt].nxt = head[x];
a[cnt].u = x;
a[cnt].v = y;
head[x] = cnt;
}
void addEdge (int x, int y) {
// 这里就不用那么麻烦的加边操作了
Edge[ ++ cnt2].u = min (x, y);
Edge[cnt2].v = max (x, y);
// min / max 是为了保证边的内部顺序
}
void tarjan (int u) {
dfn[u] = low[u] = ++ pnt;
for (int i = head[u]; i; i = a[i].nxt) {
int v = a[i].v;
if (!dfn[v]) {
fa[v] = u; // 标记父亲,因为不能走到父亲
tarjan (v);
low[u] = min (low[u], low[v]);
if (low[v] > dfn[u]) {
// 找到割边了
addEdge (u, v);
}
} else if (v != fa[u]) {
low[u] = min (low[u], dfn[v]);
}
}
}
bool cmp (node x, node y) {
// 排序函数,用来将边排序输出
if (x.u < y.u) return true;
else if (x.u == y.u && x.v < y.v) return true;
else return false;
}
signed main () {
scanf ("%lld%lld", &n, &m);
for (int i = 1; i <= m; i ++ ) {
int u, v;
scanf ("%lld%lld", &u, &v);
add (u, v), add (v, u);
}
for (int i = 1; i <= n; i ++ )
if (!dfn[i])
tarjan (i);
sort (Edge + 1, Edge + cnt2 + 1, cmp);
for (int i = 1; i <= cnt2; i ++ )
printf ("%lld %lld\n", Edge[i].u, Edge[i].v);
return 0;
}
洛谷 P8435 【模板】点双连通分量
1. 什么是点双连通分量 ?
在无向图中,存在着一种子图
注:单独的一个点,即孤立点,自成一个
2. 如何求点双?
首先,注意到这样一个引理:两个点双的公共点必定是割点。
为什么呢?因为一旦将公共点删去,两个点双之间必定无法互相到达。进而推出,公共点满足割点的性质,即公共点是割点。
进而推出:一个点双的边界必定是割点。
这又是怎么来的呢?
首先,设边界并非为割点,则对于本应为边界的割点来说,点双里的任意一点,都必定有大于
至于有没有可能出现其中的一条路径是通过非点双里的点到达的呢?由于无向图中只可能存在树边和前向边,易证得:若存在通过祖先到达的情况,则这个祖先也必定属于点双。故推出假设错误。
而假设隔着割点又新加入了节点,则必定有不通过割点到达新点的路径,与假设矛盾。
于是,我们就可以总结出求点双的方法了:
-
遍历所有点。 -
与求强连通分量类似,我们使用一个栈
存储节点。 -
一旦遇到割点,则将栈内所有
比割点的 大的节点,包括割点,全部记录,并将点双中除割点外的点弹出。
注意:
-
记录的点,就相当于一颗以割点为根的子树。
-
为什么不弹出割点?因为割点还有可能被包含在以它为叶子节点的点双中。
最后,提醒一下:孤立点也是点双,需要特判!
模板:
//#pragma GCC optimize (2)
#include <bits/stdc++.h>
#define int long long
using namespace std;
const int N = 5e5 + 5;
const int M = 4e6 + 5;
int n, m;
int low[N], dfn[N], head[N];
int cnt, pnt, top; // top 记录栈顶
struct node {
int nxt, u, v;
};
node a[M];
int fa[N], st[N];
// fa 存储父亲节点,fa[i] 存储 i 号点的父节点。该数组用来特判孤立点,同时,在遍历的时候还可以用它防止走回父节点的情况发生。
// st 为手写栈,注意:这里因为图有可能是森林,所以我们要多次清空栈,故可以使用此时相对方便的手写栈
int dcc;
vector <int> dcc_pnt[N];
// dcc 记录点双个数,dcc_pnt 存储每个点双里的点
void add (int x, int y) {
a[ ++ cnt].nxt = head[x];
a[cnt].u = x;
a[cnt].v = y;
head[x] = cnt;
}
void tarjan (int u) {
st[ ++ top] = u;
low[u] = dfn[u] = ++ pnt;
int son = 0;
//初始化,son 记录儿子节点的个数用来特判孤立点
for (int i = head[u]; i; i = a[i].nxt) {
int v = a[i].v;
if (!dfn[v]) {
son ++ ;
fa[v] = u, tarjan (v);
low[u] = min (low[u], low[v]);
if (low[v] >= dfn[u]) { // 意味着 u 为割点且找到了新的子树,故添加新的点双
// 注意这里和强连通分量的 tarjan 不一样
dcc ++ ;
dcc_pnt[dcc].push_back (u);
while (top > 0 && dfn[st[top]] >= dfn[v]) dcc_pnt[dcc].push_back (st[top]), top -- ;
}
} else if (v != fa[u]) {
// 不能走回父节点,所以要判断
low[u] = min (low[u], dfn[v]);
}
}
if (fa[u] == 0 && son == 0) dcc_pnt[ ++ dcc].push_back (u);
// 特判孤立点
}
signed main () {
scanf ("%lld%lld", &n, &m);
for (int i = 1; i <= m; i ++ ) {
int u, v;
scanf ("%lld%lld", &u, &v);
if (u != v) add (u, v), add (v, u);
}
for (int i = 1; i <= n; i ++ )
if (!dfn[i]) {
top = 0; // 初始化栈顶
tarjan (i);
}
printf ("%lld\n", dcc);
for (int i = 1; i <= dcc; i ++ ) {
printf ("%lld ", dcc_pnt[i].size ());
for (int j = 0; j < dcc_pnt[i].size (); j ++ ) printf ("%lld ", dcc_pnt[i][j]);
puts ("");
}
return 0;
}
洛谷 P8436 【模板】边双连通分量
1. 什么是边双连通分量 ?
在无向图中,存在着一种子图
2. 如何求边双?
首先,割边必定不属于任何边双,因为将它删除会导致整张图不连通。
其次,注意到边的性质:将割边删除并不会导致边双不连通,或割边变为普通边。
上面的这一条可以理解一下。
通过这两条性质,易得出边双的求法:
将所有割边删除,剩下的连通块就是边双。
为什么呢?首先,将割边删除这一步很好理解,其次,剩下的连通块中必定不含有割边,由此退出剩下的连通块为边双,因为边双,就是一张不含割边的图。
相较于点双,边双就简单多了。
模板:
//#pragma GCC optimize (2)
#include <bits/stdc++.h>
#define int long long
using namespace std;
const int N = 5e5 + 5;
const int M = 4e6 + 5;
int n, m;
int cnt, pnt;
int head[N], dfn[N], low[N], fa[N];
// 与点双中的 fa 相同,这里的 fa 记录父节点。不同的是,这里的 fa 少了判断孤立点的功能,因为孤立点不用考虑
struct node {
int u, v;
int nxt;
};
node a[M];
bool vis1[N], vis2[M];
// vis1 在求连通块的过程中标记走过的点,vis2 标记割边
int bcc;
vector <int> bcc_pnt[N];
// bcc 记录边双个数,bcc_pnt 存储边双里的节点
void add (int x, int y) {
a[cnt].nxt = head[x];
a[cnt].u = x;
a[cnt].v = y;
head[x] = cnt ++ ;
// 注意,无向图中割边要标记两次。我们使用位运算进行标记,故要求边从 0 开始计数
}
void tarjan (int u) {
dfn[u] = low[u] = ++ pnt;
for (int i = head[u]; i != -1; i = a[i].nxt) {
/*
这里注意一下:由于我们是从 0 开始将边赋值,所以遍历时有可能出问题。也就是说,如果我们还是按照上文的方法遍历链式前向星,这条边就退化成了有向边,也就出问题了。
hack input :
4 4
4 1
1 2
2 3
3 4
*/
int v = a[i].v;
if (!dfn[v]) {
fa[v] = u;
tarjan (v);
low[u] = min (low[u], low[v]);
if (low[v] > dfn[u]) vis2[i] = true, vis2[i ^ 1] = true;
// 与点双类似的标记,位运算符 ^ ,即异或,表示不进位加法
} else if (v != fa[u]) {
low[u] = min (low[u], dfn[v]);
}
}
}
void dfs (int u) {
// dfs 求连通块
vis1[u] = true, bcc_pnt[bcc].push_back (u);
for (int i = head[u]; i != -1; i = a[i].nxt) {
if (vis1[a[i].v] || vis2[i]) continue;
dfs (a[i].v);
}
}
signed main () {
scanf ("%lld%lld", &n, &m);
for (int i = 1; i <= n; i ++ ) head[i] = -1;
for (int i = 1; i <= m; i ++ ) {
int u, v;
scanf ("%lld%lld", &u, &v);
add (u, v), add (v, u);
}
for (int i = 1; i <= n; i ++ )
if (!dfn[i])
tarjan (i);
for (int i = 1; i <= n; i ++ )
if (!vis1[i])
bcc ++, dfs (i); // 由于这步是求连通块的数量,所以这里直接累加 bcc 。
printf ("%lld\n", bcc);
for (int i = 1; i <= bcc; i ++ ) {
printf ("%lld ", bcc_pnt[i].size ());
for (int j = 0; j < bcc_pnt[i].size (); j ++ ) printf ("%lld ", bcc_pnt[i][j]);
puts ("");
}
return 0;
}
写在最后:
为什么点双不能用边双的方法求呢?因为在图中删除节点,可能会使一些本不是割点的点变为割点。
举个例子,理解一下吧:
这里面,将割点
特别鸣谢:
@FeS2_qwq 纠正强连通定义。
@diannaojun 指出边双中遍历边的小瑕疵
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 单元测试从入门到精通
· 上周热点回顾(3.3-3.9)
· winform 绘制太阳,地球,月球 运作规律