游戲尋路
推薦你看個視頻未來科技開發日記#2。
我們在玩游戲中經常會使用自動尋路功能啦,例如在魔獸爭霸中右鍵點擊一個地方,系統就會自動幫我們尋路過去啦。那么我們會發現,系統幫我們找的路往往是一條較短、省時的路徑,這么做是很合理的,因為沒有人會喜歡無時不刻都在趕路、跑圖的游戲(除非有其他特色,或者本身就是在享受跑圖游戲,在此不列舉了)。例如在一張地圖上不存在任何障礙物,那么最短路徑一定是走直線啦,兩點之間線段最短嘛!但是例如魔獸爭霸、環世界等游戲地圖就復雜多了,因此路徑的選擇肯定是有算法的支撐。
我們來考慮一個較為簡易的情景,假設我這張地圖使用頂點和路徑描述,那我要從某個頂點到達另一個頂點,肯定是願意選擇最短的路徑了,此時要去考慮什么樣的路徑,邊的權值相加最小?
Dijkstra 算法
算法介紹
算法思想
Dijkstra 算法解決的是單源點最短路徑問題:給定帶權有向圖 G 和源點 v0,求從 v0 到 G 中其余個頂點的最短路徑,即一個出發點到達其他頂點的最短路徑。Dijkstra 算法的思想是按照路徑長度遞增的次序產生最短路徑的算法。
艾茲格·W·迪科斯徹
艾茲格·W·迪科斯徹(Edsger Wybe Dijkstra,1930年5月11日~2002年8月6日)荷蘭人。 計算機科學家,畢業就職於荷蘭Leiden大學,早年鑽研物理及數學,而后轉為計算學。曾在1972年獲得過素有計算機科學界的諾貝爾獎之稱的圖靈獎,之后,他還獲得過1974年 AFIPS Harry Goode Memorial Award、1989年ACM SIGCSE計算機科學教育教學傑出貢獻獎、以及2002年ACM PODC最具影響力論文獎。——百度百科
迪傑斯特拉的成就有:
- 提出 “goto 有害論”;
- 提出信號量和 PV 原語;
- 解決了“哲學家聚餐”問題;
- Dijkstra 最短路徑算法和銀行家算法的創造者;
- 第一個 Algol 60 編譯器的設計者和實現者;
- THE 操作系統的設計者和開發者;
算法流程
對於圖結構 N = (V,E),首先將 N 中的頂點分為 2 組:
- S:求出最短路徑的終點集合(初始化只有出發點 v0);
- V - S:尚未求出最短路徑的頂點集合(初始化僅 v0 不被包含)
分組之后,按照各個頂點和 v0 間最短路徑長度遞增的順序,將集合 V - S 中的點加入到 S 集合中。此時集合 S 將保證從 v0 出發到 S 中的各個頂點的路徑長不大於到集合 V - S 中的各頂點的路徑長度。
證明
算法的正確性來源於:下一條最短路徑(設終點為 x)或者邊 (v,x),或者是中間只經過 S 中的頂點而最后的到達頂點 x 的路徑。
要證明這個算法,可以使用反證法。假設路徑上有一個頂點不屬於 S,則說明存在一條終點不在 S 而長度比此路徑段的路徑。由於算法是按照路徑的遞增次序來產生最短路徑,因此長度比此路徑短的所有路徑均已經產生,終點必存在於 S。因此這個假設是不成立的,從而證明算法成立。
算法結構設計
在使用帶權值的鄰接矩陣實現的條件下,需要引入如下結構輔助設計:
- 一維數組 S[i]:記錄源點 v0 到終點 vi 是否已經確定最短路徑,用 bool 變量來表示;
- 一維數組 Path[i]:記錄源點 v0 到終點 vi 的當前最短路徑上 vi 的前驅序號,初始化的時候若 v0 到 vi 的弧存在則為 v0,不存在則將值設為 -1。
- 一維數組 D[i]:記錄點 v0 到終點 vi 的當前最短路徑長度。初始化的時候若 v0 到 vi 有弧,則 D[i] 為弧上的權值,否則為 ∞。
我們很明白兩點之間線段最短,因此第一組最短路徑為 (v0,vk),當我確定了最短路徑之后,將對應的頂點加入到頂點集 S 中。每當加入了一個新的頂點,對於第二組剩余的各個頂點而言意味着多了一條可選的中轉點,那就有可能出現一條新的路徑,因此需要對第二組剩余的各個頂點進行修正。也就是說,對於原來 v0 到 vi 的最短路徑是 D[i],當 vk 被添加之后,最短路徑將會修正為 D[k] + G.edges[k][i],若這個修正值更小就把它保存下來。接下來就選擇數組 D 中值最小的頂點加入到第一組頂點集 S 中,重復這個操作直至所有頂點都加入 S 為止。
模擬實現
假設有如圖所示網結構,現求解從 v0 出發的最短路徑。
首先我們看一下各個結構在初始化的時候發生的數據變更:
我們稍微解釋一下發生了什么,首先由於是從 v0 出發,因此初始化的時候僅分析 v0,此時 v0 直達的點分別為 2、4、5,因此這 3 個距離就直接更新到 D 數組去,且到達這 3 個點的前驅是點 v0,因此把 0 填充到數組 Path 中去。對於其他 2 個頂點,由於不能直達,因此距離被認為是無窮大,因此在 D 中填充的是 ∞,Path 填充的是 -1 表示沒有前驅路線可以到達。最后由於分析了 v0 點,因此在 S 數組中要把值修正為 true,其余點由於沒分析就設為 false。
接着我們看看每一步各個參數發生了什么:
根據參數我們是怎么讀取最短路徑的?例如選擇 v0 到 v3,讀取手法為:
Path[3] = 4、Path[4] = 0
因此最短路徑就反過來,是 0 -> 4 -> 3,長度為 50。
代碼實現
void ShortestPath_DIJ(MGraph g, int v0)
{
int v;
int min;
int Path[MAXV];
int S[MAXV];
int D[MAXV];
/*對各個輔助結構初始化*/
for(int i = 0; i < g.n; i++)
{
S[i] = false; //S 初始化為全部單元 false,表示空集
D[i] = g.edges[v0][i]; //將 v0 到各個終點的最短路徑初始化為直達路徑
if(D[i] < INFINITY) //v0 到 i 的弧存在,設置前驅為 v0
{
Path[i] = v0;
}
else //v0 到 i 的弧不存在,設置前驅為 -1
{
Path[i] = -1;
}
}
S[v0] = true; //將 v0 加入集合 S
D[v0] = 0; //出發到到其本身的距離為 0
/*初始化結束,開始循環添加點*/
for(int i = 1; i < g.n; i++) //依次考慮剩余的 n - 1 個頂點
{
min = INFINITY;
for(int j = 0; j < g.n; j++)
{
if(S[j] == false && D[j] < min) //若 vj 未被添加入集合 S 且路徑最短,拷貝信息
{
v = j; //表示選擇當前最短路徑,終點是 v
min = D[j];
}
}
s[v] = true; //將點 v 加入集合 S
for(int j = 0; j < g.n; j++)
{
if(S[j] == false && (D[v] + g.edges[v][j] < D[j])) //判斷是否要修正路徑
{
D[j] = D[v] + g.edges[v][j];
Path[j] = v; //修改 vj 的前驅為 v
}
}
}
}
輸出輔助函數
void PrintMGraph(MGraph g)
{
int i,j;
for(i=0;i<g.n;i++)
{
for(j=0;j<g.n;j++) cout<<g.edges[i][j]<<" ";
cout<<endl;
}
for(i=0;i<g.n;i++)
delete[] g.edges[i];
}
復雜度分析與優化
時間復雜度
算法中添加頂點的循環執行 n - 1 次,每次執行的時間復雜度為 O(n),所以總時間復雜度為 O(n2)。如果用帶權的鄰接表來存儲,則雖然修改 D 數組的時間可以被降下來,但由於在 D 中選擇最小分量的時間不變,所以時間復雜度仍為O(n2)。我們往往只希望找到從源點到某一個特定終點的最短路徑,但是這個問題和求源點到其他所有頂點的最短路徑一樣復雜,也得用迪傑斯特拉算法來解決。
堆優化
我們看到迪傑斯特拉算法的時間復雜度還是比較大的,我們有沒有手法可以使其時間復雜度可以降下來?左轉博客——堆、優先級隊列和堆排序。
Floyd 算法
算法介紹
算法思想
Floyd 算法又稱為插點法,是一種利用動態規划的思想尋找給定的加權圖中多源點之間最短路徑的算法。——Floyd算法
羅伯特·弗洛伊德
計算機科學家,圖靈獎得主,前后斷言法的創始人,堆排序算法和Floyd-Warshall算法的創始人之一。歷屆圖靈獎得主基本上都有高學歷、高學位,絕大多數有博士頭銜。這是可以理解的,因為創新型人才需要有很好的文化素養,豐富的知識底蘊,因而必須接受良好的教育。但事情總有例外,1978年圖靈獎獲得者、斯坦福大學計算機科學系教授羅伯特·弗洛伊德就是一位“自學成才的計算機科學家”(a Self-Taught Computer Scientist)。——羅伯特·弗洛伊德
這位大咖不是那位心理學家弗洛伊德哦,而且他的故事也值得我們學習,詳情可以看更多的資料。
算法結構設計
Floyd 算法使用鄰接矩陣來存儲圖結構,需要引入 2 個輔助結構。
- 二維數組 Path[i][j]:最短路徑上頂點 vj 的前一頂點的序號;
- 二維數組 D[i][j]:記錄頂點 vi 和 vj 之間的最短路徑長度;
算法流程
算法的時間復雜度為 O(n2),和執行 n 次的迪傑斯特拉算法一樣。由於比較難理解,我引用一下資料對這個過程的描述。
首先要初始化各個結構,將 D 所存儲的 vi 到 vj 的最短路徑長初始化為鄰接矩陣 G 中的各個邊信息。
第一步在 vi 和 vj 之間加入頂點 v0,比較 (vi,vj) 和 (vi,v0,vj) 的路徑長,取其中較短的路徑作為新的最短路徑;
第二步在 vi 和 vj 之間加入頂點 v1,得到 vi 到 v1 且中間頂點序號不大於 1 - 1 = 0 的最短路徑 (vi,…,v1),和 v1 到 vj 且中間頂點的序號不大於 1 - 1 = 0 的最短路徑 (v1,…,vj),其實這兩個路徑在上一步已經求出。比較 (vi,…,v1,…,vj) 與上一步求出的 vi 到 vj 的中間頂點序號不大於 0 的最短路徑,取其中較短的作為 vi 到 vj 的中間序號不大於 1 的最短路徑;
……
第 k 步在 vi 和 vj 之間加入頂點 vk,得到 vi 到 vk 且中間頂點序號不大於 k - 1 的最短路徑 (vi,…,v1),和 vk 到 vj 且中間頂點的序號不大於 k - 1 的最短路徑 (vk,…,vj),其實這兩個路徑在上一步已經求出。比較 (vi,…,vk,…,vj) 與上一步求出的 vi 到 vj 的中間頂點序號不大於 0 的最短路徑,取其中較短的作為 vi 到 vj 的中間序號不大於 k 的最短路徑;
經過 n 次比較后,最后求得的必是 vi 到 vj 的最短路徑,可以同時求出各對頂點間的最短路徑。——《數據結構(C語言版)》
代碼實現
void ShortestPath_Floyd(MGraph g)
{
int Path[MAXV][MAXV]; //最短路徑上頂點 vj 的前一頂點的序號
int [MAXV][MAXV]; //記錄頂點 vi 和 vj 之間的最短路徑長度
for(int i = 1; i < g.n; i++)
{
for(int j = 1; j < g.n; j++)
{
D[i][j] = g.edges[i][j];
if(D[i][j] < MAXINT && i != j)
{
Path[i][j] = i; //若 i 和 j 之間有弧,則將 j 的前驅置為 i;
}
else
{
Path[i][j] = -1; //若 i 和 j 之間有弧,則將 j 的前驅置為 -1;
}
}
}
for(int k = 0; k < g.n; k++)
{
for(int i = 1; i < g.n; i++)
{
for(int j = 1; j < g.n; j++)
{
if(D[i][k] + D[k][j] < D[i][j]) //從 i 經過 k 到 j 的路徑更短
{
D[i][j] = D[i][k] + D[k][j]; //更新路徑長
Path[i][j] = Path[k][j]; //更改 j 的前驅為 k
}
}
}
}
}
實例:旅游規划
情景需求
輸入樣例
4 5 0 3
0 1 1 20
1 3 2 30
0 3 4 10
0 2 2 20
2 3 1 20
輸出樣例
3 40
情景分析
思路還是比較明確的,拋開價格問題的話就是個最短路徑模板。所以我們來審視一下價格,首先當最短路徑只有一條時,跟價格沒啥關系,因為這個情景的需求就是最短路徑為主導。那么當最短路徑出現多分支時,價格來主導,也就是說價格和路徑長都是選擇路徑的依據,但是價格的優先級小於路徑長的優先級。因此我們的想法是,在出現相同路徑長的時候對價格進行修正,若出現更低的價格就保存。
當然這道題沒有對最終選擇出的路徑提出輸出的要求,不然直接修正是有歧義的,你怎么知道你求出的最省費用和最短路徑是對應的?其實還有一種思路就是直接以價格為權做最短路徑,這樣就可以選出最省錢的路徑選擇方案,因此這道題完全可以映射成一個生活問題:你旅游是要盡量省錢還是盡量縮短日程,二者在統一中其實還有對立的方面嘞。
偽代碼
代碼
road ShortestPath_DIJ(MGraph g, int v0,int des)
{
int v;
int min;
int Path[MAXV];
int S[MAXV];
road D[MAXV];
/*對各個輔助結構初始化*/
for (int i = 0; i < g->n; i++)
{
S[i] = false; //S 初始化為全部單元 false,表示空集
D[i].length = g->edges[v0][i].length; //將 v0 到各個終點的最短路徑初始化為直達路徑
D[i].money = g->edges[v0][i].money; //將 v0 到各個終點的最短路徑初始化為直達路徑
if (D[i].length < INFINITY) //v0 到 i 的弧存在,設置前驅為 v0
{
Path[i] = v0;
}
else //v0 到 i 的弧不存在,設置前驅為 -1
{
Path[i] = -1;
}
}
S[v0] = true; //將 v0 加入集合 S
D[v0].length = D[v0].money = 0; //出發到到其本身的距離為 0
/*初始化結束,開始循環添加點*/
for (int i = 1; i < g->n; i++) //依次考慮剩余的 n - 1 個頂點
{
min = INFINITY;
v = -1;
for (int j = 0; j < g->n; j++)
{
if (S[j] == false && D[j].length <= min) //若 vj 未被添加入集合 S 且路徑最短,拷貝信息
{
v = j; //表示選擇當前最短路徑,終點是 v
min = D[j].length;
}
}
if (v == -1)
{
break;
}
S[v] = true; //將點 v 加入集合 S
for (int j = 0; j < g->n; j++)
{
if (S[j] == false && g->edges[v][j].length < INFINITY)
{
if (D[v].length + g->edges[v][j].length < D[j].length) //判斷是否要修正路徑
{
D[j].length = D[v].length + g->edges[v][j].length;
D[j].money = D[v].money + g->edges[v][j].money;
Path[j] = v; //修改 vj 的前驅為 v
}
else if (D[v].length + g->edges[v][j].length == D[j].length
&& D[v].money + g->edges[v][j].money < D[j].money)
{
D[j].money = D[v].money + g->edges[v][j].money;
}
}
}
}
return D[des];
}
參考資料
《數據結構(C語言版|第二版)》—— 嚴蔚敏 李冬梅 吳偉民 編著,人民郵電出版社
堆、優先級隊列和堆排序
艾茲格·W·迪科斯徹
Floyd算法
羅伯特·弗洛伊德