圖論演算法 有圖有程式碼 萬字總結 向前輩致敬

nomasp發表於2015-05-11

圖的定義

背景知識

看到這篇部落格相信一開始映入讀者眼簾的就是下面這幅圖了,這就是傳說中的七橋問題(哥尼斯堡橋問題)。在哥尼斯堡,普雷格爾河環繞著奈佛夫島(圖中的A島)。這條河將陸地分成了下面4個區域,該處還有著7座連線這些陸地的橋樑。

這裡寫圖片描述

問題是如何從某地出發,依次沿著各個橋,必須經過每座橋且每座橋只能經過1次,最終回到原地。

不知道這個問題且好奇的童鞋現在肯定在忙活著找出來這道題的結果了。

是偉大的數學家尤拉(Leonhard Euler)在1736年首次使用圖的方法解決了該問題。

尤拉將上面的模型轉換成了下面這種”圖“的形式。

這裡寫圖片描述

尤拉把頂點的度定義為與該頂點相關聯的邊的條數,並且他證明了存在從任意點出發,經過所有邊恰好一次,並最終回到出發頂點的走法的充分必要條件是:每個頂點的度均為偶數。人們稱之為尤拉閉跡(Eulerian walk)。

簡要定義

graphG=VE

(graph)G = (V,E)
由頂點(vertex)的集V
V
和邊(Edge)的集E
E
組成。頂點代表了物件,在示意圖中我們使用點或圓來表示它;邊代表了兩個物件的連線關係,在示意圖中我們使用連線兩頂點的線段來表示。

有時也把邊稱作弧(arc),如果點對vw

(v,w)
是有序的,那麼圖就叫做有向的圖(有向圖)。如果點對(v,w)
(v, w)
是無序的,那麼圖就叫做無向的圖(無向圖)。簡單的講,邊沒有指向性的圖叫做無向圖,邊具有指向性的圖叫做有向圖。

頂點v

v
w
w
鄰接(adjacent)當且僅當vw
(v,w)
屬於E
E

我們可以給邊賦予各式的屬性,比如權值(cost)。權值可以表示從一個頂點到另一個頂點的距離,也可以表示一個頂點到另一個頂點說話費的代價(比如時間、金錢等)。一個邊上帶權值的圖稱為網路(network)。

如果無向圖中從每一個頂點到其他每個頂點都存在一條路徑,則稱該無向圖是連通的(connected)。具有這樣性質的有向圖稱為是強連通的的(strongly connected)。如果有向圖不是強連通的,但它的基礎圖(underlying graph)(也就是其弧上去掉方向說形成的圖)是連通的,那麼稱該有向圖是弱連通的(weakly connected)。完全圖(complete graph)是其每一對頂點間都存在一條邊的圖。

這裡寫圖片描述

所謂入度(indegree)是指的頂點v

v
的邊uv
(u,v)
的條數。

這裡寫圖片描述

如下表示了一個有著7個頂點和12條邊的有向圖。

這裡寫圖片描述

如果具有n個頂點,e條邊的圖G的頂點i的度為di

d_i
,則G的邊數為:

e=n10di2

e =\frac { \sum_{0}^{n-1} d_i} {2}

以上這個數學公式的markdown“原始碼”:
$ e =\frac { \sum_{0}^{n-1} d_i} {2} $

現在將圖看作抽象資料型別,下面給出ADT圖的結構:

objects 一個非空頂點的集合和一個無向邊的集合,其中每條邊都是一個頂點對
functions 對於所有的 graphGraph
graph \in Graph
v
v
v1
v_1
v2Vertices
v_2 \in Vertices
Graph Create() return一個空圖
Graph InsertVertex (graph, v) 向圖graph中插入沒有關聯邊的新頂點v,return改變後的圖
Graph InsertEdge (graph, v1
v_1
v2
v_2
)
在圖graph的頂點v1
v_1
v2
v_2
之間插入一條邊,return改變後的圖
Graph DeleteVertex (graph, v) 刪除圖graph的頂點v及與其關聯的所有邊,return改變後的圖
Graph DeleteEdge (graph,v1
v_1
v2
v_2
)
刪除圖graph的邊(v1
v_1
,v2
v_2
),頂點v1
v_1
,v2
v_2
不刪除,return改變後的圖
Boolean IsEmpty (graph) if(graph==空圖) return TRUE,else return FALSE
List Adjacent (graph, v) return頂點v的所有鄰接結點

圖的儲存表示方式

圖主要有3種常用的儲存表示方式:鄰接矩陣(adjacency matrices),鄰接表(adjacency lists),鄰接多重表(adjacency multilists)。

鄰接矩陣

鄰接矩陣使用|V||V|

|V|*|V|
的二維陣列來表示圖。g[i][j]
g[i][j]
表示的是頂點i
i
和頂點j
j
的關係。

1)因為在無向圖中,我們只需要知道頂點i

i
和頂點j
j
是否是相連的,因此我們只需要將g[i][j]
g[i][j]
g[j][j]
g[j][j]
設定為1或是0表示相連或不相連即可。如下圖所示。

這裡寫圖片描述

2)而在有向圖中,我們只需要知道是否有從頂點i

i
到頂點j
j
的邊,因此如果頂點i
i
有一條指向頂點j
j
的邊,那麼g[i][j]
g[i][j]
就設為1,否則設為0。有向圖與無向圖不同,並不需要滿足g[i][j]=g[j][i]
g[i][j]=g[j][i]

這裡寫圖片描述

3)在帶權值的圖中,g[i][j]

g[i][j]
表示的是頂點i到頂點j的邊的權值。由於在邊不存在的情況下,如果將g[i][j]
g[i][j]
設為0,就無法和權值為0的情況區分開來,因此選取適當的較大的常數INF(只要能和普通的權值區別開來就可以了),然後令g[i][j]=INF
g[i][j]=INF
就好了。當然,在無向圖中還是要保持g[i][j]=g[j][i]
g[i][j]=g[j][i]
。在一條邊上有多種不帶權值的情況下,定義多個同樣的|V||V|
|V|*|V|
陣列,或者是使用結構體或類作為陣列的元素,就可以和原來一樣對圖進行處理了。

這裡寫圖片描述

使用這種儲存方式,可以很方便地判斷任意兩個頂點之間是否有邊以及確定頂點的度,這也是這種表示法最大的優勢。任意一個頂點i的度等於其鄰接矩陣中頂點i所對應的行中的數字之和:

n1j=0adjmat[i][j]

\sum_{j=0}^{n-1} adjmat[i][j]

以上這個數學公式的markdown“原始碼”:
$ \sum_{j=0}^{n-1} g[i][j] $

在這種表示法中掃描所有邊至少需要O(n2)

O(n^2)
時間,因為必須檢查矩陣中的n2n
n^2-n
個元素才能確定圖中邊的條數(鄰接矩陣對角線上的n個元素都是0,因此不用檢查。又因為無向圖的鄰接矩陣是對稱的,實際只需檢查鄰接矩陣的一半元素)。通常把邊很少的圖成為稀疏圖(sparse graphs)。

鄰接表

如果用鄰接矩陣表示稀疏圖就會浪費大量記憶體空間,而用連結表,則是通過把頂點所能到的頂點的邊儲存在連結串列中來表示圖,這樣就只需要O(|V|+|E|)

O(|V|+|E|)
的記憶體空間。

這裡寫圖片描述

而所謂的鄰接表,就是用n個連結串列代替鄰接矩陣中的n行。連結串列中的結點結構至少要包含一個頂點域和一個鏈域。對於任意給定的連結串列i,連結串列中的結點就是與頂點i相鄰的所有頂點。鄰接表儲存宣告的C語言宣告如下:

#define MAX_VERTICES 50 
typedef struct node *node-pointer;
typedef struct node
{
    int vertex;
    struct node *link;
};
node_pointer graph[MAX_VERTICES];
int n=0;

鄰接多重表

在無向圖的鄰接表儲存表示中,每一條邊(vivj)

( v_i,v_j )
都表示為兩項:一項在頂點vi
v_i
的鄰接表中,而另一項在頂點 vj
v_j
的鄰接表中。在多重表中,各連結串列中的結點可以被幾個連結串列共享,此時圖中的每一條邊只對應於一個結點,而這個結點出現在該邊所關聯的兩個頂點的每個鄰接連結串列中。如下圖所示:

marked vertex1 vertex2 path1 path2

鄰接多重表結點結構的C語言宣告為:

typedef struct edge *edge-pointer
typedef struct edge
{
    short int marked;
    int vertex1;
    int vertex2;
    edge_pointer path1;
    edge_pointer path2;
};

圖的基本操作和演算法

廣度優先搜尋

請先忽視下圖中所有的下標,讓我們從頭開始。隨意選擇一個點,此處選擇v3

v3
,作為切入點。因此到v3
v3
的距離為0。從v3
v3
出發,距離為1的結點是v1
v1
v6
v6
;繼續下一步,v6
v6
已經無路可走,而與v1
v1
距離為1的是v2
v2
v4
v4
,因此對它們標記上2;繼續下去,v2
v2
v4
v4
走一步都可以到v5
v5
v4
v4
走一步可以到v7
v7
,因此v5
v5
v7
v7
被標記為3。至此搜尋便結束了。

這就是廣度優先搜尋(breadth-first search),該方法按層處理頂點。距起始點最近的那些頂點首先被求值,最遠點則最後被求值,這很像對樹的層序遍歷(level-order traversal)。

這裡寫圖片描述

為了實現廣度優先搜尋,可以使用動態連結佇列。在佇列中的每個頂點都包含兩個域:頂點的序號和連結指標。

函式bfs所使用的佇列的定義和函式原型宣告為:

typedef struct queue *queue_pointer;
typedef struct queue
{
    int vertex;
    queue_pointer link;
};
void addq(queue_pointer *, queue_pointer *,int);
int deleteq(queue_pointer *);

圖的廣度優先搜尋演算法:

void bfs(int v)
{
    node_pointer w;
    queue_pointer front,rear;
    front=rear=NULL;
    printf("%5d",v);
    visited[v]=TRUE;
    addq(&front,&rear,v);
    while(front)
    {
        v=deleteq(&front);
        for(w=graph[v];w;w=w->link)
        {
            if(!visited[w->vertex])
            {
                printf("%5d",w->vertex);
                addq(&front,&rear,w->vertex);
                visited[w->vertex]=TRUE;
            }
        }
    }
}

圖中每個頂點都被存入佇列一次,所以該演算法中的while迴圈至多重複n次。如果採用鄰接表儲存表示,那麼該演算法所需要的時間為:

d0+d1++dn1=O(e)

d_0 + d_1 + … + d_{n-1} = O(e)

其中di

d_i
為頂點 vi
v_i
的度。

而如果採用鄰接矩陣來實現,那麼對於每個頂點的訪問,while迴圈的時間為O(n)

O(n)
,所以演算法的總耗時為O(n2)
O(n^2)
。和接下來的深度優先搜尋一樣,一次廣度優先搜尋訪問到的頂點以及與這些頂點相關聯的邊形成的圖G的一個連通分支。

深度優先搜尋

深度優先搜尋內容較多,已經在下文中單獨列出。

連通圖

使用以上的兩種搜尋演算法也可以用來判斷一個無向圖是否是連通的。具體步驟如下:

1.呼叫bfs(0)或dfs(0)
2.檢查是否存在未被訪問過的頂點

具體程式碼如下:

void connected(void)
{
    int i;
    for(i=0;i<n;i++)
    {
        if(!visited[i])
        {
            dfs(i);
            printf("\n");
        }
    }
}           

演算法分析:如果採用鄰接表儲存,那麼函式dfs時間開銷為O(e)

O(e)
。這裡for迴圈的時間開銷為O(n)
O(n)
,所以整個演算法的時間複雜性為O(n+e)
O(n+e)

雙連通圖

雙聯通圖(biconnected graph)是沒有關節點的連通圖。對此有一個比較重要的公式如下:

low(u) = min{dfn(u), min{low(w)|wu的兒子}, min{dfn(w)|(u,w)是一條回退邊} }

回退邊也叫back edge,大家顧名思義就好,下面有更多應用。

下面來段求解圖的雙連通分支的演算法:

void bicon(int u, int v)
{
    node_pointer ptr;
    int w,x,y;
    dfn[u]=low[u]=num++;
    for(ptr=graph[u];ptr;ptr=ptr->link)
    {
        w=ptr->vertex;
        if(v!=w && dfn[w]<dfn[u])       
            add(&top,u,w);
        if(dfn[w]<0)
        {
            bicon(w,u);
            low[u]=MIN2(low[u],low[w]);
            if(low[w]>=dfn[u])
            {
                printf("New biconnected component: ");
                do
                {
                    delete(&top,&x,&y);
                    printf(" <%d,%d>",x,y);
                }while(!((x==u)&&(y==w)));
                printf("\n");
            }
        }
        else if(w!=v)
            low[u]=MIN2(low[u],dfn[w]);
    }
}

拓撲排序

拓撲排序(topological sort)是對有向無環圖的頂點的一種排序,它使得如果存在一條從vi到vj的路徑,那麼在排序中vj出現在vi的後面。正是由於這個特性,如果圖含有迴路,那麼拓撲排序是不可能的。

這裡寫圖片描述

拓撲排序簡單的說,就是將上圖變成下圖。

這裡寫圖片描述

求拓撲排序演算法的一種簡單方式:選中一個沒有入邊的頂點,顯示出該點,並將它和它的邊一起從圖中刪除,然後對圖的其餘部分應用同樣的方法處理。

假設每一個頂點的入度被儲存且圖被讀入一個鄰接表中,下面的程式碼則可以生成一個拓撲排序。

這裡寫圖片描述

對上圖應用拓撲排序的結果如下:

這裡寫圖片描述

最短路徑演算法

單源最短路徑問題:給定一個加權圖G=(V,E)和一個特定頂點s作為輸入,找出從s到G中每一個其他點的最短加權路徑。

如下圖所示,從v1到v6的最短加權路徑的值為6(v1v4v7v6

v1-v4-v7-v6
),從v2
v2
v5
v5
的最短加權路徑的值為5(v2v4v5
v2-v4-v5
)。

這裡寫圖片描述

下面這個圖從v5到v4的最短加權路徑可就有意思了,它是1麼?不是。按照v5v4v2v5v4

v5-v4-v2-v5-v4
的路徑走則是一條更短的路徑了,因為這是帶負值迴路的圖。而由於帶負值而引入的迴圈,這個迴圈叫做負值迴路(negative-cost cycle),當它出現在圖中時,最短路徑問題就是不確定的了。有負值的邊未必不好,但它們明顯使問題更加難了。

這裡寫圖片描述

當未指明所討論的是加權路徑還是無權路徑時,如果圖是加權的,那麼路徑就是加權的。

下面列出單源最短路徑演算法:

void shortestpath(int v,int cost[][MAX_VERTICES],int distance[],int n,short int found[])
{
    int i,u,w;
    for(i=0;i<n;i++)
    {
        found[i]=FALSE;
        distance[i]=cost[v][i];
    }
    found[v]=TRUE;
    distance[v]=0;
    for(i=0;i<n-2;i++)
    {
        u=choose(distance,n,found);
        found[u]=TRUE;
        for(w=0;w<n;w++)
            if(!found[w])
                if(distance[u]+cost[u][w]<distance[w])
                    distance[w]=cost[u][w]+distance[u];
    }
}

int choose(int distance[],int n,short int found[])
{
    int i,min,minpos;
    min=INT_MAX;
    minpos=-1;
    for(i=0;i<n;i++)
        if(distance[i]<min && !found[i])
        {
            min=distance[i];
            minpos=i;
        }
    return minpos;
}

思考:找出A到所有其他頂點的最短路徑以及B到所有其他頂點的最短無權路徑。

這裡寫圖片描述

如果要求所有頂點對之間的最短路徑,可以用下面這個演算法:

void allcosts(int cost[][MAX_VERTICES],int distance[][MAX_VERTICES],int n)
{
    int i,j,k;
    for(i=0;i<n;i++)
        for(j=0;j<n;j++)
            distance[i][j]=cost[i][j];
    for(k=0;k<n;k++)
        for(i=0;i<n;i++)
            for(j=0;j<n;j++)
                if(distance[i][k]+distance[k][j]<distance[i][j])
                    distance[i][j]=distance[i][k]+distance[k][j];
}

傳遞閉包

我們由一個問題引入傳遞閉包的概念。有一個邊不帶權值的有向圖G,要判斷任意兩個頂點i 和j 之間是否存在一條路徑,此處有兩種情況,一種是路徑長度為正數,一種是路徑長度為非負。以上兩種情況分別被稱為圖的傳遞閉包(transitive closure),和自反傳遞閉包(reflexive transitive closure)。

傳遞閉包矩陣(transitive closure matrix)是一個矩陣,記作A+

A^+
,如果從頂點i到j存在一條長度大於0的路徑,則A+[i][j]=1
A^+ [i][j] = 1
,否則A+[i][j]=0
A^+ [i][j] = 0

自反傳遞閉包矩陣是一個矩陣,記作A

A^*
,如果從頂點i到j存在一條長度大於0的路徑,則A[i][j]=1
A^* [i][j] = 1
,否則A[i][j]=0
A^* [i][j] = 0

Dijkstra演算法

前面的廣度優先搜尋中的圖是無權圖,而如果一旦變成了加權圖,那麼問題就變得困難起來了。

對於每個頂點,我們標記為known以及unknown,和上面一樣,還得有一個距離的dv

d_v
。與無權最短路徑一樣,Dijkstra演算法也是按階段進行,在每個階段選擇一個頂點v,它在所有unknown頂點中具有最小的dv
d_v
,同時演算法宣告從s
s
v
v
的最短路徑是known的。然後緊接著,不斷的進行下去即可。

那麼這個演算法到底是怎麼回事了?請看下圖。

這裡寫圖片描述

圖中已經對權重做好了標記,以v1

v1
作為切入點,因此初始情況如下左圖。

v1

v1
此時已經是known的了,而其有2個鄰接點v2
v2
v4
v4
,因此可以調整為如下右圖。正無窮圖示標識沒有連通。pv
p_v
表示前一個鄰接點。

這裡寫圖片描述

毫無疑問這裡會接下來走到v4去,因為v4的權重為1比v2的權重為2要小。調整為如下左圖。

這裡寫圖片描述

可能你已經看到了上圖中的右圖而好奇為什麼下一步是v2

v2
,但是v4
v4
根本不能走到v2
v2
。因為v4
v4
能夠走到的,比如v3
v3
,權重從v1
v1
開始一共是3,這比從v1
v1
v2
v2
還要大。於是就跳轉回到了v2
v2

下一步便走到了v5

v5
,因為只有值為3的權重,同樣的v3
v3
也是,於是它們倆被雙雙標記為known。如下左圖所示。

緊接著走到了v7

v7
,同時v6
v6
下調到了5+1=6
5+1=6
得到了如下右圖。至於為什麼要做這個調整,是因為此時v1
v1
v7
v7
的加權為1+4=5
1+4=5
,而v7
v7
v6
v6
的加權為1,所以就有了這個調整。

這裡寫圖片描述

最後便順勢走到了v6完成了整個Dijkstra演算法,它們都已被標記為known。

這裡寫圖片描述

在後面還將會有一種斐波那契堆,針對Dijkstra演算法做了優化,歡迎大家的繼續關注。

具有負邊值得圖

而如果一個圖具有負的邊值,那麼Dijkstra演算法就行不通了。這是因為一個頂點u被宣告為known後,那就可能從某個另外的unknown頂點v有一條回到u的負的路徑。而“回到”就意味著迴圈,前面的例子中我們已經知道了迴圈是多麼的……

問題並非沒有解決的辦法,如果我們有一個常數X,將其加到每一條邊的值上,這樣除去負的邊,再計算新圖的最短路徑,最後把結果應用到原圖上。然後這個解決方案也是佈滿了荊棘,因為居多許多條邊的路徑變得比那些具有很少邊的路徑權重更重了。如果我們將s放到佇列中,然後再每一個階段讓一個頂點v出隊,找出所有與v鄰接的頂點w,使得dw>dv+cv,w

d_w>d_v+c_{v,w}
,然後更新到dw
d_w
pw
p_w
,並在w
w
不在佇列中時將它放到佇列中,可以為每一個頂點設定一個位(bit)以指示它在佇列中出現的情況。

無環圖

如果圖是無環的,則可以通過改變宣告頂點為known的順序,或者叫做頂點選取法則來改進Dijkstra演算法。這種方法通過拓撲排序來選擇頂點,由於選擇和更新可以在拓撲排序執行的過程中執行,因此新的演算法只需要一趟就可以完成。

通過下面這個動作結點圖(activity-node graph)來解釋什麼是關鍵路徑分析(critical path analysis)再合適不過了。一條邊vw

(v,w)
表示動作v必須在動作w開始前完成,如前面說描述的那樣,這就意味著圖必須是無環的。

這裡寫圖片描述

為了進行這些運算,我們把動作結點圖轉化成事件結點圖(event-node graph),每個事件對應於一個動作和所有與它相關的動作完成。

這裡寫圖片描述

所以現在我們需要找出事件的最早完成時間,只要找出從第一個事件到最後一關事件的最長路徑的長。因為有正值迴路(positive-cost cycle)的存在最長路徑問題常常是沒有意義的。而由於事件結點圖是無環圖,那就不需要擔心迴路的問題了,這樣一來就不用有所顧忌了。

以下是最早完成時間。

這裡寫圖片描述

以下是最晚完成時間。

這裡寫圖片描述

藉助頂點的拓撲排序計算最早完成時間,而最晚完成時間則通過倒轉拓撲排序來計算。

而事件結點圖中每條邊的鬆弛時間(slack time)代表對應動作可以被延遲而不推遲整體完成的時間量,最早完成時間、最晚完成時間和鬆弛時間如下所示。

這裡寫圖片描述

某些動作的鬆弛時間為0,這些動作是關鍵性的動作,它們必須按計劃結束。至少存在一條完成零-鬆弛邊組成的路徑,這樣的路徑是關鍵路徑(critical path)。

網路流問題

如下左圖所示,有一個頂點s

s
,稱為源點(source);還有一個頂點t
t
,稱為匯點(sink)。對於頂點c
c
,它最大流出2,因此它的最大流入為2,如下右圖所示。而t
t
的最大流也就是5。

這裡寫圖片描述

要想計算最大流,同樣可是使用前面的思想——分階段進行。令開始時所有邊都沒有流,如下中間圖所示。我們可以用殘餘圖(residual graph)來表示對於每條邊還能再新增上多少流。對於每一條邊,可以從容量中減去當前的流而計算出殘留的流。

這裡寫圖片描述

第一步:假設我們選擇sbdt

s-b-d-t
路徑,此時會發出2個單位的流通過這條路徑的每一邊,如下中間圖所示。對比左圖,我們做如下約定:一旦注滿(使飽和)一條邊,例如a
a
b
b
b
b
d
d
,就將這條邊從殘餘圖(也就是中間圖)去掉,如下右圖所示。

這裡寫圖片描述

第二步:接下來選擇sact

s-a-c-t
路徑,此時也會發出2個單位的流通過這條路徑的每一邊,如下中間圖所示(只看sact
s-a-c-t
即可,sbdt
s-b-d-t
為上一步說走過的路徑)。同樣將殘餘圖更新如下右圖所示。

這裡寫圖片描述

第三步:從上圖的殘餘圖中我們已經可以看出來最後一步的唯一一種走法了,也就是從sadt

s-a-d-t
。做如下圖所示更新。

這裡寫圖片描述

很顯然從t

t
無法走到s
s
,因此演算法至此便終止了。因此正好5個單位的流是最大值。前面的三步我們走的如此順利,那麼問題真的如此簡單麼?

如果一開始我們選擇了sadt

s-a-d-t
,那麼演算法就會失敗了,因為路已經被堵死了。

這裡寫圖片描述

為了使演算法得以成功運作,那麼就要讓流圖中具有以相反方向傳送流的路徑,如下所示。那麼對於如下右圖中的殘餘圖而言,從d返回到a的便成了3而非4,這是因為從t流到d的流量是3個單位。現在在殘餘圖中就有a和d之間有2個方向,或者還有1個單位的流可以從a

a
導向d
d
,或者是3個單位的流導向相反的反向,當然,我們也可以撤銷流。

這裡寫圖片描述

緊接著如果通過d

d
a
a
匯入2個單位的流,演算法就會從邊ad
(a,d)
取走2個單位的流,更新流圖如下。

這裡寫圖片描述

思考:找出下面網路中的一個拓撲排序以及最大流。

這裡寫圖片描述

活動網路

AOV網路

除了一些不能再簡單的工程外,所有的工程都可以劃分為若干個成為活動(activities)的子工程。比如說大學的課程,得修完了大學英語1才能修大學英語2,也得修完了高等數學才能修線性代數、概率論、離散數學等。將這些作為頂點標記為圖時,它便是一個有向圖。

頂點表示活動的網(activity on vertex network)或AOV網,是用頂點表示活動或任務,邊表示活動或任務之間的優先關係的有向圖G。在AOV網路G中,當且僅當從頂點i到j存在一條有向路徑,則頂點i稱為頂點j的前驅(predecessor);當且僅當<i,j>

<i , j>
是G中的一條邊,則稱頂點i為頂點j的直接前驅(immediate predecessor)。如果頂點i是頂點j的前驅,則稱頂點j為頂點i的後繼(successor);如果頂點i是頂點j的直接前驅,則稱頂點j
j
為頂點i
i
的直接後繼。

拓撲排列是由有向圖中所有頂點形成一個線性序列,對圖中任意兩個頂點i

i
j
j
,如果頂點i
i
是頂點j
j
的前驅,則頂點i
i
在拓撲序列中排在頂點j
j
的前面。

我們在前面已經介紹了拓撲排序,這裡給出它的虛擬碼。

for(i=0;i<n;i++)
{
    if every vertex has a predecessor
    {
        fprintf(stderr,"Network has a cycle.\n");
        exit(1);
    }
    pick a vertex v that has no predecessors;
    output v;
    delete v and all edges leading out of v from the netwok;
}

對於拓撲排序問題,所需的操作主要有:
1)判斷頂點是否有前驅;
2)刪除頂點和關聯於該頂點的所有邊。

在操作1中,我們可以在每個頂點中都儲存其直接前驅個數的計數。對於操作2,可以使用前面介紹過的鄰接表來表示AOV網路。於是可以將其實現為以下C程式碼:

// 宣告
typedef struct node *node_pointer;
typedef struct node
{
    int vertex;
    node_pointer link;
};
typedef struct 
{
    int count;
    node_pointer link;
}hdnodes;
hdnodes graph[MAX_VERTICES];

// 函式
void topsort(hdnodes graph[],int n)
{
    int i,j,k,top;
    node_pointer ptr;
    top=-1;
    for(i=0;i<n;i++)
    {
        if(!graph[i].count)
        {
            graph[i].count=top;
            top=i;
        }
    }
    for(i=0;i<n;i++)
    {
        if(top==-1)
        {
            fprintf(stderr,"\nNetwork has a cycle. Sort terminated.\n");
            exit(1);
        }
        else
        {
            j=top;
            top=graph[top].count;
            printf("v%d, ",j);
            for(ptr=graph[j].link;ptr;ptr=ptr->link)
            {
                k=ptr->vertex;
                graph[k].count--;
                if(!graph[k].count)
                {
                    graph[k].count=top;
                    top=k;
                }
            }
        }
    }
}   

在topsort的宣告中,count域用來儲存頂點的入度,而link域則是指向鄰接表首結點的指標。鄰接表中的每個結點又包含了兩個域:vertex和link。在輸入時,可以方便地設定count域的值。當輸入一條邊<i,j>

<i, j>
時,頂點j的count就會加1。用一個棧來儲存count值為0的頂點序列。當然也可以使用佇列,但棧更容易實現。由於在count域減至0以後,count域就沒有用了,所以通過頭結點的count域把棧中的各個結點連結起來。

對於topsort的分析:對於具有n個頂點和e條邊的AOV網路,第一個for迴圈的時間開銷為O(n)

O(n)
。而第二個for迴圈執行n次。if子句在常數時間內完成;else子句中的for迴圈時間開銷為O(di
d_i
),其中di
d_i
是頂點i的出度。由於這個迴圈會在每個頂點輸出時執行一次,所以總時間為:

O((n1i=odi)+n)=O(e+n)

O((\sum_{i=o}^{n-1} d_i)+n)=O(e+n)

因此這個演算法的漸進時間為O(e+n),與問題的規模呈線性關係。

AOE網路

AOE網路就是邊表示活動的網路(activity on edge network),它的有向邊表示在一個工程中所需完成的任務或活動,而頂點表示事件,用來標識某些活動的完成。在AOV網路中,事件高數2完成意味著要先完成高數1;AOE網路中,事件高數2完成意味著已經完成了高數1。也就是說在AOE中,當一個事件發生時,就表明觸發該事件的所有活動都已經完成。

在AOE網路中,有些活動可以並行地進行,所以完成整個工程所需的最短時間是從開始頂點到終止頂點的最長路徑的長度。關鍵路徑(critical path)就是一條具有最長路徑長度的路徑。

一個事件vi

v_i
可以發生的最早發生時間(earliest time),是從開始頂點vo
v_o
到頂點vi
v_i
的最長路徑的長度。活動vi
v_i
的最遲開始時間(latest time),記作late(i)
late(i)
,是指在不增加工程工期的前提下,活動ai
a_i
能夠最遲的開始時間。

關鍵網路(critical activity)是指滿足early(i)=late(i)

early(i)=late(i)
的活動,一個活動的最遲開始時間late(i)
late(i)
與最早開始時間early(i)
early(i)
之間的差說明了該活動的關鍵程度。

下面列出兩個比較常用的公式:

1)事件最早發生時間的計算

earliest[j]=maxiP(j){earliest[i]+<i,j>}

earliest[j] = \displaystyle \max_{i \in {P(j)}} \{ earliest[i] + <i , j> 的持續時間 \}

以上這個數學公式的markdown“原始碼”:
$ earliest[j] = \displaystyle \max_{x \in {P(j)}} \{ earliest[i] + <i , j> 的持續時間 \} $ 

2)事件最晚發生時間的計算

latest[j]=miniS(j){latest[i]<j,i>}

latest[j] = \displaystyle \min_{i \in {S(j)}} \{ latest[i] - <j , i> 的持續時間 \}

最小生成樹

一個無向圖G的最小生成樹(minimum spanning tree)就是由該圖的那些連線G的所有頂點的邊構成的總值最低的樹。最小生成樹存在當且僅當G是連通的。

下面第二個圖是第一個圖的最小生成樹(碰巧是唯一的,但並不能代表一般情況)。最小生成樹是一棵樹;因為它無環;因為最小生成樹包含每一個頂點,所以它叫生成樹;此外,它顯然是包含所有頂點的最小的樹。

這裡寫圖片描述

Prim演算法

計算最小生成樹的一種方法是使其連續地一步一步成長,在每一步中,都要把一個結點當作根並且往上累加邊,於是就將關聯的頂點加到了增長中的樹上。

Prim演算法和前面求最短路徑的Dijkstra演算法思想類似,因此和前面一樣我們對每一個頂點保留值dv和pv以及一個標記頂點的known或unknown。

這裡寫圖片描述

還是老辦法,在Prim演算法中也設定一個表的初始狀態如下。

這裡寫圖片描述

將v1設定為known的,根據Prim演算法上一張圖所示,v1連線了v2、v3、v4,其dv分別為2、4、1,因此更新如下。

這裡寫圖片描述

將v4宣告為known,更新如下。

這裡寫圖片描述

將v2和v3先後宣告為known,更新如下。

這裡寫圖片描述

將v7宣告為known後更新如下左圖,最後將v6和v5也更新為known後更新如下右圖。

這裡寫圖片描述

下面是Prim演算法的虛擬碼實現,其中T為生成樹的邊集,TV是當前生成樹T的頂點集合

Kruskal演算法

第二種貪心策略是連續地按照最小的權選擇邊,並且當所選的邊不產生迴路時就把它作為取定的邊。同樣是前面的示例,用Kruskal演算法執行如下。

這裡寫圖片描述

形式上,Kruskal演算法是在處理一個森林——樹的集合。下圖展示了邊被新增到森林中的順序。

這裡寫圖片描述

當新增到森林中的邊足夠多時,演算法終止,這裡演算法的作用在於決定邊uv

(u,v)
是應該新增還是捨棄。

在該演算法執行的過程中,兩個頂點屬於同一個集合當且僅當它們在當前的生成森林(spanning forest)中連通。如果u

u
v
v
在同一個集合中,那麼連線它們的邊就要放棄,因為當它們已經連線的情況下,再新增邊uv
(u,v)
就會形成一個迴路。

如果這兩個頂點不在同一個集合中,就應該將這條邊加入,並對包含頂點u

u
v
v
的兩個集合執行一次union操作。這樣將保持集合的不變性,因為一旦邊uv
(u,v)
新增到生成森林中,若w
w
連通到u
u
x
x
連通到v
v
,這x
x
w
w
必然是連通的,因此屬於相同的集合。雖然將邊排序便於選取,但用線性時間建立一個堆則是更好的想法,此時deleteMin使得邊依次得到測試。

Sollin演算法

Sollin演算法在每一步都為生成樹遞迴地選取若干條邊,在每一步處理開始時,說選取的邊與圖中的所有n個頂點形成一個生成森林。在執行過程中,為森林中的每棵樹都選取一條邊,與選取的邊都是恰有一個頂點在樹上且代價最小。由於森林中的兩棵樹可能選取同一條邊,所以需要去掉同一條邊被多次選取的情況。在開始時,說選取的邊集為空,當最後結果只有一棵樹或者再沒有邊可供選取時,演算法就此結束。

深度優先搜尋

深度優先搜尋(depth-first search)是對前序遍歷的推廣,對每一個頂點,欄位visited被初始化成false,通過哪些尚未被訪問的結點遞迴呼叫該過程,我們保證不會陷入無限迴圈。如果圖是無向且連通的,或是有向但非強連通的,這種方法可能會訪問不到某些結點。此時我們搜尋一個未被標記的結點,然後應用深度優先遍歷,並繼續這個過程直到不存在未標記的結點為止。因為該方法保證每一條邊只被訪問一次,所以只要使用鄰接表,執行遍歷的總時間就是O(|E|+|V|)

O(|E|+|V|)

深度優先搜尋的演算法實現:

#define FALSE 0
#define TRUE 1
short int visited[MAX_VERTICES]

void dfs(int v)
{
    node_pointer w;
    visited[v]=TRUE;
    printf("%5d",v);
    for(w=graph[v];w;w=w->link);
        if(!visited[w->vertex])
            dfs(w->vertex);
}

和上文中的廣搜一樣,我們也來對dfs進行分析。

如果採用鄰接表來儲存,就可以沿著頂點的連結串列來確定其所有鄰接頂點。因此在鄰接表中的每一個頂點都至多掃描一次,所以完成搜尋時間複雜性為O(e)

O(e)

如果採用鄰接矩陣來儲存,訪問頂點的所有鄰接頂點的時間為O(n)

O(n)
,而整個過程至多訪問n
n
個頂點,因此完成搜尋時間複雜性為O(n2)
O(n^2)

無向圖

如下左圖中是一個無向圖,我們以此為例,假設從A開始,標記A為known並遞迴地呼叫dfs(B)。dfs(B)標記B為known並遞迴呼叫dfs(C)。dfs(C)標記C為known並遞迴呼叫dsf(D)。而後D的鄰接結點只有C,但C已經是knwon的,這裡便無法再繼續遞迴下去,因此返回到dfs(C)。dfs(C)看到已經標記為known的B鄰接點和D鄰接點,因此呼叫dfs(E)。dfs(E)標記E為known,同樣的它只能返回到dfs(C),再返回到dfs(B),最後返回到dfs(A)。實際上這裡接觸每條邊2次,一次是作為邊(v,w),另一次是作為邊(w,v)。

如下右圖展示了深度優先搜尋樹(depth-first spanning tree)的步驟。虛線表示向後邊(back edge),表示這條“邊”並不是樹的一部分。

這裡寫圖片描述

樹將模擬我們執行的遍歷,使用樹的邊對該樹的前序編號(preorder numbering)表示頂點被標記的順序;如果圖不是連通的,那麼處理所有的結點(以及邊)自然需要多次反覆呼叫dfs,每次都生成一棵樹,整個集合就是深度優先生成森林(depth-first spanning forest)。

雙連通性

前面我們已經介紹過了雙連通圖,如果刪除一個無向圖的仁一頂點後,剩下的圖仍然連通,那麼這樣的無向連通圖就稱為是雙連通的(biconnected)。

如果圖不是雙聯通的,那麼將其刪除後不再連通的那些頂點叫做割點(articulation point)。

深度優先搜尋提供了一種找出連通圖中所有割點的線性時間演算法。從圖中任一頂點開始,執行深度優先搜尋並在頂點被訪問時給它們編號。對於每一個頂點v,我們稱其前序編號為Num(V)。然後,對於深度優先搜尋生成樹中的每一個頂點v,計算編號最低的頂點,我們稱之為Low(V),該點可從v開始通過樹的零條或多條邊,且可能還有一條後向邊而以該序達到。

這裡寫圖片描述

有向圖

如前所述,如果圖不是強連通的,那麼從某個結點開始的深度優先搜尋可能訪問不了所有的結點,這種情況下我們從某個未做標記的結點處開始,反覆執行深度優先搜尋,直到所有的結點都被訪問到為止。

這裡寫圖片描述

對此我們從頂點B

B
開始深度優先搜尋,依次訪問BCADEF
B、C、A、D、E、F
。而後從某個未被標記的頂點重新開始,比如H
H
,然後訪問J
J
I
I
。最後從G
G
開始,也從此結束。

這裡寫圖片描述

深度優先生成森林中的虛線是一些vw

(v,w)
邊,其中的w
w
在考察時已經做了標記。在無向圖中,它們總是一些向後邊,但是可以看到,存在三種型別的邊並不通向新的頂點。這裡有一些向後邊(back edge),如AB
(A,B)
;還有一些向前邊(forward edge),如CD
(C,D)
;最後還有一些交叉邊(cross edge),如FC
(F,C)
,它們把不直接相關的兩個樹結點連線起來。深度優先搜尋森林一般通過把一些子結點和一些新的樹叢左到右新增到森林中形成。

深度優先搜尋還可以用來檢測一個有向圖是否是無環圖,因為一個有向圖是無環圖當且僅當它沒有向後邊。上面的例子明顯不是無環圖,因為它有向後邊。而拓撲排序也可以用來檢測一個圖是否是無環圖,進行拓撲排序的另一種方法是通過深度優先搜尋生成森林的後序遍歷給頂點指定拓撲編號NN11

N,N-1,……,1
。只要圖是無環的,這種排序就是一致的。


備註1:為保證權威性,所有定義、公式、圖片均來自《資料結構(C語言版)》、《資料結構與演算法分析 C++描述》、《離散數學》、《演算法導論》、《挑戰程式設計競賽》等書籍和必應、維基百科等網路。

備註2:希望不會因為參考了在備註1中提到的諸多書籍與網路而被批判,畢竟這篇部落格也融合了我許多時間、精力和思考。我只是希望像我所讀的所有外國書籍一般在書尾列出參考文獻一樣尊重原作者。

備註3:個人能力有限,暫時無力為圖論這一領域增加新演算法,本文中所有演算法均來自偉大的前輩們的著作,為表敬意,此處以金色顯示。



感謝您的訪問,希望對您有所幫助。

歡迎大家關注或收藏、評論或點贊。


為使本文得到斧正和提問,轉載請註明出處:
http://blog.csdn.net/nomasp


相關文章