目录
一、基础提要
这些东西基本上都是概念类的,提几个下面会经常用到的。
|V| ,类似于数学上取模一样的符号,代表顶点(Vertex或者Vertice)数;
|E|,代表边(Edge)数,但习惯上弧(Arc)也用这个表示;
(v, w),代表顶点 v 和顶点 w 之间的一条无向边,简称边;
<v, w>,代表从顶点 v 到顶点 w 的一条有向边,简称弧;
UDG,无向图,UnDirectedGraph,最普通最普通的图;
DG,有向图,DirectedGraph,边是有向边;
UDN,无向网,UnDirectedNet,即边有权值的无向图;
DN,有向网,DirectedNet,即弧有权值的有向图;
连通图,概念主要针对无向图来说,从一个顶点可以找到通往任意一个顶点的路径;
非连通图的连通分量,即把非连通图分成一个个连通子图,并且要包含尽可能多的顶点和边。
具体展开的话内容真的太多了,建议看思维导图。
二、图的存储方式
2.1 邻接矩阵
书上给的结构体是叫 MGraph,应该是 Matrix Graph 的缩写。
邻接矩阵主要由两个数组构成:
① Vex
这个数组的作用是存储顶点的数组元素,当数据比较复杂的时候,可以定义为结构体数组。后文代码只针对 char 类型实现,所以这是个 char 类型数组。
② Arc
这个数组的作用是存储边/弧的信息,可以为 int 类型。Arc[i][j] = k 所代表的意义是,从顶点 Vex[i] 到 顶点 Vex[j] 的边所存储的信息为 k。当然,普通情况下用 1 代表有边,用 0 代表没边;在无向网和有向网中,可以用非 0 元素存储边的权值,也可以把 0 用一个非常大的数代替,意指两个顶点间的距离无穷大(书上给的宏定义就叫 INFINITY,我这里叫 MAX_INT 了)。
#define MAX_VEX_SIZE 100
#define VexType char
#define MAX_INT 9999//2147483647
typedef enum {DG, DN, UDG, UDN} GraphKind;
typedef struct {
VexType Vex[MAX_VEX_SIZE];
int Arc[MAX_VEX_SIZE][MAX_VEX_SIZE]={0};
int vexnum, arcnum;
GraphKind kind;
}MGraph;
2.2 邻接表
书上给的结构体是叫 AdjList,应该是 Adjacency List 的缩写。
邻接表的实现方法和树的孩子表示法差不多,用一个数组来存储图,数组的每个位置包含数据元素和一个链表,链表又指示了某个顶点相邻边的信息……
#define MAX_VEX_SIZE 100
#define VexType char
#define MAX_INT 9999//2147483647
typedef enum{DG, DN, UDG, UDN} GraphKind;
typedef struct ArcNode{
int adjvex;
struct ArcNode *nextarc;
int weight;
}ArcNode;
typedef struct VNode{
VexType data;
ArcNode *firstarc;
}VNode, AdjList[MAX_VEX_SIZE];
typedef struct{
AdjList vertices;
int vexnum, arcnum;
GraphKind kind;
}ALGraph;
看懂这个定义的核心在于,看清 ArcNode 是链表的结点,整个链表的头结点存储在 VNode 中,而 VNode 是顶点的结构体,除了包含边链表头结点之外,还有自己的数据元素 data。然后,由若干个 VNode 组成的结构体数组便是 ALGraph。
如果看不懂的话,下文中会有示意图。
2.3 十字链表和邻接多重表
我考纲没写,不学了不学了。
承上启下
上文中我们探讨了图的两种存储结构,分别为邻接矩阵和邻接表,这两种结构分别又能存储四种类型的图(UDG/ DG/ UDN/ DN)。下文中将涉及基础操作的实现、广度优先遍历和深度优先遍历、最短路径算法(Dijsktra 和 Floyd 算法,以及利用 BFS 的实现方法)、DAG图的相关操作(拓扑排序和逆拓扑排序,包含普通实现以及利用 DFS 的实现方法),并探讨最小生成树(Prim 和 Kruskal 算法)以及关键路径算法的思路。已代码实现的部分,我会分为邻接矩阵和邻接矩阵两部分(见下文),未实现的部分,将汇集在一块给出分析思路(主要还是不考)。
三、基于邻接矩阵的操作
首先需要明确一点,邻接矩阵中有一个保存顶点的数组,这也就意味着,每一个顶点对应唯一一个数组下标。下文中所说的 “顶点 v” 实际上应该指 Vex[v],v 指代该顶点在数组中的对应下标。
3.1 基本操作
突然意识到还没举过例子,来瞅一眼:
下面这张图就是存储它的邻接矩阵了,这里是逻辑上把两个数组拼到一块了,可以看出来,紫色的数字和其下面一行的字母就是顶点的数组,左侧同理;而去掉字母剩下的部分,就是边的数组了。在无向图中,邻接矩阵是关于主对角线对称的,因为认为两个顶点互相有一条有向边(一条入边一条出边)。
如果不理解,我们把图变成一个有向图。
邻接矩阵会变成这样:
可以发现无向图的邻接矩阵中 1 的数量正好是有向图的两倍,就是逻辑上我们把一条无向边当成两条有向边来处理了,从一个顶点可以到另一个顶点,从另一个顶点也可以回来。
假如我们要找 <C, F> 这条边,应该按行找到顶点 C 对应下标所在的那一行,然后按列找到 顶点 F 对应下标所在那一列,如果为 1 代表存在边。为空(我没写,其实是 0)代表没有边。
int LocateVex(MGraph G, VexType v){
for(int i=0; i<G.vexnum; i++){
if(G.Vex[i]==v) return i;
}
return -1;
}
该函数的意义为,给出顶点类型的数据 v,返回其在邻接矩阵中对应下标。
int FirstAdjVex(MGraph G, int v){
for(int j=0; j<G.vexnum; j++){
if(G.Arc[v][j]!=0) return j;
}
return -1;
}
该函数的意义为,给出顶点 v,返回与其邻接(相邻)的第一个顶点。
所以在 Arc 数组的第 v 行遍历即可,返回第一个不为 0 的元素的下标(列号,对应其指向的顶点)。对着上面的图应该比较好理解吧。如果还不理解看下图,假如要找 D 相邻的第一个顶点,就去找 D 所在行的第一个 1,对应的是 C 列;同理,如果要找 H 相邻的第一个顶点,就去找 H 所在行的第一个 1,对应的是 D 列。
int NextAdjVex(MGraph G, int v, int w){
for(int j=w+1; j<G.vexnum; j++){
if(G.Arc[v][j]!=0) return j;
}
return -1;
}
该函数的意义为,给出顶点 v,返回与其邻接的、相对于 w 之后的顶点。
这里和上一个函数相比只换了 j 的起始条件,结合图应该很好理解,不赘述了。
void InsertVex(MGraph &G, VexType v){
G.Vex[G.vexnum++] = v;
}
该函数的意义为,插入一个顶点 v。
插入顶点不针对边,没有边的图也是一张图,后续通过对边的操作,使顶点连起来即可。
bool InsertArc(MGraph &G, VexType v, VexType w, int info=1){
int i = LocateVex(G, v);
int j = LocateVex(G, w);
if(G.kind==UDG || G.kind==UDN){
G.Arc[i][j] = info;
G.Arc[j][i] = info;
}
else if(G.kind==DG || G.kind==DN){
G.Arc[i][j] = info;
}
}
该函数的意义为,在顶点 v 和顶点 w 之间,插入一条边;如果是有向边,就是从 v 到 w。
info 在该函数中的作用为边的权值,如果不需要权值,那么权值默认为 1,代表有边即可。
无向图/网需要在 (v, w) 和 (w, v) 都插入一条边;
有向图/网只需插入 <v, w> 一条边即可。
我这里的实现是一个代码包含四个类型的图,但是一般具体应用不需要那么多,所以可以取舍一下。
int FirstPrior(MGraph G, int w){
for(int i=0; i<G.vexnum; i++){
if(G.Arc[i][w]!=0) return i;
}
return -1;
}
int NextPrior(MGraph G, int w, int v){
for(int i=v+1; i<G.vexnum; i++){
if(G.Arc[i][w]!=0) return i;
}
return -1;
}
FirstPrior 函数的意义为,找到连着 w 的第一个顶点,有点像是找前驱;
NextPrior 函数的意义为,找到连着 w 的、相对于 v 之后的顶点。
这两个函数是逆拓扑排序用的,如果不需要逆拓扑可以不用管。
3.2 遍历
这部分,详细原理建议了解一下树那一章,这里只简单提一下概念《数据结构(C语言版)》学习笔记6 树_奋起直追的vv的博客-优快云博客数据结构中树的介绍,包含普通的树、二叉树、森林的存储方式以及相互之间的转换,另含树和森林的遍历,二叉树的线索化,哈夫曼树等https://blog.youkuaiyun.com/vv0610_/article/details/126799910
3.2.1 BFS
BFS 即深度优先搜索,和树的 BFS 过程很类似,依然需要借助队列来实现。
typedef struct Queue{
int vex[MAX_VEX_SIZE];
int front, rear;
}Queue;
bool InitQueue(Queue &Q){
Q.front = Q.rear = 0;
return true;
}
bool EnQueue(Queue &Q, int v){
Q.vex[Q.rear++] = v;
}
bool DeQueue(Queue &Q, int &v){
v = Q.vex[Q.front++];
}
但是图不像树那样有着明确的前驱后继关系,一个顶点可能有多个邻接顶点,也可能被多个顶点邻接,比如:
在对 C 进行 BFS 的时候,其邻接的 D F G 三个顶点都会在 C 之后被遍历,再之后,D 和 G 都连着 H,所以可能会对 H 访问多次。
因此,就需要建立一个数组判断哪些结点已经被保存了,哪些还没有。
主体代码:
void BFS(MGraph G, int v){
Queue Q;
InitQueue(Q);
visit(G, v);
visited[v] = true;
EnQueue(Q, v);
while(Q.front!=Q.rear){
DeQueue(Q, v);
for(int w=FirstAdjVex(G, v); w>=0; w=NextAdjVex(G, v, w)){
if(!visited[w]){
visit(G, w);
visited[w] = true;
EnQueue(Q, w);
}
}
}
}
里面唯一一个 for 循环是为了找与 v 相邻的所有顶点,挨个遍历一遍。
比如从 C 开始 BFS,v = C。第一次 for 循环,w=FirstAdjvex(G, v)等于 D;第二次 for 循环,w=NextAdjVex(G, v, w)等于 F;第三次 for 循环,w=NextAdjVex(G, v, w)等于 G;第四次 for 循环,w=NextAdjVex(G, v, w)等于 -1,退出循环。
除此之外,还需要有个外层函数来调用 BFS:
void BFSTraverse(MGraph G, int v0=0){
for(int v=0; v<G.vexnum; v++) visited[v] = false;
BFS(G, v0);
for(int v=0; v<G.vexnum; v++){
if(!visited[v]) BFS(G, v);
}
}
第一个 for 循环,将 visited 数组置为 false,所有顶点都没访问过;
然后对开始的顶点 BFS,默认为第一个顶点;
第二个 for 循环,则是针对非连通图进行的,例如下图:
从 ABCDEFGH 任何一个顶点出发,都不能 BFS 到 IJK,所以还需要一个外层循环,在一次 BFS 结束时,对 visited 为 false 的顶点再进行 BFS。上图中只有两个连通分量,所以一共需要两次 BFS,可以推知,有几个连通分量,就需要进行几次 BFS。
执行结果如下图:
#OPT BFSsort, start from A:
ABEFCGDHIJK
#OPT BFSsort, start from B:
BAFECGDHIJK
#OPT BFSsort, start from C:
CDFGHBAEIJK
#OPT BFSsort, start from G:
GCDFHBAEIJK
#OPT BFSsort, start from H:
HDGCFBAEIJK
需要注意的是,如果顶点数组(Vex)确定了,BFS 的结果就是唯一的,因为在存顶点的时候是按照从小到大的顺序(ABCDEFGHIJK)来存的,所以在 for 循环中找邻接顶点也是按照这个顺序查的。
对于有向图,代码是一模一样的,输出结果如下:
#OPT BFSsort, start from A:
AEBCFDGHIKJ
#OPT BFSsort, start from B:
BAECFDGHIKJ
#OPT BFSsort, start from C:
CFBAEDGHIKJ
#OPT BFSsort, start from G:
GCFHBDAEIKJ
#OPT BFSsort, start from H:
HDCGFBAEIKJ
对应的图为:
BFS 序列变了,是因为路径变了,应该好理解吧。
另附测试样例:
无向图:
G.kind = UDG;
for(int i=0; i<11; i++){
InsertVex(G, 65+i);
}
InsertArc(G, 'A', 'B');
InsertArc(G, 'A', 'E');
InsertArc(G, 'B', 'F');
InsertArc(G, 'C', 'D');
InsertArc(G, 'C', 'F');
InsertArc(G, 'C', 'G');
InsertArc(G, 'D', 'G');
InsertArc(G, 'D', 'H');
InsertArc(G, 'F', 'G');
InsertArc(G, 'G', 'H');
InsertArc(G, 'I', 'J');
InsertArc(G, 'I', 'K');
InsertArc(G, 'J', 'K');
有向图:
G.kind = DG;
for(int i=0; i<11; i++){
InsertVex(G, 65+i);
}
InsertArc(G, 'A', 'E');
InsertArc(G, 'B', 'A');
InsertArc(G, 'C', 'F');
InsertArc(G, 'D', 'C');
InsertArc(G, 'D', 'G');
InsertArc(G, 'F', 'B');
InsertArc(G, 'G', 'C');
InsertArc(G, 'G', 'F');
InsertArc(G, 'G', 'H');
InsertArc(G, 'H', 'D');
InsertArc(G, 'I', 'K');
InsertArc(G, 'K', 'J');
InsertArc(G, 'J', 'I');
3.2.2 广度优先生成树/森林
用无向图举例子,按照上面从 A 开始 BFS 的运行顺序:
①第一轮 BFS,访问 A,A 入队,把 A 加入 BFS 树,当根结点:
②第一轮 BFS,A 出队,访问 B E,B E 入队,把 B E 加入 BFS 树,作为 A 的孩子结点:
③第一轮 BFS,B 出队,访问 F,F 入队,把 F 加入 BFS 树,作为 B 的孩子结点;E 出队,没有邻接点:
④第一轮 BFS,F 出队,访问 C G,C G 入队,把 C G 加入 BFS 树,作为 F 的孩子结点:
④第一轮 BFS,C 出队,访问 D,D 入队,把 D 加入 BFS 树,作为 C 的孩子结点;G 出队,访问 H,H 入队,把 H 加入 BFS 树,作为 G 的孩子结点:
D H 出队,没有邻接点。以上,第一个连通分量的 BFS 树就构造完了,至于第二个连通分量,那就是第二个 BFS 树了,对整个图来说则是 BFS 森林。
后面偷个懒:
注意,BFS生成树不一定是二叉树,如果从 C 开始,第一个分支就有三个。
3.2.3 DFS
DFS,深度优先搜索,测试用例也是上面那两个。
DFS 的原理是递归,举个例子:
假如先从 A 开始,A 遍历完了,要遍历 B 和 E;B 遍历完了,还要遍历 F;F 遍历完了要遍历 C 和 G……概括来说就是尽量往深了走,没路了再回头。
void DFS(MGraph G, int v);
void DFSTraverse(MGraph G, int v0=0){
for(int v=0; v<G.vexnum; v++) visited[v] = false;
DFS(G, v0);
for(int v=0; v<G.vexnum; v++){
if(!visited[v]) DFS(G, v);
}
}
void DFS(MGraph G, int v){
visit(G, v);
visited[v] = true;
for(int w=FirstAdjVex(G, v); w>=0; w=NextAdjVex(G, v, w)){
if(!visited[w]){
DFS(G, w);
}
}
}
应该不用解释了吧。
无向图的输出结果:
#OPT DFSsort, start from A:
ABFCDGHEIJK
#OPT DFSsort, start from B:
BAEFCDGHIJK
#OPT DFSsort, start from C:
CDGFBAEHIJK
#OPT DFSsort, start from G:
GCDHFBAEIJK
#OPT DFSsort, start from H:
HDCFBAEGIJK
有向图的输出结果:
#OPT DFSsort, start from A:
AEBCFDGHIKJ
#OPT DFSsort, start from B:
BAECFDGHIKJ
#OPT DFSsort, start from C:
CFBAEDGHIKJ
#OPT DFSsort, start from G:
GCFBAEHDIKJ
#OPT DFSsort, start from H:
HDCFBAEGIKJ
3.2.4 深度优先生成树/森林
从 A 开始 DFS 的时候,一共只走了两条路,一边是 AE,直接就到头了;另一边是 ABFCDGH,也到头了,所以树只有两个分支。另一棵树就不多说了~
3.3 最短路径
3.3.1 BFS 算法
仔细看一下这张图,
从 A 开始,先访问 A,A 到自己的距离是 0;
再访问 B E,A 到 BE 的距离是1;
再访问 F,A 到 F 的距离是 2;
再访问 C G,A 到 C G 的距离是3……
void ShortestPath_BFS(MGraph G, int v0){
Queue Q;
InitQueue(Q);
for(int v=0; v<G.vexnum; v++){
distance[v] = MAX_INT;
path[v] = -1;
visited[v] = false;
}
distance[v0] = 0;
visited[v0] = true;
EnQueue(Q, v0);
while(Q.rear!=Q.front){
DeQueue(Q, v0);
for(int w=FirstAdjVex(G, v0); w>=0; w=NextAdjVex(G, v0, w)){
if(!visited[w]){
distance[w] = distance[v0]+1;
path[w] = v0;
visited[w] = true;
EnQueue(Q, w);
}
}
}
}
两个数组,distance 用来保存从 v0 到 w 的距离,即 distance[w];path 用来保存路径,如果 path[w] = v,即从 v0 到 w 之前,需要从 v 经过。
很好理解,这里不需要外层循环了,因为在代码开头把开始顶点 v0 到其他顶点的路径长度 distance 都设为了 MAX_INT,BFS 的外层循环是处理其他连通分量的,而从 v0 开始只能访问到自己的连通分量,别的连通分量访问不到,距离就当作 MAX_INT 即可。
除了对于 distance 和 path 的处理,其他代码和 BFS 是一模一样的,所以就不多说了。
结果如下:
#OPT ShortestPath_BFS, start from A:
index: 0 1 2 3 4 5 6 7 8 9 10
vertex: A B C D E F G H I J K
dis: 0 1 3 4 1 2 3 4 9999 9999 9999
path: -1 0 5 2 0 1 5 6 -1 -1 -1
#OPT ShortestPath_BFS, start from B:
index: 0 1 2 3 4 5 6 7 8 9 10
vertex: A B C D E F G H I J K
dis: 1 0 2 3 2 1 2 3 9999 9999 9999
path: 1 -1 5 2 0 1 5 6 -1 -1 -1
#OPT ShortestPath_BFS, start from C:
index: 0 1 2 3 4 5 6 7 8 9 10
vertex: A B C D E F G H I J K
dis: 3 2 0 1 4 1 1 2 9999 9999 9999
path: 1 5 -1 2 0 2 2 3 -1 -1 -1
#OPT ShortestPath_BFS, start from G:
index: 0 1 2 3 4 5 6 7 8 9 10
vertex: A B C D E F G H I J K
dis: 3 2 1 1 4 1 0 1 9999 9999 9999
path: 1 5 6 6 0 6 -1 6 -1 -1 -1
#OPT ShortestPath_BFS, start from H:
index: 0 1 2 3 4 5 6 7 8 9 10
vertex: A B C D E F G H I J K
dis: 4 3 2 1 5 2 1 0 9999 9999 9999
path: 1 5 3 7 0 6 7 -1 -1 -1 -1
这里分析一组结果看看:
#OPT ShortestPath_BFS, start from C:
index: 0 1 2 3 4 5 6 7 8 9 10
vertex: A B C D E F G H I J K
dis: 3 2 0 1 4 1 1 2 9999 9999 9999
path: 1 5 -1 2 0 2 2 3 -1 -1 -1
从 C 开始,加入要去 A,可以发现 distance[0]=3,代表 C 到 A 的距离为 3;
path[0] = 1,代表去 A 之前要先去 B;
path[1] = 5,代表去 B 之前要先去 F;
path[5] = 2,代表可以从 C 直接到 F。
于是路径为 C - F - B - A 。
有向图同理,不用改代码:
#OPT ShortestPath_BFS, start from A:
index: 0 1 2 3 4 5 6 7 8 9 10
vertex: A B C D E F G H I J K
dis: 0 9999 9999 9999 1 9999 9999 9999 9999 9999 9999
path: -1 -1 -1 -1 0 -1 -1 -1 -1 -1 -1
#OPT ShortestPath_BFS, start from B:
index: 0 1 2 3 4 5 6 7 8 9 10
vertex: A B C D E F G H I J K
dis: 1 0 9999 9999 2 9999 9999 9999 9999 9999 9999
path: 1 -1 -1 -1 0 -1 -1 -1 -1 -1 -1
#OPT ShortestPath_BFS, start from C:
index: 0 1 2 3 4 5 6 7 8 9 10
vertex: A B C D E F G H I J K
dis: 3 2 0 9999 4 1 9999 9999 9999 9999 9999
path: 1 5 -1 -1 0 2 -1 -1 -1 -1 -1
#OPT ShortestPath_BFS, start from G:
index: 0 1 2 3 4 5 6 7 8 9 10
vertex: A B C D E F G H I J K
dis: 3 2 1 2 4 1 0 1 9999 9999 9999
path: 1 5 6 7 0 6 -1 6 -1 -1 -1
#OPT ShortestPath_BFS, start from H:
index: 0 1 2 3 4 5 6 7 8 9 10
vertex: A B C D E F G H I J K
dis: 5 4 2 1 6 3 2 0 9999 9999 9999
path: 1 5 3 7 0 2 3 -1 -1 -1 -1
3.3.2 Dijsktra 算法
首先恭喜前面两个测试用例退休了,从现在开始都是带权图了。
我们看一下这张图,圆圈里的下划线加数字代表它在数组里的位置,边上的数字代表两个顶点之间的距离。从 A 到其他顶点最短距离怎么求?
Dijsktra 的想法是,用三个数组,Final 用于确定从出发点到当前顶点是否已经是最短距离,Distance 用于确定从出发点到当前顶点的最短距离,Path 用于确定从出发点到当前顶点的路径。
第一步,A 到自己的最短距离是 0,这个是已经确定的,所以 final[0] 是 true,其他都是 false,distance[0] = 0,其他的顶点能直达就写距离,不能直达就写无穷大;path[0] 写 -1,其他能直达的就写 0(从A过去),不能直达也写 -1。如下图:
第二步,现在已经确定去 E 是 5,其他再怎么走肯定要绕路,要么是 10+某个数,要么是 7+某个数,肯定比 5 大,所以去 E 的最短路径就是 5,final[4] 标记为 true。同时,考虑如果从 E 走到其他顶点,路径是不是比从 A 直接过去要小?算一下从 A 到 E 加上从 E 到其他顶点的距离,如果确实小,那么从 E 过去是更好的,相应地修改 distance 和 path。
A-B 距离为 10,A-E-B 距离为 7,修改 distance 和 path;
A-C 距离为 ∞,A-E-C 距离为 14,修改;
A-D 距离为 7,A-E-D 距离也为 7,不改;
A-E 最小距离已经确定是 5 了,不改。
第三步,依旧是找还没确定的顶点里距离最小的,现在 A-B 是 7,A-C 是14, A-D 是 7。先从 A-B 开始,这条路径是 A-E-B,可以确定从别的路径拐过来的路径不可能比这个更小了(因为是前两步都已经是最小了),那么经过 A-E-B 到其他顶点有没有更小的呢?
A-E-C 距离为 14,A-E-B-C 距离为 13,修改 distance 和 path;
A-D 距离为 7,A-E-B-D 距离为∞,不改。
第四步,还没确定的顶点里最短的是 D,路径是 A-D,那此时 A-D 就是最短的了,因为前几步从别的顶点中转的时候都没有更小,更不可能从 C 这条更长的路转,所以 final[3]=true。只剩顶点 C 了,而 A-D-C 距离也是13,不用修改,所以最终结果为(把第五步并在一块了):
代码如下:
void ShortestPath_Dijkstra(MGraph G, int v0){
for(int w=0; w<G.vexnum; w++){ //初始化各个数组
final[w] = false;
if(G.Arc[v0][w]!=0){ //如果有路
distance[w] = G.Arc[v0][w]; //距离等于权值
path[w] = v0; //从初始点 v0 直接过去
}
else{ //如果没路
distance[w] = MAX_INT; //距离无穷大
path[w] = -1; //没有路径
}
}
final[v0] = true; //起始点到自己最短路径已经确定了
distance[v0] = 0; //起始点到自己最短路径是 0
int v, w;
for(int i=0; i<G.vexnum; i++){
int min = MAX_INT;
for(w=0; w<G.vexnum; w++){
if(!final[w]){
if(distance[w]<min){
v = w;
min = distance[w];
}
}
}//for 找还未确定最短路径里的最短的那个
final[v] = true; //找到的最短就是最短,不可能绕路更短
for(w=0; w<G.vexnum; w++){
if(!final[w] && (min+G.Arc[v][w]<distance[w]) && G.Arc[v][w]!=0){
distance[w] = min+G.Arc[v][w];
path[w] = v;
}//从新的顶点中转,路径更短则修改
}
}
}
可以结合上面的分析捋一捋。
下面给出其他几个顶点开始的最短路径。
#OPT ShortestPath_Dijsktra, start from A:
index: 0 1 2 3 4
vertex: A B C D E
dis: 0 7 13 7 5
path: -1 4 1 0 0
#OPT ShortestPath_Dijsktra, start from B:
index: 0 1 2 3 4
vertex: A B C D E
dis: 7 0 6 4 2
path: 4 -1 1 4 1
#OPT ShortestPath_Dijsktra, start from C:
index: 0 1 2 3 4
vertex: A B C D E
dis: 13 6 0 6 8
path: 3 2 -1 2 1
#OPT ShortestPath_Dijsktra, start from D:
index: 0 1 2 3 4
vertex: A B C D E
dis: 7 4 6 0 2
path: 3 4 3 -1 3
#OPT ShortestPath_Dijsktra, start from E:
index: 0 1 2 3 4
vertex: A B C D E
dis: 5 2 8 2 0
path: 4 4 1 4 -1
对于有向图来说,代码是一模一样的:
输出结果:
#OPT ShortestPath_Dijsktra, start from A:
index: 0 1 2 3 4
vertex: A B C D E
dis: 0 8 9 7 5
path: -1 4 1 4 0
#OPT ShortestPath_Dijsktra, start from B:
index: 0 1 2 3 4
vertex: A B C D E
dis: 11 0 1 4 2
path: 3 -1 1 4 1
#OPT ShortestPath_Dijsktra, start from C:
index: 0 1 2 3 4
vertex: A B C D E
dis: 11 19 0 4 16
path: 3 4 -1 2 0
#OPT ShortestPath_Dijsktra, start from D:
index: 0 1 2 3 4
vertex: A B C D E
dis: 7 15 6 0 12
path: 3 4 3 -1 0
#OPT ShortestPath_Dijsktra, start from E:
index: 0 1 2 3 4
vertex: A B C D E
dis: 9 3 4 2 0
path: 3 4 1 4 -1
3.3.3 Floyd 算法
Floyd 的算法可以按照如下理解:
假如有v0, v1, v2, ..., vn-1 这 n 个顶点,有两个数组,d[][] 用来存储 vi 到 vj 的距离,p[][] 用来存储从 vi 到 vj 需要中转的顶点,举个具体的例子:
上图是第一步,即只允许直接从一个顶点到另一个顶点,不允许中转,A 可以直达 B D E,所以更新了 d 数组和 p 数组,其他的位置也是同理,比较麻烦就不手算了。
第二步,允许经过一个顶点中转,即 A-B 可以考虑 A-C-B A-D-B A-E-B,然后把最小的那个和原来的 A 直接到 B 比较,取较小者。
上图中只比较了 A 到 B。因为过程重复次数太多,时间复杂度为 O(|V|^3),五个顶点就要进行125次操作,我在这比划完也不现实……
第三步,允许从两个顶点中转,这里需要理解一下:
我们举个例子,假如说从 V 顶点到 W 顶点。经过第二步的时候,已经确认了如果 V 到 W 中间经过一个顶点,是否有更小的路径。如果有,那么二维数组对应的 V 行 W 列是被修改的;如果没有,也就是说中转了反而更远了,那二维数组对应的位置没被修改。
也就是说,第二步结束的时候,二维数组的 V 行 W 列会有两种可能:
其一,通过某个顶点中转后,距离更短,两个数组中的内容都会被修改,假设中转的顶点为 U,那么 p[V][W] = U;
其二,通过某个顶点中转后,距离更长,那么两个数组中的内容都没被修改,p[V][W] = V。
所以,当 p[V][W] = U 时,其实相应的 d 中已经存的是 V-W-U 的距离了,即中转一次的情况;
当 p[V][W] = V 时,相应的 d 中还是 V-W 的距离,即不中转的情况。
注意,上述的 U V W 是有任意性的,现在:
假如要从 V 到 W,允许两次中转,该怎么做?
数组只保存了距离和中转的点,并没有保存中转了几次这个信息。
对于这两个数组来说,能办到的事情只有一件,就是假如从 i 到 j,经过 k 中转,那么从 i 直接到 j 的距离是 d[i][j],从 i 经过 k 到 j 的距离是 d[i][k] + d[k][j]。
其实第二步允许一步中转的时候就是这么判断的,那么第三步,允许两次中转还能这么判断吗?
完全可以,因为距离那个数组中,其实隐含了中转的信息。
这样看:
假如从 V 到 X,经过 W。需要比较的长度是 d[V][X] 和 d[V][W] + d[W][X]。
我们刚才讨论过了 d[V][W],那就从这里入手:假如第二步中更新了这个数据,也就意味着,d[V][W] = d[V][U] + d[U][W],那么这里所说的“从 V 到 X 经过 W” 其实是 “ 从 V 到 X 经过 U-W”,这不就是允许了两次中转吗?假如第二步中没更新这个数据,也就是说允许一次中转都找不到更小的路径,两次中转还会存在更小的吗?
下面给出代码:
void ShortestPath_Floyd(MGraph G){
for(int v=0; v<G.vexnum; v++){
for(int w=0; w<G.vexnum; w++){
if(G.Arc[v][w]!=0){
d[v][w] = G.Arc[v][w];
p[v][w] = v;
}
else{
d[v][w] = MAX_INT;
p[v][w] = -1;
}
}
}//初始化
for(int u=0; u<G.vexnum; u++){
for(int v=0; v<G.vexnum; v++){
for(int w=0; w<G.vexnum; w++){
if(d[v][u]+d[u][w]<d[v][w]){
d[v][w]=d[v][u]+d[u][w];
p[v][w] = u;
}//比较中转和不中转的长度
}//for3 到达的顶点
}//for2 出发的顶点
}//for1 要中转的顶点
}
下面是上面例子的输出结果:
#OPT ShortestPath_Floyd:
dis: path:
0 1 2 3 4 0 1 2 3 4
A B C D E A B C D E
0 A 10 7 13 7 5 0 A 4 4 3 0 0
1 B 7 4 6 4 2 1 B 4 4 1 4 1
2 C 13 6 12 6 8 2 C 3 2 1 2 1
3 D 7 4 6 4 2 3 D 3 4 3 4 3
4 E 5 2 8 2 4 4 E 4 4 1 4 1
这里会发现从某个顶点出发到自身是有距离的,原因是走了个环路,可以在代码中特殊处理一下。
如果实在不明白,可以自己手算一遍,其中的奥妙就会通顺了,我觉得我也没说明白,但是碍于时间关系……没法展开分析,望谅解。
3.4 拓扑排序
拓扑排序是针对 DAG 图(Directed Acyclic Graph,有向无环图)说的。
概念上应该很好理解,有向图+无环路就是 DAG 图了。弧上有权值的 DAG 图叫 AOV 网,AOV 网是有实际的工程意义的。比如 <A, B> 这条弧,A 指向 B,代表事件 A 必须在事件 B 之前发生,并且 A 发生过后至少需要 3 个单位时间(权值)B 才会发生。
那所谓拓扑排序,就是按照事件发生的先后顺序将所有的事件一一排列起来。因为拓扑排序是针对 DAG 图的,所以排序的时候不需要考虑权值。
如上图,一眼便知,A 最先发生,BC 紧随其后,D E 次之,F 最后。
3.4.1 定义法
从概念上理解,当一个事件没有前置事件,也即没有弧指向它的时候,这个顶点就会加入排序序列。而没有弧指向它,说的就是当前顶点没有入边,或者说入度为 0。
反之就是逆拓扑排序的定义,逆拓扑排序要求排在前面的靠后发生,如果一个顶点不指向别的顶点,也即它的出度为 0,那它一定是最后发生的。
所以为了排序,得写求入度和出度的函数:
void FindInDegree(MGraph G, int (&indegree)[MAX_VEX_SIZE]){
for(int w=0; w<G.vexnum; w++){
for(int v=0; v<G.vexnum; v++){
if(G.Arc[v][w]!=0) indegree[w]++;
}
}
}
void FindOutDegree(MGraph G, int (&outdegree)[MAX_VEX_SIZE]){
for(int v=0; v<G.vexnum; v++){
for(int w=0; w<G.vexnum; w++){
if(G.Arc[v][w]!=0) outdegree[v]++;
}
}
}
很好理解,如果要找顶点 w 的入度,就看邻接矩阵对应 w 的那一列有几个 1;如果要找顶点 v 的出度,就看邻接矩阵对应 v 的那一行有几个 1。
bool TopoSort(MGraph G, bool REVERSE = false){
if(!REVERSE){
int indegree[MAX_VEX_SIZE]={0};
FindInDegree(G, indegree);
Queue Q;
InitQueue(Q);
for(int v=0; v<G.vexnum; v++){
visited[v] = false;
if(indegree[v]==0){
EnQueue(Q, v);
}
}
int cnt=0;
int v;
while(Q.front!=Q.rear){
DeQueue(Q, v);
topo[cnt++] = v;
for(int w=FirstAdjVex(G, v); w>=0; w=NextAdjVex(G, v, w)){
indegree[w]--;
if(indegree[w]==0){
EnQueue(Q, w);
}
}
}
if(cnt<G.vexnum) return false;
else return true;
}
else if(REVERSE){
int outdegree[MAX_VEX_SIZE]={0};
FindOutDegree(G, outdegree);
Queue Q;
InitQueue(Q);
for(int v=0; v<G.vexnum; v++){
if(outdegree[v]==0) EnQueue(Q, v);
}
int cnt=0;
while(Q.front!=Q.rear){
int v;
DeQueue(Q, v);
topo_r[cnt++] = v;
for(int w=FirstPrior(G, v); w>=0; w=NextPrior(G, v, w)){
outdegree[w]--;
if(outdegree[w]==0) EnQueue(Q, w);
}
}
if(cnt<G.vexnum) return false;
else return true;
}
}
代码虽然看着很长,但其实是两段,分别是正向和逆向。值得一提的是,逆向拓扑排序需要找前驱,即前面提到过的 FirstPrior 函数和 NextPrior 函数。
另外代码里借用了队列,其实最好是用栈,但其实都差不多。原因是为了让每次把所有入度/出度为 0 的顶点都入队(入栈),然后再挨个出队(出栈),对于同时入度(出度)为零的多个顶点,输出的先后顺序是随意的。因为我 BFS 用了队列,不想再加个栈了,所以直接用了队列。
3.4.2 DFS排序
利用 DFS 拓扑排序的原理是这样的:
如果一个事件(假如为 W)后发生,那么表明它一定有别的顶点(假设为 V)指向它的入边。那么在 DFS 的过程中,会先 DFS 到 V,后 DFS 到 W。这是两个顶点相邻的情况。
假如还有个 U 顶点,和这个 W 顶点八竿子打不着,那么W 和 U 的发生顺序是随意的(理解一下,并不是完全随意,是和前面的顶点有关系的)。
那么就好办了,先被 DFS 的,一定是先发生的,后被 DFS 的,一定是后发生的。那么就可以加入一个标志位,来判断某个顶点是在第几次 DFS。
void DFSS(MGraph G, int v, int &order, int (&res)[MAX_VEX_SIZE]){
visited[v] = true;
for(int w=FirstAdjVex(G, v); w>=0; w=NextAdjVex(G, v, w)){
if(!visited[w]){
visited[w];
DFSS(G, w, order, res);
}
}
res[v] = order++;
}
void DFSTopoSort(MGraph G, bool REVERSE= false){
for(int v=0; v<G.vexnum; v++) visited[v] = false;
int res[MAX_VEX_SIZE];
for(int v=0; v<G.vexnum; v++){
int order = 0;
if(!visited[v]) DFSS(G, v, order, res);
}
if(!REVERSE){
for(int i=0; i<G.vexnum; i++){
for(int v=0; v<G.vexnum; v++){
if(res[v]==(G.vexnum-i-1)) topo[i] = v;
}
}
}
else if(REVERSE){
for(int i=0; i<G.vexnum; i++){
for(int v=0; v<G.vexnum; v++){
if(res[v]==i) topo_r[i] = v;
}
}
}
}
上面不管是普通的拓扑排序还是DFS的拓扑排序,我都把结果保存下来了,因为关键路径会用到,如果不用的话,可以直接 print 打出来。
四、基于邻接表的操作
4.1 基本操作
先上个例子,看看邻接表逻辑上长什么样:
可以看出,左侧有一个数组,数组的每个位置存顶点信息 data 和 链表的头结点 firstarc。firstarc 指向其后一整条边链表,如图中蓝色,链表中第一个结点的数据域是 4,代表这条边是从 A 指向 E,指针域 nextarc 指向第二个结点;第二个结点的数据与是 1,代表这条边是从 A 指向 B,指针域指向 NULL,代表没有别的边了。
注:图中 / 代表头结点不存数据;下标大的顶点在链表中更靠前是因为使用的头插法,先插入的边会排到后面。
int LocateVex(ALGraph G, VexType v){
for(int i=0; i<G.vexnum; i++){
if(G.vertices[i].data==v) return i;
}
return -1;
}
该函数的意义为,给出顶点类型的数据 v,返回其在邻接矩阵中对应下标。
int getWeight(ALGraph G, int v, int w){
ArcNode *p = G.vertices[v].firstarc->nextarc;
while(p && p->adjvex!=w) p = p->nextarc;
if(!p) return MAX_INT;
return p->weight;
}
该函数的意义为,给出顶点 v 和 w,返回 (v, w) 或 <v, w> 的权值。
邻接矩阵中没有这个函数,因为邻接矩阵的边权值都是存在二维数组里的,可以直接按位存取,而邻接表的边权值是存在链表中的,只需要多加一个数据域存权值即可。
int FirstAdjVex(ALGraph G, int v){
if(G.vertices[v].firstarc->nextarc)
return G.vertices[v].firstarc->nextarc->adjvex;
return -1;
}
该函数的意义为,给出顶点 v,返回与其邻接(相邻)的第一个顶点。
G 是图;G.vertices[v] 是数组中顶点 v 所在的位置;G.vertices[v].firstarc 对应顶点 v 后面链表的头结点;G.vertices[v].firstarc->nextarc 指向边链表中第一个结点,即第一条边;G.vertices[v].firstarc->nextarc->adjvex 是边链表中第一个结点的数据域,即邻接顶点的数组下标。
int NextAdjVex(ALGraph G, int v, int w){
ArcNode *p = G.vertices[v].firstarc->nextarc;
while(p->adjvex!=w) p=p->nextarc;
if(p->nextarc!=NULL) return p->nextarc->adjvex;
return -1;
}
该函数的意义为,给出顶点 v,返回与其邻接的、相对于 w 之后的顶点。
其实就是从 v 的边链表中,先找到指向 w 的那条边,再接着从这条边往后找下一条边就好了。
void InsertVex(ALGraph &G, VexType v){
G.vertices[G.vexnum].data = v;
G.vertices[G.vexnum].firstarc = (ArcNode *)malloc(sizeof(ArcNode));
G.vertices[G.vexnum++].firstarc->nextarc = NULL;
}
该函数的意义为,插入一个顶点 v。
插入顶点,其实就是向邻接矩阵的数组部分插入元素,同时把边链表的头结点置空。
bool InsertArc(ALGraph &G, VexType v, VexType w, int info=1){
int i = LocateVex(G, v);
int j = LocateVex(G, w);
if(G.kind==UDG || G.kind==UDN){
ArcNode *s = (ArcNode *)malloc(sizeof(ArcNode));
s->weight = info;
s->adjvex = j;
s->nextarc = G.vertices[i].firstarc->nextarc;
G.vertices[i].firstarc->nextarc = s;
ArcNode *t = (ArcNode *)malloc(sizeof(ArcNode));
t->weight = info;
t->adjvex = i;
t->nextarc = G.vertices[j].firstarc->nextarc;
G.vertices[j].firstarc->nextarc = t;
}
else if(G.kind==DG || G.kind==DN){
ArcNode *s = (ArcNode *)malloc(sizeof(ArcNode));
s->weight = info;
s->adjvex = j;
s->nextarc = G.vertices[i].firstarc->nextarc;
G.vertices[i].firstarc->nextarc = s;
}
}
该函数的意义为,在顶点 v 和顶点 w 之间,插入一条边;如果是有向边,就是从 v 到 w。
看起来比较复杂哈,其实就是创建一个边结点 ArcNode,赋上顶点下标和权值,然后对边链表的头结点做头插法即可。无向图需要插 (v, w) 和 (w, v) 两条边,有向图只需要 <v, w> 一条边即可。
int FirstPrior(ALGraph G, int w){
for(int i=0; i<G.vexnum; i++){
ArcNode *p = G.vertices[i].firstarc->nextarc;
while(p && p->adjvex!=w) p = p->nextarc;
if(p) return i;
}
return -1;
}
int NextPrior(ALGraph G, int w, int v){
for(int i=v+1; i<G.vexnum; i++){
ArcNode *p = G.vertices[i].firstarc->nextarc;
while(p && p->adjvex!=w) p = p->nextarc;
if(p) return i;
}
return -1;
}
这里还是同样留给逆向拓扑排序用的。
注:下面的操作其实和邻接矩阵思路都是一样的,且测试用例均相同,所以不再给出详细的分析过程,仅贴代码。
另:邻接矩阵和邻接表的同样测试用例输出结果不同是因为插入边的顺序不同,如果修改边的插入顺序是可以一致的。
4.2 遍历
4.2.1 广度优先遍历
void visit(ALGraph G, VexType v){
printf("%c", G.vertices[v].data);
}
void BFS(ALGraph G, int v);
bool visited[MAX_VEX_SIZE];
void BFSTraverse(ALGraph G, int v0=0){
for(int v=0; v<G.vexnum; v++) visited[v] = false;
BFS(G, v0);
for(int v=0; v<G.vexnum; v++){
if(!visited[v]) BFS(G, v);
}
}
void BFS(ALGraph G, int v){
Queue Q;
InitQueue(Q);
visit(G, v);
visited[v] = true;
EnQueue(Q, v);
while(Q.front!=Q.rear){
DeQueue(Q, v);
for(int w=FirstAdjVex(G, v); w>=0; w=NextAdjVex(G, v, w)){
if(!visited[w]){
visit(G, w);
visited[w] = true;
EnQueue(Q, w);
}
}
}
}
4.2.2 深度优先遍历
void DFS(ALGraph G, int v);
void DFSTraverse(ALGraph G, int v0=0){
for(int v=0; v<G.vexnum; v++) visited[v] = false;
DFS(G, v0);
for(int v=0; v<G.vexnum; v++){
if(!visited[v]) DFS(G, v);
}
}
void DFS(ALGraph G, int v){
visit(G, v);
visited[v] = true;
for(int w=FirstAdjVex(G, v); w>=0; w=NextAdjVex(G, v, w)){
if(!visited[w]){
DFS(G, w);
}
}
}
4.3 最短路径
4.3.1 BFS最短路径
void ShortestPath_BFS(ALGraph G, int v0){
Queue Q;
InitQueue(Q);
for(int v=0; v<G.vexnum; v++){
distance[v] = MAX_INT;
path[v] = -1;
visited[v] = false;
}
distance[v0] = 0;
visited[v0] = true;
EnQueue(Q, v0);
while(Q.rear!=Q.front){
DeQueue(Q, v0);
for(int w=FirstAdjVex(G, v0); w>=0; w=NextAdjVex(G, v0, w)){
if(!visited[w]){
distance[w] = distance[v0]+1;
path[w] = v0;
visited[w] = true;
EnQueue(Q, w);
}
}
}
}
4.3.2 Dijsktra 算法
void ShortestPath_Dijkstra(ALGraph G, int v0){
for(int w=0; w<G.vexnum; w++){
final[w] = false;
if(getWeight(G, v0, w)!=MAX_INT){
distance[w] = getWeight(G, v0, w);
path[w] = v0;
}
else{
distance[w] = MAX_INT;
path[w] = -1;
}
}
final[v0] = true;
distance[v0] = 0;
int v, w;
for(int i=0; i<G.vexnum; i++){
int min = MAX_INT;
for(w=0; w<G.vexnum; w++){
if(!final[w]){
if(distance[w]<min){
v = w;
min = distance[w];
}
}
}
final[v] = true;
for(w=0; w<G.vexnum; w++){
if(!final[w] && (min+getWeight(G, v, w)<distance[w])){
distance[w] = min+getWeight(G, v, w);
path[w] = v;
}
}
}
}
4.3.3 Floyd 算法
bool ShortestPath_Floyd(ALGraph G){
for(int v=0; v<G.vexnum; v++){
for(int w=0; w<G.vexnum; w++){
if(getWeight(G, v, w)!=MAX_INT) d[v][w] = getWeight(G, v, w);
else d[v][w] = MAX_INT;
p[v][w] = -1;
}
}
for(int u=0; u<G.vexnum; u++){
for(int v=0; v<G.vexnum; v++){
for(int w=0; w<G.vexnum; w++){
if(d[v][u]+d[u][w]<d[v][w]){
d[v][w]=d[v][u]+d[u][w];
p[v][w] = u;
}
}
}
}
}
4.4 拓扑排序
4.4.1 定义法
void FindInDegree(ALGraph G, int (&indegree)[MAX_VEX_SIZE]){
for(int w=0; w<G.vexnum; w++){
for(int v=0; v<G.vexnum; v++){
if(getWeight(G, v, w)!=MAX_INT) indegree[w]++;
}
}
}
void FindOutDegree(ALGraph G, int (&outdegree)[MAX_VEX_SIZE]){
for(int v=0; v<G.vexnum; v++){
for(int w=0; w<G.vexnum; w++){
if(getWeight(G, v, w)!=MAX_INT) outdegree[v]++;
}
}
}
bool TopoSort(ALGraph G, bool REVERSE = false){
if(!REVERSE){
int indegree[MAX_VEX_SIZE]={0};
FindInDegree(G, indegree);
Queue Q;
InitQueue(Q);
for(int v=0; v<G.vexnum; v++){
visited[v] = false;
if(indegree[v]==0){
EnQueue(Q, v);
}
}
int cnt=0;
int v;
while(Q.front!=Q.rear){
DeQueue(Q, v);
topo[cnt++] = v;
for(int w=FirstAdjVex(G, v); w>=0; w=NextAdjVex(G, v, w)){
indegree[w]--;
if(indegree[w]==0){
EnQueue(Q, w);
}
}
}
if(cnt<G.vexnum) return false;
else return true;
}
else if(REVERSE){
int outdegree[MAX_VEX_SIZE]={0};
FindOutDegree(G, outdegree);
Queue Q;
InitQueue(Q);
for(int v=0; v<G.vexnum; v++){
if(outdegree[v]==0) EnQueue(Q, v);
}
int cnt=0;
while(Q.front!=Q.rear){
int v;
DeQueue(Q, v);
topo_r[cnt++] = v;
for(int w=FirstPrior(G, v); w>=0; w=NextPrior(G, v, w)){
outdegree[w]--;
if(outdegree[w]==0) EnQueue(Q, w);
}
}
if(cnt<G.vexnum) return false;
else return true;
}
}
4.4.2 DFS法
void DFSS(ALGraph G, int v, int &order, int (&res)[MAX_VEX_SIZE]){
visited[v] = true;
for(int w=FirstAdjVex(G, v); w>=0; w=NextAdjVex(G, v, w)){
if(!visited[w]){
visited[w];
DFSS(G, w, order, res);
}
}
res[v] = order++;
}
void DFSTopoSort(ALGraph G, bool REVERSE= false){
for(int v=0; v<G.vexnum; v++) visited[v] = false;
int res[MAX_VEX_SIZE];
for(int v=0; v<G.vexnum; v++){
int order = 0;
if(!visited[v]) DFSS(G, v, order, res);
}
if(!REVERSE){
for(int i=0; i<G.vexnum; i++){
for(int v=0; v<G.vexnum; v++){
if(res[v]==(G.vexnum-i-1)) topo[i] = v;
}
}
}
else if(REVERSE){
for(int i=0; i<G.vexnum; i++){
for(int v=0; v<G.vexnum; v++){
if(res[v]==i) topo_r[i] = v;
}
}
}
}
五、其他未实现操作
5.1 最小生成树
最小生成树(MST,Minimum Spanning Tree),又叫最小代价树,指从一个连通图中,筛选所有顶点和若干条边,将他们组合成一棵树,且这棵树的边权值之和最小。
注意,前提是连通图,并且最小生成树是一棵树,不能有没连起来的结点。
如果以上条件任一没满足,则会由生成森林而非生成树。
下面将结合算法研究例子。
5.1.1 Prim 算法
Prim 算法的思路是,从某一个顶点开始构造 MST,每次将代价(权值)最小的新顶点加入 MST,直到所有顶点都加入。
考察这样一个图:
第一次可以从任意顶点开始,假如从 C 开始,把 C 加入 MST(红色表示),其他顶点加入它的代价用蓝色表示:
显然 1 是最小的代价,故第二次把 A 加入 MST:
此时 MST 包含两个结点 A 和 C,剩下的顶点中,代价最小的是 4,但有两个 4,可以任选其一加入 MST(此处选择的不同会导致结果的不同,但都是对的),这里选 D 加入 MST:
会发现 A D 之间的那条边已经没用了,原因是 A 和 D 已经在同一棵树里,不需要连接他俩。此时最小的代价是 2,F 加入 MST:
同样,C F 之间的边也已经没用了。接下来两步,分别是 5 最小,B 加入,3 最小,E 加入。结果如下:
别的结果就不演示了,可以自己试一下,只要保证选择边的代价最小,MST 的结果是不唯一的。
至于实现,这里就不展开了。
5.1.2 Kruskal 算法
Kruskal 算法的思路是,每次选择尚未联通的、权值最小的边加入 MST,并使边的两头连通。
结合例子说明:
第一步,找所有边中权值最小的,结果是 A-C 的 1,并且 A C 尚未连通,所以连通 A C:
第二步,还是找权值最小的,结果是 D-F 的 2,并且 D F 尚未连通,所以连通 D F:
第三步,还是找权值最小的,结果是 B-E 的 3,并且 B E 尚未连通,所以连通 B E:
第四步,还是找权值最小的,结果是 4,可能是 C-D 或者 C-F,任选其一,这里选 C F,并且 C F 尚未连通,所以连通 C F:
第五步:还是找权值最小的,结果是 C-D 的 4,但是 C-D 已经通了(C-F-D),所以跳过,下一个最小的权值是 5,A-D 也已经连通了(A-C-F-D),所以只剩 B-C 的5,B C 没连通,所以连通 B C:
至此,会发现所有顶点都已经连通,算法也就执行结束了。
至于实现,就不展开了。
5.2 关键路径
这是前面拓扑排序的例子,是一个典型的 AOV 网。
所谓关键路径,就是从源点(唯一一个入度为 0 的顶点)到汇点(唯一一个出度为 0 的顶点)之间的关键事件组成的路径。
好吧,那什么是关键事件呢,
比如上图中,假设事件 A 开始的时间为 0 时刻,那么 B 的最早发生时间为 3;C 的最早发生时间为 2;D 的最早发生时间为 6(B 和 C 都要完成)。这也就是说,如果整个工程到 D 就结束了,那么决定整个工期的不是 B 事件而是 C 事件。如果 B 在 4 时刻发生了,那么 D 的最早发生时间依然是 6,也就是说 B 的拖期不影响 D 的完成(一定程度上),但是如果 C 拖期了,比如 C 在 3 时刻才发生,那么 D 的最早发生时间就变成了 7,意味着项目拖期了。同理,A 拖期也是同样的。
决定项目工期的这些事件,就是关键事件;关键事件组成的路径就叫关键路径。
那怎么求关键路径呢?
这里还得引入一个概念,首先是关键活动,如前所述,事件是指顶点,而活动就是指有向边,关键活动就是关键路径上的弧。
关键路径上的关键活动有什么特征呢?首先,明确一下,活动的最早开始时间等于其出端的事件的最早开始时间,活动的最晚开始时间等于其入端的事件的最晚开始时间减去边的权值。
怎么理解?事件的最早开始时间是由前一个关键事件决定的(上面讨论的例子),而事件的最晚发生事件是从最后一个事件向前推的,如果一个事件是关键事件,那么假如整个工期按时开始,到点结束,这些关键事件的最早开始时间和最晚开始时间是一致的,换句话说,如果最早开始时间比最晚开始时间小,就意味着这个事件可以推迟了。而关键事件是显然不能推迟的,故关键事件的最早开始时间等于最晚开始时间。
那为什么还需要活动呢?因为光确定了事件,没法确定关键路径,比如上图中,如果在 AD 中再加一条边,并判断了 A C D F 是关键事件,那关键路径可以是 A-C-D-F 也可以是 A-D-C-F,显然不可能存在两种情况。
这时候,就需要包含边的信息的关键活动了,如果知道某几条边是关键活动,关键路径便明了了。
所以在计算的时候,就需要由关键事件的最早和最晚发生时间推断活动的最早和最晚发生时间,由此确定关键路径。并且,关键路径上的关键活动的最早开始时间和最晚开始时间也是一样的。
辅助数组:
ve(k):事件 vk 的最早发生时间;
vl(k):事件 vk 的最晚发生时间;
e(i):活动 ai 的最早发生时间,等于
l(i):活动 ai 的最晚发生时间;
d(i):活动 ai 的时间余量,等于 l(i)-e(i)。
首先是 ve(k) 的求法:
对所有顶点进行拓扑排序得:ACBEDF。
- ve(A) = 0
- ve(C) = ve(A)+2 = 2
- ve(B) = ve(A)+3 = 3
- ve(E) = ve(B)+3 = 6
- ve(D) = max{ ve(B)+2, ve(C)+4 } = 6
- ve(F) = max{ ve(E)+1, ve(D)+2, ve(C)+3 } = 8
然后是 vl(k) 的求法:
对所有顶点进行逆拓扑排序得:FEDBCA。
- vl(F) = ve(F) = 8
- vl(E) = vl(F)-1 = 7
- vl(D) = vl(F)-2 = 6
- vl(B) = min{ vl(E)-3, vl(D)-2 } = 4
- vl(C) = min{ vl(F)-3, vl(D)-4 } = 2
- vl(A) = ve(A) = 0
接着是 e(i) 的求法:
活动 ai 本质上是一条有向边,代表 <vk, vj> 这条弧,e(i) = ve(k)。我这里为了方便理解就不写 ai 了,直接用边指代。
- e(<A, B>) = ve(A) = 0
- e(<A, C>) = ve(A) = 0
- e(<B, D>) = ve(B) = 3
- e(<B, E>) = ve(B) = 3
- e(<C, D>) = ve(C) = 2
- e(<C, F>) = ve(C) = 2
- e(<D, F>) = ve(D) = 6
- e(<E, F>) = ve(E) = 6
还有 l(i) 的求法:
活动 ai 本质上是一条有向边,代表 <vk, vj> 这条弧,l(i) = vl(j) - weight<vk, vj>。
- l(<A, B>) = vl(B)-3 = 1
- l(<A, C>) = vl(C)-2 = 0
- l(<B, D>) = vl(D)-2 = 4
- l(<B, E>) = vl(E)-3 = 4
- l(<C, D>) = vl(D)-4 = 2
- l(<C, F>) = vl(F)-3 = 5
- l(<D, F>) = vl(F)-2 = 6
- l(<E, F>) = vl(F)-1 = 7
最后是 d(i) 的求法:
d(i) 等于 l(i)-e(i)。
- d(<A, B>) = l(<A, B>) - e(<A, B>) = 1
- d(<A, C>) = l(<A, C>) - e(<A, C>) = 0
- d(<B, D>) = l(<B, D>) - e(<B, D>) = 1
- d(<B, E>) = l(<B, E>) - e(<B, E>) = 1
- d(<C, D>) = l(<C, D>) - e(<C, D>) = 0
- d(<C, F>) = l(<C, F>) - e(<C, F>) = 3
- d(<D, F>) = l(<D, F>) - e(<D, F>) = 0
- d(<E, F>) = l(<E, F>) - e(<E, F>) = 1
余量为 0 的活动就是关键活动了,所以关键路径是 A-C-D-F。
六、算法的时间复杂度及适用情况分析
@20221105
关于下文中出现的n和e,如无特殊说明意思均为:设图有n个顶点、e条边(弧)。
6.1 常规操作
①查找边。给定边(x,y)或弧<x,y>,判断是否存在该边。
- 无向图+邻接矩阵:O(1)
- 无向图+邻接表:O(1)~O(n)
- 有向图+邻接矩阵:O(1)
- 有向图+邻接表:O(1)~O(n)
无向图和有向图函数操作相同。
在邻接矩阵表示的图中,给定边(x,y)或弧<x,y>,可以直接按下标访问到邻接矩阵对应边,通过判断非0非∞判断有边;
在邻接表表示的图中,则需要先访问顶点表找到顶点x,然后遍历顶点x的边表,最好情况下为一个顶点,最坏情况所有顶点依次相连。
②找邻接边。给定顶点x,列出与该顶点邻接的边。
- 无向图+邻接矩阵:O(n)
- 无向图+邻接表:O(1)~O(n)
- 有向图+邻接矩阵:O(n)
- 有向图+邻接表(出边):O(1)~O(n)
- 有向图+邻接表(入边):O(e)
在邻接矩阵表示的无向图中,给定顶点x,只需要遍历邻接矩阵x行或x列,而矩阵是n*n大小的,行列均有n个元素。若是有向图,则遍历x行得到出边,遍历x列得到入边。
在邻接表表示的无向图中,顶点x的边表包含的信息既为出边又为入边,故为遍历边表的时间复杂度。
在邻接表存储的有向图中,若找出边,则和无向图类似遍历顶点x的边表;若找入边,则需要判断所有边表中的所有边,共e个。
③插入顶点。给定顶点x,插入图中。
- 邻接矩阵:O(1)
- 邻接表:O(1)
插入顶点并不需要连边,所以不分有向无向。
在邻接矩阵表示的图中,若提前给邻接矩阵预留了空间,且邻接矩阵所有位置都已提前初始化,则只需要在顶点表最后新增一个元素。若没有提前初始化,则需要考虑对邻接矩阵扩容、修改值等操作。
在邻接表表示的图中,只需要在顶点表插入一个元素。
④删除顶点。给定顶点x,删除该顶点和所有相邻的边。
- 无向图+邻接矩阵:O(n)
- 无向图+邻接表:O(1)~O(e)
- 有向图+邻接矩阵:O(n)
- 有向图+邻接表(出边):O(1)~O(n)
- 有向图+邻接表(入边):O(e)
在邻接矩阵表示的无向图中,删除顶点的操作需要将顶点在顶点表中删除(置0或标记),另外将邻接矩阵中x行x列置为0,共2n+1次操作。若为有向图,操作相同。
在邻接表表示的无向图中,删除顶点的操作需要删除顶点表对应元素及其边表,最好情况顶点x没有边,最坏情况为顶点x连着所有边。
在邻接表表示的有向图中,删除出边需要删除顶点x的边表,和遍历时间复杂度相同。删除入边则需要对所有边表进行遍历,共遍历e个边表结点。
6.2 遍历
①DFS
- 邻接矩阵:O(n^2),邻接矩阵唯一,遍历结果唯一,深度优先生成树唯一
- 邻接表:O(n+e),邻接表不唯一,遍历结果不唯一,深度优先生成树不唯一
DFS共需遍历n个顶点,而对于每个顶点又需要找其邻接点。在邻接矩阵中,找邻接点需要遍历当前DFS顶点所在行,共n个元素;在邻接表中,找邻接点需要遍历边表,而访问所有边表结点的次数之和为边数e。
②BFS
- 邻接矩阵:O(n^2),邻接矩阵唯一,遍历结果唯一,广度优先生成树唯一
- 邻接表:O(n+e),邻接表不唯一,遍历结果不唯一,广度优先生成树不唯一
原因同上
6.3 最短路径
①BFS
- 邻接矩阵:O(n^2)
- 邻接表:O(n+e)
- 适用于:单源最短路径,无权图,
带权图,负权图,负权回路图
和BFS遍历的时间复杂度相同,只是在其中增加了计算路径长度的语句。
②Dijkstra
- O(n^2)
- 适用于:单源最短路径,无权图,带权图,
负权图,负权回路图
需要对n个结点依次进行判断,每次又会更新dist数组和path数组的内容。
③Floyd
- O(n^3)
- 适用于:所有最短路径,无权图,带权图,负权图,
负权回路图
共需要执行n轮,每轮中更新n*n的二维数组dist和path的内容。
6.4 最小生成树
①Prim
- O(n^2),适合边稠密
Prim算法会从某个顶点开始构造MST,共需构造n个顶点的MST。每次有新顶点加入MST时,需要遍历所有结点找到最小的边权值。
②Kruskal
- O(elog2e),适合边稀疏
Kruskal算法会依次对e条边进行判断,每次选择权值最小的边,判断其是否在集合中。该操作基于优化查找的并查集进行,时间复杂度为O(log2e)。
6.5 拓扑排序
- 邻接矩阵:O(n^2)
- 邻接表:O(n+e)
在邻接矩阵表示的AOV网中拓扑排序,n个顶点各需要进出队列一次,每次出队时会进入一个循环找当前顶点的邻接点,并更新入度数组,在邻接矩阵中找邻接点的时间复杂度为O(n)。
在邻接表表示的AVO网中拓扑排序,n个顶点个需要进出队列一次,每次出队时会遍历当前顶点的边表,并更新入度数组,在邻接表中找邻接点的时间复杂度总和为O(e)。
==总结==