关键路径CP

数据结构:关键路径 CP_关键路径CP

这个图中,最长的那条路径是V1--a1--V2--a3--V4--a7--V6,总长度为15。如果这个图表示的是一个工程的任务安排,那么这条路径上的顶点和活动,一个都不能延迟,否则会导致整个工程的延期。
为了不延迟,我们需要知道各个环节最晚要什么时候开始,我们倒推。对V6,这是终点,我们设为0;对V4,因为a7这个活动要持续6天,所以最晚开始时间就是-6(0-6),再晚就会使得有序的V6延迟了;对V2,最晚开始时间是-11(0-6-5),经过的活动是a7和a3,我们看到了有另外一条路径也可以,就是a8和a4,结果是-10(0-3-7),可是我们不能这么晚才开始,如果走这条路,只剩下10天工期,将完成不了a3和a7。计算规则就是V2的所有后驱顶点的最晚开始时间减去活动的持续时间的最小值。同理,对V1,最晚的开始时间是-15(0-6-5-4)。
有了最晚开始的时间,我们还要再算一下最早开始的时间。用顺推的办法,对V1,这是源点,我们设为0;对V2,最早开始肯定是在日期4,因为它之前有一个活动a1要持续4天时间。对V4,它的最早开始肯定是在日期9,因为它之前要经过a1和a3两个活动,虽然经过a2和a5也是可以的,这两个活动加在一起是6天,但是它们完成后V4还是不能开始的,因为a1和a3还没有完,要等着。计算规则就是V4的所有前驱顶点的最早开始时间加上活动的持续时间的最大值。同理,对V6,最早开始就是在日期15(4+5+6)。
对上面简单的图,我们是可以眼睛看出来的。不过我们不能只是眼见为实,需要我们分析这个问题。
我们先想最简单的情况,如果一个图退化成一个线性表,比如上图只剩下V1,V2,V4,V6这四个顶点,其实没有什么好计算的,最早开始时间的计算就是一个个点往后加,最晚开始时间的计算就是从后开始一个一个往前减。为什么可以这样?因为这个是一个线性序列,每个点只有一个前驱一个后驱。
但是对普通的图,我们不能这么干了,因为一个点有多个前驱也有多个后驱。按照工程安排的说法,有些活动的安排是并发进行的。所以我们得检查所有的前驱和后驱。
我们这里频繁地提到了前驱和后驱,意味着这些顶点之间的计算是有先后次序的。所以我们要先通过拓扑排序把顶点的次序和逆次序得到。
对上图,我们得到拓扑排序序列:V1,V2,V3,V4,V5,V6,逆序列就是V6,V5,V4,V3,V2,V1。
我们以拓扑序列来计算各个顶点的最早开始时间ETV:
V1,这是源点,ETV[V1]=0;
V2,它的前驱是V1,ETV[V2]=ETV[V1]+W[V1,V2]=0+4=4;
V3,它的前驱是V1,ETV[V3]=ETV[V1]+W[V1,V3]=0+2=2;
V4,它的前驱是V2和V3,ETV[V4]=max(ETV[V2]+W[V2,V4], ETV[V3]+W[V3,V4])=9
V5,它的前驱是V2,ETV[V5]=ETV[V2]+W[V2,V5]=4+3=7;
V6,它的前驱是V3,V4和V5,ETV[V6]=max(ETV[V3]+W[V3,V6], ETV[V4]+W[V4,V6], ETV[V5]+W[V5,V6])=max(2+11, 9+6, 7+7)=15
我们用一段代码计算各个顶点的最早开始时间。

    def getPrenodes(self,idx): #前驱顶点
        N=[]
        for i in range(self.vsize):
            if self.edge[i][idx]!=self.MAX_INT:
                N.append(i)
        return N
    def getETV(self):
        TOPOS=self.toposort() #topo serial
        ETV=[0]*self.vsize
        for vidx in TOPOS: #对每一个顶点,求最早开始时间
            prenodes=self.getPrenodes(vidx) #得到前驱
            if len(prenodes)>0: #有前驱,求max(前驱ETV+活动持续时间)
                maxlen=0
                for n in prenodes:
                    if maxlen<ETV[n]+self.edge[n][vidx]:
                        maxlen=ETV[n]+self.edge[n][vidx]
                ETV[vidx]=maxlen
            else: #没有前驱,源点
                ETV[vidx]=0

        return ETV

用上图进行测试:

    vertex=['V1','V2','V3','V4','V5','V6']
    edge=[[M,4,2,M,M,M],
         [M,M,M,5,3,M],
         [M,M,M,4,M,11],
         [M,M,M,M,M,6],
         [M,M,M,M,M,7],
         [M,M,M,M,M,M]
         ]

udg=buildGraph()
print(udg.getETV())

运行结果:

[0, 4, 2, 9, 7, 15]

有了最早开始时间的办法,求最晚开始时间就用类似的办法,逆推。对上图,我们得到拓扑排序序列:V1,V2,V3,V4,V5,V6,逆序列就是V6,V5,V4,V3,V2,V1。
我们以逆拓扑序列来计算各个顶点的最晚开始时间LTV:
V6,这是终止点,LTV[V6]=0;
V5,它的后驱是V6,LTV[V5]=LTV[V6]-W[V5,V6]=0-7=-7;
V4,它的后驱是V6,LTV[V4]=LTV[V6]-W[V4,V6]=0-6=-6;
V3,它的后驱是V4和V6,LTV[V3]=min(LTV[V4]-W[V3,V4], LTV[V6]-W[V3,V6])=-11
V2,它的后驱是V4和V5,LTV[V2]=min(LTV[V4]-W[V2,V4],LTV[V5]-W[V2,V5])=-11;
V1,它的后驱是V2和V3,LTV[V1]=min(LTV[V2]-W[V1,V2], LTV[V3]-W[V1,V3])=-15
代码如下:

    def getPostnodes(self,idx): #后驱顶点
        N=[]
        for j in range(self.vsize):
            if self.edge[idx][j]!=self.MAX_INT:
                N.append(j)
        return N

    def getLTV(self):
        RTOPOS=self.toposort()
        RTOPOS.reverse() #topo serial reverse
        LTV=[self.MAX_INT]*self.vsize
        for vidx in RTOPOS: #对每一个顶点,求最晚开始时间
            postnodes=self.getPostnodes(vidx) #得到后驱
            if len(postnodes)>0: #有后驱,求min(后驱ETV-活动持续时间)
                minlen=self.MAX_INT
                for n in postnodes:
                    if minlen>LTV[n]-self.edge[vidx][n]:
                        minlen=LTV[n]-self.edge[vidx][n]
                LTV[vidx]=minlen
            else: #没有后驱,终止点
                LTV[vidx]=0

        return LTV

测试结果如下:

[-15, -11, -11, -6, -7, 0]

到现在为止,我们得到了途中每个顶点的最早开始时间和最晚开始时间。我们最终在实际使用这个LTV的时候,终止顶点不是给的0值,而是给的最早开始时间的最大的值,一是因为我们不想用负数,二是之后我们需要比较ETV和LTV。所以上图最后LTV会变换成[0, 4, 4, 9, 8, 15]。
变换代码如下:

        #变换为正值
        maxv=-1*min(LTV)
        LTV=list(map(lambda x: x + maxv, LTV))

根据上面的ETV和LTV,我们来计算每条边的最早开始ETE和最晚开始LTE(工程计划中,边代表活动),这是比较容易计算出来的:
ETE[i,j]=ETV[i],一条连接Vi和Vj的边,它的最早开始就是顶点Vi的最早开始;
LTE[i,j]=LTV[j]-W[i,j],一条连接Vi和Vj的边,它的最晚开始就是顶点Vj的最晚开始减去活动持续时间。
代码如下:

    def getETE(self):
        ETE=[[self.MAX_INT]*self.vsize for i in range(self.vsize) ]
        ETV=self.getETV()
        for i in range(self.vsize):
            for j in range(self.vsize):
                if self.edge[i][j]!=self.MAX_INT:
                    ETE[i][j]=ETV[i]                    
        return ETE

    def getLTE(self):
        LTE=[[self.MAX_INT]*self.vsize for i in range(self.vsize) ]
        LTV=self.getLTV()
        for i in range(self.vsize):
            for j in range(self.vsize):
                if self.edge[i][j]!=self.MAX_INT:
                    LTE[i][j]=LTV[j]-self.edge[i][j]                    
        return LTE    

到现在这个时候,我们也求出了每条边的最早开始和最晚开始,如果某条边的最早开始和最晚开始是一样的值,说明这个活动不能早开始也不能晚开始,一点富余都没有,就说明这是关键活动,这些关键活动连在一起就是关键路径。
我们把ETE最早开始和LTE最晚开始的差叫做时间余量TM,可以简单求出来:

    def getTM(self):
        ETE=self.getETE()
        LTE=self.getLTE()
        TM=[[self.MAX_INT]*self.vsize for i in range(self.vsize) ]
        for i in range(self.vsize):
            for j in range(self.vsize):
                if self.edge[i][j]!=self.MAX_INT:
                    TM[i][j]=LTE[i][j]-ETE[i][j]
        return TM
最后打印出来时间余量为0的这些关键路径:
    def CP(self):
        TM=self.getTM()
        for i in range(self.vsize):
            for j in range(self.vsize):
                if TM[i][j]==0:
                    print(i,j)

测试运行结果:

0 1
1 3
3 5

表示V1-V2-V4-V6四个顶点三条边组成的路径。
到此我们就把几种典型的数据结构的基本操作介绍完了。计算机编程课,如果只挑出一门来,那就是数据结构最重要。虽然现在的语言和工具集已经为我们提供了丰富的结构和实用的实现,但是并不表示我们自己不需要深入理解。数据结构是一门抽象、研究数据集合和集合中元素之间关系的学科,很锻炼学习者的逻辑思考、理解能力。有了这些,你就能走得很远,不仅仅只是在Copy&Paste重复日常的搬砖工作。这些基础知识会为你提供源源不断的动力,一路辅佐你前行。
古人说过:问渠哪得清如许,为有源头活水来。