26考研 | 王道 | 数据结构 | 第六章 图
第六章 图
文章目录
- 第六章 图
- 6.1. 图的基本概念
- 6.2. 图的存储
- 6.2.1. 邻接矩阵
- 6.2.2. 邻接表
- 6.2.3. 十字链表、临接多重表
- 6.2.4. 图的基本操作
- 6.3. 图的遍历
- 6.3.1. 广度优先遍历
- 6.3.2. 深度优先遍历
- 6.3.3 图的遍历与连通性
- 6.4. 图的应用
- 6.4.1. 最小生成树
- 6.4.2. 无权图的单源最短路径问题——BFS算法
- 6.4.3. 单源最短路径问题——Dijkstra算法
- 6.4.4. 各顶点间的最短路径问题——Floyd算法
- 6.4.5. 有向⽆环图应用--描述表达式
- 6.4.6. 有向无环图应用--拓扑排序
- 6.4.7. 关键路径
- 6.5 有向图的环的问题
- `DFS检测环的核心逻辑`
- `拓扑排序检测有没有环的核心逻辑`
6.1. 图的基本概念
(1)
无向图中边AB和BA是一个边,但是有向图中AB和BA是方向相反的两条边
关于C2(n-1)的理解呢,我认为是如果一个图有7个顶点,那么除去一个顶点,剩下6个顶点都可以通过路径长度为1的路径直接到达对方,那么在这6个顶点构成的子图中,需要有15(5+4+3+2+1)=6*5/2=(n-1)*(n-2)/2
条边满足这个条件,就等于C2(n-1),此时我们再把第7个节点和这6个点构成一张图,由于7和其他6个节点都不连通,所以是非连通图,但只要再加1条边就是连通图
总结来说就是对无向图n-1个顶点构成的图中最多有C2(n-1)边,这张图肯定是连通图,但是再加上第n个节点就不连通了,所以最多有C2(n-1)边
对于子图这一概念,有向图和无向图并无差别
连通分量是无向图的概念,强连通分量是有向图的概念
这个例子不好,不如6.1习题P13题
一个图的生成树可能并不唯一。
vlogv这个值也不是绝对的,稀疏和稠密是相对的
森林中,各个子图是极小的(边尽可能少但要保证连通),同时又是连通的
有向树并不是一个强连通图
6.2. 图的存储
6.2.1. 邻接矩阵
- 图的邻接矩阵(Adjacency Matrix) 存储方式是用两个数组来表示图。一个一维数组存储图中顶点信息,一个二维数组(称为邻接矩阵)存储图中的边或弧的信息。
-
对于无向图
- 度:第i个结点的度 = 第i行(或第i列)的非零元素个数;
-
对于有向图
-
出度:第i行的非零元素个数;
-
入度:第 i 列的非零元素个数。
-
第i个结点的度=第i行第i列非零元素的和
-
复杂度均为O(n),n为顶点个数
邻接矩阵法代码实现
#define MaxVertexNum 100 //顶点数目的最大值
typedef char VertexType; //顶点的数据类型
typedef int EdgeType; //带权图中边上权值的数据类型
typedef struct{VertexType Vex[MaxVertexNum]; //顶点表EdgeType Edge[MaxVertexNum][MaxVertexNum]; //邻接矩阵,边表int vexnum, arcnum; //图的当前顶点数和弧树
}MGraph;
有的题对角线也是0,表示没有这条路径
性能分析:
(1)空间复杂度:O(n2),只和顶点数相关,和实际的边数无关。
(2)适合用于存储稠密图。
(3)无向图的邻接矩阵是对称矩阵,可以压缩存储 (只存储上三角区或者下三角区)。
邻接矩阵的性质:
6.2.2. 邻接表
代码实现
#define MAXVEX 100 //图中顶点数目的最大值
type char VertexType; //顶点类型应由用户定义
typedef int EdgeType; //边上的权值类型应由用户定义/*边表结点*/
typedef struct EdgeNode{int adjvex; //该弧所指向的顶点的下标或者位置EdgeType weight; //权值,对于非网图可以不需要struct EdgeNode *next; //指向下一个邻接点
}EdgeNode;/*顶点表结点*/
typedef struct VertexNode{Vertex data; //顶点域,存储顶点信息EdgeNode *firstedge //边表头指针
}VertexNode, AdjList[MAXVEX];/*邻接表*/
typedef struct{AdjList adjList;int numVertexes, numEdges; //图中当前顶点数和边数
}
邻接表的特点:
- 对于无向图,存储空间为O(|V|+2|E|);对于有向图,存储空间为O(|V|+|E|)!;
- 更适合用于稀疏图;
- 若G为无向图,则顶点的度为该顶点边表的长度若G为有向图,则顶点的出度为该顶点边表的长度,计算入度需要遍历整个邻接表;
- 对于无向图计算度比较容易,只要遍历当前结点的链表就行
- 对于有向图来说,计算出度也比较容易,遍历当前结点链表,但是如果要计算入度那就是需要遍历整个邻接表,这是一个缺点
- 邻接表不唯一,边表结点的顺序根据算法和输入不同可能会不同。
邻接表对比邻接矩阵
6.2.3. 十字链表、临接多重表
【十字链表】
- 十字链表是有向图的一种链式存储结构。
O(v2)指的是邻接矩阵
#define MAX_VERTEX_NUM 20 //最大顶点数量struct EBox{ //边结点int i,j; //该边依附的两个顶点的位置(一维数组下标)EBox *ilink,*jlink; //分别指向依附这两个顶点的下一条边InfoType info; //边的权值
};
struct VexBox{VertexType data;EBox *firstedge; //指向第一条依附该顶点的边
};
struct AMLGraph{VexBox adjmulist[MAX_VERTEX_NUM];int vexnum,edgenum; //无向图的当前顶点数和边数
};
【邻接多重表】
邻接表存储无向图,每条边对应两份冗余信息,删除顶点,删除边等操作时间复杂度高
而邻接矩阵本身复杂度就比较高了
邻接多重表是无向图的另一种链式存储结构。
四种存储方法比较:
6.2.4. 图的基本操作
**Adjacent(G, x, y):**判断图 G 是否存在边 < x , y > 或 ( x , y )。
邻接矩阵:看二维数组对应位置是不是1 O(1)
邻接表:看x结点的链表里面有没有y O(1)~O(n-1)
**Neighbors(G, x):**列出图 G 中与结点 x 邻接的边。
邻接矩阵:遍历x对应行或者列都可以,把是1的顶点列出来 O(n)
邻接表:遍历边结点的链表即可
邻接表的:无向图和有向图的出边O(1)~O(n) 无向图入边是O(|E|)E是边的总数
**InsertVertex(G, x):**在图 G 中插入顶点 x 。
邻接表:完成结点在节点表的初始化就行 O(1)
邻接矩阵:完成结点在节点表的初始化就行 O(1)
**DeleteVertex(G, x):**从图 G 中删除顶点 x
邻接矩阵:可以在结构体中增加bool型变量表示这个顶点是否被删除,这样只需要把原来要删除的节点的行和列置为0即可 复杂度为O(n)
邻接表:最坏需要遍历所有的边 无向图 O(1)~O(|E|)
有向图 出边O(1)~O(|V|) 入边 O(|E|)
**AddEdge(G, x, y):**若无向边 ( x , y ) 或有向边 < x , y > 不存在,则向图 G 中添加该边。
邻接矩阵:对应位置写1 复杂度为O(1)
邻接表:对应节点链表头插或者尾插 O(1)
**RemoveEdge(G, x, y):**若无向边 ( x , y ) (x, y)(x,y) 或有向边 < x , y > <x,y><x,y> 存在,则从图 G 中删除该边。
邻接矩阵:O(1)
邻接表:O(1)~O(|V|)
**FirstNeighbor(G, x):**求图 G 中顶点 x 的第一个邻接点,若有则返回顶点号。若 x 没有邻接点或图中不存在 x,则返回 -1。
邻接矩阵:扫描x结点对应的行 复杂度为O(1)~O(n)
有向图的话,出边就要找行,入边就要很找列,复杂度和无向图一样
邻接表:找到对应节点链表的第一个结点返回即可 O(1)
有向图的话,出边还是O(1),入边还得遍历其他所有的边复杂度为 O(1)~O(|E|)
NextNeighbor(G, x, y):假设图 G 中顶点 y 是顶点 x 的一个邻接点,返回除 y 之外顶点 x 的下一个邻接点的顶点号,若 y 是 x 的最后一个邻接点,则返回 -1。
邻接矩阵:扫描x结点对应的行 复杂度为O(1)~O(n)
邻接表:找到对应节点链表的第一个结点之后的第二个节点返回即可 O(1)
**Get_edge_value(G, x, y):**获取图 G 中边 ( x , y )或 < x , y > 对应的权值。
**Set_edge_value(G, x, y, v):**设置图 G 中边 ( x , y )或 < x , y >对应的权值为 v。
剩下这两个和AddEdge(G, x, y)这个操作一样的时间复杂度,能否判断了边是否存在也就是找到了对应的边,自然可以判断权值
6.3. 图的遍历
6.3.1. 广度优先遍历
1、广度优先遍历 (Breadth-First-Search,BFS)要点:
(1)找到与一个顶点相邻的所有顶点;
(2)标记哪些顶点被访问过;
(3)需要一个辅助队列。
2、广度优先遍历用到的操作:
**FirstNeighbor(G, x):**求图 G 中顶点 x 的第⼀个邻接点,若有则返回顶点号;若 x 没有邻接点或图中不存在 x,则返回 -1。
**NextNeighbor(G, x, y):**假设图 G 中顶点 y 是顶点 x 的⼀个邻接点,返回除 y 之外顶点 x 的下⼀个邻接点的顶点号,若 y 是 x 的最后⼀个邻接点,则返回 -1。
3、广度优先遍历代码实现:
/*邻接矩阵的广度遍历算法*/
void BFSTraverse(MGraph G){int i, j;Queue Q;for(i = 0; i<G,numVertexes; i++){visited[i] = FALSE;}InitQueue(&Q); //初始化一辅助用的队列for(i=0; i<G.numVertexes; i++){//若是未访问过就处理if(!visited[i]){vivited[i] = TRUE; //设置当前访问过visit(i); //访问顶点EnQueue(&Q, i); //将此顶点入队列//若当前队列不为空while(!QueueEmpty(Q)){DeQueue(&Q, &i); //顶点i出队列//FirstNeighbor(G,v):求图G中顶点v的第一个邻接点,若有则返回顶点号,否则返回-1。//NextNeighbor(G,v,w):假设图G中顶点w是顶点v的一个邻接点,返回除w外顶点vfor(j=FirstNeighbor(G, i); j>=0; j=NextNeighbor(G, i, j)){//检验i的所有邻接点if(!visited[j]){visit(j); //访问顶点jvisited[j] = TRUE; //访问标记EnQueue(Q, j); //顶点j入队列}}}}}
}
4、复杂度分析:
(1)空间复杂度: 最坏情况,辅助队列大小为O|V|。
(2)对于邻接矩阵存储的图,访问|V|个顶点需要O|V|的时间,查找每个顶点的邻接点都需要 O|V|的时间,而总共|V|有个顶点,时间复杂度O(|V|2)。
(3)对于邻接表存储的图,访问|V|个顶点需要O|V|的时间,查找各个顶点的邻接点共需要O|E|的时间,时间复杂度为O(|V|+|E|)
复杂度分析不要去看代码的while循环,去分析访问顶点和边所需要花费的时间
5、⼴度优先⽣成树:⼴度优先⽣成树由⼴度优先遍历过程确定。由于邻接表的表示⽅式不唯⼀,因此基于邻接表的⼴度优先⽣成树也不唯⼀。
但是邻接矩阵生成的广度优先生成树是唯一的
6.3.2. 深度优先遍历
1、深度优先遍历(Depth First Search),也有称为深度优先搜索,简称为DFS。
代码实现:
bool visited[MAX_VERTEX_NUM]; //访问标记数组
/*从顶点出发,深度优先遍历图G*/
void DFS(Graph G, int v){int w;visit(v); //访问顶点visited[v] = TRUE; //设已访问标记//FirstNeighbor(G,v):求图G中顶点v的第一个邻接点,若有则返回顶点号,否则返回-1。//NextNeighbor(G,v,w):假设图G中顶点w是顶点v的一个邻接点,返回除w外顶点vfor(w = FirstNeighbor(G, v); w>=0; w=NextNeighor(G, v, w)){if(!visited[w]){ //w为u的尚未访问的邻接顶点DFS(G, w);}}
}
/*对图进行深度优先遍历*/
void DFSTraverse(MGraph G){int v; for(v=0; v<G.vexnum; ++v){visited[v] = FALSE; //初始化已访问标记数据}for(v=0; v<G.vexnum; ++v){ //从v=0开始遍历if(!visited[v]){DFS(G, v);}}
}
2、复杂度分析:
(1)空间复杂度主要来自来⾃函数调⽤栈,最坏情况下递归深度为O(|V|);最好情况为O(1)
(2)对于邻接矩阵存储的图,访问V个顶点需要O(|V|)的时间,查找每个顶点的邻接点都需要 O(|V|)的时间,而总共有V个顶点,时间复杂度为O(|V|2)。
(3)对于邻接表存储的图,访问|V|个顶点需要O(|V|)的时间,查找各个顶点的邻接点共需要O(|E|)的时间,时间复杂度为O(|V|+|E|)
3、深度优先遍历序列:
- 同⼀个图的邻接矩阵表示⽅式唯⼀,因此深度优先遍历序列唯⼀,深度优先⽣成树也唯⼀;
- 同⼀个图的邻接表表示⽅式不唯⼀,因此深度优先遍历序列不唯⼀,深度优先⽣成树也不唯⼀。
4、深度优先⽣成树:
同一个图的邻接矩阵表示方式唯一,因此深度优先遍历序列唯一,深度优先生成树也唯一
同一个图邻接表表示方式不唯一,因此深度优先遍历序列不唯一,深度优先生成树也不唯一
5、深度优先⽣成深林:
6.3.3 图的遍历与连通性
6.4. 图的应用
6.4.1. 最小生成树
**1、生成树:**连通图的生成树是包含图中全部顶点的一个极小连通子图。 若图中顶点数为 n,则它的生成树含有 n-1 条边。对生成树而言,若砍去它的一条边,则会变成非连通图,若加上一条边则会形成一个回路。
**2、最⼩⽣成树(最⼩代价树):**对于一个带权连通无向图G=(V,E)。设R为G的所有生成树的集合,若T为R中边的权值之和最小的生成树,则T称为G的最小生成树(Minimum-Spannino-Tree,MST).。
- 最小生成树可能有多个,但边的权值之和总是唯一且最小的。
- 最小生成树的边数 =顶点数 -1。砍掉一条则不连通,增加一条边则会出现回路。
- 如果一个连通图本身就是一棵树,则其最小生成树就是它本身。
- 只有连通图才有生成树,非连通图只有生成森林。
3、求最小生成树的两种方法
- Prim算法
- Kruskal算法
Prim算法(普里姆):从某一个顶点开始构建生成树(集合),每次将代价最小的新顶点纳入生成树(看做一个点的集合),直到所有顶点都纳入集合为止。
-
时间复杂度: O(V2) 适合用于边稠密图。
-
每次选点选边是选所有与集合中的点相连的边,而不是集合中某个单独的点的边
Kruskal算法(克鲁斯卡尔):每次选择一条权值最小的边,使这条边的两头连通(原本已经连通的就不选)直到所有结点都连通。时
- 间复杂度: O(lEllog2lEl) 适合用于边稀疏图。
每一次循环就把一个结点放到最小生成树中,所以要进行n-1次循环,也就是v-1次
而每次循环都要遍历isjoin数组和lowcost数组,复杂度为n,也就是v
那合起来就是o(n2)了
并查集,每个点刚开始都是一个集合,选了哪条边就把这两个点放到一个集合里面去,所以要遍历一遍所有的边,这是E,而要判断两个点是不是一个集合,这是并查集的操作,时间复杂度是loge
1.只要无向连通图中没有权值相同的边,那么最小生成树就是唯一的
2.最小生成树不唯一,一定存在权值相等的边,但这个边不一定是最小的,边数肯定大于n-1(不理解看3)
3.图的边小于n-1,图不连通,不存在最小生成树。无向连通图的边等于n-1,则最小生成树就是他本身,肯定唯一
6.4.2. 无权图的单源最短路径问题——BFS算法
1、使用 BFS算法求无权图的最短路径问题,需要使用三个数组
d[]
数组用于记录顶点 u 到其他顶点的最短路径path[]
数组用于记录最短路径从那个顶点过来visited[]
数组用于记录是否被访问过
广度优先生成树结点在第几层那根节点到它的距离就是几
那么我们以2号节点为根结点,用广度优先构造出来的生成树的高度肯定是最小的
2、代码实现:
#define MAX_LENGTH 2147483647 //地图中最大距离,表示正无穷// 求顶点u到其他顶点的最短路径
void BFS_MIN_Disrance(Graph G,int u){for(i=0; i<G.vexnum; i++){visited[i]=FALSE; //初始化访问标记数组d[i]=MAX_LENGTH; //初始化路径长度path[i]=-1; //初始化最短路径记录}InitQueue(Q); //初始化辅助队列d[u]=0;visites[u]=TREE;EnQueue(Q,u);while(!isEmpty[Q]){ //BFS算法主过程DeQueue(Q,u); //队头元素出队并赋给ufor(w=FirstNeighbor(G,u);w>=0;w=NextNeighbor(G,u,w)){if(!visited[w]){d[w]=d[u]+1;path[w]=u;visited[w]=TREE;EnQueue(Q,w); //顶点w入队}}}
}
6.4.3. 单源最短路径问题——Dijkstra算法
1.最短路径一定是简单路径
2.Dijkstra只是不能求有负权值的,有回路的还是可以求得
带权路径长度:当图是带权图时,一条路径上所有边的权值之和,称为该路径的带权路径长度
- BFS算法的局限性:BFS算法求单源最短路径只适⽤于⽆权图,或所有边的权值都相同的图。
- Dijkstra算法能够很好的处理带权图的单源最短路径问题,但不适⽤于有负权值的带权图。
- 使用 Dijkstra算法求最短路径问题,需要使用三个数组:
final[]
数组用于标记各顶点是否已找到最短路径。dist[]
数组用于记录各顶点到源顶点的最短路径长度。path[]
数组用于记录各顶点现在最短路径上的前驱。
初始化
1.遍历所有顶点O(n)
2.检查每个顶点相连的边,如果是邻接矩阵的话,就要遍历这一整行O(n)
最后就是O(n2)
解释上图例子:以v0为起点,第一次是把v2当做了下一个顶点,那么到v2的路径已然确定就是7,然后去检查和v2相连的边,发现没有,然后第二次才会去v1判断v1,但是此时v2的final已经是true了,那么也不可能根据v1更新v2了
迪杰斯特拉算法(Dijkstra算法)不能处理负权边的根本原因是其贪心策略无法应对路径长度可能越加越小的情况。下面通过一个四节点图的例子,用通俗的语言说明:
通俗解释
Dijkstra算法的核心是:每一步都选择当前离起点最近的节点,并认为这是最终的最短路径。
一旦一个节点被标记为“已确定最短路径”,算法就不再回头检查它。但如果有负权边,后续可能会通过其他路径绕回到已标记节点,得到更短的路径,而算法却无法修正之前的错误结果。
四节点图的例子
假设有一个有向图,节点为 A、B、C、D,边权如下:
- A → B(权值5)
- A → C(权值6)
- C → B(权值-2)
- B → D(权值1)
目标:求 A 到 D 的最短路径。
正确的最短路径
实际最短路径是 A → C → B → D,总权值为: (-2) + 1 = 5。
Dijkstra算法的错误过程
-
第一步:从 A 出发,邻居是 B(权值和 C(权值6)。选择当前最近的节点 B,标记 B 的最短路径为5。
- 更新 D 的距离:A → B → D,总权值5 + 1 = 6
-
第二步:剩下的未标记节点是 C(权值6)和 D(暂时权值6)。选择 C,标记 C 的最短路径为6。
-
检查 C 的邻居 B:通过 C 到 B 的路径权值为6 + (-2) = 4,比 B 已确定的权值5更小。
但 B 已被标记,算法不会更新它
-
-
第三步:最后处理 D,认为最短路径是 A → B → D(权值6),忽略实际存在的更优路径 A → C → B → D(权值5)。
为什么算法会出错?
- 贪心策略的短视:算法认为 B 的最短路径已确定(权值5),但后续通过 C → B 的负权边(-2)可以将路径缩短到4。由于 B 被提前标记,算法无法修正这个错误
- 负权边破坏递增性:Dijkstra假设路径长度只会递增或不变,但负权边会导致路径长度越加越小,破坏这一前提
对比其他算法
- 贝尔曼-福特算法(Bellman-Ford):通过多次松弛所有边来修正路径,允许负权边(但不能有负权环)
- SPFA算法:优化版的贝尔曼-福特,用队列减少冗余计算,适合含负权边的图
总结
Dijkstra算法像是一个“一条路走到黑”的决策者:一旦做出选择(标记节点),就不再回头。而负权边就像“后悔药”,让之前的决策可能出错,但算法无法反悔。因此,涉及负权边的问题需换用其他算法
其实就是如果已经标记过了,那就认为这个是最短的,你通过负权值得到的更短的路径不会更新到已经标记过的顶点的路径长度上去
代码实现
#define MAX_LENGTH = 2147483647;// 求顶点u到其他顶点的最短路径
void BFS_MIN_Disrance(Graph G,int u){for(int i=0; i<G.vexnum; i++){ //初始化数组final[i]=FALSE;dist[i]=G.edge[u][i];if(G.edge[u][i]==MAX_LENGTH || G.edge[u][i] == 0)path[i]=-1;elsepath[i]=u;final[u]=TREE;}for(int i=0; i<G.vexnum; i++){int MIN=MAX_LENGTH;int v;// 循环遍历所有结点,找到还没确定最短路径,且dist最⼩的顶点vfor(int j=0; j<G.vexnum; j++){if(final[j]!=TREE && dist[j]<MIN){MIN = dist[j];v = j;}}final[v]=TREE;// 检查所有邻接⾃v的顶点路径长度是否最短for(int j=0; j<G.vexnum; j++){if(final[j]!=TREE && dist[j]>dist[v]+G.edge[v][j]){dist[j] = dist[v]+G.edge[v][j];path[j] = v;}}}
}
6.4.4. 各顶点间的最短路径问题——Floyd算法
多源最短路,而dijkstra是单源最短路
- Floyd算法:求出每⼀对顶点之间的最短路径,使⽤动态规划思想,将问题的求解分为多个阶段。
- Floyd算法可以⽤于负权值带权图,但是不能解决带有“负权回路”的图(有负权值的边组成回路),这种图有可能没有最短路径。
- Floyd算法使用到两个矩阵:
dist[][]
:目前各顶点间的最短路径。path[][]
:两个顶点之间的中转点。
- 代码实现:
int dist[MaxVertexNum][MaxVertexNum];
int path[MaxVertexNum][MaxVertexNum];void 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.Edge[i][j]; path[i][j]=-1;}}// 算法核心部分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]=k;}}}}
}
8.实例:
9.可以解决带有负权边的图
不能解决如下问题:
10.最短路径算法比较:
BFS算法 | Dijkstra算法 | Floyd算法 | |
---|---|---|---|
无权图 | ✔ | ✔ | ✔ |
带权图 | ✘ | ✔ | ✔ |
带负权值的图 | ✘ | ✘ | ✔ |
带负权回路的图 | ✘ | ✘ | ✘ |
时间复杂度 | O(|V|^2)或(|V|+|E|) | O(|V|^2) | O(|V|^3) |
通常⽤于 | 求⽆权图的单源最短路径 | 求带权图的单源最短路径 | 求带权图中各顶点间的最短路径 |
6.4.5. 有向⽆环图应用–描述表达式
1、有向⽆环图:若⼀个有向图中不存在环,则称为有向⽆环图,简称 DAG图(Directed Acyclic Graph)。
DAG描述表达式:((a+b)*(b*(c+d))+(c+d)*e)*((c+d)*e)
顶点中不可能出现重复的操作数
2、有向无环图描述表达式的解题步骤:
- Step 1:把各个操作数不重复地排成一排
- Step 2:标出各个运算符的生效顺序 (先后顺序有点出入无所谓)
- Step 3:按顺序加入运算符,注意“分层”
- Step 4:从底向上逐层检查同层的运算符是否可以合体
- 不同层的操作符是不可以合并的
对同一个表达式如果采用不同的运算顺序得到的图也不一样,这说明表达式的有向无环图并不唯一
6.4.6. 有向无环图应用–拓扑排序
1.入度为0的点不唯一,这些点都可以作为拓扑排序起点
出度为0的点不唯一,这些点都可以作为终点
2.若一个有向图的邻接矩阵为三角矩阵,则图中必不存在环,因此拓扑排序序列必然存在(可能不唯一)
- 原理:三角矩阵(如上三角矩阵)的主对角线以下元素全为0,说明所有边的方向都是从编号较小的顶点指向较大的顶点。这种结构不可能形成环
如果一个有向图具有有序的拓扑排序序列,则它的邻接矩阵必定为三角矩阵
- 若顶点编号严格按拓扑排序顺序排列(例如拓扑序列为v₁, v₂, v₃),则所有边的方向只能从编号小的顶点指向大的顶点,邻接矩阵自然为上三角矩阵
如果图存在拓扑排序序列,却不一定能满足邻接矩阵是三角矩阵,但是可以通过适当调整编号使其邻接矩阵满足 是三角矩阵这个性质
- 拓扑排序的存在仅保证图无环,但邻接矩阵是否为三角矩阵取决于顶点编号顺序。若顶点编号与拓扑顺序不一致,邻接矩阵可能非三角,但通过重新编号可使其成为三角矩阵
3.有向无环图的拓扑序列唯一不可以唯一确定该图
4.存在拓扑排序的图一定没有环
**1、AOV网(Activity on Vertex Network,用顶点表示活动的网):**用DAG图(有向无环图)表示一个工程。顶点表示活动,有向边<Vi,Vj>表示活动Vi必须先于活动Vj进行。
2、拓扑排序:在图论中,由⼀个有向⽆环图的顶点组成的序列,当且仅当满⾜下列条件时,称为该图的⼀个拓扑排序:
- 每个顶点出现且只出现⼀次;
- 若顶点 A 在序列中排在顶点 B 的前⾯,则在图中不存在从顶点 B 到顶点 A 的路径。
- 或定义为:拓扑排序是对有向⽆环图的顶点的⼀种排序,它使得若存在⼀条从顶点 A 到顶点 B 的路径,则在排序中顶点 B 出现在顶点 A 的后⾯。每个 AOV ⽹都有⼀个或多个拓扑排序序列。
3、拓扑排序的实现:
4、代码实现拓扑排序(邻接表实现):
#define MaxVertexNum 100 //图中顶点数目最大值typedef struct ArcNode{ //边表结点int adjvex; //该弧所指向的顶点位置struct ArcNode *nextarc; //指向下一条弧的指针
}ArcNode;typedef struct VNode{ //顶点表结点VertexType data; //顶点信息ArcNode *firstarc; //指向第一条依附该顶点的弧的指针
}VNode,AdjList[MaxVertexNum];typedef struct{AdjList vertices; //邻接表int vexnum,arcnum; //图的顶点数和弧数
}Graph; //Graph是以邻接表存储的图类型// 对图G进行拓扑排序
bool TopologicalSort(Graph G){InitStack(S); //初始化栈,存储入度为0的顶点for(int i=0;i<g.vexnum;i++){if(indegree[i]==0)Push(S,i); //将所有入度为0的顶点进栈}int count=0; //计数,记录当前已经输出的顶点数while(!IsEmpty(S)){ //栈不空,则存入Pop(S,i); //栈顶元素出栈print[count++]=i; //输出顶点ifor(p=G.vertices[i].firstarc;p;p=p=->nextarc){//将所有i指向的顶点的入度减1,并将入度为0的顶点压入栈v=p->adjvex;if(!(--indegree[v]))Push(S,v); //入度为0,则入栈}}if(count<G.vexnum)return false; //排序失败elsereturn true; //排序成功
}
5.逆拓扑排序
邻接矩阵时间复杂度:O(V²)
邻接矩阵空间复杂度:O(V²)
邻接表时间复杂度:O(V+E)
邻接表空间复杂度:O(V+E)
6.用dfs实现的逆拓扑排序
用dfs实现的逆拓扑排序 和dfs遍历图的区别就是加了个最后一行的打印
其实相当于是树的后序遍历了,因为访问节点的操作在递归调用之后
时间复杂度和空间复杂度都和dfs一样
时间复杂度
-
DFS逆拓扑排序的时间复杂度为 O(V+E),其中V为顶点数,E为边数
原因在于DFS需要遍历所有顶点和边一次,递归或迭代实现均遵循这一复杂度。在逆拓扑排序中,每个顶点被访问一次,每条边被处理一次,最终通过栈逆序输出结果
空间复杂度
- 空间复杂度为 O(V),主要取决于以下因素:
- 递归调用栈:DFS递归深度最多为顶点数V(如线性链状图)。
- 访问状态数组:需记录每个顶点是否被访问过(例如
visited
数组)。 - 结果栈:存储顶点顺序的临时空间
6.4.7. 关键路径
改变AOE网中的任何一个活动的持续时间,需要重新计算关键活动,可能导致关键路径的改变
关键路径上活动的时间延长多少,整个工程也随之延长多少
缩短所有关键路径共有的一个关键活动可以缩短关键路径长度,但不可以一直缩短,不然关键路径可能就不再是关键路径了
由于图的关键路径不止有一条,如果所有的关键路径都缩短,才可以说图的关键路径长度缩短了,之缩短一条关键路径长度不一定会让图的关键路径长度减小
1、AOE 网:在带权有向图中,以顶点表示事件,以有向边表示活动,以边上的权值表示完成该活动的开销(如 完成活动所需的时间),称之为⽤边表示活动的⽹络,简称 AOE ⽹ (Activity On Edge NetWork)。
2、AOE⽹具有以下两个性质:
- 只有在某顶点所代表的事件发⽣后,从该顶点出发的各有向边所代表的活动才能开始;
- 只有在进⼊某顶点的各有向边所代表的活动都已结束时,该顶点所代表的事件才能发⽣。 另外,有些活动是可以并⾏进⾏的。
3、在 AOE ⽹中仅有⼀个⼊度为 0 的顶点,称为开始顶点(源点),它表示整个⼯程的开始; 也仅有⼀个出度为 0 的顶点,称为结束顶点(汇点),它表示整个⼯程的结束。
- 从源点到汇点的有向路径可能有多条,所有路径中,
具有最⼤路径⻓度的路径称为关键路径
,⽽把关键路径上的活动称为关键活动
。 - 完成整个⼯程的最短时间就是关键路径的⻓度,若关键活动不能按时完成,则整个 ⼯程的完成时间就会延⻓。
4、求和活动和事件的最早和最迟发生时间
1.求所有事件的最早发生时间ve()
2.求所有事件的最迟发生时间 vl()
根据最后一个点的最早和最迟相等这个特点,给vl(6)赋值为8,然后一个一个往前推
3.求所有活动的最早发生时间 e()
其实就是事件的边是由哪个结点发出的,那么事件的边的最早发生时间就和结点的最早发生时间是一样的
比如a4是由v2发出的,v2最早开始时间是3,所以a4的最早开始时间就是3
4.求所有活动的最迟发生时间 l()
其实就是事件的边所指向的节点的最迟发生时间减去事件的边的权值就是事件的最晚发生时间
比如a4指向的节点是v5,v5最晚发生时间是7,那么a4最晚发生时间就是7-3=4
5.求所有活动的时间余量 d()
d() =l() - e()=最早-最迟
d(i)=0的活动就是关键活动,这些活动绝对不可以拖延,否则在规定工期内就完不成了。
6.由关键活动求关键路径
关键活动是a2,a5,a7
由这些边的结点组成的路径就是关键路径
5、关键活动、关键路径的特性:
当关键活动变成非关键活动之后,关键路径可能会发生改变,这个时候再减小之前减小的那个关键活动的时间就已经没用了
可能有多条关键路径,只提⾼⼀条关键路径上的关键活动速度并不能缩短整个⼯程的⼯期,只有加快那些包括在所有关键路径上的关键活动才能达到缩短⼯期的⽬的。
那些包括在所有关键路径上的关键活动
在上面的图中表示为炒菜
关键路径有两条:
1.a1,a4
2.a2,a3,a4
时间都是6,只提⾼⼀条关键路径上的关键活动速度并不能缩短整个⼯程的⼯期,只有加快那些包括在所有关键路径上的关键活动,比如炒菜,才能达到缩短⼯期的⽬的
6.5 有向图的环的问题
首先,能检测有没有环的只有DFS和拓扑排序
其次王道书中有一道题说是关键路径算法也行,说是第一步是拓扑排序,就暂且当做是可以检测有没有环吧
DFS检测环的核心逻辑
需区分全局访问次数和当前递归路径上的访问状态
三色标记法
-
状态定义:
0
:未访问1
:正在当前递归路径中被访问(灰色)2
:已完全处理(黑色)
-
检测条件:若在遍历节点u时,发现其邻接节点v的状态为1(灰色),则说明存在v→u的回边,形成环
-
代码示例(关键部分):
-
函数的返回值代表着是否发现了环
bool dfs(int u) {visit[u] = 1; // 标记为正在访问//遍历和u相邻的节点for (int v : adj[u]) {//和u相邻的节点v是正在被访问的状态,也就是发现了环if (visit[v] == 1) return true; // 发现环//结点v并没有被访问过,那就递归调用v,看看以v出发进行深度优先遍历会不会发现环if (visit[v] == 0 && dfs(v)) return true;}visit[u] = 2; // 标记为已处理return false; }
void DFSTraverse(Graph G)//对图G进行深度优先遍历
{for (v = 0; v < G.vexnum; ++v)//初始化已访问标记数据visited[v] = FALSE;for (v = 0; v < G.vexnum; ++v)//本代码中是从v=0开始遍历if (!visited[v])DFS(G, v);
}
void DFS(Graph G, int v)//从顶点v出发,深度优先遍历图G
{visited[v] = TRUE;//设已访问标记for (w = FirstNeighbor(G, v); w >= 0; w = NextNeighor(G, v, w)){if (!visited[w])//w为u的尚未访问的邻接顶点DFS(G, w);print(v);//输出顶点}
}
拓扑排序检测有没有环的核心逻辑
拓扑排序原来的逻辑是:
- 统计所有节点的入度。
- 将入度为0的节点加入队列。
- 删除队列中的节点,并减少其邻接节点的入度。若邻接节点入度变为0,加入队列。
现在是需要判断一下处理完所有可以处理的节点后还没有没有剩余节点,有剩下的那肯定就有环了,没有的话就没有环。