目录
一、图的思维导图
二、图的基本概念
1、图:图由节点的用穷结合 V 和边的集合 E 组成。为了与树形结构进行区别,在图结构中常常将节点成为顶点,边是顶点的有序偶对,若两个顶点之间存在一条边,则表示这两个顶点具有相邻关系。
2、有向图和无向图:图中每条边都有方向的图称为有向图,图中每条边都没有方向的图称为无向图。
3、弧:在有向图中,通常将边称为弧,含箭头的一端称为弧头,另一端称为弧尾,记作 <vi,vj>,它表示从顶点 vi,到顶 vj 有一条边。
4.顶点的度、入度和出度:在无向图中,边记为(vi,vj),它等价于在有向图中存在 <vi,vj> 和 <vj,vi>两条边。与顶点 v 相关的边的条数称为顶点 v 的度。在有向图中,指向顶点 v 的边的条数称为顶点 v 的入度,由顶点 v 发出的边的条数称为顶点 v 的出度。
5、有向完全图和无向完全图:若有向图中由 n 个顶点,则最多有 n(n-1) 条边(图中任意连个顶点都有两条边相连),将具有 n(n-1) 条边的有向图称为有向完全图。若无向图中有 n 个顶点,则最多有 n(n-1)/2 条边(任意两个顶点之间都有一条边),将具有 n(n-1)/2 条边的无向图称为无向完全图。
6、路径和路径长度:在一个图中,路径为相邻顶点序偶所构成的序列。路径长度是指路径上边的数目。
7、简单路径:序列中顶点不重复出现的路径称为简单路径。
8、回路:若一条路径中第一个顶点和最后一个顶点相同,则这条路径是一条回路。
9、连通、连通图和连通分量:在无向图中,如果从顶点 vi 到顶点 vj 有路径,则称 vi 和 vj 连通。如果图中任意两个顶点之间都连通,则称该图为连通图,否则,图中的极大连通子图称为连通分量。
10、强连通图和强连通分量:在有向图中,若从 vi 到 vj 有路径,则称从vi到vj是连通的。如果对于每一对顶点 vi 和 vj,从 vi 到 vj 和从 vj 到 vi 都有路径,则称该图为强连通图;否则,将其中的极大强连通子图称为强连通分量。
11、权和网:图中每条边都可以附有一个对应的数,这种与边相关的数称为权。权可以表示从一个顶点到另一个顶点的距离或者花费的代价。边上带有权的图称为带权图,也称网。
三、图的存储结构
1、邻接矩阵:
(1)邻接矩阵是图的顺序存储结构。邻接矩阵是表示顶点之间相邻关系的矩阵。对于无向图,邻接矩阵是对称的,矩阵中“1”的个数为图中总边数的二倍,矩阵中第 i 行元素之和与第 i 列的元素之和分别为该顶点的出度和入度,总和为顶点 i 的度。对于有向图,矩阵中“1”的个数为图的边数,矩阵中第 i 行元素之和即为顶点 i 的出度,第 j 列元素之和即为顶点 j 的入度。
(2)邻接矩阵的结构体定义如下:
typedef struct{
int no; //顶点编号
char info; //顶点其他信息
}VertexType; //顶点类型
typedef struct{
int edges[maxSize][maxSize]; //邻接矩阵定义
int n,e; //分别为顶点和边数
VertexType vex[maxSize]; //存放节点信息
}MGraph; //图的邻接矩阵类型
(3)邻接矩阵的用法:
void function(MGraph G){
int a = G.n;
int b = G.e;
...
}
2、邻接表:
(1)邻接表是图的一种链式存储结构。邻接表是对图中的每个顶点 i 建立一个单链表,每个单链表的第一个节点存放有关顶点信息,把这一顶点看作链表的表头,其余节点存放有关边的信息。因此邻接表由单链表的表头形成的顶点表(用一维数组存储),和单链表其余节点形成的边表两部分组成。一般顶点表存放顶点信息和指向第一个边节点指针,边表节点存放与当前相邻接顶点序号和下一个边节点的指针。
(2)特点:
有向图:顶点 vi 的出度为第 i 个单链表中的结点个数,顶点的入度为整个单链表中邻接点域值是 i 的结点个数。若有向图中有 n 个顶点、e条边,则其邻接表需 n 个头结点和 e 个表结点。(邻接表不唯一)。
无向图:若无向图中有 n 个顶点、e 条边,则其邻接表需 n 个头结点和 2e 个表结点。适宜存储稀疏图。无向图中顶点 vi 的度为第 i 个单链表中的结点数。(邻接表不唯一)。
(3)邻接表存储表示的定义如下:
typedef struct ArcNode{
int adjvex; //该边指向的节点位置
struct ArcNode *nextarc; //指向下一条边的指针
int info; //该边的相关信息
}ArcNode;
typedef struct{
char data; //顶点信息
ArcNode *fristarc; //指向第一条边的指针
}VNode;
typedef struct{
VNode adjlist[maxSize]; //邻接表
int n,e; //顶点数和边数
}AGraph; //图的邻接表类型
3、十字链表:
邻接表存储结构中,存有向图的邻接表缺点是:求结点的入度困难。针对这个问题我们采取十字链表存储。
十字链表(Orthogonal List)是有向图的另一种链式存储结构。我们也可以把它看成是将有向图的邻接表和逆邻接表结合起来形成的一种链表。有向图中的每一条弧对应十字链表中的一个弧结点,同时有向图中的每个顶点在十字链表中对应有一个结点,叫做顶点结点。hlink表示弧头相同的下一条边,tlink表示弧尾相同的下一条边。
4、邻接多重表:
邻接表存储结构中:存无向图的缺点:每条边都要存储两遍。针对这个问题我们采取邻接多重表存储。
邻接多重表是无向图的另一种链式存储结构。邻接多重表和十字链表类似,也是由顶点表和边表组成,每一条边用一个节点表示,其顶点表节点结构和边表结构如图:
顶点表由两个域组成,vertex域 存储和该定点相关的信息,firstedge域 指示第一条附于该顶点的边;边表节点由 6 个域组成,mark为标记域,可用以标记该条边是否被搜索过,ivex和jvex为该边依附的两个顶点在图中的位置,ilink 指向下一条依附于顶点ivex 的边,jlink 指向下一条依附于顶点 jvex 的边,info 为指向与边相关的各种信息的指针域。
四、图的遍历算法操作
1、深度优先搜索遍历
(1)图的深度有限搜索遍历类似于二叉树的先序遍历。基本思想:首先访问出发点 v,并将其标记为已访问过;然后选取与 v 邻接的未被访问的任意一个顶点 w ,并访问它;再选取与 w 邻接的未被访问的任一顶点进行访问,以此重复进行。当一个顶点所有的邻接顶点都被访问过时,则依次退回到最近被访问过的顶点,若该顶点还有其他邻接顶点未被访问,则从这些未被访问的顶点中取一个并重复上述访问过程,直至图中所有顶点都被访问过为止。示例:
深度优先搜索遍历算法执行过程:任取一个顶点,访问之,然后检查这个顶点的所有邻接顶点,递归访问其中未被访问的顶点。
(2)以邻接表为存储结构的图的深度优先遍历搜索算法如下:
int visit[maxSize]; //作为顶点访问标记数组,初始都为0
void DFS(AGraph *G,int v){
ArcNode *p;
visit[v] = 1; //对当前节点打上访问标记
Visit(v);
p = G->adjlist[v].firstarc; //p指向顶点v的第一条边
while(p != NULL){
if(visit[p->adjvex] == 0){ //如果顶点未被访问,则递归访问它
DFS(G,p->adjvex);
p = p->nextarc; //p指向顶点v的下一条边的终点
}
}
}
2、广度优先搜索遍历:
(1)图的广度优先搜索遍历类似于树的层次遍历,基本思想是:首先访问起始顶点 v ,然后选取与 v 邻接的全部顶点 w1,,,wn进行访问,再依次访问与 w1,,,wn 邻接的全部顶点(已经访问过的除外),以此类推,直到所有顶点都被访问过为止。示例:
(2)广度优先搜索遍历图的时候需要用一个队列(二叉树的层次遍历也要用到队列)算法执行过程如下:
1)任取图中一个顶点访问,入队,并将这个顶点标记为已访问。
2)当队列不空时循环执行:出队,依次检查出对顶点的所有邻接顶点,访问没有被访问的邻接顶点将其入队。
3)当队列为空时,跳出循环,广度优先搜索遍历完成。
(3)以邻接表为存储结构的广度优先遍历算法如下:
void BFS(AGraph *G,int v,int visit[maxSize]){ //visit[]数组被初始化全为 0
ArcNode *p;
int que[maxSize],font=rear=0; //队列定义的简写
int j;
Visit(v); //访问顶点v
visit[v] = 1; //标记为已访问
rear = (rear+1)%maxSize; //当前顶点v入队
que[rear] = v;
while(front != rear){ //队空的时候说明遍历完成
front = (front+1)%maxSize; //顶点出队
j = que[front];
p = G->adjlist[j].firstarc; //p指向出队顶点 j 的第一条边
while(p != NULL){ //将p的所有邻接点中未被访问的入队
if(visit[p->adjvex] == 0){ //如果该点未被访问
Visit(p->adjvex);
visit[p->adjvex] = 1;
rear = (rear+1)%maxSize; //该顶点入队
que[rear] = p->adjvex;
}
p = p->nextarc; //p指向 j 的下一条边
}
}
}
五、最小代价生成树
1、普里姆算法:
(1)普里姆算法思想:从图中任意取出一个顶点:把它当成一棵树,然后从与这棵树相接的边中选取一条最短(权值最小)的边,并将这条边及其所连接的顶点也并入这棵树中,此时得到了一棵有两个顶点的树。然后从与这棵树相接的边中选取一条最短的边,并将这条边及其所连顶点并入当前树中,得到一棵有3个顶点的树。以些类推,直到图中所有顶点都被并入树中为止,此得到的生成树就是最小生成树。带权无向图采取普里姆算法求解最小生成树示例:
在普里姆算法构造最小生成树的过程中,需要建立两个数组 vset[] 和 lowcost[] 。vset[i] = 1 表示顶点 i 已经被并入生成树中,vset[i] = 0 表示顶点 i 还未被被并入生成树中。lowcost[] 数组中存放当前生成树到剩余各顶点最短边的权值。
(2)普里姆算法执行过程:
从树中某一顶点 v0 开始,构造生成树的算法执行过程如下:
① 将 v0 到其他顶点的所有边当作候选边;
② 重复以下步骤 n-1 次,使得其他 n-1 个顶点被并入到生成树中。
i 从候选边中挑选出权值最小的边输出,并将该边另一端相接的顶点 v 并入生成树中;
ii 考察所有剩余顶点 vi ,如果(v,vi)的全职比 lowcost[vi] 小,则用(v,vi)的权值更新 lowcost[vi] 。
(3)普里姆算法代码如下:
void Prim(MGraph g,int v0,int &sum){
int lowcost[maxSize],vset[maxSize],v;
int i,j,k,min;
v = v0;
for(i=0;i<g->n;++i){
lowcost[i] = g.edgs[v0][i]; //将当前节点的路径长度赋值给数组lowcost
vset[i] = 0;
}
vset[v0] = 1; //将v0并入树中
sum = 0; //sum清零用来累计树的权值
for(i=0;i<g.n-1;i++){
min = INF; //INF是已经定义的比图中所有权值都大的常量
//下面这个循环用于选出候选边中的最小者
for(j=0;j<g.n;j++){
if(vset[j]==0 && lowcost[j]<min){ //选出当前生成树到其余顶点
min = lowcost[j]; //最短边中的最短的一条
k = j;
}
}
vset[k] = 1;
v = k;
sum += min; //这里用sum记录了最小生成树的权值
//下面这个循环以刚并入的顶点 v 为媒介更新候选边
for(j=0;j<g.n;j++){
if(vset[j]==0 && g.edges[v][j]<lowcost[j]) //挑选出权值最小的边
lowcost[j] = g.edges[v][j];
}
}
}
(3)普里姆算法时间复杂度分析:观察算法代码,普里姆算法主要部分是一个双重循环,外层循环内由两个并列的单层循环,单层循环内的操作都是常量级的,因此可以取任一个单层循环内的操作作为基本操作,例如,取min = lowcost[j]; 这一句作为基本操作,其执行次数为 n*n,此时普里姆算法的时间复杂度为O(n*n)。可见普里姆算法的事件复杂度只与图中顶点有关系,与边没有关系,因此普里姆算法适用于稠密图。
2、克鲁斯卡尔算法:
(1)克鲁斯卡尔算法思想:每次找出候选边中权值最小的边,就将该边并入生成树中。重复此过程直到所有边都被检测完为止。带权无向图采取克鲁斯卡尔算法求解最小生成树示例:
(2)克鲁斯卡尔算法执行过程:将图中边按照权值从小到大排序,然后从最小边开始扫描各边,并检测当前边是否为候选边,即是否该边的并入会构成回路,如不构成回路,则将该边并入当前生成树中,直到所有边都被检测完为止。判断是否产生回路要用到并查集,并查集中保存了一棵或者几棵树,这些树有这样的特点:通过树中一个结点,可以找到其双亲结点,进而找到根结点(其实就是之前讲过的树的双亲存储结构)。这种特性有两个好处:一是可以快速地将两个含有很多元素的集合并为一个。两个集合就是并查集中的两棵树,只需找到其中一棵树的根,然后将其作为另一棵树中任何一个结点的孩子结点即可。二是可以方便地判断两个元素是否属于同一个集合。通过这两个元素所在的结点找到它们的根结点,如果它们有相同的根,则说明它们属于同一个集合,否则属于不同集合。并查集可以用一维数组来简单地表示。
假设road[]数组中已经存放了图中各边及其链接的两个顶点信息,且排序函数已经存在,克鲁斯卡尔算法代码如下:
typedef struct{
int a,b; //a和b为一条边所连的两个顶点
int w; //边的权值
}Road;
Road road[maxsize];
int v[maxsize]; //定义并查集数组
int getRoot(int a){ //在并查集中查找根结点的函数
while(a!=v[a])
a=v[a];
return a;
}
void Kruskal(MGraph g,int &sum,Road road[]){
int i;
int N,E,a,b;
N = g.n;
E = g.e;
sum = 0;
for(i=0;i<N;++i)
v[i]=i;
sort(road,E); //对road数组中的E条边按其权值从小到大排序
for(i=0;i<E;++i){
a = getRoot(road[i].a);
b = getRoot(road[i].b);
}
if(a!=b){
v[a] = b;
sum += road[i].w; //求生成树的权值
}
}
(3)克鲁斯卡尔算法时间复杂度分析:从上述克鲁斯卡尔算法代码中可以看出,算法时间花费在函数sort()和单层循环上。循环是线性级的,可以认为算法时间主要花费在函数sort()上。因为排序算法时间复杂度一般都大于常量级,所以,克鲁斯卡尔算法的时间复杂度主要由选取的排序算法决定。排序算法所处理数据的规模由图的边数 e 决定,与顶点数无关,因此克鲁斯卡尔算法适用于稀疏图。
注意:普里姆算法和克鲁斯卡尔算法都是针对于无向图的。
六、最短路径
1、迪杰斯特拉算法:(一个顶点到其他顶点的最短路径)
(1)迪杰斯特拉算法思想:设有两个顶点集合 S 和 T,集合 S 中存放图中已找到最短路径的顶点,集合 T 存放图中剩余顶点。
初始状态时,集合 S 中只包含源点 v0,然后不断从集合 T 中选取到顶点 v0 路径长度最短的顶点 vu 并入到集合 S 中。集合 S 每并入一个新的顶点vu,都要修改顶点 v0 到集合 T 中顶点的最短路径长度值。不断重复此过程,直到集合T的顶点全部并入到 S 中为止。
(2)迪杰斯特拉算法执行过程:
i 初始化:先找出从源点 vo 到各终点的直达路径,即通过一条弧到达的路径。
ii 选择:从这些路径中找出一条长度最短的路径(vo,vk)。
iii 更新:然后对其余各条路径进行适当调整:若在图中存在弧(u,vk),且(v0,u)+(u,vk)<(vo,vk),则以路径(vo,u,vk)代替(vo,vk)。在调整后的各条路径中,再找长度最短的路径,依此类推。
(3)例题:用迪杰斯特拉算法求顶点 0 到其他各个顶点最短过程如下:
2、弗洛伊德算法:(所有顶点之间的最短路径)
(1)弗洛伊德算法思想:逐个顶点试探。从vi 到 vj 的所有可能存在的路径中。选出一条长度最短的路径
(2)弗洛伊德算法执行过程:求最短路径步骤:
初始时设置一个 n 阶方阵,令其对角线元素 0,若存在弧<vi,vj>,则对应元素为权值;否则为 ∞ 。逐步试着在原直接路径中增加中顶点,若加入中间顶点后路径变短,则修改之;否则,维持原值。所有顶点试探完毕,算法结束。
(2)例题:用弗洛伊德算法求解其最短路径:
七、拓扑排序
1、AOV网:用一个有向图表示一个工程的各子工程及其相互制约的关系,其中以顶点表示活动,弧表示活动之间的优先制约关系,称这种有向图为顶点表示活动的网,简称AOV网(Activity On Vertex network)(有向无环图)。
2、AOV网的特点:
i 若从 i 到 j 有一条有向路径,则 i 是 j 的前驱;j 是 i 的后继。
ii 若<i,j>是网中有向边,则i是j的直接前驱;j是i的直接后继。
iii AOV网中不允许有回路,因为如果有回路存在,则表明某项活动以自己为先决条件,显然这是不合理的。
3、拓扑排序:在AOV网没有回路的前提下,我们将全部活动排列成一个线性序列,使得若AOV网中有弧<i,j>存在,则在这个序列中,i一定排在j的前面,具有这种性质的线性序列称为拓扑有序序列,相应的拓扑有序排序的算法称为拓扑排序。
4、拓扑排序的方法:
i 在有向图中选一个没有前驱的顶点且输出之。
ii 从图中删除该顶点和所有以它为尾的弧。
iii 重复上述两步,直至全部顶点均已输出;或者当图中不存在无前驱的顶点为止。
5、例题:输出下图的拓扑有序序列:(一个AOV网的拓扑序列不是唯一的)
6、检测AOV网中是否存在环方法:
对有向图构造其顶点的拓扑有序序列,若网中所有顶点都在它的拓扑有序序列中,则该AOV网必定不存在环。
八、关键路径
1、AOE网:用一个有向图表示一个工程的各子工程及其相互制约的关系,以边表示活动,边上的权值表示活动持续时间,以顶点表示活动的开始或结束事件,称这种有向图为边表示活动的网,简称为AOE网(Activity On Edge)(有向无环图)。
2、对于一个表示工程的AOE网,只存在一个入度为 0 的顶点,称为源点,表示整个工程的开始;也只存在一个出度为 0 的顶点,称为汇点,表示整个工程的结束。
3、在AOE网中,从源点到汇点的所有路径中,具有最大路径长度的路径称为关键路径。完成整个工期的最短时间就是关键路径长度所代表的时间。关键路径上的活动称为关键活动。关键路径是个特殊的概念,它既代表了一个最短又代表了一个最长,它是图中的最长路径,又是整个工期所完成的最短时间。
4、求解关键路径过程:
(1)根据工程图求出拓扑有序序列 a 和逆拓扑有序序列 b。
(2)根据序列a和b分别求出每个事性的最早发生时间和最迟发生时间,求解方法如下:
①一个事件的最早发生时间为指向它的边(假设为a)的权值加上发出a这条边的事件的最早发生时间。如果有多条边,则逐一求出对应的时间并选其中最大的结果作为当前事件的最早发生时间。
②一个事件的最迟发生时间为由它所发出的边(假设为b)所指向的事件的最迟发生时间减去b这条边的权值。如果有多条边,则逐一求出对应的时间并选其中最小的结果作为当前事件的最迟发生时间。
(3)根据2)中结果求出每个活动的最早发生时间和最迟发生时间。
(4)根据3)中结果找出最早发生时间和最迟发生时间相同的活动,即为关键活动。由关键活动所连成的路径即为关键路径。
5、例题:求该工程图的关键路径。