图论知识总结

图论

参考

oiwiki 网上博客

LCA

性质

  1. \(LCA(A \cup B) = LCA(LCA(A), LCA(B))\)
  2. 一堆点集的LCA 等于其中 dfn 最大的和最小的点的 LCA

dfs序求lca

好写且 \(O(1)\),吊打欧拉序和倍增。

如果两个点 \(x\)\(y\) 不存在祖孙关系,那么 \(LCA(x,y) = fa(min(dfn_x, dfn_y))\)

我们钦定 \(dfn_x < dfn_y\) 这里的 min() 是指 \([dfn_x, dfn_y]\) 之间深度最小的点的编号。

考虑证明很简单,思考dfn是怎么来的即可。

如果存在祖孙关系,那么 \(LCA = x\),但是为了方便写,我们可以把两种情况统一起来,我们将dfn_x加一即可,这样加一之后不影响第一种情况,第二种情况的答案也可以算到。

代码:

int dfn[N], tim, st[21][N];
void dfs(int u, int pre) {
	st[0][dfn[u] = ++tim] = pre;
	for(int i = head[u]; i; i = edge[i].next) {
		int v = edge[i].to;
		if(v == pre) continue;
		dfs(v, u);
	}
}
int Min(int x, int y) { return dfn[x] < dfn[y] ? x : y; }
void init() {
	for(int i = 1; i <= 20; i++) 
		for(int j = 1; j + (1 << i) - 1 <= n; j++) 
			st[i][j] = Min(st[i - 1][j], st[i - 1][j + (1 << i - 1)]);
}
int lca(int x, int y) {
	if(x == y) return x;
	if((x = dfn[x]) > (y = dfn[y])) swap(x, y);
	int d = 31 ^ __builtin_clz(y - x); x++;
	return Min(st[d][x], st[d][y - (1 << d) + 1]);
}

实际实现不需要dep和fa数组,具体细节见代码。

这个做法的瓶颈在RMQ,如果用一些高科技搞RMQ可以做到 \(O(n)~O(1)\),但基本常数较大且不怎么好些。

树剖求lca

\(O(n)~O(logn)\) 常数小,代码也比较简单,可以替代倍增,但最好的还是dfs序。

树的直径

两种方法:两次dfs或者树形DP。推荐第二种,因为好写且支持找负边权的直径,树形DP又有两种方法,这里写一种好写的。

不过有意义的,两次dfs的结论也可以记住:

在都是非负边权的情况下,从树上任意一点 \(x\) 出发,到达的最远的点 \(y\) 一定是直径的一端。

具体证明很简单,使用反证法,分三种情况分别考虑

  1. \(x\) 就在直径上
  2. \(x\) 不在直径上,但是求得的路径 \((a,b)\) 与直径 \((z, w)\) 有交点。
  3. 没有交点。

DP代码:

#include<bits/stdc++.h>
using namespace std;
const int N = 1e4 + 10;
int n, dp[N], ans;
void chmx(int &x, int y) { x = max(x, y); }
vector<int> G[N];
void dfs(int u, int fa) {
	for(int v : G[u]) if(v ^ fa) {
		dfs(v, u);
		chmx(ans, dp[u] + 1 + dp[v]);
		chmx(dp[u], 1 + dp[v]);
	}
}
int main() {
	scanf("%d", &n);
	for(int i = 1, u, v; i < n; i++)
		scanf("%d%d", &u, &v), G[u].push_back(v), G[v].push_back(u);
	dfs(1, 0);
	printf("%d", ans);
	return 0;
}

动态维护直径

树的重心

性质

  1. 重心最多两个,如果有两个,那么肯定相邻。
  2. 以树的重心为根时,所有子树的大小都不超过整棵树大小的一半。(这个也是充要条件)
  3. 树中所有点到某个点的距离和中,到重心的距离和是最小的;如果有两个重心,那么到它们的距离和一样。(这个性质也挺好)
  4. 要是通过一条边把两棵树连起来,那么新的重心在原来两棵树的重心的路径上。(有助于动态维护重心)
  5. 在一棵树上添加或删除一个叶子,那么它的重心最多只移动一条边的距离

动态维护树的重心

证明: 先咕着。

树剖

长剖和实剖先不展开。主要就是重链剖分。
重链剖分及其擅长解决树上路径问题,他把树剖成了若干条重链,并且任意一条路径上最多 \(logn\) 条重链,这使得我们可以使用一些可以维护链上信息并且支持信息快速合并的数据结构来维护重链,并且树剖利用和拓展dfs序的性质,把链搞成一个区间,使得我们的维护更加的方便。
顺带着dfs序的性质,树剖对于子树信息的维护也很擅长。
具体算法内容就不赘述了,这里只做总结。

dsu on tree

也叫树上启发式合并,有些时候我们完全可以直接启发式合并,但是这个算法给了另一种启发式合并的思路,dsu on tree主要是对子树信息统计,具体算法很人类智慧,考虑我们要统计每个点的子树信息,并且只能用一个桶或者数据结构。

比如我们dfs目前到了节点 \(u\),我们会将 \(u\) 的儿子 \(v\) 一一递归下去,这样的话我们遍历完一个儿子就要清空桶,不然其他儿子的信息就不对,遍历完儿子之后我们再来统计 \(u\) 的信息,那么就有一个人类智慧,最后一个儿子遍历完不用清空,可以留下来给 \(u\) 用,这样就有了一个剪枝。再人类智慧的,我们可以钦定最后一个儿子是重儿子,这样剪枝最优。事实上,这已不仅仅是剪枝,而已经达到了 \(O(nlogn)\) 的复杂度了。

考虑证明:计算每个点会被统计几次,如果他在重链上,那么他的信息会被继承上去,不会重复统计,所以说一个点的统计次数就等于这个点到根的重链数量,也就是 \(O(logn)\),所以总时间复杂度 \(O(nlogn)\)

虚树

虚树适用于某些题目,这些题目通常需要你做很多次树形DP,只有少部分点是我们DP需要的,我们可以根据他们的祖孙关系建出虚树,这样复杂度就降下来了。

结论1:一个集合的LCA 等于集合里dfn最大的和dfn最小的两点的LCA。

结论2:将一个集合的点按dfn排序得到 \(a_1...a_n\)\(LCA(a_1, a_2), LCA(a_2, a_3)...LCA(a_{n-1}, a_n)\) 中一定出现等于 \(LCA(a_1, a_n)\) 的点。

我们把关键点取出来,按dfn排序,取出相邻两点的LCA,然后去重,得到的这些点根据祖孙关系建树的话一定可以连通。由结论1和结论2可以知道,任意一个区间的LCA,都会在相邻两点的LCA中出现。所以这些点一定够。

需要哪些点知道了,接下来就只需要建树,把这些点再按dfn排序,遍历这个数组,遍历所有相邻的数 \(x, y\),将 \(LCA(x, y)\) 连向 \(y\)。就做完了。

考虑证明:

  1. 如果 \(x\)\(y\) 存在祖孙关系,那 \(x\) 肯定是祖先,并且他们之间不会有其他点,\(x\) 连向了 \(y\)。这是正确的。
  2. 如果不存在,那么就来自不同的子树,考虑 \(LCA(x, y)\)\(y\) 的路径上肯定没有其他点,这么连也是对的。

考虑上面一共连了 \(n - 1\) 条边,并且都是合法的,我们选出的点保证了他们根据祖孙关系建树一定连通,所以连出来的这么个东西就是树。

树分治

这个内容主要是题目,并且很熟了,就不写了。

点分治

边分治

点分树

树哈希

树上随机游走

矩阵树定理

拓扑排序

DAG可以DP。根据拓扑序来DP没有后效性。所以把限制看成边,把问题转化成图很关键。

最小生成树

Kruskal

这个算法人人都会吧,是个贪心算法,但是正确性还是有待证明(比如有多条边权值一样,那我选哪些,选了这些边很可能对树的形态造成影响导致后面选的不优)。所以所以这里证明一下实际上没有影响,就是能选就选:

我们现在只需要证明任意时刻,我们选的边集一定可以被某一个MST包含即可。

我们设目前选的边集为 \(G\),他被这个图的一个MST \(T\)包含,现在我们加入一条合法的边 \(e\)。然后归纳证明一下

  1. 如果 \(e\) 包含于 \(T\),那么就显然成立。
  2. 如果不包含,那么 \(e\)\(T\) 中一定形成了一个环,并且这个环上不可能有比他大的,不然 \(e\) 就可以替换其中一条大边成为一个新的MST,这不符合定义,并且不存在未被选的比 \(e\) 小的边,这个显然正确,如果存在,那么在之前就会被选,所以环上只会存在若干条和 \(e\) 一样大的边,这是我们可以随便拆一条边,然后把 \(e\) 放进去,设其为 \(t\),那么我就令 \(T - t + e\) 为新的MST。

这就引出了生成树的唯一性。

生成树的唯一性

考虑要看生成树是不是唯一,就是上面kruskal的证明过程。
具体实现就是考虑所有权值相同的颜色段,扫描到一个颜色段后,先看有多少个能放进去得到一个预估放的数量,然后一个一个放,得到实际放的数量,如果二者不相等,就说明一定有边形成了环,而且环上有权值相同且已经被选。所以就可以拿目前这个边去替换那个权值相同,根据前文的证明,替换了不会影响答案。

Boruvka

就是每个连通块同过最小边往外合并,初始时每个点为一个连通块,每一轮连通块减半,所以最多 \(logn\),总合并次数 \(O(nlogn)\),考虑找到最小边一般要数据结构,所以一般复杂度是 \(O(nlog^2n)\) 的,这个算法很擅长解决完全图MST的问题。

关于正确性的证明暂时还不会...网上也没找到资料。

瓶颈生成树

定义:最大边在所有生成树中最小。
一般我们要求最小瓶颈生成树,但是这个我们不会求,考虑最小生成树一定是瓶颈生成树,求最小生成树就很简单了。

证明:如果一个MST \(T\) 不是瓶颈生成树,那么存在一个瓶颈生成树 \(G\) 中所有的边都比 \(T\) 中的最大边小,所以我们可以把最大边拆了,然后拿瓶颈生成树中随便一条边把两个连通块连起来。考虑一定存在这条边,如果不存在,那就说明 \(G\) 不连通,所以一定存在。

最小瓶颈路

定义:\(x\)\(y\) 的最小瓶颈路就是一条 \(x\)\(y\) 的路径,满足这条路径上的最大值最小。
结论:最小生成树上两点路径就是一条瓶颈路。

证明:
构造性证明: 假设 \(x\)\(y\) 的最小瓶颈路中边权最大值为 \(m\),考虑 kruskal 的过程,小于等于 \(m\) 的边,我们能连就连,所以 \(x\)\(y\) 一定连通。
反证法: 仍然假设 \(x\)\(y\) 的最小瓶颈路中边权最大值为 \(m\),如果最小生成树上 \(x\)\(y\) 上的路径最大值 \(>m\),我们就把 \(>m\) 的边拆掉,得到若干个连通块,考虑我们用最小瓶颈路上的边一定可以把这些连通块连起来。

kruskal重构树

原图中两个点之间的所有简单路径上最大边权的最小值 = 最小生成树上两个点之间的简单路径上的最大值 = Kruskal 重构树上两点之间的 LCA 的权值
这些奇妙的性质搭配数据结构就很方便。

kruskal的过程中, 每次合并两个连通块时, 新建一个节点, 点权为边权, 将两个连通块的根节点作为新点的左右儿子。

原图中两个点之间的所有简单路径上最大边权的最小值 = 最小生成树上两个点之间的简单路径上的最大值 = Kruskal 重构树上两点之间的 LCA 的权值。

也就是两点之间最小瓶颈路就是他们的 lca 的点权。

到点 \(x\) 的简单路径上最大边权的最小值 \(\leq val\) 的所有点 \(y\) 均在 Kruskal 重构树上的某一棵子树内,且恰好为该子树的所有叶子节点

最小斯坦纳树

最小树形图

最小直径生成树

最短路算法

Floyd

这个算法可以求全源最短路, 不关无向有向, 不管边权正负, 但是不能有负环。

考虑 Floyd 就是 DP, 我们设 \(f[k][i][j]\) 是只经过 \(1\)\(k\) 这些点, \(i\)\(j\) 的最短路径长度。那么则有转移方程:

\[f[k][i][k] = max(f[k - 1][i][j], f[k - 1][i][k] + f[k - 1][k][j]) \]

然后滚动数组优化一下空间。

拓展

用 Floyd 求正权无向图的最小环。

我们仍然套用Floyd的状态和方程。

考虑这肯定是一个简单环, 因为是正环, 构造性证明即可。 然后显然我们可以把每一个简单环映射在每个简单环上的最大编号的节点, 这样我们枚举最大编号的节点就可以考虑完所有的环, 然后我们就要枚举 最大编号的的节点 \(u\) 两侧的节点 \(x\)\(y\), 显然剩下的节点直接取 \(x\)\(y\) 之间的最短路径即可, 考虑这个路径一定不经过 \(u\), 因为 \(u\) 为最大编号的节点

Bellman–Ford

这个算法可以求有负边权的图的最短路, 还可以判断负环。 主要是基于松弛操作。 我在初学最短路算法时有个最大的疑惑就是为什么松弛操作要叫做 “松弛”操作, 现在知道了, 本来是 relax , 考虑要满足 \(d[v] \leq d[u] + edge[i].dis\), 当我们进行一次松弛操作后, 就离完全满足这个不等式又近了一步, 慢慢的松弛直到完全满足这个不等式, 就可以称为 relax, 翻译为松弛。

算法: 考虑最短路最长有多少条边, \(n - 1\) 条边, 我们每一轮松弛操作, 也就是对每一条边进行松弛, 最起码使所有的最短路边数增加一(否则就无法松弛, 也就是已经是最短路), 所以最多进行 \(n - 1\) 轮, 那么时间复杂度 \(O(nm)\)

判断负环: 考虑如果有负环, 那么松弛操作就永远不会停止, 所以如果 \(n - 1\) 轮松弛后, 还有边可以松弛操作, 那么就是有负环。

需要注意的是, 如果只是从 \(s\) 出发, 只能判断从 \(s\) 出发能否到达负环, 我们要判断整个图有没有负环的话, 需要连一个超级源点连向所有节点, 边权为 \(0\)

优化

队列优化(SPFA):
考虑人类智慧, 比如第一轮松弛操作时, 显然我们可以只松弛与 \(s\) 相连的节点, 所以只有已经被松弛过的节点才可能被优化。 所以我们可以用一个队列, 用来装被松驰过的节点。 该算法同样可以判断负环, 我们可以开个数组记录每条最短路的边数, 当一条最短路的边数 \(\geq n\) 时, 说明进入了负环。
时间复杂度: \(O(km)\)

优先队列优化 :
考虑人类智慧, 队列中更小的放前面, 可以省去一些非松弛操作, 但是会带来 \(logn\) 的复杂度。

双端队列优化:
考虑比对头小的就放入队头, 否则放入队尾。

考虑SPFA复杂度玄学, 我觉得最有用的就是 dinic 跑费用流时好用, 如果想用dinic过大数据可以把各种优化都试一试。

Dijkstra

最常用的最短路算法了, 可以求非负边权图的最短路, 时间复杂度 \(O(nlogn)\)

算法: 我们定义两个集合 \(S\)\(T\)\(S\) 为已经确定最短路的点集, \(T\) 为还未确定的点集。 我们每次从 \(T\) 中取出路径长度最短的点加入集合 \(S\), 然后再对他的所有出边进行松弛。

显然, 每个点遍历了他的所有出边, 取出最小值可以用优先队列, 时间复杂度 \(O(mlogm)\)

正确性证明: 很明显, 我们只需要证明, 每次从 \(T\) 中取出最小值时, 那么这个点已经确定了最短路, 我们可以感性证明, 如果他不是最短路, 那么实际的最短路肯定先被取出来。

例题

P4568 [JLOI2011] 飞行路线

分层图, 其实我更愿意用DP的思想去理解, 这样更加的通用, 我们设 \(dp[i][j]\) 表示从 \(s\)\(i\) 经过了 \(k\) 次优惠, 我们考虑转移 \(dp[i][j]\) 可以向 \(dp[][j + 1]\) 或者 \(dp[][j]\) 转移, 实际哪个点根据边来判断, 再加上边权即可, 然后我们可以用最短路算法帮我们跑最短路。

[ABC077D] Small Multiple

同样可以用DP的思想, 我们发现是 \(K\) 的整数倍, 也就是 \(\equiv 0 \pmod {K}\), 我们发现有效状态只有 \(0\)\(K - 1\), 然后考虑转移, 我们想要枚举所有的数, 那么可以通过加 1, 就可以枚举所有的数了, \(dp[i]\)\(dp[(i + 1) % k]\) 转移, 边权为 1, 但是我们还要计算数位和, 如果进位了就数位和就错了, 我们可以考虑再建一种边 \(dp[i]\)\(dp[10i % k]\) 转移, 边权为 0, 这样就可以避免一直加 1加到进位的情况, 因为那样肯定不优。

CF786B Legacy

考虑要单点对区间, 区间对单点, 考虑套用线段树的结构, 我们开两个线段树, 然后把叶子节点之间连边权为 0 的边, 这样就相当于同一个点了。

P6348 [PA2011] Journeys

考虑区间对区间, 和上面的没什么区别, 只是搞个虚点又可以减少边数。

P9520 [JOISC2022] 监狱

最短路树和图

考虑最短路图就是所有满足 \(d[v] == d[u] + edge[i].dis\) 的边和点构成的图, 最短路树就是他的一颗生成树, 我们一般还要求最短路树边权和最小, 可以在dijkstra的时候就建好。

CF1076D Edge Deletion

板子题

P6880 [JOI 2020 Final] 奥运公交

考虑数据范围 \(N\) 很小, 甚至可以 \(O(n^2)\) dijkstra, 然后我们可以枚举一下翻哪条边, 如果不在最短路 DAG 上, 那么就不影响, 如果在, 就会有影响, 我们不知道哪一条是最短路, 所以就要跑dijkstra, 我们建个最短路树这样就只需要跑 \(O(n)\) 次。

同余最短路

可以求出有多少数值可由给定数的系数非负线性组合得到。
一般用SPFA更快, 因为建图特殊。

差分约束

差分约束问题通常有多个形如 \(x_v − x_u ≤ a_i\) 的问题组成,然后判断是否有解。
差分约束的解决依赖于三角形不等式,即 \(∀(u, v, w) ∈ E, dis_u + w ≥ dis_v\)
在单源最短路中,如果有边 \((u, v, w)\),那我们有 \(dis_v ≤ dis_u + w(u, v)\),即 \(dis_v − dis_u ≤ w(u, v)\),也就
\(dis_u − dis_v ≥ −w(u, v)\)
找负环代码:

bool Bellman_Ford() {
	for(int k = 1; k < n; k++) {
		bool fl = 0;
		for(int u = 1; u <= n; u++) 
			for(int i = head[u]; i; i = E[i].next) {
				int v = E[i].to;
				if(dis[v] > dis[u] + E[i].dis) 
					dis[v] = dis[u] + E[i].dis, fl = 1;
			}
		if(!fl) return 1;
	}
	for(int u = 1; u <= n; u++) 
		for(int i = head[u]; i; i = E[i].next) 
			if(dis[v] > dis[u] + E[i].dis) 
				return 0;
	return 1;
} 
bool SPFA() {
	queue<int> q;
	for(int i = 1; i <= n; i++) q.push(i), ins[i] = 1;
	while(!q.empty()) {
		int u = q.front(); q.pop();
		ins[u] = 0;
		for(int i = head[u]; i; i = E[i].next) {
			int v = E[i].to;
			if(dis[v] > dis[u] + E[i].dis) {
				dis[v] = dis[u] + E[i].dis;
				cnt[v] = cnt[u] + 1;
				if(cnt[v] == n) return 0;
				if(!ins[v]) q.push(v), ins[v] = 1;
			}
		}
	}
	return 1;
}

P5590 赛车游戏

考虑我们要满足边权为 \([1,9]\),考虑可以用差分约束, \(dis[u] + edge[i].dis \geq dis[v]\), 那么就有 \(edge[i].dis \geq dis[v] - dis[u]\)\(edge[i].dis \leq dis[u] - dis[v]\)。 直接建边即可, 考虑如果不在 1 到 \(n\) 之间的路径的边就直接不管。

连通性相关

强连通分量

定义:

  1. 树边,dfs树上的边。
  2. 横叉边,指向一个访问过的点,但不是这个点的祖先和孙子。
  3. 反祖边,指向一个访问过的点,这个点是他的祖先。
  4. 前向边,指向一个访问过的点,这个点是他的孙子。

\(dfn_u\):深度优先搜索遍历时结点 \(u\) 被搜索的次序。
\(low_u\):在 u 的子树中能够回溯到的最早的已经在栈中的结点。设以 \(u\) 为根的子树为 \(Subtree_u\)\(low_u\) 定义为以下结点的 \(dfn\) 的最小值:\(Subtree_u\) 中的结点;从 \(Subtree_u\) 通过一条不在搜索树上的边能到达的结点。

结论1:若 \(u\) 是某个强连通分量遇到的第一个点,那么强连通分量中的其他点一定在 \(u\) 的子树中。

证明:如果遇到一个 \(v\) 不在子树中,那只能是走横叉边或者反祖边过去,但是根据定义,这两条边一定是到达访问过的点,与 \(u\) 是第一个访问到的矛盾,所以不存在这个 \(v\)

算法:
我们完全可以根据 \(low\)\(dfn\) 的定义来更新。假设现在已经搜索到了 \(u\),通过一条边到了 \(v\)

  1. 如果 \(v\) 没被访问过,直接搜下去。然后根据定义 \(low_u = min(low_u, dfn_v)\)
  2. 如果 \(v\) 访问过且在栈中,那么这个肯定是反祖边,根据定义 \(low_u = min(low_u, dfn_v)\)
  3. 如果 \(v\) 访问过且不在栈中,直接不管他,\(v\) 已经处理过了。但是这里不能用来更新 \(low_u\),因为这是有向图 \(v\) 不在栈中,说明他们不连通,所以更新了错误的信息。

根据上述过程就可以更新到每个点的 \(low\)。但是有什么用呢。

考虑到一个强连通分量的所有点,他们肯定可以回溯到最上面的那个属于强连通分量的那个点,也就是第一个遇到的。所以我们可以得出,这个强连通分量中有且仅有一个点使得 \(low_u = dfn_u\),所以我们就可以用这个判定!

结论2:如果 \(dfn_u = low_u\),就说明这个点是强连通分量的第一个点,栈中剩下的就都是这个强连通分量的点。

因为我们的递归函数已经把其他强连通分量的筛掉了。因为我们有第一个结论,强连通分量中其他的点一定在子树中,所以递归完就把其他的强连通分量解决了,栈中 \(u\) 上面的点就都是 \(u\) 这个强连通分量中的。

void Tarjan(int u) {
	low[u] = dfn[u] = ++tim; 
	stk[++top] = u, ins[u] = 1;
	for(int i = head[u]; i; i = E[i].next) {
		int v = E[i].to;
		if(!dfn[v]) {
			Tarjan(v);
			low[u] = min(low[u], low[v]);
		} else if(ins[v]) 
			low[u] = min(low[u], dfn[v]);
	}	
	if(low[u] == dfn[u]) {
		sc++;
		do {
			ins[stk[top]] = 0;
			scc[sc].push_back(stk[top]);
		} while(stk[top--] != u);
	}
}

双连通分量

这个是针对无向图的。
定义:
边双连通:\(u\)\(v\) 边双连通,就是删掉任意一条边,他们仍然连通。
点双连通:\(u\)\(v\) 点双连通,就是删掉任意一个点,他们仍然连通。
简单地:边双连通有传递性,点双连通没有传递性。

点双连通分量 \(edcc\):极大的点双连通子图。
边双连通分量 \(vdcc\):极大的边双连通子图。

树边:在 dfs 搜索树上的边。
返祖边:除了树边的边都是反祖边。

证明:如果 \(dfn_u < dfn_v\),且 \(v\) 不是 \(u\) 的儿子,那么 \(v\) 一定是 \(u\) 子树内除 \(u\) 某点的儿子,否则 \((u, v)\) 会变成一条树边。

边双连通分量

考虑这玩意就是 \(SCC\) 放在无向图中。很多 \(SCC\) 的结论放在这里也成立,证明方法也很类似,就不证了。

\(scc\) 不同的是,无向图的连通分量不需要判断是否在栈中,因为无向图除了树边都是返祖边,所以一定在栈中。

注意:

  1. 边双中重边是有意义的,所以我们防止回到父亲的时候就不能单纯记录一个 \(fa\),而是要记录边。
  2. 自环要判掉。
void Tarjan(int u, int pre) {
	low[u] = dfn[u] = ++tim, stk[++top] = u;
	for(int i = head[u]; i; i = E[i].next) {
		int v = E[i].to;
		if(i == (pre ^ 1)) continue;
		if(!dfn[v]) {
			Tarjan(v, i);
			low[u] = min(low[u], low[v]);
		} else 
			low[u] = min(low[u], dfn[v]);
	}
	if(low[u] == dfn[u]) {
		++edc;
		do edcc[edc].push_back(stk[top]);
		while(stk[top--] != u);
	}
}

点双连通分量

结论:如果一个 \(vdcc\) 的第一个访问到的点为 \(u\),那么这个 \(vdcc\) 的其他点一定在他的子树内,并且这个点一定是割点。

证明:这个 \(vdcc\) 的其他点一定在他的子树内这和前面证明类似。如果这个点不是割点,那么他和他的祖先就有其他路径连通,也就是双连通,与他是这个 \(vdcc\) 第一个访问到的点矛盾。

所以算法就很简单了,\(low\)\(dfn\) 正常维护,判定就判定这个点是不是割点,如果是,那么就把栈中这个点上面的都弹出。因为递归函数把其他 \(vdcc\) 都解决了,栈中剩下的就是 \(vdcc\)

注意:点双需要判断自环,在找孤立点的时候有自环就会锅掉。

void Tarjan(int u) {
	low[u] = dfn[u] = ++tim, stk[++top] = u;
	if(u == rt && !head[u]) vdcc[++vdc].push_back(u); 
	for(int i = head[u]; i; i = E[i].next) {
		int v = E[i].to;
		if(!dfn[v]) {
			Tarjan(v);
			low[u] = min(low[u], low[v]);
			if(low[v] == dfn[u]) { 
				vdc++;
				do vdcc[vdc].push_back(stk[top]);
				while(stk[top--] != v);
				vdcc[vdc].push_back(u);
			}
		} else
			low[u] = min(low[u], dfn[v]);
	}
}

为了代码和前面的代码更像更简单,我们不写 else if(v != fa),并且把判定改成 low[v] == dfn[u]

割点和桥

割点

了解了前面的定义,我们就可以很轻易的得出结论:

当一个点 \(u\) 满足其某一个儿子 \(v\)\(dfn_u \leq low_v\),那么 \(u\) 是割点。

证明:很简单,删掉 \(u\) 之后会产生至少一个新的连通块。所以 \(u\) 是割点。
注意:根节点需要特判,如果根节点有至少两个子树,那么就一定是割点。

void Tarjan(int u) {
	low[u] = dfn[u] = ++tim;
	int son = 0;
	for(int i = head[u]; i; i = E[i].next) {
		int v = E[i].to;
		if(!dfn[v]) {
			Tarjan(v);
			low[u] = min(low[u], low[v]);
			son++;
			if(dfn[u] == low[v]) cut[u] = 1;
 		} else 
 			low[u] = min(low[u], dfn[v]);
	}
	if(u == rt && son < 2) cut[u] = 0;
}

这里用 dfn[u] == low[v] 是因为我没有判断 \(v != fa\)

当一个点 \(u\) 满足且某一个儿子 \(v\)\(dfn_u < low_v\),那么边 \((u, v)\) 是桥。

void Tarjan(int u, int fa) {
	low[u] = dfn[u] = ++tim;
	for(int i = head[u]; i; i = E[i].next) {
		int v = E[i].to;
		if(!dfn[v]) {
			Tarjan(v, u);
			low[u] = min(low[u], low[v]);
			if(dfn[u] < low[v]) cut[i] = 1;
 		} else if(v != fa)
 			low[u] = min(low[u], dfn[v]);
	}
}

这里就必须判断 v != fa 了。

圆方树

圆方树就是求 \(vdcc\) 的时候,把这个 \(vdcc\) 中的点向 \(vdc\) 连边。

void Tarjan(int u) {
	low[u] = dfn[u] = ++tim, stk[++top] = u;
	if(u == rt && !head[u]) add(++vdc, u); 
	for(int i = head[u]; i; i = E[i].next) {
		int v = E[i].to;
		if(!dfn[v]) {
			Tarjan(v);
			low[u] = min(low[u], low[v]);
			if(low[v] == dfn[u]) {
				vdc++;
				do add(vdc, stk[top]);
				while(stk[top--] != v);
				add(vdc, u);
			}
		} else
			low[u] = min(low[u], dfn[v]);
	}
}

显然这么连边会构成一棵树,我们可以将 \(vdcc\) 看成若干个连通块,这些连通块直接不成环,内部也没有环,连通块之间通过割点连接,所以是一棵树。

环计数问题

2-SAT

感觉和差分约束挺容易搞混的,差分约束是求若干不等式限制的一组解。2-SAT 解决的问题是给定若干个集合,每个集合大小为 2,代表一个限制,这两个元素不能共存,给出一个选择方案,从每个集合选出一个数,使得满足所有的限制条件。

我们用边可以表示很多信息,比如 \(a > b\),我可以将 \(a\) 连向 \(b\)。或者 \(a\) 不能与 \(b\) 共存,就可以用一个双向边。我总结的规律就是只要是具备传递性的关系,我们都可以建图。

从例题出发。

P5782

定义 \(pair(x)\) 表示与 \(x\) 在同一个集合的另一个元素,\(x\) 不能和 \(y\) 在一起,可以得出如果选了 \(pair(x)\) 则必须选 \(y\),选了 \(y\) 则必须选 \(pair(x)\)。我们可以用有向边表示 \((x, y)\) 表示选了 \(x\) 则必须选 \(y\)

我们就会得到一个一般图,很容易可以得出如果我们缩点后,\(x\)\(pair(x)\) 属于一个 \(scc\),那么就矛盾了,无解。

所以我们就会判断有无解了,那我们怎么输出一组可行解呢。

考虑我们缩点以后,这是一个 DAG,显然如果 \(x\) 的拓扑序小于 \(pair(x)\) 的拓扑序,说明我们只能选择 \(pair(x)\),因为我们选了 \(x\) 的话,必须要选 \(pair(x)\),所以我们只能选 \(pair(x)\)

欧拉回路

定义:
欧拉路径: 经过连通图中所有边恰好一次的迹是欧拉路径。
欧拉回路: 经过连通图中所有边恰好一次的回路是欧拉回路。
欧拉图: 有欧拉回路的图是欧拉图。
半欧拉图: 有欧拉路径没有欧拉回路的图是半欧拉图。

判定:
有向图是欧拉图, 当且仅当图弱连通且所有点出度等于入度。
有向图是半欧拉图, 当且仅当图弱连通且仅有两个点入度等于出度减一和加一, 其他点出度等于入度。
无向图是欧拉图, 当且仅当图连通且所有点度数都为偶数。
无向图是半欧拉图, 当且仅当图连通且仅有两个点度数为奇数, 其他点度数都为偶数。

算法

找欧拉回路从一个点 \(u\) 出发, 遍历所有的出边, 等所有出边遍历完以后, 将 \(u\) 加入栈, 最后倒着输出即可, 如果要输出字典序最小的欧拉回路, 则从字典序最小的开始, 每次按字典序遍历所有的出边即可。

如果找欧拉路径则是从出度等于入度加1的点开始或者某个度数为奇数的点开始即可。

posted @ 2024-09-25 11:35  qqrj  阅读(33)  评论(1编辑  收藏  举报