讀完本文,你不僅學會了演算法套路,還可以順便去 LeetCode 上拿下如下題目:
-----------
經常有讀者問我「圖」這種資料結構,其實我在 學習資料結構和演算法的框架思維 中說過,雖然圖可以玩出更多的演算法,解決更復雜的問題,但本質上圖可以認為是多叉樹的延伸。
面試筆試很少出現圖相關的問題,就算有,大多也是簡單的遍歷問題,基本上可以完全照搬多叉樹的遍歷。
那麼,本文依然秉持我們號的風格,只講「圖」最實用的,離我們最近的部分,讓你心裡對圖有個直觀的認識,文末我給出了其他經典圖論演算法,理解本文後應該都可以拿下的。
圖的邏輯結構和具體實現
一幅圖是由節點和邊構成的,邏輯結構如下:
什麼叫「邏輯結構」?就是說為了方便研究,我們把圖抽象成這個樣子。
根據這個邏輯結構,我們可以認為每個節點的實現如下:
/* 圖節點的邏輯結構 */
class Vertex {
int id;
Vertex[] neighbors;
}
看到這個實現,你有沒有很熟悉?它和我們之前說的多叉樹節點幾乎完全一樣:
/* 基本的 N 叉樹節點 */
class TreeNode {
int val;
TreeNode[] children;
}
所以說,圖真的沒啥高深的,就是高階點的多叉樹而已。
不過呢,上面的這種實現是「邏輯上的」,實際上我們很少用這個 Vertex
類實現圖,而是用常說的鄰接表和鄰接矩陣來實現。
比如還是剛才那幅圖:
用鄰接表和鄰接矩陣的儲存方式如下:
鄰接表很直觀,我把每個節點 x
的鄰居都存到一個列表裡,然後把 x
和這個列表關聯起來,這樣就可以通過一個節點 x
找到它的所有相鄰節點。
鄰接矩陣則是一個二維布林陣列,我們權且稱為 matrix
,如果節點 x
和 y
是相連的,那麼就把 matrix[x][y]
設為 true
(上圖中綠色的方格代表 true
)。如果想找節點 x
的鄰居,去掃一圈 matrix[x][..]
就行了。
如果用程式碼的形式來表現,鄰接表和鄰接矩陣大概長這樣:
// 鄰接矩陣
// graph[x] 儲存 x 的所有鄰居節點
List<Integer>[] graph;
// 鄰接矩陣
// matrix[x][y] 記錄 x 是否有一條指向 y 的邊
boolean[][] matrix;
那麼,為什麼有這兩種儲存圖的方式呢?肯定是因為他們各有優劣。
對於鄰接表,好處是佔用的空間少。
你看鄰接矩陣裡面空著那麼多位置,肯定需要更多的儲存空間。
但是,鄰接表無法快速判斷兩個節點是否相鄰。
比如說我想判斷節點 1
是否和節點 3
相鄰,我要去鄰接表裡 1
對應的鄰居列表裡查詢 3
是否存在。但對於鄰接矩陣就簡單了,只要看看 matrix[1][3]
就知道了,效率高。
所以說,使用哪一種方式實現圖,要看具體情況。
好了,對於「圖」這種資料結構,能看懂上面這些就綽綽夠用了。
那你可能會問,我們這個圖的模型僅僅是「有向無權圖」,不是還有什麼加權圖,無向圖,等等……
其實,這些更復雜的模型都是基於這個最簡單的圖衍生出來的。
有向加權圖怎麼實現?很簡單呀:
如果是鄰接表,我們不僅僅儲存某個節點 x
的所有鄰居節點,還儲存 x
到每個鄰居的權重,不就實現加權有向圖了嗎?
如果是鄰接矩陣,matrix[x][y]
不再是布林值,而是一個 int 值,0 表示沒有連線,其他值表示權重,不就變成加權有向圖了嗎?
如果用程式碼的形式來表現,大概長這樣:
// 鄰接矩陣
// graph[x] 儲存 x 的所有鄰居節點以及對應的權重
List<int[]>[] graph;
// 鄰接矩陣
// matrix[x][y] 記錄 x 指向 y 的邊的權重,0 表示不相鄰
int[][] matrix;
無向圖怎麼實現?也很簡單,所謂的「無向」,是不是等同於「雙向」?
如果連線無向圖中的節點 x
和 y
,把 matrix[x][y]
和 matrix[y][x]
都變成 true
不就行了;鄰接表也是類似的操作,在 x
的鄰居列表裡新增 y
,同時在 y
的鄰居列表裡新增 x
。
把上面的技巧合起來,就變成了無向加權圖……
好了,關於圖的基本介紹就到這裡,現在不管來什麼亂七八糟的圖,你心裡應該都有底了。
下面來看看所有資料結構都逃不過的問題:遍歷。
圖的遍歷
學習資料結構和演算法的框架思維 說過,各種資料結構被發明出來無非就是為了遍歷和訪問,所以「遍歷」是所有資料結構的基礎。
圖怎麼遍歷?還是那句話,參考多叉樹,多叉樹的遍歷框架如下:
/* 多叉樹遍歷框架 */
void traverse(TreeNode root) {
if (root == null) return;
for (TreeNode child : root.children) {
traverse(child);
}
}
圖和多叉樹最大的區別是,圖是可能包含環的,你從圖的某一個節點開始遍歷,有可能走了一圈又回到這個節點。
所以,如果圖包含環,遍歷框架就要一個 visited
陣列進行輔助:
// 記錄被遍歷過的節點
boolean[] visited;
// 記錄從起點到當前節點的路徑
boolean[] onPath;
/* 圖遍歷框架 */
void traverse(Graph graph, int s) {
if (visited[s]) return;
// 經過節點 s,標記為已遍歷
visited[s] = true;
// 做選擇:標記節點 s 在路徑上
onPath[s] = true;
for (int neighbor : graph.neighbors(s)) {
traverse(graph, neighbor);
}
// 撤銷選擇:節點 s 離開路徑
onPath[s] = false;
}
注意 visited
陣列和 onPath
陣列的區別,因為二叉樹算是特殊的圖,所以用遍歷二叉樹的過程來理解下這兩個陣列的區別:
上述 GIF 描述了遞迴遍歷二叉樹的過程,在 visited
中被標記為 true 的節點用灰色表示,在 onPath
中被標記為 true 的節點用綠色表示,這下你可以理解它們二者的區別了吧。
如果讓你處理路徑相關的問題,這個 onPath
變數是肯定會被用到的,比如 拓撲排序 中就有運用。
另外,你應該注意到了,這個 onPath
陣列的操作很像 回溯演算法核心套路 中做「做選擇」和「撤銷選擇」,區別在於位置:回溯演算法的「做選擇」和「撤銷選擇」在 for 迴圈裡面,而對 onPath
陣列的操作在 for 迴圈外面。
在 for 迴圈裡面和外面唯一的區別就是對根節點的處理。
比如下面兩種多叉樹的遍歷:
void traverse(TreeNode root) {
if (root == null) return;
System.out.println("enter: " + root.val);
for (TreeNode child : root.children) {
traverse(child);
}
System.out.println("leave: " + root.val);
}
void traverse(TreeNode root) {
if (root == null) return;
for (TreeNode child : root.children) {
System.out.println("enter: " + child.val);
traverse(child);
System.out.println("leave: " + child.val);
}
}
前者會正確列印所有節點的進入和離開資訊,而後者唯獨會少列印整棵樹根節點的進入和離開資訊。
為什麼回溯演算法框架會用後者?因為回溯演算法關注的不是節點,而是樹枝,不信你看 回溯演算法核心套路 裡面的圖。
顯然,對於這裡「圖」的遍歷,我們應該把 onPath
的操作放到 for 迴圈外面,否則會漏掉記錄起始點的遍歷。
說了這麼多 onPath
陣列,再說下 visited
陣列,其目的很明顯了,由於圖可能含有環,visited
陣列就是防止遞迴重複遍歷同一個節點進入死迴圈的。
當然,如果題目告訴你圖中不含環,可以把 visited
陣列都省掉,基本就是多叉樹的遍歷。
題目實踐
下面我們來看力扣第 797 題「所有可能路徑」,函式簽名如下:
List<List<Integer>> allPathsSourceTarget(int[][] graph);
題目輸入一幅有向無環圖,這個圖包含 n
個節點,標號為 0, 1, 2,..., n - 1
,請你計算所有從節點 0
到節點 n - 1
的路徑。
輸入的這個 graph
其實就是「鄰接表」表示的一幅圖,graph[i]
儲存這節點 i
的所有鄰居節點。
比如輸入 graph = [[1,2],[3],[3],[]]
,就代表下面這幅圖:
演算法應該返回 [[0,1,3],[0,2,3]]
,即 0
到 3
的所有路徑。
解法很簡單,以 0
為起點遍歷圖,同時記錄遍歷過的路徑,當遍歷到終點時將路徑記錄下來即可。
既然輸入的圖是無環的,我們就不需要 visited
陣列輔助了,直接套用圖的遍歷框架:
// 記錄所有路徑
List<List<Integer>> res = new LinkedList<>();
public List<List<Integer>> allPathsSourceTarget(int[][] graph) {
// 維護遞迴過程中經過的路徑
LinkedList<Integer> path = new LinkedList<>();
traverse(graph, 0, path);
return res;
}
/* 圖的遍歷框架 */
void traverse(int[][] graph, int s, LinkedList<Integer> path) {
// 新增節點 s 到路徑
path.addLast(s);
int n = graph.length;
if (s == n - 1) {
// 到達終點
res.add(new LinkedList<>(path));
path.removeLast();
return;
}
// 遞迴每個相鄰節點
for (int v : graph[s]) {
traverse(graph, v, path);
}
// 從路徑移出節點 s
path.removeLast();
}
這道題就這樣解決了,注意 Java 的語言特性,向 res
中新增 path
時需要拷貝一個新的列表,否則最終 res
中的列表都是空的。
最後總結一下,圖的儲存方式主要有鄰接表和鄰接矩陣,無論什麼花裡胡哨的圖,都可以用這兩種方式儲存。
在筆試中,最常考的演算法是圖的遍歷,和多叉樹的遍歷框架是非常類似的。
當然,圖還會有很多其他的有趣演算法,比如 二分圖判定,環檢測和拓撲排序(編譯器迴圈引用檢測就是類似的演算法),最小生成樹,Dijkstra 最短路徑演算法 等等,有興趣的讀者可以去看看,本文就到這了。
_____________
檢視更多優質演算法文章 點選我的頭像,手把手帶你刷力扣,致力於把演算法講清楚!我的 演算法教程 已經獲得 90k star,歡迎點贊!