0a120a13a23⋱⋯⋯0a1na2nan−1,n0:=[00αA′]显然,当a12=0 时,拓扑排序中结点V1 排在首位,然后删除与V1 相关的边,此时问题转化为一幅新的图的拓扑排序问题。而新图的邻接矩阵就是A′. 所以,若∀i,ai,i+1=0,则将依次输出V1,V2,⋯,Vn,此时拓扑序列唯一。
而当a12=0 时,观察A 可发现,此时V1,V2 均是入度为0的结点,二者的顺序可交换,所以此时拓扑序列不唯一。
关键路径|Critical Path
在上一节我们说过的拓扑排序主要是为解决一个工程能否顺序进行的问题,但有时我们还需要解决工程完成需要的最短时间问题。如果要获得一个工程流程拓扑图的最短时间,就必须要分析它们的拓扑关系,并且找到当中最关键的流程,关键流程所需的时间就是整个工作所需的最短时间。
AOE网
在一个表示工程的带权有向图中,用顶点表示事件,用有向边表示活动,用边上的权值表示活动的持续时间,这种用有向图的边表示活动的网,称为AOE网(Activity On edge Network)。
一个工程有且仅有一个开始、一个结束。对应地,AOE网只有一个源点一个汇点。
- 源点(起点):AOE网中入度为0的顶点,表示整个工程的开始。
- 汇点(终点):AOE网中出度为0的顶点,表示整个工程的结束。
既然AOE网是表示工程流程的,所以就具有明显的工程属性。只有在某顶点代表的事件发生后,从该顶点出发的各活动才能开始。只有在进入某顶点的各活动都已经结束,该顶点代表的事件才能发生。
应当掌握 AOE 和 AOV 网的区别。
AOV网是顶点表示活动的网,它只描述活动之间的制约关系;
而AOE网是用边表示活动的网,边上的权值表示活动持续的时间。
应该注意,从源点到汇点具有最大长度的路径叫关键路径,在关键路径上完成的活动叫关键活动。显然就 图7-9-3 的AOE网而言,【开始→发动机完成→部件集中到位→组装完成】就是关键路径,路径长度为3+0.5+2=5.5。
🔔【工作的最短时间】应当和所谓的【最短时间】作区分。
诚然,当我们真的只关注源点到汇点的最短时间的话,上图中最短的肯定是【开始→轮子完成→部件集中到位→组装完成】这条路径,路径长度:0.5+0.5+2=3。但是这并不是本节我们所指的关键路径。
同样以上图为例,只有等【发动机完成】之后,事件【部件集中到位】才真正抵达。而这个事件之前的一系列事件可以并行也可以一个个去完成。显然,并行的时候花费的时间最短,但再短也得等【发动机完成】这个事件结束才到下一个事件。所以,此处我们说的工作最短时间是在这个意义上的。
相关参量
通过以上分析,我们有:如果某项活动的最早开始时间和最晚开始时间一样,表示中间没有空隙,则此项活动就为关键活动
为此,我们需要明确每一个活动的时间情况,于是给定以下参量,供后续计算。
事件的最早/迟发生时间
事件Vk 的最早发生时间ve(k) |earliest time of vertex
—— 它是从源点V1 到某个顶点Vk 的最长路径长度(可以理解为以Vk 作为汇点的关键路径子问题)。它决定了Vk 之后的活动能够开工的最早时间。
递推公式为:
ve(k)=∀j,Vj→Vkmax{ve(j)+w(Vj,Vk)}
式中,Vj 是Vk 的任意前驱,w(Vj,Vk) 则是有向边Vj→Vk 的边权(即工作时间)。
规定:ve(1)=0. 即源点V1 的最早发生时间默认为0。
计算时,我们通常是从源点往后计算,所以采用从前往后的更新方法计算每个点的ve() 值。
即列出Vj 的所有后继,对后继作更新:
ve(k)←max{ve(k),ve(j)+w(Vj,Vk)}
其中,Vk 是Vj 的任一后继。
事件Vk 的最迟发生时间vl(k) |latest time of vertex
—— 它是指在不推迟整个工期的前提下,该事件最迟发生时间。也就是顶点Vk 对应的事件最晚需要开始的时间,超出此时间将会延误整个工期。
递推公式为:
vl(k)=∀j,Vk→Vjmin{vl(j)−w(Vk,Vj)}
式中,Vj 是Vk 的任意后驱,w(Vj,Vk) 是有向边Vk→Vj 的边权.
规定:vl(n)=ve(n),Vn 是汇点。
计算时,我们通常是从汇点往前计算,所以采用从后往前的更新方法计算每个点的vl() 值。
即列出Vj 的所有前驱,对前驱作更新:
vl(k)←min{vl(k),vl(j)−w(Vk,Vj)}
其中,Vk 是Vj 的任一前驱。
活动的最早/迟开始时间
某个活动Ai 对应着一段有向边,设为<Vk,Vj>。则:
Ai 的最早开始时间e(i) |earliest time of edge —— 它是该活动的起点表示的事件的最早发生时间,即:e(i)=ve(k).
Ai 的最迟开始时间l(i)|latest time of edge —— 它是该活动的终点表示的事件的最迟发生事件与该活动所需时间之差,即l(i)=vl(j)−w(Vk,Vj).
一个活动的最迟开始时间与其最早开始时间的差额记为d(i)=l(i)−e(i) —— 它是该活动完成的时间余量,即在推迟工期完成时间的情况下,活动Ai 可以拖延的时间。
如果一个活动的d()=0 说明它必须如期完成,一刻不能耽误,所以d()=0 的活动均为关键活动。
关键路径
求关键路径的算法步骤如下:
- 从源点V1 出发,令ve(1)=0,按拓扑有序求出所有顶点(事件)的最早发生时间ve()
- 从汇点Vn 出发,令vl(n)=ve(n),按拓扑逆序求出所有顶点(事件)的最迟发生时间vl()
- 根据各顶点的ve() 值求出所有边(活动)的最早开始时间e()
- 根据各顶点的vl() 值求出所有边(活动)的最迟开始时间l()
- 求各顶点的差额d()=e()−l() ,则d()=0 的边构成关键路径,关键路径上的顶点为关键活动
下面给出一实例。
Step0:ve(1)=0,拓扑排序序列:V1,V2,V3,V4,V5,V6Step1:更新V1的后继ve()值:{ve(2)=ve(1)+a1=3ve(3)=ve(1)+a2=2Step2:更新V2的后继ve()值:{ve(4)=ve(2)+a3=5ve(5)=ve(2)+a4=6Step3:更新V3的后继ve()值:{ve(4)=max(ve(3)+a5,ve(4))=6ve(6)=ve(3)+a6=5Step4:更新V4的后继ve()值:ve(6)=max(ve(4)+a7,ve(6))=8Step5:更新V5的后继ve()值:ve(6)=max(ve(5)+a8,ve(6))=8
Step0:vl(6)=ve(6)=8,拓扑逆序序列:V6,V5,V4,V3,V2,V1Step1:更新V6的前驱vl()值:{vl(4)=vl(6)−a7=6vl(5)=vl(6)−a8=7Step2:更新V5的前驱vl()值:vl(2)=vl(5)−a4=4Step3:更新V4的前驱vl()值:{vl(3)=min(vl(4)−a3,vl(2))=4vl(3)=vl(4)−a5=2Step4:更新V3的前驱vl()值:vl(1)=vl(3)−a3=0Step5:更新V2的前驱vl()值:vl(1)=min(vl(2)−a1,vl(1))=0
最终得到关键路径:V1→V3→V4→V6
🔔注意
关键路径上的活动是关键活动,它是决定整个工程工期的关键因素,即可以通过加快关键活动来缩短整个工程的工期。但是关键活动的缩短存在一个下限,因为一旦缩短到一定的程度,这条路径就不再是关键路径,该关键活动就会变成非关键活动。
AOE网的关键路径并不唯一,即关键路径可能有多条。且对于有几条关键路径的网,只加快一条关键路径上的关键活动并不能缩短整个工程的工期,只有加快那些包括在所有关键路径上的关键活动才能达到缩短工期的目的。
最小生成树|Minimum Spanning Tree,MST
在上述文章中,详细介绍了最小生成树的两种算法 Prim 和 Kruskal ,包括其算法思想、正确性证明、伪代码以及编程实现。
该文章总体思想来源于《算法导论》,其中给出的的复杂度分析也是基于最优情况,所以此处作一些额外的补充。
算法的复杂度
不采用优先队列,而直接利用暴力循环查找最小边时,Prim 算法的时间复杂度为O(∣V∣2),不依赖边集大小,它适用于边稠密图的最小生成树。
而 Kruskal 算法的时间复杂度一般认定为O(∣E∣log∣E∣),适用于边稀疏且顶点多的图的最小生成树。
但是,一般情况下都使用 Kruskal 算法,在稠密图尤其是完全图上,Prim 的复杂度虽然比 Kruskal 优,但 不一定 实际跑得更快。
相关性质
- 无向连通图必定存在最小生成树,当相同权值的边有多条时,生成树结果可能不唯一,此外一定唯一,但是代价一定唯一;
- 当最小生成树唯一时,Kruskal 和 Prim 算法均能得到相同的结果;
- 最小生成树的n−1 个边不一定是图中权值最小的n−1 个边,因为最小的边不一定能使得图连通,也就谈不上构成一棵树了。
破圈法
最短路径|Shortest Path
对于一个 有向带权连通图G=(V,E,W) ,最短路径问题就是寻找给定源点s∈V 到G 中其他顶点v∈V 的一条路径p=<v0,v1,⋯,vk> 使得当v0=s,vk=v 时w(p) 的值最小。
其中,
w(p)=i=0∑k−1w(vi,vi+1)
为该路径上所有边权之和。w(i,j) 是连接顶点i,j 的边e=<i,j> 的权重。
令u 到v 的最短路径权重为δ(u,v),则有,
δ(u,v)={p∈Pminw(p)∞,P 为所有 u→v 的路径构成的集合,P=∅,∃ 路径 p 使得 u→v
所以,u 到v 的最短路径就是使得w(p)=δ(u,v) 的路径p.
最优子结构
可以证明最短路径问题具有最优子结构。
即:两个结点之间的最短路径中,包含有路径内其他结点之间的最短路径。
此处插入证明
最短路问题的分类
待更
主要讲单源、多源
Dijkstra 算法
Dijkstra(/ˈdikstrɑ/或/ˈdɛikstrɑ/)算法由荷兰计算机科学家 E. W. Dijkstra 于 1956 年发现,1959 年公开发表。是一种求解 非负权图 上单源最短路径的算法。
算法过程与伪代码
将结点分成两个集合:一组是已确定最短路径长度的点集(记为S 集合),即源点s 到任意结点v∈S 都已求得最短路径。另一组集合是尚未确定最短路径长度的点集(记为V−S 集合)。一开始所有的点都属于T 集合。
初始化源点到自身的距离dist(s,s)=0,到其他点的距离dist(s,v) 均为+∞。
然后重复进行下列操作:
- 从V−S 集合中,选取一个源点到它的路径长度最小的结点u,加入到S 集合中。
- 对u 的所有出边执行松弛操作,即讨论u 的出边是否与S 内的结点进行连接,然后相应地执行更新操作:若加入u 之后使得S 内其他结点的路径长度比原来更小,则这些结点更新为新路径。
直到V−S 集合为空,算法结束。
Algorithm:DijkstraInput:DirectedgraphG=(V,E,W)withweightOutput:Alltheshortestpathsfromthesourcevertexstoeveryvertexvi∈V∖{s}1:S←{s}2:dist[s,s]←03:forvi∈V−{s}do4:dist[s,vi]←w(s,vi)(whenvinotfound,dist[s,vi]←∞)5:whileV−S=∅do6:findvj∈Vmindist[s,vj]fromthesetV−S7:S←S∪{vj}8:forvi∈V−Sdo9:ifdist[s,vj]+wj,i<dist[s,vi]then10:dist[s,vi]←dist[s,vj]+wj,i
实例演示
待更
时间复杂度
有多种方法来选取一个源点到它的路径长度最小的结点u,不同的实现导致了 Dijkstra 算法时间复杂度上的差异。
- 暴力:不使用任何数据结构进行维护,每次第二个操作执行完毕后,直接在V−S 集合中暴力寻找最短路长度最小的结点。这使得第二步的时间复杂度为O(∣E∣),而第一步的总时间复杂度为O(∣V∣2),全过程的时间复杂度为O(∣V∣2+E)=O(∣V∣2)。
- 二叉堆:每成功松弛一条边(u,v),就将v 插入二叉堆中(如果v 已经在二叉堆中,直接修改相应元素的权值即可),第一步操作直接取堆顶结点即可。共计O(∣E∣) 次二叉堆上的插入(修改)操作,O(∣V∣) 次删除堆顶操作,而插入(修改)和删除的时间复杂度均为O(log∣V∣),时间复杂度为O((∣V∣+∣E∣)log∣V∣)=O(∣E∣log∣V∣)。
- 优先队列:和二叉堆类似,但使用优先队列时,如果同一个点的最短路被更新多次,因为先前更新时插入的元素不能被删除,也不能被修改,只能留在优先队列中,故优先队列内的元素个数是O(∣E∣) 的,时间复杂度为O(∣E∣log∣E∣)。
- Fibonacci 堆:和前面二者类似,但 Fibonacci 堆插入的时间复杂度为O(1),故时间复杂度为O(∣V∣log∣V∣+∣E∣)=O(∣V∣log∣V∣),时间复杂度最优。但因为 Fibonacci 堆较二叉堆不易实现,效率优势也不够大,算法竞赛中较少使用。
- 线段树:和二叉堆原理类似,不过将每次成功松弛后插入二叉堆的操作改为在线段树上执行单点修改,而第一步则是线段树上的全局查询最小值。时间复杂度为O(∣E∣log∣V∣)。
在稀疏图中,∣E∣=O(∣V∣),使用二叉堆实现的 Dijkstra 算法较 Bellman-Ford 算法具有较大的效率优势;
而在稠密图中,∣E∣=O(∣V∣2),这时候使用暴力做法较二叉堆实现更优。
正确性证明
从算法过程中不难发现,dijkstra 算法属于贪心策略算法。因此,需要对其正确性进行证明。
待更
Floyd-Warshall 算法
动态规划。
dij(k)={wij,min(dij(k−1),dik(k−1)+dkj(k−1)),k=0k≥1
滚动数组、状态压缩
1 2 3 4 5 6 7
| for (k = 1; k <= n; k++) { for (x = 1; x <= n; x++) { for (y = 1; y <= n; y++) { f[k][x][y] = min(f[k - 1][x][y], f[k - 1][x][k] + f[k - 1][k][y]); } } }
|
传递闭包
https://oi-wiki.org/graph/shortest-path/#floyd-算法
事实上,Floyd 算法 适用于任何图,不管有向无向,边权正负,但是最短路必须存在。(不能有个负环)
二叉树转邻接表
对于二叉树的邻接表转换,需要明确:二叉树结点最多只有两个孩子结点,因此我们可以分为四种情况,通过递归的方式进行转换,算法思想如下:
- 判断当前树是否为空,为空则返回当前下标不做处理,否则存入当前根结点;
- 如果当前树有左右两个结点,则建立边链表,自增下标后存左子树,将左子树存毕后,将返回得到的新下标自增再存右子树,最终返回当前下标;
- 如果当前树只有左结点,则建立边链表,自增下标后存左子树,最终返回当前下标;
- 如果当前树只有右结点,则建立边链表,自增下标后存右子树,最终返回当前下标;
- 如果当前树是叶子结点,则指链表为空,返回当前下标。
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
| template<class elementType> int InsertBinTree(ALGraph<elementType> &graph,BinTree<elementType> *tree, int idx){ int new_idx; if(tree->isNULL) return idx; graph.GraphList[idx].data = tree->root_data; if((!tree->left->isNULL) && (!tree->right->isNULL)){ graph.GraphList[idx].first = new ArcNode(); graph.GraphList[idx].first->VexIndex = idx+1; graph.GraphList[idx].first->weight = 1.0; new_idx = InsertBinTree(graph,tree->left,idx+1); graph.GraphList[idx].first->next = new ArcNode(); graph.GraphList[idx].first->next->VexIndex = new_idx+1; graph.GraphList[idx].first->next->weight = 1.0; graph.GraphList[idx].first->next->next = NULL; new_idx = InsertBinTree(graph,tree->right,new_idx+1);; return new_idx; } else if(!tree->right->isNULL){ graph.GraphList[idx].first = new ArcNode(); graph.GraphList[idx].first->VexIndex = idx+1; graph.GraphList[idx].first->weight = 1.0; graph.GraphList[idx].first->next = NULL; idx = InsertBinTree(graph,tree->right,idx+1); return idx; } else if(!tree->left->isNULL){ graph.GraphList[idx].first = new ArcNode(); graph.GraphList[idx].first->VexIndex = idx+1; graph.GraphList[idx].first->weight = 1.0; graph.GraphList[idx].first->next = NULL; idx = InsertBinTree(graph,tree->left,idx+1); return idx; } else{ graph.GraphList[idx].first = NULL; return idx; } }
template<class elementType> ALGraph<elementType>::ALGraph(BinTree<elementType> *tree){ VexNum = InsertBinTree<elementType>(*this,tree,0)+1; }
|