图论学习笔记1

图论的题目并不少见,而且大多以 神秘 闻名。即大部分 ad-hoc 都可是图论。

图的基本定义以及图的基础算法(最短路,最小生成树,lca,DAG……)在这里就不写了。

在此之前,介绍一种 LCA 的新求法,不是大家普通的 tarjan 算法。

1.LCA tarjan 算法

算法介绍

这里给出一种 LCA 的新求法,在之前我们已经使用 lca 和 树链剖分 的方法解决了这道题。

介绍新求法的目的是为后面的深搜树做铺垫。

这种算法是一种离线算法,需要提前记录下来每一个询问。然后再边深搜边处理询问。

其中需要使用到并查集的一些结构,在算法流程之间会体现出来类似的思想。

时间复杂度:预处理 \(O(n)\),查询直接输出 \(ans\) 即可,是 \(O(1)\)。是比 lca 优秀的。

使用场景:只有当以下二个条件满足的时候才能使用 LCA tarjan 算法

  • 问题支持离线。 只有我们预先知道了所有的查询才能处理。

  • 树结构稳定。 如果查询途中修改了树的形状则必须要重新处理。

优点

  • 时间复杂度低。时间复杂度为 \(O(n + q \cdot α(n))\)\(n\) 为结点个数,\(q\) 为查询次数,\(α(n)\) 接近于常数。

  • 离线处理高效。能一次性处理所有查询,适合需要批量处理预先已知查询的场景(如静态树结构)。

  • 并查集的高效操作。利用并查集的路径压缩和按秩合并,合并与查询操作的时间复杂度极低,接近常数时间。

缺点

  • 仅支持离线查询。必须提前知道所有查询,无法处理动态新增的实时请求。这在需要即时响应的场景中不适用。

  • 空间复杂度较高。需要为每个结点存储其关联的查询列表,空间复杂度为 \(O(n + Q)\)。当查询量极大时,内存占用显著增加。

  • 对树的静态性依赖。假设树结构固定,若树在查询过程中动态变化(如结点增删),需重新执行算法,无法增量更新。

  • 不适用于特殊场景。对某些非树结构(如有向图或无向图中的环)无法直接应用,算法设计针对树结构。

与其他lca算法的比较

算法 类型 预处理时间 单次查询时间 空间复杂度 适用场景
Tarjan 离线算法 离线 O(n + Q) O(α(n)) O(n + Q)
倍增法 在线 O(n log n) O(log n) O(n log n) 动态或单次查询
RMQ+欧拉序 在线 O(n) O(1) O(n log n) 频繁查询的高效响应
树链剖分 在线 O(n) O(log n) O(n) 一般不用来专门求lca

算法流程(what???)

首先需要存下来每一个询问 \((u, v)\),然后在 \(u\) 上面记录下点 \(v\) 表示它要与 \(v\) 进行求 lca 的操作,在 \(v\) 上面记录点 \(u\) 同理。

然后就开始树上的遍历。以这张图为例。

注意,我们这里是先将自己的所有子树遍历完,再处理自己积攒的查询以及合并。

首先我们一路从 \(3\) 开始遍历,然后是 \(5\),然后是 \(7\)

然后我们发现 \(7\) 位置上需要处理 \(1\) 号结点,但是这时候还没有访问到 \(1\) 号点,不能确定查询的结果。

因此我们在这时不会回答这个查询,只有当结点 \(u\) 在查询上对应的另一个结点 \(v\) 被访问到时,且 \(u\) 本身也访问到了,这个查询才可以回答。

继续接着模拟。这时候 \(7\) 已经没有了儿子,需要回溯。这时候核心部分就来了:在回溯到 \(5\) 的时候,将 \(7\)\(5\) 合并到一组,并将 \(5\) 号结点设为“组长”。这里的意思我们这里先不讲,放到后面说。

然后 \(5 \to 6 \to 2\),一路上没有经过任何查询。在 \(2\) 回溯的一瞬间,将 \(2\) 合并到 \(6\) 号形成一个块。

发现 \(6\) 的子树已经遍历完成了,再将 \(6\) 号合并到 \(5\) 号,这样以 \(5\) 号为组长的组有 \({7,5,6,2}\) 四个结点。

为了便于处理查询,定义一个数组 \(fa\) 代表一个点合并到的结点。一开始显然 \(\forall 1 \le i\le n,fa_i=i\)

因为 \(7\)\(6\) 都直接合并到了 \(5\),这时 \(fa_7 = fa_6 =5\)。而 \(2\) 直接合并到了 \(6\),所以 \(fa_2 = 6\)

虽然此时 \(2\) 实际上合并到了 \(5\),但是可以通过 \(fa_{fa_2} = 5\) 确定真正属于的结点。很类似并查集中的 找根结点 过程,显然这里也可以使用路径压缩。

继续模拟\(5 \to 8 \to 1\),发现 \(1\) 号对应的 \(7\) 号和 \(5\) 号结点已经访问过了,可以处理查询了。

又一个核心部分:观察到 \(fa_7 =5\),所以立刻得出答案 \(lca(1,7) = 5\)!就这么直接!其次观察到 \(fa_5 = 5\),也可以得出答案 \(lca(1,5) = 5\)

显然这两次查询的答案都没有问题,所以我们可以得出一个公式:假设一次询问 \((u,v)\) 中的 \(u\)\(v\) 先访问到,在访问到 \(v\) 的时候,\(lca(u,v)=find(u)\)其中 \(find()\) 函数是在并查集中很经常用到的一个找父亲的函数,我们应该都不陌生。在此处同理,是一样的实现方法。

概括一下算法的流程:

  • 遍历到了结点 \(u\),先将 \(u\) 的子树遍历完成,即先把儿子全部深搜了注意记录一下 \(pre\),在以后有用

  • 然后处理查询。假设有查询 \(u, v\) ,显然 \(u\) 这里记录着 \(v\) 点的查询信息。

    • 如果 \(v\) 已经被访问到,则直接 \(lca(u,v)=find(v)\)

    • 如果 \(v\) 还没有被访问到,则暂时不处理这个查询。

  • 该回溯了,在回溯的一瞬间,将 \(fa_u\) 设为 \(pre\),即将 \(u\) 合并到 \(pre\) 的位置。

前面写那么一长串是为了让人更加容易理解。

算法正确性(why???)

前面的合并部分都可以理解,只是求 \(lca\) 的部分让人有些匪夷所思:为什么这样是对的???

这就需要使用到一些数学的技巧。众所周知,当我们要证明某一个式子的结果恰好是 \(x\) 时,有以下这两种方法:

  • 1.将式子颠来倒去,最终得到结果。

  • 2.通过放缩法或直观分析这个式子的结果的可能区间。一般都是先往大了估计,然后再往小了估计,之后再往大的估计……最终当我们发现这些区间的交区间只有一个可能值的时候,这个值就是答案。(显然 大 -> 小 -> 大…… 的证明顺序可以颠倒)

第一种方法显然就是正规 \(lca\) 算法的求法。这里我们使用第二种方法。


假设目前我们已经将以 \(x\) 为根的子树全部遍历完成,且存在 \((u, v)\) 查询使得 \(u\) 在这个子树内而且 \(v\) 不在(\(u,v\) 可以颠倒。\(u,v\) 都在子树内的查询早就处理完了,都不在子树内的查询完全没有指望)。

我们这里必须有一个要求:\(v\) 虽然不在 \(x\) 子树内,但是必须要在 \(x\) 的兄弟的子树内。如果 \(v\) 不满足要求且不是下面的特殊情况,\(x\) 就可以往上提直到 \(v\) 满足要求。

特殊情况考虑

当然有一种情况使得 \(v\) 不存在:\(x\) 的父亲一直到根结点都只有一个儿子。此时若 \(v\) 不在 \(x\) 的子树,则只有可能是 \(x\) 的祖先。

此时显然 \(lca(u,v) = v\),则如果我们的算法算出来的结果也是这个值,我们的算法在此情形下就是正确的。

显然只有我们遍历到 \(v\) 的时候才能处理 \((u,v)\) 的查询,而此时 \(v\) 以下的所有结点的 \(find()\) 值都是 \(v\) 了,则肯定有 \(lca(u, v) = fa_u = v\)

这种特殊情况下,我们的算法被证明是正确的。且只要保证 \(v\) 存在,我们在下面的证明就一定可以成功。


Step1.证明 \(lca\) 的下限

显然,两个点的路径之间必须要经过两个点的 \(lca\),即 \(u \to v\) 一定要经过 \(x\) 的父亲。

所以,\(lca\)深度最多是 \(x\) 的父亲,这样我们就证明了 \(lca\) 的下限。这是真实数据的下限。

而在算法中 \(fa\) 值结点的深度只会更小,而目前 \(x\) 子树内的结点的 \(find()\) 值已经至少为 \(x\) 的父亲(因为已经全部遍历完成)。这是算法的下限。

两个下限一样。

Step2.证明 \(lca\) 的上限

既然 \(x\)\(v\) 是兄弟关系,则 \(x\)\(v\) 不会经过父结点再往上的结点了。

因为这样子会绕路,绕了父结点到那个再往上的结点的路径。

所以一定在父结点下面或父结点的位置,这是真实情况。

因为父结点所在的子树还没有遍历完,子树之内的 \(find()\) 值(使用 \(find()\) 而不是 \(fa\) 是因为前者是自动更新的但是 \(fa\) 相当于一个预数组,不会在过程中无故自动改变)不可能是父结点上面的位置,只能在其位置或者其下面。这是算法之中的情况。

两个上限也一样。

因为算法和实际情况是一样的结果,所以可以证明:在任何情况下,算法一定正确。

复杂度分析

时间复杂度

因为其遍历的时间复杂度为 \(O(n)\),查询复杂度为 \(O(n)\)

但是并查集单词查询可以视作 \(O(n \log n)\),我们当然希望这种算法比原先的 树链剖分 算法更优,当然也比 lca 算法更优。

如果加上路径优化和启发式合并,其时间复杂度可以变为 \(O(n \log a(n))\)。其中 \(a(n)\) 为反阿克曼函数,因为阿克曼函数增长及其快速,因此其反函数增长极其缓慢。

可以视为 \(a(n)_max = 5\) 在正常 \(n\) 的情况下,相当于常数。

因此,LCA tarjan 算法的时间复杂度为 \(O(n)\)

空间复杂度

只是邻接表多上一个记录查询的东西即可,空间复杂度为 \(O(n)\)

综上,算法总体比 lca 优秀,不过还是需要问题满足前言的两个条件。

模板

#include <bits/stdc++.h>
using namespace std;
const int N = 5e5 + 10;  // 最大结点数(注意根据题目需求调整)
int ans[N];               // 存储所有查询的答案
vector<pair<int, int>> q[N]; // q[u]存储与u相关的查询:{目标结点v, 查询编号id}
vector<int> v[N];         // 树的邻接表存储
int n, m, s;              // 结点数、查询数、根结点编号
int fa[N];                // 并查集父结点数组(兼具访问标记功能)

// 查找
int find(int x) {
    if (fa[x] == x)
        return x;
    return fa[x] = find(fa[x]); // 路径压缩
}

// 核心DFS遍历(tarjan算法实现)
void dfs(int u, int pre) { // u:当前结点, pre:父结点
    fa[u] = u;  // 初始化当前结点的集合代表为自身(相当于创建新集合)
    // 递归处理所有子结点(深度优先遍历)
    for (auto i : v[u]) 
        if (i != pre)      // 跳过父结点防止回溯
            dfs(i, u);      // 递归处理子树
    // 处理与当前结点相关的所有查询(后序遍历阶段)
    for (auto que : q[u]) {
        int y = que.first;  // 查询的另一结点
        int id = que.second;// 查询编号
        ans[id] = find(y);  // 若y已被访问,find(y)即为LCA。否则为 0
    }
    fa[u] = pre; // 关键操作:将当前结点合并到父结点集合
}

int main() {
    ios::sync_with_stdio(0); // 关闭同步流加速输入输出
    cin >> n >> m >> s;
    for (int i = 1; i < n; i++) {
        int x, y;
        cin >> x >> y;
        v[x].push_back(y);  // 无向
        v[y].push_back(x);
    }
    // 存储所有查询(双向存储)
    for (int i = 1; i <= m; i++) {
        int x, y;
        cin >> x >> y;
        q[x].push_back({y, i}); // 在x的查询列表记录y
        q[y].push_back({x, i}); // 在y的查询列表记录x
    }
    dfs(s, 0);  // 初始父结点设为0(虚拟根)
    for (int i = 1; i <= m; i++)
        cout << ans[i] << endl;
    return 0;
}

2.深搜树

深搜树是一种很有用的数据结构,既可以解决一些很神秘的图论问题(如果没有想到深搜树的话),又从此衍生出了很多算法。例如tarjan图论算法全家桶(边双连通分量,点双连通分量,割点,割边,强连通分量),还有数据结构(例如圆方树)。

深搜树,顾名思义,就是深搜和树的结合体

实际上,深搜树就是在一个图深搜中,经过的边组成的树。

深搜的过程是这样的:针对每一个点,遍历所有可以到达的结点。如果没有被访问则去递归,如果被访问过了就不去了。

当一个点所有可以到达的结点都已经遍历过而且回溯的时候,这个点也就可以回溯了。


这就是深搜树的构造过程,但是图有可能不是连通的!这时候就好像有一些点无法到达。

这时我们把每一个 连通子图 都看成独立的,针对每一个连通子图进行深搜并构造深搜树即可。

请注意,这里我就不会在下文继续说 “连通子图” ,而是使用 “图” 代替。即下文所说的所有的 “图” 都已经默认是连通的了。


但是为什么一定是一个 呢?

首先根据深搜序的性质:深搜最多只会访问每一个结点一次。也就是不存在一个结点走了好几步然后又回到了这个结点。

因此我们可以知道:深搜得出的“图”一定无环。(树也是图的一种)

然后又通过搜索基于的图是连通的,所以又有深搜得出的“图”一定连通。

无环连通图,简称


讲了那么久还是只讲了它的定义,那么深搜树有怎样的特点和意义呢?

首先我们先区分两个边的类型:树边回边

树边(tree edge:顾名思义,就是深搜构造出来的树上边。连接父亲和儿子。

但是一个图绝对不是只有树那么形态简单,而是有一些杂边。

回边(back edge:连接子孙和祖先。也是在图上而不是在树上的边。

显然,树边和回边一定是会有的,但是为什么就只有这两种边呢?

我们只需要证明剩下的一些杂边,全部都是回边。

假设有一条边连接了树上的两个结点,而且不是子孙和祖先的关系也不是父子关系(则这条边既不是树边也不是回边)。则根据深搜不撞南墙不回头的性质,显然访问到其中任意一点时,明明就还有路可以走,为何返回呢?这条边也会成为树边。矛盾。

因此,深搜树只有树边和回边两种边。而我们是使用DFS的原理来证明的。


深搜树并不只有这些定义,为了更好的解决问题,我们还需要认识两个数组:dfn[] 数组和 num[] 数组。

两个数组的定义如下:

  • dfn[i] 表示在深搜树上 \(i\) 号结点被 dfs() 访问的次序。

  • num[k] 表示在深搜树上第 \(k\) 个被访问到的结点编号。

显然这两个数组是对称的,例如:如果 dfn[8] = 2,则 num[2] = 8

这两个数组我们以后会继续沿用。

为什么要使用 dfn[] 而不是继续使用结点的原本编号呢?

因为在深搜树上,我们更关心的是结点的位置而不是结点的原本编号,相当于原本的编号被作废了。


到这时候的深搜树不是一种算法,而是可以算作一种考虑图论问题的新角度。

因为这时候将图变成了树,处理一些问题时会简单一些。

在下面几道题的讲解中,将不需要任何算法就可以解决。但是考察一定的数学思想。

模板

模板题

#include <bits/stdc++.h>
using namespace std;
const int N = 100010;
int dfn[N], num[N], id;
vector<int> v[N];//邻接表
int n, m;

void dfs(int x) {
	dfn[x] = ++id;
	num[id] = x;//记录一下
	for (auto i : v[x])
		if (!dfn[i])
			dfs(i);//深搜
}

int main() {
	scanf("%d%d", &n, &m);
	for (int i = 1; i <= m; i++) {
		int x, y;
		scanf("%d%d", &x, &y);
		v[x].push_back(y);
		v[y].push_back(x);
	}
	for (int i = 1; i <= n; i++)
		sort(v[i].begin(), v[i].end());//注意留意题目要求,我就因为这里没有排序一直 WA10
	dfs(1);
	for (int i = 1; i <= n; i++)
		printf("%d ", dfn[i]);
	printf("\n");
	for (int i = 1; i <= n; i++)
		printf("%d ", num[i]);
	return 0;
}

CF405E Graph Cutting

题意

给定一个 \(n\) 个结点,\(m\) 条边的无向连通图,请把图剖分为若干条长度为 \(2\) 条边的链。形式化地,你需要将图中的所有边两两配对,使得每一对中的两条边相邻且每条边恰好被包含于一个对中。
输出剖分方式,格式见样例。若无解,输出 No solution

思路

目前深搜树的使用场景暂时还没有什么关键字(如果不结合算法的话),因此建议遇到图论问题,可以试着思考一下在深搜树方面有什么突破口。

例如这道题,乍一看好像没有思路,但是根据奇偶性,\(m\) 为奇数时肯定不行(最后肯定会剩下一条边)。但是这就意味着 \(m\) 为偶数时一定可以吗?

如果简单粗暴地在图上直接构造,笔者目前没有想到任何方法。但是这里我们还可以考虑一下如果使用深搜树,可否有一些不同的异样的感觉。

需要将图拆分成长度为 \(2\) 的路径,我们不禁想到这有可能是一条树边和一条回边,或是两条回边。

而且 \(2\) 这个数字和 \(3\) 有关系,因为长度为 \(2\) 的路径会经过 \(3\) 个点。根据以往做题的经验,我们一般都是将思路的重心放在中间的点,于是我们考虑:枚举每一个深搜树的点,看一下从此点出去的边(树边和回边)能否两两配对。

因为如果直接描述构造的过程,可能过于笼统。看图:

有一颗深搜树,黄色边表示树边,绿色边表示回边。

首先,我们一路深搜至叶结点,也就是最左边的点。因为最左边的点出去的边正好是 \(2\) 条,是偶数,于是我们贪心地将这些边两两配对,配对完之后就删掉,直到出去的边为 \(0\) 条。


发现不能再次往下递归了,返回了父结点,然后父结点走一次到达了新的叶结点。

发现此时情况一样,直接配对两条边,将这些边删除。


返回,发现父结点已经无路可走,两两配对,并删除边。

注意这些没有出边点实际上并不需要删除,边也不需要实际删除,只需要标记不存在即可。

然后回溯,走两步到达了叶结点。这时候我们发现一个严峻的事实:叶结点出去的边是奇数!

这种情况是我们以前没有遇到过的,那么这时候该如何处理呢?

给出的方法是这样子的:留下自己到父亲的树边,并将其余的边两两配对。

这种方法似乎情有可原:如果父亲也是奇数条出边,那么这条边就可以补上父亲的,使父亲的边可以两两配对。

然后将删除这条树边的任务留给自己的父亲。自己就不用管了。


回溯到了父亲,发现父亲竟然可以两两配对了!于是,将这条树边和父亲连的另一条树边配对并删除。


这就是构造的完整过程。

总结概括一下:

  • 遍历所有的子树

  • 尝试删除子树留下来的边和自己有关联的边(如果存在)

    • 如果为偶数,则全部配对并删除。
    • 否则保留自己和父亲连的边,将其余的边全部配对并删除。

可以证明,如果 \(m\) 为偶数的话,最终一定可以找到一种配对的方案。

CF1364D Ehab's Last Corollary

题目描述

给出一张 \(n\) 个点的无向连通图和一个常数 \(k\)

你需要解决以下两个问题的任何一个:

  1. 找出一个大小为 \(\lceil\frac k2\rceil\) 的独立集。
  2. 找出一个大小不超过 \(k\) 的环。

独立集是一个点的集合,满足其中任意两点之间在原图上没有边直接相连。

可以证明这两个问题必然有一个可以被解决。

思路

第一个问题

看到独立集不难想到二分图,又不难想到树(树一定是二分图)。

而可以使用二分图黑白染色来求独立集。

题面中要求恰好怎么办?取独立集的子集即可。

因此,如果得到的图是一棵树的话,第一个问题就一定可以解决。

第二个问题

而这棵树是什么呢?就是图的深搜树。而其余的边(back edge)就都是回边,这是以前讲过的。

这时候我们又不难想到基环树,因为基环树就是一棵树加上一条边,构成了一个环。

因此可以使用归纳法,逐步加入一条条 back edge,构成了一个个环。也就是 back edge 构成了图上的环。

而回想一下以前学过的东西,back edge 是连接子孙和祖先的,也就是环的长度是可以通过深度的减法算出来的(上一句话指的“环”是一条 back edge 加上若干条树边形成的环)!

但是我们不是很能保证算出来的环长度一定 \(\le k\),怎么办呢???


命题 \(1\):只要整个图的深搜树有 back edge,就一定可以找到一个长度 \(\le k\) 的环。

既然不能证明它一定是对的,那我就将它改变成另一个与问题有关系且正确的结论!

命题 \(2\)整个图的深搜树,取其 \(dfn\) 序为 \(1\)\(k\) 的子树,只要这颗子树有 back edge,就一定可以找到一个长度 \(\le k\) 的环。

这就对了!

因为题目中要求的是 \(\le k\) 即可。我不管你的环长什么样,只要我给你的数据规模是 \(k\) 而且你一定有环,你的环的规模就一定不会超过数据的规模

至此第二个问题就完整的解决了。

概述

这里有思路的概述!

  • 针对整个图构建深搜树,并取其中 \(dfn\) 序为 \(1\)\(k\) 的子树。

    • 如果子树没有 back edge,则深搜树一定是一个二分图。黑白染色即可。

    • 如果子树有 back edge,则任意找一条 back edge,然后输出构成的环即可(可以保证一定规模 \(\le k\))。

这种找环的方法非常值得我们效仿,尤其是要求环长不超过某一个值。

注意是 dfn 序而不是结点编号,这里我们前面有说过。连续的结点编号不一定连通,但是连续的 dfn 序就一定连通。


接下来我们进入正题:Tarjan 算法全家桶

3.涉及概念

在此之前,我们先看看有怎样的一些概念。

  • 连通分支(connected component:极大的、而且点可以互相到达的子图。

  • 桥(bridge)或割边(cut edge:一种边。若删除,则连通分支增加。

  • 边双联通(2-edge-connected:两点之间存在两条无重边路径;删除任意一边,两点仍然连通。

  • 边双连通分支(2-edge-connected component极大的、而且点两两之间边双联通的子图。我们也说是边双联通分量。

  • 极大: 就是已经大到极限了,只要多加入一个东西就不满足要求了。

  • 缩点: 若将每一个边双连通分量看为一个点,分量内部的边被省略,只留下出去的边。

例如原图:
将其缩点后得到:

其中括号括起来的部分是在同一个边双连通分量的意思。

这样又形成了一个树结构,在之后很有用。

  • 点双连通(2-connected:两点之间存在两条无重复中间结点的路径;删除任其他点,两点仍然联通。

  • 点双连通分量(2-connected component极大的、而且点两两之间双联通的子图。

  • 割点 cut-vertex 一种点。删除这个点,连通分量数量会增加。


4.一些性质

性质一:每个边双连通分量上的边集至少有一个圈起所有结点的环。

如果有这样的环的话则一定是一个边双连通分量。

否则一定会有一条孤立的边,导致这条边任意左边的结点和任意右边的结点两两不边双联通。(即左边取一点 \(x\),右边取一点 \(y\),则 \(x \to y\) 的两条路径一定都会经过这条边,不满足边双联通分量定义了)

性质二:一个点总是恰好属于一个边双连通分量。

这里,我们使用反证法:假设有一个点属于多个边双连通分量。

前面说过,每一个边双都至少是一个环。而我们这里也只考虑环,即将其他的连接环上两结点的杂边我们不考虑了。(因为如果两个点在现有的图上连通,则再加几条边仍然联通)

这时我们可以注意一下每一个点的度数,显然 任意一个环 对 环上的任意一个点 的度数的贡献都为 \(2\)

因此任意一个点的度数一定是偶数。根据小学奥数一笔画问题,可以知道这时候任意两点一定存在一条经过所有边的路径。

欸?经过所有边?这些边扯开不就是一个环了吗?

因此,这些边双连通分量都违反了极大的原则,最终都可以通过合并变成一个边双连通分量,矛盾。

性质三:不属于任意边双连通分量的所有边,全部都是桥

可以根据定义得到。

因此我们可以知道,找桥和找边双连通分量是两个有着非常直接联系的一个工作

性质四:缩点之后,桥正好将这些点拼成了一个树的结构。

显然这些“点”构不成“环”,同理根据性质二的推导过程。

而显然这些“点”连通,因为我们默认图是连通的。如果不连通,可以拆成几个连通分支。

无环连通图,简称树。

这个树在后面会被称作 bridge tree。

性质六:边双连通满足传递性。

传递性:若 \(A\)\(B\) 边双连通,\(B\)\(C\) 边双连通,则 \(A\)\(C\) 仍然边双连通。

显然,\(A\)\(B\) 有至少 \(2\) 条不相交路径,\(B\)\(C\) 也有两条不相交路径,则 \(A\)\(C\) 就一定可以选出两条不相交的路径。

可以画图自行理解。

5.Tarjan 找边双算法

这种算法主要是使用深搜树来解决问题的。

这里介绍的思路虽然相同,但是实现的方法略有不同

Tarjan 同学本人使用的存储点的方式,而这里介绍的算法基于了并查集的操作。

前面说过,并查集有时候比不上栈,但是在加上 启发式合并 的时候可以视为 \(O(1)\)

优点

  • 线性时间复杂度:Tarjan算法的时间复杂度为 O(\(V\)+\(E\))(顶点数+边数),在处理大规模图时效率显著。

  • 空间效率高:算法仅需维护 dfn(深度优先编号)和 low(可回溯的最小编号)两个数组。

  • 无需预处理:在DFS过程中动态维护信息,适合动态图的在线处理需求。

缺点

  • 递归实现的风险:递归深度过大时可能导致栈(系统栈)溢出。

使用场景:在将边双连通分量分量缩成点后,就会成为一棵树。这时可以将问题转换到树上。

思路

我们现在做几个断言:

断言一:back edge 永远也不会成为 bridge

前面提到过,back edge 是连接祖先和子孙的一条边,其肯定构成了一个环(一条回边 \(+\) 祖先到子孙的树边)。故绝对不是 bridge。

断言二:\(u\) 能通过子树到达 \(dfn\) 更小的点,则 \(u\) 不是某边双连通分量的最高点,否则 \(u\) 与父结点之间为桥。

注意到到达 \(dfn\) 更小的点,相当于到达更加上面的点,也就是会经过若干条(\(\ge 1\))条回边。

而回边连起了一个个环,因此这个点也会在环里面(有点类似当一个点 \(u\) 到达了一个 \(> u\) 的点 \(v\),但是 \(v\) 一跳回到了 \(< u\) 的位置 \(w\),则易知 \(w < u < v\),显然 \(u \in [w,v]\)。而这个区间就正好是回边形成的环的区间),则这个点不是边双连通分量的最高点。

再看如果不能到达更小的点了,则说明这个点向下不会再有往更加上面的点,因此上面的点和这个点的边双连通分量无关,即这个点就是最高点(则到父结点为 )。


因此我们记录一些新的数组:p[i] 表示 \(i\) 号结点的子树中的结点,能通过回边到达的最小 dfn 值。

显然一开始 p[i] = dfn[i](自己一开始就是在自己的位置,类似单位元),但是在之后循环连接点的时候就会趁机更新。

性质五:如果一个点到父结点的边为桥,则该点就是其边双连通分量的最高点。且循环完全部连接点后,一定会得出p[i] = dfn[i]

这个性质就是由上面的断言二推导而成。


设目前循环到了 \(v\)\(u\)\(v\) 之间有一条边:

\(v\) 已经访问过了,需要尝试将 p[u] 设为 dfn[v]

因为 \(v\) 已经被访问过,则说明这条边是回边,而此时 \(u\) 可以到达 \(v\),则需要尝试将 p[u] 设为 dfn[v]

然而,为什么不是试图改为 p[v] 呢?

仔细审题!\(v\)\(u\) 的祖先!(这条边是回边,而回边连接着祖先和子孙)

既然已经访问过了,那不妨设从 \(v\) 一直走到 \(u\) 中途经过的点依次为 \(x_1,x_2,···,x_k\)。显然 \(x\) 里面的数也一定是 \(u\) 的祖先。

如果试图改为 \(p_v\)则有一定的概率会错误。

因为 \(p_v\) 的子树集合不和 \(p_u\) 相同,则 \(p_v\) 很有可能是从某一个 \(x\) 中的点得到的。

\(x\) 中的任意一个点都是 \(u\) 的祖先!则 \(p_u\) 很有可能通过 \(p_v\) 得到不该得到的更小结果。

因此,只能试图改为 \(dfn_v\)。而这是绝对正确的。


\(v\) 还未被访问过,需要尝试将 p[u] 设为 p[v]

显然 \(v\)\(u\) 的儿子,可以大胆的选用最优的值更新。


实现

也许你已经猜到了对于哪个数组使用并查集的方法了,没错,这个数组就是 \(p\) 数组。

这里的循环方法和正规的一样,只是获得边双连通分量的方法不一样。

如果想认识一种新的做法,就往下看。


就是类似并查集的方法,到最后,每一个点的 \(p\) 值都指向其边双连通分量的最高点。(显然需要使用 \(find\) 函数更新一下)。

而且 \(p\) 数组的初始值和并查集中的 \(fa\) 初始值一样,即 \(\forall 1\le i \le n,low_i=i\)

模板题

补充说明一下,dfn 数组也称时间戳数组。

#include <bits/stdc++.h>
using namespace std;
const int N = 500010;

int n, m; // 结点数和边数
vector<int> v[N]; // 邻接表存储图
int p[N], dfn[N]; // Tarjan算法核心数组:low记录回溯值,dfn记录时间戳
int num[N], cnt; // num将时间戳映射回结点,cnt为时间戳计数器

// 并查集的路径压缩查找(此处low数组被复用为并查集父结点数组)
int find(int x) {
    if (p[x] == x) // 找到根结点
        return x;
    return p[x] = find(p[x]); // 路径压缩
}

vector<int> ans[N];  // 存储每个边双连通分量的结点

// tarjan找边双连通分量算法

void tarjan(int u, int pre) {
    dfn[u] = ++cnt; // 分配时间戳
    num[cnt] = u; // 记录时间戳对应的原始结点编号
    p[dfn[u]] = dfn[u]; // 初始化low值为当前时间戳(关键点)
    for (auto i : v[u]) { // 遍历所有的连接点
        if (!dfn[i]) { // 未访问的子结点
            tarjan(i, u);
            // 更新当前结点的low值为子树最小low值
            p[dfn[u]] = min(p[dfn[u]], p[dfn[i]]);
        } else if (i != pre)   // 处理回边(非父结点)
            p[dfn[u]] = min(p[dfn[u]], dfn[i]); // 用回边指向结点的时间戳更新low值
        else // 父结点特殊处理(避免重复访问)
            pre = 0;     // 确保父边只处理一次
    }
}


int main() {
    cin >> n >> m;
    // 建图
    for (int i = 1; i <= m; i++) {
        int x, y;
        cin >> x >> y;
        v[x].push_back(y);
        v[y].push_back(x);
    }
    // 初始化low数组为结点自身(此时low数组作为并查集父结点数组)
    for (int i = 1; i <= n; i++)
        p[i] = i;
    // Tarjan
    for (int i = 1; i <= n; i++)
        if (!dfn[i])
            tarjan(i, 0);
    // 将结点归类到边双连通分量
    for (int i = 1; i <= n; i++)
        ans[find(i)].push_back(i);  // find(i)得到分量根结点
    // 统计分量数量
    int cnt = 0;
    for (int i = 1; i <= n; i++)
        if (ans[i].size())
            cnt++;
    // 输出结果
    cout << cnt << endl;
    for (int i = 1; i <= n; i++) {
        // 跳过无效条目:空分量或非根结点
        if (!ans[i].size() || p[i] != i) continue;
        cout << ans[i].size() << " ";
        // 注意此处输出的是num[j]而非j,因为存储的是时间戳编号
        for (auto j : ans[i])
            cout << num[j] << " ";  // 将时间戳转换为原始结点编号
        cout << endl;
    }
    return 0;
}

可以发现,代码中绝大的处理部分都是使用的 dfn[]num[]。所以在之后我建议在处理问题的时候使用树上的结点编号,并抛弃原结点编号。

割边求法:显然,不在任何一个边双连通分量里的边都是割边。

CF118E Bertown roads

问题

给出一个 \(n\)\(m\) 边的无向连通图,现在需要为这个图的每条边定向,使这个图成为一个强连通分量。

强连通分量: 一个极大的有向图,使得其中的点两两可以互相到达。

思路

显然,当这个图存在,则一定无解(这个桥的左边右边怎样都不能相互到达)。

所以,这个图现在是一个边双连通分量。 即所有的点都在一个环上面。

不妨再次使用深搜树的视角来解决问题。

不妨偷换概念(这里的表达似乎不太恰当,就是将原图上的结点变为树上的结点):我们需要构造一种方案,使得定向之后,每一个点都可以有一种方法到达根结点,而根结点可以到达每一个点。

不难给出构造:将所有的树边的方向都设为从父亲到儿子,将所有回边的方向都设为从子孙到祖先。

于是此时深搜树上的点都可以通过树边+回边到达根结点,然后再从根结点通过树边到达所有的点。

所以,只要图是一个边双连通分量,就一定可以根据构造给出一种方案。

实际上,这种构造方法真的很有用,可以作为在比赛中不很容易想到做法的一道奇妙题。所以这种构造方法需要记住。


总结一下流程:

  • 跑 tarjan 边双连通分量。这是为了通过边双连通分量的数量判桥。

  • 转化为深搜树。将图化为深搜树,注意还要保留一条边的输入顺序的编号。

  • 构造。考虑每一条边的属性,并对其定向。

P2860 [USACO06JAN] Redundant Paths G

问题

给出一个无向连通图,求至少添加几条边才可以使其变成边双连通分量。

思路

这里就不说思考过程了,只需要记住:深搜树和 bridge tree 都是一种考虑图论问题的新视角。

因为考虑深搜树发现不能做,因此考虑使用 bridge tree。

显然,bridge tree 可以以任何一个边双连通分量做根结点。

于是我们先跑一遍 tarjan 算法,然后从原图中剥离出所有的边双连通分量,剩下的边和边双连通分量就可以组成 bridge tree。

于是我们的任务就变成了:在 bridge tree 上面添加最少的边,使得这个 tree 变成一个边双连通分量。

结论1:bridge tree 的叶结点一定参与连边。

如果这个叶结点不参与连边,则不管上面的结点怎么连也连不到这个点上,则这个点一定孤立。

则一定不能形成边双连通分量。

结论2:设叶结点的数量为 \(k\),则只需要连 \(\lceil \frac{k}{2} \rceil\) 条边即可。

其中 \(\lceil x \rceil\) 的意思是向上取整。

不妨使用数学归纳法,枚举 \(k\)

\(k=0\):一棵树不可能只有 \(0\) 个叶结点。

\(k=1\):一棵树不可能只有 \(1\) 个度数为 \(1\) 的结点。

\(k=2\):设树的规模为 \(n\),且除去这两个度数为 \(1\) 的点的其他所有的点的度数和为 \(x\),显然 \(x\ge 2(n-2)\)(因为其他点的度数一定大于等于 \(2\)).

则可以通过所有点的度数总和列方程:\(2(n-1)=2+x\),得到 \(x=2*(n-2)\)。这意味着什么?

没错,这说明这棵树中有两个结点的度数为 \(1\),剩下 \(n-2\) 个结点的度数均为 \(2\)。那这是什么?

这说明,这棵“树”是条链。

显然,这时候我们只需要多加一条连接链的两端的边即可。

所以这时候是满足要求的。

\(k=3\):则一定存在一个 \(r\) 点满足点的度数 \(>1\)

将这个点变为根结点。

所以, \(r\) 至少有两个不同的子树,又至多有三个不同的子树。

不管有多少个子树,我们都只需要选最左边的叶结点连上最右边的叶结点即可。显然这两个点一定在不同的子树里面。

然后,再将中间的叶结点连上根结点 \(r\) 即可。

所以,此时是可以使用 \(2\) 条边满足要求的。

所以,这个结论在 \(k=3\) 时仍然成立。

\(k\ge 4\)

到了我们最喜欢的归纳环节。

我们仍然以 \(r\) 为根结点。

所以, \(r\) 至少有两个不同的子树。

我们选任意两个点(分别在 \(r\) 的两个不同的子树内)进行连边,点的数量 \(-2\)

然后,我们将这两个点连边所形成的环(也就是边双连通分量)重新缩点,然后重新变回了一棵树

然后我们再选一个 \(r\),将 \(r\) 设为根结点,然后选任意两个点(分别在 \(r\) 的两个不同的子树内)进行连边……

就这么一直删除,最终一定只会剩下 \(2\) 个叶结点或 \(3\) 个叶结点。,而这两种情况都是满足结论的。

而连边的过程中,每一次叶结点的数量 \(-2\),连边的次数就会 \(+1\)。同样满足结论。

因此,结论得证。


既然有了这个结论,我们只需要算出缩点之后 bridge tree 的叶结点的个数 \(k\),最终的答案就是 \(\lceil \frac{k}{2} \rceil\)

如果需要构造的话,也同样很简单。按照上述过程即可,不过可能需要分类讨论奇偶性。

于是这道题就做完了。

虽然代码的核心在于缩点,但是思路的核心还是在于结论的证明。


顺便说一下怎么实现缩点。

我们只需要遍历原图中的每一条边,然后判断两个是不是在一个边双连通分量之内即可。

如果在同一个边双连通分量里面,则不需要连边。

如果不在同一个里面,则将两个结点属于的边双连通分量的编号连边。

但是,显然很有可能缩点之后会出现重边,重边对这道题的影响很大(会累积点度数),必须要判断一下重边。

使用 set 判断重边即可。


我们来梳理一下,通过这两道题我们知道了怎样的性质。

  • 任意一个无向连通图都可以通过将边定向变为一个强连通分量。

  • 对于任意一个无向连通图,当需要将其化为一个边双连通分量的时候,可以通过 bridge tree 来处理。

同时,还有两个思考方式:

  • 深搜树

  • 桥树(bridge tree)

就此,边双连通分量的内容完毕。

6.Tarjan 找点双连通分量算法

实际上,点双和边双是有些相似的,而且还比边双更加地常用。但是实际上写起来又有一些奇怪。

根据定义可以发现,是点双连通分量的图一定也是边双连通分量。(直观理解,两条路径没有相同的中间结点,就必定不会出现相同的中间结点点对,也就不会有同样的边)

同理,不是边双连通分量的图也一定不是点双连通分量。


极端情况: 一个连通块可能是只有一或二个点的,那么这个算不算点双连通分量?

这个问题一直很有争议,直到现在还是没有准确的答案。

在一般情况下,我们都将两个点的连通块视为点双联通分量。可能也有一些理论不承认这个说法,但是对于下面将要介绍的方法来说,如果这种极端情况不被承认的话,方法就会截然不同。


不过,点双连通和边双连通还是有区别的,那就是:

性质七:点双连通不满足传递性。

即使 \(A\)\(B\) 点双连通,\(B\)\(C\) 点双连通,\(A\)\(C\) 也不一定点双连通。

有如下的例子:

如图,\(A\)\(B\) 的确点双连通,\(B\)\(C\) 也点双连通,但是 \(A\)\(C\) 无论如何也要经过 \(B\) 因此一定不点双联通。

所以,\(B\) 点可能既在 \({A,B}\) 的点双连通分量中,也在 \({B,C}\) 的点双连通分量中。

这就是点双连通分量很恼人的一个点:一个结点很有可能在好几个点双连通分量中,怎么求?


性质八:一个环上的点一定同时属于若干个点双连通分量。即不存在环上的若干个点属于某一个点双连通分量,而另一些点不属于这个点双连通分量。

没错,点双连通分量也与环有关。

因为从环上删除任意一个结点,剩下来的点仍然连通(会形成一条链),所以环上的任意一个点都不是割点。

所以一定不存在有一些点被孤立的情况,性质八成立。


性质九:相邻点双联通分量的公共点一定是一个割点。

首先说一下较为通俗的 相邻点双连通分量 的定义:两个相邻点双连通分量指的是这两个点双挨得非常近,而且中间没有其他的点双连通分量。

下证:相邻点双连通分量没有第二个公共点。

使用反证法,我们先假设有第二个公共点。

此时,\({A,B,D}\)\({B,C,D}\) 两个所谓的点双连通分量有两个公共点:\(B\)\(D\)

但是可以发现,删除 \(B\) 之后这个图仍然可以通过 \(D\) 连通,而删除 \(D\) 之后这个图仍然可以通过 \(B\) 连通!

所以这两个相邻点双连通分量可以合并成一个点双联通分量!

因此,这与极大矛盾,不成立。

那如果这两个点双连通分量有公共边但是没有公共点呢?

可以发现,这条公共边也是点双连通分量,所以这两个点双联通分量实则并不相邻!。矛盾了。

而继续考虑,这三个点双联通分量之间的公共点分别为这条“公共边”的两个端点。 仍然满足要求。

于是,这个性质是成立的。


算法主体思路

因为边双和点双差不多,所以考虑在 tarjan 求边双的算法基础上进行启发,由此得到点双的算法。

前面我们讲过的 tarjan (求边双)的本质是:试图从当前的点向下遍历,走树边。然后再通过某一条边回到自己的祖先或自己的位置,使用的是回边。从而形成一个环,从而形成两条无重复的路径,也就是形成了边双连通分量。

对于点双,我们的想法也是类似的。试图从当前的点向下遍历,走树边。然后再通过某一条边回到自己的祖先或自己的位置,使用的是回边。从而形成一个环,从而形成两条无重复的路径,也就是形成了点双连通分量。对于一个环一定是一个点双联通分量这件事,正确性不言而喻。

虽然我们问的是关于点的问题,但是当 tree edge 和 back edge 形成了一个环时,则可以立即断定环上的点是属于同一个点双了。

而再次回想边双算法,发现算法是通过将某个边双的最高点设为该边双的“代表点”,然后发现一个环的时候,直接将这个环的最低结点合并到最高结点。 而我们是使用了类似并查集的思想解决了这种问题。

点双的想法恰恰类似。也是对于点双设定一个代表(这里就不再是最高点了!我马上就会解释),当发现一个环的时候,将这个环的最低结点属于的点双合并到最高结点属于的点双。

但是点双的不同就出在“代表是什么”这个问题上。因为众所周知,割点可以是多个相邻点双联通分量的公共点。 如果单单选定最高点为代表的话,很有可能产生 多个点双连通分量被看成同一个点双 的“尴尬瞬间”。


于是,大体总结一下上文。我们发现通过找环来合并 边双联通分量 的方法完全适用于合并 点双联通分量,但是在设定代表方面又遇到了新的困难。

通俗点讲,就是说:“我怎么对于每一个点双联通分量,使用一种通用的方法把它们分开,从而顺利地区分它们。”

还不通俗?举一个例子。就好像你一道题拼尽全力无法做出时,发现OJ上可以下载数据。于是你使用 下载数据 方法得到了所有的错误数据的输入输出,然后你很不要脸的决定 面向数据 编程,当输入为一个结果的时候输出一个特定的结果。但是各个数据之间难免出现一些小的相同点,你需要发掘其中的不同点,使得你的代码对于所有已知数据的输出都是正确的。而现在我们遇到的问题就是,如何发掘各个点双连通分量的不同点。

这个问题以后我们再来解决。别说我是制造悬念,吸引读者往下读。


因为边双算法有一个环节是找桥,也称作割边。则不妨我们在点双算法中找割点。

注意到一个性质:当一个点的某一个子结点(默认已经将图看成了dfs树)的子树中的所有点都只能通过回边返回到这个结点或更低的位置,则这个点一定是割点。正确性不言而喻。

但是这个点也属于这个使其变成割点的子树的点双连通分量。

而对于没有让它变成割点的子树,其点双连通分量也会包含它以及 其构成的环(因为这个子树没有使这个点变成割点)上的更高的点。

这里,一个颜色的点双连通分量的代表点为对应的颜色。

可以发现,一个点双连通分量中的除割点以外的最高点为该点双连通分量的代表(因为点双连通分量是由一个割点和一个子树构成)。

可以证明,这种代表方法一定是正确的。

#include <bits/stdc++.h>
using namespace std;
const int N = 500010;
int n, m;                  // 顶点数和边数
vector<int> e[N];          // 邻接表存图
int cnt = 0, dfn[N], num[N]; // dfn记录每个顶点的访问顺序,num是dfn的反向映射
int p[N];                   // 并查集数组,用于合并点双连通分量

int find(int x) {          // 并查集查找,带路径压缩
    if (p[x] == x) return x;
    return p[x] = find(p[x]);
}

int prv[N], nxt[N];        // 记录DFS树中每个结点的前驱和后继

// DFS遍历,x是当前结点,pr是父结点,返回当前连通分量的最小根
int dfs(int x, int pr) {
    dfn[x] = ++cnt;        // 分配时间戳
    num[cnt] = x;          // 记录该时间戳对应的顶点
    p[dfn[x]] = dfn[x];    // 初始化并查集父结点为自身
    prv[dfn[x]] = dfn[pr]; // 当前结点的前驱是父结点
    nxt[dfn[pr]] = dfn[x]; // 父结点的后继是当前结点
    
    // 遍历邻接点
    for (auto i : e[x]) {
        if (!dfn[i]) {     // 树边,递归访问子结点
            p[dfn[x]] = min(p[dfn[x]], dfs(i, x));
        } else if (i != pr && dfn[i] < dfn[x]) { // 回边,处理祖先结点
            // 更新当前结点能连接到的最小祖先
            p[dfn[x]] = min(p[dfn[x]], nxt[dfn[i]]);
        }
    }
    return p[dfn[x]];      // 返回当前连通分量的根
}

vector<int> ans[N];        // 存储每个点双连通分量的结点

void Tarjan() {
    // 遍历所有未访问的顶点,进行DFS
    for (int i = 1; i <= n; i++)
        if (!dfn[i]) dfs(i, 0);
    int cnt2V = 0; // 统计点双连通分量数量
    // 统计根结点数量(非DFS树根的连通分量)
    for (int i = 1; i <= n; i++)
        cnt2V += (p[i] == i && prv[i] != 0);
    // 将结点归类到对应的连通分量
    for (int i = 1; i <= n; i++) {
        ans[find(i)].push_back(i); // 合并到根结点对应的分量
        if (find(i) == i && prv[i]) // 将前驱加入分量(处理割点共享)
            ans[i].push_back(prv[i]);
        // 孤立结点单独成一分量
        if (!prv[i] && !nxt[i]) cnt2V++;
    }
    // 输出结果
    cout << cnt2V << endl;
    for (int i = 1; i <= n; i++) {
        if ((p[i] == i && prv[i]) || (!prv[i] && !nxt[i])) {
            cout << ans[i].size() << " ";
            for (auto j : ans[i]) cout << num[j] << " ";
            cout << endl;
        }
    }
}

int main() {
    cin >> n >> m;
    for (int i = 1; i <= m; i++) {
        int x, y; cin >> x >> y;
        e[x].push_back(y);
        e[y].push_back(x);
    }
    Tarjan();
    return 0;
}

下面开始讲割点求法。

前面我们讲过,在深搜树中,只要一个点的任何一个 子结点 的子树里面所有的点 都 不能通过回边 回到 这个点的父亲或祖先(为了便于阅读,使用空格断了一下句子),这个点就是割点。

考虑枚举这个子结点的子树,从而尝试判断父亲是割点。设这个子结点编号为 \(x\),再次强调一遍,这里采用的是深搜序。

首先一定需要保证 \(find_x = x\),要不然这个父亲就不会因为这个子结点成为割点了。注意 \(find_x\) 是查找的 \(p_x\) 的最终值,也就是点双连通分量的次高点也是代表点。

还要保证存在这个父亲。即 \(prv_x\not = 0\),要不然你即使算出来了父亲是割点你也无法找到这个父亲。

然后开始按照父亲分类讨论:

  • 如果这个点不是根结点的儿子,则父亲就一定是割点。

  • 如果这个点是根结点的儿子。

    • 如果根结点只有一个儿子。那删了根结点和没删没啥区别啊!你只是删掉了一个头啊,剩下还是一个连通块。

    • 如果根结点不止有一个儿子。那删了是真的出事了,会分成好多个连通块。

为什么要按照父亲分类讨论呢?因为如果父亲是根结点,把父亲删掉后,删掉的点的上面就不会可能出现一个连通块了。(显然根结点没有父亲)

void dfs(){};// tarjan求点双连通分量算法,模板见上文
void Tarjan() {
	for (int i = 1; i <= n; i++)
		if (!dfn[i])
			dfs(i, 0);// 跑 tarjan
	for (int i = 1; i <= n; i++)
		if (find(i) == i)// 如果这个点的父亲可能因为这个点成为割点
			if (prv[i] != 0)// 首先你需要确定这个父亲不是“黑户”
				if (prv[prv[i]] != 0 || nxt[prv[i]] != i)// 如果这个点不是根结点的儿子 或 根结点不止有一个儿子
//这里有一个很妙的写法,即使用 nxt 顺便判断一下根结点是不是只有一个儿子。
//因为根据算法过程,最终 nxt 存储的一定是一个点的最后一个子结点。
//根据根结点的定义,如果 i 是根结点的一个儿子,find(i) == i (根结点没有父亲,点双连通分量的次高点永远也不会是根结点,即 find(i) != i 永远也不会成立)和 prv[i] != 0(根结点显然不会成为“黑户”) 一定会成立
//所以,根结点的儿子一定可以闯到 nxt[prv[i]] != i 这一关。
//如果根结点只有一个子结点,则 nxt[prv[i]] != i 永远也不会成立,根结点永远也不会成为割点。
//如果根结点有不止一个子结点,则除了根结点的最后一个子结点以外,一定会有另外一个子结点把根结点变成割点。因此,这种写法是正确的。
					f[num[prv[i]]] = 1;//记录割点,注意刚刚使用到的 find(i) 和 nxt 和 prv 都是使用的深搜序,需要使用 num 数组转回来。
	int x = 0;
	for (int i = 1; i <= n; i++)
		x += f[i];//bool 可以直接加到 int 里面,这里是记录割点的个数
	cout << x << endl;
	for (int i = 1; i <= n; i++)
		if (f[i])
			cout << i << " ";//输出每一个割点
}

7.强连通分量

接下来是一个大家可能都很熟悉的东西:强连通分量。

实际上,强连通分量有一种专门解决这个问题的算法:kosaraju 算法,还有 tarjan 算法。这里只介绍 tarjan 算法,因为有很多的实用性,例如前面使用 tarjan 算法求解点双和边双的问题。

这里的实现方法又和市面上的有一些区别,实际上这种方法(仍然是并查集写法)会更加好写一点。

强连通(strongly connected):在有向图中,两个点互相可以到达。(发现如果是无向图只需要使用并查集即可,但是有向图因为同时需要考虑两个方向就相对有些难搞)

比较需要注意的是:强连通是一个等价关系。 即强连通具有传递性。举个例子就可以理解一下:若 \(u,v\) 强连通(即存在 \(u \to v\)\(v \to u\) 的两条路径),且 \(v,w\) 强连通(即存在 \(v \to w\)\(w \to v\) 的两条路径),则显然 \(u,w\) 也强连通(因为 \(u \to v + v \to w = u \to w\)\(w \to v + v \to u = w \to u\),其中加号表示路径的拼接)。

这样的等价关系就比点双友好很多了,和边双也不相上下。因为强连通的等价关系交代了一件事:图可以类似边双一样,将图划分成几个不重复的的子图,使得这个子图中的点两两强连通,而不在同一个子图中的点都是不强连通的。

这些划分出来的子图就是强连通分量

强连通分量:(strongly connected component,简称 scc):极大的、点两两连通的子图。

没错,scc 就是这样的一个英文缩写。


下面开始讲算法思路,坐稳了!

不妨揣摩一下发明人的心理:既然这个人能够发明这么多的算法,而这些算法都以一个名字 tarjan 命名,就说明这些算法一定存在一些共同点。

回想一下边双和点双的思想,发现环都在它们找到双连通分量的过程中起到了较大的作用。于是考虑 tarjan 找强连通分量也如法炮制,使用环来寻找强连通分量。

发现一个很重要的点:一个有向的环一定是一个强连通分量。 正确性不言而喻。

不妨再次使用深搜树,并思考有向环在其中的意义。

因为图变成了有向图,所以深搜树中的树边和回边就真的成为了有向边。在深搜构造深搜树的时候,也要严格遵守边的方向。

考虑有向图下,深搜树的结构。

有一个不太好的消息就是:深搜树的边就不止有两种了。

说一下这几种深搜树的边:

  • 树边(tree edge):根据图片,显然就是父结点指向子结点。

  • 前向边(forward edge):祖先连向后代的边。手动模拟发现,这种情况是可能发生的:

  • 回边(back edge):后代连向祖先的边。也有一些人称其为“后向边”“返祖边”,因为为了便于理解,就取了和无向图深搜树一样的名字。

  • 横向边(cross edge):不是祖先、子孙的关系,而是两个点,深搜序大的指向深搜序小的。

    • 那么这时候就有同学会问了:为什么不是深搜序小的指向深搜序大的呢?

    • A:实际上,这和无向图的“横向边不存在”的原理是一样的。你如果是深搜序小的指向深搜序大的,则在深搜序小的点 被访问到的时候 深搜序大的还没被访问到,那你为什么不直接走这条边,而是使这条边变成横向边?而深搜序大的指向深搜序小的是不可能成为前面提到的三种边的,因为当搜索到这条边的起点的时候,终点已经被访问到了。

这时候又有人问了:这么多种边,咋做?

我们容易发现一些让我们感到 cheerful 的事情:前向边完全就对强连通分量没有任何的用处。

因为你这个祖先和后代,你的祖先可以通过前向边走到后代,又可以通过树边走到后代,发现这种对答案不会有贡献。所以前向边是没有意义的。

但是回边的话呢,我们非常的喜欢它。因为我们可以看到有向环的一种架构:一堆树边 + 一条回边 = 一个有向环。

于是我们在这里设定一下 \(p\) 的意义:\(p_i\) 表示 \(i\) 最高可以通过边到达的点的编号。

发现当没有横向边的时候,其和边双连通分量的流程是一样的(使用一堆树边 + 一条回边 = 一个有向环这个结论)。考虑横向边会对强连通分量带来什么影响。


首先,我们可以得到:\({1,2,3}\) 是在一个强连通分量里面,\({4}\) 单独成为一个强连通分量。我们从中标出一些容易得到的 p 值。

然后考虑 8 号结点的 p 值,发现其为 6,但是 find 一下就可以变成 5。

然后我们正式考虑处理横向边。发现 10 的 p 值应该是 5,我们推测:cross edge 的起点的 low 值可以由 终点的 low 值 更新而来。而 9 的 p 值也就是 5 了。

所以思路可以将图分成以下几个强连通分量:

但是我们思考一个问题:如果 7 -> 4 有一条横向边,则结论是错误的。

所以得出:有些 cross edge 是可以用来更新的,但是有些 cross edge 是不能用来更新的。

那到底啥时候能更新,啥时候不能更新呢?


对于每一条 cross edge(u -> v),有:

  • \(v\) 所在的组的代表已经回溯了,不采用。

  • \(v\) 所在的组的代表还没有回溯,就采用。

为什么呢?

因为走边的目标是为了从这个点回到这个点的祖先,而 \(v\) 已经回溯了,就说明已经不能从 \(v\) 到达祖先了。

于是直接记录 f 表示一个点是否回溯了即可。

#include <bits/stdc++.h>
using namespace std;

const int N = 500010; // 最大结点数
int n, m;             // 结点数、边数
vector<int> v[N];     // 邻接表存图
int cnt = 0;          // DFS序计数器
int dfn[N];            // dfn[x] 表示结点x的DFS访问顺序
int num[N];            // num[cnt] = x,记录第cnt个访问的结点是x
int p[N];              // 并查集父结点数组,用于合并同一SCC中的结点
int f[N];              // f[i]标记dfn为i的结点所在的强连通分量的最高点是否已经回溯

// 并查集查找函数,带路径压缩
int fnd(int x) {
    if (p[x] == x) return x;
    return p[x] = fnd(p[x]);
}

// DFS遍历,返回当前结点所在SCC的最小dfn值
int dfs(int x) {
    dfn[x] = ++cnt;        // 记录x的访问顺序
    num[cnt] = x;          // 记录该顺序对应的原始结点
    p[dfn[x]] = dfn[x];    // 初始化当前结点的父结点为自身

    for (auto i : v[x]) {  // 遍历所有邻接结点
        if (!dfn[i]) {     // 如果邻接结点未被访问
            // 递归搜索,并尝试用子结点的最小dfn更新当前结点的父结点
            p[dfn[x]] = min(p[dfn[x]], dfs(i));
        } else if (!f[fnd(dfn[i])]) { // 邻接结点已访问且不在已确定的SCC中
            // 如果邻接结点属于未处理的SCC,尝试更新当前结点的父结点
            p[dfn[x]] = min(p[dfn[x]], dfn[i]);
        }
    }
    f[dfn[x]] = (p[dfn[x]] == dfn[x]);
    return p[dfn[x]]; // 返回当前结点的父结点(最小dfn值)
}

vector<int> ans[N]; // 存储每个scc的结点

void Tarjan() {
    // 对所有未访问的结点启动dfs
    for (int i = 1; i <= n; i++)
        if (!dfn[i]) dfs(i);

    // 统计强连通分量总数
    int scc_cnt = 0;
    for (int i = 1; i <= n; i++)
        scc_cnt += (p[i] == i); // 根结点数即为SCC的数量

    cout << scc_cnt << endl;

    // 将同一scc的结点归类到ans数组中
    for (int i = 1; i <= n; i++)
        ans[fnd(i)].push_back(num[i]); // fnd(i)找到i所属的SCC根结点

    // 对每个scc的结点排序,并整体按字典序排序输出
    for (int i = 1; i <= n; i++)
        sort(ans[i].begin(), ans[i].end());

    sort(ans + 1, ans + n + 1);

    // 输出结果
    for (int i = 1; i <= n; i++)
        if (!ans[i].empty()) {
            for (auto j : ans[i]) cout << j << " ";
            cout << endl;
        }
}

int main() {
    cin >> n >> m;
    for (int i = 1; i <= m; i++) {
        int x, y;
        cin >> x >> y;
        v[x].push_back(y);
    }
    Tarjan();
    return 0;
}

8.欧拉路径(或者是回路)

先讲一下定义:

  • 欧拉路径是一个图上从一个点到另一个点的路径,使得每一条边都被走过了。

  • 欧拉回路是一个图上从一个点回到自己的环,使得每一条边都被走过了。

我们不妨先看一下这道题:T415964。也就是边的深搜。

这种方法实际上没啥用,但是有助于对后面的欧拉路径做铺垫。

容易发现,边的深搜也应该是“不撞墙不回头”的,即只有当当前的结点没有任何没有走过的边的时候,才能回溯。

为了便于理解,这里不妨举个例子:

为了判断那些边被走过了那些边没被走过,不妨使用一个 cur[i] 表示点 \(i\) 当前的最早的没有走过的边的编号(即这条边对应的点为 v[i][cur[i]])。

这里的 cur 思想,就是以后欧拉路径的一种思想。


实际上,欧拉路径/回路可以视为整个图论的起源。

有一个典型的例子就是哥尼斯堡七桥问题,河岸已经被抽象成了点,桥已经被抽象成了边。

发现无论怎么走,都没法将所有的边走恰好一遍。在后面确实被证明是无法走完的。

这是一个数学故事,也是欧拉路径/回路的来源。


不如将图分为无向图和有向图来分别考虑。

无向图

我们小学奥数学过一笔画问题,这可以用来判断一个无向图是否存在欧拉路径/回路。

首先,图必须联通。(图都不连通,怎么一笔画啊?)

使用以下一笔画问题的结论,就可以判断是否存在欧拉路径/回路:

  • 如果度数为奇数的点有恰好 \(0\) 个,则有欧拉回路。

  • 如果度数为奇数的点有恰好 \(2\) 个,则有欧拉路径。

    • 而且欧拉路径一定是从一个度数为奇数的点出发,到达另一个度数为奇数的点。
  • 如果上面两个条件都不满足,则不存在欧拉路径/回路。

有向图

第一点:如果所有的点的入度和出度都是相等的,则该有向图存在欧拉回路。

因为每一个点经过一次都必须是一个一进一出,而且这是回路,入度和出度都相等才行。

显然可以得到,如果是欧拉路径的话,对入度和出度的关系就没有那么高了。因为起始点只需要是一出,终点只需要是一进即可。

第二点:如果所有的点中,入度比出度多 \(1\) 的点有恰好一个,出度比入度多 \(1\) 的点也恰好有一个,其他相等:则该有向图存在欧拉路径。

这里的原因在上面已经讲过了。

注意,欧拉路径的起始点是出度比入度多 \(1\) 的点,终点是入度比出度多 \(1\) 的点

第三点:如果前面两个都不满足,则该有向图不存在欧拉路径/回路。

显然。


这样就可以判断完毕了。

算法思路

可以看成,无向图和有向图的判断路径/回路的方法虽然不一样,但是找路径/回路的过程是一模一样的。

算法的思想很简单:先确定一个路径/回路,然后尝试在基础上扩展路径。

我们举一个很简单的例子:奥运五环。

可以证明,里面一定存在一个欧拉路径。

我们先从最左边的点出发,一不小心,走掉了第一个环回到了自己。但是实际上还有四个环没走。

于是考虑扩张路径。注意到第一个环和第二个环存在交点,于是考虑从这个交点扩张。又不小心,又走掉了第二个环。至少,路径扩张成功了一部分。

于是就这么扩张即可。最终得到了答案为:

注意这里的实现使用的是深搜


是不是有一点边深搜的感觉了?举个例子。

假设从 \(1\) 号结点开始。走过的边不能再走一遍。

一号结点不小心走了 \(1 \to 2 \to 3 \to 4 \to 5 \to 1\) 回到了自己。

然后就是经典边深搜:\(1\) 已经无路可走,因为 \(5\) 也无路可走,于是回溯:\(1 \to 5 \to 4\)

发现 \(4\) 有路了!

假设一路走 \(4 \to 3 \to 7 \to 6 \to 8 \to 4\) 回到了自己。

然后发现 \(4\) 无路可走。回溯到 \(8\)。发现 \(8\) 有路了。

然后 \(8 \to 6 \to 7 \to 8\)。所有的边都走完了,然后就一路回溯到 \(1\)

但是我们会发现有一些难以统计路径:在搜索中,\(1 \to 2 \to 3 \to 4 \to 5 \to 1\) 构成一个环,\(4 \to 3 \to 7 \to 6 \to 8 \to 4\) 构成一个环,\(8 \to 6 \to 7 \to 8\) 应该构成一个环。但是,当已经有 \(1 \to 2 \to 3 \to 4\) 的时候就已经需要跳到 \(4 \to 3 \to 7 \to 6 \to 8 \to 4\) 这个环了。

于是考虑常见套路:在每一个点回溯的时候再记录下这个点的编号。注意,这样得到的是欧拉路径的反路径

可以自己模拟一下图的深搜过程,最终得到的应该是 \(1,5,4,9,6,7,9,6,7,3,4,3,2,1\),反转过来正好就是符合要求的欧拉回路(也可以称作欧拉路径)。

于是就可以得到答案序列 \(1 \to 2 \to 3 \to 4 \to 3 \to 7 \to 6 \to 8 \to 6 \to 7 \to 8 \to 4 \to 5 \to 1\)

代码其实很好写。

实际上我们可以使用栈来存储编号。

模板代码

无向图的代码:

//模板题:P2731 
#include <bits/stdc++.h>
using namespace std;
int n = 500, m;
const int N = 2010;
int dis[N], cur[N]; // dis 用于记录每个顶点的度数,cur 用于记录当前顶点的邻接边遍历位置 
vector<pair<int, int> > v[N]; // 邻接表
stack<int> stk; // 用于存储欧拉路径的栈 
bool f[N]; // 标记边是否被访问过 
 
// Hierholzer 算法实现,用于寻找欧拉路径/回路 
void hierholzer(int u) {
    // 遍历当前顶点的所有邻接边 
    for (int &i = cur[u]; i < (int)v[u].size();) {
        if (f[v[u][i].second]) { // 如果边已经被访问过,跳过 
            i++;
            continue;
        }
        f[v[u][i].second] = 1; // 标记边为已访问 
        hierholzer(v[u][i++].first); // 递归访问邻接顶点 
    }
    stk.push(u); // 将当前顶点压入栈中 
}
 
int mi = 1e9; // 记录图中最小顶点的编号 
 
// 判断是否存在欧拉路径/回路,并输出结果 
bool euler() {
    // 对每个顶点的邻接表进行排序,确保按顶点编号从小到大遍历 
    for (int i = 1; i <= n; i++)
        sort(v[i].begin(), v[i].end());
 
    int st = 1e9, cnt = 0; // st 是欧拉路径的起点,cnt 是奇数度顶点的数量 
    // 统计奇数度顶点的数量,并找到最小的奇数度顶点作为起点 
    for (int i = 1; i <= n; i++)
        if (dis[i] % 2)
            cnt++, st = min(st, i);
 
    if (!cnt) // 如果没有奇数度顶点,说明存在欧拉回路,起点为最小顶点 
        st = mi;
 
    // 如果奇数度顶点的数量为 0 或 2,说明存在欧拉路径/回路 
    if (cnt == 2 || cnt == 0) {
        hierholzer(st); // 调用 Hierholzer 算法寻找欧拉路径/回路 
        while (!stk.empty()) // 输出欧拉路径/回路 
            cout << stk.top() << endl, stk.pop();
        return 1;
    }
    return 0; // 否则,不存在欧拉路径/回路 
}
 
int main() {
    cin >> m; // 输入边的数量 
    for (int i = 1; i <= m; i++) {
        int x, y;
        cin >> x >> y; // 输入边的两个顶点 
        v[x].push_back({y, i}); // 将边加入邻接表 
        v[y].push_back({x, i});
        mi = min(mi, x), mi = min(mi, y); // 更新最小顶点编号 
        dis[y]++, dis[x]++; // 更新顶点的度数 
    }
    if (!euler()) // 调用 euler 函数判断是否存在欧拉路径/回路 
        cout << "No"; // 如果不存在,输出 "No"
    return 0;
}

有向图的代码:

//模板题:P7771 
#include <bits/stdc++.h>
using namespace std;
int n, m;
const int N = 100010;
int dis[N], pos[N]; // dis 用于记录每个顶点的入度与出度的差值,pos 用于记录当前顶点的邻接边遍历位置
vector<int> v[N]; // 邻接表
stack<int> stk; // 用于存储欧拉路径的栈

// Hierholzer 算法实现,用于寻找欧拉路径/回路
void hierholzer(int u) {
	// 遍历当前顶点的所有邻接边
	for (int &i = pos[u]; i < (int)v[u].size();)
		hierholzer(v[u][i++]); // 递归访问邻接顶点
	stk.push(u); // 将当前顶点压入栈中
}

// 判断是否存在欧拉路径/回路,并输出结果
bool euler() {
	// 对每个顶点的邻接表进行排序,确保按顶点编号从小到大遍历
	for (int i = 1; i <= n; i++)
		sort(v[i].begin(), v[i].end());

	int st = 1, cnt0 = 0, cnt1 = 0; // st 是欧拉路径的起点,cnt0 是入度比出度多1的顶点数量,cnt1 是出度比入度多1的顶点数量
	// 统计入度与出度的差值,并找到起点
	for (int i = 1; i <= n; i++)
		if (dis[i] == 1) // 入度比出度多1,说明是欧拉路径的终点
			st = i, cnt0++;
		else if (dis[i] == -1) // 出度比入度多1,说明是欧拉路径的起点
			cnt1++;
		else if (dis[i] != 0) // 如果存在其他情况,说明不存在欧拉路径/回路
			return 0;

	// 如果满足以下条件之一,说明存在欧拉路径/回路:
	// 1. 所有顶点的入度等于出度(欧拉回路)
	// 2. 只有一个顶点的入度比出度多1,且只有一个顶点的出度比入度多1(欧拉路径)
	if ((!cnt0 && !cnt1) || (cnt0 == 1 && cnt1 == 1)) {
		hierholzer(st); // 调用 Hierholzer 算法寻找欧拉路径/回路
		while (!stk.empty()) // 输出欧拉路径/回路
			cout << stk.top() << " ", stk.pop();
		return 1;
	}
	return 0; // 否则,不存在欧拉路径/回路
}

int main() {
	cin >> n >> m; // 输入顶点数量 n 和边数量 m
	for (int i = 1; i <= m; i++) {
		int x, y;
		cin >> x >> y; // 输入边的两个顶点
		v[x].push_back(y); // 将边加入邻接表
		dis[y]--, dis[x]++; // 更新顶点的入度与出度的差值
	}
	if (!euler()) // 调用 euler 函数判断是否存在欧拉路径/回路
		cout << "No"; // 如果不存在,输出 "No"
	return 0;
}
posted @ 2025-02-05 09:58  wusixuan  阅读(69)  评论(0)    收藏  举报