圖的基本概念
圖\(G\)由頂點集\(V\)和邊集\(E\)組成,記為\(G=(V,E)\),其中\(V(G)\)表示圖\(G\)中頂點的有限非空集;\(E(G)\)表示圖\(G\)中頂點之間的關系(邊)集合。若\(V={v_1,v_2,v_3,\ldots,v_n}\),用\(|V|\)表示圖\(G\)中頂點的個數,也稱為圖\(G\)的階,\(E={(u,v)|u\in V,v\in V}\),用\(E\)表示圖\(G\)中邊的條數。
注意:線性表可以是空表,樹可以是空樹,但圖不可以是空圖。也就是說,圖中不能一個頂點也沒有,圖中頂點集\(V\)一定非空,但是邊集\(E\)可以為空,此時圖中只有頂點而沒有邊。
有向圖
若E是有向邊(也稱為弧)的有限集合時,則圖G為有向圖。弧是頂點的有序對,記為\(<v,w>\),其中v,w是頂點。w稱為弧頭,v稱為弧尾,稱為從頂點v到頂點w的弧,也稱為v鄰接到w,或w鄰接自v。
簡單圖
一個圖G如果滿足:
- 不存在重復邊。
- 不存在頂點到自身的邊。
則可以稱為簡單圖。上圖\((a),(b)\),都是簡單圖,並且數據結構中只討論簡單圖。
多重圖
若圖\(G\)中,某兩點之間的邊數多於一條,又允許頂點通過一條邊和自己關聯,則G為多重圖。多重圖的定義和簡單圖是相對的。
完全圖
在無向圖中,如果任意兩個頂點之間都存在邊,則稱該圖為無向完全圖。含有n個頂點的無向完全圖有\(\frac{n(n-1)}{2}\)條邊。在有向圖中,如果任意兩個頂點之間都存在方向相反的兩條弧,則稱該圖為有向完全圖。含有n個頂點的有向完全圖有\(n(n-1)\)條有向邊。
上圖中\((b)\)為無向完全圖,\((c)\)為有向完全圖。
子圖
設有兩個圖\(G=(V,E)\)和\(G^`=(V^`,E^`)\),若\(E^`\)是\(E\)的子集,\(V^`\)是\(V\)的子集,則稱\(G`\)是\(G\)的子圖。
上圖中\((c)\)是\((a)\)的子圖。
注意:並非\(V\)和\(E\)的任何子集都能構成\(G\)的子圖,因為這樣的子集可能不是圖,也就是說,\(E\)的子集中的某些邊關聯的頂點可能不再這個\(V\)的子集中。
連通,連通圖和連通分量
在無向圖中,若從頂點v到頂點w有路徑存在,則稱v和w是連通的。若圖G中任意兩個頂點都是連通的,則稱G為連通圖,否則稱為非連通圖。無向圖中極大連通子圖稱為連通分量。如果一個圖中有n個頂點,並且有小於n-1條邊,則此圖必是非連通圖,如下圖所示有三個連通分量(極大連通子圖)。
圖的鄰接矩陣存儲表示法具有以下特點:
- 無向圖的鄰接矩陣一定是一個對稱矩陣(並且唯一)。因此在實際存儲鄰接矩陣時只需要存儲上(或下)三交矩陣的元素即可。
- 對於無向圖,鄰接矩陣的第i行(或第i列)非零元素的個數正好是第i個頂點的度\(TD(V_i)\)。
- 對於有向圖,鄰接矩陣的第i行(或第i列)非零元素的個數正好是第i個頂點的出度\(OD(V_i)\)(或入度\(ID(V_i)\))。
- 用鄰接矩陣法存儲圖,很容易確定圖中任意兩個頂點之間是否有邊相連。但是,要確定圖中有多少條邊,則必須按行,按列對每個元素進行監測,所花費的時間代價很大。這是用鄰接矩陣存儲圖的局限性。
- 稠密圖適合用鄰接矩陣存儲表示。
- 設圖G的鄰接矩陣為A,\(A^n\)的元素\(A^n[i][j]\)等於由頂點i到頂點j的長度為n的路徑的數目,該結論了解即可,證明方法在離散數學中。
鄰接表法
當一個圖為稀疏圖時,是用鄰接矩陣表示法顯然浪費了大量的存儲空間。而圖的鄰接表法結合了順序存儲和鏈式存儲的方法,大大的減少了這種不必要的浪費。
所謂鄰接表就是對圖G中的每個頂點\(V_i\)建立一個單鏈表,第i個單鏈表中的結點表示依附於頂點\(V_i\)的邊(對於有向圖則是以頂點\(V_i\)為尾的弧),這個單鏈表就成為頂點\(V_i\)的邊表(對有向圖來說是出邊表)。邊表的頭指針和頂點的數據信息采用順序存儲(稱為頂點表),所以在鄰接表中存在兩種結點;頂點表結點和邊表結點。
圖的鄰接表存儲結構定義如下:
#define MaxVertexNum 100
typedef char VertexType;
typedef struct ArcNode // 邊表結點
{
int adjvex; // 該弧所指向的頂點的位置。
struct ArcNode *next;// 指向下一條依附於該頂點的弧的指針。
}ArcNode;
typedef struct VNode // 頂點表結點
{
VertexType data; // 頂點信息
ArcNode *first; // 指向依附於該頂點的弧的指針
}VNode,AdjList[MaxVertexNum];
typedef struct
{
AdjList vertices; // 鄰接表
int vexnum,arcnum; // 圖的頂點數和弧數
}ALGraph;
圖的鄰接表存儲方法具有一下特點:
- 如果G為無向圖,則所需的存儲空間為\(O(|V|+2|E|)\);如果G為有向圖,則所需的存儲空間為\(O(|V|+|E|)\)。前者的倍數2是由於無向圖中,每條邊在鄰接表中出現了兩次。
- 對於稀疏圖,采用鄰接表表示將極大的節省存儲空間。
- 在鄰接表中,給定一頂點,能很容易的找到它的所有臨邊,因為只需要讀取它的鄰接表就可以了。在鄰接矩陣中,相同的操作則需要掃描一行,花費的時間是\(O(n)\)。但是如果要確定給定的兩個頂點間是否存在邊,則在鄰接矩陣里可以立即查到,在鄰接表中則需要在相應結點對應的邊表中查找另一節點,效率較低。
- 在有向圖的鄰接表表示中,求一個給定頂點的出度只需計算其鄰接表中結點個數即可;但求其頂點的入度,則需要遍歷全部的鄰接表。因此也有人采用逆鄰接表的存儲方式來加速求解給定頂點的入度。當然這實際上與鄰接表的存儲方式是類似的。
- 圖鄰接表表示並不唯一,這是因為在每個頂點對應的單鏈表中,各邊結點的鏈接次序可以任意,取決於建立鄰接表的算法以及邊的輸入次序。
十字鏈表
十字鏈表是有向圖的一種鏈式存儲結果。在十字鏈表中,對應於有向圖中的每條弧有一個結點,對應於每個頂點也有一個結點。這些節點的結構如下:
弧結點中有5個域:其中尾域(tailvex)和頭域(headvex)分別只是弧尾和弧頭這兩個頂點在圖中的位置,鏈域hlink指向弧頭相同的下一條弧,鏈域tlink指向弧尾相同的下一條弧,info域指向該弧的相關信息。這樣弧頭相同的弧在同一個鏈表上,弧尾相同的弧也在同一個鏈表上。
頂點域中有三個域:data域存放頂點相關的數據信息,如頂點名稱,firstin和firstout兩個域分別指向以該頂點為弧頭和弧尾的第一個弧結點。
其中,mark為標志域,可用以標記該條邊是否被搜索過;ivex和jvex為該邊衣服的兩個頂點在圖中的位置;ilink指向下一條依附於頂點ivex的邊;jlink指向下一條依附於頂點jvex的邊,info為指向和邊相關的各種信息的指針域。
十字鏈表
每個頂點也有一個結點表示,它由如下所示的兩個域組成。
data | firstedge |
---|
其中,data域存儲該頂點的相關信息,firstedge域指示第一跳依附於該頂點的邊。
在鄰接多重表中,所有依附於同一頂點的邊串聯在同一鏈表中,由於每條邊依附於兩個頂點,則每個邊結點同時連接在兩個鏈表中。
BFS算法求解單源最短路徑問題
如果圖\(G=(V,E)\)為非帶權圖,定義從頂點u到頂點v的最短路徑\(d(u,v)\)為從u到v的任何路徑中最少的邊數;如果沒有通路,則為\(d(u,v)=\infty\)。
使用BFS,我們可以求解一個滿足上述定義的非帶權路徑的單源最短路徑問題,這是由廣度優先搜索總是按照距離由近到遠來遍歷圖中每個頂點的性質決定的。
BFS算法求解單源最短路徑問題的算法如下:
void BFS_MIN_Distance(Graph G,int u)
{
for(int i=0;i<G.vexnum;i++)
d[i]=INT_MAX;
visited[u]=true;
d[u]=0;
EnQueue(Q,u);
while(!IsEmpty(Q))
{
DeQueue(Q,u);
for(w=FirstNeighbor(G,u);w>=0;w=NextNeighbor(G,u,w))
{
if(!visited[w])
{
visited[w]=true;
d[w]=d[u]+1;
EnQueue(Q,w);
}
}
}
}
深度優先搜索(Depth-First-Search)
與廣度優先搜索不同,深度優先搜索\((DFS)\)類似於樹的先序遍歷。正如其名稱中所暗含的意思一樣,這種搜索算法所遵循的策略是盡可能“深”的搜索一個圖。它的基本思想如下:首先訪問圖中某一起始頂點v,然后從v出發,訪問與v鄰接且未被訪問的任一定點\(w_1\),再訪問與\(w_1\)鄰接且未被訪問的任意頂點\(w_2\),……重復上述過程。當不能再繼續向下訪問時,一次退回到最近被訪問的頂點,若他還有鄰接頂點未被訪問過,則從該點開始繼續上述搜索過程,知道搜索頂點均被訪問過為止。
一般情況下,其遞歸形式的算法非常簡潔。下面描述其算法過程。
#define MAX_VERTEX_NUM 100
bool visited[MAX_VERTEX_NUM];
void DFSTraverse(Graph G)
{
for(v=0;v<G.vexnum;i++)
visited[v]=false;
for(v=0;v<G.vexnum;i++)
if(!visited[v])
DFS(G,v);
}
void DFS(Graph G,int v)
{
visit(v);
visited[v]=true;
for(w=FirstNeighbor(G,v);w>=0;w=NextNeighor(G,v,w))
if(!visited[w])
DFS(G,w);
}
DFS算法性能分析
-
DFS算法是一個遞歸算法,需要借助一個遞歸工作棧,故她的空間復雜度為\(O(|V|)\)。
-
遍歷圖的過程實質上是對每個頂點查找其臨接點的過程,其耗費的時間取決於所采用的存儲結構。當以鄰接表進行表示時,查找每個頂點的臨接點所需時間為\(O(|V|)\),故總的時間復雜度為\(O(|V|^2)\)。當以鄰接表表示時,查找所有頂點的臨接點所需時間為\(O(|E|)\),訪問頂點所需時間為\(O(V)\),此時,總的時間復雜度為\(O(|V|+|E|)\)。
上面的代碼BFSTraverse和DFSTraverse中添加了第二個for循環,再選取初始點,繼續進行遍歷,以防止一次無法遍歷圖中的所有頂點。
圖的應用
本節是歷年考察的重點。圖的應用主要包括:最小生成樹,最短路徑,拓撲排序和關鍵路徑。一般而言,這部分內容直接以算法設計題形式考查的可能性很小,而更多的是結合圖的實例來考查算法的具體執行過程。此外,還需要掌握對於給定的模型建立相應的圖去解決問題。
最小生成樹(Minimum-Spaning-Tree)
一個連通圖的生成樹是圖的極小連通子圖,它包含圖中所有頂點,並且只包含極可能少的邊。這意味着對於生成樹來說,若砍去它的一條邊,就會是生成樹變成非連通圖;若給它增加一條邊,就會形成圖中的一條回路。
對於一個帶權連通無向圖\(G=(V,E)\),生成樹不同,每棵樹的權(即樹中所有邊上的權值之和)也可能同。
設R為G的所有生成樹的集合,若T為R中邊的權值之和最小的那棵生成樹,則稱T為G的最小生成樹。
不難看出,最小生成樹具有如下性質:
1. 最小生成樹不是唯一的,即最小生成樹的樹形不唯一,R中可能有多個最小生成樹。當圖G中各邊權值互不相等時,G的最小生成樹是唯一的;若無向連通圖G的邊比頂點數少1,即G本身就是一棵樹,G的最小生成樹就是其本身。
2. 最小生成樹的邊的權值之和總是唯一的,雖然最小生成樹不唯一,但其對應的邊的權值之和是唯一的,而且是最小的。
3. 最小生成樹的邊數為頂點數減 1。
構造最小生成樹有多種算法,但大多數算法都利用了最小生成樹的下列性質:
假設\(G=(V,E)\)是一個帶權連通無向圖,U是頂點集V的一個非空子集。若\((U,V)\)是一條具有最小權值的邊,其中\(u\in U,v\in {V-U}\),則必存在一棵包含邊(u,v)的最下生成樹。
Prime算法
隨意選一個點,開始建立該點集,和其他點的路徑長度關系,有路徑就有沒有的話 設置一個\(\infty\),然后開始在路徑的集合里面尋找最短的到達一個點的路徑,將這個點加到該點集之中。然后因為加入了新的點,這個時候點集就改變了,我們需要更新一下新的點集到其余點的路徑,然后再次選出一個該點集到沒有加入該點集的點的最短的路徑的一個點。然后就這樣一直搞。
KruSkal算法
按照路徑的長度進行從小到大的排序,排序完畢之后,選出最小的一條邊,作為當前長度。然后選出第二小的邊,檢查是否會成環,不會的話把長度加起來,然后選第三小的邊,檢查是否會成環,不會的話把長度加起來,然后選第四小的邊。。。。知道跳出來 頂點-1條邊。
以前做的《布線問題》作為例題,來解釋Prime和Kruskal。
最短路徑
Dijkstra算法
和Prime是一樣的,不過Prime是最小生成樹,計算的是將這些點連起來花費的最小代價。而Dijkstra計算的是,從某點開始到其他點花費的最小代價。
假設從1開始出發,選取一個目前1到那個點最近的點,將該點標記為已訪問,然后從1頭過這個點我到達其他點的距離會不會更近,如果更近的話,更新一下距離數組。然后從距離數組中選取出另一個未被加入的點,並且是距離1距離最小的點,假設讓1通過該點到達其他點會不會更近,如果更近的話更新一下距離數組。
Floyd算法
多源最短路徑:核心代碼如下
for(k=1;k<=n;k++) //Floyd核心算法...
{
for(i=1;i<=n;i++) // 所有的 路 都讓 k 加進去試試
{
for(j=1;j<=n;j++) //如果 從 i到j的路上 有k 走的會更輕松的話 , 那就讓 k 去吧
{
if(e[i][j]>e[i][k]+e[k][j]) // 判斷 是否會 更加輕松
e[i][j]=e[i][k]+e[k][j];
}
}
}
三層for循環,如果從i到j路過k的話更快就更新一下距離數組。
拓撲排序
有向無環圖:一個有向圖中不存在環,則稱為有向無環圖,簡稱DAG圖。
AOV網:如果用DAG圖表示一個工程,其頂點表示活動,用有向邊\(<V_i,V_j>\)表示活動\(V_i\)必須先於活動\(V_j\)進行的這樣一種關系,則這種有向圖稱為頂點表示活動的網絡記為AOV網。在AOV網中,活動\(V_i\)是\(V_j\)的直接前驅,活動\(V_j\)是\(V_i\)的直接后繼,這種前驅和后繼關系具有傳遞性,且任何活動\(V_i\)不能以它自己作為自己的前驅或后繼。
拓撲排序:在圖論中,由一個有向無環圖的頂點組成的序列,當且僅當滿足下列條件時,稱為該圖的一個拓撲排序。
- 從DAG圖中選擇一個沒有前驅的頂點並輸出。
- 從圖中刪除該頂點和所有以它為為起點的有向邊。
- 重復1和2直到當前的DAG圖為空或當前圖中不存在無前驅的頂點為止。而后一種情況則說明有向圖中必然存在環。
拓撲序:如果圖中有從v到w有一條有向路徑,則v一定排在w之前。滿足此條件的頂點序列稱為一個拓撲序。
獲得一個拓撲序的過程就是拓撲排序。
\(AOV\)如果有合理的拓撲序,則必定是有向無環圖\((Directed Acyclic Graph,DAG)\)。
這個東西就意味着V在開始之前就必須結束。
void TopSort()
{
for(cnt=0;cnt<|V|;cnt++)
{
V=未輸出的入度為0的頂點;
if(這樣的V不存在)
{
Error("圖中有回路");
break;
}
輸出V,或者記錄V的輸出序號;
for(V的每個臨接點W)
{
Indegree[W]--;// 每個臨接點的入度-1。
}
}
}
void TopSort()
{
for(圖中每個頂點V)
if(InDegree[V]==0)
EnQueue(V,Q);
while(!IsEmpty(Q))
{
V=DeQueue(Q);
輸出V,或者記錄V的輸出序號;
cnt++;
for(V的每個臨接點W)
if(--Indegree[W]==0)
EnQueue(W,Q);
}
if(cnt!=|V|)
Error("圖中有回路");
}
關鍵路徑
在帶權有向圖中,以頂點表示事件,有向邊表示活動,邊上的權值表示完成活動需要的開銷,則這種圖稱為\(AOE\)網。
\(AOE\)網具有以下兩個性質:
- 只有在某頂點所代表的時間發生后,從該頂點出發的各有向邊所代表的活動才可以進行;
- 只有在進入某一頂點的各有向邊,所代表的活動都已經結束時,該頂點所代表的事件才可以發生。
在AOE網中僅有一個入度為0的頂點,稱為開始頂點(源點),它表示整個工程的開始;網中也僅存在一個出度為0的頂點,稱為結束頂點(匯點),它表示整個工程的結束。
在AOE網中有些活動是可以並行進行的,從源點到匯點的有向路徑可能有多條,並且這些路徑長度可能不同。完成不同路徑上的活動所需時間雖然不同,但是只有所有路徑上的活動都完成了,整個工程才能算結束了。因此,從源點到匯點的所有路徑中,具有最大路徑長度的路徑稱為關鍵路徑,我們將關鍵路徑上的活動稱為關鍵活動。
完成整個工程的最短時間就是關鍵路徑的長度,也就是關鍵路徑上各活動花費開銷的總和。這是因為關鍵活動影響了整個工程的時間,即如果關鍵活動不能按時完成的話,整個工程的完成時間就會增長。因此只要找到了關鍵活動,就找到了關鍵路徑,也就可以得出最短完成時間。
事件\(V_k\)的最早發生時間VE(K)
他是指從最早開始頂點V到\(V_k\)的最長路徑長度。事件的最早發生時間決定了所有人從\(V_k\)開始的活動最早能夠開工的最早時間。
時間\(V_k\)的最早發生時間\(VE(k)\)
它是指從開始頂點V到\(V(k)\)的最長路徑長度。時間的最早發生時間決定了所有從\(V_k\)開始的活動能夠開工的最早時間。可以用下面的遞推公式進行計算。
\(ve(源點)=0\)
\(ve(k)=Max\{ve(j)+Weight(v_j,v_k)\}\),\(Weight(v_j,v_k)\)表示\(<v_j,v_k>\)上的權值。
時間\(v_k\)的最遲發生時間\(vl(k)\)
它是指在不推遲整個工程完成的前提下,即保證他所指向的事件\(v_i\)在\(ve(i)\)時刻能夠發生時,該事件最遲必須發生的時間。
活動\(a_i\)最早開始時間\(e(i)\)
它是指該活動的七點所表示的事件最早發生時間。如果邊\(<v_k,v_j>\)表示活動\(a_i\),則有\(e(i)=ve(k)\)。
活動\(a_i\)的最遲開始時間。
它是指該活動的終點所表示的事件最遲發生時間與該活動所需時間之差。
一個活動\(a_i\)的最遲開始時間\(l(i)\)和其最早開始時間\(e(i)\)的差額\(d(i)=l(i)-e(i)\)。
它是指該活動完成的時間余量,是在不增加整個工程所需的總時間的情況下,活動\(a_i\)可以拖延的時間。如果一個活動的時間余量為0時,說明該活動必須要如期完成,否則就會拖延完成整個工程的進度,所以成\(l(i)-e(i)=0\),即\(l(i)=e(i)\)的活動\(a_i\)是關鍵活動。
求關鍵路徑的算法步驟如下:(事件是結點,活動是邊。)
- 求AOE網中所有事件的最早發生時間\(ve()\)。
- 求AOE網中所有事件的最遲發生時間\(vl()\)。
- 求AOE網中所有活動的最早開始時間\(e()\)
- 求AOE網中所有活動的最遲開始時間\(l()\)
- 求AOE網中所有活動的差額\(d()\),找出所有\(d()=0\)的活動構成關鍵路徑。