一、图的定义与相关概念
在线性结构中,数据元素以线性排列,元素间存在前驱与后继关系。在树形结构中,数据元素以树形结构(层次结构)排列,每个元素可以存在多个下层元素,但是只允许存在一个上层元素。
如果元素间存在比较复杂的关系,即每个元素都与多个元素存在关系,则元素间的关系可以用“图”来表示。
图(Graph)是由顶点的有穷非空集合和顶点之间边的集合组成的,通常表示为G(V,E),其中G表示图,V表示顶点的集合,E表示边的集合。
根据不同的使用方式,图的具体定义也分为以下几类:
无向图:若顶点Vi到Vj之间的边没有方向,则称为无向边。若图中任意的边都是无向边,则称该图为无向图。
有向图:若从顶点Vi到Vj之间的边有方向,则称这条边为有向边,也称为弧(Arc)。如果图中任意两个顶点之间的边都是有向边,则称该图为有向图。
在无向图中,如果任意两个顶点之间都存在边,则称为无向完全图。含有n个顶点的无向完全图有n(n-1)/2条边。
在有向图中,如果任意两个顶点之间都存在方向互反的两条弧,则称为有向完全图。含有n个顶点的有向完全图有n(n-1)条边。
有很少条边或弧的图称为稀疏图,反之称为稠密图。
有些图的边或弧具有与之相关的数字,这些数字称为权(Weight)。这些权值可以表示从一个点到另一个点的距离或耗费。这种带权图也常称为网(Network)。
对于无向图,顶点v的度(Degree)是与v相关联的边的数目。对于有向图,度分为入度(InDegree)和出度(OutDegree)两种,其中以v为尾的弧的数目称为v的出度,而以v为头的弧的数目称为v的入度。
从一个顶点v到另一个顶点u的顶点序列称为路径(Path),其路径长度是路径上边或弧的数目。
二、图的存储结构
1、邻接矩阵
考虑到图是由顶点和边或弧两部分构成,因此合并存储比较困难,因此我们采用分开存储的方式。
图的邻接矩阵(Adjacency Matrix)存储方式是用一个二维数组来表示图的存储方式。存储方式为:
arc[i][j]= 1(若vi与vj可以连通)
0(反之,或者i=j)
若图内的边或弧存在权值,则存储方式为:
arc[i][j]= 该边/弧权值(若vi与vj可以连通)
0(若i=j)
∞(反之)
无向图的邻接矩阵是一个对称的矩阵,而有向图可以在邻接矩阵内得到入度与出度。
//见附图
邻接矩阵可以看做是图的顺序存储结构。
2、邻接表
邻接矩阵是一种不错的存储图的方式,但是我们也发现,若图的边或弧相对顶点数目较少,则在矩阵中会大量出现无用数据,这对存储空间是极大的浪费。此时,我们可以引入链式存储结构来避免空间浪费的问题。将数组与链表结合起来,用于表示图的存储方法称为邻接表(Adjacency List)。
邻接表的存储方式如下:
1.图中顶点使用一维数组存储(当然也可以使用链表存储,不过使用数组可以更加方便读取顶点信息),并且每个数组的元素都有一个指向邻接顶点的指针
2.途中每个顶点vi的所有邻接顶点构成一个线性表,由于邻接点的个数不确定,因此使用单链表存储。
3.对于带权图,在邻接表内增加一个存储权值weight的数据域。
//见附图
三、最小生成树(Minimum Cost Spanning Tree)
在带权图中,使用n-1条边,将n个顶点连接起来,并且得到的权值和最小,则这样的结构称为该图的最小生成树。
//例如,为n个村镇搭建通信网络,每个村庄之间的距离不同,则为了得到最低成本,我们应沿着最小生成树的方式搭建网络。
构建最小生成树有两种算法:Prim算法与Kruskal算法
//最小生成树图例见附图
1、Prim算法
该算法于1930年由捷克数学家沃伊捷赫·亚尔尼克(Vojtěch Jarník)发现,并在1957年由美国计算机科学家罗伯特·普里姆(Robert C. Prim)独立发现。
Prim算法的步骤如下:
//第0步表示准备工作,下同
0.设全部顶点集合为U,已选取顶点集合为V
1.选取任意点作为起始点,将其加入V中
2.在集合U-V中,选取能与集合V内的顶点连通的顶点且权值最小的边,并将这个顶点加入V中
3.不断重复步骤2,直至V与U数量相等。此时被选取的边构成最小生成树。
以图例的无向有权图为例:
0.设该图全部顶点集合为U={v0,v1,v2,v3,v4,v5,v6,v7,v8},已选取顶点集合为V={}
1.设起点为v0,将v0加入V,此时V={v0}
2.在剩下的顶点中,选取能与V中节点v0连通的顶点,且需要权值最小,此时选取边(v0,v1),同时将v1加入V。此时V={v0,v1}
3.在剩下的顶点中,选取能与V中节点{v0,v1}连通的顶点,且需要权值最小,此时选取边(v0,v5),同时将v5加入V。此时V={v0,v1,v5}
4.在剩下的顶点中,选取能与V中节点{v0,v1,v5}连通的顶点,且需要权值最小,此时选取边(v1,v8),同时将v8加入V。此时V={v0,v1,v5,v8}
5.在剩下的顶点中,选取能与V中节点{v0,v1,v5,v8}连通的顶点,且需要权值最小,此时选取边(v2,v8),同时将v2加入V。此时V={v0,v1,v2,v5,v8}。由于v2已经加入V,因此边(v1,v2)作废
6.在剩下的顶点中,选取能与V中节点{v0,v1,v2,v5,v8}连通的顶点,且需要权值最小,此时选取边(v1,v6),同时将v6加入V。此时V={v0,v1,v2,v5,v6,v8}。由于v6已经加入V,因此边(v5,v6)作废。
7.在剩下的顶点中,选取能与V中节点{v0,v1,v2,v5,v6,v8}连通的顶点,且需要权值最小,此时选取边(v6,v7),同时将v7加入V。此时V={v0,v1,v2,v5,v6,v7,v8}
8.在剩下的顶点中,选取能与V中节点{v0,v1,v2,v5,v6,v7,v8}连通的顶点,且需要权值最小,此时选取边(v4,v7),同时将v4加入V。此时V={v0,v1,v2,v4,v5,v6,v7,v8}。由于v4已经加入V,因此边(v4,v5)作废。
9.在剩下的顶点中,选取能与V中节点{v0,v1,v2,v4,v5,v6,v7,v8}连通的顶点,且需要权值最小,此时选取边(v3,v7),同时将v3加入V。此时V={v0,v1,v2,v3,v4,v5,v6,v7,v8}。由于v3已经加入V,因此边(v2,v3),(v3,v4),(v3,v6),(v3,v8)作废。
10.此时,所有顶点都已在V中,即U=V,算法结束。得到最小生成树。
Prim算法的时间复杂度为O(n^2)
//经过优化的Prim算法实际为O(nlogn),这里不做介绍
2、Kruskal算法
Prim算法以顶点作为目标去构建最小生成树,而Kruskal算法则是以边作为目标去构建最小生成树。
Kruskal算法算法针对边进行展开,边数较少时效率会非常高,因此非常适用于稀疏图。而Prim算法针对顶点进行展开,因此稠密图时效率高于Kruskal算法。
Kruskal算法的步骤如下:
0.令该图的初始状态为n个顶点而无边的非连通图,其中每一个顶点自成一个连通分量。同时设定一个边的集合T。
1.每次选取代价最小的边,若该边的两端依附于不同的连通分量,则将其加入T中,并且将两个独立的连通分量合并;否则舍弃该边。
2.依次类推,直至所有顶点都在同一连通分量上为止,此时T内的边就构成了最小生成树。
以图例的无向有权图为例:
0.令图内v0~v8所有顶点互相独立,每个顶点自成一个连通分量。设定边集合T
1.选取权值最小的边(v4,v7),此时将v4与v7合并成同一连通分量,并将边(v4,v7)加入T中
2.选取权值最小的边(v2,v8),此时将v2与v8合并成同一连通分量,并将边(v2,v8)加入T中
3.选取权值最小的边(v0,v1),此时将v0与v1合并成同一连通分量,并将边(v0,v1)加入T中
4.选取权值最小的边(v0,v5),此时将v0、v1、v5合并成同一连通分量,并将边(v0,v5)加入T中
5.选取权值最小的边(v1,v8),此时将v0、v1、v2、v5、v8合并成同一连通分量,并将边(v1,v8)加入T中
6.选取权值最小的边(v1,v6),此时将v0、v1、v2、v5、v6、v8合并成同一连通分量,并将边(v1,v6)加入T中
7.选取权值最小的边(v3,v7),此时将v3、v4、v7合并成同一连通分量,并将边(v3,v7)加入T中
8.选取权值最小的边(v5,v6),由于v5与v6已在同一连通分量中,选取该边会造成环路,因此舍弃
9.选取权值最小的边(v1,v2),由于v1与v2已在同一连通分量中,选取该边会造成环路,因此舍弃
10.选取权值最小的边(v6,v7),此时将v0、v1、v2、v3、v4、v5、v6、v7、v8合并成同一连通分量,并将边(v6,v7)加入T中
11.此后的选边均会造成环路,因此选取结束。最终在集合T中的边构成最小生成树
Kruskal算法的时间复杂度为O(n^2)
//经过优化的Kruskal算法实际为O(nlogn),这里不做介绍
四、最短路径算法——Dijkstra算法
对于有权图,最短路径指的是两顶点之间经过的边上权值之和最小的路径。使用Dijkstra算法可以求得任意一个顶点到所有其他顶点的最短路径。
Dijkstra算法的步骤如下:
0.1.将所有顶点分成两个集合:已知最短路径集合P和位置最短路径集合Q。算法开始时,集合P内只有一个起点。
0.2.设定一个存储起点到其他顶点最短路径预估值的数组dist[],其中数组下标代表顶点编号
1.选取dist[]内最小的元素(已加入集合P的不再选取),以该顶点作为起点计算所有与之连通的顶点的路径长度,并将该顶点从集合Q加入集合P
2.若通过该顶点算出的某其他顶点路径长度小于预估值,则更改该顶点的预估值(此过程称为“松弛”)。
3.重复步骤1和2,直至所有顶点加入集合P(或集合Q为空)
以图例的无向有权图为例:
0.选取起点为v0,并建立数组dist[]={0,1,5,∞,∞,∞,∞,∞,∞}。由于算法还未开始运行,因此v0只有到v1和v2的预估值,而其余顶点预估值未知。将v0加入集合P。
1.选取dist[]内最小的值:v1=1,以该点作为起点计算所有可以连通的顶点的路径长度。其中:
(v0,v1)回到v0,不考虑
(v1,v2)长度为3,若通过v1到达v2,则路径长度为1+3=4,小于预估值5,因此将数组内v2的值更新为4
(v1,v3)长度为7,若通过v1到达v3,则路径长度为1+7=8,因此将数组内v3的值更新为8
(v1,v4)长度为5,若通过v1到达v4,则路径长度为1+5=6,因此将数组内v4的值更新为6
经过该步后,dist[]={0,1,4,8,6,∞,∞,∞,∞},并将v1加入集合P,接下来不再选取
2.选取dist[]内最小的值:v2=4,以该点作为起点计算所有可以连通的顶点的路径长度。其中:
(v0,v2)回到v0,不考虑
(v1,v2)回到v1,不考虑
(v2,v4)长度为1,若通过v2到达v4,则路径长度为4+1=5,小于预估值6,因此将数组内v4的值更新为5
(v2,v5)长度为7,若通过v2到达v5,则路径长度为4+7=11,因此将数组内v5的值更新为11
经过该步后,dist[]={0,1,4,8,5,11,∞,∞,∞},并将v2加入集合P,接下来不再选取
3.选取dist[]内最小的值:v4=5,以该点作为起点计算所有可以连通的顶点的路径长度。其中:
(v1,v4)回到v1,不考虑
(v2,v4)回到v2,不考虑
(v3,v4)长度为2,若通过v4到达v3,则路径长度为5+2=7,小于预估值8,因此将数组内v3的值更新为7
(v4,v5)长度为3,若通过v4到达v5,则路径长度为5+3=8,小于预估值11,因此将数组内v5的值更新为8
(v4,v6)长度为6,若通过v4到达v6,则路径长度为5+6=11,因此将数组内v6的值更新为11
(v4,v7)长度为9,若通过v4到达v7,则路径长度为5+9=14,因此将数组内v7的值更新为14
经过该步后,dist[]={0,1,4,7,5,8,11,14,∞},并将v4加入集合P,接下来不再选取
4.选取dist[]内最小的值:v3=7,以该点作为起点计算所有可以连通的顶点的路径长度。其中:
(v1,v3)回到v1,不考虑
(v3,v4)回到v4,不考虑
(v3,v6)长度为3,若通过v3到达v6,则路径长度为7+3=10,小于预估值11,因此将数组内v6的值更新为10
经过该步后,dist[]={0,1,4,7,5,8,10,14,∞},并将v3加入集合P,接下来不再选取
5.选取dist[]内最小的值:v5=8,以该点作为起点计算所有可以连通的顶点的路径长度。其中:
(v2,v5)回到v2,不考虑
(v4,v5)回到v4,不考虑
(v5,v7)长度为5,若通过v5到达v7,则路径长度为8+5=13,大于预估值12,舍弃
经过该步后,dist[]={0,1,4,7,5,8,10,14,∞},并将v5加入集合P,接下来不再选取
6.选取dist[]内最小的值:v6=10,以该点作为起点计算所有可以连通的顶点的路径长度。其中:
(v3,v6)回到v3,不考虑
(v4,v6)回到v4,不考虑
(v6,v7)长度为2,若通过v6到达v7,则路径长度为10+3=12,小于预估值14,因此将数组内v7的值更新为12
(v6,v8)长度为7,若通过v6到达v8,则路径长度为10+7=17,因此将数组内v8的值更新为17
经过该步后,dist[]={0,1,4,7,5,8,10,12,17},并将v6加入集合P,接下来不再选取
7.选取dist[]内最小的值:v7=12,以该点作为起点计算所有可以连通的顶点的路径长度。其中:
(v4,v7)回到v4,不考虑
(v5,v7)回到v5,不考虑
(v6,v7)回到v6,不考虑
(v7,v8)长度为4,若通过v7到达v8,则路径长度为12+4=16,小于预估值17,因此将数组内v8的值更新为16
经过该步后,dist[]={0,1,4,7,5,8,10,12,16},并将v7加入集合P,接下来不再选取
8.选取dist[]内最小的值:v8=16,以该点作为起点计算所有可以连通的顶点的路径长度。其中:
(v6,v8)回到v6,不考虑
(v7,v8)回到v7,不考虑
经过该步后,dist[]={0,1,4,7,5,8,10,12,16},并将v8加入集合P,接下来不再选取
9.所有顶点都已加入集合P,则算法结束,最终数组内的值dist[]={0,1,4,7,5,8,10,12,16}表示从v0出发到达其他所有顶点的最短路径长度
需要注意的是,到达不同顶点的最短路径可能不是同一条,例如该范例中,v0到达v5的路径与到达其他顶点的路径不相同。
以某顶点作为起点计算所有最短路径长度的Dijkstra算法的时间复杂度为O(n^2),若需要得到所有顶点的最短路径,则还需要一次循环,即时间复杂度为O(n^3)
//经过堆优化的Dijkstra算法实际为O(nlogn),这里不做介绍