逻辑结构

定义

是由顶点的有穷非空集合和顶点之间边的集合组成, 通常表示为: G(V,E), 其中,G表示一个图,V是图G中顶点的集合,E是图G中边的集合

image-20220512210137535

线性表可以是空表,树可以是空树,但是图不可以是空,即V一定是非空集


类型

image-20220512210442402

无向图:如果E是由无向边的有限集合时,则图G为无向图

:对于无向图,顶点的度指依附于该顶点的边的条数

无向完全图:在无向图中,如果任意两个顶点之间都存在边,则称该图为无向完全图。含有n个顶点的无向完全图有$\frac{n(n+1)}{2}$条边

image-20220512210814592

有向图:若E是有向边(弧)的有限集合时,则图G为有向图,弧是顶点的有序对,记为,其中v,w是顶点,v称为弧尾,w称为弧头,称为从顶点v到顶点w的弧,

入度,出度:对于有向图,入度是以顶点为终点的有向边的数目,记为ID(v),出度是以顶点为起点的有向边的数目,记为OD(v),顶点的度等于其入度和出度之和,对于有向图,图的总入度和总出度肯定是相同的

有向完全图:在有向图中,如果任意两个顶点之间都存在方向互为相反的两条弧,则称该图为有向完全图。含有n个顶点的有向完全图有n× (n-1)条边


子图: 假设有两个图G= (V,{E})和G'= (V',{E'}),如果V’是V的子集,且E’是E的子集,则称G’为G的子图。如下图带底纹的图均为左侧无向图与有向图的子图。

路径和路径的长度:从顶点A到顶点B的路径是一个顶点序列。路径的长度是路径上的边或弧的数目。有向图的路径也是有向的

回路:第一个顶点和最后一个顶点相同的路径称为回路

简单路径、简单回路或简单环:序列中顶点不重复出现的路径称为简单路径。除了第一个顶点和最后一个顶点之外,其余顶点不重复出现的回路,称为简单回路或简单环

点与点的距离:从顶点u出发到顶点v的最短路径若存在,则此路径的长度称为从uv的距离,若不存在路径,则记该距离为无穷($\infty$)


image-20220517160124855

连通、连通图和连通分量:在无向图G中,如果从顶点v到顶点v’有路径,则称v和v’是连通的。 如果对于图中任意两个顶点vi、vj ∈E, vi,和vj都是连通的,则称G是连通图

无向图中的极大连通子图称为连通分量。注意连通分量的概念,它强调:

  • 要是子图;
  • 子图要是连通的;
  • 连通子图含有极大顶点数;
  • 具有极大顶点数的连通子图包含依附于这些顶点的所有边。

图2,图3,图4都是无向图1的子图,图2是极大连通子图,图3也极大连通子图,图4不满足极大的特点

image-20220517160843870

强连通图和强连通分量:在有向图G中,如果对于每一对vi,vj属于E,从vi到vj和vj 到vi都有路径,则称G是强连通图。有向图中的极大强连通子图称作有向图的强连通分量。

即ABCDE组成了一个强连通分量,而F缺乏到B的路径,没有包含在内,F和G单独为一个强连通分量

每一个孤立结点都构成连通分量


生成树:一个极小的连通子图, 它含有图中全部的n个顶点,但只有足以构成一棵树的n-1条边,简而言之就是用最少的边连接所有顶点

权和网:有些图的边或弧具有与它相关的数字,这种与图的边或弧相关的数叫做权。这些权可以表示从一个顶点到另一个顶点的距离或耗费。这种带权的图通常称为网

是一种不存在回路,且连通的无向图


性质

无向图边数的2倍等于各顶点的度数的总和


存储结构

邻接矩阵法

图的邻接矩阵存储方式是用两个数组来表示图。一个一维数组存储图中顶点信息,一个二维数组(称为邻接矩阵)存储图中的边或弧的信息

定义

1
2
3
4
5
6
7
8
9
10
11
12
13
#define VertexMax 100 //最大顶点数为100
#define MaxInt 32767 //表示最大整数,表示 ∞
#define Maxsize 100 //队列最大元素个数100

typedef char VertexType; //每个顶点数据类型为字符型
typedef int dataType;

typedef struct
{
char Vertex[100];//存放顶点元素的一维数组,VertexType表示元素的数据类型
int AdjMatrix[1][100];//邻接矩阵二维数组
int vexnum, arcnum;//图的顶点数和边数
}MGraph;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//查找元素v在一维数组 Vertex[] 中的下标,并返回下标 
int LocateVex(MGraph* G, char v)
{
int i;

for (i = 0; i < G->vexnum; i++)
{
if (v == G->Vertex[i])
{
return i;
}
}

printf("No Such Vertex!\n");
return -1;
}

结点数为n的图G中,邻接矩阵是n×n大小的,初始化数值为0,如果ij列数值为1,表示第i个元素到第j个元素存在路径,在无向图中,是双向的,即ij列以及ji列数值都会为1,而在有向图中,是单向的

无向图:

image-20220517170049835

有向图:

image-20220517170107887

创建邻接矩阵

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
//无向图和有向图
void CreateUDG(MGraph* G)
{
int i, j;
//1.输入顶点数和边数
printf("输入顶点个数和边数(用逗号隔开):");
scanf("%d,%d", &G->vexnum, &G->arcnum);
printf("\n");

//2.输入顶点元素
printf("输入顶点元素(无需空格隔开):");
scanf("%s", G->Vertex);
printf("\n");
//3.矩阵初始化
for (i = 0; i < G->vexnum; i++)
for (j = 0; j < G->vexnum; j++)
{
G->AdjMatrix[i][j] = 0;
}

//4.构建邻接矩阵
int n, m;
cv1, v2;

printf("请输入边的信息:\n");
for (i = 0; i < G->arcnum; i++)
{
scanf(" %c%c", &v1, &v2);
n = LocateVex(G, v1); //获取v1所对应的在Vertex数组中的坐标
m = LocateVex(G, v2); //获取v2所对应的在Vertex数组中的坐标

if (n == -1 || m == -1)
{
printf("NO This Vertex!\n");
return;
}

G->AdjMatrix[n][m] = 1; // 创建有向图
// G->AdjMatrix[m][n] = 1; 创建无向图
// G->AdjMatrix[n][m] = w; 将1改为w,而将0改为∞,则是网
}
}

对于带权图(网)来说,将矩阵里面的值初始化为,如果存在路径,则路径的值为权值

无向网:

image-20220517171039499

有向网:

image-20220517171059855


对有向图来说

i个结点的出度=第i行非零元素个数

i个结点的入度=第i列非零元素个数

i个结点的度=第i行,第i列非零元素个数之和

邻接矩阵法求顶点的度的时间复杂度为O(n),空间复杂度为O(n²),之和顶点数有关,和实际边数无关,适用用于存储稠密图,而且无向图的邻接矩阵是对称矩阵,可以压缩存储

设图G的邻接矩阵为A,则$A^n$的元素$A^n[i][j]$等于由顶点i到顶点j的长度为n的路径的数目


邻接表法

邻接表由顶点表和边表组成,顶点表是结构体数组,边表是一环一环的链节

image-20220518144342896

顶点表是结构体数组,其中的每个元素有两个域:

  • 数据域(用于存放顶点元素如V1、V2、V3或A、B、C之类的)

  • 指针域,用于连接边表。

边表是结构体,其中每个结点也有两个域:

  • 下标域,存储的是对应元素在顶点表中的下标。
  • 指针域,用于连接后续结点

邻接表法和树的孩子表示法一模一样,都是顺序+链式存储

定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
typedef struct ArcNode//边表 
{
int adjvex;//存储的是该顶点在顶点数组即AdjList[]中的位置 下标域
struct ArcNode *next; //指针域,用于连接后续结点
}ArcNode;

typedef struct VNode //单个顶点
{
int vertex; //数据域
//int weight;//存储网的时候需要添加此项
struct ArcNode *firstarc; //第一个孩子节点 指针域,用于连接边表
}VNode;

typedef struct //顶点表
{
VNode AdjList[VertexMax];//由顶点构成的结构体数组
int vexnum,arcnum; //顶点数n和边数e
}ALGraph;

image-20220518144643827

image-20220518150702207

对于无向图,边节点的数量是2n,整体空间复杂度为V+2n

对于有向图,边节点的数量是n,整体空间复杂度为V+n

在无向图中,如果要求节点的度,遍历节点的边表即可,在有向图中,如果要找有向边的入度,那么需要遍历所有结点

图的邻接表表示并不唯一,空间复杂度低,适合存放稀疏图


邻接多重表

因为邻接表法在存储无向图的时候会存储冗余数据,所以利用十字链表的方法,使用邻接多重表来存储无向图

比起十字链表法,邻接多重表只有边表被扩充了

image-20220518160212613

image-20220518160235586

空间复杂度V+n,而且删除边和节点操作变得简单,只适合用存储无向图


十字链表法

邻接表法容易求顶点的出度,但不容易求顶点的入度

如果我们更改邻接表的定义,将有向图中指向该结点的弧记录为孩子,那么就变成了逆邻接表,容易求顶点的入度,但不容易求顶点的出度

如果把邻接表和逆邻接表结合起来,既可以方便求入度,也可以方便求出度,这就是十字链表法

顶点结点:

image-20220518154636296

弧结点:

image-20220518154311640

相比于邻接表法,顶点表多了一个firstin区域,用于记录指向结点的弧

边表扩充了一倍,同时记录了弧头顶点编号和弧头相同的下一条弧(橙色部分)

image-20220518155636604

(如果把橙色区域以及指针全去了,就是邻接表法)

空间复杂度为V+n,只适合存储有向图


image-20220518161142466


遍历

img

广度优先遍历(BFS)

类似于树的层次遍历

即先从某个结点开始,先遍历最离其最近的结点,再遍历到深层的结点

image-20220518163613467

基本步骤:

  1. 设置全局变量visited数组并初始化为全0,代表所有节点均未被访问

  2. 设置起始点:包括对起始点进行输出、标记成已访问、入队

  3. 对后续结点进行操作:由起始点开始,对后续结点进行操作(输出、标记成已访问、入队
    (步骤2-3为广度优先搜索

  4. 循环重复2-3的操作避免有“孤岛”结点被遗漏。
    (步骤4 循环执行广度优先搜索避免遗漏“孤岛”结点,就是广度优先遍历)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
//邻接矩阵法

int visitedBFS[VertexMax];//定义"标志"数组为全局变量
void BFS(MGraph *G,int i)
{
int j;
CyQueue q; //队列
create(&q); //初始化队列

//1.设置起始点
printf("%c",G->Vertex[i]);//1.输出起始结点
visited[i]=1;//2.将已访问的结点标志成1
EnQueue(&q,i);//3.将第一个结点入队

//2.由起始点开始,对后续结点进行操作
while(!QueueEmpty(&q))//队列非空
{
DeQueue(&q,&i); //出队 i的值根据出队的元素在变
for(j=0;j<G->vexnum;j++)
{
if(G->AdjMatrix[i][j]==1&&visited[j]==0) //如果矩阵内存在路径
{
printf("%c",G->Vertex[j]);//输出符合条件的顶点
visitedBFS[j]=1;//设置成已访问状态1
EnQueue(&q,j);//入队 j的值根据入队的元素在变
}
}
}
}
//为了避免孤岛结点无法被遍历到,需要确认visit数组都为1
void BFSTraverse(MGraph *G)
{
int i;

//数组初始化为全0
for(i=0;i<G->vexnum;i++)
{
visited[i]=0;
}

for(i=0;i<G->vexnum;i++)
{
if(visited[i]==0)
{
BFS(G,i);
}
}
}

对于BFS,邻接矩阵的遍历序列唯一,邻接表不唯一

对于无向图,调用BFS函数的次数等于连通分量的次数

空间复杂度:来自于辅助队列,大小为O(n)

时间复杂度:

对于邻接矩阵:针对每一个元素i,都会扫描j列来确定是否邻接,所以其时间复杂度为O(n²)

对于邻接表:针对每一个元素i,只需要找其边表即可确定邻接的点,所以时间复杂度为O(n)


深度优先遍历(DFS)

类似于树的先序遍历

基本步骤

  1. 设置全局变量visited数组并初始化为全0,代表所有节点均未被访问
  2. 选取初始端点:对初始端点进行访问,并在visited数值中标记成已访问状态(代码演示的初始端点是G->Vertex[i],此时i=0
  3. 循环对所有节点都执行步骤2,前提是该节点未被访问!(对应函数DFSTraverse,主要用于非连通图能访问到每一个结点)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
int visitedDFS[VertexMax];//定义"标志"数组为全局变量 

void DFS(MGraph* G, int i)
{
int j;

//1.处理起始点
printf("%c", G->Vertex[i]);//1.输出起始结点
visitedDFS[i] = 1;//2.将已访问的结点标志成1

//2.由起始点开始,对后续结点进行操作
for (j = 0; j < G->vexnum; j++)//依次搜索vi的邻接点
{
if (G->AdjMatrix[i][j] == 1 && visitedDFS[j] == 0)
//当满足有边且未被访问过时,递归调用去查找该邻接点
{
DFS(G, j);//注意此处的G已经是指针类型,不需要再&G
}
}
}
void DFSTraverse(MGraph* G)
{
int i;
//初始化"标志"数组为0,代表未访问
for (i = 0; i < G->vexnum; i++)
{
visitedDFS[i] = 0;
}
for (i = 0; i < G->vexnum; i++)
{
if (visitedDFS[i] == 0)
{
DFS(G, i);//注意此处的G已经是指针类型,不需要再&G
}
}
}

对于BFS,邻接矩阵的遍历序列唯一,邻接表不唯一

空间复杂度:来自于递归调用工作栈,大小为O(n)

时间复杂度:

对于邻接矩阵:针对每一个元素i,都会扫描j列来确定是否邻接,所以其时间复杂度为O(n²)

对于邻接表:针对每一个元素i,只需要找其边表即可确定邻接的点,所以时间复杂度为O(n)


最小生成树

在无向网中,所有路径的权值之和最小的生成树,称为最小生成树(Minimum Cost Spanning Tree)

普里姆算法(Prim)

顶点操作,在最小生成树的顶点集U待处理顶点集V-U中,不断地寻找最短边(代价最小边),找到后将对应顶点加入集合U,直到所有顶点均处理完毕(V-U里没有剩余顶点)

  1. 首先我们需要一个结构体数组:最短路径数组shortedge来存储当前各个顶点之间的最短路径信息,其中的adjvex用于存储最短边的邻接点,lowcost是其对应权值,也就是当前最小的代价

  2. shortedge数组写入初始化信息,将起始点放入集合U中,即 shortedge[k].lowcost=0;lowcost为0表示该顶点属于U集合,k是起始点的位置

  3. 求最小值的函数(minimal):只需要在当前shortage数组中比较出lowcost最小的元素,返回它的下标loc即可在Vertex数组中找到该元素。

  4. 对后续顶点处理:通过minimal函数找到最小路径所对应的的顶点, 将此路径对应的顶点放入集合U中(将其对应的lowcost改为0),更新shortedge数组(集合U中加入新的顶点,阵营U中有可能生成新的最小路径到达阵营V-U中)

    image-20220518203726957

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65

    //1.结构体数组存储最短路径
    typedef struct//最短路径数组结构体(候选最短边)
    {
    char adjvex;//候选最短边的邻接点
    int lowcost;//候选最短边的权值
    }ShortEdge;

    //3. 求最小值的函数(minimal):只需要在当前shortage数组中比较出lowcost最小的元素
    // 返回它的下标loc即可在Vertex数组中找到该元素。

    int minimal(MGraph* G, ShortEdge* shortedge)
    {
    int i;
    int min, loc;

    min = MaxInt;
    for (i = 1; i < G->vexnum; i++)
    {
    if (min > shortedge[i].lowcost && shortedge[i].lowcost != 0)
    {
    min = shortedge[i].lowcost;
    loc = i;
    }
    }
    return loc;
    }

    void MiniSpanTree_Prim(MGraph* G, VertexType start)
    {
    int i, j, k;
    ShortEdge shortedge[VertexMax];

    //2.处理起始点start,写入初始信息
    k = LocateVex(G, start);
    for (i = 0; i < G->vexnum; i++)
    {
    shortedge[i].adjvex = start;
    shortedge[i].lowcost = G->AdjMatrix[k][i];
    //G是无向网,AdjMatrix[k][i]中存的是权值
    }
    shortedge[k].lowcost = 0;//lowcost为0表示该顶点属于U集合

    //4.处理后续结点
    for (i = 0; i < G->vexnum - 1; i++)//对集合U,去找最短路径的顶点
    {
    k = minimal(G, shortedge);//找最短路径的顶点

    printf("%c->%c,%d\n", shortedge[k].adjvex, G->Vertex[k], shortedge[k].lowcost);
    //输出找到的最短路径顶,及路径权值
    shortedge[k].lowcost = 0;//将找到的最短路径顶点加入集合U中

    for (j = 0; j < G->vexnum; j++)
    //U中加入了新的K节点,可能出现新的最短路径,故更新shortedge数组
    {
    if (G->AdjMatrix[k][j] < shortedge[j].lowcost)
    //有更短路径出现时,将其替换进shortedge数组
    {
    shortedge[j].lowcost = G->AdjMatrix[k][j];
    shortedge[j].adjvex = G->Vertex[k];
    }
    }

    }
    }

时间复杂度:O(n²),显然有个嵌套,第一个嵌套是遍历n-1个节点,时间复杂度是O(n),第二个嵌套中,先要找到最短路径的顶点,即miniaml函数,时间复杂度是O(n),找到后需要更新shortedge数组,时间复杂度是O(n),共O(2n),所有总时间复杂度为O(n²)

适合于稠密图


克鲁斯卡尔算法(Kruskal)

每次选取最短边,但不能构成回路

如果用邻接矩阵和邻接表,每次寻找最短边都要搜索所有边,故邻接矩阵和邻接表均不合适,所以最终选用了边集数组

边集数组edge是一个结构体数组,每一个单元包含起点、终点、权值

image-20220518205130127

定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
typedef struct
{
VertexType begin;//起点
VertexType end;//终点
int weight;
}Edge;//边集数组edge[]的单元

typedef struct
{
VertexType Vertex[VertexMax];//顶点数组
Edge edge[VertexMax];//边集数组
int vexnum;//顶点数
int edgenum;//边数
}EdgeGraph;

选取最短边的关键:排序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//选用简单排序
void sort(EdgeGraph *E)
{
int i,j;
Edge temp;

for(i=0;i<E->edgenum-1;i++)
{
for(j=i+1;j<E->edgenum;j++)
{
if(E->edge[i].weight>E->edge[j].weight)
{
temp=E->edge[i];
E->edge[i]=E->edge[j];
E->edge[j]=temp;
}
}
}
}

检查回路:只需要看两个顶点所属的树是否有相同的根节点,使用了并查集判断是否成环

1
2
3
4
5
6
7
8
9
int FindRoot(int t,int parent[])//t接收到是结点在Vertex数组中的下标 
{
while(parent[t]>-1)//parent=-1表示没有双亲,即没有根节点
{
t=parent[t];//逐代查找根节点
}

return t;//将找到的根节点返回,若没有根节点返回自身
}

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
void MiniSpanTree_Kruskal(EdgeGraph *E)
{
int i;
int num;//生成边计数器,当num=顶点数-1 就代表最小生成树生成完毕
int root1,root2;
int LocVex1,LocVex2;
int parent[VertexMax];
//用于查找顶点的双亲,判断两个顶点间是否有共同的双亲,来判断生成树是否会成环

//1.按权值从小到大排序
sort(E);
print(E);
//2.初始化parent数组
for(i=0;i<E->vexnum;i++)
{
parent[i]=-1;
}

printf("\n 最小生成树(Kruskal):\n\n");
//3.
for(num=0,i=0;i<E->edgenum;i++)
{
LocVex1=LocateVex(E,E->edge[i].begin);
LocVex2=LocateVex(E,E->edge[i].end);
root1=FindRoot(LocVex1,parent);
root2=FindRoot(LocVex2,parent);

if(root1!=root2)//若不会成环,则在最小生成树中构建此边
{
printf("\t\t%c-%c w=%d\n",E->edge[i].begin,E->edge[i].end,E>edge[i].weight);
//输出此边
parent[root2]=root1;//合并生成树
num++;

if(num==E->vexnum-1)//若num=顶点数-1,代表树生成完毕,提前返回
{
return;
}
}
}
}

排序算法决定了克鲁斯卡尔算法的时间复杂度。若采用插入排序,时间复杂度为$O(n²)$ ,若采用堆排序或者快速排序,那么时间复杂度为$O(nlog_2n)$ [注:$n$为边数],也就是说克鲁斯卡尔算法的时间复杂度取决于边数,所以适合稀疏图


最短路径问题

最短路径算法

  • 对于网(带权图)而言,求两点之间的最短路径有两种算法:迪杰斯特拉(Dijkstra算法)和 弗洛伊德(Floyd算法)
  1. 单源最短路径—迪杰斯特拉算法:从一个起始点出发,到达一个终点
  2. 多源最短路径—弗洛伊德算法:求每一对顶点之间的最短路径

迪杰斯特拉算法(Dijkstra)

迪杰斯特拉算法处处可见普里姆算法的影子,大体上两者都是在寻找当前情况下的最短边,而不同之处在于,迪杰斯特拉算法做了路程的累加

image-20220523141139432

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
int FindMinDist(int dist[],int s[],int vexnum) 
{
int i;
int loc;
int min=MaxInt+1;
for(i=0;i<vexnum;i++)
{
if(s[i]==0)//只对s[i]=0的顶点进行查找
{
if(dist[i]<min)
{
min=dist[i];
loc=i;
}
}
}
return loc;//返回dist中最小元素的下标
}

void ShortestPath_Dijkstra(MGraph *G,VertexType start)
{
int i,j,num;
int loc;
int min;
int dist[VertexMax];//最短路径长度数组
int path[VertexMax];//最短路径数组
int s[VertexMax];
//代表集合S(1代表该顶点已处理,属于集合S;0代表该顶点未处理,不属于集合S,属于集合V-S)

//1.初始化dist和path数组
loc=LocateVex(G,start);//获取源点的下标位置
for(i=0;i<G->vexnum;i++)
{
dist[i]=G->AdjMatrix[loc][i];//初始化dist数组

if(dist[i]!=MaxInt)//初始化path数组
{
path[i]=loc;
}
else
{
path[i]=-1;
}
}

//2.初始化S数组(s数组:代表集合S,1代表该元素属于集合S(已处理的顶点),0该元素不属于集合S(未处理的顶点))
for(i=0;i<G->vexnum;i++)
{
s[i]=0;
}
s[loc]=1;//代表起始点(源点)以处理完毕
num=1;

//3.
while(num<G->vexnum)
{
min=FindMinDist(dist,s,G->vexnum);
//在dist数组中查找其对应s[i]=0,即未处理的最小值元素
s[min]=1;//将找到的最短边所对应的的顶点加入集合S

for(i=0;i<G->vexnum;i++)//加入新的顶点后,更新dist和path数组
{
if((s[i]==0)&&(dist[i]>dist[min]+G->AdjMatrix[min][i]))//路径累加
{
dist[i]=dist[min]+G->AdjMatrix[min][i];
path[i]=min;
}
}
num++;
}
}

时间复杂度:O(n²)


弗洛伊德算法(Floyd)

多源点意为多起始点,也就是图中所有顶点都将作为起始点,求此顶点到达图中其他所有顶点的最短路径

在之前学习的Dijskra算法,我们知道Dijskra算法是求解单源点最短路径的算法,使用Dijskra算法可以求出一个源点到其他所有顶点的最短路径,那么我们将Dijskra算法循环执行n次(n为顶点数),每次带入图中的一个顶点,不就实现了求解多源最短路吗?

Dijskra算法执行单次的时间复杂度为$O(n^2)$,其中n为顶点个数,循环执行n次,那么使用Dijskra算法求解多源点最短路径的整体时间复杂度为$O(n^3)$

此次我们引入的求解多源点最短路径问题的算法是——Floyd算法,时间复杂度也为$O(n^3)$,但是在算法构造和算法可读性上优于执行n次的Dijskra算法

弗洛伊德的核心思想是:对于网中的任意两个顶点(例如顶点 A 到顶点 B)来说,之间的最短路径不外乎有 2 种情况:

  1. 直接从顶点 A 到顶点 B 的边的权值为顶点 A 到顶点 B 的最短路径。
  2. 从顶点 A 开始,经过若干个顶点,最终达到顶点 B,期间经过的边的权值和为顶点 A 到顶点 B 的最短路径

所以,弗洛伊德算法的核心为:对于从顶点 A 到顶点 B 的最短路径,拿出网中所有的顶点进行如下判断:
Dist(A,K)+ Dist(K,B)< Dist(A,B)
其中,K 表示网中所有的顶点;Dist(A,B) 表示顶点 A 到顶点 B 的距离

也就是说,拿出所有的顶点 K,判断经过顶点 K 是否存在一条可行路径比直达的路径的权值小,如果式子成立,说明确实存在 一条权值更小的路径,此时只需要更新记录的权值和即可

image-20220523142527193

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
int dist[VertexMax][VertexMax];
int path[VertexMax][VertexMax];

void ShortestPath_Floyd(MGraph G)
{
int i,j,k;
//初始化部分
for(i=0;i<G.vexnum;i++)
{
for(j=0;j<G.vexnum;j++)
{
dist[i][j]=G.AdjMatrix[i][j];

if(dist[i][j]!=MaxInt)
{
path[i][j]=i;//存入前驱
}
else path[i][j]=-1;
}
}

//Floyd算法核心部分
for(k=0;k<G.vexnum;k++)//拿出每个顶点作为遍历条件
for(i=0;i<G.vexnum;i++)
for(j=0;j<G.vexnum;j++)
{
if(dist[i][j]>dist[i][k]+dist[k][j])
{
dist[i][j]=dist[i][k]+dist[k][j];
path[i][j]=path[k][j];//存入前驱
}
}

}

image-20220523142855434


有向无环图描述表达式

  1. 把各个操作数不重复地排成一排
  2. 标出各个运算符的生效顺序
  3. 按顺序加入运算符,注意分层
  4. 从底向上逐层检查同层的运算符是否可以合体

image-20220523144808573


拓扑排序

AOV网(Activity On Vertex NetWork):用顶点表示活动,边表示活动发生的 先后关系

image-20220523152946305

拓扑序列:在AOV网(无环图)中,由顶点$V_i$到顶点$V_j$有一条路径,则在该线性序列中的顶点$V_i$必定在顶点$V_j$之前。

拓扑排序:将AOV网中的顶点序列排成拓扑序列就叫拓扑排序。

拓扑排序条件:必须是有向无环图(Directed Acycline Graph),AOV网满足有向无环图条件,若是有向成环图,则会进入死循环。

有向无环图只在意图中是否有环,即使不是连通图,也可以是有向无环图

拓扑排序的执行过程相当于每次 删去入度为0的顶点这个顶点发射出去的边,那么我们每次删去一个顶点和其发射边,就会生成一个新图,在这个新图上继续执行删去入度为0的顶点这个顶点发射出去的边,直到所有顶点都被删完。删除每个顶点的顺序,就是拓扑序列

步骤:

  1. 我们首先找到入度为0的顶点,然后对其进行输出,接着根据邻接表的邻接关系,找到与其邻接的其他顶点,再去对其他顶点进行相同的处理,由此往复。
  2. 我们需要一个 临时存取空间space,我们每次把入度为0的顶点放入space中,然后按顺序(顺序可以从头开始、从尾开始、甚至可以任意取)从space中取出,然后进行步骤1的处理,再将更新后入度为0的顶点放入space中,直到space中的元素被取空,即拓扑排序结束。
  3. 临时存取空间space的存取顺序可以是任意的,所以,这个space可以是栈结构,队列结构,也可以是一个数组,甚至可以是其他(源代码用数组模拟栈结构)。由此可见,存取顺序的不同,直接导致了拓扑排序结果不唯一。
  4. 总结一下拓扑排序在程序中的执行流程:首先我们搞了一个临时存储空间space,然后将入度为0的顶点放入space,再按顺序取出,每去取出一次,就根据邻接表的邻接关系,查找当前取出的顶点的邻接点,对每个邻接点入度-1,更新完入度后,看看有没有出现新的入度为0的结点,将其放入space中,直到space为空时,即所有顶点处理完毕,输出的序列就是拓扑序列

邻接表法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
typedef struct ArcNode//边表 
{
int adjvex;//存储的是该顶点在顶点数组即AdjList[]中的位置
struct ArcNode *next;
}ArcNode;

typedef struct VNode //单个顶点
{
char data; //数据域
struct ArcNode *firstarc; //第一个孩子节点
}VNode;

typedef struct //顶点表
{
VNode AdjList[MAX];//由顶点构成的结构体数组
int vexnum,arcnum; //顶点数n和边数e
}ALGraph;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
int indegree[MAX];//用来存储所有节点的入度之和

void FindInputDgeree(ALGraph G){//计算所有节点的入度
ArcNode *p; //临时边表变量
int i;

for(i = 0; i < G.vexnum; i ++)
indegree[i] = 0; //初始化入度数组

for(i = 0; i < G.vexnum; i ++){
p = G.AdjList[i].firstarc; //p设置为顶点的第一条边

while(p){ //如果边不为空
indegree[p->adjvex] ++; //顶点的对应位置的度+1
p = p->next;
}
}
}

void TopologicalSort(ALGraph *G)
{
int i;
int count=0;
//用于统计拓扑排序生成的结点数(若生成结点数 < 图的结点数,则代表图中有环,拓扑排序不成功)
//顺便检测了图中是否存在环
SqStack S; //文中说的space就是此处的栈结构
ArcNode *p;//临时边表变量

InitStack(S); //初始化栈

FindInputDegree(G); //求入度

//2.初始化部分:将初始入度为0的顶点序号入栈
for(i=0;i<G->vexnum;i++)
{
if(indegree[i]==0)
{
Push(S,i);
}
}

//3.拓扑排序
while(!StackEmpty(S)){
Pop(S,i); //pop操作会改变i的值
printf("%c",G.AdjList[i].data); //输出栈顶元素
count++;

p = G.AdjList[i].firstarc; //p为结点的第一条边
while(p)
{
indegree[p->adjvex]--; //p->adjvex代表的的A-->B中的B的序号1
if(indegree[p->adjvex]==0)//判断去掉一条边后节点的入度是否为零
{
Push(S,p->adjvex); //如果入度为0,压入栈中
}
p = p->next; //下一条边
}

//4.判断拓扑排序是否成功(生成结点数 < 图的结点数,则代表图中有环,拓扑排序不成功)
if(count<G->vexnum)
printf("TopologicalSort Failed!\n");
else return;
}

邻接矩阵法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
//邻接矩阵实现拓扑排序

void FindInDegree(MGraph G){//计算图中各节点的入度
int i,j;
for(i = 0; i < G.vexnum; i ++){
for(j = 0; j < G.vexnum; j ++)
if(G.AdjMatrix[i][j])//当两顶点之间存在边时,入度自加
indegree[j] ++; //列表示入度
}
}

void TopologicalSort(MGraph G){
FindInDegree(G);
SqStack S; //使用栈存储
int i,j,count = 0;
InitStack(S); //初始化栈

for(i = 0; i < G.vexnum; i ++)
if(indegree[i]==0)
Push(S,i);//把入度为零的节点压栈

printf("拓扑序列如下:\n");
while(!StackEmpty(S)){
Pop(S,i);
printf("%c\n",G.Vertex[i]);
count ++;

for(j = 0; j < G.vexnum; j ++){
if(G.AdjMatrix[i][j]!=0){
indegree[j]--; //删除出栈结点向对应的边,让其相邻结点的入度-1
if(indegree[j]==0) //如果入度为0,进栈
Push(S,j);
}
}
}

if(count < G.vexnum)
printf("此图有环存在!\n");
}

拓扑排序可以判断图中是否存在环


逆拓扑排序:根据拓扑排序的思路,如果用逆邻接表来存储图,先输出出度为为0的结点,自然构成了逆拓扑排序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void DFS(MGraph* G, int i)
{
int j;

//1.处理起始点

visitedDFS[i] = 1;//2.将已访问的结点标志成1

//2.由起始点开始,对后续结点进行操作
for (j = 0; j < G->vexnum; j++)//依次搜索vi的邻接点
{
if (G->AdjMatrix[i][j] == 1 && visitedDFS[j] == 0)
//当满足有边且未被访问过时,递归调用去查找该邻接点
{
DFS(G, j);//注意此处的G已经是指针类型,不需要再&G
}
}
printf("%c", G->Vertex[i]);//逆拓扑排序,在出栈时输出
}

如此,DFS当然也可以用于拓扑排序,只要用队列来存储访问到的结点,就是拓扑排序

不如说,利用队列+DFS的方法是代码量最少实现拓扑排序的办法

时间复杂度:邻接表:O(V+E) 邻接矩阵:O(V²)


判断图中是否有环

DFS

思路如下,在利用DFS遍历节点时,将遍历节点的visitedDFS[i]数组设置为-1,表示正在访问,如果在之后的递归过程中发现有节点的visitedDFS[i]为-1,则表示有环

这种方法只能用于有向图,如果要用DFS检测无向图的环,需要一个parent数组记录其父节点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
void DFSTraverse(MGraph* G)
{
int i;
//初始化"标志"数组为0,代表未访问
for (i = 0; i < G->vexnum; i++)
{
visitedDFS[i] = 0;
}
for (i = 0; i < G->vexnum; i++)
{
if (visitedDFS[i] == 0)
{
int x;
x= DFS(G, i);//注意此处的G已经是指着类型,不需要再&G
if (x == false) {
printf("false");
}
}
}
}
/*深度优先遍历DFS*/
int visitedDFS[VertexMax];//定义"标志"数组为全局变量
bool DFS(MGraph* G, int i)
{
int j;
//1.处理起始点
if (1 == visitedDFS[i])
return true;
if (-1 == visitedDFS[i])
return false;
visitedDFS[i] = -1;
//2.由起始点开始,对后续结点进行操作
for (j = 0; j < G->vexnum; j++)//依次搜索vi的邻接点
{
if (G->AdjMatrix[i][j] == 1)//当满足有边时,递归调用去查找该邻接点
//此处要把visitedDFS[j] == 0的条件删掉,因为有向无环图是不可能递归到父节点的
{
DFS(G, j);//注意此处的G已经是指针类型,不需要再&G
if (DFS(G,j) == false) {
return false;
}
}
}
visitedDFS[i] = 1;
return true;
}

拓扑排序

在上一小节中已经使用了拓扑排序来判断是否存在有向环了,而使用拓扑排序同样可以判断一个无向图中是否存在环,只要将入栈条件从度为0的结点,改成度为0或1的结点即可


并查集

并查集可以找到无向图的环,在最小生成树的克鲁斯卡尔算法(使用了边集数组)中,就已经使用了并查集来寻找图中的环


总结
无向图:

  • DFS(需要数组记录父节点)
  • 拓扑排序(将度为0和1的结点压入栈)
  • 并查集(克鲁斯卡尔算法)

有向图:

  • DFS(给visit数组多设置一个状态变量表示正在搜索)
  • 拓扑排序(将度为1的结点压入栈)

关键路径

AOE网(Activity On Edge NetWork):在带权有向图中若以顶点表示事件,有向边表示活动,边上的权值表示该活动持续的时间,AOE网通常用于 估算事件/工程完成的工期(时间)

从定义上来看,很容易看出两种网的不同,AOV网的活动以顶点表示,而AOE网的活动以有向边来表示,AOV网的有向边仅仅表示活动的先后次序。纵观这两种网图,其实它们总体网络结构是一样的,仅仅是活动所表示的方式不同,因此可以猜想从AOV网转换成AOE网应该是可行的

关键路径:在AOE网中仅有一个入度为0的点,称为开始顶点,它表示整个工程的开始,也仅有一个出度为0的点,称为结束顶点,代表整个工程的结束,这两个点之间的距离有很多条,在所有路径中,具有最大路径长度的路径称为关键路径,而把关键路径上的活动称为关键活动

所以求关键路径非常简单,只需要在图中找出权值和最大的一条或多条路即可,关键活动的最早和最晚发生时间一致,而非关键活动的最晚发生时间只需要和关键活动同时发生就行了

image-20220523160028568

  1. 事件$v_i$的最早发生时间ve(i):事件$v_i$的最早发生时间是等待$v_i$之前的所有活动都完成,所以ve(i)是从源点到vi的最长路径长度。起始点(源点)的最早发生时间为0。($v_k$是$v_i$的前驱事件)
    所以可以推出:
    ve(0) = 0;(源点的最早发生时间为0)
    i∈[1,n]时,ve(i) = Max{ ve(k) + weight(k->i)} ;
  2. 事件$v_i$的最晚发生时间vl(i):不拖延工期的前提下,事件vi被允许的最晚发生时间。($v_k$是$v_i$的后续事件)活动ai=<$v_i$,$v_k$>,$v_i$的最晚发生时间,取决于这个活动被允许的最晚结束时间,而所被允许的最晚结束时间也正是vk的最晚开始时间,也就是vl(k),所以我们就得到等式:
    最晚发生时间vl(i)+ 活动持续时间weight = 最晚结束时间vl(k)
    所以可以推出:
    vl(n-1) = ve(n-1);(汇点的最早发生时间最晚发生时间相等)
    i∈[0,n-2]时,vl(i) = Min{ vl(k) - weight(i->k)} ;

步骤:

  1. 求出事件最早发生时间ve[i]:顺拓扑序
  2. 求出事件最晚发生时间vl[i]:逆拓扑序
  3. 通过ve[i]和vl[i]计算出活动最早开始时间(e) 与 活动最晚开始时间(l)
  4. e(i)==l(i)则当前i所指向的活动是关键活动(Critical Activity)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
void CriticalPath(ALGraph *G)
{
int i;
int j,k;// <Vj,Vk>
int e,l;//活动最早开始时间/活动最晚开始时间
int topo[VertexMax];//拓扑数组,用于存储拓扑排序结果(存储内容是每个结点的坐标)
int ve[VertexMax]; //事件vi的最早发生时间
int vl[VertexMax]; //事件vi的最晚发生时间
struct ArcNode *p;

//1.调用拓扑排序,检测图是否存在环
if(!TopologicalSort(G,topo))//若拓扑排序成功,topo数组也将处理完毕
return;

//2.正拓扑排序,求出事件最早发生时间
for(i=0;i<G->vexnum;i++)
ve[i]=0;//所有ve都初始化为0
for(i=0;i<G->vexnum;i++)
{
j=topo[i];//j为起始点,k为终点
p=G->AdjList[j].firstarc;//用指针p去依次寻找j的每一个邻接点
while(p)
{
k=p->adjvex;
if(ve[k]<ve[j]+p->weight)
//根据j的邻接点k,不断更新ve[]的值(选出最大值,原理类似于选择排序)
{
ve[k]=ve[j]+p->weight;
}
p=p->next;
}
}
//3.逆拓扑排序,求出事件最迟发生时间
for(i=0;i<G->vexnum;i++)
vl[i]=ve[G->vexnum-1];//所有vl都初始化为ve[G->vexnum-1]
for(i=G->vexnum-1;i>=0;i--)
{
j=topo[i];
p=G->AdjList[j].firstarc;//让p去依次查找邻接点
while(p)
{
k=p->adjvex;
if(vl[j]>vl[k]-p->weight)
//根据j的邻接点k,不断更新vl[]的值(选出最小值,原理类似于选择排序)
{
vl[j]=vl[k]-p->weight;
}
p=p->next;
}
}
//4.计算e和l,通过判断e是否等于l确定该活动是否是关键活动,从而确定关键路径
for(i=0;i<G->vexnum;i++)
{
p=G->AdjList[i].firstarc;//让p去依次查找邻接点
while(p)
{
j=p->adjvex;
e=ve[i];//计算活动最早开始时间 e
l=vl[j]-p->weight;//计算活动最晚开始时间 l


if(e==l)//如果e=l,说明该活动是关键活动
{
//把每个关键活动输出,即是关键路径
printf("\t%c->%c(%d)\n",G->AdjList[i].vertex,G->AdjList[j].vertex,p->weight);
}
p=p->next;
}
}
}