1. 程式人生 > >關鍵路徑演算法詳解

關鍵路徑演算法詳解

對於工程管理,人們最關注的兩個問題分別是工程是否能順利進行,以及估算整個工程完成所需要的最短時間和影響工程時間的關鍵活動。前一個問題可用拓撲排序解決,後一個問題則需要找出工程進行的關鍵路徑,關鍵路徑上的活動完成所需要的時間就是工程完成所需要的最短時間。

關鍵路徑通常是所有工程活動中最長的路徑,關鍵路徑上的活動如果延期將直接導致工程延期。

利用 AOV 網表示有向圖,可以對活動進行拓撲排序,根據排序結果對工程中活動的先後順序做出安排。但是尋找關鍵路徑,估算工程活動的結束時間,則需要使用 AOE 網表示有向圖。

AOE 網中用頂點表示事件,有向邊表示活動,邊上的權值表示活動持續的時間。只有在某頂點所代表的事件發生後,從該頂點出發的各有向邊所代表的活動才能開始,反之亦然,只有在指向某一頂點的各有向邊所代表的活動都己經結束後,該頂點所代表的事件才能發生。

AOE 網只有一個入度為0的頂點(源點)和一個出度為 0 的頂點(匯點),分別代表開始事件和結束事件,其他的頂點則表示兩個意義,其一是此點之前的所有活動都己經結束,其二是此點之後的活動可以開始了。

計算關鍵路徑的演算法需要根據AOE網的特徵調整圖的資料結構定義,本節介紹的演算法仍然使用鄰接表來表示圖,但是需要重新定義頂點和邊的資料結構。因為 AOE 網的邊代表具體的活動,需要在資料結構中明確體現“邊”的定義,調整後的邊和頂點的定義如下所示:
typedef struct tagEdgeNode {
    int vertexlndex; //活動邊終點頂點索引 std::string name; //活動邊的名稱
    int duty;   //活動邊的時間(權重)
}EDGE_NODE;

typedef struct tagVertexNode {
    int sTime; //事件最早開始時間
    int eTime; //事件最晚開始時間
    int inCount; //活動的前驅節點個數
    std::vector<EDGE_NODE> edges; //相鄰邊表
}VERTEX_NODE;
演算法開始之前,每個頂點的 sTime 被初始化為 0,eTime 被初始化為一個有效範圍之外的最大值(0x7FFFFFFF),演算法結束之後,sTime 和 eTime 會被計算為實際的時間值。

什麼是關鍵路徑

開始討論關鍵路徑之前,先來介紹一下活動的最早開始時間和最晚開始時間。

工程中一個活動何時開始依賴於其前驅活動何時結束,只有所有的前驅活動都結束後這個活動才可以開始,前驅活動都結束的時間就是這個活動的最早開始時間。與此同時,在不影響工程完工時間的前提下,有些活動的開始時間存在一些餘量,在時間餘量允許的範圍之內推遲一段時間開始活動也不會影響工程的最終完成時間,活動的最早開始時間加上這個時間餘量就是活動的最晚開始時間。活動不能在最早開始時間之前開始,當然,也不能在最晚開始時間之後開始,否則會導致工期延誤。

如果一個活動的時間餘量為 0,即該活動的最早開始時間和最晚開始時間相同,則這個活動就是關鍵活動,由這些關鍵活動串起來的一個工程活動路徑就是關鍵路徑。根據關鍵路徑的定義,一個工程中的關鍵路徑可能不止一個,我們常說的關鍵路徑指的是工程時間最長的那條路徑,也就是從源點到匯點之間最長的那條活動路徑。

計算關鍵路徑的演算法

計算關鍵路徑的基礎是先找出工程中的所有關鍵活動,確定一個活動是否是關鍵活動的依據就是活動的最早開始時間和最晚開始時間,因此需要先介紹如何計算活動的最早開始時間和最晚開始時間。

在 AOE 網中,事件 ei 必須在指向 ei 的所有活動都結束後才能發生,只有 ei 發生之後,從 ei 發出的活動才能開始,因此 ei 的最早發生時間就是 ei 發出的所有活動的最早開始時間。

如果用 est[i] 表示事件 ei 的最早開始時間,用 duty[i,y] 表示連線事件 ei 和事件 ej 的活動需要持續的時間,則事件 ei 的最早開始時間可以用以下關係推算:

est[0] = 0
est[n] = max{est[i]+duty[i,n], est[j]+duty[j,n],...., est[k]+duty[k,n]}(其中i,j,...k是事件n的前驅事件)

這個推算關係是建立在合法的拓撲序列的基礎上的,因此,推算事件的最早開始時間需要對圖中的事件節點進行拓撲排序。本節我們只關注最早開始時間的計算方法。

假設 sortedNode 引數中存放的圖的拓撲排序結果,CalcESTime() 函式從拓撲序列的第一個頂點開始(變數 u 代表的頂點),遍歷這個頂點發出的有向邊指向的相鄰頂點(變數 v 代表的頂點),如果該頂點的最早開始時間與有向邊代表的活動持續時間的和(這個結果存放在臨時變數 uvst 中)大於有向邊指向的相鄰頂點的最早開始時間,則更新這個相鄰頂點的最早開始時間。

需要注意的是,演算法並沒有直接利用推算關係中的 max 選擇處理,而是按照 sortedNode 序列中的頂點先後關係,只在處理到相鄰頂點時才更新最早開始時間(這正是所有頂點的 sTime 被初始化成 0 的原因),當 sortedNode 序列中的所有頂點都處理完之後,就相當於變相地實現了max 選擇的處理。
void CalcESTime(GRAPH *g, const std::vector<int>& sortedNode)
{
    g->vertexs[0].sTime = 0; //est[0] = 0
    std::vector<int>::const_iterator nit = sortedNode.begin();
    for(; nit != sortedNode.end(); ++nit)
    {
        int u = *nit;
        //遍歷u出發的所有有向邊
        std::vector<EDGE_NODE>::iterator eit = g->vertexs[u].edges.begin();
        for(; eit != g->vertexs[u].edges.end(); ++eit)
        {
            int v = eit->vertexlndex;
            int uvst = g->vertexs[u].sTime + eit->duty;
            if(uvst > g->vertexs[v].sTime)
            {
                g->vertexs[v].sTime = uvst;
            }
        }
    }
}
事件 ei 的最晚開始時間定義為:ei 的後繼事件 ej 的最晚開始時間減去 ei 和 ej 之間的活動的持續時間的差,當 ei 有多個後繼事件時,則取這些差值中最小的一個作為 ei 的最晚開始時間。如果用 lst[j] 表示事件 ej 的最晚開始時間,用 dutyt[i,j] 表示事件 ei 和後繼事件 ej 之間的活動需要持續的時間,則事件 ei 最晚開始時間可以用以下關係推算:

lst[n] = est[n]
est[i] = min{lst[j]-duty[i,j], est[k]-duty[i,k],...,est[m]-duty[i,w]}
(其中j,k...,m是事件i的後繼事件)

這個最晚開始時間的推算關係是建立在合法的拓撲序列的逆序基礎上的,CalcLSTime() 函式對 sortedNode 序列的處理順序和 CalcESTime() 函式剛好相反,從拓撲序列的最後一個頂點(變數 u 代表的頂點)開始向前遍歷。如果該頂點的後繼頂點(變數 v 代表的頂點)的最晚開始時間與連線這兩個頂點的活動的持續時間的差小於該頂點(u 頂點)的最晚開始時間,則更新該頂點的最晚開始時間。

和 CalcESTime() 函式一樣,CalcLSTime() 函式也沒有直接利用 min 選擇處理,但是通過逆序遍歷 sortedNode 序列中的所有頂點,變相地實現了 min 選擇的處理。
void CalcLSTime(GRAPH *g, const std::vectors<int>& sortedNode)
{
    //最後一個節點的最晚開始時間等於最早開始時間
    g->vertexs[g->count - 1].eTime = g->vertexs[g->count - 1].sTime;
    std::vector<int>::const_reverse_iterator cit = sortedNode.rbegin();
    for(; cit != sortedNode.rend(); ++cit)
    {
        int u = *cit;
        //遍歷u出發的所有有向邊
        std::vector<EDGE_NODE>::iterator eit = g->vertexs[u].edges.begin();
        for(; eit != g->vertexs[u].edges.end(); ++eit)
        {
            int v = eit->vertexlndex;
            int uvet = g->vertexs[v].eTime - eit->duty;
            if(uvet < g->vertexs[u].eTime)
            {
                g->vertexs[u].eTime = uvet;
            }
        }
    }
}
在 AOE 網中計算好每個頂點代表的事件的最早開始時間和最晚開始時間之後,就可以很容易計算出每條邊代表的活動的最早開始時間和最晚幵始時間。

假如某個活動兩端的事件分別是 ei 和 ej,則該活動的最早開始時間就是事件 ei 的最早開始時間,該活動的最晚開始時間就是事件 ej 的最晚開始時間減去該活動的持續時間。用這個關係計算出所有活動的最早開始時間和最晚開始時間,只要最早開始時間和最晚開始時間相同的活動都是關鍵活動,按照事件頂點的拓撲序列的先後關係,順序輸出這些事件頂點相關的關鍵活動,得到的關鍵活動序列就是關鍵路徑。

綜合前面的分析,計算關鍵路徑的需要以下四個步驟。
  1. 對事件頂點進行拓撲排序,得到事件的拓撲序列;
  2. 計算事件頂點的最早開始時間;
  3. 計算事件頂點的最晚開始時間;
  4. 計算活動的最早幵始時間和最晚開始時間,並按照事件的拓撲順序逐次輸出關鍵活動,得到關鍵路徑。

這四個步驟非常清晰地體現在 CriticalPath() 函式中,重點是第四個步驟輸出關鍵路徑。判斷活動是否是關鍵活動是通過這行 if 語句實現的:

if(g->vertexs[u].sTime == g->vertexs[v].eTime - eit->duty)

但是要實現按照活動順序輸出關鍵活動路徑的功能,還需要按照事件頂點拓撲排序的結果逐個判斷每個事件發出的活動(就是事件頂點發出的有向邊),按照活動的開始次序逐個輸出關鍵活動。

CriticalPath() 函式中的第一個 for 迴圈就是按照拓撲排序的結果逐個處理事件頂點,第二個 for 迴圈就是搜尋一個頂點的所有有向邊,查詢關鍵活動。
bool CriticalPath(GRAPH *g)
{
    std::vector<int> sortedNode;
    if(!TopologicalSorting(g, sortedNode)) //步驟 1
    {
        return false;
    }
    CalcESTime(g, sortedNode); //步驟2
    CalcLSTime(g, sortedNode); //步驟3
    //步驟4:輸出關鍵路徑上的活動名稱
    std::vector<int>::iterator nit = sortedNode.begin();
    for(; nit != sortedNode.end(); ++nit)
    {
        int u = *nit;
        std::vector<EDGE_NODE>::iterator eit = g->vertexs[u].edges.begin();
        for(; eit != g->vertexs[u].edges.end(); ++eit)
        {
            int v = eit->vertexlndex;
            if(g->vertexs[u].sTime == g->vertexs[v].eTime - eit->duty)
            {
                std::cout << eit->name << std::endl;
            }
        }
    }
    return true;
}
表 1 活動資料
活動名稱 時間(天) 依賴
P1 8
P2 5
P3 6 P1,P2
P4 4 P3
P5 7 P2
P6 7 P4,P5
P7 4 P1
P8 3 P7
P9 4 P4,P8

對於表 1 的活動關係資料,轉化成 AOE 網形式的有向圖之後,用 CriticalPath() 函式計算出的關鍵路徑是 P1-P3-P4-P6