图论

图论是数学的一个分支,图是图论的主要研究对象。图是由若干给定的顶点及连接两顶点的边所构成的图形,这种图形通常用来描述某些事物之间的某种特定关系。顶点用于代表事物,连接两顶点的边则用于表示两个事物间具有这种关系。

下面介绍几种图论相关的基本算法。

拓扑排序

定义:给定一个 \(n\) 个点 \(m\) 条边的有向无环图(DAG),对它的节点进行排序后使得对于任何的顶点 \(u\)\(v\) 的有向边 \((u, v)\),都可以有 \(u\)\(v\) 的前面。

实现:拓扑排序需要用到点的入度和出度的概念:
若有点 \(u\) 指向点 \(v\),则:

  • \(u\) 的出度加一
  • \(v\) 的入度减一
    简单来说,一个点的入度为有几个点指向它,一个点的出度为它指向了几个点。拓扑排序利用了这一点,排时每次取出一个入度\(0\) 的点,去掉它的所有出边,循环往复,直到所有的点都被取出,这个取出的过程就是拓扑排序。

用处:很显然,如果拓扑排序结束后仍有点未背取出,或没有入度为零的点,则这个图有环。

给出几道例题来说明拓扑排序的几种编码。

1.luogu P4017(最大食物链计数)
第一眼就可以看出这题是个拓扑排序,原因:

  • 1.是图论题且不用求最短路
  • 2.从“最左端是不会捕食其他生物的生产者”可以想到要从入度为 \(0\) 的点开始找
    由于这里只要求“最大食物链的数量”,所以可以直接用 \(bfs\) 来操作。用 \(bfs\) 时(这里的队列存放的是目前入度为 \(0\) 的点),先把每个为 \(0\) 的点压进队列里,然后遍历每个入度为零的点的每个邻居,把这些邻居的入度都减 \(1\),然后把这个点弹出。如果又出现了入度为 \(0\) 的点,就再压进队列里,直到队列为空为止。

给出代码实现:

#include<bits/stdc++.h>
#define mod 80112002
#define N 5010
using namespace std;
int n, m, u, v, ans;
int in[N], out[N], s[N];
vector<int>a[N];
queue<int>q;
int main(){
	scanf("%d%d", &n, &m);
	for(int i = 1; i <= m; i++){
		scanf("%d%d", &u, &v);
		a[v].push_back(u);	//u被v吃 
		in[u]++;	//u的入度加1 
		out[v]++;	//v的出度加1 
	}
	for(int i = 1; i <= n; i++){
		if(!in[i]){	//如果有入度为零的 
			q.push(i);	//这个点可以作为起点,入队 
			s[i] = 1;	//记它的数量为1 
		}
	}
	while(!q.empty()){
		u = q.front();	//取出队首 
		q.pop();
		for(auto v : a[u]){	//遍历它的每个邻居 
			s[v] += s[u];	//邻居加上它的大小 
			s[v] %= mod;	//记得取模 
			in[v]--;	//把两个点相邻的点去掉,即邻居的入度减1 
			if(!in[v]){	//如果邻居的入度为0 
				if(!out[v]){	//它的出度也为0,这是一条食物链(初读为0就是一条食物链的尽头 
					ans += s[v];	//加上答案,取模,因为出度为0,不必再入队 
					ans %= mod;
				}
				else{
					q.push(v);	//要不然这个点也可以作为出发点(此时它入度为0) 
				}
			}
		}
	}
	printf("%d", ans);
	return 0;
} 

2.luogu P1685
这里我们定义 \(c[i]\) 表示到点 \(i\) 的次数,\(dis[i]\) 示到点 \(i\) 的总路径长度,所以 \(ans = (dis[t] + (c[t] - 1) * T0) % mod\)。用 \(dfs\) 来递推这两个数组,但不能不能直接爆搜,因为可能会出现搜到某个点后面的点没搜完就去搜其他的点了。所以加上拓扑排序,当一个点入度为 \(0\) 时就说明已经没有点可以去更新它了,说明它的信息收集已经完全了。

给出代码:

#include<bits/stdc++.h>
#define N 50010
#define mod 10000
using namespace std;
int n, m, s, t, t0, u, v, w, cnt;
int h[N], in[N];
long long c[N], dis[N];	// 
struct node{
	int ne;
	int to;
	int w;
}a[N];
void add(int u, int v, int w){
	a[++cnt].ne = h[u];
	a[cnt].to = v;
	a[cnt].w = w;
	h[u] = cnt;
	in[v]++;	//加入度 
}
void dfs(int u){
	for(int i = h[u]; i; i = a[i].ne){
		int v = a[i].to;
		dis[v] += dis[u] + c[u] * a[i].w;	//加上到u的距离和经过了几次u乘上中间这段路 
		dis[v] %= mod;
		c[v] += c[u];	//加上上个点经过的次数 
		c[v] %= mod;
		in[v]--; 
		if(!in[v]){	//入度为0,前面都遍历完了 
			dfs(v);
		}
	}
}
int main(){
	scanf("%d%d%d%d%d", &n, &m, &s, &t, &t0);
	c[s] = 1;	//初始化,经过一次起点 
	for(int i = 1; i <= m; i++){
		scanf("%d%d%d", &u, &v, &w);
		add(u, v, w);
	}
	dfs(s);
	printf("%lld", (dis[t] + (c[t] - 1) * t0) % mod);	//到t的距离加上经过次数乘乘船的费用 
	return 0;
}

最短路

最短路有好几种代码,这里从简单到复杂依次介绍。

全源最短路(Floyd)

优点:

  • 可以求任意两点之间的最短路
  • 编码简单

缺点:

  • 范围不广,只限于 \(N \leq 400\) 的数据大小

我们设 \(root[k][i][j]\) 表示只能经过 \(1\) ~ \(k\) 的节点的最短路,初始化 \(root[0][i][j]\) 为两点边权或极大值,易得 \(root[k][i][j] = min(root[k - 1][i][x], root[k - 1][x][j])\)

显然 \(root[n][i][j]\)\(i \rightarrow j\) 的最短路,第一位可以压掉,所以空间复杂度降为 \(O(n ^ 2)\),时间复杂度为 \(O(n ^ 3)\)

这里直接给出模板题(luogu B3647)代码:

#include<bits/stdc++.h>
#define N 110
using namespace std;
int n, m, u, v, w;
int root[N][N];	//root[i][j]为从点i到点j的最短路 
int main(){
	scanf("%d%d", &n, &m);
	memset(root, 0x3f, sizeof(root));
	for(int i = 1; i <= m; i++){
		scanf("%d%d%d", &u, &v, &w);
		root[u][v] = root[v][u] = min(root[u][v], w);	//防止有重边 
	}
	for(int k = 1; k <= n; k++){
		for(int i = 1; i <= n; i++){
			for(int j = 1; j <= n; j++){
				root[i][j] = min(root[i][j], root[i][k] + root[k][j]);
			}
		}
	}
	for(int i = 1; i <= n; i++){	//进行Floyd算法后,自己和自己的距离并不为0 
		root[i][i] = 0;
	}
	for(int i = 1; i <= n; i++){
		for(int j = 1; j <= n; j++){
			printf("%d ", root[i][j]);
		}
		printf("\n");
	}
	return 0;
}
负权最短路(Bellman-Ford)

\(Bellman\) - \(Ford\) 是一种是一种基于松弛操作的最短路算法,可以求出带有负权边的图的最短路,并可以对最短路不存在的情况进行判断。

松弛操作,对于每条边 \((u, v)\),此操作对应这个式子:\(dis[v] = min(dis[v], dis[u] + w[u][v])\),这个操作是显然的。

此算法所做的,就是不断尝试对图上每一条边进行松弛。每进行一轮循环,就对图上所有的边都尝试进行松弛,直到一次循环中不能进行松弛为止。时间复杂度为 \(O(nm)\)

初始化 \(dis\)\(+\infty\)\(dis[s]\)\(0\)

有一种情况,如果从 \(s\) 点出发,抵达一个负环(图中一个边权和为负的环)时,松弛会无止境地进行下去。如果第 \(n\) 轮循环时仍然存在能松弛的边,说明从 \(s\) 点出发,能够抵达一个负环。

下面给出用 \(Bellman\) - \(Ford\) 判负环的代码实现:

const inf = 0x3f3f3f3f;
int dis[N];
struct node{
	int v;
	int w;
};
vector<node>a[N];
bool bellmanford(int x){
	memset(dis, inf, sizeof(dis))
	dis[s] = 0;
	bool flag;
	for(int i = 1; i <= n; i++){
		flag = false;	//判断是否发生松弛操作 
		for(int u = 1; i <= n; u++){
			if(dis[u] == inf){	//无穷大与常数相加减依旧为无穷大 
				continue;
			}
			for(auto e : a[u]){
				int v = e.v;
				int w = e.w;
				if(dis[v] > dis[u] + w){
					dis[v] = dis[u] + w;
					flag = true;
				}
			}
		}
		if(!flag){	//不能松弛就停止算法 
			break;
		}
	}
	return flag;
}

注意!\(s\) 点为源点跑 \(Bellman\) - \(Ford\) 算法时,如果没有给出存在负环的结果,只能说明从 \(s\) 点出发不能抵达一个负环,而不能说明图上不存在负环。因此最好的做法是新建立一个源点,向图上每个节点连一条权值为 \(0\) 的边,然后以此源点为起点执行 \(Bellman\) - \(Ford\) 算法,如再来判断整个图上是否存在负环。

队列优化(SPFA)

很明显,有时我们并不需要那么多无用的松弛操作,因为只有上一次被松弛过的节点所连的边才能再进行松弛操作。

所以我们新建一个队列,把有可能进行松弛操作的点压进这个队列里,这样就可以只访问必要的边了。

\(SPFA\) 算法也可以用于判断 \(s\) 点是否能抵达一个负环,只需记录进行最短路算法时经过了几条条边,当经过了至少 \(n\) 条边时,说明 \(s\) 点可以抵达一个负环。

给出用 \(SPFA\) 算法判负环的代码:

const inf = 0x3f3f3f3f;
int dis[N], cnt[N];	//cnt[i]记录从s到i经过了几条边 
bool vis[N];	//vis[i]为是否访问过点i 
struct node{
	int v;
	int w;
};
vector<node>a[N];
qurur<int>q; 
bool spfa(int x){
	memset(dis, inf, sizeof(dis))
	dis[s] = 0;
	vis[s] = true;
	while(!q.empty()){
		int u = q.front();
		q.pop();
        vis[u] = false;
		for(auto e : a[u]){
			int v = e.v;
			int w = e.w;
			if(dis[v] > dis[u] + w){
				dis[v] = dis[u] + w;
				cnt[v] = cnt[u] + 1;
				if(cnt[v] >= n){
					return true;
				}
				if(!vis[v]){
					q.push(v);
					vis[v] = true;
				}
			}
		}
	}
	return false;
}
单源最短路(Dijkstra)

\(Dijkstra\) 算法用于求在非负权图中的单源最短路,是一种非常优秀的单源最短路径算法。

\(Dijkstra\) 求最短路的过程如下:
算法先会将点分为两个集合,一个集合存放已经确定的最短路长度的点(不妨记为点集 \(S\)),另一个集合则存放未确定的最短路长度的点(记为点集 \(T\))。一开始所有的点都被存放在点集 \(T\) 里,然后不断进行以下操作:

  • 1.从 \(T\) 集合中,选取一个最短路长度最小的结点,移到 \(S\) 集合中。
  • 2.对刚刚被加入 \(S\) 集合的结点的所有出边进行松弛操作。
时间复杂度

有多种方法来维护操作 \(1.\) 中最短路长度最小的点,不同的方法导致了 \(Dijkstra\) 算法时间复杂度上的差异,这里只讲两种常见的维护方法。

  • 1.暴力
    即不使用任何数据结构维护,每次 \(2.\) 操作执行完毕后,直接在 \(T\) 集合中暴力寻找每个节点的最短路长度。\(2.\) 操作总时间复杂度为 \(O(m)\)\(1.\) 操作总时间复杂度为 \(O(n ^ 2)\),全过程的时间复杂度为 \(O(n ^ 2 + m)\) = \(O(n ^ 2)\)
  • 2.二叉堆维护
    每松弛一条边 \((u, v)\),就将 \(v\) 插入二叉堆中(如果 \(v\) 已经在二叉堆中,直接修改相应元素的权值即可),\(1.\) 操作直接取堆顶结点即可。共计 \(O(m)\) 次二叉堆上的插入(修改)操作,\(O(n)\) 次删除堆顶操作,而插入(修改)和删除的时间复杂度均为 \(O(\log n)\),时间复杂度为 \(O((n + m) \log n)\) = \(O(m \log n)\)

要根据图的性质(稀疏图、稠密图)来确定是否要用二叉堆维护。

这里给出这两种代码:
暴力

const int inf = 0x3f3f3f3f;
int u, minn;
int dis[N];
bool vis[N];
struct node{
	int v;
	int w;
};
vector<int>a[N];
void dijkstra(int s){
	memset(dis, inf, sizeof(dis));
	dis[s] = 0;
	for(int i = 1; i <= n; i++){
		u = 0;
		minn = inf;
		for(int j = 1; j <= n; j++){
			if(!vis[j] && dis[j] < minn){
				u = j;
				minn = dis[j];
			}
		}
		vis[u] = true;
		for(auto e : a[u]){
			int v = e.v;
			int w = e.w;
			dis[v] = min(dis[v], dis[u] + w);
		}
	}
}

二叉堆维护

const int inf = 0x3f3f3f3f;
int dis[N];
bool vis[N];
struct node{
	int v;
	int w;
};
struct edge{
	int u;
	int dis;
	bool operator > (const node &x) const{
		return dis > x.dis;
	}
};
vector<int>a[N];
priority_queue<edge, vecot<edge>, greater<edge> >q;
void dijkstra(int s){
	memset(dis, inf, sizeof(dis));
	dis[s] = 0;
	q.push(edge{0, s});
	while(!q.empty()){
		int u = q.top().u;
		q.pop();
		if(vis[u]){
			continue;
		}
		vis[u] = true;
		for(auto e : a[u]){
			int v = e.v;
			int w = e.w;
			if(dis[v] > dis[u] + w){
				dis[v] = dis[u] + w;
				q.push(dis[v], v);
			}
		}
	}
}

下面较少一个负环的一个应用:差分约束系统。

差分约束系统

差分约束系统是一种特殊的 \(n\) 元一次不等式组,包含 \(n\) 个变量 \(x_1\) ~ \(x_n\)\(m\) 个约束条件,每个约束条件是两个变量的差,形如 \(x_i - x_j \leq y_k\)\(y_k\) 为常数。需要求出一组解,满足所有的约束条件。

差分约束系统要么无解,要么有无限解。因为一旦存在一组解,那么把每一个 \(x_i\) 加上任意一个常数,方程依旧成立。事实上当我们把 \(x_i - x_j \leq y_k\),转化成 \(x_i \leq x_j + y_k\) 时,这个式子和松弛操作几乎完全一样。所以,这道题可以转换成一个最短路径问题。我们对每个约束条件从节点 \(i\) 向节点 \(j\) 建一条边权为 \(c_k\) 的边。再增加一个 \(0\) 号节点,从这个节点向每个节点连一条边权为 \(0\) 的边,从它为起点,进行 \(SPFA\)(由于 \(c_k\) 为常数,可正可负),计算到其他点的最短路径(解),有负环则无解。

直接给出模板题(luogu P5960)的代码:

#include<bits/stdc++.h>
#define N 10010	//要开大点,要不然会RE 
#define inf 0x3f3f3f3f
using namespace std;
int n, m, u, v, w, tot;
int h[N], dis[N], cnt[N];
bool vis[N];
queue<int>q;
struct node{
	int ne;
	int to;
	int w;
}a[N];
void add(int u, int v, int w){	//链式前向星 
	a[++tot].ne = h[u];
	a[tot].to = v;
	a[tot].w = w;
	h[u] = tot;
}
bool spfa(int s){	//判负环 
	memset(dis, inf, sizeof(dis));
	dis[s] = 0;
	vis[s] = true;
	q.push(s);
	while(!q.empty()){
		int u = q.front();
		q.pop();
		vis[u] = false;
		for(int i = h[u]; i; i = a[i].ne){
			int v = a[i].to;
			int w = a[i].w;
			if(dis[v] > dis[u] + w){
				dis[v] = dis[u] + w;
				cnt[v] = cnt[u] + 1;
				if(cnt[v] > n){	//因为加了一个源点,所以应是 > n 
					return true;
				}
				if(!vis[v]){
					q.push(v);
					vis[v] = true;
				}
			}
		}
	}
	return false;
}
int main(){
	scanf("%d%d", &n, &m);
	for(int i = 1; i <= m; i++){
		scanf("%d%d%d", &u, &v, &w);
		add(v, u, w);
	}
	for(int i = 1; i <= n; i++){	//新建一个0点为起点 
		add(0, i, 0);
	}
	if(spfa(0)){
		printf("NO");
	}
	else{
		for(int i = 1; i <= n; i++){
			printf("%d ", dis[i]);
		}
	}
	return 0;
}

连通性问题

割点和割边

在无向图中所有能互通的点便组成了一个连通分量。如果去掉一个点后,图中的连通分量数量有增加,则称这个点为割点。同样的,如果去掉一条边后,图中的连通分量数量有增加,则称这个点为割边。接下来来探究如何求割点与割边。

1.求割点

如果用暴力的做法:依次删除每个点,然后统计连通性,复杂度为 \(O(n(n + m))\),很高。下面给出用 \(dfs\) 求个点的算法.

我们对一个连通分量做一个 \(dfs\),能够产生一棵“\(dfs\) 生成树”。这里给出两个定理:

  • 1.如果生成树中的根节点有大于等于两个的子节点,那么这个根节点是割点。
    这个定理很好理解,如果根节点只有一个子节点,那它就不会是一个割点。因为把它删去后,只会留下以它的子节点为根的一棵子树。

  • 2.如果生成树中的一个非根节点 \(u\) 存在一个子节点 \(v\)\(v\) 的及其后代都没有回退边退回 \(u\) 的祖先,则 \(u\) 是割点。
    这条定理也很好理解,看图:

    在图中, \(2\) 是割点,它能把它的根节点与它的后代分成两个连通分量。但如果加一条 \((6, 1)\) 的边,图变成这样:

    那么此时这个图就没有割点了,因为即使删去 \(2\)\(2\) 的后代仍然与 \(2\) 的祖先相连,构成一个新的连通分量:

如何编码实现定理 \(2.\)
我们可以在 \(dfs\) 时记录每个节点的 \(dfn\)(时间戳),以及用一个 \(low\) 数组记录每个节点不经过其父亲能到达的最小的 \(dfn\)
更新 \(low\) 的方法:

  • 如果 \(v\)\(u\) 的儿子,则有 \(low[u] = min(low[u], low[v])\)
  • 如果 \((u, v)\) 是返祖边,则 \(low[u] = min(low[u], dfn[v])\)

下面给出割点模板(luogu P3388)的代码:

#include<bits/stdc++.h>
#define N 20010
using namespace std;
int n, m, u, v, cnt, ans;
int dfn[N], low[N];
bool flag[N];
vector<int>a[N];
void dfs(int cur, int u, int fa){
	cnt++; 
	dfn[u] = low[u] = cnt;
	int child = 0;
	for(auto v : a[u]){
		if(!dfn[v]){
			child++;
			dfs(cur, v, u);
			low[u] = min(low[u], low[v]);
			if(low[v] >= dfn[u] && u != cur){	// 定理2. 
				flag[u] = true;
			}
		}
		else if(dfn[v] < dfn[u] && v != fa){	//是返祖边 
			low[u] = min(low[u], dfn[v]);
		}
	}
	if(u == cur && child >= 2){
		flag[cur] = true;
	}
}
int main(){
	scanf("%d%d", &n, &m);
	for(int i = 1; i <= m; i++){
		scanf("%d%d", &u, &v);
		a[u].push_back(v);
		a[v].push_back(u);
	}
	for(int i = 1; i <= n; i++){
		if(!dfn[i]){	//dfs
			dfs(i, i, -1);
		}
	}
	for(int i = 1; i <= n; i++){
		if(flag[i]){
			ans++;
		}
	}
	printf("%d\n", ans);
	for(int i = 1; i <= n; i++){
		if(flag[i]){
			printf("%d ", i);
		} 
	}
	return 0;
}
2.求割边

求割边时,只需将求割点代码中的

low[v] >= dfn[u]

改成

low[v] > dfn[u]

即可。

双连通分量

双连通分量分为两种:

  • 点双连通分量:如果一个连通分量中不存在割点,则这个连通分量称为点双连通分量。
  • 边双连通分量:如果一个连通分量中不存在割边,则这个连通分量称为边双连通分量。

生成树

先给出生成树的定义:有一张图 \(G\),将其去点一些边,使这张图变成一棵树,这棵树便被称为图 \(G\) 的生成树。
有生成树就有生成树问题,最著名的便是最小生成树问题。

最小生成树问题

在给定的图 \(G\) 中的所有生成树中,边权总和最小的那棵生成树便是最小生成树。问最小生成树的边权总和为多少?
介绍两种基本算法。

1.Kruskal算法

\(Kruskal\) 是一种简单易懂的最小生成树解决办法。它的基本思路就是先对所有边排序,然后从小到大选择,如果出现环则跳过此边(用并查集判环),直到加入边的总数为节点减一结束。

下面给出最小生成树板子(luogu P3366)\(Kruskal\) 算法代码:

#include<bits/stdc++.h>
#define N 5010
#define M 200010
using namespace std;
int n, m, u, v, w, cnt, ans;
int fa[N];
struct node{
	int u;
	int v;
	int w;
}a[M];
bool cmp(node q, node p){
	return q.w < p.w;
}
int find(int x){
	if(x == fa[x]){
		return x;
	}
	return fa[x] = find(fa[x]);
}
void kruskal(){
	sort(a + 1, a + m + 1, cmp);
	for(int i = 1; i <= n; i++){
		fa[i] = i;
	}
	for(int i = 1; i <= m && cnt < n - 1; i++){
		int fu = find(a[i].u);
		int fv = find(a[i].v);
		if(fu != fv){
			fa[fu] = fv;
			cnt++;
			ans += a[i].w;
		}
	}
	if(cnt == n - 1){
		printf("%d", ans);
	}
	else{
		puts("orz");
	}
}
int main(){
	scanf("%d%d", &n, &m);
	for(int i = 1; i <= m; i++){
		scanf("%d%d%d", &a[i].u, &a[i].v, &a[i].w);
	}
	kruskal();
	return 0;
}
2.Prim算法

\(Prim\) 算法的基本思路就是:从任意一个节点作根开始,把点分成加入的和没加入的。每次从没加入的点与加入的点中选取两个节点使得这两个节点之间边权最短,直到加满 \(n - 1\) 条边。

欧拉路

欧拉路是简单的图问题,相当于小孩童玩的“一笔画”游戏。

欧拉路的相关定义

欧拉路:从某个点出发,遍历整个图,图中每条边都只通过一次的路径便是欧拉路。
欧拉回路:起点与终点相同的欧拉路便是欧拉回路。

关于欧拉路的问题主要有两个:是否存在欧拉路与打印欧拉路的路径。问题的解决通过处理点的连接边的数量(度数)来解决,度数为奇数的叫做奇点,度数为偶数的则称为偶点。

欧拉路或欧拉回路的存在性判断

首先,图应是连通的,可以用 \(dfs\) 或并查集来判断连通性。

下面给出无向图与有向图的欧拉路和欧拉回路的存在性判断:

  • 1.无向图
    • 1)如果图中只有两个奇点,则存在欧拉路,一个作起点,一个作终点。
    • 2)如果图中的点都是偶点,则存在欧拉回路,任何一点做起(终)点都行。
  • 2.有向图
    • 1)如果有向图中只有一个出度比入度多一的点 \(u\),只有一个入度比出度多一的点 \(v\),其它点自己的入度与出度相等,则存在欧拉路。\(u\) 作起点,\(v\) 作终点。
    • 2)如果图中所有点自己的入度与出度相同,则存在欧拉回路,任何一点做起(终)点都行。
posted @ 2023-10-02 20:06  lijingqian  阅读(12)  评论(0编辑  收藏  举报