图论——连通性
图论——连通性
基本知识
路径相关
-
途径:连接一串结点的序列称为 途径,用点序列 \(v_{0..k}\) 和边序列 \(e_{1..k}\) 描述,其中 \(e_i = (v_{i - 1}, v_i)\),通常写为 \(v_0\to v_1\to \cdots \to v_k\)。
-
迹:不经过重复边的途径称为 迹。
-
回路:\(v_0 = v_k\) 的迹称为 回路。
-
路径:不经过重复点的迹称为 路径,也称 简单路径。不经过重复点比不经过重复边强,所以不经过重复点的途径也是路径。注意题目中的简单路径可能指迹。
-
环:除 \(v_0 = v_k\) 外所有点互不相同的途径称为 环,也称 圈 或 简单环。
连通性相关
-
连通:对于无向图的两点 \(u, v\),若存在途径使得 \(v_0 = u\) 且 \(v_k = v\),则称 \(u, v\) 连通。
-
弱连通:对于有向图的两点 \(u, v\),若将有向边改为无向边后 \(u, v\) 连通,则称 \(u, v\) 弱连通。
-
连通图:任意两点连通的无向图称为 连通图。
-
弱连通图:任意两点弱连通的有向图称为 弱连通图。
-
可达:对于有向图的两点 \(u, v\),若存在途径使得 \(v_0 = u\) 且 \(v_k = v\),则称 \(u\) 可达 \(v\),记作 \(u \rightsquigarrow v\)。
无向图的连通性
本节研究双连通分量相关知识点,研究对象为无向连通图。若无特殊说明,默认图为 无向连通图。
相关定义
无向图连通性,主要在研究割点和割边。
- 割点:在无向图中,删去后使得连通分量数增加的结点称为 割点。
- 割边:在无向图中,删去后使得连通分量数增加的边称为 割边(桥)。
孤立点和孤立边的两个端点都不是割点,但孤立边是割边。非连通图的割边为其每个连通分量的割边的并。
为什么割点和割边这么重要?对于无向连通图上的非割点,删去它,图仍然连通,但删去割点后图就不连通了。因此割点相较于非割点对连通性有更大的影响。割边同理。
- 点双连通图:不存在割点的无向连通图称为 点双连通图。根据割点的定义,孤立点和孤立边均为点双连通图。
- 边双连通图:不存在割边的无向连通图称为 边双连通图。根据割边的定义,孤立点是边双连通图,但孤立边不是。
点双连通图和边双连通图有很好的性质,帮助我们解题。
- 点双连通分量:一张图的极大点双连通子图称为 点双连通分量(V-BCC),简称 点双。
- 边双连通分量:一张图的极大边双连通子图称为 边双连通分量(E-BCC),简称 边双。
将某种类型的连通分量根据等价性或独立性缩成一个点的操作称为 缩点,原来连接两个不同连通分量的边在缩点后的图上连接对应连通分量缩点后形成的两个点。
边双和点双缩点后均得到一棵树,而强连通分量缩点后得到一张有向无环图。
-
点双连通:若 \(u,v\) 处于同一个点双连通分量,则称 \(u,v\) 点双连通。一个点和它自身点双连通。由一条边直接相连的两点也是点双连通的。
-
边双连通:若 \(u,v\) 处于同一个边双连通分量,则称 \(u,v\) 边双连通。一个点和它自身边双连通,但由一条边直接相连的两点不一定边双连通。
点双连通和边双连通是无向图连通性相关最基本的两条性质。
注:点双连通和边双连通有若干等价定义(或很相似),本文选取的定义并非最常见的定义。其它定义将会作为连通性性质在下文介绍。
双连通的基本性质
研究双连通的性质时,最重要的是把定义中的基本元素 —— 割点和割边的性质理清楚。然后从整体入手,考察对应分量在原图上的分布形态,再深入单个分量,考察分量内两点之间的性质。以求对该连通性以及连通分量有直观的印象,思考时有清晰的图像作为辅助,做题思路更流畅。
边双连通比点双连通简单,所以先介绍边双连通。
边双连通
考虑割边两侧的两个点 \(u,v\)(不是割边的两端)。因为删去割边后 \(u,v\) 不连通,所以考虑任何一条 \(u\) 到 \(v\) 的迹,这条割边一定在迹上 —— 否则删去后 \(u,v\) 仍连通。将这样的边称为必经边:从 \(u\) 到 \(v\) 必须要经过的边。两点之间的所有必经边,就是连接它们所有迹的边集的交。在研究必经边时,重复经过一条边是不优的,所以只需考虑两点之间的所有迹。
结论:两点之间任意一条迹上的所有割边,就是两点之间的所有必经边。
可以看出割边就是必经边的代名词。必经边需要两个点才有定义,而割边直接由原图定义,且边双缩点后可根据树边(割边)直接求出两点之间的必经边。
断开一条割边,整张图会裂成两个连通块。断开所有割边,整张图会裂成割边条数 \(+1\) 个连通块。每个连通块内部不含割边且不能再扩大(再扩大就包含割边了),是原图的边双连通分量。这说明边双连通分量由割边连接,且形成一棵树的形态。将边双缩成一个点,得到边双缩点树,每条边对应原图的一条割边,每个点对应原图的一个边双连通分量。
不同边双没有公共点,即每个点恰属于一个边双,所以:
边双连通的传递性:若 \(a\) 和 \(b\) 边双连通,\(b\) 和 \(c\) 边双连通,则 \(a\) 和 \(c\) 边双连通。
边双内部不含割边,所以:
结论:\(u,v\) 边双连通当且仅当 \(u,v\) 之间没有必经边。
考虑边双中的一条边 \((u,v)\)。将其删去后,其两端仍连通,将连接它们的路径和 \(u,v\) 拼起来,得到不经过重复边的回路。
结论:对于边双内任意一条边 \(u,v\),存在经过 \((u,v)\) 的回路。
结论:对于边双内任意一点 \(u\),存在经过 \(u\) 的回路。
点双连通
删去割点后不连通的两个点之间任意一条路径必然经过该割点,称这样的点为必经点:从 \(u\) 到 \(v\) 必须要经过的点。在研究必经点时,重复经过一个点是不优的,所以只需考虑两点之间的所有路径。
结论:两点之间任意一条路径上的所有割点,就是两点之间的所有必经点。
与边双不同的是,两个点双之间可能有交:考虑一个 “\(8\)” 型结构,中间的交点在两个点双都出现了。因此 点双连通不具有传递性。但若两个点双有交,交点一定唯一,否则两点双可以合并为更大的点双(证明点双相关性质时,点双的极大性通常是导出矛盾的关键):删。进一步地,若两点双有交,交点一定阻碍了它们继续扩大:如果删去该点之后两点双仍连通,同样地,两点双可以合并为更大的点双。
结论:若两点双有交,那么交点一定是割点。
-
区分:称两点属于同一个边双,即两点边双连通,就是判断它们所在边双是否相同。称两点属于同一个点双,即两点点双连通,是检查是否存在点双同时包含这两个点。因为两点双交点数大于 \(1\),所以若两点点双连通,那么包含这两点的点双唯一。
-
说明:点双包含原图割点,但在只关心该点双时,原图割点变成了非割点。删去割点后,原图分裂为若干连通分量,但该点双仍连通。
现在我们知道点双交点是割点,那么割点一定是点双交点吗?删去一个割点,将整张图分成若干连通块。存在割点的两个邻居 \(u,v\) 不连通。\(u,v\) 不会处于同一个点双。假设存在这样的点双,则删去割点后 \(u,v\) 不连通,矛盾。又因为 “一边连两点” 是点双连通图,所以割点和 \(u,v\) 均点双连通。这说明割点属于超过一个点双。
结论:一个点是割点当且仅当它属于超过一个点双。
结论:由一条边直接相连的两点点双连通。
结合上述性质,得:
推论:一条边恰属于一个点双。
可知割点是连接点双的桥梁,正如割边是连接边双的桥梁。用一个点代表一个点双,并将点双代表点向它包含的割点连边,得到 块割树。
考虑点双中的一个点 \(x\)。将其删去后,剩下所有点连通。因此,若 \(d(x) > 1\),考虑其任意两个邻居 \(u \neq v\),将新图 \(u,v\) 之间的路径和 \(x\) 连起来,得到经过 \(x\) 的简单环。若 \(d(x) = 1\),则整个点双为 “一边连两点” 的平凡形态。
结论:对于 \(n \geq 3\) 的点双内任意一点 \(u\),存在经过 \(u\) 的简单环。
Tarjan 求割点
前置知识:DFS 树,DFS 序。
- 注意区分:DFS 序表示对一张图 DFS 得到的结点序列,而时间戳 DFN 表示每个结点在 DFS 序中的位置。
记 \(x\) 的子树为 \(x\) 在 DFS 树上的子树,包含 \(x\) 本身,记作 \(T(x)\)。记 \(T'(x) = V\backslash T(x)\),即整张图除了 \(T(x)\) 以外的部分。
不妨认为 \(G\) 是无向连通图。对于非连通图,对每个连通分量分别求割点。
提出一种新的理解 Tarjan 算法的方式。Tarjan 算法时 low
数组凭空出现,抽象的定义让很多初学者摸不着头脑,从提出问题到解决问题的逻辑链的不完整性让我们无法感受到究竟是怎样的灵感促使了这一算法的诞生。
非根的割点判定
设 \(x\) 不为 DFS 树的根节点,则 \(T'(x)\) 非空。
若 \(x\) 是割点,则存在 \(y \in T(x)\) 满足 \(y\) 不经过 \(x\) 能到达的所有点均属于 \(T(x)\)。
交换条件和结论,若存在 \(y \in T(x)\) 满足 \(y\) 不经过 \(x\) 能到达的所有点均属于 \(T(x)\),则 \(x\) 是割点。因为删去 \(x\) 后 \(y\) 和 \(T'(x)\) 不连通。反之,每个 \(y\) 都不经过 \(x\) 和 \(T'(x)\) 连通,而删去 \(x\) 不影响 \(T'(x)\) 的连通性(\(T'(x)\) 直接通过树边连通),所以整张图仍连通。
如何表示 “不经过 \(x\) 能到达的所有点均属于 \(T(x)\)”?
注意到,如果 \(y\) 不经过 \(x\) 和 \(T'(x)\) 连通,那么存在 \(y\) 到 \(v \in T'(x)\) 的路径,满足 \(v\) 是路径上第一个属于 \(T'(x)\) 的结点。设路径上倒数第二个点为 \(u\in T(x)\)。如果 \((u, v)\) 是树边,那么 \(u = x\),矛盾。因此 \((u, v)\) 是非树边,那么 \(v\) 是 \(u\) 的祖先(祖先后代性)。又因为 \(x\) 是 \(u\) 的祖先且 \(v\) 在 \(x\) 的子树外,所以 \(v\) 是 \(x\) 的祖先。这说明 \(v\) 已经被访问过 且 时间戳小于 \(x\)(时间戳单调性)。设 \(d_x\) 表示 \(x\) 的时间戳,则 \(d_v < d_x\)。
进一步地,因为 \(x\) 的不同儿子子树之间没有非树边(子树独立性),设 \(x\) 的儿子 \(y’\) 的子树包含 \(y\),那么 \(u \in T(y')\)。
因此,如果 \(y\) 不经过 \(x\) 和 \(T'(x)\) 连通,即 \(x\) 不是割点,那么存在 \(u \in T(y')\) 使得 \(u\) 可以通过一条非树边到达 \(T'(x)\) 的某个结点,且一定为 \(x\) 的祖先。设 \(f_x\) 表示与 \(x\) 通过 非树边 相连的所有点的时间戳的最小值,则条件可写为 \(f_u < d_x\)。
-
对于 \(T(y')\),如果存在 \(u \in T(y')\) 满足 \(f_u < d_x\),那么删去 \(x\) 后 \(T(y')\) 的每个点和 \(T'(x)\) 均连通:\(T(y')\) 内所有点连通,且 \(u\) 和 \(T'(x)\) 连通。
-
反之,如果 \(T(y')\) 内所有点的 \(f\) 值均不小于 \(d_x\),那么删去 \(x\) 后 \(T(y')\) 的每个点和 \(T'(x)\) 均不连通。因为如果连通,那么总得有个点能一步连通。
这样,我们得到了非根结点的割点判定法则:
\(x\) 是割点当且仅当存在树边 \(x\to y'\),使得 \(y’\) 子树 不存在 点 \(u\) 使得 \(f_u < d_x\)。
这等价于存在 \(x\) 的儿子 \(y’\),满足 \(\min_{u\in T(y')} f_u \geq d_x\)。
设 \(g_x\) 表示 \(x\) 的子树内所有点 \(u \in T(x)\) 的 \(f_u\) 的最小值,根据树形 DP,有
对于右半部分,忽略 \((x, y)\) 必须是非树边的条件不会导致错误:如果 \(y\) 是 \(x\) 的儿子,显然没有问题。如果 \(y\) 是 \(x\) 的父亲,也不会导致错误,因为判定是 \(g_u \geq d_x\),有等号。但必须注意求解割边时不能忽略,因为判定是 \(g_u > d_x\)。
说明:将 \(g_x\) 初始化为 \(d_x\) 显然不会导致错误。
应用:研究删去 \(x\) 后整张图的形态。删去 \(x\) 后,每个判定 \(x\) 为割点的 \(y’\) 的 \(T(y')\) 单独形成一个连通块,剩余部分(其它所有 \(T'(x)\) 和 \(T(y’)\))形成一个连通块。因为判定割点的准则就是删去 \(x\) 后 \(y\) 是否与 \(T'(x)\) 连通。
根的割点判定与代码
设 \(x\) 为 DFS 树的根节点。
若 \(x\) 在 DFS 树上有大于一个儿子,根据子树独立性,删去 \(x\) 后各儿子子树不连通,所以 \(x\) 为割点。反之删去 \(x\) 后剩余部分通过树边连通,\(x\) 不是割点。
综上,使用 Tarjan 算法求解无向图 \(G\) 的所有割点的时间复杂度为 \(\mathcal{O}(n + m)\)。
模板题 代码。
#include <bits/stdc++.h>
using namespace std;
constexpr int N = 1e5 + 5;
int n, m, R;
int dn, dfn[N], low[N], cnt, buc[N]; // dfn 是时间戳 d, low 是 g
vector<int> e[N];
void dfs(int id) {
dfn[id] = low[id] = ++dn; // 将 low[id] 初始化为 dn 不会导致错误, 且一般都这么写
int son = 0;
for (int it : e[id]) {
if (!dfn[it]) {
son++, dfs(it), low[id] = min(low[id], low[it]);
if (low[it] >= dfn[id] && id != R)
cnt += !buc[id], buc[id] = 1;
} else
low[id] = min(low[id], dfn[it]);
}
if (son >= 2 && id == R)
cnt += !buc[id], buc[id] = 1;
}
int main() {
cin >> n >> m;
for (int i = 1; i <= m; i++) {
int u, v;
cin >> u >> v;
e[u].push_back(v), e[v].push_back(u);
}
for (int i = 1; i <= n; i++)
if (!dfn[i])
R = i, dfs(i);
cout << cnt << endl;
for (int i = 1; i <= n; i++)
if (buc[i])
cout << i << " ";
return 0;
}
割边的求法
Tarjan
求割边的思路和求割点的思路类似。
探究一条边是割边的充要条件。
首先,树边使得整张图连通,所以割掉非树边不影响连通性。因此 \(e = (u, v)\) 是割边的 必要条件 是 \(e\) 为树边。
不妨设 \(v\) 是 \(u\) 的儿子。若割掉 \(e\) 后图不连通,那么因为 \(T(v)\) 和 \(T'(v)\) 内部通过树边连通,所以图只能分裂为 \(T(v)\) 和 \(T(v')\) 两部分。这说明 \(T(v)\) 的所有结点必须通过 \(e\) 才能到达 \(T(v')\),即 是连通 \(e\) 内外的 “桥”。
考虑如何判定这种情况。根据求解割点的经验,不难得出 \(e\) 为割边当且仅当 \(g_v > d_u\)。对比割点判定法则,没有等号的原因为:删去的是边而非结点,所以只要子树内结点能绕过 \(e\) 到达 \(T(v’)\),包括 \(u\) 本身,那么 \(e\) 就不是割边。
Tarjan
求割边有个细节,就是判断非树边。对于当前边 \(u\to v\),若 \(v\) 是 \(u\) 的父亲,那么跳过这条边。这样做有重边时会出现错误,因为会将树边的重边也判为树边。
解决方法是记录边的编号。对于 vector
,在 push_back
时将当前边的编号一并压入。对于链式前向星,使用成对变换技巧:初始化 cnt = 1
,每条边及其反边在链式前向星中存储的编号分别为 \(2k\) 和 \(2k + 1\),将当前边编号异或 \(1\) 即得反边编号。
算法的时间复杂度为 \(\mathcal{O}(n + m)\)。
边双连通分量缩点
将 \(G\) 的割边删去,剩余每个连通分量是原图的边双连通分量。得到原图边双连通分量之后,记录每个点所在边双编号,容易对 \(G\) 边双缩点。
接下来介绍 Tarjan 算法实现的边双连通分量缩点,类似方法可应用在点双缩点上。关于点双缩点,见 “图论进阶” 圆方树。
先 Tarjan
找出所有割边,再 DFS 找出所有边双连通分量是可行的,但太麻烦了。我们希望一遍 Tarjan
就可以做到求出所有割边和边双连通分量。为此,需要对 Tarjan
算法进行一些改进。
考虑 \(G\) 的 DFS 树 \(T\),找到其中任意一条割边,满足子树内没有割边。这样,该割边的子树内所有结点形成边双连通分量。将这些结点从图中删去,包括与它们相邻的所有边。重复该过程直到整张图不含割边,则剩下来的图也是原图的边双连通分量。整个过程可以视作在边双缩点树上剥叶子,不断断开一条与叶子相连的割边,剥下一个边双连通分量。如果有 \(c\) 条割边,那么有 \(c + 1\) 个边双连通分量。
考虑将上述操作与 Tarjan
算法相结合:回溯时,若判定 \(u\to v\) 为割边,则 \(v\) 的子树内还没有被删去的点形成一个边双,将它们全部删去。具体地,维护栈 \(S\),表示已经访问过且还未被删去的所有结点。每次 DFS 进入一个结点时,将其压入栈,那么从栈底到栈顶时间戳依次递增。遇到割边 \(u\to v\) 时,从栈顶到 \(v\) 的所有结点构成一个边双,将它们全部弹出。
最后栈内剩下一些点,它们单独形成一个边双,且包含根结点。不要忘记将它们弹出。
正确性说明(感性理解,非严格证明):设原图割掉 \((u, v)\) 后形成的连通块分别为 \(u\in U\) 和 \(v\in V\)。由于判定 \(u\to v\) 为割边是在回溯时进行的,所以 \(V\) 中所有结点全部被访问过,且全部在 \(v\) 之后访问,即栈中从 \(v\) 往上一直到栈顶均属于 \(V\),这些结点就是我们要删除的。对于 \(V\) 内部的所有割边,我们在判定它们为割边时就已经处理掉了它们对应的边双,所以弹出结点内部也不会有割边。
上述思想在点双缩点和强连通分量缩点时也会用到,需要用心体会。
算法的时间复杂度是优秀的 \(\mathcal{O}(n + m)\)。
模板题 代码。
#include <bits/stdc++.h>
using namespace std;
constexpr int N = 5e5 + 5;
int n, m;
vector<pair<int, int>> e[N];
vector<vector<int>> ans;
int dn, dfn[N], low[N], stc[N], top;
void form(int id) {
vector<int> S;
for (int x = 0; x != id;)
S.push_back(x = stc[top--]);
ans.push_back(S);
}
void tarjan(int id, int eid) {
dfn[id] = low[id] = ++dn;
stc[++top] = id;
for (auto _ : e[id]) {
if (_.second == eid)
continue;
int it = _.first;
if (!dfn[it]) {
tarjan(it, _.second);
low[id] = min(low[id], low[it]);
if (low[it] > dfn[id])
form(it);
} else
low[id] = min(low[id], dfn[it]);
}
}
int main() {
ios::sync_with_stdio(0), cin.tie(0), cout.tie(0);
cin >> n >> m;
for (int i = 1; i <= m; i++) {
int u, v;
cin >> u >> v;
e[u].push_back({v, i});
e[v].push_back({u, i});
}
for (int i = 1; i <= n; i++) {
if (!dfn[i])
tarjan(i, 0), form(i);
}
cout << ans.size() << "\n";
for (auto S : ans) {
cout << S.size() << " ";
for (int it : S)
cout << it << " ";
cout << "\n";
}
return 0;
}
例题
[CEOI2011] Traffic
“保证边在交点以外的任何地方不相交”,从这句话得知图是一个平面图,又因为图为一个平面图,所以我们很容易地发现一个性质。
对于所有西岸和东岸先按照 \(y\) 降序排列,然后依次考虑西岸的每一个点,如果这个西岸的点不能到达某个东岸的点,那么他后面的所有点都不能到达这个东岸的点。换句话说也就是,每个西岸的点能到达东岸的点是一段 \(y\) 值连续的区间,且如果一个点不能到达区间内的一个其它点,那么所有的东岸点都不能到达,因为图是平面图。
所以我们考虑一个这样的做法,求出所有西岸点能到达的所有东岸点,再对这些点以及中间的点建出反图,先按东岸点 \(y\) 值从大到小的顺序求出每个西岸点能到达的 \(y\) 值最大的东岸点。然后按东岸点 \(y\) 值从小到大的顺序求出每个西岸点能到达的 \(y\) 值最小的东岸点。
所以最终答案即为这个西岸点能到达的 \(y\) 值最大的点减去能到达的 \(y\) 最小的点 \(+ 1\)。
[POI2008] BLO-Blockade
分情况讨论,即这个点是不是原图的割点,所以我们要求出割点。
- 如果不是,那么答案即为 \(2\times (n - 1)\)。
- 如果是,那么答案为去掉这个割点的每个连通块的大小乘去除这个连通块的的其它所有连通块的大小之和。
很容易发现,答案具有对称性,所以我们只用在求割点的过程中求出一半答案最后再 \(\times 2\) 即可。
这种发现性质并应用性质的思想值得学习。
[HNOI2012] 矿场搭建
这道题很有意思,很明显这道题和点双连通分量有关,可以分 \(3\) 种情况讨论。
- 如果一个点双连通分量里面没有割点那么只需设置 \(2\) 个救援出口,因为只设置一个的话,可能刚好这个点发生事故。令这个点双大小为 \(s\),所以方案数 \(s\times (s - 1) ÷ 2\)。
- 如果一个点双连通分量里面有且只有一个割点,那么要只需设置 \(1\) 个救援出口,因为如果割点发生事故的话,点双里的其它点可以去这个救援出口,否则可以通过割点到达其它连通块。令这个点双大小为 \(s\),所以方案数为 \(s - 1\),既不能在割点设置救援出口。
- 如果一个点双连通分量里面有多个割点,那么要无需设置救援出口,因为如果一个割点发生事故的话,点双里的其它点还可以通过其他割点到其他连通块的救援出口。方案数为 \(1\)。
先求出所有点双,最后将所有连通块的方案数相乘即为答案。
实时交通查询系统
很明显是关于必经点的问题,所以先建出圆方树,再考虑怎么求出答案,因为圆点和方点交错出现,我们可以分类讨论两个圆点在圆方树上的路径的除开本身起点和终点的两个圆点的其它圆点个数。(注: 圆方树的性质为圆点只向方点连边,方点只向圆点连边,换句话说也就是圆点和方点交错出现。)
- 如果两个圆点在圆方树上的 \(LCA\) 为方点,令两个圆点和 \(LCA\) 在圆方树上的深度分别为 \(depth_u\) , \(depth_v\) 与 \(depth_x\)。则 \(u\) 到 \(LCA\) 路径上的圆点个数为 \((depth_u - depth_x - 1) ÷ 2\),\(v\) 到 \(LCA\) 的圆点个数为 \((depth_v -depth_x - 1) ÷ 2\),两者加起来为 \((depth_u + depth_v - 2\times depth_x) ÷ 2 - 1\)。
- 如果两个圆点在圆方树上的 \(LCA\) 为圆点,令两个圆点和 \(LCA\) 在圆方树上的深度分别为 \(depth_u\) , \(depth_v\) 与 \(depth_x\)。则 \(u\) 到 \(LCA\) 路径上的圆点个数为 \((depth_u - depth_x) ÷ 2\),\(v\) 到 \(LCA\) 的圆点个数为 \((depth_v -depth_x) ÷ 2\),因为两者直接加起来会把 \(LCA\) 算两次,所以最后要 \(-1\),两者加起来为 \((depth_u + depth_v - 2\times depth_x) ÷ 2 - 1\)。
综上所述,圆点个数为 \((depth_u + depth_v - 2\times depth_x) ÷ 2 - 1\)。可是题目问的是从第 \(s\) 条路到第 \(t\) 条路的必经点个数,所以我们分别把两条路的四个端点进行询问两点之间的必经点,最后取最大值即可。
有向图的可达性
研究有向图可达性时,强连通分量是最基本的结构。
本文研究强连通分量。若无特殊说明,默认图为 有向弱连通图。
相关定义
- 强连通:对于有向图的两点 \(u, v\),若它们相互可达,则称 \(u, v\) 强连通,这种性质称为 强连通性。
显然,强连通是等价关系,强连通性具有传递性。
-
强连通图:满足任意两点强连通的有向图称为 强连通图。它等价于图上任意点可达其它所有点。
-
强连通分量:有向图的极大强连通子图称为 强连通分量(Strong Connected Component,SCC)。
强连通分量在求解与有向图可达性相关的题目时很有用,因为在只关心可达性时,同一强连通分量内的所有点等价。
有了使用 Tarjan 算法求割点,割边和边双缩点的经验,我们可以自然地将这些方法进行调整后用于求解强连通分量的缩点问题。不过在此之前,我们需要研究有向图 DFS 树的性质,毕竟整个过程一定离不开 DFS。
有向图 DFS 树
不同于无向图,对于弱连通图 \(G\),从任意一点出发 DFS,不一定能访问到图上的所有结点。一般而言,有向图的 “DFS 树” 是一个森林:按任意顺序遍历每个结点,若当前点没有 DFS 过,则从该点开始 DFS,并按构建无向图 DFS 树的方式构建以该点为根的有向图 DFS 树。这会形成若干棵 DFS 树,但在研究强连通性时,它们之间相互独立。对于任意强连通的两点 \(u, v\),第一次访问它们所在的强连通分量的 DFS 树一定同时包含 \(u, v\)。因此不同 DFS 树之间的任意两点不是强连通的。
我们考察有向图 DFS 树 \(T\) 的形态,从而得到判定 SCC 的准则。
除了有向的树边以外,\(T\) 还有一些边:
-
从祖先指向后代的非树边,称为 前向边。
-
从后代指向祖先的非树边,称为 返祖边。
-
两端无祖先后代关系的非树边,称为 横叉边。
前向边和返祖边由无向图 DFS 树的非树边的两种定向得到,而横叉边则是无向图 DFS 树所不具有的了,因为无向图 DFS 树的非树边具有祖先后代性。
为什么有向图 DFS 树会出现横叉边?因为一个后访问到的结点可以向之前访问的结点连边。例如:
\(G = (\{1, 2, 3\}, \{1\to 2, 1\to 3, 3\to 2\})\),如果我们在 DFS 时先访问结点 \(2\),那么在访问结点 \(3\) 时就会找到横叉边 \(3\to 2\)。
接下来讨论有向边 \(u\to v\),设 \(d = lca(u, v)\),\(d\to u\) 的第一个结点为 \(u'\),\(v'\) 同理。设 \(fa(i)\) 表示 \(i\) 在 \(T\) 上的父亲。设 \(T(i)\) 表示 \(i\) 的子树。设 \(dfn(i)\) 表示点 \(i\) 的时间戳。
尽管子树的独立性变弱了,但我们依然可以发现一些性质:
-
对于前向边 \(u\to v\),\(u\) 本身就能通过树边到达 \(v\),删去后没有任何影响。它甚至不影响可达性。
-
对于返祖边 \(u\to v\),它使 \(T\) 上从 \(u\) 到 \(v\) 的路径上的所有点之间强连通。多个返祖边结合可以形成更大更复杂的强连通结构。
-
对于横叉边 \(u\to v\),\(dfn(v) < dfn(u)\)。否则 \(u\) 先被访问,那么无论是 \(u\) 的儿子访问到了 \(v\),还是 \(u\) 本身直接访问 \(v\) 离开 \(u\) 之前 \(v\) 一定被访问过了,即 \(v\in T(u)\)。那么 \(u\to v\) 为前向边,矛盾。感性理解一下就是,只会有右边的子树连向左边的子树,否则如果左边的子树连向右边的子树就不符合 \(dfs\) 的遍历顺序,又因为左边子树的 \(dfn\) 值一定比右边的子树大,所以有 \(dfn(v) < dfn(u)\)。
综合上述三条性质,可知返祖边和横叉边均减小时间戳,只有树边增大时间戳。前向边不影响强连通性,所以接下来我们忽略掉所有前向边。
考虑横叉边 \(u\to v\),则 \(dfn(v) < dfn(u)\)。因此,为了探究横叉边对强连通性的影响,对于 \(dfn(v) < dfn(u)\) 的点对 \((u,v)\),我们希望知道 \(v\) 可达 \(u\) 的充要条件。
考虑到若 \(v\) 可达 \(u\),那么 \(v\rightsquigarrow u\) 的路径上存在一条边 \(x\to y\) 使得 \(dfn(x) < dfn(u) \leq dfn(y)\)。因为 \(dfn(x) < dfn(y)\),所以 \(x\to y\) 是树边。当 \(y = u\) 时,显然 \(u\in T(x)\);否则 \(y\neq u\),则 \(u\) 在 \(x\) 之后,\(y\) 之前被访问,则 \(u\in T(x)\)。因此,若 \(dfn(v) < dfn(u)\) 且 \(v\) 可达 \(u\),则 \(v\) 可达 \(fa(u)\)。对 \(u, u'\) 的树上路径上的所有点依次使用该结论,推出 \(v\) 可达 \(d\)。
反之,若 \(v\) 可达 \(d\),显然 \(v\) 可达 \(u\)。
结论: 若 \(dfn(v) < dfn(u)\),则 \(v\) 可达 \(u\) 当且仅当 \(v\) 可达 \(d\)。
可知对于横叉边 \(u\to v\),它对强连通性产生影响当且仅当 \(v\) 可达 \(u\),且一定满足 \((u,v)\) 强连通。称这些边为有用的横叉边。
该结论可以进一步推出:
结论: 若 \(u,v\) 强连通,则 \(u,v\) 在树上路径上的所有点强连通。
结论: 强连通分量在有向图 DFS 树上弱连通。
Tarjan 求 SCC
因为每个 SCC 在 \(T\) 上的最浅结点唯一,故考虑在最浅的结点处求出包含它的 SCC。称一个点是关键点,当且仅当它是某个 SCC 的最浅结点。
考虑判定关键点。对于点 \(x\),如果它不是关键点,那么它的子树内一定存在点 \(u\),使得从 \(u\) 出发,存在返祖边 \(u\to v\) 满足 \(v\) 是 \(x\) 的祖先,或存在横叉边 \(u\to v\) 满足 \(v\) 可达 \(x\) 的祖先。无论是哪种情况,均有 \(dfn(v) < dfn(x)\)。
因此,设 \(g_x\) 表示 \(T(x)\) 内所有结点 \(u\) 的返祖边 \(u\to v\) 和 \(v\) 可达 \(d\) 的横叉边 \(u\to v\) 的所有 \(v\) 的最小时间戳。我们断言,点 \(x\) 是关键点当且仅当 \(g_x\geq dfn(x)\)。
证明: 考虑证明点 \(x\) 不是关键点当且仅当 \(g_x < dfn(x)\)。
充分性:若 \(g_x < dfn(x)\),那么存在 \(u\in T(x)\),使得从 \(u\) 出发存在返祖边或有用的横叉边 \(u\to v\) 满足 \(dfn(v) < dfn(x)\)。如果 \(u\to v\) 是返祖边,那么 \(v\) 显然是 \(x\) 的祖先;如果 \(u\to v\) 是横叉边,那么 \(v\) 可达 \(d\),又因为 \(dfn(d) < dfn(v) < dfn(x)\) 且 \(d, x\) 均为 \(u\) 的祖先,所以 \(d\) 是 \(x\) 的祖先。无论哪种情况,都说明包含 \(x\) 的 SCC 包含 \(x\) 的祖先,\(x\) 不是关键点。
必要性:若 \(x\) 不是关键点,那么 \(x\) 可达 \(fa(x)\)。考虑任意一条 \(x\rightsquigarrow fa(x)\) 的路径,找到第一次离开 \(T(x)\) 的边 \(u\to v\)。若 \(u\to v\) 是返祖边,那么 \(v\) 是 \(x\) 的祖先;若 \(u\to v\) 是横叉边,那么 \(dfn(v) < dfn(x)\) 且 \(v\) 可达 \(fa(x)\),则 \(v\) 可达 \(u\),则 \(v\) 可达 \(d\)。无论哪种情况,都说明 \(g_x < dfn(x)\)。
说明:一般将 \(g_x\) 初始化为 \(dfn(x)\),判定条件为 \(g_x = dfn(x)\)。
类似边双缩点,这次我们依然采用 “剥叶子” 的方式:每次找到深度最大的关键点,其子树内所有未被删去的结点和它自己形成一个 SCC,将它们删去。
维护栈 \(S\),表示已经访问过但还没有被删去(未形成确定 SCC)的结点。在回溯时判定关键点。若 \(x\) 为关键点,则将栈顶一直到 \(x\) 的所有结点弹出,表示它们形成一个 SCC。
最后考虑如何求 \(g\)。
- 对于树边 \(u\to v\),用 \(g_v\) 更新 \(g_u\)。
- 对于前向边 \(u\to v\),用 \(dfn(v)\) 更新 \(g_u\) 没有任何影响,因为 \(dfn(v) \geq g_u\)。
- 对于返祖边 \(u\to v\),用 \(dfn(v)\) 更新 \(g_u\)。
- 对于横叉边 \(u\to v\),若 \(v\) 不可达 \(d\),那么 \(v\) 和 \(d\) 不强连通,所以从 \(v\) 不断回溯至 \(d\) 的过程中一定有关键点弹出 \(v\)。否则 \(v\) 可达 \(d\),那么 \(v\) 和 \(d\) 强连通,因为 \(d\) 没有被弹出,所以 \(v\) 也没有被弹出。但是直接这样证明会涉及 \(g\) 的正确性和弹出 SCC 的正确性之间的循环论证。注意到横叉边更新 \(g\) 的正确性只依赖于已经弹出的每个点集是 SCC,所以归纳假设已经弹出的每个点集为 SCC,这样当前弹出点集的每个点的 \(g\) 是正确的,从而保证了当前弹出点集也是 SCC。
对于返祖边 \(u\to v\),\(v\) 显然没有被弹出。
综上所述,得到如下转移式:对于树边 \(u\to v\),用 \(g_v\) 更新 \(g_u\)。对于非树边 \(u \to v\),若 \(v\) 在栈中,则用 \(dfn(v)\) 更新 \(g_u\)。
SCC 缩点后,得到的新图一定不包含环,否则环上所有结点对应的 SCC 可以形成一个更大的 SCC。这说明 SCC 缩点图是一张 DAG。
重要结论:对于两个 SCC \(S_1,S_2\),若 \(S_1\) 可达 \(S_2\),则 \(S_1\) 比 \(S_2\) 后弹出栈。按弹出顺序写下所有 SCC,得到缩点 DAG 的反拓扑序。因此,按编号从大到小遍历 SCC,就是按拓扑序遍历缩点 DAG,省去了拓扑排序。
以下是 模板题 题解。
因为可以多次经过同一个点,所以一旦进入某个 SCC,就一定可以走遍其中所有点,获得所有点权值之和的贡献。但是一旦离开 SCC,就没法再回来了,否则离开后遍历的点和当前 SCC 强连通。
因此,将有向图 SCC 缩点后得到 DAG,每个 SCC 缩点后的权值等于其中所有点的权值之和。问题转化为 DAG 最长带权路径,拓扑排序 DP 即可。时间复杂度 \(\mathcal{O}(n + m)\)。
#include <bits/stdc++.h>
using namespace std;
constexpr int N = 1e4 + 5;
int n, m, cn, col[N];
int a[N], val[N], f[N];
int top, stc[N], vis[N], dn, dfn[N], low[N];
vector<int> e[N], g[N];
void tarjan(int id) {
vis[id] = 1, dfn[id] = low[id] = ++dn, stc[++top] = id;
for (int it : e[id]) {
if (!dfn[it])
tarjan(it), low[id] = min(low[id], low[it]); // 树边
else if (vis[it])
low[id] = min(low[id], dfn[it]); // 在栈内则更新
}
if (dfn[id] == low[id]) {
col[id] = ++cn;
while (stc[top] != id)
col[stc[top]] = cn, vis[stc[top--]] = 0; // 弹出栈内结点到 id 形成强连通分量
vis[id] = 0, top--;
}
}
int main() {
cin >> n >> m;
for (int i = 1; i <= n; i++)
scanf("%d", &a[i]);
for (int i = 1; i <= m; i++) {
int u, v;
scanf("%d%d", &u, &v);
e[u].push_back(v);
}
for (int i = 1; i <= n; i++)
if (!dfn[i])
tarjan(i);
for (int i = 1; i <= n; i++) {
val[col[i]] += a[i];
for (int it : e[i]) {
if (col[i] != col[it])
g[col[i]].push_back(col[it]);
}
}
int ans = 0;
for (int i = cn; i; i--) { // 按编号顺序从大到小遍历就是按拓扑序遍历
f[i] += val[i];
ans = max(ans, f[i]);
for (int it : g[i])
f[it] = max(f[it], f[i]);
}
cout << ans << endl;
return 0;
}
例题
间谍网络
考虑这个有向图中的一个强连通分量,我们只用掌握了其中一个点,那么其它点都会被掌握。所以我们先用 Tarjan
缩点,再重新建一个图,入度为 \(0\) 的点即为我们必须掌握的点。所以我们取这些点的点权之和就行。(注:这里的点权为缩点后整个强连通分量的点权的最小值。)
[APIO2009] 抢掠计划
先分析题目性质,发现如果我们能到达一个强连通分量里的点那么我们一定能到达里面的所有点。所以我们可以先用 Tarjan
缩点,再进行拓扑排序 + \(DP\)就行,具体地,\(DP\) 过程为:
设 \(f_i\) 表示到达缩点后编号为 \(i\) 的强连通分量的的路径中抢劫的钱数最多的数目。
则初始化应为 \(f_{belong_s} = sum_{belong_s}\),其中 \(s\) 表示起点,\(belong_s\) 表示 \(s\) 所在的强连通分量的编号。
转移方程应为(采用刷表法):
其中 \(u\) 为当前队首,\(v\) 为与 \(u\) 相邻的点。
最终答案为 \(\max(f_{belong_t})\) \(t\) 为酒吧,\(belong_t\) 为 \(t\) 所在的强连通分量编号。
注:此题有一个坑点,进行 Tarjan
缩点时只需以 \(s\) 作为起点即可。不能把其余点作为起点进行 Tarjan
,因为可能从起点出发可能到不了这些点,从而导致更新答案错误。
[ZJOI2007] 最大半连通子图
因为一个强连通分量里的点绝对是这个图的半连通子图,所以我们先对原图进行缩点,然后再进行拓扑排序 + \(DP\),具体地:
设 \(f_i\) 表示以编号为 \(i\) 的强连通分量作为终点的最大半连通子图的大小,\(g_i\) 则表示不同的最大半连通子图的数目。
则转移方程为(采用刷表法):
其中 \(sum_i\) 表示编号为 \(i\) 的强连通分量的大小。
初始化为在新图中入度为 \(0\) 的强连通分量 \(s_i\),则 \(f_{s_i} = sum_{s_i},g_{s_i} = 1\)。
则最后答案即为 \(f\) 值最大的强连通分量的 \(g\) 值之和再 \(\mod X\)。
注: 此题有一个巨大的坑点,就是直接缩点后进行拓扑 + \(DP\) 是不行的,因为可能会有重边,导致计数是 \(g\) 值会被多算几次,导致答案错误。这时候我们要先去完重边后再进行 \(DP\)。
[SNOI2017] 炸弹
很容易就想到如果一个炸弹能炸到其它炸弹我们就将这个1炸弹与它能炸到的炸弹连一条有向边。
但是一个炸弹 \(i\) 能炸到 \(x_i - r_i \sim x_i + r_i\) 之间的所有炸弹,一个一个去连边肯定很 \(T\),但我们发现这是一段连续的区间,所以可以用线段树优化建图来优化这个过程。
建图完毕后,这个图可能有环存在,我们先进行缩点,把原图缩成一个有向无环图,然后再进行 \(DP\)。但我们发现,这个 \(DP\) 和以往缩点后进行的 \(DP\) 不大一样,因为往常是进行拓扑排序再 \(DP\),而这道题不行,因为初始状态应为出度为 \(0\) 的强连通分量,所以此时我们有两种做法。
- 对这个 \(DAG\) 建反图,再进行拓扑 + \(DP\)。
- 我们发现出度为 \(0\)为初始状态很符合记忆化搜索的过程,所以直接进行 \(dfs\) 然后再更新答案即可。
注: 这道题拓扑排序还是要去重边,原因同上一道例题,因为统计个数就会算重答案,而 \(dfs\) 且不用担心,因为有记忆化保证每个点不会被同一个点更新多次。
[HAOI2010] 软件安装
我们发现直接建图后对于一个强连通分量里的所有点要么全选,要么全不选,所以可以先把这些强连通分量缩点。
又因为原图除入度为 \(0\) 的点后,剩余所有点的入度皆为 \(1\),所以这张图刚好是多棵树组成的森林。
那我们就可以从上一题的经验中知道,这道题也可以进行记忆化搜索。
直接树上 \(DP\) 即可。
[SDOI2010] 所驼门王的宝藏
考虑如果我们到了某一行的某一个格子,且这个格子的类型为“横天门”,那么我们就可以到达这一行的所有格子,且特别地我们与这一行的其它“横天门”形成了一个环。所以我们可以考虑对每一行新建一个虚点,不管什么样的格子都向这个虚点连一条有向边,如果这个格子为“横天门”,那么这个虚点再向这个点单独连边。“纵寰门”同理。
然后我们再对这个图进行缩点,拓扑排序 + \(DP\) 跑最长链即可。