数据结构:图的基本概念

图是一种比线性表和树更为复杂的数据结构。在图结构中,结点之间的关系可以是任意的,图中任意两个数据元素之间都可能相关。

图结构在计算机科学中有着广泛的应用。例如,在社交网络分析中,可以使用图结构来表示用户之间的关系;在路线规划中,可以使用图结构来表示道路网络和城市之间的连接关系;在人工智能领域中,图结构可以用于表示知识图谱和推荐系统等。

在离散数学中,图论是专门研究图的性质的数学分支,而在数据结构中,则应用图论的知识讨论如何在计算机上实现图的操作,因此主要学习图的存储结构,以及若干图的操作的实现。

一、定义

图(Graph)G由两个集合V和E组成,记为G=(V, E),其中V是顶点的有穷非空集合,E是V中顶点偶对的有穷集合,这些顶点偶对称为边。V(G)和E(G)通常分别表示图G的顶点集合和边集合,E(G)可以为空集。若E(G)为空,则图G只有顶点而没有边。

image-20240209112148567

对于图G,若边集E(G)为有向边的集合,则称该图为有向图;若边集E(G)为无向边的集合,则称该图为无向图。如上图所示,左图为有向图,右图为无向图。

在有向图中,顶点对<x, y>是有序的,它称为从顶点x到顶点y的一条有向边。因此,<x, y>与<y, x>是不同的两条边。顶点对用一对尖括号括起来,x是有向边的始点,y是有向边的终点。<x, y>也称作一条弧,其中x为弧尾,y为弧头。

在无向图中,顶点对(x, y)是无序的,它称为与顶点x和顶点y相关联的一条边。这条边没有特定的方向,(x, y)与(y, x)是同一条边。为了有别于有向图,无向图的顶点对用一对圆括号括起来。

二、基本术语

下面介绍图结构中的一些基本术语(注:n表示图中顶点数目,e表示边的数目)。

  • 子图:假设有两个图G=(V, E)和G’=(V’, E’),如果V’\subseteqV且E’\subseteqE,则称G’为G的子图。如下图所示,图(b)是图(a)的子图。

image-20240209102106850

  • 无向完全图和有向完全图:对于无向图,若具有n(n-1)/2条边,则称为无向完全图。对于有向图,若具有n(n-1)条弧,则称为有向完全图。

  • 稀疏图和稠密图:边或弧很少(如e<nlog2ne<n\log_2 n)的图称为稀疏图,反之称为稠密图。

  • 权和网:若在图的每条边上标上具有某种含义的数值,该数值称为该边上的权值。这些权值可以表示从一个顶点到另一个顶点的距离或耗费。这种带权的图通常称为网。

  • 邻接点:对于无向图G,如果图的边(v, v’)\inE,则称顶点v和v’互为邻接点,即v和v’相邻接。边(v, v’)依附于顶点v和v’,或者说边(v, v’)与顶点v和v’相关联。

  • 度、入度和出度:顶点v的度是指和v相关联的边的数目,记为TD(v)。例如,下图(b)中的顶点v3v_3的度是2。

    对于有向图,顶点v的度分为入度和出度。入度是以顶点v为头的弧的数目,记为ID(v);出度是以顶点v为尾的弧的数目,记为OD(v)。顶点v的度为TD(v)=ID(v)+OD(v)。例如,下图(a)中的顶点v1v_1的入度ID(v1v_1)=1,出度OD(v1v_1)=2,度TD(v1v_1)=ID(v1v_1)+OD(v1v_1)=3

image-20240209112148567

  • 路径和路径长度:在无向图G中,从顶点v到顶点v’的路径是一个顶点序列(v=vi,0,vi,1,...,vi,m=v)(v=v_{i,0},v_{i,1},...,v_{i,m}=v'),其中(vi,j1,vi,j)E(v_{i,j-1},v_{i,j})\in E1jm1 \leq j \leq m。如果G是有向图,则路径也是有向的,顶点序列应满足<vi,j1,vi,j>E<v_{i,j-1},v_{i,j}>\in E1jm1 \leq j \leq m。路径长度是一条路径上经过的边或弧的数目。
  • 简单路径:路径序列中顶点不重复出现的路径称为简单路径。
  • 回路或环:起点和终点是同一个顶点的路径称为回路或环。
  • 简单回路或简单环:除了起点和终点外,其余顶点不重复出现的回路,称为简单回路或简单环。
  • 连通、连通图和连通分量:在无向图G中,如果从顶点v到顶点v’有路径,则称v和v’是连通的。如果对于图中任意两个顶点vi,vjVv_i,v_j \in Vviv_ivjv_j都是连通的,则称G是连通图。而所谓连通分量,指的是无向图中的极大连通子图。例如,下图(a)就是一个连通图,而图(b)则是非连通图,但它有3个连通分量,见图(c)。

image-20240209112819102

  • 连通图的生成树:一个极小连通子图,它含有图中全部顶点,但只有足以构成一棵树的n-1边,这样的连通子图称为连通图的生成树。如下图,图(c)是图(a)的最大连通分量的一棵生成树。如果在这棵生成树中添加一条边,必定构成一个环,因为这条边使得它依附的那两个顶点之间有了第二条路径。

image-20240209120623835

  • 强连通图和强连通分量:在有向图G中,如果对于每一对vi,vjVv_i,v_j \in Vvivjv_i \neq v_j,从viv_ivjv_j和从vjv_jviv_i都存在路径,则称G是强连通图。有向图中的极大强连通子图称作有向图的强连通分量。例如,下图(a)就是一个强连通图,而图(b)则不是强连通图,但它有两个强连通分量,见图(c)。

image-20240209114747255

  • 有向树和生成森林:只有一个顶点的入度为0,其余顶点的入度均为1的有向图称为有向树。例如,下图(a)就是一棵有向树。有向图的生成森林,由若干棵不相交的有向树组成,这些有向树包含了图中全部顶点,但只有足以构成这些有向树的若干条弧。例如,下图(c)就是有向图(b)的生成森林。

image-20240209122749403

三、存储结构

由于图的结构比较复杂,任意两个顶点之间都可能存在联系,因此无法以数据元素在存储区中的物理位置来表示元素之间的关系,即图没有顺序存储结构,但可以借助二维数组来表示元素之间的关系。

另一方面,由于图中任意两个顶点之间都可能存在关系,因此,用链式存储表示图是很自然的事,图的链式存储有多种,有邻接表、十字链表和邻接多重表。

在具体应用中,应根据实际需要,选择不同的存储结构。

3.1、邻接矩阵

3.1.1、表示法

邻接矩阵(Adjacency Matrix)是一个表示顶点之间相邻关系的二维数组。设G(V, E)是具有n个顶点的图,则G的邻接矩阵是具有如下性质的n阶方阵:

A[i][j]={1<vi,vj>(vi,vj)E0其他A[i][j] = \begin{cases} 1\quad<v_i,v_j>或(v_i,v_j)\in E \\ 0\quad其他 \end{cases}

如下图,是一个有向图和它的邻接矩阵:

image-20240209163737075

若G是网,则邻接矩阵可以定义为:

A[i][j]={wij<vi,vj>(vi,vj)E其他A[i][j] = \begin{cases} w_{ij}\quad<v_i,v_j>或(v_i,v_j)\in E \\ \infty\quad其他 \end{cases}

其中,wujw_{uj}表示边上的权值,\infty表示计算机允许的、大于所有边上权值的数。如下图,是一个有向网和它的邻接矩阵:

image-20240209171532865

用邻接矩阵表示法表示图,除了一个用于存储邻接矩阵的二维数组外,还需要用一个一维数组来存储顶点信息。在C语言中,图的邻接矩阵类型描述如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 表示极大值
#define MaxInt 32767
// 最大顶点数
#define MVNum 100

// 假设顶点的数据类型为字符型
typedef char VerTexType;

// 假设边的权值类型为整型
typedef int ArcType;

typedef struct {
// 顶点表
VerTexType vexs[MVNum];
// 邻接矩阵
ArcType arcs[MVNum][MVNum];
// 图的当前顶点数和边数
int vexnum, arcnum;
}AMGraph;

3.1.2、创建无向网

已知一个图的顶点和边,使用邻接矩阵表示法来创建此图的方法比较简单,下面以一个无向网为例来说明创建图的算法。该算法的步骤为:

  • 输入总顶点数和总边数。
  • 依次输入顶点的信息存入顶点表中。
  • 初始化邻接矩阵,使每个权值初始化为极大值。
  • 构造邻接矩阵。依次输入每条边依附的顶点和其权值,确定两个顶点在图中的位置之后,使相应边赋予相应的权值,同时使其对称边赋予相同的权值。

相应的算法描述为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 采用邻接矩阵表示法,创建无向网G
Status CreateUDN(AMGraph &G) {
// 输入总顶点数和总边数
cin>>G.vexnum>>G.arcnum;

// 依次输入顶点信息
for(i=0; i<G.vexnum; ++i)
cin>>G.vexs[i]

// 初始化邻接矩阵,边的权值均为极大值MaxInt
for(i=0; i<G.vexnum; ++i)
for(j=0; j<G.vexnum; ++j)
G.arcs[i][j]=MaxInt;

// 构造邻接矩阵
for(k=0;k<G.arcnum;++k) {
// 输入一条边依附的顶点和其权值
cin>>v1>>v2>>w;
// 确定v1和v2在G中的位置,即顶点数组的下标
i=LocateVex(G,v1);
j=LocateVex(G,v2);
// 边<v1, v2>的权值置为w
G.arcs[i][j]=w;
// 置<v1, v2>的对称边<v2, v1>的权值置为w
G.arcs[j][i]=G.arcs[i][j];
}

return OK;
}

该算法的时间复杂度是O(max(n2,ne))O(max(n^2,n*e))

若要建立无向图,只需对上述算法做两处小的改动:一是初始化邻接矩阵时,将边的权值均初始化为0;二是构造邻接矩阵时,将权值w改为常量值1即可。同样,将该算法稍做修改即可建立一个有向网或有向图。

3.1.3、优缺点

邻接矩阵表示法的优点是:

  • 便于判断两个顶点之间是否有边,即根据A[i][j]=0或1来判断。
  • 便于计算各个顶点的度。对于无向图,邻接矩阵第i行元素之和就是顶点i的度;对于有向图,第i行元素之和就是顶点i的出度,第i列元素之和就是顶点i的入度。

邻接矩阵表示法的缺点是:

  • 不便于增加和删除顶点。

  • 不便于统计边的数目,需要扫描邻接矩阵所有元素才能统计完毕,时间复杂度为O(n2)O(n^2)

  • 空间复杂度高。如果是有向图,n个顶点需要n2n^2个单元存储边。如果是无向图,因其邻接矩阵是对称的,所以对规模较大的邻接矩阵可以采用压缩存储的方法,仅存储下三角(或上三角)的元素,这样需要n(n−1)/2个单元即可。但无论以何种方式存储,邻接矩阵表示法的空间复杂度均为O(n2)O(n^2),这对于稀疏图而言尤其浪费空间。

3.2、邻接表

3.2.1、表示法

邻接表(Adjacency List)是图的一种链式存储结构。

在邻接表中,会为图中每个顶点viv_i建立一个单链表,把与viv_i相邻接的顶点放在这个链表中。邻接表中每个单链表的第一个结点存放有关顶点的信息,把这一结点看成链表的表头,其余结点存放有关边的信息。因此,邻接表由两部分组成:表头结点表和边表。

表头结点表,由所有表头结点以顺序结构的形式组成,以便可以随机访问任一顶点的边链表。表头结点包括数据域(data)和链域(firstarc)两部分,如下图(a)所示。其中,数据域用于存储顶点viv_i的名称或其他信息;链域用于指向链表中第一结点(即与顶点viv_i相邻接的第一个邻接点)。

边表,由表示图中顶点间关系的n个边链表组成。边链表中边结点包括邻接点域、数据域和链域三部分,如下图(b)所示。其中,邻接点域指示与顶点viv_i相邻接的点在图中的位置;数据域存储和边相关的信息,如权值等;链域指示与顶点viv_i相邻接的下一个邻接点。

image-20240209184622997

例如,在下图(a)中有两个图,图(b)则是它们对应的邻接表。

image-20240209191537191

在无向图的邻接表中,顶点viv_i的度恰为第i个链表中的结点数;而在有向图中,第i个链表中的结点个数只是顶点viv_i的出度,为求入度,必须遍历整个邻接表。在所有链表中,其邻接点域的值为i的结点的个数就是顶点viv_i的入度。有时,为了便于确定顶点的入度,可以建立一个有向图的逆邻接表,即对每个顶点viv_i建立一个链接所有进入viv_i的边的表。例如,下图(c)为有向图G1G_1的逆邻接表。

image-20240210101001014

根据上述讨论,要定义一个邻接表,需要定义存放顶点的头结点和表示边的边结点。在C语言中,图的邻接表存储结构的类型描述如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 最大顶点数
#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;

3.2.2、创建无向图

基于邻接表表示法创建一个图,需要创建其相应的顶点表和边表。下面以一个无向图为例来说明创建图的算法。该算法步骤为:

  • 输入总顶点数和总边数。
  • 依次输入顶点的信息存入顶点表中,使每个表头结点的指针域初始化为NULL。
  • 创建邻接表。依次输入每条边依附的两个顶点,确定这两个顶点的序号i和j之后,将此边结点分别插入viv_ivjv_j对应的两个边链表的头部。

相应的算法描述为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// 采用邻接表表示法,创建无向图G
Status CreateUDG(ALGraph &G) {
// 输入总顶点数,总边数
cin>>G.vexnum>>G.arcnum;

// 输入顶点信息,构造表头结点表
for(i=0; i<G.vexnum; ++i) {
// 输入顶点的值
cin>>G.vertices[i].data;
// 初始化表头结点的指针域为NULL
G.vertices[i].firstarc=NULL;
}

// 输入各边,构造邻接表
for(k=0; k<G.arcnum; ++k) {
// 输入一条边依附的两个顶点
cin>>v1>>v2;

// 确定v1和v2在G中的位置,即顶点在G.vertices中的下标
i=LocateVex(G,v1);
j=LocateVex(G,v2);

// 生成一个新的边结点*p1
p1=new ArcNode;
// 邻接点序号为j
p2->adjvex=j;
// 将新结点*p1插入到顶点vi的边表头部
p1->nextarc=G.vertices[i].firstarc;
G.vertices[i].firstarc=p1;

// 生成另一个对称的新边结点*p2
p2=new ArcNode;
// 邻接点序号为i
p2->adjvex=i;
// 将新结点*p2插入到顶点vj的边表头部
p2->nextarc=G.vertices[j].firstarc;
G.vertices[j].firstarc=p2;
}

return OK;
}

该算法的时间复杂度是O(n*e)。

建立有向图的邻接表与此类似,只是更加简单,每读入一个顶点对序号<i, j>,仅需生成一个邻接点序号为j的边表结点,并将其插入到viv_i的边链表头部即可。若要创建网的邻接表,可以将边的权值存储在info域中。

需要注意的是,一个图的邻接矩阵表示是唯一的,但其邻接表表示不唯一,这是因为在邻接表表示中,各边表结点的链接次序取决于建立邻接表的算法,以及边的输入次序。

3.2.3、优缺点

邻接矩阵和邻接表是图的两种最常用的存储结构,它们各有所长。与邻接矩阵相比,邻接表有其自己的优缺点。其优点是:

  • 便于增加和删除顶点。
  • 便于统计边的数目,按顶点表顺序扫描所有边表可得到边的数目,时间复杂度为O(n+e)。
  • 空间效率高。对于一个具有n个顶点e条边的图G,若G是无向图,则在其邻接表表示中有n个顶点表结点和2e个边表结点;若G是有向图,则在它的邻接表表示或逆邻接表表示中均有n个顶点表结点和e个边表结点。因此,邻接表或逆邻接表表示的空间复杂度为O(n+e),适合表示稀疏图。对于稠密图,考虑到邻接表中要附加链域,因此常采取邻接矩阵表示法。

其缺点是:

  • 不便于判断顶点之间是否有边,要判定viv_ivjv_j之间是否有边,就需扫描第i个边表,最坏情况下要耗费O(n)时间。
  • 不便于计算有向图各个顶点的度。对于无向图,在邻接表表示中,顶点viv_i的度是第i个边表中的结点个数。在有向图的邻接表中,第i个边表上的结点个数是顶点viv_i的出度,但求viv_i的入度较困难,需遍历所有顶点的边表。若有向图采用逆邻接表表示,则与邻接表表示相反,求顶点的入度容易,而求顶点的出度较难。

3.3、十字链表

十字链表(Orthogonal List)是有向图的另一种链式存储结构。它可以看成是将有向图的邻接表和逆邻接表结合起来得到的一种链表。在十字链表中,对应于有向图中每一条弧有一个结点,对应于每个顶点也有一个结点。这些结点的结构形式如下图所示。

image-20240210111630096

在弧结点中有5个域:其中尾域tailvex和头域headvex分别指示弧尾和弧头这两个顶点在图中的位置,链域hlink指向弧头相同的下一条弧,而链域tlink指向弧尾相同的下一条弧,info域指向该弧的相关信息。弧头相同弧在同一链表上,弧尾相同的弧也在同一链表上。而它们的头结点即为顶点结点。

顶点结点由3个域组成:其中data域存储和顶点相关的信息,如顶点的名称等;firstin和firstout为两个链域,分别指向以该顶点为弧头或弧尾的第一个弧结点。

例如,下图(b)是下图(a)所示图的十字链表。

image-20240210121325532

若将有向图的邻接矩阵看成是稀疏矩阵的话,则十字链表也可以看成是邻接矩阵的链式存储结构,在图的十字链表中,弧结点所在的链表非循环链表,结点之间相对位置自然形成,不一定按顶点序号有序,表头结点即顶点结点,它们之间不是链接,而是顺序存储。

在C语言中,有向图的十字链表存储结构的类型描述如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#define MAX_VERTEX_NUM 20

typedef struct ArcBox {
// 该弧的尾和头结点的位置
int tailvex,headvex;
// 分别为弧头相同和弧尾相同的弧的链域
struct ArcBox *hlink,*tlink;
// 该弧相关信息的指针
InfoType *info;
}ArcBox;

typedef struct VexNode {
VertexType data;
// 分别指向该顶点的第一条入弧和出弧
ArcBox *firstin, *firstout;
}VexNode;

typedef struct {
// 表头向量
VexNode xlist[MAX_VERTEX_NUM];
// 有向图的当前顶点数和弧数
int vexnum, arcnum;
}QLGraph;

只要输入n个顶点的信息和e条弧的信息,便可建立该有向图的十字链表。建立十字链表的时间复杂度和建立邻接表是相同的。在十字链表中既容易找到以viv_i为尾的弧,也容易找到以viv_i为头的弧,因而容易求得顶点的出度和入度(或需要,可在建立十字链表的同时求出)。在某些有向图的应用中,十字链表是很有用的工具。

3.4、邻接多重表

邻接多重表(Adjacency Multilist)是无向图的另一种链式存储结构。虽然邻接表是无向图的一种很有效的存储结构,在邻接表中容易求得顶点和边的各种信息。但是,在邻接表中每一条边有(vi,vj)(v_i,v_j)两个结点,分别在第i个和第j个链表中,这给某些图的操作带来不便。例如,在某些图的应用问题中,需要对边进行某种操作,如对已被搜索过的边作记号或删除一条边等,此时需要找到表示同一条边的两个结点。因此,在进行这一类操作的无向图的问题中采用邻接多重表作存储结构更为适宜。

image-20240210175332262

邻接多重表的结构和十字链表类似。在邻接多重表中,每一条边用一个结点表示,它由6个域组成。其中,mark为标志域,可用以标记该条边是否被搜索过;ivex和jvex为该边依附的两个顶点在图中的位置;ilink指向下一条依附于顶点ivex的边;jlink指向下一条依附于顶点jvex的边;info指向和边相关的各种信息的指针域。

每一个顶点也用一个结点表示,它由两个域组成。其中,data域存储和该顶点相关的信息,firstedge域指示第一条依附于该顶点的边。

例如,下图(b)是下图(a)所示图的邻接多重表。

image-20240210182857719

在邻接多重表中,所有依附于同一顶点的边串联在同一链表中,由于每条边依附于两个顶点,则每个边结点同时链接在两个链表中。可见,对无向图而言,其邻接多重表和邻接表的差别,仅仅在于同一条边在邻接表中用两个结点表示,而在邻接多重表中只有一个结点。因此,除了在边结点中增加一个标志域外,邻接多重表所需的存储量和邻接表相同。

在C语言中,邻接多重表的类型描述如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#define MAX_VERTEX_NUM 20

typedef enum{unvisited, visited} VisitIf;

typedef struct EBox {
// 访问标记
VisitIf mark;
// 该边依附的两个顶点的位置
int ivex, jvex;
// 分别指向依附这两个顶点的下一条边
struct EBox *ilink, *jlink;
// 该边信息指针
InfoType *info;
}Ebox;

typedef struct VexBox {
VertexType data;
// 指向第一条依附该顶点的边
EBox *firstedge;
}VexBox;

typedef struct {
VexBox adjmulist[MAX_VERTEX_NUM];
// 无向图的当前顶点数和边数
int vexnum, edgenum;
}AMLGraph;

四、小结

根据不同的分类规则,图分为多种类型:无向图、有向图、完全图、连通图、强连通图、带权图(网)、稀疏图和稠密图等。邻接点、路径、回路、度、连通分量、生成树等是在图的算法设计中常用到的重要术语。

图的存储方式有两大类:以边集合方式的表示法和以链接方式的表示法。其中,以边集合方式表示的为邻接矩阵,以链接方式表示的包括邻接表、十字链表和邻接多重表。邻接矩阵表示法借助二维数组来表示元素之间的关系,实现起来较为简单;邻接表、十字链表和邻接多重表都属于链式存储结构,实现起来较为复杂。在实际应用中具体采取哪种存储表示,可以根据图的类型和实际算法的基本思想进行选择。其中,邻接矩阵和邻接表是两种常用的存储结构。

img

五、参考

《数据结构(C语言版 第2版)》

《数据结构 自考02331》


数据结构:图的基本概念
https://kuberxy.github.io/2024/02/04/数据结构11:图的基本概念/
作者
Mr.x
发布于
2024年2月4日
许可协议