图的基本概念
图的定义
图G(Graph)由顶点集V(Vertex)和边集E(Edge)组成,记为$$G=(V,E)$$,其中V(G)表示图G中顶点的有限非空集:E(G)表示图G中顶点之间的关系(边)集合,如图1-1。
注:
线性表可以是空表,树可以是空树,但图不能是空图。图可以没有边,但不能没有顶点。
有向图
有向图就是点与点之间相互有方向之间的关系,若设v,w为顶点,而<v,w>为从顶点v到顶点w,则有向图中$$<v,w>\neq<w,v>$$。
无向图
在无向图中,若设v,w为顶点,而<v,w>为从顶点v到顶点w,则有$$<v,w>=<w,v>$$。
简单图、多重图
简单图就是满足以下条件的图:
- 不存在重复边。
- 不存在顶点到自身的边
多重图就是图中某两个结点的边数多余一条,又允许顶点通过同一条边和自己关联,则G为多重图。
顶点的度、入度、出度
对于无向图:顶点v的度是指依附于该顶点的边的条数,记为TD(v)。
在具有n个顶点、e条边的无向图中,无向图的全部顶点度的和等于边数的2倍:
$$
\sum_{i=1}^nTD(v_i)=2e
$$
对于有向图:
入度是以顶点v为终点的有向边的数目,记为ID(v)。
出度是以顶点v为起点的有向边的数目,记为OD(v)。
顶点v的度等于其入度和出度之和,即$$TD(v)=ID(v)+OD(v)$$。
在具有n个顶点、e条边的有向图中,
$$
\sum_{i=1}^nID(v_i)=\sum_{i=1}^nOD(v_i)=e
$$
即出度和入度的数量相等且等于边数。
路径、回路、距离
路径:顶点$$v_p$$到顶点$$v_q$$之间的一条路径是指顶点序列,在有向图中的路径也是有向的。
回路:第一个顶点和最后一个顶点相同的路径称为回路或环。
简单路径:在路径序列中,顶点不重复出现的路径。
简单回路:除第一个顶点和最后一个顶点外,其余顶点不重复出现的回路称为简单回路。
路径长度:路径上边的数目。
点到点距离:顶点u到到顶点v的最短路径称为u到v的距离,若不存在路径,则记路径为无穷。
连通、连通图、强连通图
连通:
- 无向图中若顶点v到w有路径存在,则v和w是连通。
- 有向图中,若顶点v到w和从w到v都有路径,则v和w是强连通。
连通图:若任意两个顶点都是连通的,则称为连通图,否则为非连通图。
强连通图:若图中任意一对顶点都是强连通,则称图为强连通图。
注:
对于n个顶点的无向图G,
若G是连通图,则最少有n-1条边。
若G是非连通图,则最多可能有$$C_{n-1}^2$$条边。
对于n个顶点有向图G,
若G是强连通图,则最少有n条边(形成回路)。
子图
如图1-4所示,右图的图是左图的子集,即称为子图。
而若有满足V(G’)=V(G)的子图G’,则称其为G的生成子图,图1-5即为生成子图,相比原图少了些边。
连通分量、强连通分量、生成树、生成森林
无向图中的极大连图子图(子图必须连通,同时保留尽可能多的边)称为连通分量。
有向图中的极大强连通子图(子图必须强连通,同时保留尽可能多的边)称为有向图的强连通分量。
连通图的生成树是包含图中全部顶点的极小连通子图。
在非连通图中,连通分量的生成树构成了非连通图的生成森林。
边的权、带权图/网
边的权:在一个图中,每条边都可以标上具有某种含义的数值,该数值称为该边的权值。
带权图/网:边上带有权值的图称为带权图,也称网。
带权路径长度:当图是带权图时,一条路径上所有边的权值之和,称为该路径的带权路径长度。
图的存储及基本操作
邻接矩阵法
邻接矩阵存储就是用一个一维数组存储顶点的信息,用二维数组存储图中边的信息(即各顶点之间的邻接关系),存储顶点的邻接关系的二维数组称邻接矩阵。
图2-1右边的矩阵就是无向图使用邻接矩阵后所在内存中存储的结构。
获取某个结点的度只需对所在行或列进行遍历,获取到的非零元素总和即为结点的度。
图2-2是有向图用邻接矩阵后所在内存中存储的结构。
获取某个结点的出度是对所在行进行遍历获取非零元素总和,获取入度则是对所在列进行遍历获取非零元素总和。
邻接矩阵存储结构定义:
#define MaxVertexNum 100 //顶点数目最大值
typedef char VertexType; //顶点的数据类型
typedef int EdgeType; //带权图中边上权值的数据类型
typedef struct{
VertexType Vex[MaxVertexNum]; //顶点表
EdgeType Edge[MaxVertexNum][MaxVertexNum]; //邻接矩阵,边表
int vexnum,arcnum; //图的当前顶点数和弧度
}MGraph;
邻接矩阵存储法的特点
- 无向图的邻接矩阵一定是一个对称矩阵且唯一。因此,在实际存储邻接矩阵只需存储上(或下)三角矩阵的元素。
- 对于无向图,邻接矩阵的第i行(或第i列)非零元素(或非∞元素)的个数正好是顶点i的度TD(v)。
- 对于有向图,邻接矩阵的第i行非零元素(或非∞元素)的个数正好是顶点i的出度OD(v);第i列非零元素(或非∞元素)的个数正好是顶点i的入度ID(v)。
- 用邻接矩阵存储图,很容易确定图中任意两个顶点之间是否有边相连。但是,要确定图有多少条边,则必须按行、按列对每个元素进行检测。
- 稠密图适合用邻接矩阵的存储表示。
- 设图G的邻接矩阵为A,$$A^n$$的元素$$A^n[i][j]$$等于顶点i到顶点j的长度为n的路径数目。
邻接表法
邻接表法结合了顺序存储和链式存储,可以减少使用邻接矩阵对空间的浪费。
邻接表法是将图中的每个顶点建立一个单链表,第i个单链表中的结点表示依附于顶点v的边(对于有向图则是以顶点v为尾的弧),这个单链表就称为顶点v的边表。
图邻接表的存储结构:
#define MaxVertexNum 100 //顶点数目最大值
typedef char VertexType; //顶点的数据类型
//边表的结构
typedef struct ArcNode{ //边表结点
int adjvex; //该弧所指向的顶点的位置
struct ArcNode *next; //指向下一条弧的指针
//InfoType info; //网的边权值
}ArcNode;
//图的各个结点的结构
typedef struct VNode{ //顶点表结点
VertexType data; //顶点信息
ArcNode *first; //指向第一条依附该顶点的弧的指针
}VNode, AdjList[MaxVertexNum];
//此为图创建
typedef struct{
AdjList vertices; //邻接表
int vexnum,arcnum; //图的顶点数和弧数
}ALGraph; //是以邻接表存储的图类型
邻接表法的特点
- 若图为无向图,则所需存储空间为O(|V|+2|E|);若G为有向图,则所需存储空间为O(|V|+|E|)。前者的倍数是2是由于每条边在邻接表中出现了两次。
- 对于稀疏图,采用邻接表可以极大地节省存储空间。
- 在邻接表中,给一顶点可以很容易找出它的邻边,因为只需读取它的邻接表即可。而对于有向图,邻接表查结点的入度不如邻接矩阵来的快。
- 图的邻接表表示并不唯一,它取决于建立邻接表的算法及边的输入次序。
十字链表
十字链表是有向图的一种链式存储结构。在十字链表中,对应于有向图中的每条弧有一个结点,对应于每个顶点也有一个结点,如图所示。
顺着firstin
可以找到该结点所有的入边,顺着firstout
可以找到该结点所有的出边。
邻接多重表
邻接多重表是无向图的另一种链式存储结构。
在邻接表中,容易求得顶点和边的各种信息,而执行删除和求两个顶点之间是否存在边等操作时效率较低。所以就采用邻接多重表来解决,结构如图所示。
邻接多重表可以根据所对应的顶点编号很快的找到顶点所有对应的边。
图的遍历
广度优先遍历(BFS)
广度优先的基本思想是:
先访问起始顶点v,接着由v出发,依次访问v的各个未访问过的顶点,然后依次通过这些顶点去访问其他没有访问过的顶点,直到所有顶点都被访问过为止。若图中有顶点尚未被访问,则重新从未被访问的顶点重复上述过程。
广度优先遍历算法实现:
Queue Q; //通过队列来安排访问顺序
bool visited[MAX_VERTEX_NUM]; //访问标记数组
void BFSTraverse(Graph G){ //对图G进行广度优先遍历
for(i=0;i<G.vexnum;++i)
visited[i] = FALSE; //将标记数组初始化,FALSE代表元素还未被访问
InitQueue(Q); //初始化辅助队列Q
for(i=0;i<G.vexnum;++i) //从0号顶点开始遍历
if(!visited[i]) //查看顶点是否被访问过
BFS(G,i); //将未被访问过的顶点进行遍历
}
void BFS(Graph G,int v){ //从顶点v出发,广度优先遍历图G
visit(v); //访问初始顶点v visit()是一个函数,主要就是包含访问顶点要做的事
visited[v] = TRUE; //将访问过的顶点做已访问标记
Enqueue(Q,v); //将顶点v入队列
while(!isEmpty(Q)){ //若队列中有元素说明有需要遍历的顶点
DeQueue(Q,v); //遍历过后将顶点v出列接下来来检查顶点v周围是否有未被访问过的顶点
for( w = FirstNeighbor(G,v); w >= 0; w = NextNeighbor(G,v,w)) //FirstNeighbor(G,v)用于获取图G中顶点v的第一个邻接点 NextNeighbor(G,v,w)是返回除w外的顶点v的下一个邻接点的顶点号 以此for循环就能检查与顶点v连接的所有邻接点
if(!visited[w]){ //w为v的邻接点中尚未访问过的顶点
visit(w); //进行访问
visited[w]= TRUE; //访问过后进行标记
EnQueue(Q,w); //将顶点w入队,然后再次进行while循环时候就会访问w的邻接点相关信息
}
}
}
辅助数组visited[]
标志顶点是否被访问过,初始状态为FALSE
,一旦有顶点被访问就设置为TRUE
,防止被多次访问。
BFS算法性能分析
无论是邻接表还是邻接矩阵的存储方式,BFS算法都需要借助一个辅助队列Q,n个顶点均需入队一次,在最坏情况下,空间复杂度为O(|V|)。
采用邻接表存储方式:
每个顶点均需访问一次,所以时间复杂度为O(|V|),而在访问任意顶点的邻接点时,每条边至少访问过一次,故时间复杂度为O(|E|),总时间复杂度为O(|V|+|E|)。
采用邻接矩阵存储方式:
查找每个顶点的邻接点所需时间为O(|V|),邻接点也是O(|V|),所以总时间复杂度为$$O(|V|^2)$$。
BFS算法求解单源最短路径问题
若图为非带权图,定义从顶点u到顶点v的最短路径d(u,v)为从u到v的任何路径中最少的边数;若从u到v没有通路,则d(u,v)=∞。
BFS算法求解单源最短路径问题:
void BFS_MIN_Distance(Graph G,int u){
//d[i]表示从u到i结点的最短路径
for(i=0; i<G.vexnum; ++i)
d[i]=∞; //初始化路径长度
visited[u] = TRUE; //对访问过的顶点进行标记
d[u] = 0; //设置路径长度为0
EnQueue(Q,u); //将顶点u进行入队
//BFS算法主过程
while(!isEmpty(Q)){ //若队列中有元素说明有需要遍历的顶点
DeQueue(Q,v); //遍历过后将顶点v出列接下来来检查顶点v周围是否有未被访问过的顶点
for( w = FirstNeighbor(G,v); w >= 0; w = NextNeighbor(G,v,w))
if(!visited[w]){ //w为v的邻接点中尚未访问过的顶点
visited[w]= TRUE; //访问过后进行标记
d[w]=d[u]+1; //路径长度加1后再进行遍历
EnQueue(Q,w); //将顶点w入队,然后再次进行while循环时候就会访问w的邻接点相关信息
}
}
}
广度优先生成树
在广度遍历过程中,可以得到一颗遍历树,就称为广度优先生成树,此树用邻接矩阵生成是唯一的,而用邻接表则是不唯一的。
如图3-1,若用邻接矩阵从2号顶点开始遍历生成树则是图3-3。
而邻接表由于链表连接邻接点方式不唯一,所以生成树也是不唯一的。
深度优先遍历(DFS)
深度优先遍历基本思想:
首先访问图中某一起始顶点v,然后由v出发,访问与v邻接且未被访问的任一顶点w,再访问w邻接且未被访问的任一顶点,以此重复。当不能再继续向下访问时,依次退回到最近被访问的顶点,若它还有邻接顶点未被访问过,则从该点开始继续上述搜索过程,直至所有顶点均被访问过。
深度优先遍历算法实现:
bool visited[MAX_VERTEX_NUM]; //标记访问数组
void DFSTraverse(Graph G){ //对图G进行深度优先遍历
for(v=0; v<G.vexnum; ++v)
visited[v]=FALSE; //初始化已访问标记数组
for(v=0; v<G.vexnum; ++v) //进行图的深度优先遍历
if(!visited[v]) //若没被访问则进行访问
DFS(G,v);
}
void DFS(Graph G,int v){ //从顶点v出发,深度优先遍历图G
visit(v); //访问顶点v
visited[v]=TRUE; //将已访问顶点进行标记
for(w=FistNeighbor(G,v); w>=0; w=NextNeighbor(G,v,w))
if(!visited[w]){ //w为v尚未访问的邻接顶点
DFS(G,w); //递归往深度遍历
}
}
DPS算法性能分析
DFS算法作为一个递归算法要借助递归工作栈,故其空间复杂度为O(|V|)。
邻接矩阵时间复杂度:
以邻接矩阵表示时,与广度优先遍历同样需要遍历查找邻接点,故总的时间复杂度和广度优先遍历同为$$O(|V|^2)$$。
邻接表时间复杂度:
与广度优先遍历同样的搜索方式,且同为O(|V|+|E|)。
图的应用
最小生成树
对于一个带权连通无向图$G=(V,E)$,生成树不同,每棵树的权(即树中所有边上的权值之和)也可能不同。设R为G的所有生成树的集合,若T为R中边的权值之和最小的生成树,则T称为G的最小生成树。
- 最小生成树可能由多个,但边的权值之和是唯一且最小的
- 最小生成树的边数=顶点数-1
最小生成树算法
Prim算法:
从某一个顶点开始构建生成树;每次将代价最小的新顶点纳入生成树,直到所有顶点都纳入为止。
Kruskal算法
kruskal算法是一种按权值的递增次序选择合适的边来构造最小生成树的方法。
最短路径
解决无权图可以使用哦个广度优先搜索来解决最短路径问题,而对于带权图则需要另寻他法,即可分为:单源最短路径,即求图中某一顶点导其他各顶点的最短路径,可通过Dijkstra算法求解;二是求每对顶点间的最短路径,可通过Floyd算法求解。
Dijkstra算法求单源最短路径问题
单源指的就是单独一个源头,在Dijkstra算法中设置了一个集合S来记录已求得的最短路径的顶点,初始时把源头$v_0$放入到S,集合S每并入一个新顶点,都要修改源点$v_0$到集合S中顶点当前的最短路径长度值。
Dijkstra算法在构造的过程中还设置了两个辅助数组:
dist[]
:记录从源头到其他各顶点的最短路径长度,若源头$v_0$到某个结点有路径,则dist[i]
相应结点的位置标上路径长度,若源头到某个结点没有路径,则为∞。path[]
:表示从源头到其他各顶点的最短路径前驱结点。在算法结束后,可根据其值追溯得到源点到其他顶点的最短路径。
上图以$v_0$为源头,$v_0$到自身的权值为0,到$v_1$的权值为10,到$v_2,v_3$目前没有路径则设置为∞,到$v_4$的路径权值为5,以此初始化dist[]
。
路径前驱path[]
则初始默认为-1,若与源头连接则设置为0。
Dijkstra算法开始时根据final[]
数组,根据final[]
值中为false
且dist[]
中目前权值最小的值来进行遍历搜索当前顶点未连接的结点,并将当前结点final[]
中的值设置为true
。
如图4-2所示,当遍历$v_4$顶点时候,可以获得到顶点$v_1$比原先更短的路径长度8,并将path
中的结点前驱改为4,而且获取了到$v_2,v_3$的路径长度14,7,并且结点前驱都为4。
后再进行遍历查询下一个除$v_4$以外dist
中最短路径长度的$v_3$。并检查其他$v_1,v_2$这些尚未确定最短路径结点以$v_3$作为结点前驱会不会发生变化。
扫描$v_3$,显然到$v_2$经过$v_3$的路径长度13要小于从$v_4$直接到$v_2$的路径长度14,所以将$v_2$所对应的dist
改为13,并将path
直接前驱改为3。
后再根据路径长度来扫描到$v_1$,而此时经过$v_1$到$v_2$的最短路径为5+3+1=9,要小于原先的路径13,所以将$v_2$的dist
设置为9,$path$为1。
最后$v_2$已经没有其他为false的顶点就直接设置为true即可。
注:对于负权值带权图dijkstra算法可能失效!
Floyd算法求各顶点之间最短路径问题
Floyd算法的基本思想是:递推产生一个n阶方阵序列$A^{(-1)},A^{(0)},\dots, A^{(n-1)}$,其中$A^{k}[i][j]$表示从顶点$v_i$到顶点$v_j$的路径长度,k表示绕行第k个顶点的运算步骤。
若上图4-5允许在$v_0$中转,最短路径就是求$A^{(0)},path^{0}$,而所进行的就是对矩阵进行检查:
$$
若\quad A^{(-1)}[2][1]>A^{(-1)}[2][0]+A^{(-1)}[0][1]=11\
则\quad A^{(0)}[2][1]=11\
\quad path^{(0))}[2][1]=0
$$
进行以允许在$v_0$中转后,就可得到$v_2->v_1$的路径长度。
后再添加允许再$v_1$中转,此时对所有元素扫描后,只有$v_0->v_2$路径满足最短路径。由公式得:
$$
若\quad A^{(0)}[2][0]>A^{(0)}[0][1]+A^{(0)}[1][2]=10\
则\quad A^{(1)}[0][2]=10\
\quad path^{(1))}[0][2]=1
$$
即可得此时$v_0->v_2$的最短路径。
再允许$v_2$中转,此时对所有元素扫描后,只有$v_1->v_0$路径满足最短路径。由公式得:
$$
若\quad A^{(1)}[1][0]>A^{(1)}[1][2]+A^{(1)}[2][0]=9\
则\quad A^{(2)}[1][0]=9\
\quad path^{(2))}[1][0]=2
$$
经过n论递推后得到图4-8,获得所有路径间的最短路径。
有向无环图描述表达式
若一个有向图中部存在环路,则称为有向无环图,简称DAG图。
有向无环图是描述含有公共子式的表达式的有效工具,例表达式:((a+b)*(b*(c))+(c+d)*e)*((c+d)*e)
可以用下图的二叉树形式来表示:
观察树或表达式,可以发现表达式(c+d)
和(c+d)*e
在二叉树中重复出现,即可利用有向无环图来实现相同子式的共享,从而节省存储空间。
利用有向无环图即可将图4-9的二叉树转换为图4-10的有向无环图。
拓扑排序
AOV网:若用DAG图(有向无环图)表示一个工程,其顶点表示活动,用有向边$<V_i,V_j>$表示活动$V_i$必须先于活动$V_j$进行的一种关系,则将这种有向图称为顶点表示活动的网络,记为AOV网。
拓扑排序的概念:在图论中,由一个DAG图的顶点组成的序列且满足以下条件时,称为该图的拓扑排序:
- 每个顶点出现且只出现一次。
- 若顶点A在序列中排在顶点B的前面,则在图中不存在从顶点B到顶点A的路径。
拓扑排序通俗的讲就是找到每个顶点的先后顺序,然后根据先后进行排序即可。
拓扑排序的实现:
- 从AOV网中选择一个没有前驱(入度为0)的顶点并输出。
- 从网中删除该顶点和其他所有以它为起点的有向边。
- 重复1和2直到AOV网为空或当前网中部存在无前驱的顶点为止。
拓扑排序算法实现(基于邻接表结构):
bool TopologicalSort(Graph G){
InitStack(S); //初始化栈,存储入度为0的顶点
int i;
for(i=0; i<G.vexnum; i++) //遍历图中的每个顶点
if(indegree[i]==0) //若入度为0则入栈 indegree[]用于记录每个顶点的入度为多少
Push(S,i);
int count=0; //记录当前已经输出的顶点数
while(!IsEmpty(S)){ //栈不空,则存在入度为0的顶点,就进行遍历
Pop(S,i); //栈顶元素出栈
print[count++]=i; //输出顶点i print[]用于记录拓扑序列
for(p=G.vertices[i].firstarc; p; p=p->nextarc){ //遍历该顶点邻接表相关的顶点
v=p->adjvex;
if(!(--indegree[v]))
Push(S,v); //入度为0,则出栈
}
}
if(count < G.vexnum)
return false; //排序失败,有向图中有回路
else
return true;
}
关键路径
在带权有向图中,以顶点表示事件,以有向边表示活动,以边上的权值表示完成该活动的开销(如完成活动所需的时间),称之为用边表示活动的网络,简称AOE网(Activity On Edge NetWork)。
AOE网具有以下两个性质:
- 只有在某顶点所代表的事件发生后,从该顶点出发的各有向边所代表的活动才能开始;
- 只有在进入某顶点的各有向边所代表的活动都已结束时,该顶点所代表的事件才能发生。
另外,有些活动是可以并行进行的。
AOE网的概念:
- 在AOE网中仅有一个入度为0的顶点,称为开始顶点(源点)。它表示整个工程的开始。
- 网中只存在一个出度为0的点,称为结束顶点(汇点),它表示整个工程的结束。
- 从源点到汇点的有向路径可能有多条,而其中具有最大路径长度的路径称为关键路径,从而把关键路径上的活动称为关键活动。
- 事件$v_k$的最早发生时间$ve(k)$:决定了所有从$v_k$开始的活动能开工的最早时间。
- 活动$a_i$的最早开始时间$e(i)$:指该活动弧的起点所表示的事件的最早发生事件。
- 事件$v_k$的最迟发生事件$vl(k)$:指在不推迟整个工程完成的前提下,该事件最迟必须发生的时间。
- 活动$a_i$的最迟开始时间$l(i)$:指该活动弧的终点所表示事件的最迟发生时间与该活动所需时间之差。
- 活动$a_i$的时间余量$d(i)=l(i)-e(i)$:表示在不增加完成整个工程所需总时间的情况下,活动$a_i$可以拖延的时间。
- 若一个活动的时间余量为零,则说明这活动$a_i$为关键活动。
求关键路径的算法步骤:
- 从源点出发,令$ve(源点)=0$,按拓扑有序求所有时间的最早发生时间$ve()$。
- 从汇点出发,令$vl(汇点)=ve(汇点)$,按逆拓扑有序求所有事件的最迟发生时间$vl()$。
- 求所有活动的最早发生时间$e()$。
- 求所有活动的最迟发生时间$l()$。
- 求所有活动的时间余量$d()$,而余量为0的就是关键活动,由关键活动可得关键路径。