花了两周时间干完Tarjan,我好弱啊%%%
一些普适的概念
时间戳(dfn)
说白了就是用dfs遍历图中所有节点的顺序,很好理解,一般用数组
d
f
n
[
i
]
dfn[\ i\ ]
dfn[ i ] 表示第几个访问的节点
i
i
i 。
PS:本人在学校机房,不方便把图片放进来,敬请谅解。如有需要请自行手写。
追溯值(low)
看起来比较抽象,其实我也不太好给出合理的解释,大概意思就是 l o w [ i ] low[\ i \ ] low[ i ] 代表节点 i i i 通过直接或间接存在的路径,可以返回到的时间戳(dfn值)最小的节点 j j j ,其中 l o w [ i ] = d f n [ j ] low[\ i \ ] = dfn[\ j \ ] low[ i ]=dfn[ j ] 。然后就引入了一些有关搜索树中边的名称的概念,在下面的板块会提及。
搜索树
这个也很好理解,就是在一张连通图中(不管有向还是无向),所有的节点以及发生递归的边共同构成一张搜索树,显然搜索树中节点数比边数多1(这是树的性质)。
相应的,如果这张图不是连通图,那么相应构成的就是搜索森林。
在搜索树中,我们以有向边 ( x , y ) (x, y) (x,y) 为例(先后顺序不代表前父后子):
- 若边 ( x , y ) (x, y) (x,y) 是搜索树中的边,则称边 ( x , y ) (x, y) (x,y) 为树枝边。
- 若边 ( x , y ) (x, y) (x,y) 不是搜索树中的边,且在搜索树中 y y y 是 x x x 的后代,则边 ( x , y ) (x, y) (x,y) 是前向边(祖先指向后代)。
- 若边 ( x , y ) (x, y) (x,y) 不是搜索树中的边,且在搜索树中 x x x 是 y y y 的后代,则边 ( x , y ) (x, y) (x,y) 是后向边(后代指向祖先)。
- 整个图中除了以上三种边就是横叉边。
PS:显然,只有有向图中存在横叉边,证明略。
Tarjan与有向图
强连通
在有向图中,若存在一个子图,且该子图中的任意两点都相互连通,则该子图为强连通图。
根据定义,显然,一个环一定是强连通图;但是,一个强连通图不一定是一个环,有可能是多个环拼接在一起。
在有向图中最大的强连通图为强连通分量。
PS:有些书和博客把这里的最大强连通子图说成极大强连通子图,都是一个意思。
强连通分量判定法则(scc)
根据定义,亦可得,每一个节点只会属于一个强连通子图。
其实真正求的是哪一个节点属于哪一个强连通图,强连通分量的定义是最大的强连通子图,强连通分量一般来说是一个(有可能是多个相等的),但是好像也可以指强连通图。不知怎么搞的,既然都这么说,那就这么说罢。
我们按照dfn的顺序将每一个节点编号入栈,当 d f n [ x ] = = l o w [ x ] dfn[\ x\ ] == low[\ x\ ] dfn[ x ]==low[ x ] 时,将栈顶到 x x x 之间所有的节点出栈,这些节点构成一个强连通分量。
d f n [ x ] = = l o w [ x ] dfn[\ x\ ] == low[\ x\ ] dfn[ x ]==low[ x ] 即为强连通分量的判定条件。简要说明:节点 x x x 能返回到最早的节点就是 x x x 本身。从 x x x 到 s . t o p ( ) s.top(\ ) s.top( ) 这些节点的父节点可以看做 x x x ,且它们最多只能返回到节点 x x x ,所以这些节点构成一个强连通分量。
代码实现如下:
void tarjan(int x) {
dfn[x] = low[x] = ++cnt;
sta[++top] = x; ins[x] = true; // ins[x] 标记 x 是否入栈
for (int i = head[x]; ~i; i = nxt[i]) {
int y = ver[i];
if (!dfn[y]) {
tarjan(y);
low[x] = min(low[x], low[y]);
} else if (ins[y]) {
low[x] = min(low[x], dfn[y]);
}
}
if (dfn[x] == low[x]) {
int y; ++num; // num 是连通块的编号
do {
y = sta[top--]; ins[y] = false;
c[y] = num; // c[y] 保存 y 属于哪个连通块
scc[num].push_back(y); // scc[num] 保存连通块 num 里有哪些点
} while (y != x);
}
}
以大家的能力,不用注释也能看懂的。%%%
scc缩点
强连通分量的缩点很简单。因为每个节点只可能属于一个scc,所以我们可以把一个scc缩成一个点。设存在有向边 ( x , y ) (x, y) (x,y) ,若 c [ x ] = = c [ y ] c[\ x\ ] == c[\ y\ ] c[ x ]==c[ y ] ,则这条边会被吞掉;反之,这条边就会被建立。
代码实现如下:
// 以下代码加到主函数中:
for (int x = 1; x <= n; ++x) {
for (int i = head[x]; i != -1; i = nxt[i]) {
int y = ver[i];
if (c[x] == c[y]) continue;
add_c(c[x], c[y]);
}
}
Tarjan与无向图
桥(割边)
定义很好理解:在无向连通图中,若删去一条边,使得该连通图分裂成两个不连通的连通图,则这条边为桥(割边)。
桥(割边)判定法则
d
f
n
[
x
]
<
l
o
w
[
y
]
dfn[\ x \ ] < low[\ y\ ]
dfn[ x ]<low[ y ]
其中
y
y
y 是
x
x
x 的子节点。
简要说明:从
y
y
y 出发,没有任何一条路径能到达
x
x
x 及
x
x
x 以上的节点,显然这条边被删去以后分裂成两个子连通图,即这条边是桥(割边)。
代码实现如下:
void tarjan(int x, int fa_edge) { // 第二个参数传的是刚才发生递归的边的编号
dfn[x] = low[x] = ++cnt;
for (int i = head[x]; ~i; i = nxt[i]) {
int y = ver[i];
if (!dfn[y]) {
tarjan(y, i);
low[x] = min(low[x], low[y]);
if (dfn[x] < low[y]) {
bridge[i] = bridge[i ^ 1] = true;
// bridge[i] 保存 i 是否为桥(割边)
}
} else if (i != (fa_edge ^ 1)) { // 成对变换
low[x] = min(low[x], dfn[y]);
}
}
}
这里用到了一个叫成对变换的东西,若不懂则可以去借鉴其他文章。这里默认读者理解什么是成对变换。
边双连通分量(e-dcc)
图的双连通分量(dcc)包括边双连通分量(e-dcc)点双连通分量(v-dcc),这里先介绍e-dcc。
在无向连通图中,不包含桥的子图为边双连通图,最大的边双连通子图为边双连通分量。
(定义与强连通分量类似。)
说白了就是把一张无向图中所有的桥删去,每一个连通块就是一个e-dcc。
代码实现如下:
// 各数组名的含义与 scc 上面的类似
void edcc_dfs(int x) {
c[x] = num;
for (int i = head[x]; i != -1; i = nxt[i]) {
int y = ver[i];
if (bridge[i] || c[y]) continue;
edcc_dfs(y);
}
}
// 以下代码加到主函数中:
for (int i = 1; i <= n; ++i) {
if (c[i]) continue;
++num;
edcc_dfs(i);
}
e-dcc缩点
显然也很简单。和scc的缩点的思路类似。
代码如下:
// 以下代码加到主函数中:
for (int i = 2; i <= tot; ++i) {
// tot 是链式前向星中边的编号,为了保证正常的成对变换,tot 要从偶数编号开始计起
int x = ver[i], y = ver[i ^ 1];
if (c[x] == c[y]) continue;
add_c(c[x], c[y]);
}
割点(割顶)
割点(割顶)判定法则
d
f
n
[
x
]
≤
l
o
w
[
y
]
dfn[\ x \ ] \le low[\ y\ ]
dfn[ x ]≤low[ y ]
其中
y
y
y 是
x
x
x 的子节点。
解释与桥的类似。简要说明:从
y
y
y 出发,无论走哪一条路径都只能到达
x
x
x 及
x
x
x 以下的节点,显然这个点被删去以后分裂成两个子连通图,即这条点是割点(割顶)。
值得注意的是,若 x x x 为搜索树的根节点,则 x x x 仍需要满足至少有两个节点与其相连才可能保证 x x x 为割点。所以我们要对根节点进行特判。
代码实现如下:
void tarjan(int x) {
dfn[x] = low[x] = ++cnt;
int flag(0); // 记录有多少个点与 x 连通
for (int i = head[x]; ~i; i = nxt[i]) {
int y = ver[i];
if (!dfn[y]) {
tarjan(y);
low[x] = min(low[x], low[y]);
if (dfn[x] <= low[y]) {
++flag;
if (x != rt || flag > 1) cut[x] = true;
// 若 x 不为根则 x 一定是割点
// 若 x 为根,且和根相连的节点数 >= 2 亦为真
// 否则不是割点
}
} else {
low[x] = min(low[x], dfn[y]);
}
}
}
点双连通分量(v-dcc)
在无向连通图中,不包含桥的子图为边双连通图,最大的边双连通子图为边双连通分量。
(定义与边双连通分量类似。)
但和e-dcc不同的是,e-dcc把桥删去即可求得每个边双联通分量。v-dcc中,每一个割点可能属于多个点双连通分量。所以v-dcc缩点是把每个割点单拎出来,再把每个v-dcc单拎出来,如果这个v-dcc中包含割点,那么就将这个v-dcc和这个割点连边。
显然,如果一个点是孤立点(就是没有任何边点与其相连),那么它自己属于一个v-dcc。所以也需要对孤立点进行特判。
代码实现如下:
void tarjan(int x) {
dfn[x] = low[x] = ++cnt;
sta[++top] = x;
int flag(0);
if (x == rt && head[x] == -1) { // 对孤立点的特判
vdcc[++num].push_back(x);
return;
}
for (int i = 0; i < ver[x].size(); ++i) {
int y = ver[x][i];
if (!dfn[y]) {
tarjan(y);
low[x] = min(low[x], low[y]);
if (dfn[x] <= low[y]) {
++flag;
if (x != rt || flag > 1) cut[x] = true;
int z; ++num;
do {
z = sta[top--];
vdcc[num].push_back(z);
} while (z != y);
vdcc[num].push_back(x); // x 可能属于多个 v-dcc
}
} else {
low[x] = min(low[x], dfn[y]);
}
}
}
v-dcc缩点
刚才在上面的板块说了一些。就是把每个v-dcc缩成一个点。因为割点可能属于多个v-dcc,所以要把每一个割点单拎出来。具体思路要结合上面的看。
代码实现如下:
// 主函数里
cnt = num;
for (int i = 1; i <= n; ++i) {
if (cut[i]) new_cut[i] = ++cnt;
}
for (int x = 1; x <= num; ++x) {
for (int i = 0; i < vdcc[i].size(); ++i) {
int y = vdcc[x][i];
if (cut[y]) {
add_c(x, new_cut[y]);
add_c(new_cut[y], x);
} else c[y] = x;
}
}
PS:若有错误和疑问,欢迎在评论区留言,看到一定回复%%%