在上一篇文章中,我們學習完了圖的相關的存儲結構,也就是 鄰接矩陣 和 鄰接表 。它們分別就代表了最典型的 順序存儲 和 鏈式存儲 兩種類型。既然數據結構有了,那么我們接下來當然就是學習對這些數據結構的操作啦,也就是算法的部分。不管是圖還是樹,遍歷都是很重要的部分,今天我們就先來學習最基礎的兩種圖的遍歷方式。
樹的遍歷演化到圖的遍歷
還記得在樹的學習中,我們講到過先序、中序、后序以及層序遍歷這幾種遍歷形式嗎?其實先序、中序和后序可以看作是一種遍歷方式,它們都是使用棧結構來進行遍歷,特點就是先一條路走到黑,然后再返回來走沒有過的路。而層序遍歷則是使用隊列一層一層地進行遍歷,特點就是先遍歷完子結點,然后再挨個遍歷每個子結點的下一層結點。
復習完了樹的遍歷方式再學習圖的遍歷方式就會非常簡單了,因為在圖的遍歷中,最基礎的也是基於棧和隊列的兩種遍歷形式。只不過它們的名字略有不同,基於棧的遍歷方式叫作 深度優先遍歷 ,而基於隊列的遍歷方式叫作 廣度優先遍歷 。其實也就是對應着樹中的先、中、后序遍歷和層序遍歷,本質上沒有什么太大的區別。
深度優先遍歷
我們依然還是從棧的遍歷方式入手,也就是圖中的 深度優先遍歷 這種形式。對於棧來說,不斷地將新的結點壓棧,直到發現它沒有其它的子結點后再原路返回,當發現某個結點有其它的結點時再進入子結點壓棧,這就是深度遍歷的思想。
在這里,需要注意的是我們要記錄一下已經訪問過的結點,當出現多個結點都有連接到某一個結點的路徑時,保證這個結點只訪問過一次。這是和樹結構的最大不同,因為樹是一路向下的,平級結點之間沒有聯系,一個結點只有一個上級結點。而圖則是任意一個結點都可以和其它任意的結點有關系。
鄰接矩陣
首先,我們看一下鄰接矩陣的深度優先遍歷算法的實現。現在看不懂沒關系,往下拉去看下圖解,然后結合着一起看。當然,更好的方案是自己運行起來。
$visited = []; // 已訪問結點
function DFS_AM($graphArr, $x)
{
global $visited;
echo "節點:{$x}", PHP_EOL;
$visited[$x] = true; // 當前結點標記為已訪問
// y 就是鄰接矩陣中的橫行
for ($y = 1; $y <= count($graphArr); $y++) {
// 循環判斷第 [x][y] 個數據是否為 1,也就是是否有邊
// 並且這個結點沒有被訪問過
if ($graphArr[$x][$y] != 0 && !$visited[$y]) {
// 進行棧遞歸,傳遞的參數是當前這個結點
DFS_AM($graphArr, $y);
}
}
}
BuildGraph($graphArr); // 建立鄰接矩陣圖
echo '請輸入開始遍歷的結點(1-結點數):';
fscanf(STDIN, "%d", $startNode); // 輸入從第幾個結點開始訪問
DFS_AM($graphArr, $startNode); // 開始深度優先遍歷
代碼量不多吧,使用的就是上篇文章中建立鄰接矩陣的代碼,如果已經忘了就回去看看或者直接從文章最下面的鏈接去看源代碼吧。
接下來我們進行測試:
# php 5.3圖的遍歷:深度優先與廣度優先.php
輸入結點數:4
請輸入邊數:3
請輸入邊,格式為 出 入 權:1 2 1
請輸入邊,格式為 出 入 權:1 3 1
請輸入邊,格式為 出 入 權:3 4 1
請輸入開始遍歷的結點(1-結點數):3
節點:3
節點:1
節點:2
節點:4
鄰接表
當然,鄰接表的遍歷思想也是相同的,只是中間的循環算法使用的是鏈式特點的方式。
$visited = []; // 已訪問結點
function DFS_AL($graph, $x){
global $visited;
$p = $graph->adjList[$x]; // 指定結點的第一個邊結點
echo "節點:{$x}", PHP_EOL; // 輸出指定結點的信息
$visited[$x] = true; // 設置該結點已被訪問
while($p != null){
$y = $p->adjVex; // 獲得邊結點信息
if(!$visited[$y]){ // 如果結點沒有被訪問過
DFS_AL($graph, $y); // 進入這個邊結點的深度遍歷過程
}
$p = $p->nextArc; // 移動到下一個邊結點
}
}
$graph = BuildLinkGraph();
$graphBFS = $graph;
echo '請輸入開始遍歷的結點(1-結點數):';
fscanf(STDIN, "%d", $startNode); // 輸入從第幾個結點開始訪問
DFS_AL($graph, $startNode);// 開始深度優先遍歷
是不是也很簡單,接下來也是簡單地測試一下:
# php 5.3圖的遍歷:深度優先與廣度優先.php
請輸入 結點數 邊數:
4 3
請輸入邊,格式為 出 入 權:1 2 1
請輸入邊,格式為 出 入 權:1 3 1
請輸入邊,格式為 出 入 權:3 4 1
請輸入開始遍歷的結點(1-結點數):3
節點:3
節點:4
節點:1
節點:2
輸出的順序怎么和鄰接矩陣的不太一樣?我們在上篇文章中實現的鄰接表使用的是頭插法,后輸入的數據添加在結點鏈接的前面,如果我們將 3 4 1 放在第一個輸入的話,那么結點就和鄰接矩陣的遍歷結果一樣了。
深度優先遍歷圖示
直接就上來看了代碼,又講了半天算法,是不是還是一頭霧水?沒關系,我們直接上圖看看:
左邊是鄰接矩陣的,右邊是鄰接表的。我們測試的圖比較簡單,4 個結點 3 條邊,下面是復雜一些有 6 個結點 5 條邊的圖。大家可以自己測試一下。每一步的遍歷和執行順序看小黑圓的數字。下面我們以鄰接矩陣的第一張圖來簡單地講解下訪問的步驟:
-
首先我們輸入從 結點3 開始訪問,然后開始深度遍歷,這時輸出 結點3
-
第一步 結點3 的循環中獲得它和 結點1 有邊,於是遞歸傳入 結點1 ,結點1 入棧
-
輸出 結點1,目前的遞歸中 結點1 在棧頂
-
在 結點1 的循環中發現 結點1 和 結點 2 有邊,於是遞歸傳入 結點2 ,結點2 入棧
-
輸出 結點2,目前的遞歸中 結點2 在棧頂
-
注意了,重點在這里,結點2 的循環中沒有發現與其它未訪問的結點有邊存在了,於是遞歸開始返回,也就是開始出棧了,依次返回到 結點1 、結點3,沒有任何輸出,棧空了,遞歸回到最外層的函數了
-
繼續 結點3 的循環,發現與 結點4 有邊,遞歸傳入 結點4
-
輸出 結點4,目前的遞歸中 結點4 在棧頂
-
結點4 的循環中沒有發現其它未訪問的結點及邊了,遞歸返回,結點4 出棧
-
結點3 循環完成,遍歷結束
一步一步的很清晰吧,大家試着自己分析一下下面那個復雜一些圖的深度遍歷順序,看看和我們程序輸出的結果是不是一樣的。在很多的考研或者數據結構考試中,經常會有選擇題或應用題讓你手動地來寫出深度優先遍歷的順序哦!
廣度優先遍歷
接下來就是廣度優先遍歷了,其實說白了就是我們在學習樹的遍歷時候的層序遍歷。前面我們說過,深度遍歷是一條路走到黑,沒路了退回來。而廣度遍歷則是一層一層的查看,直到找到出口。
鄰接矩陣
使用鄰接矩陣來進行廣度優先遍歷的操作,其實最核心的遍歷方式和深度遍歷沒什么區別,都是對某一個結點進行邊的查找,唯一不同的就是把遞歸棧換成了隊列而已。
$visited = [];
function BFS_AM($graphArr, $x){
global $visited;
$queue = InitSqQueue(); // 初始化隊列
$graphCount = count($graphArr); // 獲取矩陣結點數量
$visited[$x] = true; // 結點標記為已訪問
EnSqQueue($queue, $x); // 結點入隊
while($x){ // 循環判斷結點是否 fasle
// 遍歷所有結點是否與這個結點有邊
for ($y = 1; $y <= $graphCount; $y++) {
// 如果有邊並且未訪問過
if ($graphArr[$x][$y] != 0 && !$visited[$y]) {
$visited[$y] = true; // 結點標記為已訪問
EnSqQueue($queue, $y); // 結點入隊
}
}
$x = DeSqQueue($queue); // 出隊一個結點
echo $x, PHP_EOL; // 輸出結點
}
}
echo '請輸入開始廣度遍歷的結點(1-結點數):';
fscanf(STDIN, "%d", $startNode);
BFS_AM($graphArr, $startNode); // 開始廣度遍歷
代碼中的注釋也很清晰明了了,我們直接進行測試:
……
……
請輸入開始廣度遍歷的結點(1-結點數):3
3
1
4
2
鄰接表
同樣地,我們也提供鄰接表的鏈式廣度優先遍歷的核心函數。
$visited = [];
function BFS_AL($graph, $x){
global $visited;
$visited[$x] = true; // 結點標記為已訪問
$queue = InitSqQueue();// 初始化隊列
EnSqQueue($queue, $x); // 結點入隊
// 如果一直有能出隊的結點,就一直循環
// 也就是說,如果隊列空了,循環就結束
while(($i = DeSqQueue($queue))!==false){
echo $i, PHP_EOL; // 輸出結點
$p = $graph->adjList[$i]; // 結點的第一個邊結點
while($p != null){ // 如果不為空
$y = $p->adjVex; // 邊結點信息
if(!$visited[$y]){ // 如果沒有訪問過
$visited[$y] = true; // 標記結點為已訪問
EnSqQueue($queue, $y); // 入隊結點
}
$p = $p->nextArc; // 結點指針指向下一個
}
}
}
echo '請輸入開始遍歷的結點(1-結點數):';
fscanf(STDIN, "%d", $startNode);
BFS_AL($graph, $startNode); // 開始廣度遍歷
核心的循環中的操作其實也和深度遍歷沒什么太大的區別,同樣是換成了隊列這種存儲形式而已。
……
……
請輸入開始廣度遍歷的結點(1-結點數):3
3
4
1
2
廣度優先遍歷圖示
好吧,上面又羅列完了算法,接下來就是圖示的時間了,相信還是一看圖大家就馬上能明白廣度優先遍歷的具體步驟了。
和上面的圖一樣,同樣還是左邊是鄰接矩陣,右邊是鄰接表。在這里,我們依然還是直接分步驟來看一下左邊最上面圖的遍歷操作順序:
-
輸入 結點3 開始廣度遍歷,結點標記為已訪問,這時 結點3 入隊
-
使用 while 循環判斷 結點x 是否為 null ,如果不為 null 進入循環體
-
遍歷所有結點是否與這個結點有邊,如果有邊,並且這個結點沒有被訪問過,標記已訪問,加入隊列
-
出隊一個元素,並賦值給 x
-
輸出 x 結點的信息
廣度優先遍歷沒有棧的回溯,就是一條線性的隊列走完就完了,所以圖示會非常清晰。單獨一個結點我們會將和它相關的所有結點入隊,然后出隊最頂上的結點,這樣就能夠像樹的層序遍歷一樣按照一層一層的順序來遍歷整個圖。同樣地,拿起紙筆,找復雜一點的圖,試試能不能手寫出類似於廣度優先遍歷順序的題目吧!
總結
大家學完了之后是不是發現今天介紹的深度優先和廣度優先兩種遍歷方式真的和樹的遍歷方式沒什么太大的區別。最大的不同就是我們要標記已訪問過的結點而已。不管怎么說,使用棧和隊列來對樹或圖進行遍歷是所有樹和圖的操作算法中最最基礎的部分,也是考試和面試中最常見的問題,大家一定要深入理解掌握哦!
測試代碼:
參考文檔:
《數據結構》第二版,嚴蔚敏
《數據結構》第二版,陳越
《數據結構高分筆記》2020版,天勤考研
關注公眾號:【硬核項目經理】獲取最新文章
添加微信/QQ好友:【xiaoyuezigonggong/149844827】免費得PHP、項目管理學習資料
知乎、公眾號、抖音、頭條搜索【硬核項目經理】
B站ID:482780532