1、图的基本概念和术语
前面学过:
- 线性是一对一
- 树形是一对多
而今天要学习的图形结构是多对多。
图的定义:
G=(V,E)
- V:顶点(数据元素)的__有穷非空__集合。
- E:边的有穷集合。
__有向图:__每条边都是有方向的
__无向图:__每条边都是无方向的
__完全图:__任意两个点都有一条边相连
而完全图又分为两部分:无向完全图,有向完全图。
__无向完全图:__n个顶点,有n(n-1)/2条边。
__有向完全图:__n个顶点,n(n-1)条边。
__稀疏图:__有很少边或弧的图。
__稠密图:__有较多边或弧的图。
__网:__边/弧带权的图。
__邻接:__有边/弧相连的两个顶点之间的关系。
存在(vi,vj),则称vi和vj互为邻接点。
存在<vi,vj>,则称vi邻接到vj,vj邻接于vi。
这里有点离散数学的知识:如果是(vi,vj),那vi和vj部分先后;如果是<vi,vj>,就是有先后的,vi是前,vj是后,所以说<vi,vj>,是vi邻接到vj,vj邻接于vi。
__关联(依附):__边/弧与顶点之间的关系。存在(vi,vj)/<vi,vj>,则称该边/弧关联于vi和vj。
__顶点的度:__与该顶点相关联的边的数目,记为TD(v)。
在__有向图__中,顶点的度等于该顶点的__入度__与__出度__之和。
顶点v的__入度__是以v为终点的有向边的条数,记作ID(v)。
顶点v的__出度__是以v为始点的有向边的条数,记作OD(v)。
下面示例,顶点的度:
上面是个无向图,所以各顶点的度为:
下面是个有向图:需要分入度和出度
下面是各顶点的度,找入度和出度的有个简单的方法。入度就是其它结点指向本结点的边的条数;出度就是本结点指向其它结点的边的条数。
__问题:__当有向图中仅1个顶点的入度为0,其余顶点的入度均为1,此时是何形状?
答案:是一个树,而且是一棵有向树。如下:
__路径:__连续的边构成的顶点序列。
__路径的长度:__路径上边或弧的数目/权值之和。
举个例子,如下无向图:
v1到v4的路径,就是:v1v3v4。【注意】不能写v1到v3,v3到v4。路径的定义是顶点序列
,一定要直接用顶点取表示。
那v1到v4路径长度,上图没有带权值,那就是边的数目和,就是2。如果给出v1到v3的权值是12,v3到v4的权值是15,那v1到v4路径长度就为12+15=27。
__回路(环):__第一个顶点和最后一个顶点相同的路径。
__简单路径:__顶点不重复的路径。
__简单回路(简单环):__除路径起点和终点相同外,其余顶点均不相同的路径。
连通图(强连通图):在无(有)向图G=(V,{E})中,若对任何两个顶点v、u都存在从v到u的路径,则称G是连通图(强连通图)。
- 其中连通图为无向图。
- 强连通图为有向图。
v0、v1、v2、v3、v4任意结点可以连通。
v0和v4、v5不能相连。
下面再来看一下强连通图和非强连通图:
__子图:__设有两个图G=(V,{E})、G1=(v1,{E1}),若V1**∈** V(V1属于V),E1**∈**E(E1属于E),则称G1是G的子图。G1的顶点和边是G的子集。
例:(b)、(c)是(a)的子图。
__连通分量(强连通分量):无向图G的__极大连通子图__称为G的__连通分量。
极大连通子图意思是:孩子图是G连通子图,将G的任何不在孩子图中的顶点加入,子图不再连通。
如下:
__强连通分量:__有向图G的__极大强连通子图__称为G的强连通分量。
极大强连通子图的意思是:该子图是G的强连通子图,将D的任何不再该子图中的顶点加入,子图不再是强连通的。
__极小连通子图:__该子图是G的连通子图,在孩子图中删除任何一条边,子图不在连通。
__生成树:__包含无向图G所有顶点的极小连通子图。
__生成森林:__对非连通图,由各个连通分量的生成树的集合。
当然G1生成树,也可以画成下面树的形状:
2、图的存储结构
上面说到图的逻辑结构是多对多的。
图没有顺序存储结构,但我们可以借助二维数组来表示元素间的关系。
这种叫做:数组表示法(邻接矩阵法)。
那能不能用来链式存储结构呢?起始也能,就有有点麻烦,因为无法确定一个结点有多少个前驱和后继,导致无法确定指针域的个数。所以不建议使用链表进行存储。
但是也能用链式存储结构来存储图。
使用多重链表:
- 邻接表。
- 邻接多重表。
- 十子链表。
这一章重点介绍:
- 邻接矩阵(数组)表示法。
- 邻接表(链式)表示法。
2.1、数组(邻接矩阵)表示法
核心思想:
-
建立一个__顶点表__(记录各个顶点信息)这个顶点表是个一维数组,和一个__邻接矩阵__(表示各个顶点之间关系,就是边)此邻接矩阵是个二维数组。
-
设图A=(V,E)有n个顶点,则,顶点表Vexs[n]
-
图的邻接矩阵是一个二维数组A.arcs[n][n],此邻接矩阵,是表示各个顶点之间的关系,也是表示边的。如果两个顶点之间有边,那就把A.arcs[i][j]所在位置的值标识为1,否则标识为0。
那这个二维数组是多大的呢?答案:图中有几个顶点,就是n*n的二维数组。因为我们要确定任意两点之间的关系,所以需要横纵坐标都遍及到,所以需要n*n的二维数组。
-
了解了邻接矩阵的核心思想之后,我们来根据图具体表示。
2.1.1、无向图的邻接矩阵表示法
如下图:
那邻接矩阵A.arcs[i][j]就如下表示:
我们来发现此邻接矩阵有什么规律?
-
首先两主对角线全部为0。
-
此矩阵关于对角线对称:为什么会是这样的呢?原因:假如v1和v3之间有边,那反过来v3和v1一样有边,同理其它任意两个结点也是同样的道理,所以在二位数组中就会呈现对称的情况。
那如何计算某个顶点的度呢?(度的定义:和某一个顶点相关联的边的个数)。
如右计算:顶点i的度=第i行(列)中1
的个数。
那如果此图是完全图,如下,邻接矩阵是什么样呢?(完全图:任意两个顶点都有边相连)
那此图对应的邻接矩阵,只有对角元素为0,其余为1,如下:
2.1.2、有向图的邻接矩阵
有向图的邻接矩阵核心思想和无向图一样。这里不在阐述。
有向图的邻接矩阵也是二维数组。并且有向图中有几个顶点,那二维数组的大小就是n*n。很明显,根据上图,这里的有向图的邻接矩阵是4*4的二维数组。
主要探讨有向图的邻接矩阵如何表示,可以这样:
如上有向图,v1顶点指向v2和v3,那我们就在A.arcs[v1][v2]和A.arcs[v1][v3]位置处标记为1;又因为v1不指向其本身,并且不指向v4,所以A.arcs[v1][v1]为0,A.arcs[v1][v4]为0。
然后再看v2、v3、v4的指向。就可以得出此邻接矩阵的表示了。如下图:
【注】:在有向图的邻接矩阵中
- __第i行__含义:以节点vi为尾的弧(即出度边)。
- __第i列__含义:以节点vi为头的弧(即入度边)。
如何计算此有向图中任意顶点的度呢?
这里需要出度和入度分开求:
-
顶点的__出度__=第i行元素之和。
-
顶点的__入度__=第i列元素之和。
-
顶点的__度__=第i行元素之和+第i列元素之和。
2.1.3、网(有权图)的邻接矩阵
网友无向/有向网,这里已有向网举例。
网的邻接矩阵也是二维数组。并且道理一样,二维数组的大小根据网中顶点个数确定,假设有n个顶点,二维数组的大小就为:n*n。
网的邻接矩阵表示法:如果i、j两个顶点之间有弧或者边,那使A.arcs[i][j]=Wij(i、j两顶点之间的权值)。
否则标记为无穷。如下:
现有如下有向网:
如下表示此邻接矩阵:
2.1.4、邻接矩阵的代码实现
算法思想:
- 输入总顶点数和总边数。
- 依次输入点的信息存入顶点表中。(单层循环依次输入)。
- 初始化邻接矩阵,使每个权值初始化为极大值(32767)(双重循环实现)。
- 构造邻接矩阵。
结构体代码,这一部分上面的无向图和有向图都可以用。
C
//用两个数组分别存储顶点表和邻接表。
#include <stdio.h>
#define MVNum 100 //最大顶点数
#define MaxInt 32767 //表示极大值,即无穷
typedef char VerTexType; //设顶点的数据类型为字符型
typedef int ArcType; //假设边的权值类型为整型
typedef struct
{
VerTexType vexs[MVNum]; //顶点表
ArcType arcs[MVNum][MVNum]; //邻接矩阵
int vexnum, arcnum; //图的当前点数和边数
}AMGraph;
int main()
{
return 0;
}
这里采用邻接矩阵表示法创建无向网
代码实现:
C
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#define MVNum 100 //最大顶点数
#define MaxInt 32767 //表示极大值,即无穷
typedef char VerTexType; //设顶点的数据类型为字符型
typedef int ArcType; //假设边的权值类型为整型
typedef struct
{
VerTexType vexs[MVNum]; //顶点表
ArcType arcs[MVNum][MVNum]; //邻接矩阵
int vexnum, arcnum; //图的当前点数和边数
}AMGraph;
//查找顶点下标
int LocateVex(AMGraph *G, char v)
{
for (int i = 0; i < G->vexnum; i++)
{
if (G->vexs[i] == v)
{
return i;
}
}
}
//邻接矩阵表示法创建无向网
void CreateUDN(AMGraph *G)
{
int w; //代表权值
char v1, v2; //代表任意两个顶点
int ii, jj; //接收结点的位置
//输入总定点数
scanf("%d", &G->vexnum);
//输入总边数
scanf("%d", &G->arcnum);
//输入顶点信息
for (int i = 0; i < G->vexnum;i++)
{
//由于这里需要循环多次使用scanf读入数据会出现回车从缓存区被读入的情况
//所以前面加个空格,用于清理缓冲区
scanf(" %c", &G->vexs[i]);
}
//初始化邻接矩阵
for (int i = 0; i < G->vexnum; i++)
{
for (int j = 0; j < G->vexnum; j++)
{
G->arcs[i][j] = MaxInt;
}
}
//创建邻接矩阵
for (int k = 0; k < G->arcnum; k++)
{
//输入一条边所依附的顶点及边的权值
scanf(" %c%c%d", &v1, &v2, &w);
ii = LocateVex(G, v1); //定位顶点v1 的位置
jj = LocateVex(G, v2); //定位顶点v2的位置
G->arcs[ii][jj] = w; //给边(v1,v2)赋权值
G->arcs[jj][ii] = G->arcs[ii][jj]; //给边(v2,v1)赋权值
}
}
int main()
{
AMGraph G;
CreateUDN(&G);
return 0;
}
如果是创建无向图,算法改动两处:
- 初始化邻接矩阵时,将边的权值均初始化为0;
- 构造邻接矩阵时,将权值w改为常量1即可。
同时,构建有向图(网)也是需要稍微改动一下算法即可,去掉倒数第四行代码:
如果是创建有向网,算法改动一处即可:
- 邻接矩阵是非对称矩阵,仅为
G.arcs[i][j]
赋值,无需为G.arcs[j][i]
赋值。
2.1.5、邻接矩阵表示法的优缺点
优点:
-
直观、简单、好理解。
-
方便检查任意一对顶点间是否存在边。
-
方便找任一顶点的所有"邻结点"(有边直接相连的顶点)。
-
方便计算任一顶点的"度"(从该点发出的边数为"出度",指向该点的边数为"入度")
- 无向图:对应行(或列)非0元素的个数。
- 有向图:对应行非0元素的个数是"出度",对应列非0元素的个数是"入度"。
缺点:
- 不便于增加和删除顶点。
- 浪费空间------------存稀疏图(点很多而边很少)有大量无效元素。
- 浪费时间------------统计稀疏图中一共有多少条边。
2.2、邻接表
邻接表使用链式存储结构
2.2.1、无向图的邻接表
邻接表表示法(链式),如下是个无向图:
存储结构有两部分:顶点,关联同一顶点的边。
1、__顶点:__顶点用一维数组进行存储,但是这个一维数组每个元素有两部分:一个是data域用来存放顶点数据,一个是指针域firstarc,用于链接存储连接边的第一个结点。
这里的数组的就可以看做是头结点,如下:
2、__边:__同一顶点的边用线性链表存储,链表中某一个结点有两个指针域,一个是adjvex
,另一个是nextarc
。其中adjvex
指针域用来存储,此顶点与之对应的顶点的下标。nextarc
指针域用来存储下一个结点的地址。
线性链表的结构,如下:
【补充】:如果用邻接表表示网的话,就再此结点上多加个数据与,用来存放权值。
我们再来看一下整体存储顶点和边的结构图:
使用邻接表表示无向图的特点:
- 邻接表不唯一。
- 若无向图中有n个顶点,e条边,则其邻接表需n个头结点和2e个表结点。适宜存储稀疏图。
- 求度的个数很方便,直接统计单链表中的结点数。
2.2.2、有向图的邻接表
有向图的邻接表相较无向图的邻接表来说比较简单。
每条边只需要存储依次即可。保存那一条边呢?答案:保存一此顶点为弧尾的那一条边。
比如下图结点v1:
v1有三条边,但是v1作为弧尾的边只有两条,<v1,v2>和<v1,v3>。所以以为v1顶点的链表中只有顶点v2和v3,如下图:
除此之外,有向图和无向图的存储结构一样:
- 顶点用一维数组,并且每个结点中都有两个域,一个是data域用来存放顶点数据,一个是指针域firstarc,用于链接存储连接边的第一个结点。
- 边用线性链表存储,每个结点也都有两个域,一个是
adjvex
,另一个是nextarc
其中adjvex
指针域用来存储,此顶点与之对应的顶点的下标。nextarc
指针域用来存储下一个结点的地址。
使用邻接表实现有向图的特点:
找出度易,找入度难。
-
顶点vi的出度为第i个单链表中的结点个数,如下:
-
但是相对来说,求入度个数就有点复杂了。需要遍历整个邻接表中单链表。顶点vi的入度为整个单链表中邻接点域值是i-1的结点个数。
当然如果对求入度个数很频繁,我们还可以使用邻接表将有向图这样存储:
我们在存储时,只存储入度边,也就是存储以此顶点为弧头的边。
这样存储的邻接表叫做:逆邻接表。
此特点有向图:
找入度易,找出度难。
- 顶点vi的入度为第i个单链表中的结点个数。
- 顶点vi的出度为整个单链表中邻接点域值是i-1的结点个数。
练习
已知某网的邻接(出边)表,请画出该网格。
题目中说,某网的出边表,说明此网是个有向网。并且此邻接表是出度邻接表。
那我们根据此邻接表就可以还原出该网。
2.2.3、建立邻接表的算法
以下算法基于如下图:
(1)定义顶点的结构
上面说到了顶点的结构,有两个域,一个data
域用于存储顶点数据,一个firstarc
域用于连接其它与之对应顶点。
那顶点结构体定义:
C
//定义顶点结构体
typedef struct VNode
{
VerTexType data; //顶点信息
ArcNode* firstarc; //指向第一条依附该顶点的边的指针
}VNode,AdjList[MVNum];
(2)边的结构体定义
C
typedef char VerTexType;
typedef int OtherInfo;
#define MVNum 100
//定义边结构体
typedef struct ArcNode
{
int adjvex; //边结点
struct ArcNode* nextarc; //指向下一条边的指针
OtherInfo info; //和边相关的信息
}ArcNode;
(3)图的结构题定义
C
//图的结构定义
typedef struct
{
AdjList vertices; //存储顶点的数组
int vexnum,arcnum //图的当前顶点数和弧数
}ALGraph;
结构体全代码:
C
#include <stdio.h>
typedef char VerTexType;
typedef int OtherInfo;
#define MVNum 100
//定义边结构体
typedef struct ArcNode
{
int adjvex; //边结点
struct ArcNode* nextarc; //指向下一条边的指针
OtherInfo info; //和边相关的信息
}ArcNode;
//定义顶点结构体
typedef struct VNode
{
VerTexType data; //顶点信息
ArcNode* firstarc; //指向第一条依附该顶点的边的指针
}VNode,AdjList[MVNum];
//图的结构定义
typedef struct
{
AdjList vertices; //存储顶点的数组
int vexnum,arcnum //图的当前顶点数和弧数
}ALGraph;
int main()
{
return 0;
}
在实现邻接表之前,我们先来对邻接表操作举例子:
C
ALGragh G; //定义了邻接表表示的图G
G.vexnum=5; G.arcnum=5; //图G包含5个顶点,5条边
G.vertices[1].data = 'b'; //图G中第2个顶点示b
p=G.vertices[1].firstarc; //指针p指向顶点b的第一条边结点
p->adjvex=4; //p指针所指边结点是到下标为4的结点的边
算法核心思想:
- 输入总顶点数和总边数。
- 建立顶点表
- 依次输入点的信息存入顶点表中使每个表头结点的指针域初始化为NULL
- 创建邻接表
- 依次输入每条边依附的两个顶点
- 确定两个顶点的序号i和j,建立边结点。
- 将此边结点分别插入到vi和vj对应的两个边链表的头部。
全部代码:
C
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#include <stdlib.h>
typedef char VerTexType;
typedef int OtherInfo;
#define MVNum 100
//定义边结构体
typedef struct ArcNode
{
int adjvex; //边结点
struct ArcNode* nextarc; //指向下一条边的指针
OtherInfo weight; //和边相关的信息
}ArcNode;
//定义顶点结构体
typedef struct VNode
{
VerTexType data; //顶点信息
ArcNode* firstarc; //指向第一条依附该顶点的边的指针
}VNode,AdjList[MVNum];
//图的结构定义
typedef struct
{
AdjList vertices; //存储顶点的数组
int vexnum, arcnum; //图的当前顶点数和弧数
}ALGraph;
//返回v1,v2顶点的下标
int LocateVex(ALGraph *G, char v)
{
for (int i = 0; i < G->vexnum; i++)
{
if (G->vertices[i].data == v)
{
return i;
}
}
}
//创建邻接表
void CreateUDG(ALGraph *G)
{
char v1,v2; //用于接收两个顶点
int a,b; //用于接收顶点的下标
scanf("%d%d", &G->vexnum, &G->arcnum); //输入总顶点数,总边数
for (int i = 0; i < G->vexnum; i++) //构造表头结点表
{
scanf(" %c", &G->vertices[i].data); //输入顶点值
G->vertices[i].firstarc = NULL; //初始化表头结点的指针域
}
//构造边
for (int k = 0; k < G->arcnum; k++)
{
scanf(" %c%c", &v1, &v2);
a = LocateVex(G, v1);
b = LocateVex(G, v2);
}
//创建p结点,用于链接边链表,实现b->e的过程
ArcNode* p1 = (ArcNode*)malloc(sizeof(ArcNode));
p1->adjvex = b; //邻接点序号为j
p1->nextarc = G->vertices[a].firstarc; //这两行是头插的步骤
G->vertices[a].firstarc = p1; //将新节点p,插入顶点vi的边表头部
//因为我们讨论的无向网,那在连接时是双向的,上面我们把b->e链接了,现在反过来链接e->b
//所以同样的步骤即可。
ArcNode* p2 = (ArcNode*)malloc(sizeof(ArcNode));
p2->adjvex = a;
p2->nextarc = G->vertices[b].firstarc;
G->vertices[b].firstarc = p2;
}
int main()
{
ALGraph* G = NULL;
CreateUDG(G);
return 0;
}
以上代码是用邻接表实现无向图。
如果是建立有向网,反过来链接e->b的过程就不需要了,如下:
如果是有向网,无向网,需要把权值加上。
2.2.4、邻接表表示法优缺点及与邻接矩阵的关系
邻接表的优缺点:
- 方便找任一顶点的所有"邻接点"。
- 对于稀疏图节约空间
- 需要N个头指针+2E个结点(每个结点至少2个域)。
- 方便计算任一顶点的"度"?这个需要分别讨论
- 对无向图很方便。
- 对有向图来说,只能计算"出度";如果想要统计"入度"的度数,还需要构造"逆邻接表"来方便计算。
- 不方便检查任意一队顶点间是否存在。
邻接矩阵和邻接表之间的联系:
- 邻接表中每个链表对应于邻接矩阵中的一行,链表中结点个数等于一行中非零元素的个数。
邻接表和邻接矩阵的区别:
- 对于任一确定的无向图,邻接矩阵是__唯一的__(行列号与顶点编号一致),但邻接表__不唯一__(链接次序与顶点编号无关)。邻接表链接的次序和使用的算法有关,头插或者尾插。
- 邻接矩阵的空间复杂度为O(N^2),因为邻接矩阵无论怎么样都需要n*n的矩阵。而邻接表的空间复杂度为O(n+e),n是顶点个数,e是边个数。这里无向图需要2e条边,有向图需要e条边,但是数量级都是e,所以是n+e。
邻接矩阵和邻接表的用途:
- 邻接矩阵多用于稠密图;而邻接表多用于稀疏图。
2.3、十字链表
在上面我们学习了邻接表,使用邻接表也有许多不足。
- 当使用邻接表实现有向图时,求结点的度困难。
- 当使用邻接表实现无向图时,每条边都要存储两遍。
那如何解决此问题呢?其中十字链表可以解决有向图时求结点的度问题。
__十字链表(Orthogonal List):__是__有向图__的另一种链式存储结构。我们也可以把它看成是将有向图的__邻接表__和逆__邻接表__结合起来形成的一种链表。
有向图中的每一条弧对应十字链表中的一个弧结点,同时有向图中的每个顶点在十字链表中对应有一个结点,叫做__顶点结点。__
概念苦涩难懂,我们来举个例子:
在邻接表中,顶点中一个元素,只有两个域,一个数据域data
,用于存放顶点数据,一个指针域firstarc
,用于链接存储连接边的第一个结点。既然只有一个指针域,那就只能链接出度或入度链表。
那如果我们既想链接出度又想链接入度,那就在添加一个指针域不就行了吗。是的,就是这样的。
那现在顶点中一个元素就有三部分域了,如下:
data
用于存储顶点数据。
firstin
域用于链接入度的链表。
firstout
域用于链接出度的链表。
十字链表中不光是顶点结点中有所变动,弧结点中一样有所改变。在邻接表中,弧节点只有两个域:
adjvex
指针域用来存储,此顶点与之对应的顶点的下标。nextarc
指针域用来存储下一个结点的地址。
而现在弧结点一共有4个域,如下:
这样以来就实现了十字链表。下面我们来看个示例,给出如下有向图:
下面是以A顶点出度和入度的演示:
其它顶点链接操作,如上,这里不在详细说明,如下图是最终十字链表结果:
2.4、邻接多重表
上面十字链表中,提到过,在使用邻接表实现无向图时,有个缺点:每条边都要存储两遍。
临界多重表就是解决此问题的。
这里顶点结构:
这里边的结构:
3、图的遍历
遍历的定义:
从已给的连通图中某一顶点出发,沿着一些边访遍图中所有的顶点,且使每个顶点仅被访问一次,就叫 做__图的遍历__,它是图大的基本运算。
遍历实质:找每个顶点的邻接点的过程。
图的特点:
图中可能存在回路,且图的任一顶点都可能与其它顶点相通,在访问某个顶点之后可能会沿着某些边又 回到了曾经访问过的顶点。
a
那怎么样避免重复访问?
解决思路:设置辅助数组visited[n],用来标记每个被访问过的顶点。
- 初始状态visited[i]为0。
- 顶点i被访问,改为visited[i]为1,防止被多次访问。
图常用的遍历:
- 深度优先搜索(Depth_First Search------DFS)。
- 广度优先搜索(Breadth_First Search------BFS)。
3.1、深度优先搜索遍历(DFS)
3.1.1、深度优先搜索的核心思想
前面学习二叉树时,已经使用过深度优先遍历了。这里再把定义核心思想说一下。
核心思想:
- 在访问图中某一起始顶点v后,由v出发,访问它的__任一邻接顶点w1__;
- 再从w1出发,访问与w1邻接但还未被访问过的顶点w2;
- 然后再从w2出发,进行类似的访问...
- 如此进行下去,直至到达所有的邻接顶点都被访问过的顶点u为止。
- 接着,退回一步,退到前一次刚访问过的顶点,看是否还有其它没有被访问的邻接顶点。
- 如果有,则访问此顶点,之后再从此顶点出发,进行与之前类似的访问;
- 如果没有,就在退回一部进行搜索。重复上述过程,直到连通图中所有顶点都被访问过为止。
深度优先访问次序不唯一。但如果是使用邻接矩阵确定了顶点的存储结构后,那访问次序就唯一了。
3.1.2、邻接矩阵的深度优先遍历算法
深度优先搜索遍历算法实现:
__邻接矩阵__表示的无向深度遍历实现:
代码实现
图的深度优先遍历:
C
int visited[MAX_VERTEX_NUM] //定义辅助数组,这个是全局变量,用来标记已经被访问过的顶点
//邻接矩阵的深度优先搜索
void DFS(AMGraph G, int i)
{
//先标记为已访问
visited[i] = true;
printf("%c ", G.vexs[i]); //打印结点,也可以进行其它操作
for (int j = 0; j < G.vexnum; j++)
{
if (G.arcs[i][j] == 1 && !visited[j])
{
DFS(G, j);
}
}
}
但如果给的图示非连通的图呢?如下:
前面的1-8顶点遍历到了,那9-10如何遍历呢?
处理的方法:去找visited
数组,发现有哪些是flase
,说明还没遍历到,那在从这个顶点出发,就可以访问另一个子图了。
那这一部分的代码如下:
C
//邻接矩阵的深度遍历操作
void DFSTraverse(AMGraph G)
{
for (int i = 0; i < G.vexnum; i++)
{
visited[i] = false; //初始化所有顶点状态都是未访问过状态
}
for (int i = 0; i < G.vexnum; i++)
{
if (!visited[i]) //对未访问过的顶点进行深度优先搜索
{
DFS(G, i);
}
}
}
所以深度优先遍历搜索的全部代码如下:
C
int visited[MAX_VERTEX_NUM] //定义辅助数组,这个是全局变量,用来标记已经被访问过的顶点
//邻接矩阵的深度优先搜索
void DFS(AMGraph G, int i)
{
//先标记为已访问
visited[i] = true;
printf("%c ", G.vexs[i]); //打印结点,也可以进行其它操作
for (int j = 0; j < G.vexnum; j++)
{
if (G.arcs[i][j] == 1 && !visited[j])
{
DFS(G, j);
}
}
}
//邻接矩阵的深度遍历操作
void DFSTraverse(AMGraph G)
{
for (int i = 0; i < G.vexnum; i++)
{
visited[i] = false; //初始化所有顶点状态都是未访问过状态
}
for (int i = 0; i < G.vexnum; i++)
{
if (!visited[i]) //对未访问过的顶点进行深度优先搜索
{
DFS(G, i);
}
}
}
3.1.3、算法效率分析
__空间复杂度:__主要看DFS函数的调用。
- 最坏的情况:O(n),n代表有n个顶点
- 最好的情况:O(1)
__时间复杂度:__访问各结点所需要时间+探索各条边所需时间
- 邻接矩阵存储的图:访问n个顶点需要O(n)的时间,查找每个顶点的邻接点都需要O(n)的时间,而总共有n个顶点时间复杂度=O(n^2)。
- 邻接表存储的图:访问n个顶点需要O(n)的时间,查找各个顶点的邻接点共需要O(e)的时间(e表示边),所以时间复杂度为O(n+e)。
同时这里也强调一遍:
- 同一个图的邻接矩阵表示方式唯一,因此深度优先遍历序列唯一。
- 同一图邻接表表示方式不唯一,因此深度优先遍历序列不唯一。
3.2、广度优先搜索遍历(BFS)
3.2.1、广度优先算法核心思想
__方法:__从图的某一结点出发,首先依次访问该结点的所有邻接结点vi1,vi2,vi3,...,vin再按这些顶点被访问的先后次序依次访问与它们相邻接的所有未被访问的顶点。
重复以上过程,直至所有顶点均被访问为止。
例:如下图,
按照广度优先遍历的顺序为:v1--->v2--->v3--->v4--->v5--->v6--->v7--->v8。
那要是非连通图呢?也是一样得道理,如下:
按照广度优先遍历的顺序为:a c d e f h k b g 。
核心思想:
还记得再二叉树章节中,实现层序遍历二叉树的方法吗?
我们使用的是一个队列,将一个树的根结点先入队,然后读出此根节点的数据,再将此结点出队,然后再将此根节点的左右子树再入队...就这样循环往复。
广度优先遍历又叫做层序遍历。
所以这里图的广度优先遍历,和二叉树的广度优先遍历,思路大概一致。
都需要借助队列来实现。
并且这里也需要个visited数组,用来标记此顶点是否被访问过了。
假如现在有如下图:
有一个visited
数组:
第一步:从图的v1顶点开始,先将v1顶点的下标入队,然后将visited
数组中下标为0为止,标记为1(表示v1已被访问)
之后v1顶点,就可以出队了,由于v1有两个邻接点v2,v3,所以v1出队后,把v2和v3的下标入队,并且标记v2,v3已经被访问过了。
之后将v2出队,然后将v2的邻接点v4,v5入队。然后将v3出队,将v3的邻接点v6,v7入队。
就这样一直往复循环。
3.2.2、代码实现
这里代码暂且不实现,后面有需要再实现。
3.2.3、算法效率
时间效率:
-
邻接矩阵:BFS对于每一个访问到的顶点,都要循环检测矩阵中的整整一行(n个元素),时间复杂度为为:
O(n^2)。
-
__邻接表:虽然有2e个表结点,但只需扫描e个结点即可完成遍历,加上访问n个头结点的时间,时间复杂度为__O(n+e)。
3.3、DFS和BFS算法效率比较
- 空间复杂度相同,都只与调用DFS/BFS的次数有关,都无论DFS还是BFS空间复杂度都为O(n)。
- 时间复杂度只与存储结构(邻接矩阵和邻接表)有关,而与搜索路径无关。
- DFS和BFS中,邻接矩阵为O(n^2)。
- DFS和BFS中,邻接表为O(n+e)。
4、图的应用
4.1、生成树
__生成树:__所有顶点均由边连接在一起,但不存在回路的图。
【注】:
-
一个图中可以有许多棵不同的生成树
-
所有生成树具有以下共同的特点
- 生成树的顶点个数与图的顶点个数相同;
- 生成树是图的极小连通子图,去掉一条边非连通;
- 一个有n个顶点的连通图的生成树有n-1条边;
- 在生成树中再加一条边必然形成回路。
- 生成树中任意两个顶点间的路径是唯一的。
-
但含n个顶点n-1条边的图不一定是生成树。如下:
如下是一个图中每个生成树:
4.1.1、深度优先生成树和广度优先生成树
1、深度优先生成树
现有一棵无向树,我们根据深度优先遍历,来访问此无向树,将访问到的路径连起来,并且删除没有走过的路径,可以发现这就是一棵生成树,这样的生成树就叫做__深度优先生成树。__
可以看到,使用深度优先遍历,访问到了无向图中每一个结点,并且是沿着蓝色的路径进行访问的。还有两条路径没有经过,如果删除此两条没有径过的路,那就是__深度优生成树__。
如下:
2、广度优先生成树
当然有深度优先生成树,那就有广度优先生成树。
顾名思义,广度优先生成树,就是有广度优先算法去遍历如上的无向图。
如下:
4.2、最小生成树
我们先来看三个图:
第一个图(最左边的)是一个无向图,后面两个图是第一个图的生成树。
并且权值一个为19,一个为17。
那还有没有权在小的生成树呢?
最小生成树:给定一个无向网,在该网的所有生成树中,使得各边权值之和最小的那颗生成树称为该网的__最小生成树,也叫__最小代价生成树。
4.3、构造最小生成树(MST)
构造最小生成树的算法很多,其中多数算法都利用了MST(Minimum Spanning Tree)的性质。
__MST性质:__设N=(V,E)是一个连通网,U是顶点集V的一个非空子集。若边(u,v)是一条具有最小权值的边,其中u∈U,v∈V-U,则必存在一棵包含边(u,v)的最小生成树。
光听概念苦涩难懂,我们来举个例子,如下有个无向图:
那我们可以写出:
- N={V,{E}}
- V={v1,v2,v3,v4,v5,v6}
- E={(v1,v2),(v1,v3),(v1,v4),(v2,v3),(v2,v5),(v3,v4),(v3,v5),(v3,v6),(v4,v6),(v5,v6)}
- 因为U是顶点集V的一个非空子集,所以这里设U={v1}
- 所以V-U就是图中出了v1顶点之外的其他所有顶点所连的图
在生成树的构造过程中,图中n个顶点分属于两个集合:
- 已落在生成树上的顶点集:U。
- 尚未落在生成树上的顶点集:V-U。
接下来则应在所有连通U中顶点和V-U中顶点的边中选取权值最小的边。
如下图:
因为u∈U,v∈V-U,所以我们就能确定了u和v的范围了。
而现在我们要找一条最小权值的边(u,v),现在(u,v)={(v1,v2),(v1,v3),(v1,v4),(v1,v5),(v1,v6)},而我们在通过比较,发现(v1,v3)边的权值是最小的,权值为1。
因此这条边,肯定包含在要找到最小生成树中。
那反过来,我们用这个方法确定的都是权值最小的边,那这些权值最小的边构成的生成树,不就是最小生成树吗?对!是的。
4.3.1、普里姆Prim算法
算法思想:
- 设N={V,E}是连通网,TE是N上最小生成树中边的集合。
- 初始令U={u0},(u0∈V),TE={}。 这一行的意思是说,在图中随便选个顶点u0,并且TE为空。
- 在所有u∈U,v∈V-U的边(u,v)∈E中,找一条代价最小的边(u0,v0)。
- 将(u0,v0)并入集合TE,同时v0并入U。
- 重复上述操作直至U=V为止,则T=(V,TE)为N的最小生成树。
为什么U=V时结束呢?因为生成树和原图的顶点最终时相同的。U是生成树的集合,V是图中顶点的集合。
如下图:是U集合扩大为3个顶点的路径
4.3.2、克鲁斯卡尔Kruskal算法
核心思想:
-
设连通图N=(V,E),令最小生成树初始状态为只有n个顶点而无连通图T=(V,{}),每个顶点自成一个连通分量。
-
在E中选取代价最小的边,若该边依附的顶点落在此边加入到T中不同的连通分量上(即:不能形成环),则将此边加入到T中;否则,舍去此边,选取下一条边代价最小的边。
-
以此类推,直至T中所有顶点都在同一连通分量上为止。
如果使用Kruskal算法获最小生成树,是不唯一的。
4.3.3、Prim和Kruskal算法的比较
算法名 | Prim算法 | Kruskal算法 |
---|---|---|
算法思想 | 选择点 | 选择边 |
时间复杂度 | O(n^2)(n为顶点数) | O(e*loge)(e为边数) |
适应范围 | 稠密图 | 稀疏图 |
4.4、最短路径
最短路径与最小生成树不同,路径上不一定包含n个顶点,也不一定包含n-1条边。因为哦我们要探讨的是路径问题,路径问题是一般就是某个点到某个点之间的距离,中间有多少个顶点和多少个边是不确定的。
最短路径,我们遇到的问题一般分为两类:
第一类问题:两点间最短路径。
从v1到v7的路径及路径长度。
那这有好几种路径:
- v1、v2、v5、v7总权值20。
- v1、v4、v2、v5、v7总权值14。
- v1、v2、v7 总权值23
- ...
__第二类问题:__某源点到其它各点最短路径
那求某源点到其它各点最短路径,如下图:
总结:
- 单源最短路径------用Dijkstra(迪杰斯特拉)算法。
- 所有顶点间的最短路径------用Floyd(非洛伊德)算法。
4.4.1、Dijkstra算法
Dijkstra算法:按路径长度递增次序产生做最短路径。
Dijkstra算法是求某一个顶点到其它顶点的最短路径。
比如:一共v0,v1,v2,v3,v4,v5个顶点。
这个算法就是求从v0到v1或者v0到v2或者v0到v3或者v0到v4或者v0到v5的最短路径的,
反正就是任意一个顶点到其它顶点的最短路径。
核心思想:
先把V(图中顶点个数)分为两部分:
- S={v0,u,vk} S集合用于存放已经找到了最短路径的顶点集合。
- T=V-S T集合用于存放还未找到最短路径的顶底集合。
1、__初始化:__先找出从源点v0到各终点vk的直到路径(v0,vk),即通过一条弧到达的路径。
2、__选择:__从这些路径中找出一条长度最短的路径。
3、__更新:__然后对其余各条路径进行适当调整:
若在图中存在弧(u,vk),且(v0,u)+(u,vk)<(v0,vk),则以路径(v0,k,vk)代替(v0,vk)。
在调整后的各条路径中,再找长度最短的路径,以此类推。
最终算法结束的时候S=V,T为空集。
算法步骤:
我们根据如下图,,来演示此算法的步骤
__初始化:__从v0开始,初始化三个数组信息如下:
(第一轮)循环遍历所有结点,找到还没确定最短路径,且dist最小的顶点Vi,令final[i]=ture。
因为dist中v1的值为10,v4的值为5,所以选择v4,这样的就表示已经找到到达v4顶点的最短路径了,并在final中将v4标记为ture,如下:
之后检查所有邻接自v4的顶点,并且其final值为false,则更新dist和path信息。
那继续看图可知,
- (v4,v1)权值为3
- (v4,v2)权值为9
- (v4,v3)权值为2。
然后由于前面还有(v0,v4),所以:
- (v0,v4)+(v4,v1)=5+3=8,其中这里还需要再比较一下,因为从(v0,v1)权值为10,而(v0,v4)+(v4,v1)=5+3=8,所以这里选择v0、v4、v1的路径。
- (v0,v4)+(v4,v2)=5+9=14
- (v0,v4)+(v4,v3)=5+2=7
最后又因为7<8<14,所以还是v0、v4、v3的路径优先。
然后更新dist和path信息:
那至此第一轮处理完毕。
__第二轮:__循环遍历所有结点,找到还没确定最短路径,且dist最小的顶点Vi,令final[i]=ture。
由上面(v3的权值最小,所以这里将v3标记为ture。这样的就表示已经找到到达v3顶点的最短路径了
之后检查所有邻接自Vi的顶点,若其final值为false,则更新dist和path信息。
由图可知,由v3可以直达v0和v2,但由于v0已经被标记为ture了,所以不再考虑(v3,v0)的情况了。那只剩下(v3,v2)的情况了,并且其权值为6。
但是这里还需要另外考虑以下:
(v0,v4)+(v4,v2)=14,
而(v0,v4)+(v4,v3)+(v3,v2)=13
所以还是v0,v4,v3,v2的路径优先
最后更新dist和path的信息:
至此第二轮结束。
__第三轮:__循环遍历所有结点,找到还没确定最短路径,且dist最小的顶点Vi,令final[i]=ture。
目前为止v1权值为8,最小,所以将v1标记为ture,这样的就表示已经找到到达v3顶点的最短路径了
检查所有邻接自Vi的顶点,若其final值为false,则更新dist和path的信息。
那v1直达v4和v2,由于v4被标记为ture了,所以只考虑(v1,v2)的情况了。
由于上图dist中记录到达v2的最短路径为13,而现在从v1到v2等于8+1=9。这里8是dist中记录到v1的最短路径,而v1和v2之间的权值又为1,所以8+1=9,9<13,所以更新dist和path信息
如下:
至此第三轮结束。
__第四轮:__循环遍历所有结点,找到还没确定最短路径,且dist最小的顶点Vi,令final[i]=ture。
那这里只剩下v2没被标记为ture,所以直接把v2的final值标记为ture。
因为顶点全部被标记为ture。所以Dijkstra算法到此结束。
那最终数组信息,如下:
那根据此数组信息我们可以找到v0到任何顶点的最短路劲。
比如:v0到v2的最短(带权)路径长度为dist[2]=9。
根据v2的path可知前驱为v1,v1的前驱为v4,v4的前驱为v0,所以
所以通过path[]可知,v0到v2的最短(带权路径):v0--->v4--->v1--->v2。
用最终数组信息,也可以得出v0到v3之间的最短路径等等。
Dijkstra时间复杂度:O(n^2)。
4.4.2、关于Dijkstra的坑
Dijkstra算法不适用于负权值带权图。
看数组信息,发现从v0出发到v2最短路径就是7。
但事实上v0到v2的最短带权路径长度为5(从v0到v1再到v2)。
结论:Dilkstra算法不适用于负权值的带权图。
4.4.3、Floyd算法
所有顶点间的最短路径------用Floyd(弗洛伊德)。
核心思想:
- 逐个顶点试探;
- 从vi到vj的所有可能存在的路径中;
- 选出一条长度最短的路径。
下面通过一个例子,来详解这个步骤。
求此图最短路径步骤:
__初始化:__设置一个n阶方阵,令其对角线元素为0,若存在弧<vi,vj>,则对应元素为权值,否则为无穷大。
为什么要将对角线置为0呢?因为对角线是本身顶点,本身顶点权值为0。
如下:
__逐个试探:__逐步试着再原直接路径中增加中间顶点,若加入中间顶点后路径变短,则修改;否则,维持原值。所有顶点试探完毕,算法结束。
如何做呢?如下:
(1)加入A结点。既然选择加入A结点,那就需要再矩阵中挑选一个没有A结点的,如下:
蓝色部分,应为CB的权值,但是没有C->B的,但是现在添加个A结点,我们再来看来看看是否节点的路径会变短。
现在由于A顶点的加入,C顶点可以到B顶点了(看上图):C->A->B=4+3=7,在没有添加A结点之前C->B的权值是无穷大,所以现在C->B的路径变短了。
(2)再来一个加入B结点的:
这个在不加入B顶点之间,A->C的权值是11,但是加了B顶点之后,A顶点到C顶点的路径可以是:A->B->C=4+2=6。
可以发现顶点A到顶点C路径变短了。
(3)再来一个加入C结点的:
这个在不加入C顶点之间,B->A的权值是6,但是加了C顶点之后,B顶点到A顶点的路径可以是:B->C->A=2+3=5。
可以发现顶点B到顶点A路径变短了。
以上就是再原直接路径中增加中间顶点逐个试探。
而最后的结果:
就是这个无向网每个点到每个点之间的最短路径。
Floyd算法的时间复杂度:O(n^3)。
4.5、拓扑排序
__拓扑排序__以及后面要说到的__关键路径__都是有向无环图的应用。
首先我们先来看一下什么是有向无环图:
__有向无环图:__无环的有向图,简称DAG图(Directed Acycline Graph)。
有向无环图常用来描述一个工程或系统的进行过程。(通常把计划、施工、生产、程序流程等当成是一个工程)。
一个工程可以分为若干个子工程,只要完成了这些子工程(活动),就可以导致整个工程的完成。
那问题是我们如何表示这些子工程之间的关系呢?
有两种方式进行表示:
- AOV网:用一个有向图表示一个工程的各子工程及其相互制约的关系,其中以__顶点表示活动__,弧表示活动之间的优先制约关系,称这种有向图为__顶点表示活动的网__,简称AOV网(Activity On Vertex Nextwork)。
- AOE网:用一个有向图表示一个工程的各子工程及其相互制约的关系,其中以__弧表示活动__,以顶点表示活动的的开始或结束事件,称这种有向图为__边表示活动的网__,简称AOE网(Activity On Edge)。
使用AOV网是用来解决拓扑排序问题。
使用AOE网是用来解决关键路径问题。
AOV网的特点:
-
若从i到j有一条有向路径,则i是j的前驱;j是i的后继。
-
若<i,j>是网中有向边,则i是j的直接前驱,j是i的直接后继。
-
AOV网中不允许有回路,因为如果有回路存在,则表明某项活动以自己为先决条件,显然这是不对的。
拓扑排序,其实就是对一个有向无环图构造拓扑序列的过程,其实顶点变为线性序列的过程。
那如何进行拓扑排序呢?
拓扑排序的方法:
- 在有向图中选一个没有前驱的顶点且输出。
- 从图中删除该顶点和所有以它为尾的弧。
- 重复上述两步,直至全部顶点均已输出;或者当图中不存在无前驱的顶点为止。
比如我们对如上有向无环图进行拓扑排序:
C1,C2,C3,C4,C5,C7,C9,C10,C11,C6,C12,C8。
但是__一个OAV网的拓扑序列不是唯一的。__
也有可能是:
C9,C10,C11,C6,C1,C12,C4,C2,C3,C5,C7,C8。
__检测AOV网中是否存在环的方法:__对有向图构造其顶点的拓扑有序序列,若网中所有顶点都在它的拓扑有序序列中,则该AOV网必定不存在环。
4.6、关键路径
把工程计划表示为__边表示活动的网络__,即__AOE网__,用顶点表示__事件__,弧表示__活动__,弧的权表示__活动持续时间__。