图论总结
update:
2022/11/5 提升阅读体验 考虑放图片
基础知识
数据结构通常分为 线性结构 和 非线性结构 两种类型。
线性结构:结构中的数据元素之间存在一个对一个的关系,除了首元素和尾元素外每一个元素只有唯一的前趋和唯一的后继。例如:队列、栈。
非线性结构:有多个前趋或多个后继。
树:除了根结点外,其他结点有一个前趋多个后继。
图:每个结点都有多个前趋和多个后继。
图的概念
一、什么是图?
很简单,点用边连起来就叫做图,严格意义上讲,图是一种数据结构,定义为:graph=(V,E)。V是一个非空有限集合,代表顶点(结点),E代表边的集合。
二、图的一些定义和概念
有向图:图的边有方向,只能按箭头方向从一点到另一点。
无向图:图的边没有方向,可以双向。
结点的度
无向图中与结点相连的边的数目,称为结点的度。
结点的入度
在有向图中,以这个结点为终点的有向边的数目。
结点的出度
在有向图中,以这个结点为起点的有向边的数目。
权值
边的“费用”,可以形象地理解为边的长度。
连通
如果图中结点U,V之间存在一条从U通过若干条边、点到达V的通路,则称U、V 是连通的。
回路
起点和终点相同的路径,称为回路,或“环”。
完全图
一个 n 阶的完全无向图含有 条边;
一个 n 阶的完全有向图含有 条边;
(说明:无向图中,一个点可以连接 n-1 个点,但是会有重复的问题,有向图中不会。)
稠密图
一个边数接近完全图的图。
稀疏图
一个边数远远少于完全图的图。
强连通分量
有向图中任意两点都连通的最大子图。特殊地,单个点也算一个强连通分量。
强连通图
如果有向图中的任意两个图都是强连通的,那么便称这个图为强连通图。
图的储存
主要有两种方式,邻接表与邻接矩阵。
在稀疏图中,邻接表的效率比邻接矩阵高。
稠密图中差不多。
邻接矩阵
定义一个数组: g[i][j] ,表示从第 i 个点到 第 j 个点的权值。
如果要访问从 i 开始的边,则需要不断的访问其他点,所以就会慢一些。
赋值情况如下:
-
1 或权值 有边
-
0 或 没边
(不带权的图为前者,带权图为后者)
模板
#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] 可能是最短距离,也可能不是,所以上述条件成立。
松弛(改进)
根据上面的三角形定理,就可以得出一个十分重要的操作:松弛。
具体就是 不成立,说明出现了一个问题: 不是最短路, 的花费已经比 少了。想要获取最短路,只需将 = 。
这就是最短路算法的基本思想。
全局最短路
起点与终点不确定,只要这两个点属于这个图。
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 很相似。
时间复杂度: ;
边的增加对效率没有影响,容易理解。
代码
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) 的原理是贪心。
时间复杂度为 ; 不能处理有负边权的情况。
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 ) 的时间复杂度高,为 ;(点数,边数)
优点是可以处理有负边权的情况,适用于稀疏图。
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 的加强版,复杂度为 ,其中 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;
}

浙公网安备 33010602011771号