【PHP數據結構】圖的應用:最短路徑

上篇文章的最小生成樹有沒有意猶未盡的感覺呀?不知道大家掌握得怎麼樣,是不是搞清楚了普里姆和克魯斯卡爾這兩種算法的原理了呢?面試的時候如果你寫不出,至少得說出個大概來吧,當然,如果你是要考研的學生,那就要深入的理解並且記住整個算法的代碼了。

什麼是最短路徑

今天我們學習的是圖的應用中另外一個經典的問題,也就是 最短路徑 的問題。這個問題和最小生成樹是不同的,最小生成樹的要求是要連通所有的結點,並且走得是權值最小的那條路線。而最短路徑則是指的從某個頂點到另一個頂點中權值最小的那條路徑。這條路徑不一定是包含在最小生成樹中的,所以它們並沒有太大的聯繫。

從這張圖來看,我們從結點 1 到結點 2 的最短路徑是 2 ,這個很明顯。那麼從結點 1 到結點 3 呢?可不是直接的中間那個權值爲 6 的路徑,而是走 1->2->3 這條路徑,也就是權值加起來爲 5 的這條路徑。

然後我們再來看結點 3 ,它到結點 1 最短路徑應該是走 3->4->1 這條路徑,也就是權值爲 6 的這條路徑,而不是中間的那條直線的權值爲 7 的路徑。

沒錯,這就是最短路徑的概念了。在最短路徑中,我們一般會解決單向圖的問題,但實際生活中呢?最典型的地圖相關的應用其實是都是雙向圖的。不過這並不影響我們的學習,我們可以把這個示例圖中的結點看成是城市火車站點,就算是連接結點 1 和結點 3 的火車線路,也不一定來去的時間都是相同的。比如說從長沙到北京的 Z2 次火車全部運行時間爲14小時42分,而回來的 Z1 次則是14小時10分。那麼我們是否可以選擇其它的火車,比如有趟火車從長沙到石家莊可能只需要8小時,然後從石家莊到北京只需要2小時,這樣我們選擇這條線路的總時間就只需要10小時了(當然,這只是例子,大家在非高鐵的情況下肯定還是更多地會選擇起始站的火車來坐)。

多源最短路徑 Floyd 算法

首先,我們先說一個多源最短路徑的算法。那麼什麼叫做多源呢?

其實就是這一個算法就能夠得出所有結點到所有結點之間的最短路徑。沒錯,就這一個算法,不管哪個結點到哪個結點,它們之間的最短路徑都一次性算出來了。神奇嗎?不不不,更神奇的,而且你一會就會叫出 Oh!My God! 的是它的核心代碼,只有五行!!

function Floyd($graphArr){
    $n = count($graphArr);
    
    for($k = 1;$k<=$n;$k++){ // 設 k 爲經過的結點
        for($i = 1;$i<=$n;$i++){
            for($j = 1;$j<=$n;$j++){
                // 如果經過 k 結點 能使 i 到 j 的路徑變短,那麼將 i 到 j 之間的更新爲通過 k 中轉之後的結果 
                if($graphArr[$i][$j] > $graphArr[$i][$k] + $graphArr[$k][$j]){
                    $graphArr[$i][$j] = $graphArr[$i][$k] + $graphArr[$k][$j];
                }
            }
        }
    }

    for($i = 1;$i<=$n;$i++){
        for($j = 1;$j<=$n;$j++){
            echo $graphArr[$i][$j], ' ';
        }
        echo PHP_EOL;
    }
}
// 請輸入結點數:4 
// 請輸入邊數:8
// 請輸入邊,格式爲 出 入 權:1 2 2
// 請輸入邊,格式爲 出 入 權:1 3 6
// 請輸入邊,格式爲 出 入 權:1 4 4 
// 請輸入邊,格式爲 出 入 權:2 3 3
// 請輸入邊,格式爲 出 入 權:3 1 7
// 請輸入邊,格式爲 出 入 權:3 4 1
// 請輸入邊,格式爲 出 入 權:4 1 5
// 請輸入邊,格式爲 出 入 權:4 3 12
// 0 2 5 4 
// 9 0 3 4 
// 6 8 0 1 
// 5 7 10 0 

我們可以先驗證下結果,就是註釋中最後輸出的矩陣。結點 1 到結點 2、3、4的最短距離爲 2 、5 、4 。結點 3 到結點 1 、2 、4 的最短距離爲 6 、8 、1 。也就是說,原來的那個圖的鄰接矩陣成了這個最短路徑的矩陣。每一行代表每個結點到其它結點的最短距離。

好吧,結果沒問題,那麼代碼到底是寫得啥玩意?這個 k 是什麼?別急,我們一步一步來看。

  • 假設兩點之間的距離不是最短的,那麼肯定是有另外一個點做爲媒介進行跳轉,由 i 點先跳到這個點然後再跳向 j 點,這樣的一條路徑是比直接的 i 到 j 要近的,我們就定義這個點爲 k 點

  • 但是我們不知道要走哪個結點呀,而且還有可能不只是一個 k ,或許我們從 i 到 j 要經歷好多個 k ,這時候,我們就從 k 開始遍歷,也就是第一層循環

  • 在第一層循環下,進行我們正常的 i 和 j 的遍歷循環,獲得 i 直接到 j 的長度,也就是 [i][j] 。這時,由於有最外層的 k 存在,所以我們也知道了如果 i 從 k 走再從 k 到 j 的長度,也就是 [i][k] + [k][i] 這段距離

  • 很明顯,如果 [i][k] + [k][i] 的距離要比 [i][j] 短的話,更新 [i][j] 的值爲 [i][k] + [k][i]

  • 內部的 i 和 j 循環完成後,第 1 個結點做爲媒介跳轉的遍歷也完成了,當前的矩陣中各個結點之間的權重已經是經過第 1 個結點做爲媒介之後的最短路徑了

  • 但是呢,這並不準確,說不定我們可能經過 i 、k1 、 k2 、 j 的路徑纔是最短的,所以外層的 k 循環繼續遍歷並將第 2 個結點作爲媒介結點

  • 循環往復直到所有結點都做過一次中間的媒介結點之後,我們就得到了一個最短路徑的矩陣圖,也就是我們上面測試代碼中輸出的結果

我們就拿結點 4 和結點 3 來說明。我們定義 4 爲 i ,結點 3 爲 j 。

初始化時,[i][j] 爲 12 ,這個沒什麼問題,單向圖的那條帶箭頭的邊的權值就是 12 。

然後當 k 爲 1 時,也就是我們經過結點 1 來看路徑有沒有變短,這時 [i][k] 是 5 ,[k][j] 是 6 ,OK,路徑變成 11 了,把 [i][j] 的值改成 11 。

同時,在 i 爲 4 ,j 爲 2 的情況下,他們兩個的最短路徑也在這次 k=1 的循環中被賦值爲 7 。最開始 4 到 2 是沒有直接的邊的,現在在結點 1 的連接下,他們有了路徑,也就是 [4][2] = [4][1] + [1][2] = 7 。

當 k 爲 2 時,[i][j] 爲 11 ,這時 [i][k] 就是上面說過的 [4][2] 。也就是 7 ,而 [k][j] 則是 3 ,路徑又縮小了,[i][k] + [k][j] = 10 ,[i][j] 現在又變成了 10 。

循環繼續,但已經沒有比這條路徑更小的值了,所以最後 [4][2] 的最短路徑就是 10 。

看着暈嗎?拿出筆來在紙上或者本子上自己畫畫,每一步的 k 都去畫一下當前的最短路徑矩陣變成什麼樣了。這樣畫一次之後,馬上就知道這個 Floyd 算法的核心奧祕所在了。

不得不說,前人的智慧真的很偉大吧,不過說是前人,其實 Floyd 大佬在 1962 年才發表了這個算法,但這個算法的核心思想卻是數學中的動態規劃的思想。所以說,算法和數學是沒法分家的,各位大佬哪個不是數學界的一把手呢。

單源最短路徑 Dijkstra 算法

說完了多源最短路徑,我們再講一個鼎鼎大名的單源最短路徑的算法。雖說上面的多源很牛X,但是它的時間複雜度也就是時間效率也確實是太差了,沒看錯的話三個 N 次的循環嵌套就是 O(N3)。如果數據稍微多一點的話基本就可以從 Oh!My God! 變成 Oh!FxxK! 了。而且大多數情況下,我們的需求都會是固定的求從某一點到另一點的最短路徑問題,也就是單源最短路徑問題。這時,就可以使用這種效率稍微好一點的算法來快速地解決了。

// origin 表示源點,也就是我們要看哪個結點到其它結點的最短路徑
function Dijkstra($graphArr, $origin)
{
    $n = count($graphArr);
    $dis = []; // 記錄最小值
    $book = []; // 記錄結點是否訪問過
    // 初始化源點到每個點的權值
    for ($i = 1; $i <= $n; $i++) {
        $dis[$i] = $graphArr[$origin][$i]; // 源點到其它點的默認權值
        $book[$i] = 0; // 所有結點都沒訪問過
    }

    $book[$origin] = 1; // 源點自身標記爲已訪問

    // 核心算法
    for ($i = 1; $i <= $n - 1; $i++) {
        $min = INFINITY;
        // 找到離目標結點最近的結點
        for ($j = 1; $j <= $n; $j++) {
            // 如果結點沒有被訪問過,並且當前結點的權值小於 min 值
            if ($book[$j] == 0 && $dis[$j] < $min) {
                $min = $dis[$j]; // min 修改爲當前這個節點的路徑值
                $u = $j; // 變量 u 變爲當前這個結點
            }
            // 遍歷完所有結點,u 就是最近的那個頂點
        }
        $book[$u] = 1; // 標記 u 爲已訪問
        for ($v = 1; $v <= $n; $v++) {
            // 如果 [u][v] 頂點小於無窮
            if ($graphArr[$u][$v] < INFINITY) {
                // 如果當前 dis[v] 中的權值大於 dis[u]+g[u][v]
                if ($dis[$v] > $dis[$u] + $graphArr[$u][$v]) {
                    // 將當前的 dis[v] 賦值爲 dis[u]+g[u][v]
                    $dis[$v] = $dis[$u] + $graphArr[$u][$v];
                }
            }
        }
        // 最近的結點完成,繼續下一個最近的結點
    }

    for ($i = 1; $i <= $n; $i++) {
        echo $dis[$i], PHP_EOL;
    }
}

// 請輸入結點數:4 
// 請輸入邊數:8
// 請輸入邊,格式爲 出 入 權:1 2 2
// 請輸入邊,格式爲 出 入 權:1 3 6
// 請輸入邊,格式爲 出 入 權:1 4 4 
// 請輸入邊,格式爲 出 入 權:2 3 3
// 請輸入邊,格式爲 出 入 權:3 1 7
// 請輸入邊,格式爲 出 入 權:3 4 1
// 請輸入邊,格式爲 出 入 權:4 1 5
// 請輸入邊,格式爲 出 入 權:4 3 12

// 測試第四個結點到其它結點的最短路徑
Dijkstra($graphArr, 4);
// 5
// 7
// 10
// 0

代碼一下增加了不少吧,不過仔細看一下核心的算法部分,這次只是兩層循環的嵌套了,時間複雜度一下子就降到了 O(N2) ,這一下就比 Floyd 算法提升了很多。當然,它的場景也是有限的,那就是隻能一個結點一個結點的計算。

好了,我們還是來看一下 Dijkstra 到底在幹嘛吧。我們依然是使用上面那個簡單的圖,並且還是研究結點 4 到其它結點的算法執行情況。

  • 首先,我們初始化結點 4 到其他所有結點的默認值,這時結點 4 到結點 2 是沒有直接路徑的,所以是無窮大,而到結點 1 是 5,到結點 3 是 12 。

  • 然後將結點 4 標記爲已訪問,也就是 book[4] = 1

  • 進入核心算法,從頭開始遍歷結點,這裏是標記爲 i 下標,因爲這裏是單源的最短路徑,所以我們不需要再看自己到自己的最短路徑了,只需要 n-1 次循環就可以了

  • 開始 j 層的循環,先判斷當前的結點是否已經被標記過,沒有被標記過的話再看它的值是否是最小的,最後循環完成後獲得一個從結點 4 出發的權值最小的路徑,並將這條路徑到達的結點下標記爲 u ,標記 u 下標的這個結點爲已訪問結點

  • 進入 v 循環,判斷圖中 u 到 v 的結點是否是無窮,如果不是的話再判斷 u 到 v 的結點加上原來的 dis[u] 的權值是否小於 dis[v] 中記錄的權值,如果比這個小的話,更新 dis[v] 爲 u 到 v 的結點加上原來的 dis[u] 的權值

  • 循環重複地進行比較完成算法

對於結點 4 來說,dis 經歷瞭如下的變化:

  • 首先,默認情況下 dis = [5, 9999999, 12, 0]

  • 第一次循環後,結點1 完成查找,並在 v 的循環中發現了可以從結點1 到結點2 和結點3 而且比原來的值都要小 ,於是 dis = [5, 7, 11, 0]

  • 第二次循環後,結點2 完成查找,這次循環發現從結點2 到結點3 的距離更短,於是 dis = [5, 7, 10, 0]

  • 第三次循環後,結點3 完成查找,沒有發現更短的路徑,dis = [5, 7, 10, 0]

看明白了嗎?不明白的話自己試試吧,不管是斷點還是在中間輸出一下 dis 和 book ,都能夠幫助我們更好地理解這個算法的每一步是如何執行的。從代碼中就可以看出來,這個 Dijkstra 算法的時間複雜度是 O(N2) ,這可比 Floyd 快了不少了吧。

總結

關於圖的兩種最典型的應用及算法就到這裏結束了。當然,圖的內容可遠不止這些,比較典型的還是進度網絡圖等的算法,特別是做一些項目管理類的系統時會非常有用。當然,更高深的內容就要去研究《圖論》了。這個可就遠超我的水平了,希望有更多數學相關基礎的同學能夠繼續深入研究。而我嘛,先去惡補下數學吧!!

測試代碼:

https://github.com/zhangyue0503/Data-structure-and-algorithm/blob/master/5.圖/source/5.5圖的應用:最短路徑.php

參考文檔:

《數據結構》第二版,嚴蔚敏

《數據結構》第二版,陳越

《數據結構高分筆記》2020版,天勤考研

《啊哈!算法》

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章