在北京、上海這樣的一線城市,地鐵絕對是上班族的首選交通工具,盡管有時擠得要命,但你真的找不出比地鐵更准點的交通工具了。平時出門,我也總是習慣於在百度地圖或丁丁地圖里先查詢一下地鐵乘車路線,這些程序用起來非常方便。最近幾天終於有點空余時間了,我就在想,我是否也可以寫一個這樣的程序?作為一名專業碼農,我決定立刻動手。
首先,我給地鐵線路圖程序MetroGraphApp設定了幾個關鍵目標:
1、 操作界面模仿百度地圖,可以直接在線路圖上設置起點和終點。
2、 路徑查找算法不能太慢,絕大多數情況下,必須小於1秒。
3、 線路圖數據必須是可配置的,適用於各個城市的地鐵。
在介紹實現方法之前,先看一下最終的效果圖:
圖 1
圖 2
【技術准備】
MetroGraphApp是一個.NET WinForm程序,開發工具是VS2010,開發語言是C#,繪圖功能基於GDI+。線路圖數據保存在一個XML文件中。查找路線時采用的是數據結構中的最短路徑法。
【總體設計】
地鐵線路構成了數據結構中的圖(Graph),站點是節點(Node),站點之間的通行路徑就是鏈接(Link),也就是邊(Edge)。由於所有通行路徑都是雙向的,所以,這是一個無向圖。在圖中任選兩點,兩點之間的連續路徑(Path)就是我們要查找的乘車路線。
圖 3
在實際的地鐵線路中,每條Link都屬於一條線路(Line),例如:1號線、2號線,等等。兩個站點之間可能有兩條Link,它們分別屬於不同的Line,這就是“雙線並軌”。例如,在上海地鐵線路中,“寶山路”到“宜山路”這段路徑,“3號線”和“4號線”是在同一條軌道上運行的。如果是雙線並軌,繪圖的時候要並行繪制在一起。考慮到實用性和復雜性,我們在這里忽略多線並軌的情況。
整體類設計如下圖所示:
圖 4
MetroGraphView類是一個自定義的UserControl,用於繪制MetroGraph所表示的地鐵線路圖。默認情況下是沒有任何數據的,所需的線路數據是通過LoadFromFile來裝載的,該方法可以從指定的XML文件中讀取數據。
在MetroGraphView控件上,用戶可以通過鼠標點擊的方式,選擇起點和終點,然后控件會自動調用FindPath方法,獲取兩點之間的乘車路線(MetroPath),並將其繪制出來。
【源碼實現】
MetroGraph、MetroNode、MetroLink、MetroLine、MetroPath類的實現都非常簡單,這里不做過多解釋,感興趣的同學可以在文章末尾處下載源代碼。
接下來,我們將重點介紹兩個關鍵功能的實現:
1、 如何繪制線路圖?
2、 如何查找乘車路線?
【如何繪制線路圖】
地鐵線路圖的繪制可以分解為兩個部分:節點繪制和鏈接繪制。
/// <summary> /// 繪制地鐵線路圖。 /// </summary> /// <param name="g">繪圖圖面。</param> /// <param name="graph">地鐵線路圖。</param> private void PaintGraph(Graphics g, MetroGraph graph) { //繪制地鐵路徑 foreach (var link in graph.Links.Where(c => c.Flag >= 0)) PaintLink(g, link); //繪制地鐵站點 foreach (var node in graph.Nodes) PaintNode(g, node); }
細心的讀者會發現,繪制Link的時候,有一個c.Flag>=0的篩選條件,這個Flag是干什么的呢?我來解釋一下。
由於地鐵的行車路徑都是雙向的,所以,我們在構造MetroGraph的時候,兩個Node之間的Link一定是成對出現的,這兩條Link的方向是相反的。但是在繪圖的時候,我們只需要繪制其中的一條即可。這里就有一個邏輯問題,當繪制一條Link的時候,如何判斷其反向Link已經繪制過了?我用了一個最簡單的辦法,直接在Link上放一個標志Flag,如果Flag=0,則繪制,如果Flag=-1,則不繪制。這個Flag是在構造XML數據的時候直接填進去的。
此外,Flag還有另外一個重要用途。文章前面提到過“雙線並軌”的問題,例如圖3中的A、B兩個節點,他們之間存在Line3和Line4並軌的現象。對於並軌的兩條Link,我們需要將其畫成兩條平行線,這兩條線可能是水平線、垂直線,也可能是斜線。線之間沒有空隙。如下圖所示:
圖 5
如何繪制這樣兩條平行線呢?辦法很簡單,只要將線段分別向兩邊移動一定距離即可(Flag的值可以控制移動方向)。假如Link的寬度是5px,那么移動的距離應該是 2.5px,由於DrawLine時用的Pen默認是居中對齊的,這樣就可以畫出沒有間隙的兩條平行線。代碼如下:
/// <summary> /// 繪制地鐵站點間的線路。 /// </summary> /// <param name="g">繪圖圖面。</param> /// <param name="link">地鐵站點間的線路。</param> private void PaintLink(Graphics g, MetroLink link) { Point pt1 = new Point(link.From.X, link.From.Y); Point pt2 = new Point(link.To.X, link.To.Y); using (Pen pen = new Pen(link.Line.Color, 5)) { pen.LineJoin = LineJoin.Round; if (link.Flag == 0) {//單線 g.DrawLine(pen, pt1, pt2); } else if (link.Flag > 0) {//雙線並軌(如果是同向,則Flag分別為1和2,否則都為1) float scale = (pen.Width / 2) / Distance(pt1, pt2); float angle = (float)(Math.PI / 2); if (link.Flag == 2) angle *= -1; //平移線段 var pt3 = Rotate(pt2, pt1, angle, scale); var pt4 = Rotate(pt1, pt2, -angle, scale); g.DrawLine(pen, pt3, pt4); } } }
節點的繪制就要簡單多了。節點由圓圈和標簽構成,圓圈在上,標簽在下。標簽的位置,是個值得改進的問題,因為標簽可能會把Link線條給蓋住。在本程序中,我認為影響不是很大,所以,我把標簽統一放在圓圈的下方。
此外,對於可以換乘不同Line的站點,我們需要把圓圈畫得大一些,這樣更醒目。代碼如下:
/// <summary> /// 繪制地鐵站點。 /// </summary> /// <param name="g">繪圖圖面。</param> /// <param name="node">地鐵站點。</param> private void PaintNode(Graphics g, MetroNode node) { //繪制站點圓圈 Color color = node.Links.Count > 2 ? Color.Black : node.Links[0].Line.Color; var rect = GetNodeRect(node); g.FillEllipse(Brushes.White, rect); using (Pen pen = new Pen(color)) { g.DrawEllipse(pen, rect); } //繪制站點名稱 var sz = g.MeasureString(node.Name, this.Font).ToSize(); Point pt = new Point(node.X - sz.Width / 2, node.Y + (rect.Height >> 1) + 4); g.DrawString(node.Name, Font, Brushes.Black, pt); }
【如何查找乘車路線】
這是圖論中典型的路徑搜索問題。當我處理這個問題的時候,我首先想到並實現的是最短路徑法。最短路徑法具有很強的現實意義,它表明路線比較節省時間。判斷時間長短的辦法由兩個:一是通過累加Link上的權重(Weight)來判斷,二是通過Link數量來判斷。本文程序采用的是后者,因為根據實際經驗,各個站點之間的運行時間是差不多的,以上海地鐵為例,平均時間是大概3分鍾一站。當然,我們沒有考慮換乘的時間。代碼如下:
/// <summary> /// 查找指定兩個節點之間的最短路徑。 /// </summary> /// <param name="startNode">開始節點。</param> /// <param name="endNode">結束節點。</param> /// <param name="line">目標線路(為null表示不限制線路)。</param> /// <returns>乘車路線列表。</returns> private List<MetroPath> FindShortestPaths(MetroNode startNode, MetroNode endNode, MetroLine line) { List<MetroPath> pathtList = new List<MetroPath>(); if (startNode == endNode) return pathtList; //路徑隊列,用於遍歷路徑 Queue<MetroPath> pathQueue = new Queue<MetroPath>(); pathQueue.Enqueue(new MetroPath()); while (pathQueue.Count > 0) { var path = pathQueue.Dequeue(); //如果已經超過最短路徑,則直接返回 if (pathtList.Count > 0 && path.Links.Count > pathtList[0].Links.Count) continue; //路徑的最后一個節點 MetroNode prevNode = path.Links.Count > 0 ? path.Links[path.Links.Count - 1].From : null; MetroNode lastNode = path.Links.Count > 0 ? path.Links[path.Links.Count - 1].To : startNode; //繼續尋找后續節點 foreach (var link in lastNode.Links.Where(c => c.To != prevNode && (line == null || c.Line == line))) { if (link.To == endNode) { MetroPath newPath = path.Append(link); if (pathtList.Count == 0 || newPath.Links.Count == pathtList[0].Links.Count) {//找到一條路徑 pathtList.Add(newPath); } else if (newPath.Links.Count < pathtList[0].Links.Count) {//找到一條更短的路徑 pathtList.Clear(); pathtList.Add(newPath); } else break;//更長的路徑沒有意義 } else if (!path.ContainsNode(link.To)) { pathQueue.Enqueue(path.Append(link)); } } } return pathtList; }
上述算法在大多數情況下都運行得很好,但是存在兩個不足之處:
1、“較少換乘”這個優先性沒有體現出來。程序給出的最短路徑,往往有換乘1站甚至2站,而事實上有一條直達路線,只是該路線因為站點較多,被程序過濾掉了。
2、對於相距太遠的兩個節點,查找時間可能很長,甚至達到1分鍾之久。
為了解決上述問題,我對算法進行了改進。
首先,將直達路線的優先級設置為最高。就是說,如果兩個站點之間有直達路線,就不要選擇那些需要換乘的路線。
其次,經過抽樣統計,我發現直達或換乘一次就能到達目的地的概率高達80%,我相信地鐵建設人員在設計的時候就已經考慮過這個問題了。既然這樣,我可以對換乘一次的路線進行優先查找。假設起點是A,終點是B,它們的換乘點是C,那么,我可以先查出A->C的直達路線,再查出C->B的直達路線,然后將兩條路線合並即可,這樣可以顯著降低時間復雜度。
改進后代碼如下:
/// <summary> /// 查找乘車路線。 /// </summary> /// <param name="startNode">起點。</param> /// <param name="endNode">終點。</param> /// <returns>乘車路線。</returns> public MetroPath FindPath(MetroNode startNode, MetroNode endNode) { MetroPath path = new MetroPath(); if (startNode == null || endNode == null) return path; if (startNode == endNode) return path; //如果起點和終點擁有共同線路,則查找直達路線 path = FindDirectPath(startNode, endNode); if (path.Links.Count > 0) return path; //如果起點和終點擁有一個共同的換乘站點,則查找一次換乘路線 path = FindOneTransferPath(startNode, endNode); if (path.Links.Count > 0) return path; //查找路徑最短的乘車路線 var pathList = FindShortestPaths(startNode, endNode, null); //查找換乘次數最少的一條路線 int minTransfers = int.MaxValue; foreach (var item in pathList) { var curTransfers = item.Transfers; if (curTransfers < minTransfers) { minTransfers = curTransfers; path = item; } } return path; } /// <summary> /// 查找直達路線。 /// </summary> /// <param name="startNode">開始節點。</param> /// <param name="endNode">結束節點。</param> /// <returns>乘車路線。</returns> private MetroPath FindDirectPath(MetroNode startNode, MetroNode endNode) { MetroPath path = new MetroPath(); var startLines = startNode.Links.Select(c => c.Line).Distinct().ToList(); var endLines = endNode.Links.Select(c => c.Line).Distinct().ToList(); var lines = startLines.Where(c => endLines.Contains(c)).ToList(); if (lines.Count == 0) return path; //查找直達路線 List<MetroPath> pathList = new List<MetroPath>(); foreach (var line in lines) { pathList.AddRange(FindShortestPaths(startNode, endNode, line)); } //挑選最短路線 return GetShortestPath(pathList); } /// <summary> /// 查找一次中轉的路線。 /// </summary> /// <param name="startNode">開始節點。</param> /// <param name="endNode">結束節點。</param> /// <returns>乘車路線。</returns> private MetroPath FindOneTransferPath(MetroNode startNode, MetroNode endNode) { List<MetroPath> pathList = new List<MetroPath>(); foreach (var startLine in startNode.Links.Select(c => c.Line).Distinct()) { foreach (var endLine in endNode.Links.Select(c => c.Line).Where(c=> c != startLine).Distinct()) { //兩條線路的中轉站 foreach (var transferNode in this.Graph.GetTransferNodes(startLine, endLine)) { //起點到中轉站的直達路線 var startDirectPathList = FindShortestPaths(startNode, transferNode, startLine); //中轉站到終點的直達路線 var endDirectPathList = FindShortestPaths(transferNode, endNode, endLine); //合並兩條直達路線 foreach (var startDirectPath in startDirectPathList) { foreach (var endDirectPath in endDirectPathList) { var directPath = startDirectPath.Merge(endDirectPath); pathList.Add(directPath); } } } } } //挑選最短路線 return GetShortestPath(pathList); }
【總結】
MetroGraphApp程序主要是應用了圖論和GDI+的知識。最大的難點在於,如何更快地找出符合用戶需要的乘車路線。
傳統的深度遍歷方法不能很好地解決我們的問題,我們需要在遍歷之前,對前方的路徑進行一次偵測,然后把那些不需要的路徑全部“剪除”掉,這樣就可以顯著提高性能。
當然本文的算法並不能保證任意兩點之間的路徑,都能夠在1秒之內找出,有些復雜的路徑搜索還是會長達幾十秒,只是這樣的概率非常低。如果讀者感興趣,可以進一步研究改進。
最后補充一點,雖然這篇文章已經過去幾年了,還是有人不斷問我一些問題,這里集中回答一下:
1、xml路徑數據是怎么產生的?
回答:我之前寫過一個簡單的矢量圖設計軟件,可以直接在界面上標記出節點和線條,然后生成xml文件,可惜現在這個程序現在找不到了。如果有網友確實想要,我可以重新寫一個,不過需要付費的,哈哈^_^
2、路徑算法怎么實現的?
回答:其實文章已經寫得比較清楚,如果文章看不懂,完整的算法就別看了,因為更看不懂的,這個不適合你,回去把數據結構基礎知識好好補一下。另外,再次強調,這篇文章介紹的算法,純粹是探討技術的,如果有人想用於商業用途,還是省省吧,不是授權限制的問題,而是因為商業用途的地鐵線路圖程序,根本就不是簡單的路徑搜索,而是把任意兩點之間的幾條最優路徑提前算好(有些特殊路徑甚至需要人工挑選),然后緩存起來,用戶每次查找的時候,根據起點和終點拼成一個KEY,然后根據key直接從緩存中讀取,這樣的時間復雜度就是O(1),沒有比這更快的了。
源代碼下載(解壓縮密碼是:cnblogs)。
【版權聲明】
1、對於本文描述的算法,以及提供的源代碼,保留所有權利。
2、本文的源代碼只是供大家學習、研究之用,不得用於商業目的。
3、如果想在其它網站或平台轉載,請征得本人同意。
4、界面上的“起點”和“終點”兩個圖標,來源於網絡,版本歸原作者所有。