目录
AOV网和AOE网
AOV网
顶点活动(Activity On Vertex,AOV)网是指用顶点表示活动,而用边集表示活动间优先关系的有向图。
例如图10-57的先导课程示意图就是AOV图,其中图的顶点表示各项课程,也就是“活动”;有向边表示课程的先导关系,也就是“活动间的优先关系”。显然,图中不应当存在有向环,否则会让优先关系出现逻辑混乱。
AOE网
边活动(Activity On Edge,AOE)网是指用带权的边集表示活动,而用顶点表示事件的有向图,其中边权表示完成活动需要的时间。
例如图10-59中:
- 边 a 1 a_1 a1 ~ a 6 a_6 a6 表示需要学习的课程,也就是“活动”,边权表示课程学习需要消耗的时间;
- 顶点 V 1 V_1 V1~ V 6 V_6 V6 表示到此刻为止前面的课程已经学完,后面的课程可以开始学习,也就是“事件”
如:
V
5
V_5
V5表示
a
4
a_4
a4计算方法 和
a
5
a_5
a5实变函数 已经学完,
a
6
a_6
a6泛函分析 可以开始学习。从另一个角度来看,
a
6
a_6
a6只有当
a
4
a_4
a4和
a
5
a_5
a5都完成时才能开始进行,因此当
a
4
a_4
a4计算方法 学习完毕后必须等待
a
5
a_5
a5实变函数 学习完成后才能进入
a
6
a_6
a6泛函分析 的学习),显然“事件”仅代表一个中介状态。
一般来说,AOE网用来表示一个工程的进行过程,而工程常常可以分为若干个子工程(即“活动”),显然AOE网不应当有环。
考虑到对工程来说,总会有一个起始时刻和结束时刻,因此AOE网一般只有:
- 一个源点:入度为0的点;
- 一个汇点:出度为0的点。
不过虽然这么说,实际上即便有多个源点和多个汇点,仍然可以转换为一个源点和一个汇点的情况,也就是添加一个“超级源点”和一个“超级汇点”的方法,即从超级源点出发,连接所有入度为0的点;从所有出度为0的点出发,连接超级汇点;添加的有向边的边权均为0。图10-60中就是添加了超级源点S和超级汇点T的AOE网。
AOV转换为AOE
(AOE可以看作是高级版的AOV,AOE包含了AOV可以包含的信息)
如果给定AOV网中各顶点活动所需要的时间,那么就可以将AOV网转换为AOE网。下图将图10-57的AOV网转换为AOE网:
AOE解决的问题
1. 工程起始到终止至少需要多少时间;(即关键路径的长度)
2. 哪条(些)路径上的活动是影响整个工程进度的关键。(即关键路径)
AOE网中的最长路径被称为关键路径(强调:关键路径就是AOE网的最长路径),而把关键路径上的活动称为关键活动,显然关键活动会影响整个工程的进度。
AOE网中为什么关键路径是最长路径?
AOE网中,顶点被称为事件,而边才是活动的描述,边的权值代表活动所花费的时间。因此,事件就是一个活动结束,另一个活动开始的标志。
以图10-60为例,要能到达事件T,也就是工程结束,那么必须要保证最晚到达事件T的活动已经完成,然后以此类推到下一个事件,所以最长路径才是最关键的。
其实,你可以使用拓扑排序将事件进行分层,就像图10-60这样,S位于第一层,
V
1
V_1
V1和
V
2
V_2
V2位于第二层,以此类推,那么要到达最后一层T,当然是要每一层的从T开始倒推最晚到达时间。
求解最长路径
之前的博客讲过求解最短路径。下面讲解一下如何求解最长路径长度。
对一个没有正环的图(指从源点可达的正环,下同),如果需要求最长路径长度,则可以把所有边的边权乘以-1,令其变为相反数,然后使用Bellman-Ford算法或SPFA算法求最短路径长度,将所得结果取反即可。
注意:此处不能使用Dijkstra算法,原因是Dijkstra算法不能处理负边权的情况,即便原图的边权均为正,乘以-1之后也会出现负权。 (因为Dijkstra采用的是局部贪心的做法)
如下有向图,求结点1的单源最短路径,Dijkstra并不适用:
显然,如果图中有正环,那么最长路径是不存在的。但是,如果需要求最长简单路径(也就是每个顶点最多只经过一次的路径),那么虽然最长简单路径本身存在,却没有办法通过Bellman-Ford等算法来求解,原因是最长路径问题是NP-Hard问题(也就是没有多项式时间复杂度算法的问题)
注:最长路径问题,即Longest Path Problem,寻求的是图中的最长简单路径。
而如果求的是有向无环图(DAG)的最长路径长度(也就是VOE图的关键路径),则下面要讨论的关键路径的求法可以比上面的做法更快(上面的做法是更加通用的做法,适用的是一个没有正环的图)。
求解关键路径/关键活动
由于AOE网实际上就是DAG,而关键路径是图中的最长路径。因此下面实际上给出了求解有向无环图(DAG)中最长路径的方法。
关键活动具有的特性
由于关键活动是那些不允许拖延的活动,因此这些活动的 最早开始时间=最迟开始时间。
因此可以设置数组e
(earliest)和数组l
(latest),其中e[r]
和l[r]
分别表示活动
a
r
a_r
ar的最早开始时间和最迟开始时间。于是,当求出这两个数组之后,就可以通过判断e[r] == l[r]
是否成立来确定活动r
是否是关键活动。
求解关键活动
怎样求解数组e
和l
呢?
如图10-62所示,事件
V
i
V_i
Vi在经过活动
a
r
a_r
ar之后到达事件
V
j
V_j
Vj。注意到顶点作为事件,也有拖延的可能,因此会存在最早发生时间和最迟发生时间。其中事件的最早发生时间可以理解成旧活动的最早结束时间,事件的最迟发生时间可以理解成新活动的最迟开始时间。
设置数组ve
和vl
,其中ve[i]
和vl[i]
分别表示事件i的最早发生时间和最迟发生时间,然后就可以将求解e[r]
和l[r]
转换成求解这两个新的数组:
关键:将求解活动转换成求解事件。
① 对活动
a
r
a_r
ar来说,只要在事件
V
i
V_i
Vi最早发生时马上开始,就可以使得活动
a
r
a_r
ar的开始时间最早,因此e[r]=ve[i]
。
② 如果l[r]
是活动
a
r
a_r
ar的最迟发生时间,那么l[r] + length[r]
就是事件
V
j
V_j
Vj的最迟发生时间(length[r]表示活动
a
r
a_r
ar的边权)。因此l[r]=vl[j]-length[r]
。(注意:思考为什么l[r]
不能等于vl[i]
? 😃)
于是只需要求出ve
和vl
这两个数组,就可以通过上面的公式得到e
和l
这两个数组。
怎么求解ve
和vl
呢?
1.求解ve
首先,如图10-63所示,有k个事件
V
i
1
V_{i1}
Vi1~
V
i
k
V_{ik}
Vik 通过相应的活动
a
r
1
a_{r1}
ar1~
a
r
k
a_{rk}
ark到达事件
V
j
V_j
Vj,活动的边权为length[r1]
~ length[rk]
。假设已经算好了事件
V
i
1
V_{i1}
Vi1~
V
i
k
V_{ik}
Vik的最早发生时间ve[i1]
~ ve[ik]
,那么事件
V
j
V_j
Vj的最早发生时间就是ve[i1]+length[r1]
~ ve[ik]+length[rk]
中的最大值。此处取最大值是因为只有所有能到达
V
j
V_j
Vj的活动都完成之后,
V
j
V_j
Vj才能被“激活”。可以通过下面这个公式辅助理解。
这时会发现,如果想要获得ve[j]
的正确值,ve[i1]
~ ve[ik]
必须已经得到。
有什么方法能够在访问某个结点时保证它的前驱结点都已经访问完毕呢?
没错,使用拓扑排序既可以办到。当按照拓扑序列计算ve
数组时,总是能保证计算ve[j]
的时候ve[i1]
~ ve[ik]
都已经得到。
但是这时又碰到另一个问题,通过前驱结点去寻找所有后继结点很容易,但是通过后继结点
V
j
V_j
Vj去找它的前驱结点
V
i
1
V_{i1}
Vi1 ~
V
i
k
V_{ik}
Vik 似乎没有那么直观。一个比较好的办法是,在拓扑排序访问到某个结点
V
i
V_i
Vi时,不是让它去找前驱结点来更新ve[i]
,而是使用ve[i]
去更新其所有后继结点的ve
值。通过这个方法,可以让拓扑排序访问到
V
j
V_j
Vj的时候,
V
i
1
V_{i1}
Vi1 ~
V
i
k
V_{ik}
Vik一定都已经用来更新过ve[j]
,此时的ve[j]
便是正确值,就可以用它去更新
V
j
V_j
Vj的所有后继结点的ve
值。
代码如下:
//拓扑序列
stack<int> topOrder;
//拓扑排序
bool topologicalSort(){
queue<int> q;
for(int i=0;i<n;++i){
if(inDegree[i] == 0){
q.push(i);
}
}
while(!q.empty()){
int u = q.front();
q.pop();
topOrder.push(u); //将u加入拓扑序列
for(int i = 0;i < G[u].size();i++){
int v = G[u][i].v; //u的i号后继结点编号为v
inDegree[v]--;
if(inDegree[v] == 0){
q.push(v);
}
//用ve[u]来更新u的所有后继结点v
if(ve[u] + G[u][i].w > ve[v]){
ve[v] = ve[u] + G[u][i].w;
}
}
}
if(topOrder.size() == n){
return true;
}else{
return false;
}
}
经过上面的求解,其实已经求解了最长路径的长度了。
2.求解vl
同理,如图10-64所示,从事件
V
i
V_i
Vi出发通过相应的活动
a
r
1
a_{r1}
ar1~
a
r
k
a_{rk}
ark可以到达k个事件
V
j
1
V_{j1}
Vj1 ~
V
j
k
V_{jk}
Vjk,活动的边权为length[r1]
~ length[rk]
。假设已经算好了事件
V
j
1
V_{j1}
Vj1 ~
V
j
k
V_{jk}
Vjk 的最迟发生时间vl[j1]
~ vl[jk]
,那么事件
V
i
V_i
Vi的最迟发生时间就是vl[j1] - length[r1]
~ vl[jk] - length[rk]
中的最小值。此时取最小值是因为必须保证
V
j
1
V_{j1}
Vj1 ~
V
j
k
V_{jk}
Vjk的最迟发生时间都能够被满足;可以通过下面这个公式辅助理解。
和ve
数组类似,如果需要算出vl[i]
的正确值,vl[j1]
~vl[jk]
必须已经得到。这个要求与ve
数组的刚好相反,也就是需要在访问某个结点时保证它的后继结点都已经访问完毕,而这可以通过使用逆拓扑序列来实现。幸运的是,不必再做一次逆拓扑排序来得到逆拓扑序列,而是可以通过颠倒拓扑序列来得到一组合法的逆拓扑序列。此时会发现,在上面实现拓扑排序的过程中使用了栈来存储拓扑序列,那么只需要按顺序出栈就是逆拓扑序列。而当访问逆拓扑序列中的每个事件Vi时,就可以遍历
V
i
V_i
Vi的所有后继结点
V
j
1
V_{j1}
Vj1 ~
V
j
k
V_{jk}
Vjk,使用vl[j1]
~ vl[jk]
来求出vl[i]
。
这部分的代码如下所示:
fill(vl,vl+n,ve[n-1]); //vl数组初始化,初始值为终点的ve值
//直接使用topOrder出栈即为逆拓扑序列,求解vl数组
while(!topOrder.empty()){
int u = topOrder.top(); //栈顶元素为u
topOrder.pop();
for(int i=0;i<G[u].size();i++){
int v = G[u][i].v; //u的后继结点v
//用u的所有后继结点v的vl值来更新vl[u]
if(vl[v] - G[u][i].w < vl[u]){
vl[u] = vl[v] - G[u][i].w;
}
}
}
通过上面的步骤已经把求解关键活动的过程倒着推导了一遍,下面给出上面过程的步骤总结,即“先求点,再夹边 ”:
主体部分代码如下(适用汇点确定且唯一的情况,以n-1号顶点为汇点为例):
//关键路径,不是DAG返回-1,否则返回关键路径长度
int CriticalPath(){
memset(ve,0,sizeof(ve)); //ve数组初始化
if(topologicalSort() == false){
return -1; //不是DAG,返回-1
}
fill(vl,vl+n,ve[n-1]); //vl数组初始化,初始值为汇点的ve值
//直接使用topOrder出栈即为逆拓扑序列,求解vl数组
while(!topOrder.empty()){
int u = topOrder.top(); //栈顶元素为u
topOrder.pop();
for(int i=0;i<G[u].size();i++){
int v = G[u][i].v; //u的后继结点v
//用u的所有后继结点v的vl值来更新vl[u]
if(vl[v] - G[u][i].w < vl[u]){
vl[u] = vl[v] - G[u][i].w;
}
}
}
//遍历邻接表的所有边,计算活动的最早开始时间e和最迟开始时间l
for(int u=0;u<n;++u){
for(int i=0;i<G[u].size();++i){
int v = G[u][i].v,w = G[u][i].w;
//活动的最早开始时间e和最迟开始时间
int e = ve[u],l=vl[v]-w;
//如果e==l,说明活动u->v是关键活动
if(e == l){
printf("%d->%d\n",u,v); //输出关键活动
}
}
}
return ve[n-1];
}
在上述代码中,没有将活动的最早开始时间e和最迟开始时间l存储下来,这是因为一般来说e和l只是用来判断当前活动是否是关键活动,没有必要单独存下来。如果确实想要将它存储下来,只需在结构体Node中添加域e和l即可。
如果事先不知道汇点编号,有没有办法比较快地获得关键路径长度呢?当然有办法地,那就是取ve
数组的最大值。原因在于,ve
数组的含义是事件的最早开始时间,因此所有事件中ve中最大的一定是最后一个(或多个)时间,也就是汇点。于是只需要在fill
函数之前添加一小段语句,然后改变下vl
函数初始值即可,代码如下:
int maxLength = 0;
for(int i=0;i<n;++i){
if(ve[i] > maxLength){
maxLength = ve[i];
}
}
fill(vl,vl+n,maxLength);
即便图中有多条关键路径,但如果只要求输出关键活动,按照上面的写法已经可以了。如果要完整输出所有关键路径,就需要把关键活动存下来,方法就是新建一个邻接表,当确定边u->v是关键活动时,将边u->v加入邻接表。这样最后生成的邻接表就是所有关键路径合成的图,可以用DFS遍历来获取所有关键路径。
最后指出,使用动态规划的做法求解关键路径会更加简洁。