作者:Vamei 出處:http://www.cnblogs.com/vamei 歡迎轉載,也請保留這段聲明。謝謝!
圖是由節點和連接節點的邊構成的。節點之間可以由路徑,即邊的序列。根據路徑,可以從一點到達另一點。在一個復雜的圖中,圖中兩點可以存在許多路徑。最短路徑討論了一個非常簡單的圖論問題,圖中從A點到B點 ,那條路徑耗費最短?
這個問題又異常復雜,因為網絡的構成狀況可能很復雜。
一個最簡單的思路,是找出所有可能的從A到B的路徑,再通過比較,來尋找最短路徑。然而,這並沒有將問題簡化多少。因為搜索從A到B的路徑,這本身就是很復雜的事情。而我們在搜索所有路徑的過程中,有許多路徑已經繞了很遠,完全沒有搜索的必要。比如從上海到紐約的路線,完全沒有必要先從上海飛到南極,再從南極飛到紐約,盡管這一路徑也是一條可行的路徑。
所以,我們需要這樣一個算法:它可以搜索路徑,並當已知路徑包括最短路徑時,即停止搜索。我們先以無權網絡為例,看一個可行的最短路徑算法。
無權網絡
無權網絡(unweighted network)是相對於加權網絡的,這里的“權”是權重。每條邊的耗費相同,都為1。路徑的總耗費即為路徑上邊的總數。
我們用“甩鞭子”的方式,來尋找最短路徑。鞭子的長度代表路徑的距離。
手拿一個特定長度的鞭子,站在A點。甩出鞭子,能打到一些點。比如C和D。
將鞭子的長度增加1。再甩出鞭子。此時,從C或D出發,尋找距離為1的鄰接點,即E和F。這些點到A點的距離,為此時鞭子的長度。
記錄點E和F,並記錄它們的上游節點。比如E(C), F(D)。我們同樣可以記錄此時該點到A的距離,比如5。
如果要記錄節點E時,發現它已經出現在之前的記錄中,這說明曾經有更短的距離到E。此時,不將E放入記錄中。畢竟,我們感興趣的是最短路徑。如下圖中的E:
黃色的E不被記錄
最初的鞭子長度為0,站在A點,只能打到A點自身。當我們不斷增加鞭子長度,第一次可以打到B時,那么此時鞭子的長度,就是從A到B的最短距離。循着我們的記錄,倒推上游的節點,就可以找出整個最短路徑。
我們的記錄本是個很有意思的東西。某個點放入記錄時,此時的距離,都是A點到該點的最短路徑。根據記錄,我們可以反推出記錄中任何一點的最短路徑。這就好像真誠對待每個人。這能保證,當你遇到真愛時,你已經是在真誠相待了。實際上,記錄將所有節點分割成兩個世界:記錄內的,已知最短距離的;記錄外的,未知的。
加權網絡
在加權網絡中(weighted network),每條邊有各自的權重。當我們選擇某個路徑時,總耗費為路徑上所有邊的權重之和。
加權網絡在生活中很常見,比如從北京到上海,可以坐火車,也可以坐飛機。但兩種選擇耗費的時間並不同。再比如,我們打出租車和坐公交車,都可以到市區,但車資也有所不同。在計算機網絡中,由於硬件性能不同,連接的傳輸速度也有所差異。加權網絡正適用於以上場景。無權網絡是加權網絡的一個特例。
這個問題看起來和無權網絡頗為類似。但如果套用上面的方法,我們會發現,記錄中的節點並不一定是最短距離。我們看下面的例子:
很明顯,最短路徑是A->C->D->B,因為它的總耗費只有4。按照上面的方法,我們先將A放入記錄。從A出發,有B和C兩個如果將B和C同時放入記錄,那么記錄中的B並不符合最短距離的要求。
那么,為什么無權網絡可行呢?假設某次記錄時,鞭子長度為5,那么這次記錄點的鄰接點,必然是距離為6的點。如果這些鄰接點沒有出現過,那么6就是它們的最短距離。所有第一次出現的鄰接點,都將加入到下次的記錄中。比如下面的例子,C/D/E是到達A的鄰接點,它們到A的最短距離必然都是1。
對於加權網絡來說,即使知道了鄰接點,也無法判斷它們是否符合最短距離。在記錄C/D/E時,我們無法判斷未來是否存在如下圖虛線的連接,導致A的鄰接點E並不是下一步的最短距離點:
但情況並沒有我們想的那么糟糕。仔細觀察,我們發現,雖然無法一次判定所有的鄰接點為下一步的最短距離點,但我們可以確定點C已經處在從A出發的最短距離狀態。A到C的其它可能性,比如途徑D和E,必然導致更大的成本。
也就是說,鄰接點中,有一個達到了最短距離點,即鄰接點中,到達A距離最短的點,比如上面的C。我們可以安全的把C改為已知點。A和C都是已知點,點P成為新的鄰接點。P到A得距離為4。
出於上面的觀察,我們可以將節點分為三種:
- 已知點:已知到達A最短距離的點。“我是成功人士。”
- 鄰接點:有從記錄點出發的邊,直接相鄰的點。“和成功人士接觸,也有成功的機會哦。”
- 未知點:“還早得很。”
最初的已知點只有A。已知點的直接下游節點為鄰接點。對於鄰接點,我們需要獨立的記錄它們。我們要記錄的有:
- 當前情況下,從A點出發到達該鄰接點的最短距離。比如對於上面的點D,為6。
- 此最短距離下的上游節點。對於上面的點D來說,為A。
每次,我們將鄰接點中最短距離最小的點X轉為已知點,並將該點的直接下游節點,改為鄰接點。我們需要計算從A出發,經由X,到達這些新增鄰接點的距離:新距離 = X最短距離 + QX邊的權重。此時有兩種情況,
- 如果下游節點Q還不是鄰接點,那么直接加入,Q最短距離 = 新距離,Q上游節點為X。
- 如果下游節點Q已經是鄰接點,記錄在冊的上游節點為Y,最短距離為y。如果新距離小於y,那么最小距離改為新距離,上游節點也改為X。否則保持原記錄不變。
我們還用上面的圖,探索A到E的路徑:
第一步
狀態 | 已知距離 | 上游 | |
A | 已知 | 0 | A |
C | 鄰接 | 1 | A |
D | 鄰接 | 6 | A |
E | 鄰接 | 9 | A |
P | 未知 | 無窮 |
第二步
狀態 | 已知距離 | 上游 | |
A | 已知 | 0 | A |
C | 已知 | 1 | A |
D | 鄰接 | 6 | A |
E | 鄰接 | 9 | A |
P | 鄰接 | 4 | C |
第二步
狀態 | 已知距離 | 上游 | |
A | 已知 | 0 | A |
C | 已知 | 1 | A |
D | 鄰接 | 6 | A |
E | 鄰接 | 7 | P |
P | 已知 | 4 | C |
第三步
狀態 | 已知距離 | 上游 | |
A | 已知 | 0 | A |
C | 已知 | 1 | A |
D | 已知 | 6 | A |
E | 鄰接 | 7 | P |
P | 已知 | 4 | C |
最后,E成為已知。倒退,可以知道路徑為E, P, C, A。正過來,就是從A到E的最短路徑了。
上面的算法是經典的Dijkstra算法。本質上,每個鄰接點記錄的,是基於已知點的情況下,最好的選擇,也就是所謂的“貪婪算法”(greedy algorithm)。當我們貪婪時,我們的決定是臨時的,並沒有做出最終的決定。轉換某個點成為已知點后,我們增加了新的可能性,貪婪再次起作用。根據對比。隨后,某個鄰接點成為新的“貪無可貪”的點,即經由其它任意鄰接點,到達該點都只會造成更高的成本; 經由未知點到達該點更不可能,因為未知點還沒有開放,必然需要經過現有的鄰接點到達,只會更加繞遠。好吧,該點再也沒有貪婪的動力,就被扔到“成功人士”里,成為已知點。成功學不斷傳染,最后感染到目標節點B,我們就找到了B的最短路徑。
實現
理解了上面的原理,算法的實現是小菜一碟。我們借用圖 (graph)中的數據結構,略微修改,構建加權圖。
我們將上面的表格做成數組records,用於記錄路徑探索的信息。
重新給點A,C,D,E,P命名,為0, 1, 2, 3, 4。
代碼如下:
/* By Vamei */ #include <stdio.h> #include <stdlib.h>
#define NUM_V 5
#define INFINITY 10000 typedef struct node *position; typedef struct record *label; /* node */
struct node { int element; position next; int weight; }; /* table element, keep record */
struct record { int status; int distance; int previous; }; /* * operations (stereotype) */
void insert_edge(position, int, int, int); void print_graph(position, int); int new_neighbors(position, label, int, int); void shortest_path(position, label, int, int, int); /* for testing purpose */
void main() { struct node graph[NUM_V]; struct record records[NUM_V]; int i; // initialize the vertices
for(i=0; i<NUM_V; i++) { (graph+i)->element = i; (graph+i)->next = NULL; (graph+i)->weight = -1; } // insert edges
insert_edge(graph,0,1,1); insert_edge(graph,0,2,6); insert_edge(graph,0,3,9); insert_edge(graph,1,4,3); insert_edge(graph,4,3,3); print_graph(graph,NUM_V); // initialize the book
for (i=0; i<NUM_V; i++){ (records+i)->status = -1; (records+i)->distance = INFINITY; (records+i)->previous = -1; } shortest_path(graph, records, NUM_V, 0, 3); //
} void shortest_path(position graph, label records, int nv, int start, int end) { int current; (records+start)->status = 1; (records+start)->distance = 0; (records+start)->previous = 0; current = start; while(current != end) { current = new_neighbors(graph, records, nv, current); } while(current != start) { printf("%d <- ", current); current = (records+current)->previous; } printf("%d\n", current); } int new_neighbors(position graph, label records, int nv, int current) { int newDist; int v; int i; int d; position p; // update the current known
(records + current)->status = 1; // UPDATE new neighbors
p = (graph+current)->next; while(p != NULL) { v = p->element; (records + v)->status = 0; newDist = p->weight + (records + current)->distance; if ((records + v)->distance > newDist) { (records + v)->distance = newDist; (records + v)->previous = current; } p = p->next; } // find the next known
d = INFINITY; for (i=0; i<nv; i++) { if ((records + i)->status==0 && (records + i)->distance < d){ d = (records + i)->distance; v = i; } } return v; } /* print the graph */
void print_graph(position graph, int nv) { int i; position p; for(i=0; i<nv; i++) { p = (graph + i)->next; printf("From %3d: ", i); while(p != NULL) { printf("%d->%d; w:%d ", i, p->element, p->weight); p = p->next; } printf("\n"); } } /* * insert an edge * with weight */
void insert_edge(position graph,int from, int to, int weight) { position np; position nodeAddr; np = graph + from; nodeAddr = (position) malloc(sizeof(struct node)); nodeAddr->element = to; nodeAddr->next = np->next; nodeAddr->weight = weight; np->next = nodeAddr; }
運行結果如下:
From 0: 0->3; w:9 0->2; w:6 0->1; w:1
From 1: 1->4; w:3
From 2:
From 3:
From 4: 4->3; w:3
3 <- 4 <- 1 <- 0
即從0到1到4到3,也就是從A到C到P到E,是我們的最短路徑。
上面的算法中,最壞情況是目標節點最后成為已知點,即要尋找[$O(|V|)$]。而每個已知點是通過尋找[$O(|V|)$]個節點的最小值得到的。最后,打印出最短的路徑過程中,需要倒退,最多可能有[$O|E|$],也就是說,算法復雜度為[$O(|V|^2 + |E|)$]。
上面的records為一個數組,用於記錄路徑探索信息。我們可以用一個優先隊列來代替它,將已知的節點移除優先隊列。這樣可以達到更好的運算效率。
練習: 自行設計一個加權網絡,尋找最短路徑。
總結
最短路徑是尋找最優解的算法。在復雜的網絡中,簡單的實現方式無法運行,必須求助於精心設計的算法,比如這里的Dijkstra算法。利用貪婪的思想,我們不斷的優化結果,直到找到最優解。
歡迎閱讀“紙上談兵: 算法與數據結構”系列