学习笔记——图论

借鉴了OI Wiki。

先讲图吧……

图

图是一个二元组 \(G=(V(G),E(G))\)。其中 \(V(G)\) 是非空集,称为 点集,对于 \(V\) 中的每个元素,我们称其为顶点或 节点,简称 点;\(E(G)\) 为 \(V(G)\) 各结点之间边的集合,称为 边集。

图有很多类型,包括无向图,有向图等。

若 \(G\) 为 无向图,则 \(E\) 中的每一个元素为一个无序二元组 \((u,v)\),称作无向边,简称边,其中 \(u,v\in V\),设 \(e=(u,v)\),则 \(u\) 和 \(v\) 被称为 \(e\) 的端点。

若 \(G\) 为 有向图,则 \(E\) 中的每一个元素为一个有序二元组 \((u,v)\),有时也写作 \(u\to v\),称作有向边或弧,在不引起混淆的情况下也可以称作边,设 \(e=u\to v\),则 \(u\) 被称为 \(e\) 的起点,\(v\) 被称为 \(e\) 的终点,并称 \(u\) 是 \(v\) 的直接前驱,\(v\) 是 \(u\) 的直接后继。

若 \(G\) 的每条边 \(e_k=(u_k,v_k)\) 都被赋予一个数作为该边的权,则称 \(G\) 为赋权图。如果这些权都是正实数,就称 \(G\) 为正权图。

一、拓扑排序

拓扑排序要解决的问题是如何给一个 有向无环图 的所有节点排序。

本人跟偏向于 Kahn 算法

1.1 Kahn 算法

时间复杂度 \(O(n+m)\)。

构造拓扑序列步骤:

  1. 从图中选择一个入度为零的点;
  2. 输出该顶点,从图中删除此顶点及其所有的出边。

重复上面两步,直到所有顶点都输出,拓扑排序完成,或者图中不存在入度为零的点,此时说明图是有环图,拓扑排序无法完成,陷入死循环。

#include <bits/stdc++.h>
using namespace std;
#define int long long
const int N = 1e6 + 20;
vector<int> e[N], tp;
int in[N], n;

void toopsort() {
    queue<int> q;
    for(int i = 1; i <= n; i++){if(!in[i]) q.push(i);}
    while (!q.empty()) {
        int x = q.front();
        q.pop();
        cout << x << ' ';
        for(auto y : e[x]){
        	in[y] --;
            if(in[y] == 0) q.push(y);
        }
    }
    return ;
}

signed main() {
    cin >> n;
    for (int i = 1, x; i <= n; i++){
    	while(cin >> x){
    		if(!x) break;
	        e[i].push_back(x);
	        in[x] ++;
		}
    }
    toopsort();
    return 0;
}

DFS 算法

……就 DFS 啊,复杂度与 Kahn 算法一致,但要调用很多次栈,又难写,完全可以用 Kahn 算法替换。

二、最短路(差一个 Johnson 全源最短路径算法)

1.2 Floyd 算法

时间复杂度 \(O(n^3)\),空间复杂度 \(O(n^2)\),用于求任意两个结点之间的最短路。

性质

复杂度比较高,但是常数小,容易实现(只有三个 for),适用于任何存在最短路(没有负环)的图,不管有向无向,边权正负。

B3647 【模板】Floyd

定义 \(f_{k,i,j}\) 表示只允许经过结点 \(1\) 到 \(k\)(\(i\) 和 \(j\) 不需要在 \(1\) 到 \(k\) 中)的最短路。

递推式:

\[f_{k,i,j} = \min(f_{k-1,i,j}, f_{k-1,i,k}+f_{k-1,k,j}) \]

以下为最短路代码。

暴力:时间:\(O(n^3)\),空间:\(O(n^3)\)

#include <bits/stdc++.h>
using namespace std;
#define int long long
const int N = 105;
int n, m, f[N][N][N];

signed main(){
	cin >> n >> m;
	memset(f, 0x3f, sizeof(f));
	for(int i = 1; i <= n; i++) f[0][i][i] = 0;
	for(int i = 1, u, v, w; i <= m; i++){
		cin >> u >> v >> w;
		f[0][u][v] = min(f[0][u][v], w);
//		f[0][v][u] = f[0][u][v];(有向无向都可以)
	}
	for(int k = 1; k <= n; k++){
		for(int i = 1; i <= n; i++){
			for(int j = 1; j <= n; j++){
				f[k][i][j] = min(f[k - 1][i][j], f[k - 1][i][k] + f[k - 1][k][j]);
			}
		}
	}
	for(int i = 1; i <= n; i++){
		for(int j = 1; j <= n; j++){
			cout << f[n][i][j] << ' ';
		}
		cout << endl;
	}
	return 0;
}

仔细观察可以发现 \(f\) 的第一维对结果无影响,可以省略。

修改完后可得:

\[f_{i,j} = \min(f_{i,j}, f_{i,k}+f_{k,j}) \]

优化(时间:\(O(n^3)\),空间:\(O(n^2)\))

#include <bits/stdc++.h>
using namespace std;
#define int long long
const int N = 105;
int n, m, f[N][N];

signed main(){
	cin >> n >> m;
	memset(f, 0x3f, sizeof(f));
	for(int i = 1; i <= n; i++) f[i][i] = 0;
	for(int i = 1, u, v, w; i <= m; i++){
		cin >> u >> v >> w;
		f[u][v] = min(f[u][v], w);
//		f[v][u] = f[u][v];(有向无向都可以)
	}
	for(int k = 1; k <= n; k++){
		for(int i = 1; i <= n; i++){
			for(int j = 1; j <= n; j++){
				f[i][j] = min(f[i][j], f[i][k] + f[k][j]);
			}
		}
	}
	for(int i = 1; i <= n; i++){
		for(int j = 1; j <= n; j++){
			cout << f[i][j] << ' ';
		}
		cout << endl;
	}
	return 0;
}

综上,时间复杂度为 \(O(n^3)\),空间复杂度为 \(O(n^2)\)


P6175 无向图的最小环问题

给一个正权无向图,找一个最小权值和的环。

考虑环上编号最大的点为 \(u\),\(f_{u-1,i,j}\) 和 \(dis_{u,i},dis_{u,j}\) 构成了环。用 Floyd 枚举 ,计算整个环的最小值即可,复杂度为 \(O(n^3)\)。

#include <bits/stdc++.h>
using namespace std;
#define int long long
const int N = 105;
int n, m, f[N][N], dis[N][N], ans = 1e18;

signed main(){
	cin >> n >> m;
	for(int i = 1; i <= n; i++){
		for(int j = 1; j <= n; j++){
			f[i][j] = dis[i][j] = 1e18;
		}
		f[i][i] = dis[i][i] = 0;
	}
	for(int i = 1, u, v, w; i <= m; i++){
		cin >> u >> v >> w;
		f[u][v] = min(f[u][v], w);
		f[v][u] = f[u][v];
		dis[u][v] = min(dis[u][v], w);
		dis[v][u] = dis[u][v];
	}
	for(int k = 1; k <= n; k++){
		for(int i = 1; i < k - 1; i++){
			for(int j = i + 1; j < k; j++){
				ans = min(ans, f[i][j] + dis[i][k] + dis[k][j]);
			}
		}
		for(int i = 1; i <= n; i++){
			for(int j = 1; j <= n; j++){
				f[i][j] = min(f[i][j], f[i][k] + f[k][j]);
			}
		}
	}
	if(ans == 1e18) cout << "No solution.";
	else cout << ans;
	return 0;
}

2. 单源最短路 Dijkstra 算法

时间复杂度优化前 \(O(n^2)\),堆优化后 \(O(n\log n+m)\)(也有的说复杂度为 \(O((n+m)\log n)\)),优先队列维护或线段树优化后 \(O(m\log n)\),用于求解非负权图上的单源最短路。

初始化 \(dis(s)=0\),其他点的 \(dis\) 都设为 \(+\infty\)。

将结点分成两个集合:已确定最短路长度的点集(记为 \(S\) 集合)的和未确定最短路长度的点集(记为 \(T\) 集合).一开始所有的点都属于 \(T\) 集合。

然后重复从 \(T\) 集合中,选取一个最短路长度最小的结点,移到 \(S\) 集合中,并对那些刚刚被加入 \(S\) 集合的结点的所有出边执行松弛操作直到 \(T\) 集合为空。

P4779 【模板】单源最短路径(标准版)

#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N = 1e6 + 20;
struct node{
	int v, w;
};
vector<node> e[N];
int n, m, s, f[N];
bool vis[N];
priority_queue<pair<int,int>, vector<pair<int,int> >, greater<pair<int,int> > > q;

signed main(){
	scanf("%lld%lld%lld", &n, &m, &s);
	for(int i = 1; i <= n; i++) f[i] = (1ll << 31) - 1;
	f[s] = 0;
	for(int i = 1, u, v, w; i <= m; i++){
		scanf("%lld%lld%lld", &u, &v, &w);
		e[u].push_back({v, w});
	}
	q.push({0, s});
	while(!q.empty()){
		int u = q.top().second;
		q.pop();
		if(vis[u]) continue;
		vis[u] = true;
		for(node x : e[u]){
			int v = x.v, w = x.w;
			f[v] = min(f[v], f[u] + w);
			if(vis[v]) continue;
			q.push({f[v], v});
		}
	}
	for(int i = 1; i <= n; i++) printf("%lld ", f[i]);
	return 0;
}

3.1 单源最短路 Bellman–Ford 算法

时间复杂度 \(O(nm)\),用于求解 包括 非负权图上的单源最短路。

代码懒得写了,反正没用-_-……详看OI Wiki。

3.2 单源最短路 SPFA

时间复杂度在 \(O(m)\) 到 \(O(nm)\),用于求解 包括 非负权图上的单源最短路。

SPFA 为 Bellman–Ford 算法的队列优化方法,部分数据下 会达到 \(O(nm)\)!!!赛时谨慎使用!

#include <bits/stdc++.h>
using namespace std;
#define int long long
const int N = 5e3 + 20;
struct node{int v, w;};
vector<node> e[N];
int n, m, f[N], cnt[N];
bool vis[N];
queue<int> q;

signed main(){
	cin >> n >> m;
	for(int i = 1, u, v, w; i <= m; i++){
		cin >> u >> v >> w;
		e[u].push_back({v, w});
		e[v].push_back({u, w});
	}
	memset(f, 0x3f, sizeof(f));
	f[1] = 0, vis[1] = true;
	q.push(1);
	while(!q.empty()){
		int u = q.front();
		q.pop();
		vis[u] = false;
		for(node v : e[u]){
			if(f[u] + v.w < f[v.v]){
				f[v.v] = f[u] + v.w;
				if(vis[v.v]) continue;
				vis[v.v] = true;
				q.push(v.v);
				cnt[v.v] ++;
				if(cnt[v.v] >= n){
					cout << "No";
					return 0;
				}
			}
		}
	}
	for(int i = 1; i <= n; i++) cout << f[i] << ' ';
	return 0;
}

当然还有其它优化,详看OI Wiki。

4. 单源最短路 SPFA 运用 / 差分约束

差分约束是一种特殊的 \(n\) 元一次不等式组,它包含 \(n\) 个变量 \(x_1,x_2,…,x_n\) 以及 \(m\) 个约束条件,每个约束条件是由两个其中的变量做差构成的,形如 \(x_i - x_j\le c_k\),其中 \(1\le i,j\le n,i\ne j,1\le k\le m\) 且 \(c_k\) 是常数(可以是非负数,也可以是负数).我们要解决的问题是:求一组解使得所有的约束条件得到满足,否则判断出无解。

\(x_i - x_j\le c_k\) 都可以变形成 \(x_i\le c_k + x_j\),这与单源最短路中的三角形不等式 \(dis_v\le dis_u+w\) 非常相似.因此,我们可以把每个变量 \(x_i\) 看做图中的一个结点,对于每个约束条件 \(x_i - x_j\le c_k\),从 \(j\) 到 \(i\) 连一条权值为 \(c_k\) 的有向边。

初始化 \(dis_0=0\) 并向每一个点连一条权重为 \(0\) 边,跑单源最短路,若图中存在负环,则给定的差分约束系统无解,否则 \(x_i=dis_i\) 为该差分约束系统的一组解。

P5960 【模板】差分约束

#include <bits/stdc++.h>
using namespace std;
#define int long long
const int N = 5e3 + 20;
struct node{int v, w;};
vector<node> e[N];
int n, m, f[N], cnt[N];
bool vis[N];
queue<int> q;

signed main(){
	cin >> n >> m;
	for(int i = 1, u, v, w; i <= m; i++){
		cin >> v >> u >> w;
		e[u].push_back({v, w});
	}
	for(int i = 1; i <= n; i++) e[0].push_back({i, 0});
	memset(f, 0x3f, sizeof(f));
	f[0] = 0, vis[0] = true;
	q.push(0);
	while(!q.empty()){
		int u = q.front();
		q.pop();
		vis[u] = false;
		for(node v : e[u]){
			if(f[u] + v.w < f[v.v]){
				f[v.v] = f[u] + v.w;
				if(vis[v.v]) continue;
				vis[v.v] = true;
				q.push(v.v);
				cnt[v.v] ++;
				if(cnt[v.v] >= n){
					cout << "NO";
					return 0;
				}
			}
		}
	}
	for(int i = 1; i <= n; i++) cout << f[i] << ' ';
	return 0;
}

P1993 小 K 的农场

双倍经验……

#include <bits/stdc++.h>
using namespace std;
#define int long long
const int N = 5e3 + 20;
struct node{int v, w;};
vector<node> e[N];
int n, m, f[N], cnt[N];
bool vis[N];
queue<int> q;

signed main(){
	cin >> n >> m;
	for(int i = 1, op, a, b, c; i <= m; i++){
		cin >> op >> a >> b;
		if(op == 1){
			cin >> c;
			e[a].push_back({b, -c});
		}
		else if(op == 2){
			cin >> c;
			e[b].push_back({a, c});
		}
		else{
			e[a].push_back({b, 0});
			e[b].push_back({a, 0});
		}
	}
	for(int i = 1; i <= n; i++) e[0].push_back({i, 0});
	memset(f, 0x3f, sizeof(f));
	f[0] = 0, vis[0] = true;
	q.push(0);
	while(!q.empty()){
		int u = q.front();
		q.pop();
		vis[u] = false;
		for(node v : e[u]){
			if(f[u] + v.w < f[v.v]){
				f[v.v] = f[u] + v.w;
				if(vis[v.v]) continue;
				vis[v.v] = true;
				q.push(v.v);
				cnt[v.v] ++;
				if(cnt[v.v] >= n){
					cout << "No";
					return 0;
				}
			}
		}
	}
	cout << "Yes";
	return 0;
}

5. 同余最短路

P3403 跳楼机

#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N = 1e6 + 20;
struct node{
	int v, w;
};
vector<node> e[N];
int n, x, y, z, f[N], ans;
bool vis[N];
priority_queue<pair<int,int>, vector<pair<int,int> >, greater<pair<int,int> > > q;

signed main(){
	scanf("%lld%lld%lld%lld", &n, &x, &y, &z);
	n --;
	for(int i = 1; i < x; i++) f[i] = 1e18;
	for(int i = 0; i < x; i++){
		e[i].push_back({(i + y) % x, y});
		e[i].push_back({(i + z) % x, z});
	}
	q.push({0, 0});
	
	while(!q.empty()){
		int u = q.top().second;
		q.pop();
		if(vis[u]) continue;
		vis[u] = true;
		for(node x : e[u]){
			int v = x.v, w = x.w;
			if(vis[v]) continue;
			f[v] = min(f[v], f[u] + w);
			q.push({f[v], v});
		}
	}
	for(int i = 0; i < x; i++){
		if(f[i] <= n) ans += (n - f[i]) / x + 1;
	}
	cout << ans;
	return 0;
}

三、欧拉图

定义

在图论中,欧拉路径是经过图中每条边恰好一次的路径,欧拉回路是经过图中每条边恰好一次的回路。如果一个图中存在欧拉回路,则这个图被称为欧拉图;如果一个图中不存在欧拉回路但是存在欧拉路径,则这个图被称为半欧拉图。

性质(欧拉图的判断条件)

对于连通图 \(G\),以下性质是互相等价的:

  • \(G\) 是欧拉图;
  • \(G\) 中所有顶点的度数都是偶数(对于有向图,每个顶点的入度等于出度);
  • \(G\) 可被分解为若干条不共边回路的并。

具体性质证明和其它。

最小字典序欧拉路径(例题)

有向图

P7771 【模板】欧拉路径

只有满足以下两种情况之一的图才有欧拉路径:

  • 有且仅有一个起点满足入度比出度小 \(1\);有且仅有一个终点满足入度比出度大 \(1\);其他点入度等于出度。
  • 所有点入度等于出度。

如果两种情况都不满足,这个图就没有欧拉路径。

如何满足输出字典序最小欧拉路径?

  • 因为要字典序最小,所以对每个节点的邻接表从小到大排序。
  • 使用 Hierholzer 算法(DFS 回溯):
    • 从起点开始 DFS,每次选择当前节点未被访问的最小边。
    • 边遍历完后,将当前节点压入栈(或存入数组)。
    • 最后倒序输出即为欧拉路径。

个人理解:一层套一层知道枚举完,再依次退出放入答案,所以答案即为倒序枚举的欧拉路径,最后再倒序将答案输出即可。

要理解 dfs!!!

代码理解了好久……

#include <bits/stdc++.h>
using namespace std;
#define int long long
const int N = 1e5 + 20;
int n, m, it[N], in[N], out[N], st, ed, sti;
vector<int> e[N], ans;

void dfs(int u){
	for(int i = it[u]; i < e[u].size(); i = it[u]){
		it[u] ++;
		dfs(e[u][i]);
	}
	ans.push_back(u);
	return ;
}

signed main(){
	cin >> n >> m;
	for(int i = 1, u, v; i <= m; i++){
		cin >> u >> v;
		e[u].push_back(v);
		in[v] ++;
		out[u] ++;
	}
	sti = 1;
	for(int i = 1; i <= n; i++){
		if(abs(in[i] - out[i]) > 1){
			cout << "No";
			return 0;
		}
		if(in[i] - out[i] == 1) ed ++;
		if(out[i] - in[i] == 1){
			st ++;
			sti = i;
		}
	}
	if(!(ed == st && (ed == 0 || ed == 1))){
		cout << "No";
		return 0;
	}
	for(int i = 1; i <= n; i++) sort(e[i].begin(), e[i].end());
	dfs(sti);
	for(int i = ans.size() - 1; i >= 0; i--) cout << ans[i] << ' ';
	return 0;
}

无向图

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

题目有点水,可以用邻接矩阵,空间 \(O(n^2)\),但还是可以正常写邻接表。

邻接矩阵做法

这种方法更好理解,但复杂度要大很多。

直接给代码吧……

#include <bits/stdc++.h>
using namespace std;
#define int long long
const int N = 2050, M = 550;
int m, a[N][N], cnt[N], mx = 0, mn = 550, st;
vector<int> ans;

void dfs(int u){
	for(int i = mn; i <= mx; i++){
		if(!a[u][i]) continue;
		a[i][u] --, a[u][i] --;
		dfs(i);
	}
	ans.push_back(u);
	return ;
}

signed main(){
	cin >> m;
	for(int i = 1, u, v; i <= m; i++){
		cin >> u >> v;
		a[u][v] = ++ a[v][u];
		cnt[u] ++, cnt[v] ++;
		mx = max(mx, max(u, v));
		mn = min(mn, min(u, v));
	}
	st = mn;
	for(int i = mn; i <= mx; i++){
		if(cnt[i] % 2 == 1){
			st = i;
			break;
		}
	}
	dfs(st);
	for(int i = ans.size() - 1; i >= 0; i--) cout << ans[i] << endl;
	return 0;
}

邻接表做法

与有向图相同,但一个无向图存的时候为两条边,删的时候两条边都要删,所以就要对于每个边再存一个 used。

其他的一模一样……

#include <bits/stdc++.h>
using namespace std;
#define int long long
const int N = 1050, M = 550;
int m, it[M], cnt[M], st, mx = 0, mn = 550;
bool used[N];
vector<pair<int, int> > e[M];
vector<int> ans;

void dfs(int u){
	for(int i = it[u]; i < e[u].size(); i = it[u]){
		it[u] ++;
		if(used[e[u][i].second]) continue;
		used[e[u][i].second] = true;
		dfs(e[u][i].first);
	}
	ans.push_back(u);
	return ;
}

signed main(){
	cin >> m;
	for(int i = 1, u, v; i <= m; i++){
		cin >> u >> v;
		e[u].push_back({v, i});
		e[v].push_back({u, i});
		cnt[u] ++, cnt[v] ++;
		mx = max(mx, max(u, v));
		mn = min(mn, min(u, v));
	}
	for(int i = mn; i <= mx; i++) sort(e[i].begin(), e[i].end());
	st = mn;
	for(int i = mn; i <= mx; i++){
		if(cnt[i] % 2 == 1){
			st = i;
			break;
		}
	}
	dfs(st);
	for(int i = ans.size() - 1; i >= 0; i--) cout << ans[i] << endl;
	return 0;
}

欧拉图运用

CF547D Mike and Fish

推荐xht的题解,不过用的是链式前向星,我还是更习惯于用邻接表……

这个题有很多其它的玄学做法,还是老老实实码了一遍……

题意

在一个平面直角坐标系中有 \(n\) 个整点,你要给每个点染成红色或蓝色,要求同一水平线或垂直线上两种颜色的数量最多相差 \(1\)。

解题思路

这个题的思路很奇妙,将坐标 \((x_i,y_i)\) 转化成连一条 \(x_i\) 和 \(y_i+N(2\times10^5)\) 之间的无向边,再将图定向,根据方向对每条边进行染色,这样要求同一水平线或垂直线上两种颜色的数量最多相差 \(1\) 就变成了图中每个顶点的出度与入度之差最多为 \(1\)。

处理每个顶点的度数为奇数的点,要使这个点的出度与入度之差最多为 \(1\) 且出度与入度都为整数,只能让这个点的出度与入度之差为 \(1\),可以证明度数为奇数的点个数为偶数个。

证明

因为所有顶点的度数之和等于边数的两倍(必然是偶数),度数为偶数的点的度数之和为偶数,所以度数为奇数的点个数为偶数个(这样才能保证度数为奇数的点的度数之和为偶数,加起来才为偶数)。

再将所有度数为奇数的点与 \(0\) 相连,这样所有的点的度数就都为偶数了,整个图构成了一个欧拉图。

跑一边无向边的欧拉回路(欧拉路径的一种),确定每一条边的方向,将从 \(x_i\) 到 \(y_i+N(2\times10^5)\) 的路径和从 \(y_i+N(2\times10^5)\) 到 \(x_i\) 分开,类型相同的边染成同一种颜色即可。

#include <bits/stdc++.h>
using namespace std;
#define int long long
const int N = 2e5 + 20;
int n, now, it[N << 1], cnt[N << 1], vis[N << 1];
vector<pair<int, int> > e[N << 1];

void dfs(int u){
	for(int i = it[u]; i < e[u].size(); i = it[u]){
		int v = e[u][i].first, id = e[u][i].second;
		it[u] ++;
		if(vis[id]) continue;
		vis[id] = (u < N ? 2 : 1);
		dfs(v);
	}
	return ;
}

signed main(){
	cin >> n;
	now = n;
	for(int i = 1, u, v; i <= n; i++){
		cin >> u >> v;
		e[u].push_back({v + N, i});
		e[v + N].push_back({u, i});
		cnt[v + N] ++, cnt[u] ++;
	}
	for(int i = 1; i < (N << 1); i++){
		if(cnt[i] & 1){
			now ++;
			e[0].push_back({i, now});
			e[i].push_back({0, now});
		}
	}
	for(int i = 1; i < N; i++) dfs(i);
	for(int i = 1; i <= n; i++) cout << (vis[i] == 1 ? 'b' : 'r');
	return 0;
}

欧拉图代码细节

为什么要用 it[u]?

节点在环上时会再次调用到 dfs(u) ,会重复枚举与 \(u\) 连接的点,这样重复枚举没有意义所以可以用 it[u] 跳过已经枚举过的点。

四、连通性

1. 强连通分量 / 缩点

强连通的定义是:有向图 \(G\) 强连通是指,\(G\) 中任意两个结点连通。

Tarjan 算法

假设图有 \(n\) 个节点,定义下面两个数组:

  • dfn[u](发现时间):节点 \(u\) 被 DFS 第一次 访问到的次序(时间戳)。
  • low[u](追溯值):节点 \(u\) 能追溯到的最早(最小)的 dfn 值,即 \(u\) 及其后代通过一条回边能回到的最早祖先。
  1. 初始化:dfn 和 low 数组为0,一个空栈。
  2. 对每个未访问的节点,对该点及其子节点运行 DFS,求出这个点的 dfn[u] 和 low[u],对于每个进入 DFS 的节点将其放入栈中。
  3. 如果节点的 dfn[u] 和 low[u] 相同,将栈中 \(u\) 及以上的节点弹出,这些节点属于同一个强连通分量,将这个强连通分量进行缩点。
  4. 缩完所有点后整个图就变成有向无环图了。

为什么 dfn[u] == low[u] 时弹出?

当 dfn[u] == low[u] 时,说明了 \(u\) 及其后代无法跳回到 \(u\) 的任何一个真正祖先(比 \(u\) 更早的节点),它们最多只能跳回到 \(u\) 自己。

这意味着:\(u\) 及其后代形成了一个"封闭"的子图——从 \(u\) 可以到达它们,它们也只能回到 \(u\),无法逃出这个圈子。

为什么这些弹出的节点属于同一个强连通分量?

我们已知 \(u\) 可以到达所有节点,只需证明所有节点也可以到达 \(u\) 即可。

首先,既然这个节点会被统计,那么他一定是在 \(u\) 节点之后入栈,那么肯定就在 \(u\) 的子树中。

作为 \(u\) 的子节点,因为未让 low[u] 大于 dfn[u],所以所有 \(u\) 的子节点的 low 小于等于 dfn[u]。

对于 \(u\) 的儿子节点,这些节点的 low 肯定不能为自己的 dfn,所以只能为 dfn[u]。

对于 \(u\) 的儿子的儿子节点,必须为 \(u\) 或 \(u\) 的儿子的 dfn,但 \(u\) 的儿子的 dfn 也是 dfn[u],所以 \(u\) 的儿子的儿子节点的 low 为 dfn[u]。

以此类推……

为什么缩完所有点后整个图就变成有向无环图了?

已知对于一个强连通分量,因为对于这个图上的任意两点互相可达,所以没有点的入度为 \(0\),因此没有拓扑序,所以是有环图。

将一个图上的所有强连通分量缩成点后,整个图就变成了有向无环图。

时间复杂度 \(O(n+m)\)。

P3387 【模板】缩点 / 强连通分量

先用 Tarjan 进行缩点,再在缩完点后的有向无环图上拓扑求最长路(无环图中用拓扑求最短/长路最优)。

总时间复杂度 \(O(n+m)\)。

#include<bits/stdc++.h>
using namespace std;
#define int long long
const int N = 1e5 + 20;
int n, m, sum, tim, x[N], y[N], a[N], ans;
int dfn[N], low[N], vis[N], top[N], in[N], f[N];
vector<int> e[N], nw[N];
stack<int> stk;
queue<int> q;

void tarjan(int u){
	low[u] = dfn[u] = ++ tim;
	stk.push(u);
	vis[u] = 1;
	for(int v : e[u]){
		if(!dfn[v]){
			tarjan(v);
			low[u] = min(low[u], low[v]);
		}
	    else if(vis[v]){
	    	low[u] = min(low[u], dfn[v]);
		}
	}
	if(dfn[u] == low[u]){
		int v;
		while(!stk.empty()){
			v = stk.top();
			stk.pop();
			top[v] = u;
			vis[v] = 0;
			if(v != u) a[u] += a[v];
			if(v == u) break;
		}
	}
}

signed main(){
	cin >> n >> m;
	for(int i = 1; i <= n; i++) cin >> a[i];
	for(int i = 1; i <= m; i++){
		cin >> x[i] >> y[i];
		e[x[i]].push_back(y[i]);
	}
	for(int i = 1; i <= n; i++){
		if(!dfn[i]) tarjan(i);
	}
	for(int i = 1, u, v; i <= m; i++){
		u = top[x[i]], v = top[y[i]];
		if(u != v){
			nw[u].push_back(v);
			in[v] ++;
		}
	}
	for(int i = 1; i <= n; i++){
		if(top[i] == i && !in[i]){
			q.push(i);
	        f[i] = a[i];
		}
	}
	while (!q.empty()){
		int u = q.front();
		q.pop();
		for(int v : nw[u]){
			f[v] = max(f[v], f[u] + a[v]);
			in[v]--;
			if(in[v] == 0) q.push(v);
		}
	}
    for(int i = 1; i <= n; i++) ans = max(ans, f[i]);
    cout << ans;
	return 0;
}

2. 双连通分量

边双连通分量

在一张连通的无向图中,对于两个点 \(u\) 和 \(v\),如果无论删去哪条边(只能删去一条)都不能使它们不连通,我们就说 \(u\) 和 \(v\) 边双连通。

在一张连通的无向图中,对于两个点 \(u\) 和 \(v\),如果无论删去哪个点(只能删去一个,且不能删 \(u\) 和 \(v\) 自己)都不能使它们不连通,我们就说 \(u\) 和 \(v\) 点双连通。

边双连通具有传递性,即若 \(x,y\) 边双连通,\(y,z\) 边双连通,则 \(x,z\) 边双连通。

点双连通不具有传递性,反例如下图,\(a,b\) 点双连通,\(b,c\) 点双连通,而 \(a,c\) 不一定点双连通。

如图:

对于一个无向图中的极大边双连通的子图,我们称这个子图为一个 边双连通分量。

对于一个无向图中的极大点双连通的子图,我们称这个子图为一个 点双连通分量。

DFS 生成树

对于一张连通的无向图,我们可以从任意一点开始 DFS,得到原图的一棵 DFS 生成树(以开始 DFS 的那个点为根),这棵生成树上的边称作树边,不在生成树上的边称作非树边。

由于 DFS 的性质,我们可以保证所有非树边连接的两个点在生成树上都满足其中一个是另一个的祖先。

用 Tarjan 求双连通分量过程与求强连通分量类似。

Tarjan 算法 1

考虑先求出所有的桥,再 DFS 求出边双连通分量。

Tarjan 算法 2

有一个显然的性质:在无向图中,DFS 生成树上的边不是树边就只有非树边。

联系一下求强连通分量的方法,在无向图中只要一个分量没有桥,那么在 DFS 生成树上,它的所有点都在同一个强连通分量中,反过来,在 DFS 生成树上的一个强连通分量,在原无向图中是边双连通分量,所以求边双连通分量的过程实际上就是求强连通分量的过程。

P8436 【模板】边双连通分量

本人更喜欢用 Tarjan 算法 2。

直接写一个强连通分量板子,不用后续的拓扑(更好写了),但要处理重边,所以枚举当前儿子时判断防止跳回父亲节点就不能传节点,要传边的序号。

没写判断重边的代码(WA 50)

#include<bits/stdc++.h>
using namespace std;
#define int long long
const int N = 5e5 + 20;
int n, m, tim, dfn[N], low[N];
vector<int> e[N];
stack<int> stk;
vector<vector<int> > ans;

void tarjan(int u, int fa){
	low[u] = dfn[u] = ++ tim;
	stk.push(u);
	for(int v : e[u]){
		if(v == fa) continue;
		if(!dfn[v]){
			tarjan(v, u);
			low[u] = min(low[u], low[v]);
		}
	    else low[u] = min(low[u], dfn[v]);
	}
	if(dfn[u] == low[u]){
		vector<int> vec;
		vec.push_back(u);
		while(!stk.empty() && stk.top() != u){
			vec.push_back(stk.top());
			stk.pop();
		}
		stk.pop();
		ans.push_back(vec);
	}
}

signed 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]) tarjan(i, 0);
	}
	cout << ans.size() << endl;
	for(vector<int> i : ans){
		cout << i.size();
		for(int j : i) cout << ' ' << j;
		cout << endl;
	}
	return 0;
}

判断重边后的代码(AC 100)

#include<bits/stdc++.h>
using namespace std;
#define int long long
const int N = 1e5 + 20;
int n, m, tim, dfn[N], low[N];
vector<pair<int, int> > e[N];
stack<int> stk;
vector<vector<int> > ans;

void tarjan(int u, int fa){
	low[u] = dfn[u] = ++ tim;
	stk.push(u);
	for(pair<int, int> v : e[u]){
		if(v.second == (fa ^ 1)) continue;
		if(!dfn[v.first]){
			tarjan(v.first, v.second);
			low[u] = min(low[u], low[v.first]);
		}
	    else low[u] = min(low[u], dfn[v.first]);
	}
	if(dfn[u] == low[u]){
		vector<int> vec;
		vec.push_back(u);
		while(!stk.empty() && stk.top() != u){
			vec.push_back(stk.top());
			stk.pop();
		}
		stk.pop();
		ans.push_back(vec);
	}
}

signed main(){
	cin >> n >> m;
	for(int i = 1, u, v; i <= m; i++){
		cin >> u >> v;
		e[u].push_back({v, i * 2});
		e[v].push_back({u, i * 2 + 1});
	}
	for(int i = 1; i <= n; i++){
		if(!dfn[i]) tarjan(i, 0);
	}
	cout << ans.size() << endl;
	for(vector<int> i : ans){
		cout << i.size();
		for(int j : i) cout << ' ' << j;
		cout << endl;
	}
	return 0;
}

点双连通分量

posted @ 2026-10-27 16:07  show__code  阅读(1)  评论(0)    收藏  举报