初级图论

CHANGE LOG

  • 2021.12.5:修改例题代码与部分表述,增加基础定义。
  • 2022.4.22:重构文章。
  • 2022.5.21:进行一些增补,添加 Floyd 算法,E-BCC 和 SCC 缩点。
  • 2022.5.25:添加 Hierholzer 算法。

1. 最短路

最短路是图论最基本的一类问题。

下文记 /(dis_u/) 表示从源点到节点 /(u/) 的最短路,/(n/) 为节点数 /(|V|/)/(m/) 为边数 /(|E|/)

1.1 Bellman-Ford

Bellman-Ford 是一种非常暴力的求解最短路的方法(BF 之于 Dijkstra 如同 FF 之于 Dinic)。

称一轮 松弛 为对于每一条边 /((u, v)/),用 /(dis_u + w_{u, v}/) 更新 /(dis_v/)。我们断言每轮至少有一个节点的最短路被更新。松弛 /(n – 1/) 轮即可。

正确性证明:设源点为 /(1/)。在 /(1/to u/) 的最短路 /(1/to p_1/to/cdots/to u/) 中,对于每个节点 /(p_i/)/(1/to p_1/to/cdots/to p_i/) 也一定是 /(1/to p_i/) 的最短路,反证法易证。所以一个节点的最短路一定由另一个节点的最短路扩展而来。因为最短路最多有 /(n – 1/) 条边,而第 /(i/) 轮松弛会得到边数为 /(i/) 的最短路,故至多只需松弛 /(n – 1/) 轮。

该算法还可以判断一张图上是否存在负环。如果在第 /(n/) 轮松弛时仍有节点的最短路被更新,那么图存在负环。

算法的时间复杂度为 /(/mathcal{O}(nm)/)

1.2 Dijkstra

Dijkstra 是基于 贪心 的最短路算法,适用于 非负权图

扩展 节点 /(u/) 为对于 /(u/) 的所有出边 /((u, v)/),用 /(dis_u + w_{u, v}/) 更新 /(dis_v/)

在已经得到最短路的节点中,取出没有扩展过的距离源点最近(即 /(dis/) 最小)的节点并扩展。因为没有负权边,所以取出的节点的最短路长度单调不降。

如何判断一个节点已经取到了最短路?实际上不需要判断,每次取出的节点恰取到了其最短路。根据边权非负以及 dijkstra 的贪心算法流程,可以通过归纳法与反证法证明这一点。

归纳假设已经扩展过的节点 /(p_1, p_2, /cdots, p_{k – 1}/) 在扩展时均取到了其最短路。/(p_k/) 为没有被扩展的 /(dis/) 最小的节点。

/(p_k/) 的最短路一定由 /(p_i(1/leq i < k)/) 的最短路扩展而来,不可能出现 /(dis(p_i) + w(p_i, p_{k + 1}) + w(p_{k + 1}, p_k) < dis(p_j) + w(p_j, p_k)(1/leq i, j < k)/) 的情况。否则由于边权非负,/(w(p_{k + 1}, p_k) /geq 0/),所以 /(dis(p_i) + w(p_i, p_{k + 1}) < dis(p_j) + w(p_j, p_k)/),即当前 /(dis(p_{k + 1}) < dis(p_k)/),与 /(dis(p_k)/) 的最小性矛盾。

初始令源点的 /(dis/)/(0/),假设成立,因此算法正确。

取出 /(dis/) 最小的节点的过程可以用优先队列 priority_queue 维护。每次扩展 /(u/) 时,若 /(dis_u + w_{u, v} < dis_v/),那么将 /((v, dis_u + w_{u, v})/) 即节点编号和它更新后的 /(dis/) 作为二元组丢进优先队列。尝试取出节点时,以 /(dis_u + w_{u, v}/) 为关键字取出最小的二元组,则它对应的节点编号就是我们要找的节点。

注意,一个节点可能有多个入边并多次进入优先队列。但当它第一次被取出时,得到的一定是最短路。为此,需要记录 vis[i] 表示一个节点是否被扩展过。若当前取出节点已经被扩展过,则忽略。也可以判断是否有当前二元组的第二个元等于第一个元(节点编号)的 /(dis/),若不等说明当前节点被扩展过了。否则复杂度将退化为 /(m ^ 2/log m/)

算法的时间复杂度为 /(/mathcal{O}(m/log m)/)

P4779 模板题代码。

#include <bits/stdc++.h> using namespace std; #define pii pair<int, int> const int N = 1e5 + 5; int n, m, s, dis[N]; vector<pii> e[N]; int main() {   cin >> n >> m >> s;   for(int i = 1; i <= m; i++) {     int u, v, w;     scanf("%d%d%d", &u, &v, &w);     e[u].push_back(make_pair(v, w));   }   memset(dis, 0x3f, sizeof(dis));   dis[s] = 0;   priority_queue<pii, vector<pii>, greater<pii>> q; // 优先取权值较小的 pair   q.push(make_pair(0, s)); // 注意第一关键字要放到前面   while(!q.empty()) {     auto t = q.top();     q.pop();     int id = t.second; // 不要搞反了,编号是 second     if(t.first != dis[id]) continue;     for(auto _ : e[id]) {       int it = _.first, d = t.first + _.second;       if(d < dis[it]) q.push(make_pair(dis[it] = d, it));     }   }   for(int i = 1; i <= n; i++) cout << dis[i] << " ";   return 0; } 

1.3 SPFA 与负环

关于 SPFA,___。

SPFA 本质上是队列优化的 Bellman-Ford。

松弛节点 /(x/) 时找到接下来有可能松弛的点,即与 /(x/) 相邻且 最短路被更新的点 并压入队列。此外,记录一个点是否在队列中,若是则不压入,可以显著减小常数。

时间复杂度相比 BF 并没有差异,仍为 /(/mathcal{O}(nm)/)。在一般图上效率很高,但是可以被特殊数据卡成平方,所以能使用 dijkstra 时不建议 SPFA。

注意,如果使用 SPFA 求解点对点的最短路径(费用流 EK),当队头为目标节点时不能结束算法。因为一个节点进入队列并不等同于它已经取到了最短路。

SPFA 判负环:若一个点 进入队列 超过 /(n-1/) 次(注意不是 被松弛,因为一个节点被松弛并不意味着进队),或 最短路边数 大于 /(n – 1/),则整张图存在负环。对于后者,记录 /(l_i/) 表示从源点到 /(i/) 的最短路长度,松弛时令 /(l_v/gets l_u+1/) 并判断是否有 /(l_v< n/) 即可。

判入队次数要慢于最短路长度,因此推荐使用后者。

P3385 模板题代码。

#include <bits/stdc++.h> using namespace std; const int N = 2e3 + 5; int n, m, dis[N], len[N], vis[N]; vector<pair<int, int>> e[N]; void solve() {   cin >> n >> m;   for(int i = 1; i <= n; i++) e[i].clear();   for(int i = 1; i <= m; i++) {     int u, v, w;     cin >> u >> v >> w;     e[u].push_back(make_pair(v, w));     if(w >= 0) e[v].push_back({u, w});   }   queue<int> q;   memset(dis, 0x3f, sizeof(dis)); // dis 的初始值需要赋成 0x3f   memset(vis, 0, sizeof(vis)); // 清空 vis =.=   q.push(1), len[1] = dis[1] = 0; // 读题,从顶点 1 出发 =.=   while(!q.empty()) {     int t = q.front();     q.pop(), vis[t] = 0;     for(auto it : e[t]) {       int d = dis[t] + it.second, to = it.first;       if(d < dis[to]) {         dis[to] = d, len[to] = len[t] + 1;         if(len[to] == n) return puts("YES"), void();         if(!vis[to]) vis[to] = 1, q.push(to);       }     }   }   puts("NO"); } int main() {   int T;   cin >> T;   while(T--) solve();   return 0; } 

*1.4 Johnson

  • 前置知识:Bellman-Ford & Dijkstra。

Johnson 算法用于解决 带有负权边全源最短路经 问题。所谓全源最短路径问题,就是求解图上任意两个点之间的最短路。

Johnson 算法的巧妙之处在于为每个点赋予 势能 /(h_i/)。正如物理意义上的势能,从一个点出发,到达另一个点,无论走什么路径,势能的总变化量是一定的。物理实际启发我们将边 /((u, v)/) 的新权值 /(w’_{u, v}/) 设置为 /(w_{u, v} + h_u – h_v/)

考虑一条路径 /(S/to p_1 /to p_2 /to /cdots /to T/),其原长为

/[L(S/to T) = w_{S, p_1} + w_{p_1, p_2} + /cdots + w_{p_l, T} /]

新的长度为

/[L'(S/to T) = (h_S + w_{S, p_1} – h_{p_1}) + (h_{p_1} + w_{p_1, p_2} – h_{p_2}) + /cdots + (h_{p_l} + w_{p_l, T} – h_T) /]

化简后不难看出 /(L(S/to T) = L'(S/to T) + h_T – h_S/)。对于固定的 /(S, T/),原图的路径对应到新图上面,长度增加了 /(h_S – h_T/)。这与路径经过了哪些节点无关,而只与 /(S, T/) 有关。因此,原图上 /(S/to T/) 的最短路在新图上 仍为最短路

接下来考虑求解原问题。

由于负权,我们无法使用 dijkstra 求解最短路。多次 SPFA 或 BF 的时间复杂度不优秀。我们尝试应用上述分析,合理地为每个点赋权值,从而消去图上的所有负权边。

为使 /(w'(u, v) /geq 0/),只需 /(w(u, v) + h_u /geq h_v/)。这是什么?三角形不等式!若初始图中无负环,那么我们一定可以不断松弛最终得到这样一个 /(h/)。具体地,初始令所有 /(h/) 等于 /(0/),然后使用 BF 算法进行 /(n – 1/) 轮松弛。

松弛在 /(n – 1/) 轮后必然结束,否则意味着原图存在负环,因为上述操作等价于建立虚点 /(0/) 并向所有节点连一条边权为 /(0/) 的边,并求出 /(0/to i/) 的最短路 /(h_i/)

操作结束后,我们更新每条边的权值 /(w'(u, v) = w(u, v) + h(u) – h(v)/)。根据一开始的分析,得到的新图与原图任意两点间的最短路径不变,且 没有负边权,可以使用 /(n/) 轮 Dijkstra 求解任意两点间的最短路。

最后不要忘了 /(u/to v/) 的最短路加上 /(h_v – h_u/)

算法的时间复杂度为 /(/mathcal{O}(nm/log m)/)

模板题 P5905 代码。

#include <bits/stdc++.h> using namespace std; #define pii pair<int, int> const int N = 3e3 + 5; int n, m, h[N], dis[N]; vector<pii> e[N]; int main() {   cin >> n >> m;   for(int i = 1; i <= m; i++) {     int u, v, w;     cin >> u >> v >> w;     e[u].push_back(make_pair(v, w));   }   for(int i = 1; i <= n; i++) {     bool found = 0;     for(int j = 1; j <= n; j++)       for(auto it : e[j])         if(h[j] + it.second < h[it.first])           found = 1, h[it.first] = h[j] + it.second;     if(i == n && found) puts("-1"), exit(0);   }   for(int i = 1; i <= n; i++) {     long long ans = 0;     for(int j = 1; j <= n; j++) dis[j] = i == j ? 0 : 1e9;     priority_queue<pii, vector <pii>, greater <pii>> q;     q.push(make_pair(0, i));     while(!q.empty()) {       auto t = q.top();       q.pop();       int id = t.second;       if(t.first != dis[id]) continue;       for(auto _ : e[id]) {         int it = _.first, d = t.first + h[id] - h[it] + _.second;         if(d < dis[it]) q.push(make_pair(dis[it] = d, it));       }     }     for(int j = 1; j <= n; j++) ans += 1ll * j * (dis[j] + (dis[j] < 1e9 ? h[j] - h[i] : 0));     cout << ans << endl;   }   return 0; } 

1.5 Floyd 与传递闭包

和 johnson 一样,floyd 也可以解决带有负权边的全源最短路径问题。其名声远大于 johnson。

Floyd 算法的核心只有四步:枚举 /(k/),枚举 /(i/),枚举 /(j/),用 /(dis(i, k) + dis(k, j)/) 更新 /(dis(i, j)/)

注意枚举顺序,作为中转点的节点必须在最外层枚举。初始令所有有边的点对 /((u, v)/) 之间的 /(dis(u, v)/) 等于 /(w(u, v)/)

正确性证明仍然是归纳法。考虑 /(u/to v/) 的最短路 /(u/to p_1 /to /cdots /to p_k /to v/)。令 /(p_i/)/(p/) 当中最后一个被枚举到的点,若 /(dis(u, p_i)/)/(dis(p_i, v)/) 已经取到最短路,那么 /(dis(u, v)/) 自然能正确地被更新为最短路。

根据初始化操作,边界情况 /(u/to v/) 的最短路只有一条边时 /(dis(u, v)/) 取到最短路 /(w(u, v)/) 成立。由数学归纳法,假设成立,算法正确性得证。

算法的时间复杂度为 /(/mathcal{O}(n ^ 3)/)。不仅好写,而且在稠密图上运行效率高于 johnson,因此 floyd 也是数据规模较小时最短路算法的不二之选。

此外,floyd 还可以求传递闭包(笔者一直以为传递闭包是个动词,没想到是名词)。有向图 /(G/) 的传递闭包定义为 /(n/) 阶布尔矩阵 /(T/),满足 /(T_{i, j}/)/(i/) 可达 /(j/) 时等于 /(1/),否则为 /(0/)。只需将内层操作改为 /(T_{i, j} /gets T_{i, j} /lor (T_{i, k} /land T_{k, j})/) 即可。

  • bitset 可以将传递闭包的复杂度优化至 /(/dfrac {n ^ 3} w/)。对于稀疏图,缩点后传递闭包可以做到 /(/dfrac {nm} w/)

什么?这么简单的东西你还要代码?

1.6 例题

*I. P5304 [GXOI/GZOI2019]旅行者

一道有趣的题目。

/(/mathcal{O}(m/log m /log k)/) 做法的核心思想是 二进制分组 后跑最短路。两个不同的数二进制必定至少有一位不同,正确性得以保证。在洛谷要开 O2 才能过。

随机分组也可以,做 /(k/) 次的正确性是 /(1 – /left(/dfrac 3 4 /right) ^ k/)。类似题目如 CF1314 D。

/(/mathcal{O}(m/log m)/) 做法的思想很巧妙。预处理每个特殊城市到点 /(i/) 的最短距离 /(f_i/) 和对应城市 /(fr_i/),以及点 /(i/) 到所有特殊城市的最短距离 /(g_i/) 和对应城市 /(to_i/)。对于每条边 /(u/to v/),若 /(fr_u /neq to_v/),则用 /(f_u + w_{u, v} + g_v/) 更新答案。正确性证明见 srf 的题解

代码

II. P1462 通往奥格瑞玛的道路

答案满足可二分性,二分 + dijkstra 即可。

时间复杂度 /(n/log n /log c/)

III. P4568 [JLOI2011]飞行路线

注意到 /(k/) 很小,跑最短路时记录当前用了几条免费边即可,这叫分层图最短路。

时间复杂度 /(/mathcal{O}(mk/log(mk))/)

*IV. P7916 [CSP-S 2021] 交通规划

/(k = 2/) 是 “狼抓兔子”,平面图最小割转对偶图最短路。

/(k > 2/) 的时候也差不多。平面图转对偶图后,最外层会出现 /(k/) 个节点。对于一个节点 /(i/),设其逆时针方向的射线颜色和顺时针方向的射线颜色分别为 /(L_i/)/(R_i/),取值为 /(0/)/(1/),表示白或黑。若 /(L_i = R_i/),那么 /(i/) 相邻的两个射线处于同一连通块是最优的。因为任何将它们分割开的方案,通过调整使得它们在同一连通块后代价总会减少。

显然,/((L_i, R_i)/) 等于 /((0, 1)/)/((1, 0)/) 的节点个数相同。每个 /((0, 1)/) 和每个 /((1, 0)/) 匹配,路径经过的所有边两端颜色不同。因此这样的匹配方案总能给出一个划分方案及其代价。

关键性质是任意两点的匹配不会相交,即不会出现 /(a < b < c < d/)/(a/) 匹配 /(c/)/(b/) 匹配 /(d/)。若相交,则调整法可证不交时不劣。

因此,首先求出所有 /((0, 1)/)/((1, 0)/) 点之间两两最短路。求匹配最小代价可以类似括号匹配,破环成链后区间 DP,时间复杂度 /(/mathcal{O}(knm/log(nm) + k ^ 3)/)

代码

*V. CF1163F Indecisive Taxi Fee

经典题。

首先求出 /(1/to n/) 的最短路 /(P = p_0(= 1)/xrightarrow {e_1} p_1 /xrightarrow {e_2} /cdots /xrightarrow {e_L} p_L(= n)/)。设 /(t = (u, v)/)

/(t/notin P/) 时,新的最短路要么不变,要么经过这条边。很显然,强制经过某条边 /((u, v)/) 的最短路等于 /(1/)/(u/) 的最短路,加上 /(v/)/(n/) 的最短路,再加上 /(w_t/);或者交换 /(u, v/) 的地位,两种情况取个最小值。设 /(1/)/(u/) 的最短路为 /(pre(u)/)/(v/)/(n/) 的最短路为 /(suf(v)/),则答案可写为

/[/min(w(P), w(u, v) + /min(pre(u) + suf(v), suf(u) + pre(v)) /]

/(t/in P/) 时,新的最短路要么是 /(w(P) – w_t + x/),要么是强制不经过 /(t/) 的最短路(当 /(x /leq w_t/) 时必然是前者,但是分类讨论太麻烦了,不如两种情况直接取 /(/min/))。现在唯一的问题转化为对每条边 /(t/),求解 /(L(t)/) 表示强制不经过 /(t/) 的最短路长度。

考虑求出原图的任意一棵从 /(1/) 和从 /(n/) 开始的最短路树 /(T_1/)/(T_n/)。需保证这两棵树上 /(1/)/(n/) 之间的路径完全相等,即设 /(P = T_1(1/to n)/),那么 /(P/) 应当等于 /(T_n(1/to n)/)。这可以先求出 /(T_1/) 以及对应的 /(P/),再根据 /(P/)/(T_n/)。显然,/(pre(u)/) 等于 /(T_1/)/(u/) 的深度,/(suf(v)/) 等于 /(T_n/)/(v/) 的深度。

  • 注意,笔者后来发现不需要保证 /(P = T_n(1/to n)/),任意一棵从 /(n/) 开始的最短路树 /(T_n/) 均可以求得正确答案。因此,代码里面没有保证这一点。

/(pr(u)/) 表示 /(T_1(1/to u)/)/(P/) 的最后一个交点(或者说,/(pr(u)/) 等于 /(T_1/)/(n/)/(u/) 的 LCA),/(su(v)/) 表示 /(T_n(v/to n)/)/(P/) 的第一个交点。换种角度理解,/(pr(u)/) 就是从 /(1/) 开始到 /(u/) 的最短路上,最后一个与从 /(1/)/(n/) 的最短路径重合的节点,对于 /(su(v)/) 同理。

对于一条不属于 /(P/) 的边 /((u, v)/),我们尝试强制经过这条边(两个方向都要试,以下仅讨论一个方向)。对于从 /(u/to v/) 而言,根据上述分析,最短路为 /(T_1(1/to u) /cup (u, v) /cup T_n(v/to n)/),其长度为 /(C(u, v) = pre(u) + w(u, v) + suf(v)/)(定义 /(C(e)/) 表示强制经过 /(e/) 的最短路)。

容易发现,/(C(u, v)/) 这个数值可以用于更新所有路径 /(P/) 上从 /(pr(u)/)/(su(u)/) 之间所有边 /(e_i/) 对应的 /(L(e_i)/),因为 /(C(u, v)/) 对应的路径没有经过这些边。对于每一条不属于 /(P/) 的边,其 /(C/) 值会对一段 /(P/) 上对应的路径产生贡献。离线扫描线用 multiset 维护即可求出每个 /(L(e_i)/)

因此,对于 /(t/in P/) 的情况,答案为 /(/min(w(P) – w_t + x, L(t))/)

等等?是不是有些不严谨?笔者在思考本题时,遇到的一个最大的问题是如何证明对于去掉某条 /(e_i/) 后,新的最短路 /(P’/) 上必然存在一条边 /(e’_j = (p’_{j – 1}, p’_j)/) 使得 /(e_i/)/(pr(p’_{j – 1})/)/(su(p’_j)/) 之间。这个命题等价于 /(P’/) 上至少存在一条边 /((u, v)/) 使得 /(1/to u/) 的路径等于 /(T_1(1/to u)/)/(v/to n/) 的路径等于 /(T_n(v/to n)/),且 /(e_i/) 不在 /(pr(u)/)/(su(v)/) 之间 。其证明过于复杂,略去。详见 cf1163f – ycx’s blog

代码

*VI. P3238 [HNOI2014]道路堵塞

和上题一样的套路。

VII. P3573 [POI2014]RAJ-Rally

和上题一样的套路。

VIII. P2761 软件补丁问题

注意到总的补丁数量很少,所以从初始态能够到达的态一定不会太多。状压 + SPFA 即可。

1.7 参考文章

2. 差分约束

前置知识:Bellman-Ford 和 已死的 SPFA。

2.1 算法介绍

差分约束问题为给出若干形如 /(x_a-x_b/leq c/)/(x_a – x_b /geq c/) 的不等式,求任意一组 /(x/) 的解。

我们发现,只要 /(x_a, x_b/) 同在不等号某一侧时符号不同,那么所有限制均可以写为 /(x_i + c /geq x_j/)

三角形不等式 再一次出现。我们从 /(i/to j/) 连一条长度为 /(c/) 的边,然后再从超级源点 /(0/) 向每个点连长度为 /(0/) 的边防止图不连通(或者一开始令所有 /(dis = 0/) 并将所有点入队),跑最短路,每个点的最短路长度就是一组解。

因为一般这个 /(c/) 有负值(全是非负的话所有数相等不就行了嘛),所以用 Bellman-Ford 或 SPFA 求解最短路。显然,若出现负环则无解。

时间复杂度 /(/mathcal{O}(nm)/)。模板题 P5960 代码如下。

#include <bits/stdc++.h> using namespace std; const int N = 5e3 + 5; int n, m, vis[N], dis[N], len[N]; vector<pair<int, int>> e[N]; int main() {   cin >> n >> m;   for(int i = 1; i <= m; i++) {     int u, v, w;     cin >> u >> v >> w, e[v].push_back(make_pair(u, w));   }   queue<int> q;   for(int i = 1; i <= n; i++) q.push(i), vis[i] = 1;   while(!q.empty()) {     int t = q.front();     q.pop(), vis[t] = 0;     for(auto it : e[t]) {       int to = it.first, d = dis[t] + it.second;       if(d < dis[to]) {         dis[to] = d, len[to] = len[t] + 1;         if(len[to] == n) puts("NO"), exit(0);         if(!vis[to]) q.push(to), vis[to] = 1;       }     }   }   for(int i = 1; i <= n; i++) cout << dis[i] << " ";   return 0; } 

*2.2 解的字典序极值

一般而言差分约束系统的解没有 “字典序” 这个概念,因为我们只对变量之间的差值进行约束,而变量本身的值可以随着某个变量取值的固定而固定,所以解的字典序可以趋于无穷大或无穷小。

字典序的极值建立于变量有界的基础上。不妨设我们希望求出当限制 /(x_i/leq 0/) 时,整个差分约束系统的字典序的最大值。注意到 /(x_i /leq 0/) 可以看成三角形不等式 /((x_0 = 0) + 0 /geq x_i/),所以我们建立虚点 /(0/),将其初始 /(dis/) 赋为 /(0/),并向其它所有变量连权值为 /(0/) 的边(这等价于一开始令所有点入队且 /(dis = 0/))。

求解差分约束系统时,我们使用了三角形不等式,将其问题转化为最短路问题。这给予它很好的性质:我们通过 SPFA 求得的一组解,恰为字典序最大解

首先,让我们明确,对于一条从 /(u/to v/) 的边权为 /(w(u, v)/) 的边,它的含义为限制 /(x_u + w(u, v) /geq x_v/)

考虑 /(0/) 到每个节点的最短路树。对于树上每条边均满足 /(x_i + w(i, j) = x_j/)。若 /(x_i + w(i, j) > x_j/),那么整张图还可以继续松弛,若 /(x_i + w(i, j) < x_j/),说明 /(j/) 的最短路不是由 /(i/) 继承而来,因此 /((i, j)/) 必然不会作为树边出现。

这说明树上的 /(x_i + w(i, j) /geq x_j/) 的限制已经取满,取到了等号(/(x_j/) 不能再大了,再大就破坏了限制),每个变量都取到了其理论上界,自然就得到了字典序最大解。

对于字典序最小解,我们限制 /(x_i /geq 0/)。因此所有节点向 /(0/) 连边 /(w(i, 0) = 0/)。字典序最小即字典序最大时的相反数。因此,考虑将所有变量取反,那么限制也要全部取反,体现为将图中所有边(包括所有新建的边 /(i/to 0/))的 方向(而不是边权)取反。然后求字典序最大解,再取相反数即可。

2.3 例题

*I. P5590 赛车游戏

好题。

转换思路,与其设置边权使路径长度相等,不如设置路径长度去拟合边权的限制。

/(d_i/)/(1/to i/) 的最短路,我们只需保证对于所有边 /((u,v)/)/(w_{u,v}=d_v-d_u/) 即可使任意一条简单路径长相等。于是 /(1/leq d_v-d_u/leq 9/),转化为 /(d_u+9/geq d_v/)/(d_v-1/geq d_u/),差分约束求解即可。注意不在 /(1/to n/) 的任意一条路径上的边没有用,这些边不应计入限制,随便标权值。

*II. P7515 [省选联考 2021 A 卷] 矩阵游戏

神题。

构造题可以考虑调整法。

首先我们固定第一行第一列全都为 /(0/),可以仅根据 /(b/) 的限制推出剩下来整个矩阵 /(a_{i,j}/)。注意到对矩阵第 /(i/) 行的每个数进行 /(-r_i,+r_i,-r_i,+r_i,/cdots/) 操作后,仍然满足 /(b/) 的限制,列同理。因此我们设将 /(a_{i,j}/) 加上 /((-1)^jr_i+(-1)^ic_j/),那么有 /(0/leq a_{i,j}+(-1)^jr_i+(-1)^ic_j/leq 10^6/)。不过这样当 /(i,j/) 奇偶性相同时 /(r_i,c_j/) 前的符号相同,出现 和约束,这是无法处理的。

我们希望对于每一行,任意两个相邻的位置的 /(r_i/) 的系数相反。对于每一列同理。因此考虑 黑白染色,得到系数 /((-1)^{i + j + 1} r_i + (-1) ^ {i + j} c_j/)。不难发现 /(r_i/)/(c_j/) 前符号一定不同且满足限制,可以使用差分约束求解,时间复杂度 /(/mathcal{O}(Tnm(n + m))/)

题目有些卡常,注意常数。SPFA 判最短路长度而不是入队次数会跑得快一些。

代码

III. P4926 [1007]倍杀测量者

化乘法为加法不难想到取对数,考虑对没有女装的限制建出差分约束图,若出现负环则一定有人女装。对于固定分数的人 /(i/),让 /(0, i/) 之间互相连边权分别为 /(/log x_i/)/(-/log x_i/) 的边即可。

套一个二分,时间复杂度 /(/mathcal{O}(ns/log V)/)

*IV. [AGC056C] 01 Balanced

考虑将 /(0/) 看做 /(1/)/(1/) 看做 /(-1/),那么问题的限制是差分约束的形式。

对于限制 /(L – 1, R/),互相连长度为 /(0/) 的边。

对于相邻的两个位置 /(i – 1/)/(i/),题目要求 /(|v_i – v_{i – 1}| = 1/)。看似没有办法描述。当限制变为 /(|v_i – v_{i – 1}|/leq 1/) 时,我们可以在 /(i/)/(i – 1/) 之间互相连长度为 /(1/) 的边,并且最大化 /(v/) 的字典序(差分约束本身自带字典序极值性质)。

我们惊讶地发现,在保证字典序最大的情况下,不可能出现 /(v_i = v_{i – 1}/) 的情况。模拟最短路的过程,我们可以证明每个 /(v_i/) 的奇偶性是固定的,并且相邻两个 /(v/) 的奇偶性一定不同:/(0/) 边连接的两个点的下标奇偶性相同(题目保证了这一点),/(1/) 边则奇偶性不同。

整个过程可以想象成,将一些位置初始加入集合 /(S/),表示它们的 /(v/) 值为 /(0/)。每次考虑增加当前的 /(v/)/(cur/),并将 /(S/) 的极长连续段向左向右扩展,新扩展的位置的 /(v/) 值就等于 /(cur/)。这是因为,考虑 /(v_{l – 1} = v_{r + 1} = c/)/([l, r]/)/(v/) 值还没有确定。我们希望最大化 /(v/) 的字典序,自然 /(v_l = c + 1/),为此,/(v_r/) 也必须等于 /(c + 1/),这样才能让 /([l, r]/) 这一段的字典序最大,否则我们从 /(l/) 开始的上升段会变短。感性理解一下,我们希望将上升段尽量往前放,而当两端 /(v/) 值已经固定相等时,一个上升段必须对应一个下降段,所以为了把所有上升段堆到最前面,我们不得不在最后放一个下降段。

这题思想还是挺高妙的,虽然是差分约束但是形式很新颖,需要猜一些性质(或者说,不要被固有思维所限制)才能得到最终解法。

边权只有 /(0/1/),考虑 01 BFS,时间复杂度 /(/mathcal{O}(n + m)/)

若为了避免出现 /(|v_i – v_{i – 1}| = 1/) 的限制而单纯求前缀和得到 /(v/),我们会建出带有负权边的差分约束网络。用 SPFA 求解会 TLE。

代码

*V. P3530 [POI2012]FES-Festival

很好,限制非常差分约束。建出图来,先跑一遍负环判无解。但如何满足不同的 /(t/) 值数量最大呢?差分约束显然无法解决这样的问题。然后就不会了(大雾)。

首先强连通分量缩点(具体见 5.2 小节),不同 SCC 之间独立,因为我们可以将两个 SCC 之间的距离拉得任意远。而一个 SCC 内部的答案就是任意两点最短路径的最大值 /(+1/):使最短路距离增加的只有一类边,而一类边的 边权只有 /(1/),因此若 /(u/to v/) 的最短路径为 /(w/),那么从 /(u/to v/) 的路径上所有点必然取遍了 /(t_u/sim t_{v}/)/(w+1/) 个值。

因为图是稠密图,因此使用 floyd 算法求解任意两点之间的最短路。答案即每个 SCC 的答案之和。

时间复杂度三方。

3. 最小生成树

3.1 Kruskal

非常经典的最小生成树算法,基础中的基础。

贪心地将所有边按照边权从小到大排序并依次枚举每一条边,如果当前边两端点不连通则加入该边。连通性用并查集维护。时间复杂度 /(/mathcal{O}(m/log m)/)

正确性考虑反证法,如果存在一条边 /((u, v)/) 使得 /((u, v)/notin T/) 且在加入 /((u, v)/)/(T/) 上形成的环当中 /(w(u, v)/) 不是最大权值,那么断掉权值最大的边可以得到一棵更小的生成树 /(T’/)。但这与贪心策略矛盾,因为 /((u, v)/) 一定在被断掉的这条边之前被枚举到并加入 /(T/)

kruskal 重构树见 简单树论 Part 1,简单概括一下就是通过在并查集合并时新建虚点从而维护合并关系。

3.2 Prim

稍微了解一下。

维护 /(V, E/),每次找到 /(/notin E/) 且一端 /(/in V/) 且另一端 /(/notin V/) 的权值最小的边 /((u, v)/),并将 /(v/) 加入 /(V/)/((u, v)/) 加入 /(E/)。初始 /(V/) 可以包含任意一个节点。

另一种理解方式是维护每个节点的权值 /(c_v/) 表示从 /(v/) 出发的所有边 /((v, u)/) 当中,使得 /(u/in V/) 的最小权值。每次取出权值最小的点加入 /(V/),将最小生成树边权加上 /(c_v/),并利用 /(v/) 的所有出边更新剩下来不在 /(V/) 当中所有点的权值。

“取出权值最小的点” 这一过程可以类似 dijkstra 算法,通过优先队列实现。因此,算法的时间复杂度为 /(/mathcal{O}(m/log m)/)

3.3 Boruvka

boruvka 求解最小生成树的方法较为神奇。

考虑若干个点,对于每个点而言,如果想要使得它有边相连,那么与它相邻当中的边权最小的边必然入选。这一点通过反证法可以证明。

因此,我们对于每个点,求出与它相连的边权最小的边。将所有这些边加入最小生成树当中。除去至多 /(/dfrac n 2/) 条重边,每条边会使连通块个数 /(-1/),因此一次这样的过程可以使连得通块个数严格减少一半。这样,对剩余连通块继续上述过程至多 /(/log_2 n/) 轮即可求得 MST。注意,在选择边权最小的边时,不能选择两端已经在同一连通块的边。

算法的时间复杂度为 /(/mathcal{O}(m/log n)/)

Boruvka 在解决某类最小生成树问题上非常有用。它形如给定一张 /(n/) 个点的完全图,两点之间的信息可以通过某种计算得出。/(n/) 一般在 /(10 ^ 5/) 级别。

3.4 例题

I. P4180 [BJWC2010]严格次小生成树

首先 kruskal 求出最小生成树,然后枚举所有边 /((u,v)/),用它替换最小生成树上 /(u,v/) 之间权值最大或 严格 第二大的边即可。需要严格第二大是因为最大值可能和 /(w_{u, v}/) 相等。时间复杂度线性对数。

代码

*II. CF888G Xor-MST

Boruvka 的有趣应用。

根据异或和最小值不难想到对 /(a_i/) 建出 01 Trie。对于 01 Trie 上某个状态 /(p/) 的子树所包含的所有节点 /(V_p/),显然它们内部之间已经连通,因为 /(V_p/) 跨过该状态向其它状态 /(q/) 所表示的节点 /(V_q/) 连边的代价大于在 /(V_p/) 内部连边的代价。这是因为跨过连边时,根据异或的性质,代价在 /(p/) 对应位或其高位有值,但内部连边时 /(p/) 对应位及其高位没有值。

因此,考虑计算答案。若 /(p/) 的左右儿子均有节点,那么需要在 /(V_{ls_p}/)/(V_{rs_p}/) 之间连一条边使得 /(V_p/) 连通。枚举 /(V_{ls_p}/) 当中所有权值 /(v/) 并求出 /(rs_p/) 的子树中与 /(v/) 异或和最小的权值 /(u/)(01 Trie 基本操作),那么 /(/min u/oplus v/) 即为连边代价。

由于一个节点最多作为 /(ls_p/) 被枚举 /(/log V/) 次,所以即使不启发式合并,时间复杂度也是严格 /(n/log ^ 2 V/)。使用启发式之后可以将一个 /(/log V/) 变成 /(/log n/),其实没啥用。

代码

4. 无向图连通性

以下两章内容均与 tarjan 算法有关。Tarjan 老爷子真是神。

无向图和有向图的连通性是图论重要的一部分。

4.1 相关定义

无向图连通性的相关术语有 割点割边(桥)

两者的定义十分类似,分别是删去后 使连通分量个数增加 的节点或边,且它们都是 无向图 上的定义。显然,一个孤立点或一张仅有一条边构成的无向图唯二的端点都不是割点,但后者唯一的边是割边。

注意百度百科上将割边定义在无向连通图上。这是不严谨的。非连通图的割边为它的每个连通分量的割边的并。

与割点相对应的概念是 点双连通图,定义为 不存在割点无向连通 图。根据割点的定义,孤立点和由一条边连接的点对均视作点双连通图。一张图的极大点双连通子图称为 点双连通分量(V-BCC)

与割边相对应的概念是 边双连通图,定义为 不存在割边无向连通 图。根据割边的定义,孤立点是边双连通图,但由一条边连接的点对不是边双连通的。一张图的极大边双连通子图称为 边双连通分量(E-BCC)

由于 “不存在割边” 意味着对于任意两个不同节点 /(u/)/(v/),它们之间不存在必经边。因此,删去任意一条 /(u/to v/) 的路径后,/(u, v/) 仍然连通。这说明任意一条边属于 至少一个简单环(这里,简单环定义为不经过重复的 的环)。同理,若每条边至少属于一个简单环,容易证明该图边双连通。我们得到了一张无向连通图是边双连通图的等价判定:任意一条边属于至少一个简单环。

缩点 是 OI 界常见算法,简单地说就是将某种类型的连通分量根据等价性或独立性缩成一个点,原来连接两个不同连通分量的边变成了连接它们缩点后形成的两个点。根据连通分量的类型不同,缩点可分为无向图上的边双连通分量缩点,点双连通分量缩点,以及将在第五章介绍的有向图上的强连通分量(SCC)缩点。

E-BCC 和 V-BCC 缩点后均可以得到一棵树,而 SCC 缩点后得到了一张有向无环图 DAG。

*4.2 割点的 tarjan 算法

接下来介绍 tarjan 算法求割点的方法。

笔者默认读者已经了解 dfs 树与 dfs 序,此处不再赘述。做一个说明,

  • dfs 序表示对一棵树进行深度优先搜索得到的 节点序列,而 时间戳 dfn 表示每个节点在 dfs 序中的位置。这两个概念需要加以区分。

笔者主要希望提出一种新的理解 tarjan 算法的方式。因为网上众多博客讲解该算法的时候,low 数组仿佛凭空出现,抽象的定义让很多初学者摸不着头脑,而从提出问题到解决问题这样一个逻辑链的不完整性(甚至不存在,比如说 “为什么要设计 low 数组”)让我们无法感受到究竟是怎样的灵感促使了这一算法的诞生。我们不仅要学算法,更要汲取算法的思维方式。

定义一个节点 /(x/) 的子树表示该节点在 dfs 树上的子树,包含节点本身,记作 /(T(x)/)

4.2.1 非根节点的割点判定法则

以下讨论均基于 /(x/) 不为当前连通分量的 dfs 树的根节点。

对于 /(T(x)/),若其中存在节点 /(y/in T(x)/) 满足 /(y/) 不经过 /(x/) 能到达的所有点都被困在 /(T(x)/) 内,则 /(x/) 是割点,这是因为删掉 /(x/)/(y/)/(T(x)/) 外的节点(由于 /(x/) 不是根,所以 /(T(x)/) 外的节点存在)不连通,连通分量个数增加;反之每个 /(y/) 都与 /(T(x)/) 外连通,所以整个连通分量仍然连通(不会新产生连通分量)。那么怎么表示 “不经过 /(x/) 能到达的所有点被困在 /(T(x)/) 内” 呢?

考虑在 dfs 序对应的 时间戳 dfn 上做手脚。应用 动态规划 的思想:注意到 /(T(x)/) 内所有点的 dfn /(d_y(y/in T(x))/) 都不小于 /(d_x/),所以设计状态 /(f_x/) 表示 /(x/) 仅经过一条非树边 所能到达的节点的 dfn 的最小值(注意这与一般 tarjan 算法的状态设计不同,即 /(f/) 并不是 low 数组)。

  • 为什么是 “仅经过一条非树边”:由于 一个割点可能存在于多个环中,对于处在一个 “8” 字形结构最底部的节点 /(y/) 来说,它可以通过一条非树边上翻到结构中间的节点 /(x/),然后再走一条非树边上翻到结构顶部的节点 /(u/),这样在求 /(x/) 是否是割点的时候就不合法了,因为 /(f_y/)只有经过 /(x/) 才能到达的点 /(u/) 更新,不符合我们一开始 “不经过 /(x/)” 的假设。而只经过一条非树边至少保证了我们求到的 /(f_y/)/(y/) 不需要经过中间点能够直接到达的节点的 dfn 最小值。

    另外插一句,这其实也说明了为什么对于 /(u/) 的已经访问过且在栈中的邻居 /(v/),我们用 dfn[v] 而非 low[v] 更新 low[u]

考虑 /(x/) 在 dfs 树上的所有儿子 /(y_1, y_2, /cdots, y_c/),按访问顺序排列。不存在 /(y_i/neq y_j/) 使得它们的子树之间有连边,否则在 dfs 的过程中,不妨设 /(i < j/),访问 /(y_i/) 的子树时就可以直接访问到 /(y_j/) 的子树,所以 /(y_j/) 在访问 /(y_i/) 时被访问,与 /(y_j/)/(x/) 在 dfs 树上的儿子矛盾。这说明 /(x/) 的各个儿子之间的 独立性。换句话说,任何非树边连接的两个点在 dfs 树上一定呈祖先后代关系。这是无向图上 tarjan 算法正确性的重要保障。读者需要充分理解这一性质,下文将重复出现。

同时,由于 /(T(y_i)/) 内部是连通的,所以删去 /(x/)/(T(y_i)/) 内部每个节点与 /(T(x)/) 外的连通性相同。也就是说,要么 /(T(y_i)/) 所有节点均与 /(T(x)/) 外连通,要么均不连通。这说明 /(x/) 的某个儿子的子树 /(T(y_i)/) 内部节点的 等价性

因此,对于 /(x/) 的每个儿子 /(y_i/),若 /(T(y_i)/) 内部 存在 一个点使得它能够不经过 /(x/) 就到达 /(T(x)/) 外的节点,那么删掉 /(x/)/(T(y_i)/) 就和 /(T(x)/) 外连通。我们希望 存在 /(y_i/) 使得它 不满足 这样一个条件。

先考虑用我们已知的信息描述 “/(T(y_i)/) 内部存在一个点 /(p/) 使得它不经过 /(x/) 就可以到达 /(T(x)/) 外的节点”。若存在 /(p/in T(y_i)/) 使得存在一条 不经过 /(x/) 的路径 /(p/to p_1 /to p_2 /to /cdots /to p_c /to q/),满足 /(q/) 为路径上第一个 /(T(x)/) 外的节点(根据独立性,/(p/)/(p_i/) 均在 /(T(y_i)/) 内),那么 /(p_c/) 能够通过一条非树边跳出 /(T(x)/)

这意味着若存在 /(p/in T(y_i)/) 使得它不经过 /(x/) 可以到达 /(T(x)/) 外的节点,那么考虑它 第一次 到达 /(T(x)/) 外某个节点 /(q/) 的不经过 /(x/) 的路径,最后一步使得它从 /(T(y_i)/) 内跳到了 /(T(x)/) 外。因为 /(q/)/(x/) 之前被访问过了(否则 /(q/) 在访问 /(y_i/) 时成为 /(T(y_i)/) 的一个节点),所以 /(d_q < d_x/)。这意味着必然存在对应的 /(p_c/in T(y_i)/) 使得 /(f_{p_c} < d_x/)

反之,若存在 /(p/in T(y_i)/) 使得 /(f_p < d_x/),那么显然 /(T(y_i)/) 内存在一个点使得它不经过 /(x/) 可以到达 /(T(x)/) 外的节点。/(p/) 就是一个这样的节点。

这样,我们就证明了判定 /(x/) 是割点的法则:当且仅当 存在一条 树边 /(x/to y/),使得 /(y/) 子树内 不存在 节点 /(u/) 使得 /(f_u < d_x/) 时,/(x/) 是割点。这等价于对于所有 /(u/in T(y)/) 均有 /(f_u /geq d_x/),即 /(/min f_u /geq d_x/)

为此,我们只需再记录一个数组 /(g_x/) 表示 /(x/) 的子树内所有节点 /(u/in T(x)/)/(f_u/) 的最小值,即可在线性时间内判断每个节点是否是割点。

由于 /(f_u/) 等于 /(u/) 在 dfs 树上所有 返祖边 /((u, v)/) 对应的 /(d_v/) 的最小值与本身的 dfn 取 /(/min/)/(v/) 不应是 /(u/) dfs 树上的父亲,因为这意味着 /((u, v)/)树边,如果忽略这一点,那么在求解割点时 不会 影响结果(判定是 /(/min f_u/geq d_x/),有等号),但求解割边时会使判断错误(判定是 /(/min f_u > d_x/),无等号)),即 /(f_u = /min/left(d_u, /min/limits_{v/in /mathrm{ancestor}(u) /land (u, v) /in E} d_v/right)/),而 /(g_u = /min/limits_{v /in T(x)} f_v/),根据 树形动态规划,即 /(g_u = /min/left(f_u, /min/limits_{v/in /mathrm{son}(u)} g_v/right)/)

综上,我们推得

/[g_u = /min/left(d_u, /min/limits_{v/in /mathrm{ancestor}(u) /land (u, v) /in E} d_v, /min/limits_{v/in /mathrm{son}(u)} g_v/right) /]

/(g_u/) 恰好代表了 /(u/)low 数组。

三个由逗号分隔的式子分别对应了:

  • low[u] 初始化为 dfn[u]
  • 对于 /(u/) 在 dfs 树上的祖先 /(v/),用 dfn[v] 更新 low[u]。因为是无向边,所以对于任意 /((u, v) /in E/),只要 /(v/) 在此之前已经被访问过了,那么 /(v/) 在 dfs 树上一定是 /(u/) 的祖先(考虑反证法,若不成祖先后代关系,对 /(/mathrm{lca}(u, v)/) 应用子树独立性即可推出矛盾)。
  • 对于没有被访问过的 /(u/) 的邻点 /(v/),搜索 /(v/) 后回溯。这意味着在 dfs 树上 /(v/)/(u/) 的子节点。因此用 low[v] 更新 low[u]

这就是大名鼎鼎的 tarjan 算法。

4.2.2 根节点

别忘了还有 /(x/) 为根节点的情况。

此时,若 /(x/) 在 dfs 树上有超过一个儿子,那么根据子节点的独立性,去掉 /(x/) 后各儿子子树不连通使得连通块个数增加,所以 /(x/) 为割点。反之容易证明 /(x/) 不是割点。

综上,使用 tarjan 算法求解有向图 /(G/) 上所有割点的时间复杂度为 /(/mathcal{O}(n + m)/)。模板题 P3388 代码如下。

笔者再次强调,以下代码仅在求解割点时正确。求解割边需要进行额外的特判,这将在下一小节中提到。原因在上文已经分析过。

#include <bits/stdc++.h> using namespace std; const int N = 1e5 + 5; int n, m, root, dn, dfn[N], low[N], cnt, buc[N]; vector<int> e[N]; void dfs(int id) {   dfn[id] = 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 != root) cnt += !buc[id], buc[id] = 1;     }     else low[id] = min(low[id], dfn[it]);   }   if(son >= 2 && id == root) cnt += !buc[id], buc[id] = 1; } int main() {   cin >> n >> m;   for(int i = 1, u, v; i <= m; i++) cin >> u >> v, e[u].push_back(v), e[v].push_back(u);   for(int i = 1; i <= n; i++) if(!dfn[i]) root = i, dfs(i);   cout << cnt << endl;   for(int i = 1; i <= n; i++) if(buc[i]) cout << i << " ";   return 0; } 

*4.3 割边

4.3.1 Tarjan 法

Tarjan 算法求割边的思路和求解割点的思路差不多。

尝试探究一条边是割边的充要条件。

首先,如果一条边是非树边,那么割掉它显然不影响连通性,因为树边使得整张图连通。因此,/(e = (u, v)/) 是割边的 必要条件/(e/) 为树边。

不妨设 /(v/)/(u/) 的子节点。割掉 /(e/) 后若整张图不连通,那么一定是 /(T(v)/)/(T(v)/) 以外的部分不连通。这说明 /(T(v)/) 内部所有节点必须跨过 /(e/) 才能到达 /(T(v)/) 外,即 /(e/) 是沟通 /(T(v)/) 内外的

考虑如何快速判断这种情况。根据求解割点的经验,不难发现 /(e/) 为割边等价于 /(g_v > d_u/),即 low[v] > dfn[u]。相较于割点判定法则,没有等号的原因是此时我们删去的是边而非节点,所以只要子树能绕过 /(e/) 到达 /(T(v)/) 以外的部分,包括 /(u/) 本身,那么 /(e/) 就不是割边。根据子树独立性,这意味着子树通过一条非树边能够到达 /(u/) 或其祖先。又因为时间戳的性质(祖先的时间戳小于后代的时间戳),故有该判定法则。

  • 注意,求解割边时有个小细节,就是判断返祖边。

    一个简陋的想法是 dfs 时记录父亲,若当前边 /((u, v)/) 指向当前节点的父亲(/(v = fa(u)/)),那么跳过这条边。这样有个问题,就是当出现 重边 时会错误,因为我们会将树边的重边也判为树边。

    解决方法是额外记录边的编号。对于 vector 存图而言,我们在 push_back 当前边指向的节点时,还要将当前边的编号一并压进去。对于链式前向星而言,技巧是 成对变换:初始令 /(cnt = 1/),这样一来,每条边及其反边在链式前向星中存储的编号分别为 /(2/)/(3/)/(4/)/(5/),以此类推。这样,给当前边编号异或 /(1/) 即可得到反边编号。

    综上,dfs 时记录当前节点 /(u/),以及从 /(fa(u)/) dfs 下来时经过的边的编号 /(id_e/),那么对于 /(u/) 的所有出边 /(e = (u, v)/),只需判断 /(e/) 是否等于 /(id_e/)(vector)或者 /(id_e/oplus 1/)(链式前向星)即可判断 /(e/) 是否为树边,从而决定是否要忽略掉当前边。

算法的时间复杂度为 /(/mathcal{O}(n + m)/)

4.3.2 树上差分法

给出另一种求解割边的方法。

根据子树的独立性,所有非树边 /(ne/) 连接的两个端点 /((u, v)/) 一定具有祖先后代关系。这说明在 dfs 树 /(T/)/(u, v/) 之间是一条竖直的链。

因此,求出原图 /(G/) 的任意一棵生成树 /(T/),对于不在 /(T/) 上的边 /((u, v)/),将 /(u, v/) 之间所有边标记为非树边即可。可以通过树上差分实现。

算法的时间复杂度为 /(/mathcal{O}(n + m)/)

*4.4 边双连通分量

求解边双连通分量是非常容易的。

容易证明,对于一张无向连通图 /(G/),若 /(e/) 为其割边,那么对于 /(G/) 的任意子图 /(G’ = (E’, V) /subseteq G/),若 /(e/in E’/),则必然有 /(e/)/(G’/) 的割边。

删去 /(G/) 的所有割边,可以得到若干连通块。由于所有割边均被删去,所以这些连通块必然是边双连通图。因此,它们就是原图的 E-BCC。

接下来介绍边双连通分量缩点的方法。

先通过一遍 tarjan 算法找到所有割边,再 dfs 找出所有边双连通分量是可行的,但是太麻烦了。我们希望一遍 dfs 就可以做到求解割边 + 找出所有边双连通分量。为此,我们需要对 tarjan 算法进行一些改进。

具体地,我们维护一个 /(S/),表示已经访问过的,还没有形成完整的连通分量 的所有节点。每次 dfs 进入一个节点时,先将其压入栈内。这一步骤出现在各种连通分量的缩点算法中。

设当前节点为 /(u/)/(v/) 为当前遍历到的 /(u/) 的(在 dfs 树上的)子节点。若 /((u, v)/) 被判定为割边,即 low[v] > dfn[u],那么我们断言,从栈顶到 /(v/) 的所有节点(的点导出子图,下文忽略)形成了一个 E-BCC。将这些节点弹出。

考虑证明上述结论。不妨设割掉 /((u, v)/) 后形成的连通块分别为 /(u/in U/)/(v/in V/)。由于判定 /((u, v)/) 为割边是在离开节点 /(v/) 回到节点 /(u/) 时进行的,所以当前栈中节点 /(v/) 往上一直到栈顶均属于 /(V/):越靠栈顶越晚被访问,而 /(V/) 中节点全都在 /(v/) 之后被访问,且 /(U/) 中节点一定在 /(v/) 之前被访问(时刻记住 /((u, v)/) 是割边)。

对于 /(V/) 内部所有割边,我们在 dfs 到它们时就已经处理掉了它们形成的 E-BCC。因此,/(V /cup S/) 的部分一定 不存在割边,恰好为一个 E-BCC。反证法,若 /(V/cup S/) 的部分可以被分成两个 E-BCC /(V_1/)/(V_2/),考虑 /(V_1/)/(V_2/) 之间的唯一一条割边 /((x /in V_1, y /in V_2)/)(如果割边不唯一,那么 /(V_1, V_2/) 就可以合并成一个 E-BCC 了啊)。首先由于 /(x, y/in T(v)/),所以 /(x, y/) 这条割边一定会在从 /(v/) 回溯到 /(u/) 之前被处理掉。不妨设 dfs 树上 /(x/)/(y/) 的祖先,我们在离开 /(y/) 回溯到 /(x/) 时,会将 /(x, y/) 判定为割边,从而将 /(V_2/) 弹出 /(S/),与 /(V_2 /subseteq S/) 矛盾。结论得证。

整个算法可以看成不断从原图上断开一条 “偏僻” 的割边,剥下一层连通分量的过程。“偏僻” 定性描述就是剥下的连通分量不存在割边(已经断开的割边不算),那么这个连通分量自然就是一个 E-BCC。对应到缩完点后形成的树上,就是每次通过一条割边剥下一个叶子节点(每个节点都是一个 E-BCC)。

上述思想在 V-BCC 缩点和 SCC 缩点时也会运用到,读者需要仔细体会。

算法的时间复杂度是优秀的 /(/mathcal{O}(n + m)/)。代码见例题 III.

4.5 点双连通分量

高级图论 第三章圆方树。

4.6 例题

*I. P3469 [POI2008]BLO-Blockade

一道 tarjan 求割点的练手好题。

由于题目求的是有序对,因此不妨设封锁节点 /(u/) 后形成的连通块大小分别为 /(s_1, s_2, /cdots, s_k/)。不难求得答案为 /(/sum s_i(n – s_i)/)

不要忘记 /(u/) 本身形成了一个大小为 /(1/) 的连通块,以及除去所有判定 /(u/) 是割点的节点 /(v/) 形成的连通块后还有一个大小为 /(s_k = n – 1 – /sum/limits_{i = 1} ^ {k – 1} s_i/) 的连通块。

时间复杂度 /(/mathcal{O}(n)/)代码

II. SP15577 STC10 – Blockade

双倍经验题。

III. P2860 [USACO06JAN]Redundant Paths G

题目相当于求添加最少的边数使得整张图变成一个 E-BCC,即不存在割边。

考虑 E-BCC 缩点,得到的树 /(T/) 上所有边都是原图上的割边。根据求割边的结论,如果我们在 /((u, v)/) 之间加一条边,设 /(U/)/(u/) 所在 E-BCC 在缩点后的树上对应的节点,/(V/) 同理,那么相当于将 /(U, V/) 之间简单路径上的所有边在原图上变成非割边。

因此,我们希望用最少的路径覆盖 /(T/) 的每一条边。对此有经典结论(或者直接感性理解),答案即 /(T/) 的叶子个数除以 /(2/) 上取整。

证明这是答案下界非常容易,因为每一条链至多消灭掉两个叶子到它唯一相邻的点的边。当只有两个节点的时候特殊讨论一下,这是平凡的。接下来我们只需给出一个达到该下界的构造方法。

我们称两个节点匹配表示在最终方案中,存在一条连接它们的链。

首先,当叶子个数为奇数时,将一个叶子和度数 /(/geq 3/) 的节点匹配后可以转化为叶子数量为偶数的情况。如果不存在度数 /(/geq 3/) 的节点,则 /(T/) 为一条链,与叶子个数为奇数矛盾。

考虑先将所有叶子任意两两配对,再调整。如果在当前方案中,存在一条边 /((u, v)/) 没有被覆盖,考察断掉 /((u, v)/)/(u, v/) 分别所在的连通块 /(U, V/)

/(U/)/(V/) 不可能只有一个叶子(这里的叶子是相对于原树而言的,即在原树 /(T/) 上是叶子,而不是 /(U/) 本身的叶子),因为若非,则该唯一的叶子和另外某个叶子匹配时必然经过 /((u, v)/),与 /((u, v)/) 没有被覆盖矛盾。

同理可以证明 /(U/)/(V/) 不可能有奇数个叶子。

根据上述结论,当前方案必然是 /(U/) 的所有偶数个叶子两两匹配,/(V/) 的所有偶数个叶子两两匹配。

不妨设 /(U/) 某两个配对的叶子为 /(u_1, u_2/),它们在以 /(u/) 为根时的 LCA 为 /(u_d/),对于 /(V/) 同理,定义 /(v_1, v_2/)/(v_d/)

当前的方案是 /(u_1/to u_d /to u_2/) 以及 /(v_1 /to v_d /to v_2/) 上的所有边被覆盖,但通过调整,令 /(u_1/)/(v_1/)/(u_2/)/(v_2/) 匹配,则 /(u_i/to u_d /to u/to v /to v_d /to v_i/) 上的所有边被覆盖。原来被覆盖的边仍被覆盖,同时 /((u, v)/) 也被覆盖了。

因此,若对于当前方案,某条边没有覆盖,通过上述调整一定能使得原来被覆盖的边仍被覆盖,且该边也被覆盖,同时不改变原来 /(/dfrac {/# /mathrm{leaf}}{2}/) 的链的条数。答案上界得证。

时间复杂度 /(/mathcal{O}(n + m)/)

#include <bits/stdc++.h> using namespace std; const int N = 5e3 + 5; int n, m, dn, deg[N], dfn[N], low[N]; int cn, col[N], stc[N], top; vector<pair<int, int>> e[N]; void form(int id) {   cn++;   for(int x = 0; x != id; ) col[x = stc[top--]] = cn; } void tarjan(int id, int eid) {   stc[++top] = id, dfn[id] = low[id] = ++dn;   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]);   }   if(!eid) form(id); } int main() {   cin >> n >> m;   for(int i = 1; i <= m; i++) {     int u, v;     cin >> u >> v;     e[u].push_back(make_pair(v, i));     e[v].push_back(make_pair(u, i));   }   tarjan(1, 0);   if(cn == 1) puts("0"), exit(0); // 这句判断其实不要也可以   for(int i = 1; i <= n; i++)     for(auto _ : e[i]) {       int it = _.first;       if(i < it && col[i] != col[it]) deg[col[i]]++, deg[col[it]]++;     }   int leaf = 0;   for(int i = 1; i <= cn; i++) leaf += deg[i] == 1;   cout << (leaf + 1 >> 1) << endl;   return cerr << "Time: " << clock() << endl, 0; } 

5. 有向图连通性

5.1 强连通分量

强连通分量全称 Strong Connected Component,缩写 SCC。以下是一些定义和基本性质:

  • 在有向图 /(G/) 中,若两个点 /(u,v/ (u/neq v)/) 能够相互到达,则称这两个点是 强连通的,它们之间具有 强连通性
  • 在有向图 /(G/) 中,若任意两个节点都是强连通的,则称 /(G/) 是一张 强连通图
  • 有向图 /(G/) 的极大强连通子图被称为 /(G/)强连通分量
  • 很显然,强连通性具有 传递性

强连通分量在求解与连通性有关的题目时很有用,因为在只关心连通性时,同一强连通分量内的所有点是等价的。

*5.2 SCC 缩点的 tarjan 算法

有了运用 tarjan 算法求解割点和割边,V-BCC 和 E-BCC 缩点的先例,我们可以自然地将这些方法进行调整后用于求解强连通分量的缩点问题。

5.2.1 有向图 dfs 树的性质

我们考察有向图 dfs 树 /(T_d/) 的形态,从而得到判定 SCC 的准则。

首先给出一个有关 /(T_d/) 的引理。如果 /(u/)/(v/) 之前被访问且 /(u/) 可达 /(v/),则 /(v/) 属于 /(u/) 的子树 /(T_d(u)/)。可以通过 dfs 的流程和 dfs 树的定义轻松证明:在进入 /(u/) 之前,/(v/) 没有被访问,但离开 /(u/) 的时候 /(v/) 已经访问过了。

除了树边以外,/(T_d/) 还有前向边,返祖边和横叉边。前向边和返祖边由无向图 dfs 树 /(T_u/) 的返祖边的两种定向方式得到,而横叉边是 /(T_d/) 某个节点的两棵子树之间的连边。/(T_u/) 没有横叉边,因为 /(T_u/) 的子树具有独立性,我们在用 tarjan 求割点的时候就已经强调过这一点。

  • 所以为什么 /(T_d/) 会出现横叉边?因为一个后访问到的 SCC 可以向之前访问的 SCC 连边。例如 /(G = (/{1, 2, 3/}, /{1/to 2, 1/to 3, 3/to 2/})/),如果我们在 dfs 时先访问到了节点 /(2/),那么在访问节点 /(3/) 时就会找到从 /(3/)/(2/) 的横叉边。

当然,一个先访问到的 SCC 不可能向之后访问的 SCC 连横叉边 /(u/to v/),因为若非,根据引理,/(T_d/)/(u/)/(v/) 的祖先,这样 /(u/to v/) 就成了前向边或树边,与 /(u/to v/) 是横叉边矛盾。笔者称其为 横叉边的滞后性

尽管子树独立性变弱了,但我们还是能够发现一些性质,就是前向边和横叉边不影响强连通性。下面证明这一点。

  • 对于前向边 /(u/to v/) 而言,/(u/) 本身就能通过树边到达 /(v/),删去后自然没有任何影响。它甚至不影响连通性。

  • 对于横叉边 /(u/to v/) 而言,/(u, v/) 一定在不同的 SCC。若非(即 /(u, v/) 强连通),类似 /(T_u/) 的子树独立性,考察 /(u, v/)/(T_d/) 上的 LCA /(d/)。根据横叉边的滞后性,/(v/)/(u/) 之前被访问。因为 /(u, v/) 强连通,所以 /(v/) 可达 /(u/)。结合这两点,根据引理,/(T_d/)/(v/)/(u/) 的祖先,所以 /(u/to v/) 是返祖边,与 /(u/to v/) 是横叉边矛盾。

  • 对于返祖边 /(u/to v/),它会使 /(T_d/)/(v/)/(u/) 的路径上所有点形成 SCC。多个返祖边组合起来能够形成更大更复杂的 SCC。

根据上述结论,有向图 tarjan 算法的 low 数组应当定义为子树内所有节点的所有 返祖边 能到达节点的最小时间戳。

5.2.2 算法流程

容易证明每个 SCC 在 /(T_d/) 上只有一个最浅节点,我们希望在每个最浅节点处统计其对应的 SCC。考虑定性描述 “形成一个 SCC” 的情况。

对于节点 /(u/),如果它不是某个 SCC 的最浅节点,由 SCC 的定义,它只经过返祖边一定可以到达更浅的祖先。相反,如果它是,那么它的整棵子树都不能跳出 /(x/) 到达更浅的祖先。根据这一点,我们得到判断 /(u/) 是某个 SCC 的最浅节点的充要条件:low[u] >= dfn[u],也可以看做 dfn[u] == low[u]

类似 E-BCC 缩点,我们时刻维护一个栈 /(S/),表示已经访问过但还没有确定形成 SCC 的节点。每次找到最浅节点 /(u/) 时,就将栈顶一直到 /(u/) 的所有节点弹出(由于 /(u/) 是该 SCC 第一个被访问到的节点,所以它在栈中位置最深),表示它们形成了一个 SCC。

算法的正确性依赖于弹出的所有节点的强连通性,以及对应 SCC 的极大性。证明方法和 E-BCC 缩点的正确性证明差不多,这里简要说明一下。

强连通性:设弹出的点集为 /(V/),若 /(V/) 的点导出子图包含两个 SCC /(G_1/)/(G_2/),设对应点集为 /(V_1/)/(V_2/),且 /(V_1/) 的最浅节点为 /(u/)/(V_2/) 的最浅节点为 /(v/),那么在 dfs 的过程中回溯到 /(v/) 时,由于将 /(v/) 判定成 /(G_2/) 的最浅节点,同时 /(V_2/) 是在 /(V_1/) 之后访问的,所以不断弹栈直到弹出 /(v/) 的过程中,弹出的点集 /(V’/) 一定包含 /(V_2/)。与 /(V_2 /subseteq V/) 矛盾。

极大性:如果存在 /(v/) 属于 /(u/) 所在 SCC 但 /(v/notin V/),根据 /(u/) 的最浅性,/(v/) 一定在此之前被弹出,所以 dfn[v] == low[v],即 /(v/) 的子树内不存在能到达 /(u/) 或其祖先的返祖边,与 /(u, v/) 属于同一 SCC 矛盾。

最后只剩下一个问题,就是如何求 low。对于树边 /(u/to v/) 而言,根据经验自然是用 low[v] 更新 low[u]。主要是如何判断边的类型是横叉边,前向边还是返祖边。

  • 对于前向边 /(u/to v/) 而言,用 dfn[v] 更新 low[u] 没有任何影响,因为 dfn[v] <= low[u]

  • 对于返祖边 /(u/to v/) 而言,用 dfn[v] 更新 low[u]

  • 对于横叉边 /(u/to v/) 而言,不能用 dfn[u] 更新 low[v],因为横叉边对强连通性没有影响。我们已经证明过了 /(u, v/) 一定属于不同 SCC。

    考虑它们在 /(T_d/) 上的 LCA /(d/)/(v/)/(d/) 一定属于不同 SCC,否则由于 /(d/) 可达 /(u/)/(u/) 可达 /(v/),而 /(v, d/) 强连通,即 /(v/) 可达 /(d/),得出 /(u, v/) 强连通,矛盾。这说明从 /(v/) 回溯到 /(d/),向 /(u/) 方向 dfs 时,/(v/) 所在 SCC 已经被弹出,即 /(v/) 不在栈中。

    同时,由于返祖边 /(u/to v/) 使得 /(u, v/) 在同一个 SCC 中,所以此时 /(v/) 一定没有被弹出,即 /(v/) 在栈中。

    综合上述两点,我们证明了以下做法的正确性:对于非树边 /(u/to v/),若 /(v/) 不在栈中,则用 dfn[v] 更新 low[u]

5.2.3 P3387【模板】缩点

因为可以多次经过同一个点,所以一旦进入某个 SCC,就一定可以走遍其中所有点,获得其中所有点权值之和的贡献。但是离开后就没法再回来了。

因此,将所有 SCC 缩点后得到 DAG,每个 SCC 缩点后的权值等于它所包含的所有点的权值之和,问题即找到最长的一条路径使得路径上所有 SCC 的权值之和最大。拓扑排序 DP 即可,时间复杂度 /(/mathcal{O}(n + m)/)

由上我们可以发现 SCC 缩点经常和拓扑排序搭配在一起,因为缩点后的结果是一张有向无环图。

E-BCC 和 V-BCC 缩点后均可以得到一棵树,后者经过处理后就是圆方树。

#include <bits/stdc++.h> using namespace std; const int N = 1e4 + 5; int n, m, cn, col[N]; int a[N], val[N], f[N], deg[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;     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]);         deg[col[it]]++;       }   }   queue<int> q;   for(int i = 1; i <= cn; i++) if(!deg[i]) q.push(i);   while(!q.empty()) {     int t = q.front();     q.pop(), f[t] += val[t];     for(int it : g[t]) {       f[it] = max(f[it], f[t]);       if(!--deg[it]) q.push(it);     }   }   int ans = 0;   for(int i = 1; i <= cn; i++) ans = max(ans, f[i]);   cout << ans << endl;   return 0; } 

5.3 例题

*I. AT3945 [ARC092D] Two Faced Edges

强连通性相关首先考虑缩点建出 DAG。

对于连接 SCC /((S_1, S_2)/) 之间的边,反向后使得 SCC 数量减少当且仅当 /(S_1/) 在 DAG 上能够不经过该边到达 /(S_2/)。可以 /(/mathcal{O}(nm)/) 求出任意两点之间的路径数对 /(2/)/(/min/) 判断。或者直接求路径数取模,出错的概率较小。

但是 SCC 内部的边就不好办了。如果反向 /(u/to v/),我们断言如果 /(u/) 还能到达 /(v/) 那么连通性不变。

首先,对于所有 SCC 内部的点对 /((x, y)/),从 /(x/)/(y/) 要么必经 /(u/to v/) 这条边,要么非必经。对于后者,反向 /(u/to v/) 不影响 /(x, y/) 的连通性。对于前者,若 /(u/) 仍能到达 /(v/)/(x/) 仍能到达 /(y/),否则反向边 /(v/to u/) 不会产生任何影响。因为必经 /(u/to v/) 所以 /(x/) 没有其它路径到达 /(v/),反向后 /(x, v/) 不连通自然引出 /(v/to u/) 无用。

综上我们得到了判断 SCC 内部边 /(u/to v/) 反向后强连通性不变的充要条件:去掉 /(u/to v/)/(u/) 仍能到达 /(v/)。这相当于对于一个点的每条出边 /(u/to v_1, v_2, /cdots, v_i/),我们都需求出 /(u/) 在不经过 /(u/to v_j/) 的情况下能到达哪些点。

考虑这是一个前缀 /(v_1 /sim v_{j – 1}/) 和后缀 /(v_{j + 1} /sim v_i/) 的形式,因此正反各扫一遍,在扫每条出边时记录时刻每个点的可达性,并且在扫当前出边 /(u/to v/) 时若 /(v/) 已经可达则将 /(u/to v/) 标记为去掉后仍可达。由于我们依次加入所有出边时 SCC 内每个点只会被遍历一次,故总时间复杂度为 /(/mathcal{O}(nm)/)

  • 注意我们考虑的是保留从 /(u/) 出发的一段前缀或后缀的边时从 /(u/) 出发每个点的可达情况,因此当再次 dfs/(u/) 时需及时返回,否则会遍历到从 /(u/) 出发的不属于当前前缀或后缀的边。

更进一步地,发现对于连接 SCC 之间的边 /(u/to v/),我们也希望判断去掉该边后 /(u/) 能否到达 /(v/)。这和 SCC 内部的边所需要求解的东西是一致的,因此可以将所有边等价考虑。只不过对于不同 SCC 之间的边,若 /(u/not/rightsquigarrow v/) 则反转 /(u/to v/) 不会改变 SCC 个数,反之则改变。这和 SCC 内部的边恰好相反。这样我们就不需要显式地建出 DAG 了。

对整张图进行 /(/mathcal{O}(nm)/) 的深搜会 T 飞掉,我们需要更加高效的算法。考虑每次找到 /(u/) 的所有出边中没有被访问的第一个点,可以使用 bitset_Find_first() 实现。

具体而言,设 vis 表示各个节点是否被访问,e[u] 表示 /(u/) 的所有出点,则 (~vis & e[u])._Find_first() 即为所求。时间复杂度 /(/mathcal{O}/left(/dfrac {n ^ 3}{w}/right)/)

启示:序列去掉一个位置的信息可由前缀和后缀合并得到。

代码

II. P3436 [POI2006]PRO-Professor Szu

首先,容易发现若一个大小大于 /(1/) 的 SCC 或自环(下称为不合法结构)能够到达教学楼,则该不合法结构内部每个点到教学楼的路径数量都是无穷大。因此 SCC 缩点 + 建反图 拓扑排序,不合法结构不能入队。拓扑排序同时记录路径数 /(f_i/) 表示从 /(i/)/(n+1/) 的路径数量。因为不能取模,所以要对 /(36501/)/(/min/)

但题目没有保证每个点都能到教学楼(题面有误),所以需要先将反图上入度为 /(0/) 的非教学楼点入队跑一遍拓扑排序。注意此时不合法结构可以入队,因为它们没有到达教学楼的路径。

最后,若出现没有入队的点,说明这个点能够到达一个不合法结构,因此路径数同样为无穷大。此外,若 /(f_i>36500/) 也不符合题意。时间复杂度线性。

  • 如果 /(n + 1/) 所在 SCC 是不合法结构,那么不能入队。
  • 使用 vector 存图会 MLE,原题空间限制 64MB。

代码

*III. P7737 [NOI2021] 庆典

对于一个 SCC ,将其缩成一个点不影响答案。而题目给定的性质说明图的连通性可以用一棵树刻画,具体来说,拓扑排序,保留每个点最后一条入边。得到一棵外向树。

对于给定的 /(2(k + 1)/) 个城市端点,若 /(i/)/(j/) 的祖先,且 /(s/) 能到达 /(i/)/(j/) 能到达 /(t/),说明 /(i/to j/) 这条链上所有城市都有可能经过,树剖求链并即可。

时间复杂度 /(/mathcal{O}(n/log n/log/log n)/)/(/log /log n/) 是排序的复杂度。

代码

5.4 参考文章

6. 欧拉图

欧拉,永远滴神!

6.1 定义与判定

  • 欧拉路径:通过 连通图 中所有边恰好一次的路径称为欧拉路径。
  • 欧拉回路:通过 连通图 中所有边恰好一次的 回路 称为欧拉回路。回路,即路径起点和终点相同。
  • 欧拉图:具有欧拉 回路 的有向图或无向图称为欧拉图。
  • 半欧拉图:具有欧拉 通路不具有欧拉回路 的有向图或无向图称为半欧拉图。

做题时基本用不到上述定义(大雾)。

不严谨地说,欧拉图能够从任意一点作为起点一笔画完整张图然后回到该点,而半欧拉图只能从某两个点 /(S,T/) 开始才能画完整张图,其中 /(S,T/) 分别作为起点和终点。

欧拉图的判定:一张无向图 /(G/) 是欧拉图,当且仅当 /(G/) 是连通图且每个顶点的 度数都是偶数。一张有向图 /(G/) 是欧拉图,当且仅当 /(G/) 是 SCC 且每个顶点的入度和出度相等。

考虑证明上述结论。

无论是对于无向图还是有向图而言,必要性都是显然的。考虑最终欧拉回路的形态,每次进入一个点,都要从该点走出去,所以出边和入边必须两两抵消。对于起始点,它一开始走出去的边和最后走回它的边同样抵消了,所以有向图存在欧拉回路必须满足每个点出度和入度相等。同理可证无向图每个点的度数必须为偶数。

充分性通过构造法证明。考虑无向图,首先找到图上任何一个回路 /(P/)(不需要是欧拉回路)。因为除掉所有孤立点,每个点的度数都 /(/geq 2/),所以回路必然存在。然后从图上删去 /(P/) 的所有边,并删去所有孤立点。形成的所有子图仍然满足每个点的度数均为偶数,因为 /(P/) 中每个点的度数为 /(2/)。由于每个子图均与 /(P/) 有交点,所以只需要将子图的欧拉回路接到 /(P/) 上即可得到原图的欧拉回路。因此我们对子图进行同样的操作。边的个数有穷,所以整个过程必然结束,继而我们得到原图的欧拉回路。同理可证有向图欧拉回路判定条件的充分性。

半欧拉图的判定:一张无向图 /(G/) 是半欧拉图,当且仅当 /(G/) 存在两个奇度数顶点。一张有向图 /(G/) 是半欧拉图,当且仅当 /(G/) 弱连通,恰存在一个顶点使得出度减入度等于 /(1/),恰存在一个顶点使得入度减出度等于 /(1/),且剩余所有顶点出入度相等。弱连通定义为将所有有向边替换为无向边后,整张图连通。

6.2 求欧拉路径:Hierholzer 算法

Hierholzer 算法的核心是不断往当前回路的某个点中插入环,这和欧拉回路存在的判定条件的充分性证明如出一辙,或者说完全等价。

先考虑有向图吧,因为有向图不需要考虑重边的问题。

6.2.1 朴素方法

根据流程,我们有一个朴素的实现方法:首先 dfs 找到经过某个点的任意一个环 /(P/)(不需要是简单环),将 /(P/) 上的所有点标记为已删去。然后依次加入 /(P/) 上的每个节点 /(p_1, p_2, /cdots, p_{|P|}, p_1/)。加入 /(p_i/) 之前,我们先递归找到 /(p_i/) 所在子图(我们删去了一些边)的欧拉回路,然后插入当前路径。因此这是一个递归形式的算法。

每次枚举到一个点时,我们都要遍历它的所有出边以找到第一个没有被删去的边,复杂度为 /(/mathcal{O}(m ^ 2)/)

若用双向链表维护每个点剩余的所有出边,则每条边只会被遍历一次,时间复杂度 /(/mathcal{O}(n + m)/)

进一步地,我们发现每次删去的环边一定是每个节点所有没有被删去的出边中开头的若干个。也就是说,如果给 /(u/) 的所有出边 /((u, v_i)/) 钦定一个遍历顺序 /((u, v_1), /cdots, (u, v_{out(u)})/),那么找环时被删去的一定是开头的若干条边 /((u, v_1), (u, v_2), /cdots, (u, v_k)/)。因为我们不会因找不到环而反悔掉走某一条边的操作。

不断无脑深搜,最终一定能找到环

证明该结论。不妨设我们从 /(u/) 开始找环,每次走当前节点第一条没有被删去的出边并删去。走到节点 /(v /neq u/) 时,设这是我们第 /(i/) 次进入 /(v/),那么我们只会离开 /(v/)/(i – 1/) 次(每次离开必然对应一次进入,而在第 /(i/) 次进入之前,只有 /(i – 1/) 次进入)。故此时我们只删掉了 /(v/)/(i – 1/) 条出边,而 /(v/) 有不少于 /(i/) 条出边:第 /(i/) 次进入 /(v/) 意味着 /(in(v) /geq i/),而 /(in(v) = out(v)/) 所以 /(out(v) /geq i/)。因此我们必然能从 /(v/) 走出去到别的节点。

因此,最终必然只可能在 /(u/) 处走不到其它节点。但这意味着我们已经找到了一个环。

根据上述结论,我们不需要维护双向链表了。存图用的链式前向星可以满足我们的要求,因为我们每次只会删去开头的出边。这样改进后,链表头就和网络最大流 Dinic 算法的当前弧非常相似,都是 记录第一个有用的边 以省去一条条跳无用边的时间。

该结论同时也证明了找到一个环的复杂度关于环上边数线性,所以总复杂度即 /(/mathcal{O}(n + m)/)

注意,需要使用双向链表维护将一个环插入当前回路的过程,否则复杂度会退化成 /(/mathcal{O}(n(n +m))/)(如果一旦走到目标节点而非目标节点不可以在往外走时就认为找到环,复杂度会退化成 /(/mathcal{O}(m(n + m))/))。因为这样处理一个环的复杂度变成了所有未处理的边的个数之和。

6.2.2 巧妙方法

上述做法的时间复杂度已经足够优秀,但实现起来稍微有些复杂。我们希望算法能够更简单。

我们可以用自己的语言描述复杂的方法干了些什么,再思考有哪些地方可以简化。

从整体上考察,我们无非就是实现了这样的步骤:从一个起点开始找到一个环,然后以环上的每个起点开始找到一个环 …… 不断递归下去。

然后思考究竟是哪里麻烦了。我们会发现,为了依次从环上的每个起点开始找环,我们需要先 显式 地将这个环找到,排出来,再依次处理上面的所有节点。所以我们需要一个 dfs 函数找环,另一个递归式函数解决欧拉回路问题。

这样太蠢了,因为无论以怎样的顺序安排环上节点的找环顺序,都不会影响欧拉回路的正确性。我们只是找环并插入啊,换个顺序又不会让问题变得无解。

因此,我们直接在 dfs 找环的回溯过程中,直接对环上的每个节点找环。换句话说,我们将原来找环的顺序倒过来,这样我们就没有必要先显式地找到当前环了,而是在回溯的过程中,一边对当前点找环,一边往回路中插入当前环。

综上,我们得到了求解欧拉回路的最常用算法 —— Hierholzer 算法的具体步骤:遍历当前节点 /(u/) 的所有出边 /((u,v)/),若 /((u,v)/) 未走过,那么向节点 /(v/) 深搜。遍历完所有出边后,将 /(u/) 加入路径。最终得到的就是一条反着的欧拉路径。倒过来即可。

如果要求字典序最小,只需在一开始对每个点的所有出边从小到大排序。这样一来,欧拉回路上从左往右看,每个点的后继都取到了理论最小值。

6.2.3 无向图和欧拉通路

以上,我们通过两小节的篇幅提出并优化了有向图欧拉回路的求解方法。

对于无向图的欧拉回路,我们可以类似有向图欧拉回路一样做,唯一的注意点是我们需要对边判重。使用求桥边时的成对变换技巧,用 /(2k/)/(2k + 1/) 存储一条边的两个方向,并开桶记录。不能只判返祖边,因为可能有重边。

对于无向图和有向图的欧拉通路,注意必须从奇点或唯一的出度大于入度的点开始 dfs。其它地方和欧拉回路没有区别。

模板题 P7771 【模板】欧拉路径 代码如下。

#include <bits/stdc++.h> using namespace std; const int N = 2e5 + 5; int n, m, lar, top, stc[N], in[N], hd[N]; vector<int> e[N]; void dfs(int id) {   for(int &i = hd[id]; i < e[id].size(); ) dfs(e[id][i++]);   stc[++top] = id; } int main() {   cin >> n >> m;   for(int i = 1; i <= m; i++) {     int u, v;     scanf("%d%d", &u, &v);     e[u].push_back(v), in[v]++;   }   for(int i = 1; i <= n; i++) {     sort(e[i].begin(), e[i].end());     if(abs((int) e[i].size() - in[i]) > 1) puts("No"), exit(0);     if(e[i].size() > in[i])       if(lar) puts("No"), exit(0);       else lar = i;   }   dfs(lar ? lar : 1);   if(top != m + 1) puts("No");   else {     reverse(stc + 1, stc + top + 1);     for(int i = 1; i <= top; i++) cout << stc[i] << " ";   }   return 0; } 

6.3. 例题

I. P2731 [USACO3.3]骑马修栅栏 Riding the Fences

无向图最小字典序欧拉路径板题。注意有重边,所以只能记录边的编号而非两个顶点以判重。

II. P1127 词链

从每个字符串第一个字符向最后一个字符连边,跑有向图欧拉回路即可。注意对邻接链表排序要按照每条边存储的字符串的字典序排序,而非指向节点的编号大小。

III. P1341 无序字母对

板题。

*IV. P3443 [POI2006]LIS-The Postman

每条路径片段的限制形如在片段中出现的相邻两条边必须先后走,因为一条边有向边仅出现一次。

所有限制形成一条边先后走的关系的链,类似 CSP2019 T4 树上的数,将这条链缩成从起点到终点的一条边,跑欧拉回路。最后输出这条边时要再展开成链,因此要记录每条链上的节点顺序。

若限制关系出现环或分叉则无解,这可以通过并查集或链表维护。时间复杂度线性对数。

细节还是挺多的,代码

V. P3520 [POI2011]SMI-Garbage

/(f(i)/) 表示以 /(i/) 为一端的需要经过的边的数量。对于一条回路,所有点度数均为偶数,因此一次操作后 /(f(i)/) 的奇偶性不变。故若存在 /(i/) 使得 /(f(i)/) 是奇数,则无解。否则,注意到有解是需要经过的边形成的图的每个连通块均存在欧拉回路的充要条件,对这张图跑一遍欧拉回路。由于要求不能经过相同的点,而路径上相同的点之间也一定是回路,所以借助栈把相同的点之间的回路弹出。时间复杂度 /(/mathcal{O}(n + m)/)

对于不需要经过的边,可以直接忽略,这是因为是否有解与这些边无关:如果 /(f/) 均为偶数,我们必然能构造出一种不经过需要修改的边,反之无解。

6.4 参考文章

商匡云商
Logo
注册新帐户
对比商品
  • 合计 (0)
对比
0
购物车