Appearance
先简单介绍一下 dfs 生成树。
dfs 生成树在于更好地描述一个图,以往对于图问题的理解都太抽象了,无法形成对一个图的形式化描述。
有向图 dfs 树
对一个有向图进行 dfs 遍历,遍历到边
那么形成一棵自祖先指向后代的有向树。
在遍历过程中,图中的边可以分成若干类:
- 树边:dfs 生成树的边
- 反祖边(回边):指向祖先的边
- 横叉边:边的两点在 dfs 生成树中无祖先-后代关系
- 前向边:指向后代的边
无向图 dfs 树
对一个无向图进行 dfs 遍历,遍历到边
保留的边形成一棵无向树。
在遍历过程中,图中的边可以分成两类:
- 树边:dfs 生成树中的边
- 非树边:连接祖先-后代的边
与有向图 dfs 生成树相比,无向图 dfs 生成树性质更强。
无向图 dfs 树没有横叉边、前向边,是因为如果边的另一端的点是已经遍历过的,那么就会成为树边。连接祖先-后代的边除外,因为 dfs 是深度优先,所以可以出现后代被遍历过。
环
容易发现,无向图环由若干条树边和非树边形成。
对于某一条非树边,其和所对应的树上的树链形成的环称为基环(笔者自己叫的),那么任意一个树上的环,都是由若干个连续基环形成的。
强连通分量
定义:
强连通:任意两个结点连通。
强连通分量:极大的强连通子图。
Tarjan:
dfs 整个图,递归到
维护两个数组:
表示 dfs 过程中首次访问到的时间戳 表示 dfs 树中 的的子树中能够回溯到的还在栈中的最小 dfn。
当回溯到
cpp
void dfs(int x) {
low[x] = dfn[x] = ++idx;
vis[x] = 1;
q.push(x);
for (auto u : p[x]) {
if (!dfn[u]) {
dfs(u);
low[x] = min(low[x], low[u]);
} else if (vis[u]) {
low[x] = min(low[x], dfn[u]);
}
}
if (dfn[x] == low[x]) {
++id;
while (q.top() != x) {
auto now = q.top();
q.pop();
vis[now] = 0;
num[now] = id;
scc[id].push_back(now);
}
q.pop();
vis[x] = 0;
num[x] = id;
scc[id].push_back(x);
}
}
// 求完 SCC 后 q 不一定为空
for (int i = 1; i <= n; i++) {
if (!dfn[i])
dfs(i);
}性质:
- 一个点只属于一个 SCC。
- SCC 缩点后是 DAG。
- DAG 的拓扑排序就是 dfn 的逆序,参见 dfs 版拓扑排序,所以缩点后的 DAG 拓扑序就是 SCC 编号的降序。
杂谈:
强连通分量是环套环,也就是强连通分量中的每一条边都被至少一个环经过。
强连通分量是极大强连通子图,所以要求是“最大环”,所以要求能回溯到的最小点。
边双连通分量
定义:
边双连通:两个点
边双连通分量:极大边双连通分量。
Tarjan:
考虑这样一个结论:
- 对于任意两个有公共点或公共边的环,属于同一个边双连通分量(也就是边双连通分量的传递性)。
而,对于强连通分量,同样的,对于任意两个有公共点或公共边的环,属于同一个强连通分量。
边双连通分量和强连通分量一样是环套环,所以求边双连通分量的过程实际上就是求强连通分量的过程。
直接套用 Tarjan 求强连通分量即可。
注意一点:
cpp
void dfs(int x, int y) {
dfn[x] = low[x] = ++idx;
vis[x] = 1;
q.push(x);
for (auto [u, id] : p[x]) {
if ((id ^ y) == 1)
continue;
if (!dfn[u]) {
dfs(u, id);
low[x] = min(low[x], low[u]);
} else if (vis[u]) {
low[x] = min(low[x], dfn[u]);
}
}
if (dfn[x] == low[x]) {
id++;
while (q.top() != x) {
auto now = q.top();
q.pop();
vis[now] = 0;
ecc[id].push_back(now);
}
q.pop();
vis[x] = 0;
ecc[id].push_back(x);
}
}
// 求完 ECC 后 q 不一定为空
for (int i = 1; i <= n; i++) {
if (!dfn[i])
dfs(i, 0);
}性质:
边双连通分量缩完图后是一棵树,树上每一个节点是一个边双,每一条边是一条桥(反之亦然:每一个边双是缩完图后一个节点,每一条桥是缩完图后的每一条边)。
点双连通分量
定义:
点双连通:对于两个点
点双连通分量:一个无向图中的极大点双连通的子图。
Tarjan:
和强连通分量、边双连通分量不同,点双连通分量不具有传递性,一个点可能会属于多个点双连通分量。
但是只有割点会属于多个点双(容易证明,如果不是割点,那么删除这个点,这个点属于的多个点双会不连通(如果不会不连通那么这多个点双又可以合并成一个点双),那么就是割点)。
点双连通分量统计时,需要在从儿子回溯时统计,不能在回溯割点的时候统计。
对于
若
具体而言:
- 有两个以上子节点,那么删除
后,子节点会相互独立(无向图没有横叉边) - 只有一个子节点,这个点双没有割点。
- 没有子节点,这个点自身构成一个点双。
cpp
void dfs(int x, int y, int root) {
dfn[x] = low[x] = ++idx;
if (x == root && p[x].empty()) {
vcc[++id].push_back(x);
return;
}
q.push(x);
int son = 0;
for (auto u : p[x]) {
if (u == y)
continue;
if (!dfn[u]) {
dfs(u, x, root);
low[x] = min(low[x], low[u]);
if (low[u] == dfn[x] || low[u] == dfn[u]) {
if (x != root)
cut[x] = 1;
id++;
while (q.top() != u) {
auto now = q.top();
q.pop();
vcc[id].push_back(now);
}
q.pop();
vcc[id].push_back(u);
vcc[id].push_back(x);
}
if (x == root)
son++;
} else
low[x] = min(low[x], dfn[u]);
}
if (son >= 2 && x == root)
cut[x] = 1;
}
// 求完 VCC 后 q 不一定为空
for (int i = 1; i <= n; i++)
if (!dfn[i])
dfs(i, 0, i);性质:
点双连通分量缩完图后是一棵树,树上每一个点是割点或是点双,每一条边表示连接原图中割点和其所在点双的边(反之亦然:每一个点双是缩完图后一个节点,每一个割点是缩完图后的一个点)。
2-sat
2-sat 是用于解决若干个或命题解的算法。
有
2-sat 建边的本质是
求解:
根据离散数学基础,易得:
用
那么可以发现,建出来的图,对于同一个强连通分量重的点,取值是相同的:
- 如果有一个点为
,易得走下去都是 。 - 反之就都是
。
所以原问题无解,当且仅当
有解时,根据拓扑序,
特别地,
若存在边
那么一定存在边
若存在路径
那么一定存在路径
结合上述结论,可以发现,2-sat 建出来的图是一个很特殊的图。具体地,对于一个连通分量,一定存在一种划分方式,使得所有
那么,对于
使用 Tarjan 维护强连通分量,时间复杂度:
