关键路径

关键路径

在上一章当中我们研究了 最短路径 的相关算法,它们是迪杰斯特拉算法(Dijkstra)和弗洛伊德算法(Floyd),本章我们就来了解一下关键路径的一些相关算法,这也是图结构相关内容的最后一部分,但是在展开之前,我们需要先来了解一些前置知识,它们就是『拓扑序列』和『拓扑排序』

拓扑序列

那么什么是拓扑序列呢?在前面的章节当中,我们已经知道,一个无环的有向图可以称之为无环图(Directed Acyclic Graph),我们可以将其简称为 DAG 图,而在一个表示工程的有向图中,如果用顶点表示活动,用弧表示活动之间的优先关系,这样的有向图就称为顶点表示活动的网,我们称之为 AOV 网(Active On Vertex Network),而 AOV 网中的弧表示活动之间存在的某种制约关系,简单来说就说 AOV 网不能存在回路

这里涉及到几个新的概念,比如工程和活动,我们可以这样来进行理解

比如你想要开一家店铺,那么开店铺这整个流程,我们就可以将其称之为一个工程,而开店铺过程当中涉及到的比如选址,装修,进货等行为,我们就可以将其称之为一个一个的活动,所有的工程或者某种流程都可以分为若干个小的工程或者阶段,我们称这些小的工程或阶段为活动,而至于不能存在回路,可以理解为你不能先进行装修和进货,而是必须要在店铺的选址工作完成了以后才能进行接下来的流程,这样一来是不是就比较容易理解了,下面我们在来看看拓扑排序

拓扑排序

之前我们介绍的拓扑序列,如果使用概念来进行定义的话,是这样的

G = (V, E) 是一个具有 n 个顶点的有向图,V 中的顶点序列 V1, V2, ... Vn 满足若从顶点 ViVj 有一条路径,则在顶点序列中顶点 Vi 必在顶点 Vj 之前,则我们称这样的顶点序列为一个拓扑序列

而所谓的拓扑排序,其实就是对一个『有向图构造拓扑序列』的过程,我们可以以一个具体的例子来进行理解,如下图

上图是某校计算机专业所修课程,比如代号 C1 的课程,它就不需要前置课程,而比如对于课程 C3 它就需要 C1C14 这两门前置课程,我们将上图转换为 AOV 网是下面这样的

针对于上图其中的一种拓扑序列为 1, 13, 4, 8, 14, 15, 5, 2, 3, 10, 11, 12, 7, 6, 9(在下面我们会通过代码来进行求解),在用代码实现之前,我们先来看看针对 AOV 网进行拓扑排序的方法和步骤,主要流程如下,主要利用了栈的特点

  • AOV 网中选择一个没有前趋的顶点(该顶点的入度为 0)并且输出它
  • 从网中删去该顶点,并且删去从该顶点发出的全部有向边
  • 重复上述两步,直到剩余网中不再存在没有前趋的顶点为止

有了实现步骤,下面我们就来看看如何使用代码来进行实现,由于需要删除顶点,所以在这里我们选择『邻接表』的数据结构来表示会更加方便(相较于邻边矩阵),形式如下

针对于该算法时间复杂度而言

  • 对一个具有 n 个顶点,e 条边的网来说,初始建立入度为零的顶点栈,要检查所有顶点一次,执行时间为 O(n)
  • 排序中,若 AOV 网无回路,则每个顶点入、出栈各一次,每个表结点被检查一次,因而执行时间是 O(n + e)
  • 所以,整个算法的时间复杂度是 O(n + e)

首先我们需要先来生成图

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
class vex {
constructor(value) {
this.data = value
this.firstEdge = null
// 用于存放顶点的入度
this.in = 0
}
}

class adjvex {
constructor(node, weight) {
this.node = node
this.weight = weight
this.next = null
}
}

class Graph {
constructor(v, vr) {
let len = v.length
let vexs = new Array(len)
let v1 = 0, v2 = 0
let newvex = null

for (let i = 0; i < len; i++) {
vexs[i] = new vex(v[i])
}

for (let arc of vr) {
v1 = v.indexOf(arc[0])
v2 = v.indexOf(arc[1])

newvex = new adjvex(v2, arc[2])
newvex.next = vexs[v1].firstEdge
vexs[v1].firstEdge = newvex
vexs[v2].in++
}

this.adjList = vexs
}
}

let a = new Graph(['v0', 'v1', 'v2', 'v3', 'v4', 'v5', 'v6', 'v7', 'v8', 'v9', 'v10', 'v11', 'v12', 'v13'], [['v0', 'v11', 1], ['v0', 'v4', 1], ['v0', 'v5', 1], ['v1', 'v4', 1], ['v1', 'v8', 1], ['v1', 'v2', 1], ['v2', 'v5', 1], ['v2', 'v6', 1], ['v3', 'v2', 1], ['v3', 'v13', 1], ['v4', 'v7', 1], ['v5', 'v8', 1], ['v5', 'v12', 1], ['v6', 'v5', 1], ['v8', 'v7', 1], ['v9', 'v11', 1], ['v9', 'v10', 1], ['v10', 'v13', 1], ['v12', 'v9', 1]])

console.log(a)

当有了图以后,我们就可以来实现我们的排序操作

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
function topoSort(G) {
// 辅助栈
let stack = []
// 寻找入度为 0 的顶点推入栈
for (let i = 0; i < G.adjList.length; i++) {
if (G.adjList[i].in === 0) {
stack.push(i)
}
}

let currentVex = null
// 用于计数已经输出的顶点
let count = 0

while (stack.length > 0) {
currentVex = G.adjList[stack.pop()]
// 输出栈顶顶点
console.log(currentVex.data)
count++
currentVex = currentVex.firstEdge

// 删除当前顶点,遍历其邻接顶点,使它们入度减 1
while (currentVex) {
// 当邻接顶点入度为 0 时
if ((--G.adjList[currentVex.node].in) === 0) {
// 将邻接顶点压入栈中
stack.push(currentVex.node)
}
currentVex = currentVex.next
}
}

// 若输出的顶点数少于图中顶点数,则存在环
if (count < G.adjList.length) {
console.log(`存在环路`)
return false
} else {
return true
}
}

关键路径的作用

在了解完拓扑排序以后,我们就可以正式的来看看关键路径,不过对于一个新东西,在了解它之前我们一般都会先了解它是做什么的,它解决了什么样的问题,同样的,我们也使用一个示例来进行说明

比如某汽车公司生产一辆汽车,其中生产轮子需要 0.5 天,发动机需要 3 天,底盘需要 2 天,外壳需要 2 天,其他零部件需要 2 天,全部零部件集中到一处需要 0.5 天,组装成车并测试需要 2 天,那么请问生成一辆汽车最短需要多少时间呢?

针对于上面这个例子,通过简单的计算就可以得出结果,但是如果是在实际当中,往往会涉及到其他更多的部门分工合作,所以也就会变得更为复杂,那么针对于这种情况,我们该如何处理这样的问题呢?所以这就有了我们的关键路径算法,它的定义是下面这样的

在一个表示工程的带权有向图(注意这里是带权的)中,用顶点表示事件,用有向边表示活动,用边上的权值表示活动的持续时间,这种有向图的边表示活动的网,我们称之为 AOE 网(Activity On Edge Network

我们把 AOE 网中没有『入边』的顶点称为始点或源点,没有『出边』的顶点称为终点或汇点,如下图所示

AOV 网与 AOE 网的比较

下面我们来看看两者之间的区别,有一个比较好的记忆方式就是,注意它们是否带权,不带权的就是 AOV,而带权的就是 AOE,如下

而像上图 AOE 网当中标注的红色路线就是我们的关键路径,下面我们就来看看关键路径是如何建立在拓扑序列之上的

关键路径

简单来说,关键路径的算法就是我们先求得顶点事件的最早和最晚发生时间,再求得弧活动的最早和最晚开工时间,最后比较弧活动的两者时间是否相等,然后判断其是否为关键活动,在这里涉及到了四个新的概念,我们先来了解一下

名词 定义
etv 事件的最早发生时间,就是顶点的最早发生时间,简单来说就是 源点 ==> 汇点
ltv 事件的最晚发生时间,就是每个顶点对应的事件最晚需要开始的时间,如果超出此时间将会延误整个工期,简单来说就是 汇点 ==> 源点
ete 活动的最早开工时间,就是弧的最早发生时间
lte 活动的最晚开工时间,不推迟工期的最晚开工时间

其实简单来说,关键路径就是『源点到汇点之间权值最大的一条路径』,这条路径决定了整个工期,关键路径上的关键活动的最早开始的时间和最晚开始的时间应该是相同的(也就是 etv == ltv),否则说明还有其他工程会影响工期,这条路径就不是关键路径了,而引入 etelte 是为了通过循环边来找出关键活动,同时可以得到边上的权,还是以下图为例

根据图中的信息,我们可以得到如下信息

下面来简单介绍一下上图中的信息

  • 顶点我们就使用 C1,C2 ... C9 来进行表示,左边是源点,右边是汇点
  • etv,事件的最早发生时间,也就是从左往右,比如 C2 这个点,它需要经过 a1 这个活动结束以后才能到达 C2,所以 C2 的值为 6,这里我们主要看一下 C5 这种情况,它需要等两条线路都结束以后才能开始,所以它的值为 7,其他的点都是同理
  • ltv,事件的最晚发生时间,我们这里是从右往左,整个工程所需要的时间是 16 个单位,因为 a11 执行需要四个单位,所以 C8 所需要的最迟时间也就是 12,这里我们主要看一下 C6 的情况,因为 a9 需要 4 个单位,所以 C6 的值是 8(这里需要注意的就是 ltvC1 是取的较小的值,也就是这里的 0),所以在 C6 这个点上我们可以发现,它最早发生的时间是 7,而最晚发生的时间是 8,它们之间是相差一个单位的
  • ete,活动的最早开工时间,我们可以发现,我们有 9 个顶点,但是却产生了 11 条弧,比如 a1,a2,a3 它们的事件都是从 C1 开始的,所以它们都是 0,我们来看 a4 这条弧,它对应着 C2 最早的发生时间,通过观察可知,C2etv 的值为 6,所以它的 ete 的值也为 6,同理,a5 就相当于 C3 的最早发生时间,是不是有点眉目了,后面都是以此类推
  • lte,活动的最晚开工时间,又是最晚,所以我们需要从右往左来看,简单来说比如 a9 这条弧,对应着 C8 这个点的 ltv,它的 ltv 的值为 12,所以 a912 - 4 = 8

所以简单的总结一下就是,如果得出了 etvltv 的值以后,就可以很简单的推算出 etelte 的值了

  • etv 可以直接推断出 ete
  • lte 可以简单的换算得到 ltv

这里有一个需要注意的地方,就是如果细心观察可以发现,图上的 C1,C2,C5 ... 等点,也就是下图这些点

它们的 etv 是等于 ltv 的,通过图我们可以发现,其实它们之间的连线也就是我们所要的关键路径,但是这里存在一个问题,既然知道连接弧就是关键路径,那么为什么还有必要要求得 etelte

首先,关键路径是活动的集合,也就是弧的集合而不是事件的集合,虽然我们可以求出 etv == ltv 的情况,也可以一眼就看出结果,但是对于计算机而言,我们还是需要让它通过弧将其表现出来的,况且有了 etvltv,通过我们上面的介绍,得出 etelte 也是十分简单的

代码实现

其实通过上面的内容,我们可以将整个求解关键路径的过程划分为三部分

  • 求解各顶点事件的最早发生时间
  • 求解各顶点事件的最晚发生时间
  • 求解各弧上活动的最早和最晚发生时间,并做关键活动的判断

邻边表如下,右侧加粗的部分表示权值

我们还是在我们之前的拓扑排序的基础之上来进行调整,首先求得各顶点事件的最早发生时间,可以与拓扑排序相对比,可以发现它们是很相似的

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
function getEtvs(G) {
let etvs = [] // 用于存储各顶点的最早发生时间
let stack = [], T = [] // stack 为辅助栈,T 为存储拓扑序列的数组
let count = 0 // 用于统计顶点个数

// 初始化数组,并将入度为 0 的顶点推入栈
for (let i = 0; i < G.adjList.length; i++) {
etvs[i] = 0
if (G.adjList[i].in === 0) {
stack.push(i)
}
}

let currentAdjVex = null
let currentIndex = 0

while (stack.length > 0) {
// 弹出栈顶入度为 0 的顶点
currentIndex = stack.pop()
count++
T.push(currentIndex)
currentAdjVex = G.adjList[currentIndex].firstEdge

// 遍历当前顶点的所有邻接顶点
while (currentAdjVex) {
// 关键代码,求各顶点事件最早发生时间
if (etvs[currentIndex] + currentAdjVex.weight > etvs[currentAdjVex.node]) {
etvs[currentAdjVex.node] = etvs[currentIndex] + currentAdjVex.weight
}

// 将当前邻接顶点入度减 1,若等于 0,则推入栈中
if (--G.adjList[currentAdjVex.node].in === 0) {
stack.push(currentAdjVex.node)
}

currentAdjVex = currentAdjVex.next
}
}

if (count < G.adjList.length) {
return false
} else {
return [etvs, 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
function getLtvs(G, etvs, T) {
let ltvs = []

// 初始化每个顶点的最晚发生时间
for (let i = 0; i < G.adjList.length; i++) {
ltvs[i] = etvs[G.adjList.length - 1]
}

let currentAdjVex = null
let currentIndex = 0

while (T.length > 0) {
// 反向拓扑序列计算
currentIndex = T.pop()
currentAdjVex = G.adjList[currentIndex].firstEdge

while (currentAdjVex) {
// 关键代码,求各顶点事件的最晚发生时间,注意与 etv 的判断条件不一样
if (ltvs[currentIndex] > ltvs[currentAdjVex.node] - currentAdjVex.weight) {
ltvs[currentIndex] = ltvs[currentAdjVex.node] - currentAdjVex.weight
}
currentAdjVex = currentAdjVex.next
}
}
return ltvs
}

最后即可根据各顶点事件的最早和最晚发生时间,计算弧上活动的最早和最晚时间,并作比较判断是否为关键活动,需要注意的是,弧上活动 <Vi, Vj> 的最早开工时间不可能早于顶点 Vi 事件最早发生时间,而最晚开工时间则不可能晚于顶点 Vj 事件最晚发生时间减去弧上活动持续时间

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
function criticalPath(G) {
let etvs = null, ltvs = null
let T = null

// 调用函数获取图各顶点事件的最早和最晚发生时间
[etvs, T] = getEtvs(G)
ltvs = getLtvs(G, etvs, T)

let ete = 0, lte = 0
let currentAdjVex = null

// 遍历每一条弧
for (let i = 0; i < G.adjList.length; i++) {
currentAdjVex = G.adjList[i].firstEdge
while (currentAdjVex) {
// 弧上活动的最早开工时间
ete = etvs[i]
// 弧上活动的最晚开工时间
lte = ltvs[currentAdjVex.node] - currentAdjVex.weight
if (ete === lte) {
console.log('v' + i, 'v' + currentAdjVex.node, currentAdjVex.weight)
}
currentAdjVex = currentAdjVex.next
}
}
}
# Essay

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×