文章目录
图的存储结构:比线性表和树更复杂,完全不能用顺序结构
终于弄明白为啥要先学线性表,再学树,再学图了。因为他们各方面的复杂性都是递增的,比如存储方式即存储结构的设计上,基本操作上。线性表要么顺序存储要么链式存储,而树基本都是采用链式存储,二叉链表的形式,用孩子兄弟表示法,但是也比现行表复杂很多了;图则更复杂。
复杂在哪里呢?图的任意一个顶点都可以作为第一个顶点,且一个点的邻接点之间也没有次序关系。比如下图,实际上四个图都是同一个图,但是看起来却有点不一样:
点在内存中的位置完全没办法说明点之间的逻辑关系,所以完全不能用顺序结构来存储图。
那么多重链表呢?即结点有多个指针域的链表。每个顶点有一个数据域,多个指针域。但是就像之前在树中讨论的那样,指针域的个数很难确定,每个顶点的度数不一样,要么让每个顶点的指针域个数都等于图的最大顶点度数,这样很浪费空间;要么让每个点的指针域个数等于自己的度,但是需要每个结点单独拿一个数据说明指针域个数,这样会使得整个多重链表混乱不统一,不便于处理。
所以图的物理存储是个难题,但是前辈们已经给出了五种不同的解决方案:邻接矩阵,邻接表,十字链表,邻接多重表,边集数组。
邻接矩阵,邻接表,十字链表都用了一个一维数组存储顶点,即都用了顺序存储结构,但是数组元素结点的结构体的设计不一样。
邻接矩阵只用了两个数组,一个一维一个二维,完全不涉及动态分配内存,是静态的存储方式。但不能说是顺序存储哦,因为顺序存储只适合一维数组(一对一,前驱后继,用物理的顺序存储逻辑关系),二维数组并不是顺序存储。
邻接矩阵(不涉及动态存储):用两个数组表示图,一个一维数组存顶点,一个二维数组(即邻接矩阵)存边
即一个顶点数组,一个边数组,这个二维的边数组就是邻接矩阵adjacent matrix
无向图:邻接矩阵是对称矩阵,行和是顶点的度
无向图的边数组是一个对称矩阵
求点 v i v_i vi的邻接点,就遍历第i行,所有 a r c [ i ] [ j ] arc[i][j] arc[i][j]为1的 v j v_j vj就是邻接点。
有向图:邻接矩阵不对称,行和是出度,列和是入度
只要
a
c
r
[
i
]
[
j
]
=
1
acr[i][j]=1
acr[i][j]=1则
v
i
v_i
vi和
v
j
v_j
vj之间有有向边。
要找 v i v_i vi的邻接点,就遍历第i行,满足 a c r [ i ] [ j ] = 1 acr[i][j]=1 acr[i][j]=1的点就是邻接点。
网:用一个不可能的值表示不存在的边的权
下面是一个有向网
代码
表示图的结构体的代码
typedef char VertexType;//用户自定义顶点的数据类型
typedef int EdgeType;
#define MAXVEX 100 //最大顶点数,便于建立顶点数组
#define INF 65535 //表示无穷大
typedef struct
{
VertexType vexs[MAXVEX];//顶点表
EdgeType arc[MAXVEX][MAXVEX];//边表,即邻接矩阵
int numVertexes, numEdges;//顶点数,边数
}MGraph;
可以看到,缺点很明显,如果后面需要添点超过了MAXVEX,就不行了。MAXVEX太大又浪费。
创建一个无向网的代码:
void CreateMGraph(MGraph *G)
{
cout << "enter the number of vertexes and edges:\n";
scanf("%d, %d", &G->numVertexes, &G->numEdges);
int i;
//读取数据,建立顶点表
for (i=0;i < G->numVertexes; ++i)
{
cin >> G->vexs[i];
}
//初始化边表
int j;
for (i=0; i < G->numVertexes; ++i)
{
for (j = 0; j < G->numVertexes; ++j)
{
arc[i][j] = INF;
}
}
//读取数据,建立边表
int k,w;
for (k = 0; k < G->numEdges; ++k)
{
cout << "enter the index of the head and tail vertex, and then the weight:\n";
scanf("%d, %d, %d", &i, &j, &w);
G->arc[i][j] = w;
G->arc[j][i] = w;//因为这里是无向图,所以加这句代码;有向图则不加
}
}
上面代码的时间复杂度很好分析,分为三部分,第一个for语句建立顶点表,为O(n),n为顶点数;第二个for初始化边表,为 O ( n 2 ) O(n^2) O(n2);第三个for建立边表,为O(e),e是边数。总共加起来是 O ( n + n 2 + e ) O(n+n^2+e) O(n+n2+e)。
写完这个代码一下子就看出来,邻接矩阵不是一个很好的存储方式,尤其是对于稀疏图(边数相对于顶点数少),又费空间又费时间,边表的初始化的二重循环太费时间了。且MAXVEX设置的越大越费时间。
比如下图这种图,只有一条边,邻接矩阵有24个位置全是浪费掉了的空间。
邻接表:最常使用的图存储结构,把数组和链表结合使用
adjacency list
树的孩子表示法很像邻接表。它把所有结点存在一个数组里,用顺序存储结构,然后把每一个结点的所有孩子用链式结构存在一个单链表里。
图也可以这样,把所有顶点存在一个数组里,使用顺序存储结构,然后把每一个结点的出边存在一个单链表里。这样可以避免邻接矩阵的空间浪费。
有向图的逆邻接表
有向图的邻接表中的单链表只存了出度顶点,或者说出边,但是入边却很好找,逆邻接表就是给每一个顶点建立一个单链表以专门存储入度顶点或者入度边。
邻接表中每个结点的单链表的长度就是该结点的出度,逆邻接表的每个结点的单链表的长度就是该结点的入度。
带权邻接表:给边结点增加一个存储权值的数据域
代码
单链表的结点结构,即边结点
typedef int EdgeType;//自定义边的数据类型,即权值的数据类型
typedef struct EdgeNode
{
int adjvex;//顶点在顶点表数组中的下标
EdgeType weight;//无权图不需要这个数据域
struct EdgeNode * nextEdge;//指针域
}EdgeNode;
顶点表的结点结构,即数组元素结点
typedef char VertexType;//自定义顶点数据类型
typedef struct VertexNode
{
VertexType data;
EdgeNode * firstEdge;//边表(这里指存出边的单链表)的头指针
}VertexNode, AdjList[MAXVEX];//AdjList是一个数据类型,表示存了MAXVEX个顶点结构的数组类型
图的结构:
#define MAXVEX 100
typedef struct AdjGraph
{
AdjList adjlist;//AdjList是一个数据类型,表示存了MAXVEX个顶点结构的数组类型
int numV, numE;
}AdjGraph;
无向带权图的邻接表创建:
void CreateALGraph(AdjGraph * G)
{
cout << "Enter the number of vertexes and edges:\n";
scanf("%d, %d", &G->numV, &G->numE);
//读取顶点信息,建立顶点表
int i;
for (i=0;i<G->numV;++i)
{
cin >> (G->adjlist[i]).data;
(G->adjlist[i]).firstEdge = NULL;
}
//读取边信息,建立边表
int j,k,weight;
EdgeNode * e;
for (k=0;k<G->numE;++k)
{
cout << "Enter edge's head and tail vertex, and then the weight:\n";
cin >> i >> j >> weight;
e = (EdgeNode *)malloc(sizeof(EdgeNode));
e->adjvex = j;
e->weight = weight;
//把新边插在单链表的最前面,即头插法
e->nextEdge = G->adjlist[i].firstEdge;//新边的下一条边设置为当前第一条边
G->adjlist[i].firstEdge = e;//让新边成为顶点i的第一条出边(边)
//无向图的对称边的添加,有向图则不需要后面这部分
e = (EdgeNode *)malloc(sizeof(EdgeNode));//新建一条边
e->adjvex = i;
e->weight = weight;
e->nextEdge = G->adjlist[j].firstEdge;
G->adjlist[j].firstEdge = e;
}
}
时间复杂度是O(n+e),两个for循环,省略了邻接矩阵需要做的边表的初始化,可见对时间效率的提升很大。
十字链表,正交链表:整合邻接表和逆邻接表,只用于存储有向图,是有向图的天选存储结构
Orthogonal List
对于无向图来说,邻接表简直香死了。但是对有向表就不是那么完美了,因为邻接表只为有向图建立了存储出度边的单链表,如果要知道入度,就需要遍历整个邻接表才行。
而逆邻接表又只存储了入度边,要知道出度也要费老劲了。
由于无向图已经被邻接表完美解决了,所以十字链表只是为了存储有向图而专门设计的一种存储结构,和无向图没关系。
十字链表综合了邻接表和逆邻接表,多占用了一些空间,但是求顶点的入度和出度都非常方便。并且创建图的时间复杂度和邻接表一毛一样,所以邻接表是无向图的天选存储结构,而十字链表是有向图的天选存储结构。
顶点结点的结构
十字链表怎么整合邻接表和逆邻接表呢?很简单,就是每个顶点结点设置两个指针域,即顶点表数组的每一个顶点有一个由firstin指向的入边表,一个firstout指向的出边表。
firstin指向顶点的第一条入边,firstout指向顶点的第一条出边
边结点的结构
边结点的结构就有点复杂了。有两个(或者三个)数据域和两个指针域:
数据域
- tailvex: 这条边的弧尾(边的起点)在顶点表数组中的下标
- headvex:这条边的弧头(边的终点)在顶点表数组中的下标
- weight:有向网才有这个数据域
指针域 - headlink: 入边表指针域,指向当前顶点的下一条入边,即弧头和headvex相同的边
- taillink:出边表指针域,指向下一条出边,即弧尾和tailvex相同的边
例子:
看起来很复杂,其实很简单,就先不要看虚线的入边表,先看实线的出边表,就和邻接表差不多,然后再看每个结点的入边,边结点的数目等于边的数目。不难,就不多说了。
代码
边结点的结构
typedef int ElemType;//自定义权重的数据类型
typedef struct ENode
{
int headvex, tailvex;
ElemType weight;//有向无权图没有这个数据域
struct ENode * headlink, * taillink;
}ENode;
顶点结点的结构
#define MAXVEX 100
typedef int DataType; //自定义数据的数据类型
typedef struct VNode
{
DataType data;
ENode * firstin, * firstout;
}VNode, OrthoList[MAXVEX];
图的结构
typedef struct DiGraph
{
OrthoList vers;
int numV, numE;
}DiGraph;
创建一个图的函数代码
void CreateDiGraph(DiGraph * G)
{
cout << "Enter the number of vertex and edges:\n";
cin >> G->numV >> G->numE;
int i;
//读取顶点,建立顶点表
for (i=0;i<G->numV;++i)
{
cin >> G->vers[i].data;
G->vers[i].firstin = NULL;
G->vers[i].firstout = NULL;
}
//读取边,建立边表
int j, k, weight;
ENode * e;
for (k=0;k<G->numE;++k)
{
cout << "enter the tail and head of the edge, and then the weight:\n";
scanf("%d, %d, %d",&i, &j, &weight);
//构造这条新边
e = (ENode *)malloc(sizeof(ENode));
e->weight = weight;
e->tailvex = i;
e->headvex = j;
e->headlink = G->vers[j].firstin;
e->taillink = G->vers[i].firstout;
G->vers[j].firstin = e;//添加为顶点j的新入边
G->vers[i].firstout = e;//添加为顶点i的新出边
}
}
可以看到,创建一个图的时间复杂度和邻接表一模一样,都是O(n+e)。
邻接多重表(针对无向图):一条边在邻接表中要用两个结点表示,在邻接多重表只需要用一个结点表示
邻接多重表和邻接表非常相似,只是在邻接表的基础上稍微做了点改进。邻接表对于那些关注顶点的操作很适合,比如查看某顶点的出度,返回某顶点的出度边,改变顶点数据。但是对于边的操作很不方便,比如删掉一条边,由于邻接表中每一条边都涉及到两个顶点,所以删除一条边需要操作两个顶点。
十字链表是把邻接表针对有向图的特殊需求而进行优化得到的产物。邻接多重表是把邻接表针对无向图的需求进一步优化的产物。无向图用邻接表存储已经很好了,但在上面一段所说的操作中还是不方便,所以科学家们优化了无向图的边结点的结构的设计,使得每一条边也只用一个点表示。所以邻接多重表和十字链表略微有点相似,都是通过边结点结构中的指针域相互指向,使得边结点的数目可以等于边的数目,而不是边的数目的2倍。
邻接多重表的边结点结构,有两个数据域两个指针域(如果有权则有三个数据域):
- ivex:依附于这条边的顶点之一在顶点数组中的下标。
- ilink: 指针域,指向依附于顶点ivex的下一条边。
- jvex:依附于这条边的另一个顶点在顶点数组中的下标。
- jlink:指针域,指向依附于顶点jvex的下一条边。
注意ivex和jvex的顺序不重要,无所谓。
邻接表存储结构
邻接多重表存储结构
这时候如果想删除 V 0 , V 2 V_0,V_2 V0,V2的边,则只需要把6,9的指针改为空指针。
对比同一个图的邻接表和邻接多重表,会发现,图的边数是5,但是邻接表却用了10个边结点,邻接多重表只用了5个边结点。所以邻接表和邻接多重表的区别很小很小:一条边在邻接表中要用两个结点表示,在邻接多重表只需要用一个结点表示。(有向图的十字链表也是一条边只用一个顶点表示)
边集数组:适合对边进行依次处理的操作,不适合对顶点的操作
用两个数组表示图,邻接矩阵也是用两个数组表示一个图,但是邻接矩阵的二维矩阵是nn的方阵,n是顶点数目;而边集数组的二维矩阵是一个n2或者n*3矩阵,占用空间要小一些。
这种方式其实最简单,最容易想到,但是却放在最后讲,大概是因为用处更少一些。4月份参加华为软挑的时候,把txt文件中的金融转账记录数据读取到一个边集数组是我的第一步,然后利用边集数组建立了顶点数组。我现在才知道我那时候原来是用了边集数组的存储结构。所以可见边集数组存储结构确实是最简单最容易想到的,没学过图的存储结构的人也能立马想到并立即使用。
typedef struct
{
int begin;
int end;
int weight;
}Edge;
边集数组存储结构强调的是边,并且因为这种n2或者n3的数组无法反应边之间的逻辑关系,所以所有边只能是看做一个集合。所以这种存储结构只适合于对所有边依次进行处理的操作,比如依次加边给一个十字链表结构存储的图,并不适合对点进行操作。
总之,由于不能直接体现图的点之间,边之间,点和边之间的逻辑关系,边集数组往往只是图最初的模样,然后我们会为了各种方便操作,而将边集数组存储结构存储的图转换为使用邻接表或者十字链表,邻接多重表等存储结构来存储。