地鐵線路圖的設計與實現


    在北京、上海這樣的一線城市,地鐵絕對是上班族的首選交通工具,盡管有時擠得要命,但你真的找不出比地鐵更准點的交通工具了。平時出門,我也總是習慣於在百度地圖或丁丁地圖里先查詢一下地鐵乘車路線,這些程序用起來非常方便。最近幾天終於有點空余時間了,我就在想,我是否也可以寫一個這樣的程序?作為一名專業碼農,我決定立刻動手。

 

    首先,我給地鐵線路圖程序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、界面上的“起點”和“終點”兩個圖標,來源於網絡,版本歸原作者所有。

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM