11.1 圖的基本概念
-
圖是一種網狀的數據結構,其中的結點之間的關系是任意的,即圖中任何兩個結點之間都可能直接相關。
-
頂點:圖中的數據元素。設它的集合用
V(Vertex)表示。 -
邊:頂點之間的關系的集合用
E(edge)來表示: -
頂點的度:連接頂點的邊的數量稱為該頂點的度。頂點的度在有向圖和無向圖中具有不同的表示。
- 對於無向圖,一個頂點
V的度比較簡單,其是連接該頂點的邊的數量,記為D(V)。 - 對於有向圖要稍復雜些,根據連接頂點
V的邊的方向性,一個頂點的度有入度和出度之分。- 入度是以該頂點為端點的入邊數量, 記為ID(V)。
- 出度是以該頂點為端點的出邊數量, 記為OD(V)。
- 對於無向圖,一個頂點
-
無向圖(Undigraph):若圖中任意\(<v_1,v_2>\in E\)必能推導出\(<v2,v1> \in E\),此時的圖稱為無向圖。
- 無向圖用無序對\((v_1,v_2)\),表示\(v_1\)和\(v_2\)之間的一條雙向邊
(Edge)。 
- 無向圖用無序對\((v_1,v_2)\),表示\(v_1\)和\(v_2\)之間的一條雙向邊
-
有向圖(Digraph):如果圖中 \(v_1,v_2 \in V\),若存在\(<v_1,v_2> \in E\),而 \(<v_2,v_1>\notin E\) 此時的圖稱為有向圖
- 有向圖用有序對\(<v_1,v_2>\),表示\(v_1\)和\(v_2\)之間的一條單向邊
(Edge)。 
- 有向圖用有序對\(<v_1,v_2>\),表示\(v_1\)和\(v_2\)之間的一條單向邊
-
無向完全圖:如果在一個無向圖中, 任意兩個頂點之間都存在一條雙向邊,那么這種圖結構稱為無向完全圖
- 理論上可以證明,對於一個包含
N個頂點的無向完全圖,其總邊數為N(N-1)/2。 
- 理論上可以證明,對於一個包含
-
有向完全圖:如果在一個有向圖中,任意兩個頂點之間都存在方向相反的兩條邊,那么這種圖結構稱為有向完全圖。
- 理論上可以證明,對於一個包含
N的頂點的有向完全圖,其總的邊數為N(N-1)。這是無向完全圖的兩倍,這個也很好理解,因為每兩個頂點之間需要兩條邊。 
- 理論上可以證明,對於一個包含
-
有向無環圖(DAG圖)
- 如果一個有向圖無法從某個頂點出發經過若干條邊回到該點,則這個圖是一個有向無環圖。
- 樹型有向圖一定是一個有向無環圖。

-
自環和平行邊:對於節點與節點之間存在兩種邊,這兩種邊相對比較特殊
-
自環邊
(self-loop):節點自身的邊,自己指向自己。 -
平行邊(parallel-edges):兩個節點之間存在多個邊相連接,也叫重邊。

-
-
簡單圖 ( Simple Graph):不存在自環和重邊的圖叫簡單圖。
-
路徑、簡單路徑、回路:
-
路徑:無向圖中從一個節點到達另一個節點所經過的節點序列
-
簡單路徑:路徑中的各頂點不重復的路徑。
-
回路:路徑上的第一個頂點和最后一個頂點重合,這樣的路徑叫做回路。
-
下圖箭頭表示路徑

-
-
連通圖與連通分量
-
連通圖:無向圖
G中,若對任意兩點,從頂點 \(V_i\) 到頂點 \(V_j\) 有路徑,則稱 \(V_i\) 和 $V_j $ 是連通的,圖G是一連通圖。 -
連通分量:無向圖
G的連通子圖稱為G的連通分量-
任何連通圖的連通分量只有一個,即其自身,而非連通的無向圖有多個連通分量
-
以下圖為例,總共有四個連通分量,分別是:
ABCD、E、FG、HI。
-
-
-
強連通圖與強連通分量
-
強連通圖:有向圖
G中,若對任意兩點,從頂點$ V_i$ 到頂點 $V_j $ ,都存在從 $V_i $到 $V_j $ 以及從$ V_j$ 到 \(V_i\) 的路徑,則稱G是強連通圖 -
強連通分量:有向圖
G的強連通子圖稱為G的強連通分量。-
強連通圖只有一個強連通分量,即其自身,非強連通的有向圖有多個強連通分量。
-
以下圖為例,總共有三個強連通分量,分別是:
abe、fg、cdh。
-
-
11.2 圖的存儲
11.2.1 鄰接矩陣
-
設圖
G有\(n (n\geq1)\) 個頂點,則鄰接矩陣是一個n階方陣。 -
當矩陣中的
[i,j] !=0(下標從1開始) ,代表其對應的第i個頂點與第j個頂點是連接的。
-
鄰接矩陣的特點:
- 無向圖的鄰接矩陣是對稱矩陣,
n個頂點的無向圖需要n*(n+1)/2個空間大小。 - 有向圖的鄰接矩陣不一定對稱,
n個頂點的有向圖需要\(n^2\)的存儲空間。 - 無向圖中第
i行的非零元素的個數為頂點\(V_i\) 的度。 - 有向圖中第
i行的非零元素的個數為頂點 \(V_i\) 的出度,第i列的非零元素的個數為頂點 $V_i $ 的入度 。 - 一般情況下,空間復雜度為 \(O(N^2)\)。
- 無向圖的鄰接矩陣是對稱矩陣,
11.2.2 鄰接表(邊表)
- 把數組與鏈表相結合的存儲方法稱為鄰接表。鄰接表為圖
G中的每一個頂點建立一個單鏈表,每條鏈表的結點元素為與該頂點連接的頂點。 - 鄰接表的處理辦法:
- 頂點用一個一維指針數組存儲(較容易讀取頂點信息),作為表頭,每個數據元素還需要存儲指向第一個鄰接點的指針,以便於查找該頂點的邊信息(更多情況下,表頭不需要保存其他信息,因此可以直接定義一個結點類型的指針數組)。
- 每個頂點的所有鄰接點構成一個鏈表。
- 空間復雜度為 \(O(V+E)\),\(V\) 表示結點個數,\(E\) 表示邊數。
-
無向圖的鄰接表結構

上圖中
data是數據域,存儲頂點u的信息;firstedge是指針域,指向與結點u相連的第一個結點,即此頂點的第一個鄰接點。 邊表結點由
adjvex和next兩個域組成。adjvex是鄰接點域,存儲某頂點u的鄰接點在頂點v,next則存儲指向鄰接表中下一個結點的指針,如果不存在下一個結點(即沒有邊),則指針為NULL。 -
有向圖的鄰接表和逆鄰接表:

有向圖由於有方向,我們是以頂點為弧尾來存儲鄰接表的,這樣很容易就可以得到每個頂點的出度。但也有時為了便於確定頂點的入度或以頂點為弧頭的弧,我們可以建立一個有向圖的逆鄰接表,即對每個頂點
u都建立一個鏈接為u為弧頭的表。 -
對於帶權值的網圖,可以在邊表結點定義中再增加一個weight的數據域,存儲權值信息即可。

-
鄰接表創建代碼——鏈表(動態建點):
/* * 向鄰接表中添加一條邊,從 from 到 to,權值為 w * next 為指向與 from 相鄰的下一個結點(另一條邊)的指針 */ struct Edge { // 保存鏈表中的每個結點 int from, to, w; // 通常 from 可以不用,因為表頭表示了邊的起點編號 Edge* next; }; Edge* head[MAXN]; // 全局變量,定義表頭指針數組,大小為結點個數,初始值為 NULL void AddEdge(int from, int to, int w) { Edge* p = new Edge; // 新建一個結點,並將信息進行賦值 p->from = from; p->to = to; p->w = w; p->next = head[from]; // 先將該結點的 next 指向表頭指向的結點 head[from] = p; // 更改表頭的指向,即可將新結點串進來 } -
鄰接表創建代碼——前向星(數組實現):
/* * 向鄰接表中添加一條邊,從 from 到 to,權值為 w * next 為保存與 from 相鄰的下一個結點(另一條邊)在數組中的位置 */ struct Edge { // 保存鏈表中的每個結點 int from, to, w; // 通常 from 可以不用,因為表頭表示了邊的起點編號 int next; }; int head[MAXN]; // 定義表頭指針數組,大小為結點個數,初始值為 -1 int tot = 0; // 記錄總邊數,同時也表示新加的邊在數組中的下標 Edge e[MAXM]; // 定義邊數組,如果是無向圖,大小為給出邊數的 2 倍 void AddEdge(int from, int to, int w) { e[tot].from = from; // 將信息賦值到 tot 對應的位置 e[tot].to = to; e[tot].w = w; e[tot].next = head[from]; // 更新新加結點的 next 指向 head[from] = tot; // 更新表頭的指向 tot++; // 邊數加 1,也作為下一條邊的放入的位置 } -
鄰接表創建代碼——vector 實現:
/* * 向鄰接表中添加一條邊,從 from 到 to,權值為 w */ struct Edge { // 保存鏈表中的每個結點 int from, to, w; // 通常 from 可以不用,因為表頭表示了邊的起點編號 Edge(){} // 不帶參的構造函數 Edge(int x, int y, int z) { // 帶參構造函數,后面使用方便 from = x; to = y; w = z; } }; vector<Edge> e[MAXN]; // 定義 vector 數組,大小為結點個數,其中的每一個 vector 模擬一個鏈表 void AddEdge(int from, int to, int w) { e[from].push_back(Edge(from, to, w)); // 將新結點加入到 from 對應的鏈表 }
注意:如果要保存的圖是無向圖,則需要雙向加邊,例如添加一條邊 (from, to, w),則需要調用函數 AddEdge(from, to, w); AddEdge(to, from, w); 否則只需要調用一次。
11.2.3 遍歷某個結點的鄰接點
通常我們需要將與某個結點相鄰的所有結點遍歷一遍,針對圖的不同的存儲方式,遍歷的方式也不相同。
-
鄰接矩陣存儲的遍歷
// 輸出與結點 u 相鄰的所有結點,簡單明了,不解釋 for (int i = 1; i <= n; ++i) { if (g[u][i] != -1) { // 假設我們用 -1 表示 u 與 i 之間沒有邊 printf("%d ", i); } } -
鄰接表存儲的遍歷
這種存圖的方式是我們常用的,所以一定要熟練掌握。根據上面給出的不同的構建方法,給出示例代碼來輸出 結點 u 的所有鄰接點編號。
-
鏈表(指針實現):
// 從表頭開始,訪問完一個鄰接點后,通過 next 調到下一個鄰接點 for (Edge* p = head[u]; p != NULL; p = p->next) { printf("%d ", p->to); } -
前向星(數組實現):
// 從表頭開始,訪問完一個鄰接點后,通過 next 調到下一個鄰接點 for (int i = head[u]; i != -1; i = e[i].next) { printf("%d ", e[i].to); } -
vector 實現:
// 從表頭開始,訪問完一個鄰接點后,通過 next 調到下一個鄰接點 int gs = e[u].size(); for (int i = 0; i < gs; ++i) { printf("%d ", e[i].to); }
-
11.3 圖的遍歷
- 從圖中某一頂點出發訪遍圖中其余頂點,且使每一個頂點僅被訪問一次,這一過程就叫做圖的遍歷。
- 根據遍歷路徑的不同,通常有兩種遍歷圖的方法:深度優先遍歷和廣度優先遍歷。
11.3.1 深度優先遍歷
-
深度優先遍歷
(Depth_First_Search)也稱為深度優先搜索,簡稱為DFS。 -
它是從圖中某個頂點
v出發,訪問此頂點,然后從v的未被訪問的鄰接點出發深度優先遍歷圖,直至圖中所有和v有路徑相通的頂點都被訪問到。 -
對於非連通圖,只需要對它的連通分量分別進行深度優先遍歷即可。接下來我們以一個示例演示圖的深度優先遍歷。如下圖所示:

- 在開始進行遍歷之前,我們還要准備一個數組,用來記錄已經訪問過的元素。其中
0代表未訪問,1代表已訪問,如下所示:

-
假設我們是在走迷宮,
A是入口,每次都向右手邊前進。首先從A走到B,結果如下:
-
B之后有三個路,我們依然選擇最右邊,如此下去,直到走到F,如下所示:
-
到達
F后,如果我們繼續按照向右走的原則,就會再次訪問A,但A已訪問,則訪問另一個鄰接點G,如下所示:
-
到達
G后,可以發現B和D都走過了,這時候走到H,如下所示:
-
到達
H后,H的鄰接點都已訪問過了,所以我們從H退回到上層節點G,發現G,F,E的鄰接點全部已經訪問過了,直到退回到D時,發現I還沒走過,於是訪問頂點I,如下所示:
-
同理,訪問
I之后,發現與I連通的頂點都訪問過了,所以再向前回退,直到回到頂點A,發現全部頂點都訪問過了,至此遍歷完畢。
- 在開始進行遍歷之前,我們還要准備一個數組,用來記錄已經訪問過的元素。其中
-
下面給出的深度優先遍歷的參考程序,假設圖以鄰接表存儲(其他情況自己處理即可)
void dfs(int i) { //鄰接表存儲圖,訪問點 i visited[i] = true; //標記為已經訪問過 for (Edge* p = head[i]; p != NULL; p = p->next) { // 深度優先遍歷 i 的所有鄰接點 if (!visited[p->to]) { dfs(p->to); } } } // 假設全局變量已經定義好了 int main() { memset(visited, false, sizeof(visited)); // 如果是有向圖,必須用循環才能保證所有的結點都遍歷到 // 如果是連通的無向圖,從任一結點開始即可 for (int i = 1; i <= n; ++i) { if (!visited[i]) { dfs(i); } } return 0; }
11.3.2 廣度優先搜索
廣度優先遍歷並不常用,從編程復雜度的角度考慮,通常采用的是深度優先遍歷。
深度優先遍歷可以認為是縱向遍歷圖,而廣度優先遍歷(Breadth_First_Search)則是橫向進行遍歷。還以上圖為例,不過為了方便查看,我們把上圖調整為如下樣式:

我們依然以 A 為起點,把和 A 鄰接的 B 和 F 放在第二層,把和 B、F 鄰接的 C、I、G、E 放在第三層,剩下的放在第四層。
廣度優先遍歷就是從上到下一層一層進行遍歷,這和樹的層序遍歷很像。我們依然借助一個隊列來完成遍歷過程,因為和樹的層序遍歷很像,這里只展示結果,如下所示:

廣度優先遍歷和廣搜 BFS 相似,因此使用廣度優先遍歷一張圖並不需要掌握什么新的知識,在原有的廣度優先搜索的基礎上,做一點小小的修改,就成了廣度優先遍歷算法。
void bfs(int s) { //鄰接表存儲圖,訪問點 s
queue<int> que;
visited[s] = true; // 將起點 s 標記並放到隊列
que.push(s);
while (!que.empty()) { //
int now = que.front();
printf("%d ", now);
for (Edge* p = head[now]; p != NULL; p = p->next) { // 廣度優先遍歷鄰接點
if (!visited[p->to]) {
que.push(p->to); // 找到未被訪問過的鄰接點加入隊列,並標記
visited[p->to] = true;
}
}
}
}
// 假設全局變量已經定義好了
int main() {
memset(visited, false, sizeof(visited));
// 如果是有向圖,必須用循環才能保證所有的結點都遍歷到
// 如果是連通的無向圖,從任一結點開始即可
for (int i = 1; i <= n; ++i) {
if (!visited[i]) {
bfs(i);
}
}
return 0;
}
11.4 歐拉路
11.4.1 基本概念
- 如果一個圖存在一筆畫,則一筆畫的路徑叫做歐拉路,如果最后又回到起點,那這個路徑叫做歐拉回路。
- 歐拉圖:存在歐拉回路的圖稱作歐拉圖。
- 半歐拉圖:存在歐拉路徑但不存在歐拉回路的圖稱作半歐拉圖。
- 歐拉圖、半歐拉圖的判定
- 無向圖
- 奇點:跟這個點相連的邊數目有奇數個的點。對於能夠一筆畫的圖,我們有以下兩個定理。
- 定理1:無向圖
G為歐拉圖,當且僅當G為連通圖,且所有頂點度為偶數,即奇點為零。 - 定理2:無向圖
G為半歐拉圖,當且僅當G為連通圖,且除了兩個頂點的度為奇數外,其它頂點度為偶數,即存在兩個奇點。 - 半歐拉圖的歐拉路徑起點必須是一個奇點,終點是另一個奇點,歐拉圖任一點均可成為起點。
- 兩個定理的正確性是顯而易見的,既然每條邊都要經過一次,那么對於歐拉路,除了起點和終點外,每個點如果進入了一次,顯然一定要出去一次,顯然是偶點。
- 對於歐拉回路,每個點進入和出去次數一定都是相等的,顯然沒有奇點。
- 有向圖
- 基圖:忽略有向圖所有邊的方向,得到的無向圖稱為該有向圖的基圖。
- 定理1:有向圖
G為歐拉圖,當且僅當G的基圖連通,且所有頂點的入度等於出度。 - 定理2:有向圖
G為半歐拉圖,當且僅當G的基圖連通,且存在頂點u的入度比出度大1,v的入度比出度小1,其它所有頂點的入度等於出度。
- 無向圖
11.4.2 Hierholzer 算法
-
一個無向圖如果存在歐拉路經,那么我們如何遍歷才能找到一條歐拉路經呢?

-
假設上圖我們其中一種走法是:我們從點
4開始,一筆划到達了點5,形成路徑4-5-2-3-6-5。此時我們把這條路徑去掉,則剩下三條邊,2-4-1-2可以一筆畫出。顯然上面走法不是歐拉路 -
我們用
+代表入棧,-代表出棧,把剛才的路經重新描述一下:4+ 5+ 2+ 3+ 6+ 5+ 5- 6- 3- 1+ 4+ 2+ 2- 4- 1- 2- 5- 4-- 把所有出棧的記錄連接起來,得到
5-6-3-2-4-1-2-5-4 - 我們把上面的出棧序列倒序輸出,正好是一個從
4開始到5結束的一條歐拉回路。
-
算法實現:
#include <cstdio> #include <cstring> const int maxn = 500 + 5,maxe=2*1024+5;//無向圖一定注意邊數要翻倍 struct Node{//節點定義 int to,next; }a[maxe];//存儲邊 int Head[maxn],len=0;//len記錄邊數,Head[u]表示以u為起點的邊在邊表中的編號。 int Path[maxe],cnt=0;//記錄回路的節點,每條邊要訪問一次所以點數=邊數+1 bool vis[maxe];//記錄邊是否已訪問 void Insert(int x,int y){//邊表的建立x起點,y為終點 a[len].to=y;a[len].next=Head[x];Head[x]=len++; }//要用位運算標記無向圖的正反兩條邊,所以邊的編號從0開始。 void Dfs(int u){//遞歸的最大深度為邊數,當邊數較大時容易爆棧,可以改為非遞歸 for(int i=Head[u];i!=-1;i=a[i].next){ if(vis[i])continue;//第i條邊已訪問 vis[i]=vis[i^1]=1;//i是i^1的反向邊,把這兩條邊設為已訪問 Head[u]=i;//優化,前面的邊已經走過了,沒有必要每次從最后一個位置往前找了 int v=a[i].to;Dfs(v);//從第i條邊的終點深搜 i=Head[u];//優化,有可能v的子樹中也更新過了u的共點邊 } Path[++cnt]=u;//u回溯時記錄路徑經過點u } void Euler(int u){////遞歸的最大深度為邊數,當邊數較大時容易爆棧,可以改為非遞歸 std::stack<int> q; q.push(u);//把起點u進棧 while(!q.empty()){ int i,x=q.top(); for(i=Head[x];i!=-1 && vis[i];i=a[i].next); //跳出循環時i==-1或第i條邊已訪問即vis[i]=1 if(i==-1){//說明x已不存在未訪問的鄰接邊 Path[++cnt]=x;q.pop(); } else{//說明第i條未訪問 q.push(a[i].to);//第i條邊的去邊進棧 vis[i]=vis[i^1]=1;//標記第i條邊及其反向邊 Head[x]=a[i].next;//指向下一條未訪問過的鄰接邊 } } } void Solve(){ int m;scanf("%d",&m); memset(Head,-1,sizeof(Head));//邊的編號從0開始,所以要初始化為-1 for(int i=1;i<=m;++i){ int x,y;scanf("%d%d",&x,&y); Insert(x,y);Insert(y,x);//無向圖要加雙向,有向圖只加一遍 } Dfs(1);//歐拉圖隨便一個點都可以作為源點 for(int i=cnt;i>0;--i)//逆序輸出路徑 printf("%d\n",Path[i]); } int main(){ Solve(); return 0; }
11.5 最短路
- 最短路徑問題是圖的又一個比較典型的應用問題。例如,某一地區的一個公路網,給定了該網內的
n個城市以及這些城市之間的相通公路的距離,能否找到城市A到城市B之間一條距離最近的通路呢? - 如果將城市用點表示,城市間的公路用邊表示,公路的長度作為邊的權值,那么,這個問題就可歸結為在網中,求點
A到點B的所有路徑中邊的權值之和最短的那一條路徑。 - 這條路徑就是兩點之間的最短路徑,並稱路徑上的第一個頂點為源點(
Sourse),最后一個頂點為終點(Destination)。 
11.5.1 迪傑斯特拉(Dijkstra)
- 迪傑斯特拉(Dijkstra)算法是典型最短路徑算法,用於計算一個節點到其他節點的最短路徑。
- 它的主要特點是以起始點為中心向外層層擴展(廣度優先搜索思想),直到擴展到終點為止。
11.5.1.1 算法思路
-
通過
Dijkstra計算圖G中的最短路徑時,需要指定起點s(即從頂點s開始計算)。 -
引入兩個集合
(S , U),S集合包含已求出的最短路徑的點(以及相應的最短長度),U集合包含未求出最短路徑的點。 -
操作步驟:
- 初始時,
S只包含起點s;U包含除s外的其他頂點,且U中頂點的距離為:起點s到該頂點的距離(s的鄰接點的距離為邊權,其他點為∞) - 從
U中選出距離源點s最短的頂點k,並將頂點k加入到S中;同時,從U中移除頂點k。 - 松弛操作:利用
k更新U中各個頂點到起點s的距離。 - 重復步驟
2)和3),直到遍歷完所有頂點。
- 初始時,
-
圖解

-
代碼實現
#include <cstdio> #include <cstring> const int maxn = 100 + 5,Inf=0x3f3f3f3f;//兩個Inf相加不會超int int n,a[maxn][maxn],dis[maxn],path[maxn];//dis[i]表示i到源點的最短距離,path[i]記錄路徑 void Dijs(int s){//源點s bool f[maxn];memset(f,0,sizeof(f));//f[i]表示i到源點s的最短距離已求出 f[s]=1;//源點進確定集合 for(int i=1;i<=n;++i){//除鄰接點外,其他點離源點距離初始化為Inf if(a[s][i]){ dis[i]=a[s][i];path[i]=s; } else dis[i]=Inf; } dis[s]=0;path[s]=s;//源點s到自己的距離為0 for(int i=1;i<n;++i){//每次能確定一個點到源點的最短路,n-1次能求出所有 int Min=Inf,k=0;//每次從不在確定集合的點中找出離源點最近的點 for(int j=1;j<=n;++j){ if(!f[j] && Min>dis[j]){ Min=dis[j];k=j;//k記錄最近的點 } } f[k]=1;//k到源點距離已確定,進集合 for(int j=1;j<=n;++j)//經過k進行松弛操作 if(a[k][j] && dis[j]>dis[k]+a[k][j]){ dis[j]=dis[k]+a[k][j];path[j]=k; } } } void Solve(){ int m;scanf("%d%d",&n,&m);//n個頂點,m條邊 for(int i=1;i<=m;++i){ int x,y,z;scanf("%d%d%d",&x,&y,&z); a[x][y]=a[y][x]=z;//x到y的距離為z } Dijs(4);//節點4為源點 for(int i=1;i<=n;++i)//輸出每個點到源點的最短距離 printf("%d ",dis[i]); } int main(){ Solve(); eturn 0; } /*數據為上圖樣例 7 12 1 2 12 1 6 16 1 7 14 2 3 10 2 6 7 3 4 3 3 5 5 3 6 6 4 5 4 5 6 2 5 7 8 6 7 9 */- 時間效率 \(O(n^2)\) ,
n-1次的松弛必不可少,但需要O(n)的時間效率去找最小的邊,再用O(n)的效率去松弛,對這一部分我們可以用堆進行優化。
- 時間效率 \(O(n^2)\) ,
11.5.1.2 堆優化的 Dijkstra
-
對於上一節普通的最短路算法我們可以進行如下優化:
- 對於松弛部分,我們可以用鄰接表的存儲方式進行優化,對一個節點較多的圖來說一般來說鄰接表的遍歷方式是常數的,遠遠小於
O(n)。 - 對求集合外的節點到源點的最小值,我們可以建一個小根堆,這樣我們時間消耗就是進堆的操作,為
O(ElogE),我們可以用有限隊列進行操作。
- 對於松弛部分,我們可以用鄰接表的存儲方式進行優化,對一個節點較多的圖來說一般來說鄰接表的遍歷方式是常數的,遠遠小於
-
代碼實現
#include <bits/stdc++.h> const int maxn=1000+5,maxe=1e4*2+5; struct Node{ int num,dis; Node(){}; Node(int x,int y){num=x;dis=y;} bool operator <(const Node &a)const{//優先隊列默認大根堆所以重載 return dis > a.dis;//小根堆 } }; struct Edge{//邊節點 int to,dis,next; }a[maxe]; int dis[maxn],Head[maxn],len;//dis[i]表示i到源點的最短距離,Head,len同邊表 void Insert(int x,int y,int z){//邊表創建,此處最小邊編號為1 a[++len].to=y;a[len].dis=z;a[len].next=Head[x];Head[x]=len; } void Dijs(int x){ std::priority_queue <Node> q;//優先隊列,以距離為key的小根堆 bool f[maxn];memset(f,0,sizeof(f));//f[i]標記是否在確認集合 memset(dis,0x3f,sizeof(dis));//初始化其他節點到源點的最小距離為無窮大 dis[x]=0;//源點到自己的距離為0 q.push(Node(x,0));//源點進隊 while(!q.empty()){//隊列非空說明還有點可以松弛 Node t=q.top();q.pop();//取出堆頂的點並出堆,必然到源點的距離最小 int k=t.num; if(f[k])continue;//如果k到源點的最短路已經求出,說明其鄰接點已經松弛 f[k]=1;//k點進確定集合 for(int i=Head[k];i;i=a[i].next){//以k為中間點松弛k的鄰接點 int y=a[i].to,d; if(dis[y]>(d=dis[k]+a[i].dis)){ dis[y]=d;q.push(Node(y,d));//松弛成功k的鄰接點y進堆 } } } } void Solve(){ int m,n;scanf("%d%d",&n,&m); for(int i=1;i<=m;++i){ int x,y,z;scanf("%d%d%d",&x,&y,&z); Insert(x,y,z);Insert(y,x,z);//無向圖 } Dijs(4); for(int i=1;i<=n;++i) printf("%d ",dis[i]); } int main(){ Solve(); return 0; } -
迪傑斯拉算法的核心思想是貪心,此貪心思想是建立在權值為正的基礎上,所以此算法無法解決權值為負的問題。
11.5.2 Bellman-Ford 算法
-
算法的基本思路:以任意順序考慮圖的邊,沿着各條邊進行松弛操作,重復操作
V次(V表示圖中頂點的個數)。 -
對有向帶權圖
G = (V, E),從頂點s起始,利用Bellman-Ford算法求解各頂點最短距離,算法描述如下:for(i = 0;i < V;i++) for each edge(u,v) ∈ E //對每一條邊 Relax(u,v);//松弛操作- 算法對每條邊做松弛操作,並且重復
V次,所以算法可以在於O(V*E)成正比的時間內解決單源最短路徑問題。
- 算法對每條邊做松弛操作,並且重復
-
代碼實現:
#include <bits/stdc++.h> const int maxn = 10000 + 5,maxe=1e5 + 5; struct Node{ int from,to,dis;//不需要邊表,注意跟邊表的區別 }a[2*maxe];//無向圖 int d[maxn];//d[i]表示i到源點的最短距離 int m,n,len=0; void Insert(int x,int y,int z){//建邊 a[++len].from=x;a[len].to=y;a[len].dis=z; } bool Check(){//對所有邊再做一次松弛,如果成功返回1 for(int i=1;i<=len;++i){//遍歷每條邊 int x=a[i].from,y=a[i].to,z=a[i].dis; if(d[y]<d[x]+z)return 1;//松弛成功 } return 0; } void Bellman_ford(int u){ memset(d,0x3f,sizeof(d));//初始化其他點到源點的最短距離為無窮大 d[u]=0;//源點到自己的最短距離為0 for(int i=1;i<n;++i){//對每條邊做n-1次松弛 for(int j=1;j<=len;++j){//遍歷每條邊 int x=a[j].from,y=a[j].to,z=a[j].dis; d[y]=std::min(d[y],d[x]+z);//松弛操作 } } } void Solve(){ scanf("%d%d",&n,&m); for(int i=1;i<=m;++i){ int x,y,z;scanf("%d%d%d",&x,&y,&z); Insert(x,y,z);Insert(y,x,z); } Bellman_ford(4); if(Check()){//松弛完n-1次后如果還能松弛成功說明有負環回路 printf("NO\n");return; }//無法松弛說明不存在負權回路 for(int i=1;i<=n;++i) printf("%d ",d[i]); } int main(){ Solve(); return 0; } -
Dijkstra算法和Bellman-ford算法的區別:Dijkstra算法在求解的過程中,源點到集合S內各頂點的最短路徑一旦求出,則之后就不變了,修改的僅僅是源點到未確定最短距離的集合T中各頂點的最短路路徑長度。Bellman-ford算法在求解過程中,源點到各頂點的最短距離知道算法結束才能確定下來。Bellman-ford能解決負權問題,也可以判斷圖中是否存在負環,而Dijkstra只能解決權值為正的問題。
11.5.3 spfa算法
-
spfa可以看成是Bellman-ford的隊列優化版本。 -
Bellman每一輪用所有邊來進行松弛操作可以多確定一個點的最短路徑,但是用每次都把所有邊拿來松弛太浪費了。 -
只有那些松弛成功的點才有可能松弛它的鄰接點,所以我們可以用一個隊列記錄松弛成功點的,依次用這些點去松弛鄰接點。
-
代碼實現:
#include <bits/stdc++.h>
const int maxn = 10000 + 5,maxe=1e5 + 5;
struct Node{
int to,dis,next;
}a[maxe];
int d[maxn],head[maxn];//d[i]表示i到源點的最短距離
int m,n,len=0,cnt[maxn];//cnt[i]記錄節點i進隊次數
bool inq[maxn];//inq[i]=0表示節點i在隊列
void Insert(int x,int y,int z){
a[++len].to=y;a[len].dis=z;a[len].next=head[x];head[x]=len;
}
bool spfa(int s){//返回0表示不存在最短路,即有負環
memset(d,0x3f,sizeof(d));d[s]=0;//除了源點,其他點到源點最短距離為無窮
std::queue<int>q;q.push(s);inq[s]=1;//源點入隊
while(!q.empty()){
int u=q.front();q.pop();//取出隊首,並出隊
inq[u]=0;//頂點可以反復入隊,所以出隊后要把標記設為0
for(int i=head[u];i;i=a[i].next){
int v=a[i].to,dis;
if(d[v]>(dis=d[u]+a[i].dis)){//松弛成功
d[v]=dis;
if(!inq[v]){//節點v不在隊列中
if(++cnt[v]>=n)return 0;//一個點被松弛n次,肯定有負環
q.push(v);inq[v]=1;
}
}
}
}
return 1;
}
void Solve(){
scanf("%d%d",&n,&m);
for(int i=1;i<=m;++i){
int x,y,z;scanf("%d%d%d",&x,&y,&z);
Insert(x,y,z);Insert(y,x,z);
}
if(!spfa(4)){
printf("NO\n");return;
}
for(int i=1;i<=n;++i)
printf("%d ",d[i]);
}
int main(){
Solve();
return 0;
}
spfa算法對隨機數據效果很好,甚至比堆優化的dijkstra還要快。但如果在處理非負權的最短路時不建議使用spfa容易被卡。spfa有一般有三種簡單的優化,這三種優化都是把隊列換成雙端隊列,但都容易被正對性數據卡掉,LLL優化:每次將入隊結點距離和隊內距離平均值比較,如果更大則插入至隊尾,否則插入隊首。Hack:向1連接一條權值巨大的邊,這樣LLL就失效了。
SLF優化:每次將入隊結點距離和隊首比較,如果更大則插入至隊尾,否則插入隊首。- Hack:使用鏈套菊花的方法,在鏈上用幾個並列在一起的小邊權邊就能欺騙算法多次進入菊花。
SLF帶容錯:每次將入隊結點距離和隊首比較,如果比隊首大超過一定值則插入至隊尾,否則插入隊首。- 卡法是卡 SLF 的做法,並開大邊權,權值總和最好超過 \(10^{12}\)。
11.5.4 Floyd 算法
-
Floyd算法 (Floyd-Warshall algorithm) 又稱為弗洛伊德算法、插點法,是解決給定的加權圖中頂點間的最短路徑的一種算法。 -
該算法名稱以創始人之一、1978年圖靈獎獲得者、斯坦福大學計算機科學系教授羅伯特·弗洛伊德命名。
-
適用范圍:無負權回路即可,邊權可正可負,運行一次算法即可求得任意兩點間最短路。
-
問題模型:
-
某個國家有
n個城市,這n個城市間有m條公路相連 ,第i條公路長度為 \(a_i\) 。我們現在需要求任意兩個城市之間的最短路程,也就是求任意兩個點之間的最短路徑。這個問題這也被稱為“多源最短路徑”問題。
-
上圖中有
4個城市8條公路,公路上的數字表示這條公路的長短。請注意這些公路是單向的。 -
Floyd算法的數據的存儲,我們一般用鄰接矩陣,即一個二維數組來存儲。

-
算法分析:
-
如果要讓任意兩點 (例如從頂點
A點到頂點B) 之間的路程變短,只能引入第三個點 (頂點K) ,並通過這個頂點K中轉即A-> K -> B,才可能縮短其距離。 -
假如現在只允許經過
1號頂點,即K=1來中轉,求任意兩點之間的最短路程,我們只需判斷節點1是否能松弛當前邊即可。//核心代碼 for(int i=1;i<=n;++i)//枚舉邊的起點 for(int j=1;j<=n;++j)//枚舉邊的終點 if(a[i][j]>a[i][1]+a[1][j])//如果1能松弛邊i->j a[i][j]=a[i][1]+a[1][j]; -
通過節點
1松弛后結果如下圖所示。 -

-
接下來繼續求在只允許經過
1和2號兩個頂點的情況下任意兩點之間的最短路程。//經過1號頂點 for(int i=1;i<=n;++i)//枚舉邊的起點 for(int j=1;j<=n;++j)//枚舉邊的終點 if(a[i][j]>a[i][1]+a[1][j])//如果1能松弛邊i->j a[i][j]=a[i][1]+a[1][j]; //經過2號頂點 for(int i=1;i<=n;++i)//枚舉邊的起點 for(int j=1;j<=n;++j)//枚舉邊的終點 if(a[i][j]>a[i][2]+a[2][j])//如果2能松弛邊i->j a[i][j]=a[i][2]+a[2][j];
-
依此類推,我們只要依次枚舉中轉點即可。
for(int k=1;k<=n;++k)//枚舉中轉點 for(int i=1;i<=n;++i)//枚舉邊的起點 for(int j=1;j<=n;++j)//枚舉邊的終點 if(a[i][j]>a[i][k]+a[k][j])//松弛操作 a[i][j]=a[i][k]+a[k][j];
-
-
時間效率:\(O(n^3)\)
-
