概述

1956年,美国杜邦公司提出关键路径法,并于1957年首先用于1000万美元化工厂建设,工期比原计划缩短了4个月。杜邦公司在采用关键路径法的一年中,节省了100万美元。

在项目管理中,关键路径是指网络终端元素的元素的序列,该序列具有最长的总工期并决定了整个项目的最短完成时间。关键路径的工期决定了整个项目的工期。任何关键路径上的终端元素的延迟将直接影响项目的预期完成时间(例如在关键路径上没有浮动时间)。

AOE网

AOE网(Activity On Edge)即边表示活动的网,是与AOV网(顶点表示活动)相对应的一个概念。而拓扑排序恰恰就是在AOV网上进行的,这是拓扑排序与关键路径最直观的联系。AOE网是一个带权的有向无环图,其中顶点表示事件(Event),弧表示活动,权表示活动持续的时间。下面的就是一个AOV网:

1647869902616

其中$V_{1},…,V_{9} $表示事件,a_{1},a_{2}…,a_{11} 表示活动,活动的取值表示完成该活动所需要的时间,如$a_{1}=6$表示完成活动$a_{1}$所需要的时间为6天。此外,每一事件$V_{i}$表示在它之前的活动已经完成,在它之后的活动可以开始,如$V_{5}$表示活动$a_{4}$和$a_{5}$已经完成,活动$a_{7}$和$a_{8}$可以开始了。

AOE网的源点和汇点

由于一个工程中只有一个开始点和一个完成点,故将AOE网中入度为零的点称为源点,将出度为零的点称为汇点。

1647870405960

关键路径

由于AOE网中的有些活动是可以并行进行的(如活动$a_{1}$、$a_{2}$和$a_{3}$就是可以并行进行的),所以完成工程的最短时间是从源点到汇点的最长路径的长度。路径长度最长的路径就叫做关键路径(Critical Path)。如下图中红色顶点和有向边构成的就是一条关键路径,关键路径的长度就是完成活动$a_{1}$、$a_{4}$和$a_{7}$、$a_{10}$所需要的时间总和,即为 6+1+9+2 = 18

1647870657383

ETV

ETV(Earliest Time Of Vertex):事件最早发生时间,就是顶点的最早发生时间。

事件$V_{2}$的最早发生时间表示从源点$V_{1}$出发到达顶点$V_{2}$经过的路径上的权值之和,从源点$V_{1}$出发到达顶点$V_{2}$只经过了权值为6的边,则$V_{2}$的最早发生时间为6,表示在活动$a_{1}$完成之后,事件$V_{2}$才可以开始;同理,事件$V_{6}$要发生(即最早发生)需要活动$a_{3}$和活动$a_{6}$完成之后才可以,故事件$V_{6}$的最早发生时间为 5 + 2 = 7。其他顶点(事件)的最早发生时间同理可的。需要说明,事件的最早发生时间一定是从源点到该顶点进行计算的.

LTV

LTV(Latest Time Of Vertex):事件最晚发生时间,就是每个顶点对应的事件最晚需要开始的时间,如果超出此时间将会延误整个工期。

上图中的关键路径($V_{1}$,$V_{2}$ ,$V_{5}$ ,$V_{7}$ ,$V_{9}$ )的长度为18,为什么要提这个长度呢,因为要计算某一个事件的最晚发生时间,我们需要从汇点进行倒推。计算顶点$V_{2}$的最晚发生时间为例,已知关键路径的长度为18,事件$V_{2}$到汇点$V_{9}$所需要的时间为 1 + 9 + 2 = 12,则事件$V_{2}$的最晚发生时间为18-12 = 6.再来计算一下事件$V_{6}$的最晚发生时间,事件$V_{6}$到汇点$V_{9}$所需要的时间为 4 + 4 = 8,则事件$V_{6}$的最晚发生时间为 18 - 8 = 10;相当于说活动$a_{6}$完成之后,大可以休息 3天,再去完成活动$a_{9}$也不会影响整个工期。

ETE

ETE(Earliest Time Of Edge):活动的最早开工时间,就是弧的最早发生时间。

活动$a_{4}$要最早开工时间为事件$V_{2}$的最早发生时间 6;同理,活动$a_{9}$的最早发生时间为事件$V_{6}$的最早发生时间 7。显然活动的最早开工时间就是活动发生前的事件的最早开始时间。

LTE

LTE(Lastest Time of Edge):活动的最晚发生时间,就是不推迟工期的最晚开工时间。

活动的最晚发生时间则是基于事件的最晚发生时间。比如活动$a_{4}$的最晚发生时间为事件$V_{5}$的最晚发生时间减去完成活动$a_{4}$所需时间,即 7 - 1 = 6;活动$a_{9}$的最晚发生时间为事件$V_{8}$的最晚发生时间减去完成活动$a_{4}$所需时间,即 14 - 4 = 10;

从上面也就可以看出 只要知道了每一个事件(顶点)的ETV 和 LTV,就可以推断出对应的 ETE 和 LTE . 此外还需要注意,关键路径是活动的集合,而不是事件的集合,所以当我们求得 ETV 和 LTV 之后,还需要计算 ETE 和 LTE

算法思想

求关键路径的过程事实上最重要的就是上面提到的四个概念,ETV、LTV、ETE 和 LTE,求得了ETE与LTE之后只需要判断两者是否相等,如果相等则为关键路径中的一条边,则输出。

首先通过拓扑排序获得每一个事件的最早发生时间:源点的最早发生时间肯定为0,其他节点的ETV也初始化为0.在拓扑排序的过程中,对于每一条边都有一个起点和终点,起点的ETV加上这条边上的活动的时间如果比终点的ETV大,则更新终点的ETV。

根据事件的最早发生时间ETV推断事件的最晚发生时间 LTV:初始时将LTV的值初始化为汇点的时间,然后根据上面求出的拓扑排序逆序遍历,对于每一条边的起点和终点,如果终点的ETV-活动的时间小于起点的LTV,则更新。

计算活动的最早与最晚发生时间:从源点$V_{1}$开始,遍历源点的邻接顶点$V_{2}$, 将弧 <$V_{1}$,$V_{2}$> 上的活动$a_{1}$的最早发生时间更新为源点$V_{1}$的最早发生时间 0,将活动$a_{1}$的最晚发生时间更新为事件$V_{2}$的最晚发生时间 6 减去活动$a_{1}$所需要的时间6,即为0. 判断活动$a_{1}$的最早发生时间与最晚发生时间是否相等,如果相等则为关键活动,并输出。同理遍历源点 的邻接顶点$V_{3}$和$V_{4}$,并更新弧$a_{2}$和$a_{3}$的最晚发生时间与最早发生时间。后续节点的遍历同理。

代码实现

输入格式

第一行两个整数numVertexes,numEdges分别表示节点数和边数

接下来numEdges行每行三个整数x,y,z,表示从节点x到节点有一条边,该边上的活动所需时间为z

说明:节点编号从1开始

输出格式

从源点开始每行以 c格式输出,表示关键路径中的一段从a到b,该段时间为c

样例输入

9 11
1 2 6
1 3 4
1 4 5
2 5 1
3 5 1
4 6 2
5 7 9
5 8 7
6 8 4
7 9 2
8 9 4

样例输出

<1,2> 6
<2,5> 1
<5,8> 7
<5,7> 9
<7,9> 2
<8,9> 4

C++实现

#include <iostream>
#include <stack>
#include <cstring>
#include <vector>

using namespace std;

//边表节点的声明
struct EdgeNode
{
int adjvex; //用于保存该边的终点
int time; //这条边上的活动所需时间
EdgeNode *next;
};

//顶点表节点的声明
struct VertexNode
{
int indegree; //节点入度
int data; //数据
EdgeNode *firstEdge; //从该顶点出去的所有边中的第一条
};

const int MAX = 10001;
int numVertexes, numEdges; //顶点数,边数
int etv[MAX], ltv[MAX]; //事件最早发生时间,事件最晚发生时间
VertexNode graph[MAX]; //用于存图

/**
* @brief 如果拓扑排序存在,返回true并将拓扑保存在topo中,否则返回false
*
* @param topo
* @return true
* @return false
*/
bool TopologicalSort(vector<int> &topo)
{
memset(etv, 0, sizeof(etv));
stack<int> stack;
for (int i = 0; i < numVertexes; i++)
{
if (graph[i].indegree == 0)
stack.push(i); //将入度为0的顶点下标入栈
}
while (!stack.empty())
{
int top = stack.top(); //获取栈顶节点编号
stack.pop(); //出栈
topo.push_back(top); //保存拓扑排序结果
//遍历该节点所有连边
for (EdgeNode *e = graph[top].firstEdge; e; e = e->next)
{
int end = e->adjvex; //该边终点
graph[end].indegree--; //入度减一
if (graph[end].indegree == 0)
stack.push(end);
if (etv[top] + e->time > etv[end]) //更新etv
etv[end] = etv[top] + e->time;
}
}
if (topo.size() < numVertexes) //如果保存的拓扑序列长度小于节点数,说明存在环
return false;
else
return true;
}

void CriticalPath()
{
vector<int> topo;
TopologicalSort(topo);
//初始化ltv都为汇点的时间
for (int i = 1; i <= numVertexes; i++)
ltv[i] = etv[numVertexes];
//从汇点倒过来逐个计算ltv
for (int i = topo.size() - 1; i >= 0; i--)
{
int pre = topo[i];
for (EdgeNode *e = graph[pre].firstEdge; e; e = e->next)
{
int end = e->adjvex; //该边终点
if (ltv[end] - e->time < ltv[pre])
ltv[pre] = ltv[end] - e->time;
}
}
//通过etv和ltv求ete和lte
for (int i = 1; i <= numVertexes; i++)
{
for (EdgeNode *e = graph[i].firstEdge; e; e = e->next)
{
int end = e->adjvex;
int ete = etv[i];
int lte = ltv[end] - e->time;
if (ete == lte)
cout << "<" << i << "," << end << ">"
<< " " << e->time << endl;
}
}
}

int main()
{
cin >> numVertexes >> numEdges;
for (int i = 0; i < numEdges; i++)
{
int x, y, z;
cin >> x >> y >> z;
graph[y].indegree++;
EdgeNode *e = new EdgeNode;
e->adjvex = y;
e->time = z;
e->next = graph[x].firstEdge;
graph[x].firstEdge = e;
}
CriticalPath();
return 0;
}