现代生活中,大家都离不开地图导航,如我们想从某地出发去某地,导航软件会给出多条路线供我们选择,这些路线中有的可能是最短的,有的可能是避开拥堵的,有的可能是风景最美的…可是,这些地名和路线是如何存储在计算机中的呢?我们知道,一个地点可以有多条路径可以到达,而一条路径上也可以经过多个地点,这种多对多的关系用我们前面学过的线性表和树是无法表示的,怎么办呢?这就需要我们引入一种新的多对多关系的数据结构——图(Graph)。
一、定义
1、图的定义
图(Graph):是由顶点的有穷非空集合和顶点之间边的集合组成的。通常表示为G(V,E)G(V,E)G(V,E),其中GGG表示一个图,VVV表示图中的顶点集合,EEE表示图中的边集合。
对于以上定义,我们需要注意以下几点:
- 顶点集合有穷非空:不同于线性表中存在空表、树中存在空树,图中顶点集合VVV不能为空集,至少要有一个顶点。
- 顶点(Vertex):图中的每个结点称为顶点,顶点也称为结点或点。
- 边(Edge):连接图中两个顶点的线段称为边,边集可以为空集。
2、各种图的定义
- 无向边:若顶点viv_ivi到vjv_jvj之间的边没有方向,则称这条边为无向边(Edge),使用无序对(vi,vj)(v_i,v_j)(vi,vj)表示。
如果图中任意两顶点之间的边都是无向边,则称该图为无向图(Undirected Graph)。由于边没有方向,所以(v1,v2)(v_1,v_2)(v1,v2)和(v2,v1)(v_2,v_1)(v2,v1)表示同一条边。如下图所示。
对于图1所示无向图G=(V,{E})G=(V,\{E\})G=(V,{E}),其顶点集合V={A,B,C,D}V=\{A,B,C,D\}V={A,B,C,D},边集合E={(A,B),(A,C),(B,C),(C,D),(D,A)}E=\{(A,B),(A,C),(B,C),(C,D),(D,A)\}E={(A,B),(A,C),(B,C),(C,D),(D,A)}。
- 有向边:若顶点viv_ivi到vjv_jvj之间的边有方向,则称这条边为有向边,也称为弧(Arc),使用有序对<vi,vj><v_i,v_j><vi,vj>表示。其中viv_ivi称为弧尾(Tail),vjv_jvj称为弧头(Head)。
如果图中任意两顶点之间的边都是有向边,则称该图为有向图(Directed Graph)。由于边的方向性,所以<v1,v2><v_1,v_2><v1,v2>和<v2,v1><v_2,v_1><v2,v1>表示不同的边。如下图所示。
对于图2所示有向图G=(V,{E})G=(V,\{E\})G=(V,{E}),其顶点集合V={A,B,C,D}V=\{A,B,C,D\}V={A,B,C,D},弧集合E={<A,B>,<A,C>,<B,C>,<C,D>,<D,A>}E=\{<A,B>,<A,C>,<B,C>,<C,D>,<D,A>\}E={<A,B>,<A,C>,<B,C>,<C,D>,<D,A>}。
需注意无向边使用无序对“()”,有向边使用有序对“<>”。
- 简单图:如果图中任意两顶点之间至多有一条边,且没有顶点与其自身相连的边,则称该图为简单图(Simple Graph)。在当前课程中,我们所讨论的图均为简单图。即下图所示两种情况均不考虑:
- 无向完全图:在无向图中,任意两顶点之间都存在边,则称该无向图为无向完全图(Undirected Complete Graph)。如下图所示。
我们用nnn表示图中顶点的个数,eee表示图中边的个数,则无向完全图中边的个数e=n(n−1)2e=\frac{n(n-1)}{2}e=2n(n−1),而对于无向图则有0≤e≤n(n−1)20 \leq e\leq\frac{n(n-1)}{2}0≤e≤2n(n−1)。我们如何得出这个结论呢?因为无向完全图中任意两顶点之间都有边,所以从nnn个顶点与其他n−1n-1n−1个顶点相连,可以得到n(n−1)n(n-1)n(n−1)条边,但由于无向边是没有方向的,所以每条边都被计算了两次(如(A,B)(A,B)(A,B)和(B,A)(B,A)(B,A)),因此需要除以2,最终得到e=n(n−1)2e=\frac{n(n-1)}{2}e=2n(n−1)。
- 有向完全图:在有向图中,任意两顶点之间都存在方向相反的两条弧,则称该有向图为有向完全图(Directed Complete Graph)。如下图所示。
对于有向完全图中边的个数e=n(n−1)e=n(n-1)e=n(n−1),而有向图则有0≤e≤n(n−1)0 \leq e \leq n(n-1)0≤e≤n(n−1),因为有向边是有方向的,所以在计算时不需要除以2。
稀疏图、稠密图:有很少条边或弧的图称为稀疏图(Sparse Graph),有很多边或弧的图称为稠密图(Dense Graph)。通常情况下,指e<nlogne<n\log ne<nlogn,则称该图为稀疏图,否则称为稠密图。
带权图:如果图中的每条边或弧都赋有一个数值(称为权),则称该图为带权图(Weighted Graph)或网(Network)。一般来说,权可以表示距离、费用等。如下图所示是一个标志中国家主要城市间距离的带权无向图。图中的权就是城市间的距离。
- 子图:假设有两个图G=(V,E)G=(V,{E})G=(V,E)和G′=(V′,E′)G'=(V',{E'})G′=(V′,E′),如果G′G'G′的顶点集合V′V'V′是GGG的顶点集合VVV的子集(V′⊆VV'\subseteq VV′⊆V),且G′G'G′的边集合E′E'E′是GGG的边集合EEE的子集(E′⊆EE'\subseteq EE′⊆E),则称G′G'G′为GGG的子图(Subgraph)。例如下图中,红线右侧的图均为红线左侧图的子图(右侧并不是其所有子图)。
3、图的顶点与边的关系
- 邻接、关联、度
对于无向图G=(V,{E})G=(V,\{E\})G=(V,{E}),如果边(vi,vj)∈E(v_i,v_j)\in E(vi,vj)∈E,则称顶点viv_ivi与vjv_jvj邻接(Adjacent),或称viv_ivi与vjv_jvj为对方的邻接点(Adjacent Vertex),如果边(vi,vj)∈E(v_i,v_j)\in E(vi,vj)∈E,则称边(vi,vj)(v_i,v_j)(vi,vj)关联/依附(Incident)于顶点viv_ivi和vjv_jvj。顶点viv_ivi的度(Degree),记为TD(vi)TD(v_i)TD(vi),是指与该顶点关联的边的数目。
例如图7中的无向图图①,我们就可以说:顶点AAA与BBB邻接,AAA与BBB联通,边(A,B)(A,B)(A,B)关联于顶点AAA和BBB,顶点AAA的度为3。图中有5条边,各顶点度数和为3+2+3+2=103+2+3+2=103+2+3+2=10;我们发现各顶点度数和是边数的2倍,这其实是因为每条边都关联两个顶点,被计算了两次。由此,我们可以得出一个结论:无向图中所有顶点的度数之和等于边数的2倍,即一个有nnn个顶点,eee条边的图,满足如下关系:
e=12∑i=1nTD(vi)e=\frac{1}{2}\sum_{i=1}^{n}TD(v_i)e=21i=1∑nTD(vi)
对于有向图G=(V,{E})G=(V,\{E\})G=(V,{E}),如果弧<vi,vj>∈E<v_i,v_j>\in E<vi,vj>∈E,则称顶点viv_ivi邻接到vjv_jvj,vjv_jvj邻接自viv_ivi。如果弧<vi,vj>∈E<v_i,v_j>\in E<vi,vj>∈E,则称弧<vi,vj><v_i,v_j><vi,vj>和顶点viv_ivi,vjv_jvj相关联。顶点viv_ivi的出度(Outdegree),记为OD(vi)OD(v_i)OD(vi),是指以该顶点为弧尾的弧的数目;顶点viv_ivi的入度(Indegree),记为ID(vi)ID(v_i)ID(vi),是指以该顶点为弧头的弧的数目。顶点viv_ivi的度TD(vi)=OD(vi)+ID(vi)TD(v_i)=OD(v_i)+ID(v_i)TD(vi)=OD(vi)+ID(vi)。
例如图7中的有向图图②,我们就可以说:顶点AAA邻接到BBB,BBB邻接自AAA,弧<A,B><A,B><A,B>关联于顶点AAA和BBB,顶点AAA的出度为2(从AAA到BBB、从AAA到CCC),入度为1(从DDD到AAA),度为3。图中有5条弧,各顶点入度和1+1+3+0=51+1+3+0=51+1+3+0=5,各顶点出度和2+1+0+2=52+1+0+2=52+1+0+2=5,所以我们知道:有向图中所有顶点的入度之和等于所有顶点的出度之和,且都等于弧数。即一个有nnn个顶点,eee条弧的图,满足如下关系:e=∑i=1nID(vi)=∑i=1nOD(vi)e=\sum_{i=1}^{n}ID(v_i)=\sum_{i=1}^{n}OD(v_i)e=i=1∑nID(vi)=i=1∑nOD(vi)
- 路径、环
无向图G=(V,{E})G=(V,\{E\})G=(V,{E})中从顶点viv_ivi到顶点vjv_jvj的路径(Path)是一个顶点序列(vi,vk1,vk2,...,vkr,vj)(v_i,v_{k1},v_{k2},...,v_{kr},v_j)(vi,vk1,vk2,...,vkr,vj),其中边(vi,vk1)(v_i,v_{k1})(vi,vk1),(vk1,vk2)(v_{k1},v_{k2})(vk1,vk2),…,(vkr,vj)(v_{kr},v_j)(vkr,vj)均属于边集EEE。如下图所示,无向图图7-①中从顶点BBB到顶点DDD的四条路径:(B,A,D)(B,A,D)(B,A,D)、(B,C,D)(B,C,D)(B,C,D)、(B,C,A,D)(B,C,A,D)(B,C,A,D)和(B,A,C,D)(B,A,C,D)(B,A,C,D)。
如果GGG是有向图,顶点序列应满足<vi,vk1><v_i,v_{k1}><vi,vk1>,<vk1,vk2><v_{k1},v_{k2}><vk1,vk2>,…,<vkr,vj><v_{kr},v_j><vkr,vj>均属于弧集EEE。如下图所示,有向图图7-②中从顶点AAA到顶点CCC的两条路径:<A,C><A,C><A,C>和<A,B,C><A,B,C><A,B,C>。而顶点AAA到顶点DDD没有路径,因为没有任何一条路径能满足弧的方向性要求。
从上述例子中我们可以看出,图中顶点与顶点之间的路径并不唯一。
路径的长度是路径上的边或弧的数目。如图8中,标识出的路径从左到右分别为:2、2、3、3;而图9中,标识出的路径从左到右分别为:1、2。
第一个顶点和最后一个顶点相同的路径称为回路或环(Cycle)。序列中顶点不重复出现的路径称为简单路径。除了第一个顶点和最后一个顶点外,其他顶点均不重复出现的回路称为简单回路或简单环。如下图中所示两条标记粗线均构成环,左侧环为(A,B,C,D,A)(A,B,C,D,A)(A,B,C,D,A),除了第一个顶点和最后一个顶点外,其他顶点均不重复出现,所以它是一个简单环;右侧环为(B,A,D,C,A,B)(B,A,D,C,A,B)(B,A,D,C,A,B),顶点AAA在序列中重复出现,所以它不是一个简单环。
4、图的连通性
- 连通图、连通分量
在无向图G=(V,{E})G=(V,\{E\})G=(V,{E})中,如果两个顶点viv_ivi和vjv_jvj之间存在路径,则称viv_ivi和vjv_jvj是连通的(Connected);如果图中任意两顶点都是连通的,则称该图为连通图(Connected Graph)。如下图所示,左图中顶点AAA没有到顶点EEE的路径,所以它不是连通图;右图中任意两顶点之间都有路径,所以它是连通图。
无向图中的极大连通子图称为连通分量。概念中强调以下几点:
- 要是子图:连通分量必须是原图的子图。
- 子图要连通:连通分量必须是连通图。
- 连通子图要极大:连通子图必须有极大顶点数,即不能缺少任何连通的顶点,也不能多余任何不连通的顶点。
- 具有极大顶点数的连通子图包含依附于这些顶点的所有边。
如下图中左图就有两个连通分量,分别为图中①和②所示的子图,而③因为不符合条件3,即它没有极大顶点数,所以它不是连通分量。④则因为不符合条件4,缺少了依附于顶点CCC与DDD的边(C,D)(C,D)(C,D),所以它也不是连通分量。
在有向图G=(V,{E})G=(V,\{E\})G=(V,{E})中,如果对于每一对顶点vi,vj∈Vv_i,v_j \in Vvi,vj∈V,vi≠vjv_i \neq v_jvi=vj,从viv_ivi到vjv_jvj存在路径,且从vjv_jvj到viv_ivi也存在路径,则称GGG是强连通图。有向图中的极大强连通子图称为强连通分量。如下图中,图①中顶点DDD可以到达AAA和CCC,但是它们都无法到达DDD,所以图①不是强连通图;图②中任意两顶点之间都可以互相到达,所以它是强连通图,且它和图③同时都是图①的强连通分量。
- 生成树、生成森林
一个连通图的生成树(Spanning Tree)是一个包含图中所有顶点的极小连通子图。生成树有以下几个特点:
- 包含所有顶点:生成树必须包含原图中的所有顶点。
- 极小连通子图:生成树是原图的一个连通子图,并且没有冗余边,即去掉任何一条边都会导致图不连通。
- 边数:对于一个有nnn个顶点的连通图,其生成树恰好有足以构成一棵树的n−1n-1n−1条边。
如下图中,图①是一个连通图,图②和图③都是它的生成树(生成树不唯一)。从图中也可以看出,如果从生成树中再去掉一条边,图就不连通了。也就是说:如果一个图有nnn个顶点和小于n−1n-1n−1条边,则它一定是不连通的;反之,如果往生成树中再添加一条边,则必定会形成环。即:如果一个图有nnn个顶点和多于n−1n-1n−1条边,则它一定含有环。还有需要注意的是,有nnn个顶点n−1n-1n−1条边不一定是生成树,如图④所示。
如果一个有向图恰有一个顶点的入度为0,其余顶点的入度都为1,则该有向图中不存在环,且它的生成树是唯一的,或称其是一棵有向树。学习完了树,对该条概念的理解就会更加容易,我们所提到的入度为0的顶点就是树的根结点,而入度为1的顶点就是树中的其他结点,即树的非根结点只有一个双亲。
一个有向图的生成森林由若干棵有向树组成,它包含图中所有顶点,但只有足以构成若干棵不相交的有向树的弧。
如下图中,图①是一个有向图,图②和图③构成了它的一个生成森林(生成森林也不唯一)。
5、图的术语总结
看完上述内容,我们发现图的定义和术语还是非常多的,下面我们挑出一些主要内容进行一个总结:
- 图按照边的方向性可以分为无向图和有向图:
- 无向图中的边是无方向的,使用无序对(vi,vj)(v_i,v_j)(vi,vj)表示。
- 有向图中的边(即弧)是有方向的,使用有序对<vi,vj><v_i,v_j><vi,vj>表示,弧有弧头和弧尾之分。
- 图按照边的数量可以分为稀疏图和稠密图:
- 稀疏图:边的数量较少,通常指e<nlogne<n\log ne<nlogn。
- 稠密图:边的数量较多,通常指e≥nlogne\geq n\log ne≥nlogn。
- 图按照边的权值可以分为带权图和不带权图:
- 带权图/网:每条边都赋有一个数值(称为权),如距离、费用等。
- 图中任意两个顶点之间都存在边的图称为完全图:
- 无向完全图:任意两顶点之间都有一条无向边,边的数量e=n(n−1)2e=\frac{n(n-1)}{2}e=2n(n−1)。
- 有向完全图:任意两顶点之间都有两条方向相反的有向边,边的数量e=n(n−1)e=n(n-1)e=n(n−1)。
- 图中顶点依附的边的数量称为顶点的度:
- 无向图中,顶点viv_ivi的度TD(vi)TD(v_i)TD(vi)是指与该顶点关联的边的数目。
- 有向图中,顶点viv_ivi的出度OD(vi)OD(v_i)OD(vi)是指以该顶点为弧尾的弧的数目(从该顶点出发),入度ID(vi)ID(v_i)ID(vi)是指以该顶点为弧头的弧的数目(指向该顶点)。
- 图中顶点间的路径和环:
- 连通:顶点间存在路径则称它们是连通的。
- 环:路径的第一个顶点和最后一个顶点相同的路径称为环。
- 简单路径:序列中顶点不重复出现的路径称为简单路径。
- 图的连通性:
- 连通图:任意两顶点都是连通的图称为连通图,有向则称为强连通图。
- 连通分量:无向图中的极大连通子图称为连通分量,有向图中的极大强连通子图称为强连通分量。
- 图的生成树和生成森林:
- 生成树:无向图中连通的nnn个顶点的n−1n-1n−1条边称为生成树。
- 有向树:有向图中恰有一个顶点的入度为0,其余顶点的入度都为1的图称为有向树。
- 生成森林:一个有向图由若干棵有向树组成的极小连通子图称为生成森林。
二、图的抽象数据类型
同样的,图作为一种数据结构,也有其对应的抽象数据类型,也正因为它的复杂,应用广泛,使得其抽象数据类型的操作也比较多,下面我们列出一些常用的操作:
ADT 图(Graph)
Data顶点的有穷非空集合和顶点之间边的集合
OperationCreateGraph(*G, V, VR):按照顶点集合V和边(弧)集合VR构造图GDestroyGraph(*G):若图G存在,则销毁LocateVex(G, u):若图G中存在顶点u,则返回其位置,否则返回-1GetVex(G, v):返回图G中位置为v的顶点PutVex(G, v, value):将图G中位置为v的顶点修改为valueFirstAdjVex(G, *v):返回图G中位置为v的顶点的第一个邻接点的位置,若无邻接点则返回-1NextAdjVex(G, v, w):返回图G中位置为v的顶点的相对于位置为w的邻接点的下一个邻接点的位置,若无(w是v的最后一个邻接点)则返回-1InsertVex(*G, v):在图G中插入顶点vDeleteVex(*G, v):在图G中删除顶点v及其相关联的边(弧)InsertArc(*G, v, w):在图G中插入弧,若是无向图则插入边(v,w)和(w,v)DeleteArc(*G, v, w):在图G中删除弧,若是无向图则删除边(v,w)和(w,v)DFSTraverse(G, visit):对图G进行深度优先遍历BFSTraverse(G, visit):对图G进行广度优先遍历
endADT
三、图的存储结构
其实从第一部分的概念叙述中,我们就会发现我们一直在说的“顶点的位置”或“邻接点的位置”都是相对的概念,其实从图的逻辑结构定义中就可以看出,图中的顶点和边都是无序的,不同于树拥有层次关系,我们可以说图中任意一个顶点为第一个顶点,例如下面四个图,仔细观察就会发现,它们其实是同一个图,只不过我们绘制的位置不同显得不一样而已。
也正由于图的结构如此复杂,任意两个顶点之间都有可能存在联系,因此无法以数据元素在内存中的物理位置来表示它们之间的逻辑关系,换言之,我们无法通过简单的顺序存储结构来表示。而链式存储结构虽然可以表示任意复杂的逻辑关系,但由于图中顶点和边的数量都不确定,所有顶点的最大度和最小度可能会有较大的差异,该情况我们也已经在“树”中提到过,会浪费大量存储空间。这样一来,如何将图存储在计算机中就成了一个难题。不过,我们现在已经是站在巨人的肩膀上了,计算机领域的前辈们已经为我们解决了这个问题,接下来我们就来学习几种常用的图的存储结构。
1、邻接矩阵
考虑到图是由顶点和边或弧组成的,我们不用试图将它们存储在一起,而是将它们分开存储,由于我们在前面已经发现,顶点之间没有主次,所以顶点完全可以使用一个一维数组来存储,而边或弧是顶点与顶点之间的联系,我们可以使用一个二维数组来存储它们之间的关系,这样就形成了邻接矩阵(Adjacency Matrix)存储结构。
图的邻接矩阵存储方式:使用两个数组来存储图,一个一维数组存储图的顶点,一个二维数组存储图的边或弧。
假设图GGG有nnn个顶点,则邻接矩阵是一个n×nn \times nn×n的方阵,定义为:
arc[i][j]={1,若(vi,vj)∈E或<vi,vj>∈E0,其他arc[i][j]=\begin{cases} 1 , \text{若}(v_i,v_j)\in E \text{或}<v_i,v_j>\in E\\ 0 , \text{其他} \end{cases}arc[i][j]={1,若(vi,vj)∈E或<vi,vj>∈E0,其他
我们来看一个实例:
可以看到,设置了两个数组,顶点数组为vertex[4]={v0,v1,v2,v3}vertex[4]=\{ v_0, v_1, v_2, v_3 \}vertex[4]={v0,v1,v2,v3},边数组也即邻接矩阵arc[4][4]
为上图中所示的矩阵,我们发现,矩阵的主对角线上元素均为0,即arc[0][0]
、arc[1][1]
、arc[2][2]
、arc[3][3]
均为0,这是因为图中没有顶点与其自身相连的边(即没有自环),如果有自环,则对应位置的元素值为1。我们再来看一下矩阵中的其他元素,例如arc[0][1]=1
,表示顶点v0v_0v0与v1v_1v1之间有边,而arc[1][3]=0
,表示顶点v1v_1v1与v3v_3v3之间没有边,由于图中边是无向的,所以同时也意味着v3v_3v3到v1v_1v1的边也不存在,即arc[3][1]=0
。所以,我们可以得出结论:无向图的邻接矩阵是对称矩阵。
有了这个矩阵,我们可以很容易得知图中信息:
- 判断任意两个顶点viv_ivi和vjv_jvj是否邻接:只需判断对应位置的元素值
arc[i][j]
是否为1即可。 - 获取顶点的度:顶点viv_ivi的度TD(vi)TD(v_i)TD(vi)等于矩阵第iii行(或第iii列)中值为1的元素个数,或者说元素之和,即TD(vi)=∑j=0n−1arc[i][j]TD(v_i)=\sum_{j=0}^{n-1}arc[i][j]TD(vi)=∑j=0n−1arc[i][j]。
- 获取顶点的所有邻接点:顶点viv_ivi的所有邻接点就是矩阵第iii行中值为
arc[i][j]=1
的元素所对应的顶点vjv_jvj构成的集合。
我们再来看一个有向图的实例:
同样的,设置了两个数组,顶点数组为vertex[4]={v0,v1,v2,v3}vertex[4]=\{ v_0, v_1, v_2, v_3 \}vertex[4]={v0,v1,v2,v3},弧数组也即邻接矩阵arc[4][4]
为上图中所示的矩阵,主对角线元素依旧为0。但因为图中边是有向的,所以我们发现矩阵不再是对称矩阵,例如arc[1][2]=1
,表示顶点v1v_1v1到v2v_2v2有弧,而arc[2][1]=0
,表示顶点v2v_2v2到v1v_1v1没有弧。
同样的,我们也可以通过这个矩阵获取图中信息:
- 判断任意两个顶点viv_ivi和vjv_jvj是否邻接:只需判断对应位置的元素值
arc[i][j]
是否为1即可。 - 获取顶点的出度和入度:顶点viv_ivi的出度OD(vi)OD(v_i)OD(vi)等于矩阵第iii行中值为1的元素个数,或者说元素之和,即OD(vi)=∑j=0n−1arc[i][j]OD(v_i)=\sum_{j=0}^{n-1}arc[i][j]OD(vi)=∑j=0n−1arc[i][j];顶点viv_ivi的入度ID(vi)ID(v_i)ID(vi)等于矩阵第iii列中值为1的元素个数,或者说元素之和,即ID(vi)=∑j=0n−1arc[j][i]ID(v_i)=\sum_{j=0}^{n-1}arc[j][i]ID(vi)=∑j=0n−1arc[j][i]。
在图的概念中,我们还提到了带权图即网,那我们又如何使用邻接矩阵来表示呢?如下:
设网图GGG有nnn个顶点,邻接矩阵是一个n×nn \times nn×n的方阵,定义为:
arc[i][j]={wij,若(vi,vj)∈E或<vi,vj>∈E0,若i=j∞,其他arc[i][j]=\begin{cases} w_{ij} , \text{若}(v_i,v_j)\in E \text{或}<v_i,v_j>\in E\\ 0 , \text{若}i=j \\ \infty , \text{其他} \end{cases}arc[i][j]=⎩⎨⎧wij,若(vi,vj)∈E或<vi,vj>∈E0,若i=j∞,其他
其中wijw_{ij}wij表示边或弧(vi,vj)(v_i,v_j)(vi,vj)或<vi,vj><v_i,v_j><vi,vj>上的权值,∞\infty∞表示一个计算机允许的,大于所有边上权值的数,也就是一个不可能的值。这时就会有人疑惑,为什么不能和前面一样使用0来表示没有边或弧呢?这是因为在带权图中,权值可以为0,如果我们使用0来表示没有边或弧,那么当权值为0时就无法区分了,所以我们只能使用一个不可能的值来表示没有边或弧。
同样的,我们来看一个实例:
可以看到,对角线依旧为0,表示没有自环,或者如果将权重当作距离的话,表示自己到自己的距离为0,也是说得过去的。其他位置则不再赘述。
现在,我们可以给出它的结构体定义了:
#define MAXVEX 100 // 最大顶点数
#define INFINITY 65535 // 用65535来代表无穷大
typedef char VertexType; // 顶点类型
typedef int EdgeType; // 边上的权值类型
typedef struct {
VertexType vexs[MAXVEX]; // 顶点表
EdgeType arc[MAXVEX][MAXVEX]; // 邻接矩阵,边表
int numVertexes, numEdges; // 图中当前的顶点数和边数
} MGraph;
严教授在《数据结构》中给出了更详尽,更细致的定义如下,笔者将其补全(防止部分信息没有定义导致大家看得云里雾里,不过既然大家都学习到这里了,我认为不用补全也可以…但总归就是补全了balabala…)如下:
#define MAXVEX 100 // 最大顶点数
#define INFINITY 65535 // 用65535来代表无穷大
typedef char VertexType; // 顶点类型
typedef int EdgeType; // 边上的权值类型
typedef float InfoType;
typedef enum { DG, DN, UDG, UDN } GraphKind; // 图的种类(有向图,有向网,无向图,无向网)
typedef enum { VR, WVR } VRType; // 顶点关系类型(无权图,带权图)
typedef struct ArcCell { // 边表结点
VRType adj; // 顶点关系类型
InfoType *info; // 边相关信息指针
} ArcCell, AdjMatrix[MAXVEX][MAXVEX]; // 邻接矩阵类型
typedef struct {
VertexType vexs[MAXVEX]; // 顶点表
AdjMatrix arc; // 邻接矩阵,边表
int numVertexes, numEdges; // 图中当前的顶点数和边数
GraphKind kind; // 图的种类
} MGraph;
可以看出,比上面的定义多了许多内容,例如图中会存储该图的种类,边上会存储与边相关的信息(通常是权)等。同样,作为一个面向初学者的教程,还是避免复杂化,我们依旧选择《大话数据结构》中的定义,这个定义可以算得上是我们需要掌握的最小子集(虽删去了部分内容,但定义的关键内容并没有改变),所以这并不会影响我们的学习。
有了以上的结构定义,我们要创建一个图,即为向两个数组赋值,我们来看看创建一个无向网图的代码:
void CreateUDN(MGraph *G) {
int i, j, k, w;
printf("请输入顶点数和边数:\n");
scanf("%d,%d", &G->numVertexes, &G->numEdges); // 输入顶点数和边数
for (i = 0; i < G->numVertexes; i++) { // 初始化顶点scanf(&G->vexs[i]);}for (i = 0; i < G->numVertexes; i++) { // 邻接矩阵初始化for (j = 0; j < G->numVertexes; j++) {if (i == j) {G->arc[i][j] = 0; // 对角线初始化为0} else {G->arc[i][j] = INFINITY; // 其他位置初始化为无穷大}}}for (k = 0; k < G->numEdges; k++) { // 初始化边printf("请输入边(vi,vj)上的顶点序号i,j和权w:\n");scanf("%d,%d,%d", &i, &j, &w); // 输入边(vi,vj)上的顶点序号i,j和权wG->arc[i][j] = w; // 有向图只赋值arc[i][j]G->arc[j][i] = w; // 无向图还要赋值arc[j][i]}}
分析代码不难得出,nnn个顶点和eee条边的图,创建操作的时间复杂度为O(n+n2+e)O(n+n^2+e)O(n+n2+e),其中:nnn是初始化顶点表G.vexs
的时间复杂度,n2n^2n2是初始化邻接矩阵G.arc
的时间复杂度,eee是边表G.arc
赋值的时间复杂度。
2、邻接表
邻接矩阵存储结构十分简单且直观,非常容易理解,但是我们从图19中就能看出,对于稀疏图来说,邻接矩阵中一大部分都是无穷大(即没有边或弧),这就造成了存储空间的浪费。又到了喜闻乐见的顺序存储找缺点的环节了,我们都知道它的解决方案是链式存储,而图这种结构如何使用链式存储呢?前辈们已经给出了答案——邻接表。
我们在“树”的存储结构探索时学到了一种孩子表示法,其结构如下:
可以看到,我们把每个结点都存在了一个一维数组中,在其后面拉出链表表示该结点的孩子,这样就可以动态地存储每个结点的孩子了。我们依旧采用这个思路,使用一个一维数组存储图中各个顶点,然后为每个顶点拉出一个链表,表示与该顶点邻接的顶点,这便是邻接表(Adjacency List)。
邻接表的处理方式如下:
- 使用一个一维数组存储图中的顶点信息,当然也可以使用单链表,但数组使用下标访问有更高的效率。数组中不仅要存储顶点信息,还需要存储一个指向其邻接点链表的指针。
- 图中每个结点viv_ivi使用一个单链表来动态存储其邻接点,若为无向图,则称该单链表为viv_ivi的边表;若为有向图,则称该单链表为viv_ivi的出边表。
我们给出顶点数组的结点示意图如下:
其中data
域存储顶点信息,firstedge
域存储指向该顶点边表或出边表的指针。
我们再来看边表或出边表的结点示意图如下:
其中adjvex
域存储邻接点在顶点数组中的下标,weight
域存储边或弧的权值(如果是无权图则不需要该域),next
域存储指向下一个邻接点的指针。
我们来看一个无向图的实例:
可以看到,顶点数组vexs
中存储了图中的4个顶点{v0,v1,v2,v3}\{ v_0, v_1, v_2, v_3 \}{v0,v1,v2,v3},它们后面均有一个邻接点链表,例如顶点v0v_0v0与顶点v1v_1v1、v2v_2v2、v3v_3v3邻接,所以后面的链表中存储了它们的数组下标1
、2
、3
。
对于这样的结构,我们同样可以获取图中的信息:
- 判断任意两个顶点viv_ivi和vjv_jvj是否邻接:需遍历顶点viv_ivi的邻接点链表,判断是否存在邻接点vjv_jvj的下标
j
即可。 - 获取顶点的度:顶点viv_ivi的度TD(vi)TD(v_i)TD(vi)等于其邻接点链表中结点的个数,或者说是它邻接顶点表的长度。
- 获取顶点的所有邻接点:顶点viv_ivi的所有邻接点就是其邻接点链表中存储的所有邻接点下标
adjvex
所对应的顶点vjv_jvj构成的集合。
我们再来看一个有向图的实例:
可以看出,该有向图的邻接表只可以简单获取到顶点的出度,这是因为我们是以出边(即弧尾)建立的表,而获取入度,则需要遍历所有顶点的出边表,统计有多少个出边表中包含该顶点。于是,又提出了一种以入边(弧头)建立的邻接表,称为逆邻接表(Inverse Adjacency List),其结构与邻接表类似,只不过是以入边建立的表。图24中所示图的逆邻接表如下图所示:
对于我们来说,邻接表肯定是要比逆邻接表易懂的,但实际上,大家可以简单记忆为:邻接表表示每个顶点能到达哪些顶点,逆邻接表则表示每个顶点能从哪些顶点到达。
对于带权图,我们在结构定义中存在weight
域,故只需将边表结点改为带权值的结点即可,如下图所示:
可以看到,除了多出了weight
域外,其他均与前面所述相同。
现在,我们可以给出它的结构体定义了:
#define MAXVEX 100 // 最大顶点数
#define INFINITY 65535 // 用65535来代表无穷大
typedef char VertexType; // 顶点类型
typedef int WeightType; // 边上的权值类型
typedef struct EdgeNode { // 边表结点
int adjvex; // 邻接点在顶点数组中的下标
WeightType weight; // 边或弧的权值
struct EdgeNode *next; // 指向下一个邻接点的指针
} EdgeNode;
typedef struct VertexNode { // 顶点表结点
VertexType data; // 顶点信息
EdgeNode *firstedge; // 指向该顶点边表或出边表的指针
} VertexNode, AdjList[MAXVEX]; // 邻接表类型
typedef struct { // 图的邻接表存储结构
AdjList vexs; // 顶点表
int numVertexes, numEdges; // 图中当前的顶点数和边数
} GraphAdjList;
以上结构定义中,一张图使用一个结构体GraphAdjList
来表示,其中包含了一个VertexNode
一维数组(即AdjList
),和若干个边表结点EdgeNode
组成与顶点数对于的单链表构成邻接表。
我们依旧给出无向网创建代码如下:
void CreateALUDN(GraphAdjList* G)
{
int i, j, k, w;
EdgeNode* e;
printf("输入顶点数和边数:\n");
scanf("%d,%d", &G->numVertexes, &G->numEdges); // 输入顶点数和边数
for (i = 0; i < G->numVertexes; i++) // 初始化顶点表{scanf("%c", &G->AdjList[i].data); // 输入顶点信息G->AdjList[i].firstedge = NULL; //将其邻接表指针置为空}for (k = 0; k < G->numEdges; k++){printf("请输入边(vi,vj)上的顶点序号i,j和权w:\n");scanf("%d,%d,%d", &i, &j, &w); // 输入边信息e = (EdgeNode*)malloc(sizeof(EdgeNode)); // 申请新结点e->adjvex = i; // 先将第i个结点(的下标)链在顶点j的邻接表上e->weight = w; // 权值e->next = G->AdjList[j].firstedge; // 将e的后继设为原来firstedge所指向内容G->AdjList[j].firstedge = e; // 再让firstedge指向ee = (EdgeNode*)malloc(sizeof(EdgeNode)); // 申请新结点e->adjvex = j; // 再将第j个结点(的下标)链在顶点i的邻接表上e->weight = w; // 权值e->next = G->AdjList[i].firstedge; // 同上G->AdjList[i].firstedge = e;}}
从代码中我们看出,我们插入的方法用到了“单链表”中所学的头插法建表,头插法由于在表头插入,即顶点数组中的firstedge
即为头指针,如此一来就可以将插入操作控制为O(1)O(1)O(1),若是尾插且没有辅助尾指针的情况下,插入则需要O(n)O(n)O(n),nnn最大为对应顶点的度。且由于是无向图,则不仅需要把顶点viv_ivi的下标i
链在顶点vjv_jvj的邻接表中,还需要把顶点vjv_jvj的下标j
链在顶点viv_ivi的邻接表中。
3、十字链表
邻接表存储结构已经可以较好地表示图的结构了,但它也有缺点,我们已经提到了邻接表只能将方便地获取顶点的出度,而获取入度则需要遍历所有顶点的出边表后进行统计,也就是说关注了出度就不能关注入度,反之逆邻接表又有相反的问题,关注了入度就不能关注出度。那有没有一种存储结构可以同时关注入度和出度呢?答案是有的,这就是十字链表(Orthogonal List)。
我们重新定义顶点表中结点结构如下:
其中data
域存储顶点信息,firstin
域为入边表的指针,指向该顶点入边表第一个结点,firstout
为出边表的指针,指向该顶点出边表第一个结点。
我们再来看重新定义的边表结点结构如下:
其中tailvex
域存储弧尾顶点(弧的起点)在顶点数组中的下标,headvex
域存储弧头顶点(弧的终点)在顶点数组中的下标,weight
域存储边或弧的权值(如果是无权图则不需要该域),headlink
是入边表指针域,存储指向弧头相同的下一条弧的指针(即指向终点相同的下一条边),taillink
是边表指针域,存储指向弧尾相同的下一条弧的指针(即指向起点相同的下一条边)。
给出它的结构体定义:
#define MAXVEX 100 // 最大顶点数
#define INFINITY 65535 // 用65535来代表无穷大
typedef char VertexType; // 顶点类型
typedef int WeightType; // 边上的权值类型
typedef struct EdgeNode { // 边表结点
int tailvex, headvex; // 弧尾和弧头在顶点数组中的下标
WeightType weight; // 边或弧的权值
struct EdgeNode *headlink; // 指向弧头相同的下一条弧的指针
struct EdgeNode *taillink; // 指向弧尾相同的下一条弧的指针
} EdgeNode;
typedef struct VertexNode { // 顶点表结点
VertexType data; // 顶点信息
EdgeNode *firstin; // 指向该顶点入边表的指针
EdgeNode *firstout; // 指向该顶点出边表的指针
} VertexNode, OrthList[MAXVEX]; // 十字链表类型
typedef struct { // 图的十字链表存储结构
OrthList vexs; // 顶点表
int numVertexes, numEdges; // 图中当前的顶点数和边数
} GraphOrthList;
我们来看一个有向图的实例:
可以看出,构图十分地狱。为了方便理解,本人使用了“C、M、Y、K”四种颜色来区分,仔细观察,会发现每个顶点由firstout
引出(边结点中由taillink
引出)且使用黑色箭头链接起来的为出边表(邻接表);而由firstin
引出(边结点中由headlink
引出)且使用每个结点对应颜色箭头链接起来的为入边表(逆邻接表)。这样一来就构成了我们的十字链表,我们就可以同时关注入度和出度了。
例如图中顶点v0v_0v0,它有两条入边<v1,v0><v_1,v_0><v1,v0>和<v3,v0><v_3,v_0><v3,v0>,所以它的firstin
指向边结点<v1,v0><v_1,v_0><v1,v0>,而<v1,v0><v_1,v_0><v1,v0>的headlink
指向边结点<v3,v0><v_3,v_0><v3,v0>,而<v3,v0><v_3,v_0><v3,v0>的headlink
为NULL
,表示入边表结束;它有两条出边<v0,v1><v_0,v_1><v0,v1>和<v0,v2><v_0,v_2><v0,v2>,所以它的firstout
指向边结点<v0,v1><v_0,v_1><v0,v1>,而<v0,v1><v_0,v_1><v0,v1>的taillink
指向边结点<v0,v2><v_0,v_2><v0,v2>,而<v0,v2><v_0,v_2><v0,v2>的taillink
为NULL
,表示出边表结束。
我们还是只给出一个有向网创建代码:
void CreateOLDN(GraphOrthList* G)
{
int i, j, k, w;
EdgeNode* e;
printf("输入顶点数和边数:\n");
scanf("%d,%d", &G->numVertexes, &G->numEdges); // 输入顶点数和边数
for (i = 0; i < G->numVertexes; i++) // 初始化顶点表{scanf("%c", &G->vexs[i].data); // 输入顶点信息G->vexs[i].firstin = NULL; //将其入边表指针置为空G->vexs[i].firstout = NULL; //将其出边表指针置为空}for (k = 0; k < G->numEdges; k++){printf("请输入弧(vi,vj)上的顶点序号i,j和权w:\n");scanf("%d,%d,%d", &i, &j, &w); // 输入弧信息e = (EdgeNode*)malloc(sizeof(EdgeNode)); // 申请新结点e->tailvex = i; // 弧尾e->headvex = j; // 弧头e->weight = w; // 权值e->headlink = G->vexs[j].firstin; // 将e的headlink指向原来firstin所指向内容G->vexs[j].firstin = e; // 再让firstin指向ee->taillink = G->vexs[i].firstout; // 同上G->vexs[i].firstout = e;}}
从代码中我们看出,我们依旧使用了头插法建表,由于是有向图,则只需将弧<vi,vj><v_i,v_j><vi,vj>的结点e
插入到顶点viv_ivi的出边表中,但是同时不要忘了插入到顶点vjv_jvj的入边表中。虽给出代码使用的是头插法,但图29中所示图并不是头插法建表的结果,笔者为了方便理解,绘制了以<v0,v1><v_0,v_1><v0,v1>、<v0,v2><v_0,v_2><v0,v2>、<v1,v0><v_1,v_0><v1,v0>、<v1,v2><v_1,v_2><v1,v2>、<v3,v0><v_3,v_0><v3,v0>、<v3,v2><v_3,v_2><v3,v2>的顺序进行尾插法的建立结果,代码中为降低时间复杂度和代码的复杂度,便使用了头插法建表。
十字链表的优势在于结合了邻接表和逆邻接表,对于有向图来说,可以方便同时获取顶点的入度和出度,且我们发现其创建操作和邻接表创建操作是差不多的,时间复杂度也是一样的。它的缺点可能就只有结构复杂了一些,理解起来稍微有些困难了。总之,十字链表也是一种十分有用的图的存储结构。
4、邻接多重表
不难看出,十字链表是在邻接表基础上对有向图进行的改进,而对于无向图来说,邻接表已经可以较好地表示图的结构了,但它有一个特点不知道大家有没有注意到,那就是无向图的边是双向的,即边(vi,vj)(v_i,v_j)(vi,vj)和边(vj,vi)(v_j,v_i)(vj,vi)是同一条边,只不过表示方式不同而已,而邻接表中却将其存储为两个边结点,如图23中所示图中只有5条边,但是邻接表中却有10个边结点,这就造成了存储空间的浪费,且这样还导致删除边操作变得复杂了,因为删除边(vi,vj)(v_i,v_j)(vi,vj)时,还需要删除边(vj,vi)(v_j,v_i)(vj,vi)。
那有没有一种存储结构可以避免这种浪费呢?答案是有的,这就是邻接多重表(Adjacency Multi-list)。
我们按照十字链表的思路,重新定义边表结点结构如下:
其中ivex
域存储边的一个顶点在顶点数组中的下标,ilink
是边表指针域,存储指向与ivex
相同的下一条边的指针,jvex
域存储边的另一个顶点在顶点数组中的下标,weight
域存储边的权值(如果是无权图则不需要该域),jlink
是边表指针域,存储指向与jvex
相同的下一条边的指针。
我们给出它的结构体定义:
#define MAXVEX 100 // 最大顶点数
#define INFINITY 65535 // 用65535来代表无穷大
typedef char VertexType; // 顶点类型
typedef int WeightType; // 边上的权值类型
typedef struct EdgeNode { // 边表结点
int ivex, jvex; // 边的两个顶点在顶点数组中的下标
WeightType weight; // 边的权值
struct EdgeNode *ilink; // 指向与ivex相同的下一条边的指针
struct EdgeNode *jlink; // 指向与jvex相同的下一条边的指针
} EdgeNode;
typedef struct VertexNode { // 顶点表结点
VertexType data; // 顶点信息
EdgeNode *firstedge; // 指向该顶点边表的指针
} VertexNode, AMLAdjList[MAXVEX]; // 邻接多重表类型
typedef struct { // 图的邻接多重表存储结构
AMLAdjList vexs; // 顶点表
int numVertexes, numEdges; // 图中当前的顶点数和边数
} GraphAMLAdjList;
我们还是来看实例:
可以看出,构图与十字链表类似。若是有人阅读过《大话数据结构》一书,会发现里面示例图与本人绘制的并不一致,如下:
关于这幅图,笔者认为程老师是为了帮助大家理解各条连线是如何联系的,所以简化了连线过程,但这样一来不免会让一部分同学产生对于结点间联系的疑惑,笔者尝试了多种建图过程,均无法绘制得与程老师所作完全一致的图,这样一来使得该图失了一定的严谨性。注意以上仅是个人的一点点拙见,仍受个人知识储备、理解能力等因素的限制,若有不妥之处,还请不吝赐教。
为保证大家对于该存储方式的理解,笔者还是按照系统的建图过程绘制了图31,可以看出比起程老师的图确实复杂了一些,但不失可转化为代码的严谨性。图中依旧采用“C、M、Y、K”四种颜色来区分,每个顶点对应颜色所链接起来的边结点即为该顶点的边表,例如顶点v0v_0v0,它有三条边(v0,v1)(v_0,v_1)(v0,v1)、(v0,v2)(v_0,v_2)(v0,v2)和(v0,v3)(v_0,v_3)(v0,v3),所以它的firstedge
指向边结点(v0,v3)(v_0,v_3)(v0,v3),而(v0,v3)(v_0,v_3)(v0,v3)的ilink
指向边结点(v0,v1)(v_0,v_1)(v0,v1),而(v0,v1)(v_0,v_1)(v0,v1)的ilink
指向边结点(v0,v2)(v_0,v_2)(v0,v2),而(v0,v2)(v_0,v_2)(v0,v2)的ilink
为NULL
,表示边表结束。
可能还是有同学不能理解为什么程老师的图失了严谨性,本人将自己的建图过程给出,以下是边的建立顺序:(v0,v1)(v_0,v_1)(v0,v1)、(v0,v2)(v_0,v_2)(v0,v2)、(v0,v3)(v_0,v_3)(v0,v3)、(v1,v2)(v_1,v_2)(v1,v2)、(v2,v3)(v_2,v_3)(v2,v3)。
我们一步一步来分析建图过程:
首先,建立顶点表:v0v_0v0、v1v_1v1、v2v_2v2、v3v_3v3,并将它们的firstedge
均置为NULL
,数组中顶点下标与顶点角标一致,便不再标出。
然后,建立边(v0,v1)(v_0,v_1)(v0,v1),新建边结点e1
,将e1->ivex=0
,e1->jvex=1
,e1->ilink=NULL
,e1->jlink=NULL
,将顶点v0v_0v0的firstedge
指向e1
,将顶点v1v_1v1的firstedge
指向e1
。
建立边(v0,v2)(v_0,v_2)(v0,v2),新建边结点e2
,将e2->ivex=0
,e2->jvex=2
,将e2->ilink
指向原来顶点v0v_0v0的firstedge
所指向的内容(即边结点e1
),将顶点v0v_0v0的firstedge
指向e2
,将顶点v2v_2v2的firstedge
指向e2
,可以看到,使用的依旧是头插法。
建立边(v0,v3)(v_0,v_3)(v0,v3),新建边结点e3
,将e3->ivex=0
,e3->jvex=3
,将e3->ilink
指向原来顶点v0v_0v0的firstedge
所指向的内容(即边结点e2
),将顶点v0v_0v0的firstedge
指向e3
,将顶点v3v_3v3的firstedge
指向e3
。
建立边(v1,v2)(v_1,v_2)(v1,v2),新建边结点e4
,将e4->ivex=1
,e4->jvex=2
,将e4->ilink
指向原来顶点v1v_1v1的firstedge
所指向的内容(即边结点e1
),将顶点v1v_1v1的firstedge
指向e4
,将e4->jlink
指向原来顶点v2v_2v2的firstedge
所指向的内容(即边结点e2
),将顶点v2v_2v2的firstedge
指向e4
。
最后,建立边(v2,v3)(v_2,v_3)(v2,v3),新建边结点e5
,将e5->ivex=2
,e5->jvex=3
,将e5->ilink
指向原来顶点v2v_2v2的firstedge
所指向的内容(即边结点e4
),将顶点v2v_2v2的firstedge
指向e5
,将顶点v3v_3v3的firstedge
指向e5
。
至此,图的邻接多重表存储结构就建立完成了,我们也能发现如果代码方法不一样(如头插法改尾插法)或建立边的顺序不一样,我们最后获得的结果也会不同。我们再给出一个无向网创建代码:
void CreateAMLUDN(GraphAMLAdjList* G)
{
int i, j, k, w;
EdgeNode* e;
printf("输入顶点数和边数:\n");
scanf("%d,%d", &G->numVertexes, &G->numEdges); // 输入顶点数和边数
for (i = 0; i < G->numVertexes; i++) // 初始化顶点表{scanf("%c", &G->vexs[i].data); // 输入顶点信息G->vexs[i].firstedge = NULL; //将其邻接表指针置为空}for (k = 0; k < G->numEdges; k++){printf("请输入边(vi,vj)上的顶点序号i,j和权w:\n");scanf("%d,%d,%d", &i, &j, &w); // 输入边信息e = (EdgeNode*)malloc(sizeof(EdgeNode)); // 申请新结点e->ivex = i; // 边的一个顶点e->jvex = j; // 边的另一个顶点e->weight = w; // 权值e->ilink = G->vexs[i].firstedge; // 将e的ilink指向原来firstedge所指向内容G->vexs[i].firstedge = e; // 再让firstedge指向ee->jlink = G->vexs[j].firstedge; // 同上G->vexs[j].firstedge = e;}}
从代码中我们看出,与十字链表创建代码十分类似,大家也可以自行尝试编写其余代码。
至此,我们已经介绍了图的四种存储结构,分别是邻接矩阵、邻接表、十字链表和邻接多重表,这四种存储结构各有优缺点,大家可以根据实际需求选择合适的存储结构。下表总结了这四种存储结构的优缺点:
存储结构 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
邻接矩阵 | 1. 结构简单,易于理解和实现。 2. 适合表示稠密图,边数接近顶点数平方。 3. 可以快速判断两个顶点是否邻接。 | 1. 存储空间浪费,尤其是对于稀疏图,大部分元素为无穷大。 2. 插入和删除边的操作复杂。 | 适用于边数较多的稠密图 |
邻接表 | 1. 存储空间利用率高,适合表示稀疏图。 2. 插入和删除边的操作较为简单。 | 1. 判断两个顶点是否邻接需要遍历邻接点链表。 2. 获取顶点的入度需要遍历所有顶点的出边表。 | 适用于边数较少的稀疏图 |
十字链表 | 1. 可以同时关注顶点的入度和出度,适合有向图。 2. 插入和删除边的操作较为简单。 | 1. 结构复杂,理解和实现较为困难。 2. 存储空间相对较大,因为每条边需要存储两个指针。 | 适用于需要频繁查询入度和出度的有向图 |
邻接多重表 | 1. 避免了邻接表中的存储空间浪费,适合无向图。 2. 插入和删除边的操作较为简单。 | 1. 结构复杂,理解和实现较为困难。 2. 存储空间相对较大,因为每条边需要存储两个指针。 | 适用于需要频繁插入和删除边的无向图 |