🌞欢迎来到数据结构的世界
🌈博客主页:卿云阁💌欢迎关注🎉点赞👍收藏⭐️留言📝
🌟本文由卿云阁原创!
🙏作者水平很有限,如果发现错误,请留言轰炸哦!万分感谢!
目录
图的基本概念
图G由顶点集V和边E组成。顶点数(图的阶)和边数。V一定是非空集。
我们国家的铁路网络,其实就是一个图,我们可以把车站理解成顶点(V),各个车站的铁路其实就是边(E)。手机微信的好友关系也可以看成是图,如果两个人是好友关系就有边,微博上,如果一个明星被用户关注,就相当于一个有向的箭头。
有向图和无向图的表示
好友是互相的(无向图-----边),关注是(有向图---弧,v是弧尾,w是弧头)
简单图和多重图的表示
简单图 ① 不存在重复边;(不需要和一个人加两次好友)
② 不存在顶点到自身的边;(微信中不能自己加自己好友)
数据结构课程只探 讨 “简单图”
顶点的度,入度和出度
对于有向图来说:顶点的度TD(v)=ID(v)+OD(v)
例如在下面的例子中:顶点的度TD(A)=ID(A)+OD(A)=5
入度之和=出度之和=弧数
对于无向图来说:度之和=边的2倍
在微信里面如何知道一个人是否是社交达人,他加的好友越多说明他越有可能是一个交际达人(一个顶点连接的边有多少条),在微博里面如何知道一个人是否是微博大V,就是看关注他的人有多少,有多少弧尾是连接着这条节点的。
顶点和顶点之间关系的术语
路径:
左图: 顶点A到顶点D的路径就可以表示成-Vb,Vb,Vd
右图: 顶点A到顶点D的路径就可以表示成-Va,Vb,Vc,Vd(与方向有关)
回路:
左图: Vb,Vd,Ve
右图: Va,Vc,Vb
简单路径:
左图: A->D:Va,Vb,Ve,Vd A->D:Va,Vb,Ve,Vb,Vd(不是简单路径)
简单回路
路径长度
点到点的距离(最短的路径)
连通:(无向图)
左图: Vf和其它的顶点都是不连通的。
强连通:(有向图)
右图: 顶点A和顶点B是强连通的。
连通图(无向图)和强连通图(有向图)
研究图的内部子图
挑选几个顶点几条边构成的图---子图
子图包含了所有的顶点---生成子图
连通分量(描述无向图)
左图中我们可以把它分成3个连通分量,每个连通分量都是原图的一个子图,并且子图都是连通的,这些子图除了连通之外还有一个特性,这个子图中包含尽可能多的顶点和边。比如我们国家的铁路图我们可以把它分成三个连通分量。
强连通分量(描述有向图)
生成树 (连通图)
包含全部顶点,边要尽可能的少。
生成森林(非连通图)
这两个概念是有现实意义的,比如6个村子,找到成本最低的修路的方案。我们就可以由这个图找到其所有的生成树(保证了连通),再找到最优的方案。比如我们要看一下每一条路修起来的成本是多少。
边的权、带权图/网
带权路径长度:比如北京到上海的路径长度就是800。
几种特殊的图
无向完全图,有向完全图
稀疏图和稠密图
注意:有向树不是一个强连通图
邻接矩阵法
比如左图(无向图)我们要求B的度就是看那一行非0元素的个数。
比如右图(右向图)我们要求A的出度就是看那一行非0元素的个数。入度就是看那一列非0元素的个数。
采用邻接矩阵法存储图(MGraph)储需要定义一个存储节点的数组 int vex[MaxVexnum];还需要定义一个存储边的关系的二维数组 int arc[MaxVexnum][MaxVexcnum];节点的数量vexnum,边的数量arcnum。
#define MaxVexnum 2 #define INFINITY typedef struct { int vex[MaxVexnum]; int arc[MaxVexnum][MaxVexnum]; int vexnums,arcnums; }MGraph; int main() { MGraph G; return 0; }
比如我们要把第一行和第四列的元素相乘,a1,2这个元素等于1说明从A到B这个节点是有一条边的,a2,4这个元素等于1说明从B到D这个节点是有一条边的,这两个元素的乘积等于1说明我们可以找到一条路径这条路径是从A到B到D的,从A到D如果路径长度为2的话我们只能找到1条。
邻接表法
邻接矩阵的空间复杂度高,所以这一小结我们将将介绍邻接表法,它是采用顺序存储加链式存储的方法实现的。
定义一个结构体存放节点信息(ArcNode)
边所指向的节点的信息 int adjvex
指向下一个边的指针 struct ArcNode *firstarc
定义一个结构体存放顶点信息(VNode)
顶点的信息 int data;
指向第一个边的指针 ArcNode *firstarc
定义一个结构体存放图(ALGraph)
所有顶点的信息 VNode vertexs[MaxVexnum]
指当前的顶点数和边数 vexnum,arcnum
typedef char VertexType; typedef struct ArcNode { int adjvex; //该弧所指向的顶点的位置 struct ArcNode * nextarc; //指向下一条弧的指针 }ArcNode; typedef struct VNode { VertexType data; //顶点信息 ArcNode *firstarc;//指向第一条依附该顶点的弧的指针 }VNode; typedef struct { VNode vertexs [MAX_VERTEX_NUM]; int vexnum, arcnum; //图的当前顶点数和弧数 }ALGraph;
我们也可以用邻接表法来存储有向图,有一条弧是从A指向B所以A后面接看一个数值是1的节点,即后面的每一个节点实际上是贡献出度的节点的信息。
在无向图中每一条边在邻接表中都会对应两个节点,所以边界点的数量实际上是边的数量的两倍。
如何求顶点的度,入度和出度呐?
对于无向图来说要求顶点的度我们只要遍历这个顶点的边链表。有多少个边节点,它的度就是多少。
对于有向图来说要求顶点的出度我们只要遍历这个顶点的边链表。有多少个边节点,它的度就是多少。(从当前节点出去的弧)
对于有向图来说要求顶点的入度我们只要遍历整个顶点的边链表。(缺点)
由于各个边在这个链表中的先后顺序是任意的,所以邻接链表的表示方式并不唯一,邻接矩阵的表示是唯一的。
十字链表法(有向图),邻接多重图(无向图)
邻接矩阵的主要问题是空间复杂度太高,邻接链表的缺点是当存储有向图的时候求顶点的入度很麻烦。
下面这个有向图中一共有4个节点,A B C D,这几个节点的信息会分别存储在数组的0,1,2,3这个位置,对与A来说有一条弧是A指向B的,还有一个节点是A指向C的,它指向的第一条弧的信息是从0指向1的(A指向B的),然后找弧尾相同的下一个节点,这个节点表示从0号指向2号(A指向C的)。还有两条弧是其它节点指向A的,需要顺着A这个节点的橙色区域向后找2,这条弧的信息是从2指向0的(C指向A的),顺着橙色指针继续向后找,就可以找到下一条弧,这条弧的信息是从3指向0的(D指向A的)。
所以我们需要两个结构体,第一个是顶点的信息
包括:
数据域 data
作为弧头的第一条弧 firstin
作为弧尾的第一条弧 firstout
第二个是弧结点的信息
包括:
弧尾编号
弧头编号
权值
弧头相同的下一条弧
弧尾相同的下一条弧
当我们顺着绿色顶点向后找的话,从当前节点往外发射的所有的弧,如果我们顺着橙色 就可以找到指向该节点的边。
邻接矩阵的主要问题是空间复杂度太高,邻接链表的缺点是每一条边会对应两个冗余信息,如果想删除边的话,需要删除这样两份数据,比如我们要删除A节点,处理要删除A节点和后面链表上的节点之外,我们还要删除冗余数据。
对于A这个节点,它和B和D有两条边,我们顺着指针向后找,可以找到0号到1号的这条边,也就是A和B相连的一条边,顺着橙色指针向后找就可以找到0号到3号的这条边,也就是A和D相连的一条边的信息。
对于B这个节点,它和A和C,E有两条边,找到A和B相连的指针,B是存在右边这个位置的,然后我们顺着绿色指针继续往后寻找。我们现在要删除AB相连的这条边,只需要修改两个指针的值即可,假设我们要删除E的话,除了要删除E之外,还要删除和E相连的边的信息,
图的基本操作
Adjacent(G,x,y) :判断图里面是否存在x,y这样的边或者弧。
如果存储的是无向图采用邻接矩阵存储,假设判断B和D之间是否有边,判断arc[B][D]是否等于1即可。时间复杂度O(1)。
采用邻接表可以检查B的边节点是否有D,最好的情况下应该是第一个元素,此时时间复杂度是O(1),最坏的情况是遍历完整个边节点都没有发现所找的节点,与B相连的边最多有n-1条此时时间复杂度是O(V)。
Neighbors(G,x)图G中与结点x邻接的边。
如果存储的是无向图采用邻接矩阵存储,找和某一个结点邻接的边,只需要遍历这一行或者这一列,然后把元素对应是1的边列出来,此时时间复杂度是O(V)。
采用邻接表可以遍历边节点,最好的情况下应该是边节点的链表只有一个元素,此时时间复杂度是O(1),最坏的情况是边节点有n-1个,此时时间复杂度是O(V)。
如果存储的是有向图采用邻接矩阵存储,找和某一个结点邻接的出边,只需要遍历这一行,找入边的话遍历这一列,需要然后把元素对应是1的边列出来,此时时间复杂度是O(V)。
采用邻接表找出边的话和刚才一样,找入边,就需要遍历整个邻接表的边节点,此时时间复杂度是O(E)。这里就无法判断那种情况更好,不知道E的大小。
InsertVertex(G,x):在图G中插入结点x
如果存储的是无向图采用邻接矩阵存储,只需写入顶点的相关信息,因为存放结点的二维数组一开始就已经初始化了,此时时间复杂度是O(1)。
采用邻接表存储,只需写入顶点的相关信息,指针置为NULL,此时时间复杂度是O(1)。
DeleteVertex(G,x):在图G中删除结点x
如果存储的是无向图采用邻接矩阵存储,我们删除C之后,要清空C对应这一行这一列的元素,可以在顶点的结构体中增加一个bool类型的值用于表示整个结点是否是一个空节点,只需要修改一行或者一列的数据,此时时间复杂度是O(V)。
采用邻接表存储,删除C之后,然后删除与C结点的链表,还要在相连结点的边表中,找到C的信息,然后删除。最好的情况是C结点没有连边,此时时间复杂度是O(1),。最坏的情况是C结点连了尽可能多的边,此时时间复杂度是O(E)。
如果存储的是有向图
采用邻接表存储,我们要删除当前节点往外发射的边,只需要删除后面的链表,如果要删除入边,只能遍历整个邻接表。
AddEdge(G,x,y)增加边
如果存储的是无向图采用邻接矩阵存储,此时时间复杂度是O(1)。
采用邻接表存储,比如我们要添加C到F的边,我们需要在C和F边链表的后面添加信息(可以采用头插法),此时时间复杂度是O(1)。
FirstNeighbor(G,x)找到指定节点x的第一个邻接点。
如果存储的是无向图采用邻接矩阵存储,从左到右进行扫描,找到第一个1,此时时间复杂度是O(1)-O(V)。
采用邻接表存储,找到边节点链表的第一个节点,此时时间复杂度是O(1)。
如果存储的是有向图采用邻接矩阵存储,找到某一个节点的出边就需要扫描一行,找入边就需要扫描一列,此时时间复杂度是O(1)-O(V)。
采用邻接表存储,找出边是很方便的,但是找入边是很麻烦的,最好的情况就是第一个元素就是指向该顶点的一条边,此时时间复杂度是O(1),最坏的情况就是有可遍历完所有的元素都找不到。
NextNeighbor(G,x,y)找到除了节点y,x的下一个邻接点。
如果存储的是无向图采用邻接矩阵存储,从左到右进行扫描,最好的情况是下一个元素就是,此时时间复杂度是O(1)。
采用邻接表存储,找到边节点链表的第一个节点,此时时间复杂度是O(1)。
图的广度优先遍历算法(BFS)
由于树本身就是一种特殊的图,我们之前学过树的广度优先遍历(层序遍历)。
从根节点出发,找到和根节点相连的所有节点,也就是2,3,4节点,再从2,3,4节点出发找到与他们相连的其它节点,也就是5,6,7,8节点,这样就可以依次逐层找到树里面的节点,并且查找这些l节点的时候尽可能的横向去找。
其实和图是类似的,比如我们从2号节点出发,开始广度优先遍历,首先访问2号节点,通过2号节点又可以找到下一层的1和6号节点,所以接下来要访问1和6号节点,接下来访问5,3,7号节点,最后访问4和8号节点。
下面我们看一下两者的区别在哪里?
都需要找到与特定节点相邻的下一个节点,对于树来说很简单就是找它的孩子,对于图来说,可以通过之前的基本操作来完成。
图存在回路,比如与6号相连的节点有2,3,7。我们可以设置一个标记来表示节点是否被访问过。
bool visited[MaxVertexNum]是一个访问标记数组,下标从1开始,初始情况都为false。
假设我们从2号顶点出发开始访问(visit(v)),然后把2号节点对应的数组元素置为true(visited[v]=true),表示它已经被访问过了,然后让2号节点入队(EnQueue(v))(队头指针指向2号节点)。
如果队列不空的情况下(!isEmpty(Q)),让2号节点出队(DnQueue(Q,v)),下面我们要找到与2号节点相连的所有节点,也就是1和6节点,由于1和6号节点没有被访问过(!visited[w]),所以访问1号节点,并把他的标记置为true,让1号节点入队。在找与6号节点相连的节点,由于2号节点已经被访问过了,所以不需要进行其它的处理,对于5号节点,标记为false,访问,修改标记同时把5号节点放到队尾,此时就完成了1号节点的for循环,此时处理的就是6号节点,6号节点出队,3号节点和6号节点入队,接下来处理5号节点,5号节点出队,接下来处理3号节点,3号节点出队,4号节点入队,接下来处理7号节点,7号节点出队,8号节点入队。
从顶点3出发找相邻节点的时候,我们是按照序号递增的次序排列的,但是存储结构不同的话,找到的顺序可能不一样,图的邻接矩阵的表示方式是唯一的,从3号节点出发找到的顺序应该是4,6,7。图的邻接表的表示方式是不唯一的。
如果我们在刚才的图中增加3个节点,并且这3个节点与其它节点是不连通的,无法遍历所有的节点,visited数组记录了节点是否被访问的信息,当我们第一次调用了BFS算法后可以检查一下,这个数组中能不能找到值为false的节点,如果能找到就从该顶点出发再调用BFS这个函数,所以我们可以再定义 一个函数BFSTraverse(Graph G),
- 首先把visited数组全部置为false,
- 然后初始化一个辅助队列InitQueue(Q),
- 然后遍历这个数组,然后用for循环扫描整个数组,找到第一个为false的节点,就从这个节点出发调用BFS算法。
#include <stdio.h> #include <stdbool.h> #define MaxVertexNum 10 bool visited[MaxVertexNum]; // 定义队列及其相关操作 typedef struct { int data[MaxVertexNum]; int front; int rear; } Queue; void InitQueue(Queue *Q) { Q->front = Q->rear = 0; } void EnQueue(Queue *Q, int x) { Q->data[Q->rear] = x; Q->rear = (Q->rear + 1) % MaxVertexNum; } int DeQueue(Queue *Q) { int x = Q->data[Q->front]; Q->front = (Q->front + 1) % MaxVertexNum; return x; } bool IsEmpty(Queue *Q) { return Q->front == Q->rear; } // 定义图的结构体 typedef struct { int vertexnum; int matrix[MaxVertexNum][MaxVertexNum]; // 邻接矩阵 } Graph; void InitGraph(Graph *G, int n) { G->vertexnum = n; int i, j; for (i = 0; i < n; i++) { for (j = 0; j < n; j++) { G->matrix[i][j] = 0; // 初始化邻接矩阵 } } } void AddEdge(Graph *G, int u, int v) { G->matrix[u][v] = 1; // 添加边,这里以有向图为例 } int FirstNeighbor(Graph *G, int v) { int i; for (i = 0; i < G->vertexnum; i++) { if (G->matrix[v][i] == 1) { return i; } } return -1; } int NextNeighbor(Graph *G, int v, int w) { int i; for (i = w + 1; i < G->vertexnum; i++) { if (G->matrix[v][i] == 1) { return i; } } return -1; } void visit(int v) { printf("%d ", v); } void BFS(Graph *G, int v) { visit(v); visited[v] = true; Queue Q; InitQueue(&Q); EnQueue(&Q, v); while (!IsEmpty(&Q)) { int u = DeQueue(&Q); int w; for (w = FirstNeighbor(G, u); w >= 0; w = NextNeighbor(G, u, w)) { if (!visited[w]) { visit(w); visited[w] = true; EnQueue(&Q, w); } } } } void BFSTraverse(Graph *G) { int i; for (i = 0; i < G->vertexnum; i++) { visited[i] = false; } for (i = 0; i < G->vertexnum; i++) { if (!visited[i]) { BFS(G, i); } } } int main() { int n = 6; // 图的顶点数量 Graph G; InitGraph(&G, n); // 添加边 AddEdge(&G, 0, 1); AddEdge(&G, 0, 2); AddEdge(&G, 0, 3); AddEdge(&G, 1, 4); AddEdge(&G, 2, 3); AddEdge(&G, 2, 4); AddEdge(&G, 3, 5); AddEdge(&G, 4, 5); BFSTraverse(&G); return 0; }
下面我们来分析一下这个算法的效率(空间复杂度和时间复杂度)
空间复杂度主要是我们的队列,最坏的情况是,我们从1号节点出发,其它的节点都和1相连,所以访问1号节点的时候需要把所有节点放到辅助队列中,我们需要访问每一个顶点,同时我们还要探索这个顶点的每一条边,所以时间开销主要是访问节点,探索边。
比如4号节点第一次被访问是是从3号节点出发的边过去的,而不是从7或者8过去的,对于这n个节点的图来说我们总共标记了n-1条边,如果把其它的边去掉,这个图就变成了树,没有回路存在了,这就是这个图的广度优先生成树,是根据广度优先搜索的过程得来的,
图的深度优先遍历算法(DFS)
类似树的先根遍历,首先访问1号节点,2是1的子树,接下来访问2号节点,5是2的一棵子树接下来访问5号节点,接下来访问6号节点,接下来访问3号节点,访问4号,7号,8号节点。
假设我们从2号节点出,首先访问2号节点,标记置为true,for循环找到和2相邻的节点,和2相邻的是1,1号节点没被访问过。访问1号节点,标记置为true。for循环找到和1相邻的节点,和2相邻的是2节点,由于2被访问过了,接下来找到5号节点。
由于和5号节点相邻的节点全部访问过,所以5号节点的for循环什么也不做,返回上一层的递归调用, 也就是1号节点这一层,由于之前的5号节点已经是1号的最后一个邻接点,所以1号节点的for循环执行结束。返回2号节点这一层。
之前已经处理了2号邻接点的第一个邻接点,也就是1号,找到下一个和2号邻接的节点,也就是6号顶点,6号节点没有被访问过接下来对6号节点执行DFS操作,访问之后,找到与6号相邻的其它节点,2号被访问过,所以应该访问3号,对3号节点执行DFS操作,对4号节点执行DFS操作,对7号节点执行DFS操作,对8号节点执行DFS操作。
#include <stdio.h> #include <stdbool.h> #define MaxVertexNum 10 bool visited[MaxVertexNum]; // 定义图的结构体 typedef struct { int vertexnum; int matrix[MaxVertexNum][MaxVertexNum]; // 邻接矩阵 } Graph; void InitGraph(Graph *G, int n) { G->vertexnum = n; int i, j; for (i = 0; i < n; i++) { for (j = 0; j < n; j++) { G->matrix[i][j] = 0; // 初始化邻接矩阵 } } } void AddEdge(Graph *G, int u, int v) { G->matrix[u][v] = 1; // 添加边,这里以有向图为例 } int FirstNeighbor(Graph *G, int v) { int i; for (i = 0; i < G->vertexnum; i++) { if (G->matrix[v][i] == 1) { return i; } } return -1; } int NextNeighbor(Graph *G, int v, int w) { int i; for (i = w + 1; i < G->vertexnum; i++) { if (G->matrix[v][i] == 1) { return i; } } return -1; } void visit(int v) { printf("%d ", v); } void DFS(Graph *G, int v) { visit(v); visited[v] = true; int w; for (w = FirstNeighbor(G, v); w >= 0; w = NextNeighbor(G, v, w)) { if (!visited[w]) { DFS(G, w); } } } void DFSTraverse(Graph *G) { int i; for (i = 0; i < G->vertexnum; i++) { visited[i] = false; } for (i = 0; i < G->vertexnum; i++) { if (!visited[i]) { DFS(G, i); } } } int main() { int n = 6; // 图的顶点数量 Graph G; InitGraph(&G, n); // 添加边 AddEdge(&G, 0, 1); AddEdge(&G, 0, 2); AddEdge(&G, 0, 3); AddEdge(&G, 1, 4); AddEdge(&G, 2, 3); AddEdge(&G, 2, 4); AddEdge(&G, 3, 5); AddEdge(&G, 4, 5); DFSTraverse(&G); return 0; }
最小生成树
假设有一个城市P城,周围规划了5个地方,地方之间有可能有这样几种修路的方案,路上的数字表示修这样的一条道路需要的成本,比如修一条P城到学校的路需要1块钱,学校到矿场需要5元,我们没有必要把所有的道路都修起来,我们需要确定一个修路方案,确保所有路之间都是可以到达的,但是成本花销要尽可能的低,比如现在我们有两种修路方案,但是现在还有没有更好的修路方案呐?(确保连通且成本较低)
权值最小的生成树就叫做最小生成树
如果这个连通图本身就是一棵树,那么最小生成树就是其本身。
Prim算法
从一个节点出发,每一次都将代价最小的新节点纳入生成树即可。
Kruskal算法
挑选权值最小的边,然后让这两条边的两头连通
时间复杂度
最短路径问题(BFS算法)
“G港”是个物流集散中⼼,经常需要往各个城市运东⻄,怎么运送距离最近?——单源最短路径问题
各个城市之间也需要互相往来,相互之间怎么⾛距离最近?——每对顶点间的最短路径
比如我们现在从2这个节点出发寻找最短路径,可以找到和它相邻的顶点1和6,显然距离是1,在找到下一层相邻的节点5,3,7此时的距离是2, 在找到下一层相邻的节点4,8时的距离是3。
代码实现
我们在原来代码的基础上增加了两个数组,d[ ]是用来记录到原始顶点的最短的长度,path[ ]用于记录最短路径直接前驱。
比如我们现在从2号节点出发,u=2,初始化时我们把d数组里的值全部设置成无穷,path数组里的值设置成-1,接下来把d[2]=0(2号节点到2号节点的路径长度为0),标记2号顶点已经被访问过,再放入队列中,接下来执行while循环,如果队列非空,弹出队头元素,
然后从2号节点出发找到所有相邻的节点,如果该节点没有被访问过,就更改最短路径的长度。
比如现在我们想知道2号节点到8号节点的最短路径,从d[8]数组可以反应最短路径应该时3,通过path[8]=7,我们可以知道前驱时7号节点,path[7]=6,我们可以知道前驱时6号节点,path[6]=2,我们可以知道前驱时2号节点。
广度优先生成树一定是高度最小的生成树
最短路径问题(Dijkstra)
初始化: 假设我们要找到V0到其它节点的最短路径,首先我们需要定义三个数组,final[ ]表示有没有找到V0到其它节点的最短路径,一开始把final[0]设置成true,因为V0到V0的最短路径就是其本身,dist[ ]表示当前找到的最短路径的长度,比如一开始我们能找到的V0到V1的最短路径是10,V0到V4的最短路径是5,所以dist[1]=10,dist[4]=5,由于不存在直接从V0到V2和V3的边所以我们把dist[2],dist[3]的值设置成无穷。path[ ]用于记录最短路径的直接前驱,目前我们能确定的比较好的路径是从V0到V1所以我们把path[1]=0。
表示我现在已经可以确定对于4这个节点来说它的最短路径是5直接前驱是0。 因此我们现在就确定了V0到V4的最短路径,接下来我们要检查,所有和V4相邻的顶点(V1,V2,V3),如果从V4过来有没有可能比之前的路劲更短呐,对于V1来说我们之前可以确定路径长度比较好的是为10,也就是从V0过来,如果V1节点的前驱是V4的话,我们就能找到长度为8的路径,此时我们会把dist[1]=8,path[1]=4。对于V2来说,我们之前没找到从V1到V2的路径,但是现在如果我们经过V4,再到达V2的话,我们就能找到(V0-V4-V2)也就是总长度为14的路径,所以我们把dist[2]=14,path[2]=4,对于V3来说,我们之前没找到从V1到V3的路径,但是现在如果我们经过V4,再到达V3的话,我们就能找到(V0-V4-V3)也就是总长度为14的路径,所以我们把dist[3]=7,path[3]=4。
循环遍历所有节点找到目前还没找到最短路径,且dist最小的节点V3,令final[3]=true,检查V3是所有的邻接点(V2,V0),并且final值为false的节点V2。
对于V2来说,我们之前能找到的最短路径是14,前驱是4,但是现在如果我们经过V3,再到达V2的话,最短路径是13,前驱是3。
循环遍历所有节点找到目前还没找到最短路径,且dist最小的节点V1,令final[1]=true,检查V1是所有的邻接点(V4,V2),并且final值为false的节点V2。
对于V2来说,我们之前能找到的最短路径是13,前驱是3,但是现在如果我们经过V1,再到达V2的话,最短路径是9,前驱是1。
接下来只需要处理V2节点即可,我们把final[2]=true,此时找不多final为false的其它节点,所以我们不要再做其它操作。比如我们现在要找到V2这个数组的最短路径,dist[2]=9。
时间复杂度
从dist[]数组中找到最小的点应该是需要O(n)这个时间复杂度。我们还需要检查和Vi所有相邻的节点,如果采用邻接矩阵存储的话,就是要扫描和这个顶点相关的一整行,也需要O(n)的时间复杂度,所以每一轮的处理的复杂度是O(n)。总共是n-1轮处理。时间复杂度是O(n2)。
比如说我们玩的吃鸡的游戏,它会有一个毒圈,V2是安全区,V1是血包,从V0到V2会掉7点血,但是从V0到V1到V2会掉5点血。
Dijkstra 算法不适⽤于有负权值的带权图。
最短路径问题(Floyd算法)
动态规划问题是比较难理解的算法,我们会把一个大的问题的求解,分成多个阶段,比如我们利用上述算法实现各个顶点之间的最短路径(Vi到Vj的最短路径)。
我们可以先设置一个初始阶段,即我们不允许有中转,最短路径是?
如果允许在V0处中转。
如果允许在V0,V1处中转。
比如我们想求左图中各个节点之间的最短路径,我们可以先设置这样的两个矩阵,第一个矩阵就是这个图的邻接矩阵(A) 目前来看可以找到的各个节点之间的最短路径的路径长度,第一个矩阵就是最短路径对应是中转点path。刚开始的时候不允许有中转点,所以一开始的时候所有path的值都为-1。
如果允许在V0处中转的话,最短路径是什么?
求解方法很简单,我们需要遍历上一阶段留下来的矩阵A,对于矩阵A中的每一个元素我们都需要进行这样的检查,比如从V2到V1,不允许有中转点的时候,A[2][1]的长度是无穷,从V2到V0到V1,A[2][1]的长度是11,path[2][1]=0。
如果允许在V0,V1处中转的话,最短路径是什么?
之前我们能找到V0到V2的最短路径是13,并且中间没有任何的中转点,从V0到V1到V1,A[2][1]的长度是10,path[2][1]=1。
如果允许在V0,V1,V2处中转的话,最短路径是什么?
之前我们能找到V1到V0的最短路径是10,并且中间没有任何的中转点,从V0到V2到V0,A[1][0]的长度是9,path[1[0]=2。
时间复杂度
假设现在我们有5个顶点,分析过程与上面的过程类似,最终我们也得到了两个矩阵,最后我们看一下如何用这两个矩阵得到我们最终想要的结果,比如我们要找到V0到V4的最短路径是4,V0到V4中间有个中转点是V3,V0到V4中间有个中转点是V2,V2到V3中间有个中转点是V1。
Floyd算法可以用于负权值带权图Floyd 算法不能解决带有“负权回路”的图(有负权值的边组成回路),这种图有可能没有最短路径。(如下图所示,这条回路走的越多,路径越小)
拓扑排序
AOV网(用顶点表示活动的网,有向无环图)
下面这个图表示怎么去做番茄炒蛋并且把它吃掉,顶点表示活动,有向边表示顶点Vi必须先于顶点Vj进行,比如我么在洗番茄之前要先买菜,打鸡蛋之前也要买菜,切番茄除了有番茄之外还要准备厨具。
拓扑排序(找到做事的先后顺序)
做番茄炒蛋我们可以先准备厨具,或者先买菜,这里我们选择先准备厨具,接下来买菜,我们再选择先洗番茄,然后选择切番茄,接下来打鸡蛋,然后下锅炒,最后吃。
- 选择入度为0的顶点输出
- 删除该顶点和以这个顶点为起点的有向边
- 知道AOV网为空或者不存在无前驱的顶点(说明有回路)
每个AOV网都有一个或者多个拓扑排序序列。
代码实现
indegree[ ]用于记录当前顶点的入度。
print[ ]用于记录拓扑排序序列
Initstack用于记录度为0的节点
初始化的时候:
0号节点的入度为0, 1号节点的入度为1, 2号节点的入度为0, 3号节点的入度为2,4号节点的入度为2。
print[ ]全部初始化为-1,
检查所有入度为0的节点,把节点的下标放到栈里面(Push(S,i),定义一个count变量用于记录已经输出的顶点数,拓扑排序的过程就是不断的删除入度为0节点的过程,所以当栈不空时我们需要出栈(把2号出栈),print[count]=2,然后count++,接下来把和2号相连的节点的入度都减1,即--indegree[3],--indegree[4],此时栈非空,弹出栈顶元素,修改count的值,在for循环里处理和0号节点相邻的节点,只有1号节点,然后把1号节点的入读减1,此时1号节点的度为0,把1号节点入栈,出栈1号节点把它计入到print数组中,同时把3号节点的入度减1,由于3号节点的入读等于0,所以把3号节点入栈,然后出栈,3号节点把它计入到print数组中,接下来把4号节点的入读减1,然后把4号节点入栈,出栈4号节点把它计入到print数组中,此时while循环执行结束,此时count的值等于5(节点的数量),表示我们排序是成功的。
如何找到一个节点i相邻的边?
p=G.vertices[i].firstarc指向依附于i节点的第一条边,p,
p=p->nextarc向后遍历边节点,
v=p->adjvex表示该节点的数据域
bool TopologicalSort(Graph G) { InitStack(S); for(int i=0,i<G.vexnum,i++) if(indegree[i]==0) Push(S,i); int count=0; while(!isEmpty(S)) { Pop(S,i); print[count++]=i; for(p=G.vertices[i].firstarc;p;p=p->nextarc) { v=p->adjvex; if(!(--indegree[v])) Push(S,v); } } if(count<G.vexnum) return false; else return true; }
复杂度
逆拓扑排序(每次输出出度为0的节点)
代码实现
这个排序中我们需要实现删除处每一个节点的边,显然采用邻接矩阵存储会更好,这里补充一个新的数据结构,逆邻接表,邻接表的边接点存放的是往外发射的信息,逆邻接表中的边接点存放的是指向该节点的信息。
逆拓扑排序的实现(DFS算法)
我们在访问一个节点时候,需要把这个节点的visited数组置为true,当我们访问完这个顶点并且访问完与这个顶点所有相邻的顶点之后,我们会把这个顶点输出出来,刚开始我们会从0号节点为入口调用DFS函数,并且把0号节点的visited置为true,从0号节点出发找到第一个与它邻接的点1,1号节点没有被访问过所以进入下一层的DFS函数。从1号节点出发找到第一个与它邻接的点3,3号节点没有被访问过所以进入下一层的DFS函数。从3号节点出发找到第一个与它邻接的点4,4号节点没有被访问过所以进入下一层的DFS函数,4号节点找不到相邻的节点,所以4号节点什么也不会做,然后把4号节点打印输出,3号,1号,0号节点类似,此时for循环继续向后扫描找到下一个没有被访问的节点2。
DFS实现逆拓扑排序: 在顶点退栈前输出
关键路径
VOE网(顶点表示事件边表示活动)
首先我们需要花费两分钟的时间打鸡蛋,一分钟的时间洗番茄,之后还需要花费3分钟的时间切番茄,之后我们还需要花费两分钟的时间来炒菜。
活动是要持续一段时间的,而事件的发生是瞬间的。
只有开始事件发生后我们才能进行洗番茄或者打鸡蛋的活动。
只有打鸡蛋和切番茄活动结束后,我们才能开始进行可以炒了这个事件。
有的事件是可以并行的(比如打鸡蛋和洗番茄)
AOE网中只有一个入度为0的顶点叫做开始顶点(源点),只有一个出度为0的顶点叫做结束顶点(汇点),从源点到汇点的路径可能有多条,具有最大长度的路径叫做关键路径,关键路径上的活动叫做关键活动。关键路径的长度是完成整个工程的最短时间。
假设我现在很饿,我进入了一家饭店,我问老板,最快多久可以做?老板说现在马上,也就是开始这个事件可以在t=0的时候可以做。
最快多久可以切,厨师开始算了,从t=0开始我洗番茄需要1min,所以最快可以在1min之后开始切番茄。
最快多久可以炒,准备切好的番茄需要4min的时间,开炒之前还需要准备鸡蛋,打鸡蛋需要2min的时间,但是我们可以从t=0的时候打鸡蛋,所以到4min这个时间点是可以炒的。
最快多久可以吃,还需要2min进行炒菜,总共是需要6min的。
事件vk的最早发生时间ve(k)——决定了所有从vk开始的活动能够开工的最早时间
活动ai的最早开始时间e(i)——指该活动弧的起点所表示的事件的最早发生时间
比如切番茄最早可以从t=1的时候开始,炒菜这个活动最早可以从t=4的时候进行
事件vk的最迟发生时间vl(k)——它是指在不推迟整个工程完成的前提下,该事件最迟必须发生的时间。
活动ai的最迟开始时间l(i)——它是指该活动弧的终点所表示事件的最迟发生时间与该活动所需时间之差。
比如像打鸡蛋这个活动,它后面的时间V3必须在t=4的时候发生,打鸡蛋总共要消耗2min的时间,所以我们必须在t=2的时候打鸡蛋。我们虽然从0时刻开始做法,但是不需要立刻打鸡蛋,打鸡蛋这个事情我们可以向后拖2min。
活动ai的时间余量d(i)=l(i)-e(i),表⽰在不增加完成整个⼯程所需总时间的情况下,活动ai可以拖延的时间 若⼀个活动的时间余量为零,则说明该活动必须要如期完成,d(i)=0即l(i) = e(i)的活动ai是关键活动 由关键活动组成的路径就是关键路径
求关键路径的步骤
首先要要对这个AOE网进行拓扑排序,然后依次计算最早发生时间。
比如切番茄这个关键活动,如果我们能够用1min完成切番茄这个活动,完成番茄炒蛋总共需要4min的时间,如果我们能够用0.5min完成切番茄这个活动,这个关键活动就变成了分关键活动了。
如果我们把打鸡蛋的时间设置成4min,比如我们把切番茄缩短成1min,总工程还是需要6min,这个时候可以把多条路径上的活动时间减少(比如打鸡蛋和切番茄)。此外还可以只压缩炒菜的时间。
数据结构---图
于 2023-08-01 16:08:54 首次发布