图论总结

update:

2022/11/5 提升阅读体验 考虑放图片

基础知识

数据结构通常分为 线性结构非线性结构 两种类型。

线性结构:结构中的数据元素之间存在一个对一个的关系,除了首元素和尾元素外每一个元素只有唯一的前趋和唯一的后继。例如:队列、栈。

非线性结构:有多个前趋或多个后继

树:除了根结点外,其他结点有一个前趋多个后继。

图:每个结点都有多个前趋和多个后继。

图的概念

一、什么是图?

很简单,点用边连起来就叫做图,严格意义上讲,图是一种数据结构,定义为:graph=(V,E)。V是一个非空有限集合,代表顶点(结点),E代表边的集合。

二、图的一些定义和概念

有向图:图的边有方向,只能按箭头方向从一点到另一点。

无向图:图的边没有方向,可以双向。

结点的度

无向图中与结点相连的边的数目,称为结点的度。

结点的入度

在有向图中,以这个结点为终点的有向边的数目。

结点的出度

在有向图中,以这个结点为起点的有向边的数目。

权值

边的“费用”,可以形象地理解为边的长度。

连通

如果图中结点U,V之间存在一条从U通过若干条边、点到达V的通路,则称U、V 是连通的。

回路

起点和终点相同的路径,称为回路,或“环”。

完全图

一个 n 阶的完全无向图含有 n(n1)/2n*(n-1)/2 条边;

一个 n 阶的完全有向图含有 n(n1)n*(n-1) 条边;

(说明:无向图中,一个点可以连接 n-1 个点,但是会有重复的问题,有向图中不会。)

稠密图

一个边数接近完全图的图。

稀疏图

一个边数远远少于完全图的图。

强连通分量

有向图中任意两点都连通的最大子图。特殊地,单个点也算一个强连通分量

强连通图

如果有向图中的任意两个图都是强连通的,那么便称这个图为强连通图。

图的储存

主要有两种方式,邻接表与邻接矩阵。

在稀疏图中,邻接表的效率比邻接矩阵高

稠密图中差不多。

邻接矩阵

定义一个数组: g[i][j] ,表示从第 i 个点到 第 j 个点的权值。

如果要访问从 i 开始的边,则需要不断的访问其他点,所以就会慢一些。

赋值情况如下:

  • 1 或权值 有边

  • 0 或 ++ \infty 没边

(不带权的图为前者,带权图为后者)

模板

#include<iostream>
  using namespace std;
  int i,j,k,e,n;
  double g[101][101];double                                                    w;
  int main()
  {
    int i,j;
    for (i = 1; i <= n; i++)
      for (j = 1; j <= n; j++)
        g[i][j] = 0x7fffffff(赋一个超大值);  //初始化,对于不带权的图g[i][j]=0,表示没有边连通。这里用0x7fffffff代替无穷大。
     cin >> e;
     for (k = 1; k <= e; k++)
    {
         cin >> i >> j >> w;             //读入两个顶点序号及权值
         g[i][j] = w;                    //对于不带权的图g[i][j]=1
         g[j][i] = w;                    //无向图的对称性,如果是有向图则不要有这句!
    } 
    …………
    return 0;
}

链式前向星(邻接表)

图的邻接表存储法,又叫链式存储法。本来是要采用链表实现的,但大多数情况下只要用数组模拟即可。

在稀疏图中,邻接表之所以比邻接矩阵快,是因为邻接表是以每条边来储存。

所以邻接表的原理其实就是把边组合起来,使得可以以边来遍历。

链式前向星与邻接表的区别

当添加一条边时,邻接表是从 head[] 的最后进行存储,也就是后放入的后取。

举个例子:

x[1] x[2] x[3] x[4];

如果以邻接表来存储 x[5] ,那么就是存到 x[4] 的后面,但是有一个问题:不知道现在有几条边,创建一个新数组 tail[] 来记录 。

这样使代码复杂化,而链式前向星则是存到 x[1] 之前,第一个访问的为 x[5] ,再按顺序去访问。

也就是 x[5] x[1] x[2] x[3] x[4];

vector 容器(动态数组)实现邻接表

有时候题目要求我们按照字典序输出,此时使用链式前向星就不能满足题目的要求,这时使用 vector 就可以实现。

使用 vector 需要添加 vector 头文件 ;

vector 的定义: vector < type > name ;

vector 的访问: 使用迭代器或是下标访问 ;

常用函数

name.size(); name 的长度。

name.push_back(a); 在 name 的末尾添加 a 。

sort(name.begin(),name.end()); 对 name 进行排序。


参考程序

#include <iostream>
using namespace std;
const int maxn=1001,maxm=100001;
struct Edge
{
	int next;//下一条边的编号 
	int to;//这条边到达的点 
	int dis;//这条边的长度 
}edge[maxm];
int head[maxn],num_edge,n,m,u,v,d;
//head[from] 表示从 from 开始的边的编号
void add_edge(int from,int to,int dis)//加入一条从from到to距离为dis的单向边 
{
	edge[++num_edge].next=head[from];//下一条边访问之前从from开始的边
	edge[num_edge].to=to;
	edge[num_edge].dis=dis;
	head[from]=num_edge;//新的从 from 开始的边
}

int main()
{
	num_edge=0;
	scanf("%d %d",&n,&m);//读入点数和边数
	for(int i=1;i<=m;i++)
	{
	      scanf("%d %d %d",&u,&v,&d);//u、v之间有一条长度为d的边 
	      add_edge(u,v,d);//此处如果为无向图,要另加一条v到u的边
	}
	DFS(1);// 从第 1 条边开始访问
	return 0;
}

两种方法各有用武之地,根据题目自行判断。

一些基础的东西便到此结束。

图的遍历

图的遍历主要分为两种方式,深度优先与广度优先。

深度优先可以认为是:“不撞南墙不回头”。

而广度优先搜索则为“需要走的步数多的后访问”。

两种方式的时间复杂度相同。

图的遍历其实就是通过一种恒定的标准,去访问,并且不能重复的访问一条边。我们可以用 visit[] 来存储是(true)否(false)被访问。

一般来说使用的是深度优先遍历,并且用邻接表存储。


参考程序

void DFS(int a)
{
	vis[a]=1;//已经走过
	for(int i=head[a]; i; i=edge[i].next)//按顺序访问每一条边
	{
		int to=edge[i].to;//找到一个到达的点
		if(vis[to]==0)//还没有被访问过
			DFS(to);//访问那个点
	}
}

但是这样访问会出现一个问题,万一这个图没有被连通就不能访问完所有的边(因为访问不到另一边的点)。

那怎么办呢?其实就可以按照顺序分别访问,把还没有被访问的点访问一遍。

int main() {
	for(int i=1;i<=n;i++) {
      if(!vis[i]) dfs(i);
   }
}

欧拉路与欧拉回路

定义(一笔画)

  • 欧拉路:所有的边可以一次走完的路径。

  • 欧拉回路:所有的边可以一次走完并且可以回到原点的路径。

特点

都在连通图的时候才成立。

  • 欧拉路:对于起点与终点,进去后就不再出来,自然为奇点;对于其他点,进去了要出来,去其他的点。所以有且只有两个奇点

  • 欧拉回路:对于起点(同样也是终点)来说,从这里先出去最后回来,才构成欧拉回路,所以为偶点。对于其他点来说,进去了还要回起点,所以也是偶点。所以欧拉回路全是偶点

实现

深度优先遍历来实现,如果边搜索边输出,就会有一个问题:搜索提前走到了终点,很明显欧拉路不能成立

所以需要寻找新的方法,我们会发现,如果搜索走到了一个点并且发现没有任何边可以走了,这就说明了一个情况:这个点,进得去,出不来,说明这就是一个奇点。前面说到,欧拉路的起点与终点都是奇点,所以就可以得出如果 dfs 没有路可走了,那一定是到终点了

所以就可以利用上述结论实现:

void oula(int p) {
	vis[p]=1;
   for(int i=head[p];i;i=edge[i]。next) {
   		int to=edge[i].to;
      if(vis[to]==0) dfs(to);
   }
   ans[++cnt]=to;
}

判断欧拉路的连通性

在一个图中,如果为欧拉路,路径的点数=边数+1

所以可以利用以上结论验证图的连通性


最短路问题

最短路径( Shortest Path ):对在权图 G=(V,E) ,从一个源点 s 到汇点 t 有很多路径,其中路径上权和最少的路径,称从 s 到 t 的最短路径。

简单来说:对于一个有权图来说,两个定点的权值最少的路径。

分为单源最短路和全局最短路,有很多种算法可以求解,下面将一一介绍。

首先介绍关于最短路的定理:

以下讨论的均为无负环情况

“三角形定理”

假设原点 s 到 x 和 y 点的最短距离为 dis[x] , dis[y] ,并且从 x 到 y 的距离为 graph[x][y] ,则满足一个条件:dis[x] + graph[x][y] ≥ dis[y] ;

其实并不难理解,因为 dis[x] 是最短路径,加上 graph[x][y] 可能是最短距离,也可能不是,所以上述条件成立。

松弛(改进)

根据上面的三角形定理,就可以得出一个十分重要的操作:松弛。

具体就是 dis[x]+graph[x][y]dis[y]dis[x] + graph[x][y] ≥ dis[y] 不成立,说明出现了一个问题: dis[y]dis[y] 不是最短路, dis[x]+graph[x][y]dis[x] + graph[x][y] 的花费已经比 dis[y]dis[y] 少了。想要获取最短路,只需将 dis[y]dis[y] = dis[x]+graph[x][y]dis[x] + graph[x][y]

这就是最短路算法的基本思想。

全局最短路

起点与终点不确定,只要这两个点属于这个图。

floyd

现在小 c 准备去一个地方旅游,有些地方有直达的公路,有些没有。

现在他要从 A 点到 B 点,想知道 A 到 B 的最短距离。

问题

如果从 A 点到 B 点有直达路线,那么从 A 到 B 的最短距离一定为 len[a][b]吗?

非也。假设有 C 点,那么以 c 作为中转站 len[a][c] + len[c][b] ,有可能比 len[a][b] 要短。那该怎么样知道从 a 到 b 要通过哪一个中转站呢?

十分简单,枚举就好了。

而且只有求完小的路线才能求出大的路线。


这就是 floyd 的思路,与区间 dp 很相似。

时间复杂度: O(N3)O(N^3)

边的增加对效率没有影响,容易理解。

代码
for(int k=1;k<=n;k++)//
    for(int i=1;i<=n;i++)
        for(int j=1;j<=n;j++)
                a[i][j]=min(a[i][j],a[i][k]+a[k][j]);
应用

传递闭包:假设 a[i][j]==1 代表 i 比 j 大。如果 a[i][k]==1 && a[k][j]==1 ,那么可以保证 a[i][j] = 1 。

代码:

for(int k=1; k<=n; k++)
    for(int i=1; i<=n; i++)
         for(int j=1; j<=n; j++)
               if(a[i][k]&&a[k][j]) a[i][j]=1;

运用这种类似于动态规划的思想,也可以求出最小环问题:

#include<bits/stdc++.h>
using namespace std;
int main() {
	for(int k=1;k<=n;k++){
		for(int i=1;i<=k-1;i++)
            for(int j=i+1;j<=k-1;j++)
        	answer=min(answer,dis[i][j]+g[j][k]+g[k][i]);
     for(int i=1;i<=n;i++)
            for(int j=1;j<=n;j++)
           dis[i][j]=min(dis[i][j],dis[i][k]+dis[k][j]);
	}
    return 0;
}

单源最短路

单源最短路是指有图 graph(V,E) ,从一个定点到另外的所有点的最短路。有一个神奇的点在于,求到一个点与求到所有点的时间复杂度是相同的。

dijkstra

dijkstra(以下简称 dij) 的原理是贪心。

时间复杂度为 O(N2)O(N^2) ; 不能处理有负边权的情况。

dij 其实不难。把所有的顶点分成两部分,一个是已经求出最短路的( Y ),一个是没有求出最短路的( N ),最开始 Y 里面没有顶点。

步骤如下:

(1) 把起点的最短路赋值为 0 。

(2) 找一个没有被访问的最短路最短的点 x (刚开始为起点),放进 Y 。

(3) 对与 x 相连的点做松弛操作。

(4) 如果已|Y|=|V|(所有点都求出最短路了),结束。否则执行(2)。


因为图中是不存在负边权的,所以如果当前点的最短路是最短的,就可以发现其他任何在 N 的点都不可能比当前短了,就可得出 当前点一定是最短路,所以加进 Y 里 。

每次循环都能求出一个点的最短路,循环 n 次就求出了所有点的最短路。

dijkstra 模板

//链式前向星实现dijkstra
//模板,根据实际更改
//yfz233
#include<bits/stdc++.h>
using namespace std;
const int MAXN=11111;
const int MAXM=611111;
struct Node {
	int next,to,dis;
}edge[MAXM];
int n,m,from,to,dis;
int head[MAXN],num_edge; 
void add_edge(int from,int to,int dis) {
	num_edge++;
	edge[num_edge].next=head[from];
	edge[num_edge].to=to;
	edge[num_edge].dis=dis;
	head[from]=num_edge;
}
int dis[MAXN],v[MAXN],pre[MAXN];
//dis:从 s 到点 i 的最短距离
//v:是否对点 v 进行松弛操作 
void dijkstra(int s) {//从源点 s 到其他点的最短距离
	for(int i=0;i<MAXN;i++) {
      dis[i]=2147483647;
	}
	dis[s]=0;
	for(int i=1;i<=n;i++) {
		int u=0;
		for(int j=1;j<=n;j++) {
			if(v[j]==0&&dis[j]<dis[u]) {
				u=j; 
			}
		}
		if(u==0) break;//如果图为连通则可以松弛,但找不到可以松弛的了(图不连通)
		v[u]=1; //改变u点的访问状态
		for(int j=head[u];j;j=edge[j].next) {
			int to=edge[j].to;
			if(dis[to]>dis[u]+edge[j].dis) {
				dis[to]=dis[u]+edge[j].dis,pre[to]=u;
			//pre:路径 
			}
		}
	}
}
//打印路径
void print(int q) {
	if(q==0) return ;
	print(pre[q]);
	cout<<' '<<q;
} 
int main() {
	scanf("%d%d",&n,&m);
	for(int i=1;i<=m;i++) {
		scanf("%d%d%d",&from,&to,&dis);
		add_edge(from,to,dis);
		//add_edge(from,to,dis);
		//无向图 
   }
	int start,end;
	scanf("%d%d",&start,&end);
	dijkstra(start);
	cout<<dis[end];
	return 0;
}
//邻接矩阵实现dijkstra
//模板,根据实际更改
//yfz233
#include<bits/stdc++.h>
using namespace std;
const int MAXN=11111;
int graph[MAXN][MAXN]; 
int dis[MAXN],v[MAXN],pre[MAXN];
//dis:从 s 到点 i 的最短距离
//v:是否对点 v 进行松弛操作 
void dijkstra(int s) {//从源点 s 到其他点的最短距离
	for(int i=0;i<MAXN;i++) {
      dis[i]=2147483647;
	}
	dis[s]=0;
	for(int i=1;i<=n;i++) {
		int u=0;
		for(int j=1;j<=n;j++) {
			if(v[j]==0&&dis[j]<dis[u]) 
				u=j; 
			}
		}
		if(u==0) break;//找不到可以松弛的了
		v[u]=1; //改变u点的访问状态
		for(int j=1;j<=n;j++) {
			if(dis[j]>dis[u]+graph[u][j]) {
				dis[j]=dis[u]+graph[u][j],pre[j]=u;
			//pre:路径 
			}
		}
	}
}
//打印路径
void print(int q) {
	if(q==0) return ;
	print(pre[q]);
	cout<<' '<<q;
} 
int main() {
	int n;
	scanf("%d%d",&n);
	for(int i=1;i<=n;i++) { 
		for(int j=1;j<=n;j++) {
			scanf("%d",&graph[i][j]);
			if(graph[i][j]==0) graph[i][j]=0x7ffffff;			
		}
		graph[i][i]=0;
	}
	int start,end;
	scanf("%d%d",&start,&end);
	dijkstra(start);
	cout<<dis[end];
	return 0;
}

Bellman-ford

Bellman-ford( 以下简称 ford ) 的时间复杂度高,为 O(VE)O(VE);(点数,边数)

优点是可以处理有负边权的情况,适用于稀疏图。

Bellman-ford 就是每次都用所有的边进行松弛,就可以求出一个估计的最短路,循环 N-1 次就可以求出最短路。

根据 dijkstra 的证明可以知道,每次松弛完后最短的就是一个新的点的最短路。所以同样可以证明, Bellman-ford 也可以求出最短路。

下面是详细步骤:

(1) :初始化,源点初始化为 0 , 其他赋值为正无穷。

(2) :循环 n-1 次,遍历所有的边,松弛。

(3) :再判断同样的边能否松弛,如果还有得松弛,就说明有负边。

代码

void Bellman_ford (int s) {
	memset(dis,125,sizeof(dis));
	dis[s]=0;
	for(int i=1;i<n;i++) {
		for(int j=1;j<=num_edge;j++) {
			int from=edge[j].from;
			int to=edge[j].to;
			if(dis[to]>dis[from]+edge[j].dis) {
				dis[to]=dis[from]+edge[j].dis;
			}
		}
	}
}

SPFA

可以看作 ford 的加强版,复杂度为 O(KE)O(KE) ,其中 K 为常数,平均为 2 , E 为边的数量。

在比赛中可能会因为图的不同(稠密图,构造的网格图)而导致超时,退化成 ford 。

可以判断负环。

其原理就是从 ford 的每次遍历所有的边入手,在 dij 中每次只需要遍历相连的边即可,但是在 ford 中却遍历所有的边,显然大大增加了耗费的时间,而这些边有些时候对于求最短路没有效果。

算法的步骤:

(1)创建一个队列 q ,用于保存需优化的节点,最开始里面放入源点。

(2)不断循环,找当前队首的节点 f ,并 找到 f 指向的节点 z 做松弛操作。

(3)如果优化成功,就说明这个被优化的节点还可以优化其他的节点,故加入队尾。

(4)如果发现一个节点的入队次数 > n-1 次,那就说明有负环。否则回到(2)直到队列为空。

代码

queue<int>q;
void spfa(int s) {
	memset(dis,125,sizeof(dis));noans=dis[0];
	memset(v,0,sizeof(v));
	dis[s]=0;v[s]=1;q.push(s);
	while(!q.empty()) {
		int x=q.front();q.pop();v[x]=0;
		for(int i=head[x];i;edge[i].next) {
			int to=edge[i].to;
			int cost=edge[i].dis;
			if(dis[t]>dis[x]+cost) {
				dis[t]=dis[x]+cost;
				if(v[t]==0) q.push(t);v[t]=1;
			}
		}
	} 
}

最短路的东西很多,图论的东西更多,下面来讲并查集。

并查集

英文:Disjoint Set (不相交集合),一种树形数据结构,如英文的翻译所示,它用于解决一些关于不相交集合的合并与查询的问题,由主要的操作是“并”与“查”,因此被称为“并查集”。

基础的方法:
int find(int x) {//查询 O(1)
	return fa[x];
}

void merge(int x,int y) {//合并 O(N)
	int a1=find(x);
	int a2=find(y);
	int s=min(x,y);
	int b=max(x,y);
	for(int i=1;i<=n;i++) {
		if(fa[i]==b) fa[i]=s;
	} 
}

对于最基础的并查集来说,它属于线性结构,合并速度不够快。但是可以优化,加快速度。


对于并查集有两个优化方法。

  • 对于合并速度的改进,可以把并查集改成树形结构,在合并的时候,把当前这颗树的父亲指向另一棵树。但是每当查询元素的时候,时间复杂度就会变成树的深度。

  • 这个问题,可以用类似于记忆化的思想解决。每当查找的时候,就把这棵树的父亲直接指向最后寻找到的父亲。这样把树的深度压缩。这种操作叫做压缩路径。可以把时间复杂度变成一个小常数。

  • 那对于合并还有更好的优化方式吗?其实是有的。可以将深度小的树合并至深度大的树。有一个显而易见的结论是:查询的时间与树的深度是有关的。根据这个结论,可知这种方法可以减少查询的时间。这种方法叫做按秩排序。

但是一般来说,只需要运用路径压缩的方法就能让并查集变得快很多。所以大多数情况下,仅仅用到压缩路径。

int find(int x) {//递归 
	if(x!=fa[x]) {
		fa[x]=find(fa[x]);
	}
	return fa[x];
}
int find(int x) {//非递归 
	int r=x;
	while(r!=fa[r]) {
		r=fa[r];
	}
	while(x!=r) {
		int c=x;
		x=fa[x];
		fa[c]=r;
	}
	return r;
}
void merge(int aa,int bb) {
	int a=find(aa);
	int b=find(bb);
	if(a!=b) fa[b]=a;
}
posted @ 2022-06-23 13:40  cjrqwq  阅读(37)  评论(0)    收藏  举报  来源