13 KiB
【算法学习】圆方树
本知识点,涉及最大流,最小割等知识点,超出目前的通力范围,暂不研究,明年初开发学习进阶课,预计后年开始做周边习题。
写于 2023-07-28
众所周知,树(或森林)有很好的性质,并且容易通过很多常见数据结构维护。
而 一般图 则没有那么好的性质,所幸有时我们可以 把一般图上的某些问题转化到树上考虑。
而 圆方树 就是一种 将图变成树 的方法。本文将介绍圆方树的构建,性质和一些应用。
限于篇幅,本文中有一些结论未经证明,读者可以自行理解或证明。
一、圆方树的定义
圆方树最初是处理 仙人掌图 (每条边在不超过一个简单环中的无向图)的一种工具,不过发掘它的更多性质,有时我们可以在一般无向图上使用它。
要介绍圆方树,首先要介绍 点双连通分量。
一个点双连通图的一个定义是:图中任意两不同点之间都有至少两条点不重复的路径。 点不重复既指路径上点不重复(简单路径),也指两条路径的交集为空(当然,路径必然都经过出发点和到达点,这不在考虑范围内)。
可以发现对于只有一个点的图比较难定义它是不是一个点双,这里先不考虑节点数为 1
的图。
一个近乎等价的定义是:不存在割点的图。
这个定义只在图中只有两个点,一条连接它们的边时失效。它没有割点,但是并不能找到两条不相交的路径,因为只有一条路径。 (也可以理解为那一条路径可以算两次,的确没有交,因为不经过其他点)
虽然原始的定义的确是前者,但是为了方便,我们规定点双图的定义采用后者。
而一个图的点双连通分量则是一个极大点双连通子图。 与强连通分量等不同,一个点可能属于多个点双,但是一条边属于恰好一个点双(如果定义采用前者则有可能不属于任何点双)。
在圆方树中,原来的每个点对应一个圆点,每一个点双对应一个方点。
所以共有 n+c
个点,其中 n
是原图点数,c
是原图点双连通分量的个数。
而对于每一个点双连通分量,它对应的方点向这个点双连通分量中的每个点连边。 每个点双形成一个 菊花图 ,多个 菊花图 通过原图中的割点连接在一起(因为点双的分隔点是割点)。
显然,圆方树中每条边连接一个圆点和一个方点。
下面有一张图,来自 WC
的 PPT
,显示了一张图对应的点双和圆方树形态。
解释: 原始无向连通图->划出点双->每个点双对应一个方点->抽象出这个方点->点双中所有点向这个点点连边->将无向图转化为圆方树
圆方树的点数小于 2n
,这是因为割点的数量小于 n
,所以请注意各种数组大小要开两倍。
其实,如果原图连通,则 圆方树 才是一棵树,如果原图有 k
个连通分量,则它的圆方树也会形成 k
棵树形成的森林。
注:如果原图中某个连通分量只有一个点,则需要具体情况具体分析,我们在后续讨论中不考虑孤立点。
二、圆方树的构建
对于一个图,如何构造出它的圆方树呢?首先可以发现如果图不连通,可以拆分成每个连通子图考虑,所以我们只考虑连通图。
因为圆方树是基于点双连通分量的,而点双连通分量又基于割点,所以只需要用类似求割点的方法即可。
求割点的常用算法是 Tarjan
算法,如果你会了理解下面的内容就很简单了,如果你不会也没关系。
我们跳过 Tarjan
求割点,直接介绍圆方树使用的算法(其实是 Tarjan
的变体):
对图进行 DFS
,并且中间用到了两个关键数组 dfn
和 low
(类似于 Tarjan
)。
dfn[u]
存储的是节点u
的DFS
序,即第一次访问到u
时它是第几个被访问的节点。low[u]
存储的是节点u
的DFS
树中的子树中的某个点v
通过最多一次返祖边或向父亲的树边能访问到的点的最小DFS
序。
如果没有听说过 Tarjan
算法可能会有点难理解,让我们举个例子吧:
(可以发现这张图其实和上面图片中的图等价)
这里树边从上至下用直线画出,返祖边从下至上用曲线画出。节点的编号便是它的 DFS
序。
并不是很难理解吧,注意这里 9
的 low
是 7
,与一些求割点的做法有差异,因为为了方便,我们规定了可以通过父边向上,但主要思想是相同的。
我们可以很容易地写出计算 dfn
和 low
的 DFS
函数(初始时 dfn
数组清零):
void Tarjan(int u) {
low[u] = dfn[u] = ++dfc; // low 初始化为当前节点 dfn
for (int v : G[u]) { // 遍历 u 的相邻节点
if (!dfn[v]) { // 如果未访问过
Tarjan(v); // 递归
low[u] = min(low[u], low[v]); // 未访问的和 low 取 min
} else low[u] = min(low[u], dfn[v]); // 已访问的和 dfn 取 min
}
}
接下来,我们考虑点双和 DFS
树以及这两个数组之间的关联。
可以发现,每个点双在 DFS
树上是一棵连通子树,并至少包含两个点;特别地,最顶端节点仅往下接一个点。
同时还可以发现每条树边恰好在一个点双内。
我们考虑一个点双在 DFS
树中的最顶端节点 u
,在 u
处确定这个点双,因为 u
的子树包含了整个点双的信息。
因为至少有两个点,考虑这个点双的下一个点 v
,则有 u
,v
之间存在一条树边。
不难发现,此时一定有 low[v]=dfn[u]
。
更准确地说,对于一条树边 u→v
,u,v
在同一个点双中,且 u
是这个点双中深度最浅的节点当且仅当 low[v]=dfn[u]
。
这并不难处理,我们可以在 DFS
过程中维护一个栈,存储还未确定所属点双(可能有多个)的节点。
在找到点双时,点双中除了 u
以外的其他的点都集中在栈顶端,只需要不断弹栈直到弹出 v
为止即可。
当然,我们可以同时处理被弹出的节点,只要将其和新建的方点连边即可。最后还要让 u
和方点连边。
这样就很自然地完成了圆方树的构建,我们可以 给方点标号为 n+1
开始的整数,这样可以有效区分圆点和方点。
这部分可能讲述得不够清晰,下面贴出一份代码,附有详尽注释以及帮助理解的输出语句和一份样例,建议读者复制代码并自行实践理解,毕竟代码才是最能帮助理解的(不要忘记开 c++11
)。
#include <cstdio>
#include <vector>
#include <algorithm>
using namespace std;
const int MN = 100005;
int N, M, cnt;
vector<int> G[MN], T[MN * 2];
int dfn[MN], low[MN], dfc;
int stk[MN], tp;
void Tarjan(int u) {
printf(" Enter : #%d\n", u);
low[u] = dfn[u] = ++dfc; // low 初始化为当前节点 dfn
stk[++tp] = u; // 加入栈中
for (int v : G[u]) { // 遍历 u 的相邻节点
if (!dfn[v]) { // 如果未访问过
Tarjan(v); // 递归
low[u] = min(low[u], low[v]); // 未访问的和 low 取 min
if (low[v] == dfn[u]) { // 标志着找到一个以 u 为根的点双连通分量
++cnt; // 增加方点个数
printf(" Found a New BCC #%d.\n", cnt - N);
// 将点双中除了 u 的点退栈,并在圆方树中连边
for (int x = 0; x != v; --tp) {
x = stk[tp];
T[cnt].push_back(x);
T[x].push_back(cnt);
printf(" BCC #%d has vertex #%d\n", cnt - N, x);
}
// 注意 u 自身也要连边(但不退栈)
T[cnt].push_back(u);
T[u].push_back(cnt);
printf(" BCC #%d has vertex #%d\n", cnt - N, u);
}
} else
low[u] = min(low[u], dfn[v]); // 已访问的和 dfn 取 min
}
printf(" Exit : #%d : low = %d\n", u, low[u]);
printf(" Stack:\n ");
for (int i = 1; i <= tp; ++i) printf("%d, ", stk[i]);
puts("");
}
int main() {
#ifndef ONLINE_JUDGE
freopen("YFS.in", "r", stdin);
#endif
scanf("%d%d", &N, &M);
cnt = N; // 点双 / 方点标号从 N 开始
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 u = 1; u <= N; ++u)
if (!dfn[u]) Tarjan(u), --tp;
// 注意到退出 Tarjan 时栈中还有一个元素即根,将其退栈
return 0;
}
注:黄海修改了一个使用链式前向星的版本,也是无向图,只有无向图中才有点双连通分量的概念
#include <bits/stdc++.h>
using namespace std;
const int N = 1e5 + 10, M = N << 2;
int n, m;
int ts, root, dfn[N], low[N], stk[N], top;
vector<int> bcc[N];
int bcc_cnt;
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++;
}
void tarjan(int u) {
printf(" Enter : #%d\n", u);
dfn[u] = low[u] = ++ts;
stk[++top] = u;
if (u == root && h[u] == -1) {
bcc[++bcc_cnt].push_back(u);
return;
}
for (int i = h[u]; ~i; i = ne[i]) {
int v = e[i];
if (!dfn[v]) {
tarjan(v);
low[u] = min(low[u], low[v]);
if (low[v] >= dfn[u]) {
bcc_cnt++;
printf(" Found a New BCC #%d.\n", bcc_cnt);
int bcc_id = bcc_cnt + n;
int x;
do {
x = stk[top--];
bcc[bcc_cnt].push_back(x);
// 将x向圆方树方格建边
add(x, bcc_id);
printf(" BCC #%d has vertex #%d\n", bcc_cnt, x);
} while (x != v);
bcc[bcc_cnt].push_back(u);
// 将u向圆方树方格建边
add(u, bcc_id);
printf(" BCC #%d has vertex #%d\n", bcc_cnt, u);
}
} else
low[u] = min(low[u], dfn[v]);
printf(" Exit : #%d : low = %d\n", u, low[u]);
printf(" Stack:\n ");
for (int i = 1; i <= top; ++i) printf("%d, ", stk[i]);
puts("");
}
}
int main() {
#ifndef ONLINE_JUDGE
freopen("YFS.in", "r", stdin);
freopen("YFS_2.out", "w", stdout);
#endif
memset(h, -1, sizeof h);
cin >> n >> m;
for (int i = 0; i < m; i++) {
int a, b;
cin >> a >> b;
add(a, b), add(b, a);
}
for (root = 1; root <= n; root++)
if (!dfn[root]) tarjan(root);
// for (int i = 1; i <= bcc_cnt; i++) {
// for (int j = 0; j < bcc[i].size(); j++)
// printf("%d ", bcc[i][j]);
// puts("");
// }
return 0;
}
提供一个测试用例:
13 15
1 2
2 3
1 3
3 4
3 5
4 5
5 6
4 6
3 7
3 8
7 8
7 9
10 11
11 10
11 12
三、圆方树的应用
我们讲一些可以使用圆方树求解的例题。
[APIO2018]铁人两项
这题可以作为圆方树模板题看待。