数据结构:关键路径
再谈“泡茶”
张三给客人沏茶,烧开水需要 12 分钟,洗茶杯要 2 分钟,买茶叶要 8 分钟,放茶叶泡茶要 1 分钟。为了让客人早点喝上茶,你认为最合理的安排,多少分钟就可以了?
在上一篇博客拓扑排序中我其实并没有解决最优化策略,而只是用这个话题做个引入。再回顾下,我认为拓扑排序提供的是一种导向,它描述了工程这么去操作是否可行,以及给了一种大致上的流程规划。但是这毕竟只是一种导向,并不精确,它指示的不是解决这个问题的最优解。现在我就来个“最优化策略解决篇”,例如我要得出一个工程的最快完成时间,这样的描述就具有很强的可操作性。就好比这个简单的小学奥数题,拓扑排序解决的是导向问题,即有开水才能泡茶,茶杯洗干净才能放茶叶、有茶叶才能放茶叶,现在我要完成这个事件,我的做法是:烧开水(同时洗茶杯、买茶叶) 12 分钟 + 放茶叶泡茶 1 分钟 = 13 分钟,这就是最优化策略。
AOE 网
AOE(Activity On Edge) 网是建立在 AOV 网之上的网结构,简单的说就是用边来表示活动的网,在 AOV 网的基础上中我们引入权,则 AOE 网就是带权的有向无环图。对于一个工程,我们总希望有一个总体而精确的认知,例如我想知道完成工程的最短时间是多少?对于这个工程来说哪些活动是起到了决定性的作用的呢?AOE 网可以用来描述这些问题。
Critical Path
对于一个工程来说,工程的发起可能有诸多诱因,但是工程的起始是唯一的,就例如莱克星顿的枪声标志了美国独立战争的开始,工程的结束可能产生诸多影响,但是结束的标志是唯一的,就例如巴黎和约的签订标志着美国独立战争的结束。因此对于一个工程而言,仅存在一个开始点,也就是入度为 0 的点在起始状态唯一,我称之为源点。同理对于一个工程而言,仅存在一个结束点,也就是出度为 0 的点在起始状态唯一,我称之为汇点。
我们在这里关心的是工程的至少需要时间,“至少”这个词我们品一下,含义是:最多中的最少!也就是说一个工程的至少需要时间肯定是收到一些决定性的活动而确定的,我称之为关键活动,由关键活动组成的路径我称之为关键路径,也就是从源点到汇点的带权路径长度最长的路径。
由于图结构是一个多对多的结构,因此关键活动也受着其他活动的牵制,因此当其他活动有所变动时,可能会牵一发而动全身,对关键路径产生牵制作用而诞生新的关键路径。因此一旦有某些事件或活动被修改,关键路径可能就需要重新推导。
确定关键路径
辅助参数
- 事件的最早发生时间 etv:顶点 vk 的最早发生时间;
- 事件的最迟发生时间 ltv:顶点 vk 的最早发生时间,超出这个时间会造成工程延误;
- 活动最早开始时间 ete:弧 ak 最早发生时间;
- 活动最晚开始时间 lte:弧 ak 最晚发生时间,在不发生工程延误的条件下最晚开启时间;
对于任意活动而言,lte - ete 的值称之为时间余量,表示在这个量的限制下的时间延误不影响整个工程的进度,对于关键活动而言,将会满足 ete = lte,这表示活动必须准时完成,否则将会导致工程延误。
求解过程
- 对顶点拓扑序列,并按照得到的序列求得 etv;
- 按照逆拓扑序列得到 ltv;
- 求出活动的 ete;
- 求出活动的 lte;
- 找到 ete = lte 的关键活动。
求解样例
求这个带权的有向无环图的关键路径:
其各个参数为:
算法实现
结构设计
还是使用熟悉的邻接表来存储。求关键路径前需要先实现拓扑排序,因此需要有结构来存储排序序列:
- 一维数组 ve:事件 vi 最早发生时间;
- 一维数组 vl:事件 vi 最迟发生时间;
- 一维数组 topo:存储拓扑排序序列。
算法流程
- 得出拓扑排序序列,储存于数组 topo;
- 初始化最早发生时间一维数组 ve,使其每一个元素为 0;
- 拓扑序列按照从前往后求 etv,用每一个顶点的每一个邻接点更新 etv:
- 初始化最早发生时间一维数组 vt,使其每一个元素为汇点的 etv;
- 拓扑序列按照从后往前求 ltv,用每一个顶点的每一个邻接点更新 ltv:
- 根据 ve 和 vl 求解每个活动的 ete 和 lte,根据判断是否 ete == lte 确定关键路径。
伪代码
代码实现
bool CriticalPath(ALGraph g)
{
int topo[MAXV]; //存储拓扑序列
int ve[MAXV]; //事件 vi 最早发生时间;
int vl[MAXV]; //事件 vi 最迟发生时间;
int i;
int v,vl; //暂存顶点和其邻接点序号
ArcNode* ptr; //操作邻接表所用指针
int ete,lte; //单个活动的活动最早和最迟开始时间
/*得出拓扑排序序列*/
if(TopologicalOrder(g,topo) == false) //求解拓扑序列
return false; //若有环,则关键路径一定不存在
/*初始化最早发生时间一维数组 ve*/
for(i = 0; i < g.n; i++)
{
ve[i] = 0;
}
/*按照拓扑序列求 etv*/
for(i = 0; i < g.n; i++)
{
v = topo[i];
ptr = g.edges[v].fiestarc;
while(ptr) //依次更新所有顶点 etv
{
vl = ptr->adjvex;
if(ve[vl] < ve[v] + ptr->weight)
{
ve[vl] = ve[v] + ptr->weight;
}
ptr = ptr->nextarc;
}
}
/*初始化最迟发生时间一维数组 vt*/
for(i = 0; i < g.n; i++)
{
vt[i] = ve[g.n - 1];
}
/*按照拓扑逆序序列求 ltv*/
for(i = g.n - 1; i >= 0; i++)
{
v = topo[i];
ptr = g.edges[v].fiestarc;
while(ptr) //依次更新所有顶点 etv
{
vl = ptr->adjvex;
if(vt[vl] < vt[v] + ptr->weight)
{
vt[vl] = vt[v] + ptr->weight;
}
ptr = ptr->nextarc;
}
}
/*获取关键路径*/
for(i = 0; i < g.n; i++)
{
ptr = g.edges[i].fiestarc; //ptr 指向 i 的第一个邻接点
while(ptr)
{
ete = ve[i];
lte = vl[ptr->adjvex] - ptr->weight; //ptr->adjvex 为顶点 i 的邻接点
if(ete == lte) //判断关键活动
{
cout << g.edges[i].data << "," g.edges[ptr->adjvex].data << endl;
}
ptr = ptr->nextarc;
}
}
}
时间复杂度
求每个事件和活动的最早、迟开始时间时,都要对所有顶点的邻接表进行扫描,因此时间复杂度 O(n + e)。
参考资料
《大话数据结构》—— 程杰 著,清华大学出版社
《数据结构(C语言版|第二版)》—— 严蔚敏 李冬梅 吴伟民 编著,人民邮电出版社