【PHP數據結構】圖的存儲結構

圖的概念介紹得差不多了,大家可以消化消化再繼續學習後面的內容。如果沒有什麼問題的話,我們就繼續學習接下來的內容。當然,這還不是最麻煩的地方,因爲今天我們只是介紹圖的存儲結構而已。

圖的順序存儲結構:鄰接矩陣

什麼是鄰接矩陣

首先還是來看看如何用順序結構來存儲圖。不管是棧、隊列、樹,我們都可以使用一個簡單的數組就可以實現這些數據結構的順序存儲能力。但是圖就不一樣了,從上篇文章中,我們學到過,一個結點的表示是 <x, y> 這種形式。如果我們把這個結點相像是一個座標軸上的點,那麼我們是不是就可以用一個二維數組來表示它呢?沒錯,讓二維數組的第一維表示爲 x 軸,第二維表示爲 y 軸,這樣我們就可以構建出一張圖來了。沒錯,二維數組這種形式還有一個別名就叫做:矩陣。

在圖的術語中,使用二維數組來表示的圖的順序存儲結構就叫做鄰接矩陣。就像下面這個表格一樣。

在這個表格中,我們有橫豎兩個座標,X1-4 和 Y1-4 表示這個圖中一共有 4 個結點,通過它們的對應關係就可以看做是一個結點與另一個結點之間是否有邊。比如說 X1 和 Y2 這一對座標 <X1, Y2> ,它們的值是 1 ,這就說明 結點1 到 結點2 之間有一條邊。在這裏,我們使用的是無權圖,也就是用 0 表示沒有邊,用 1 表示兩個結點之間有邊。同時,它還是一張無向圖,所以 <Y2, X1> 的值也是 1 ,它的意圖是從 結點2 到 結點1 之間也有一條邊。如果是有向圖,那麼就要根據有向箭頭的指向來確定這條邊是否設置爲 1 。

上面的這個鄰接矩陣對應的圖是什麼樣子的呢?大家可以自己嘗試手動畫一畫。畫不出來也不要緊,因爲我們纔剛開始學嘛。其實它就是我們最開始展示的那張圖的鄰接矩陣。

左邊的圖就是對應的我們上面的那個表格中的鄰接矩陣。那麼右邊那個有向圖的鄰接矩陣是什麼樣子的呢?我們也寫寫試試。

有意思吧?那麼如果是有權圖呢?其實很簡單的我們將圖中的 1 直接換成對應邊的權值就可以了,不過有可能有的邊的權值就是 0 ,所以在有權圖中,我們可以定義一個非常大的數,或者定義一個非常小的負數當做 無限數 來表示這兩個結點沒有邊。

構造鄰接矩陣

接下來,我們就通過代碼來構造這樣一個鄰接矩陣的存儲結構。我們還是用無向圖的例子來實現。因爲無向圖是需要反向的結點也賦值的,所以它比有向圖多了一個步驟,其它的基本上都是相似的。

// 鄰接矩陣
$graphArr = [];
function CreateGraph($Nv, &$graphArr)
{
    $graphArr = [];
    for ($i = 1; $i <= $Nv; $i++) {
        for ($j = 1; $j <= $Nv; $j++) {
            $graphArr[$i][$j] = 0;
        }
    }
}

// 鄰接矩陣
function BuildGraph(&$graphArr)
{
    echo '請輸入結點數:';
    fscanf(STDIN, "%d", $Nv);
    CreateGraph($Nv, $graphArr);

    if ($graphArr) {
        echo '請輸入邊數:';
        fscanf(STDIN, "%d", $Ne);
        if ($Ne > 0) {
            for ($i = 1; $i <= $Ne; $i++) {
                echo '請輸入邊,格式爲 出 入 權:';
                fscanf(STDIN, "%d %d %d", $v1, $v2, $weight);
                $graphArr[$v1][$v2] = $weight;
                // 如果是無向圖,還需要插入逆向的邊
                $graphArr[$v2][$v1] = $weight;
            }
        }
    }
}

在這段代碼中,首先我們通過 CreateGraph() 方法來初始化一個二維矩陣。也就是根據我們輸入的結點數量,實現一個 X * Y 的二維數組結構,並且定義它的所有值都是 0 ,也就是說,這個圖目前還沒有邊。

然後,在 BuildGraph() 方法調用完 CreateGraph() 之後,我們繼續輸入邊的信息。先輸入邊的數量,我們有幾條邊,如果邊小於等於 0 的話就不要繼續創建了。其實還可以嚴謹一點根據 無向完全圖和有向完全圖 的定義來讓邊不能超過最大的限度。

接下來,我們就循環繼續輸入邊的信息,這裏我需要的輸入格式是邊的 出結點 、入結點 、權值。由於我們的示例是無向圖,所以我們除了要爲 <x, y> 創建邊之外,也要爲 <y, x> 創建邊。代碼的註釋中已經說明了。

解釋代碼可能還是比較抽象。直接運行一下試試吧。

BuildGraph($graphArr);
// 請輸入結點數:4
// 請輸入邊數:4
// 請輸入邊,格式爲 出 入 權:1 2 1
// 請輸入邊,格式爲 出 入 權:1 3 1
// 請輸入邊,格式爲 出 入 權:1 4 1
// 請輸入邊,格式爲 出 入 權:3 4 1

print_r($graphArr);
// Array
// (
//     [1] => Array
//         (
//             [1] => 0
//             [2] => 1
//             [3] => 1
//             [4] => 1
//         )

//     [2] => Array
//         (
//             [1] => 1
//             [2] => 0
//             [3] => 0
//             [4] => 0
//         )

//     [3] => Array
//         (
//             [1] => 1
//             [2] => 0
//             [3] => 0
//             [4] => 1
//         )

//     [4] => Array
//         (
//             [1] => 1
//             [2] => 0
//             [3] => 1
//             [4] => 0
//         )

// )
//  x
//y 0 1 1 1
//  1 0 0 0
//  1 0 0 1
//  1 0 1 0

在命令行環境中調用我們的 PHP 文件,然後根據提示的內容依次輸入相關的信息。最後打印出來的數組內容是不是就和我們上面的表格中一模一樣了。簡簡單單的一段代碼,我們就實現了圖的順序存儲。

可能有的同學會一時懵圈。因爲我第一眼看到的時候也是完全懵了,不過仔細的對比畫出來的圖和上面的表格其實馬上就能想明白了。這次我們真的是進入二維的世界了。是不是感覺圖瞬間就把樹甩到十萬八千里之外了。完全二叉樹的時候,我們的思想是二維的,但結構還是一維的,而到鄰接矩陣的時候,不管是思想還是代碼結構,全部都進化到了二維空間,高大上真不是吹的。

圖的鏈式存儲結構:鄰接表

說完順序存儲結構,自然不能忽視另一種形式的存儲結構,那就是圖的鏈式存儲結構。其實對於圖來說,鏈式結構非常簡單和清晰,因爲我們只需要知道一個結點和那些結點有邊就行了。那麼我們就讓這個結點形成一個單鏈表,一路往後鏈接就好了,就像下圖這樣。(同樣以上圖無向圖爲例)

從 結點1 開始,它指向一個後繼是 結點2 ,然後繼續向後鏈接 結點3 和 結點4 。這樣,與 結點1 相關的邊就都描述完成了。由於我們展示的依然是無向圖的鄰接表表示,所以 結點2 的鏈表結點指向了 結點 1 。也就是完成了 <y, x> 的反向指向。

對於代碼實現來說,我們可以將頭結點,也就是正式的 1-4 結點保存在一個順序表中。然後讓每個數組元素的值爲第一個結點的內容。這樣,我們就可以讓鏈表結點只保存結點名稱、權重和下一個結點對象的指向信息就可以了。

// 頭結點
class AdjList
{
    public $adjList = []; // 頂點列表
    public $Nv = 0; // 結點數
    public $Ne = 0; // 邊數
}
// 邊結點
class ArcNode
{
    public $adjVex = 0; // 結點
    public $nextArc = null; // 鏈接指向
    public $weight = 0; // 權重
}

接下來,我們來看看如何使用鄰接表這種結構來建立圖。

function BuildLinkGraph()
{
    fscanf(STDIN, "請輸入 結點數 邊數:%d %d", $Nv, $Ne);
    if ($Nv > 1) {
        // 初始化頭結點
        $adj = new AdjList();
        $adj->Nv = $Nv; // 保存下來方便使用
        $adj->Ne = $Ne; // 保存下來方便使用
        // 頭結點列表
        for ($i = 1; $i <= $Nv; $i++) {
            $adj->adjList[$i] = null; // 全部置爲 NULL ,一個無邊空圖
        }
        
        if ($Ne > 0) {
//
            for ($i = 1; $i <= $Ne; $i++) {
                echo '請輸入邊,格式爲 出 入 權:';
                fscanf(STDIN, "%d %d %d", $v1, $v2, $weight);
                // 建立一個結點
                $p1 = new ArcNode;
                $p1->adjVex = $v2; // 結點名稱爲 入結點
                $p1->nextArc = $adj->adjList[$v1]; // 下一跳指向 出結點 的頭結點
                $p1->weight = $weight; // 設置權重
                $adj->adjList[$v1] = $p1; // 讓頭結點的值等於當前新創建的這個結點

                // 無向圖需要下面的操作,也就是反向的鏈表也要建立
                $p2 = new ArcNode;

                // 注意下面兩行與上面代碼的區別
                $p2->adjVex = $v1; // 這裏是入結點
                $p2->nextArc = $adj->adjList[$v2]; // 這裏是出結點
                
                $p2->weight = $weight;
                $adj->adjList[$v2] = $p2;
            }

            return $adj;
        }

    }

    return null;
}

代碼中的註釋已經寫得很清楚了。可以看出,在鄰接表的操作中,無向圖也是一樣的比有向圖多一步操作的,如果只是建立有向圖的話,可以不需要 p2 結點的操作。特別需要注意的就是,在這段代碼中,我們使用的是鏈表操作中的 頭插法 。也就是最後一條數據會插入到 頭結點 上,而最早的那個邊會在鏈表的最後。大家看一下最後建立完成的數據結構的輸出就明白了。

print_r(BuildLinkGraph());
// AdjList Object
// (
//     [adjList] => Array
//         (
//             [1] => ArcNode Object
//                 (
//                     [adjVex] => 4
//                     [nextArc] => ArcNode Object
//                         (
//                             [adjVex] => 3
//                             [nextArc] => ArcNode Object
//                                 (
//                                     [adjVex] => 2
//                                     [nextArc] => 
//                                     [weight] => 1
//                                 )

//                             [weight] => 1
//                         )

//                     [weight] => 1
//                 )

//             [2] => ArcNode Object
//                 (
//                     [adjVex] => 1
//                     [nextArc] => 
//                     [weight] => 1
//                 )

//             [3] => ArcNode Object
//                 (
//                     [adjVex] => 4
//                     [nextArc] => ArcNode Object
//                         (
//                             [adjVex] => 1
//                             [nextArc] => 
//                             [weight] => 1
//                         )

//                     [weight] => 1
//                 )

//             [4] => ArcNode Object
//                 (
//                     [adjVex] => 3
//                     [nextArc] => ArcNode Object
//                         (
//                             [adjVex] => 1
//                             [nextArc] => 
//                             [weight] => 1
//                         )

//                     [weight] => 1
//                 )

//         )

//     [Nv] => 4
//     [Ne] => 4
// )

使用鄰接表來建立的圖的鏈式存儲結構是不是反而比鄰接矩陣更加的清晰明瞭一些。就像樹的鏈式和順序結構一樣,在圖中它們的優缺點也是類似的。鄰接矩陣佔用的物理空間更多,因爲它需要兩層一樣多元素的數組,就像上面的表格一樣,需要佔據 4 * 4 的物理格子。而鄰接表我們可以直接數它的結點數,只需要 12 個格子就完成了。而且,更主要的是,鏈式的鄰接表可以隨時擴展邊結點和邊數,不需要重新地初始化,我們只需要簡單地修改上面的測試代碼就能夠實現,而鄰接矩陣如果要修改結點數的話,就得要重新初始化整個二維數組了。

總結

對於圖來說,除了鄰接矩陣和鄰接表之外,還有其它的一些存儲形式,不過都是鏈式的鄰接表的一些優化和變形而已。大家有興趣的可以自己去了解一下 十字鏈表 、鄰接多重表 這兩種存儲結構。

好了,基礎的存儲結構已經鋪墊完了,關於圖的概念也都熟悉掌握了,接下來,我們就要準備去做最重要的操作了,那就是如何來對圖進行遍歷。

測試代碼:

https://github.com/zhangyue0503/Data-structure-and-algorithm/blob/master/5.圖/source/5.2圖的存儲結構.php

參考資料:

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

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

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

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