數據結構與算法--圖的搜索(深度優先和廣度優先)
有時候我們需要系統地檢查每一個頂點或者每一條邊來獲取圖的各種性質,為此需要從圖的某個頂點出發,訪遍圖中其余頂點,且使得每一個頂點只被訪問一次,這個過程就稱為圖的搜索或者圖的遍歷。如果限制某個頂點只被訪問一次?我們可以建立一個布爾數組,在某個頂點第一次被訪問時,將該頂點在數組中對應的下標設置為true。圖的搜索通常由兩種方案——深度優先搜索和廣度優先搜索。
深度優先搜索
深度優先搜索(Depth First Search),簡稱DFS,該方法主要思想是:
- 從某一個頂點開始,選擇一條沒有到達過的頂點(布爾數組中對應的值為false)
- 標記剛選擇的頂點為“訪問過”(布爾數組中對應的值設置為true)
- 來到某個頂點,如果該頂點周圍的頂點都訪問過了,返回到上個頂點
- 當回退后的頂點依然是上述情況,繼續返回
這聽起來像是遞歸。沒錯,代碼確實是遞歸實現的,并且實現起來特別簡單。
package Chap7;
import java.util.Arrays;
import java.util.List;
public class DepthFirstSearch {
// 用來標記已經訪問過的頂點,保證每個頂點值訪問一次
private boolean[] marked;
// s為搜索的起點
public DepthFirstSearch(UndiGraph<?> graph, int s) {
marked = new boolean[graph.vertexNum()];
dfs(graph, s);
}
private void dfs(UndiGraph<?> graph, int v) {
// 將剛訪問到的頂點設置標志
marked[v] = true;
// 打印剛訪問的頂點,可換成其他操作
System.out.println(v);
// 從v的所有鄰接點中選擇一個沒有被訪問過的頂點
for (int w : graph.adj(v)) {
if (!marked[w]) {
dfs(graph, w);
}
}
}
public static void main(String[] args) {
List<String> vertexInfo = Arrays.asList("v0", "v2", "v3", "v4", "v5");
int[][] edges = {{0, 1}, {0, 2}, {0, 3},
{1, 3}, {1, 4},
{2, 4}};
UndiGraph<String> graph = new UndiGraph<>(vertexInfo, edges);
DepthFirstSearch search = new DepthFirstSearch(graph, 0);
}
}
從代碼中看出,深度優先搜索其實就兩步:
- 標記訪問過的頂點
- 遞歸地訪問當前頂點所有沒有被標記過的鄰居頂點。
在上面的實現中,我們對訪問的每個頂點執行了打印操作。打印只是告訴我們搜索的順序。不過我們很想知道從某個起點開始到另一個頂點的路徑。
為此我們用到了一個edgeTo[]
的整型數組,這個數組可以記住每個頂點到起點的路徑,而不是記錄當前頂點到起點的路徑。為了做到這一點在由邊v-w第一次訪問任意w時,將edgeTo[w]
設為v,表示v-w是起點s到w的路徑上的最后一條已知的邊。比如0-2-3-5,表示從0到5的路徑,那么edgeTo[5] = 3
。同理如果只是到3的路徑,那么edgeTo[3] =2
, 到2的路徑是edgeTo[2] = 0
。這樣,我們得到的edgeTo[]
其實是一棵根結點為起點的樹,而且數組里存的是下標的父結點。就像下圖一樣。
edgeTo[1]= 2
,而結點1的父結點就是結點2;edgeTo[2] = 0
,而頂點2的父結點就是結點0,這和樹的雙親表示法有點類似。不存在給edge[0]
(根結點)賦值的情況,因為此例中我們的起點是頂點0,所以edgeTo[0]
保持默認值0。從樹中可以看出,起點到頂點5的路徑是0-2-3-5。如果我們寫一個方法pathTo()
,若傳入5,只能先獲取到edgetTo[5]
,得到父結點為3,然后根據edgeTo[3]
得到父結點2...一直到獲取到根結點,可以看到獲取的順序是從葉子結點到根結點,但是真正輸出路徑的時候是從根結點到葉子結點,所以利用棧(Stack)可以實現這一過程。稍微想一下,先入棧的5被排在了底下,最后入棧的0排在了最頂上,確實是這樣的。
現在來實現。
package Chap7;
import java.util.Arrays;
import java.util.LinkedList;
import java.util.List;
public class DepthFirstSearch {
// 用來標記已經訪問過的頂點,保證每個頂點值訪問一次
private boolean[] marked;
// 起點
private final int s;
// 到該頂點的路徑上的最后一條邊
private int[] edgeTo;
public DepthFirstSearch(UndiGraph<?> graph, int s) {
this.s = s;
marked = new boolean[graph.vertexNum()];
edgeTo = new int[graph.vertexNum()];
dfs(graph, s);
}
private void dfs(UndiGraph<?> graph, int v) {
// 將剛訪問到的頂點設置標志
marked[v] = true;
// System.out.println(v);
// 從v的所有鄰接點中選擇一個沒有被訪問過的頂點
for (int w : graph.adj(v)) {
if (!marked[w]) {
edgeTo[w] = v;
dfs(graph, w);
}
}
}
// 連通圖的任意一個頂點都有某條路徑能到達任意一個頂點,如果v在這個連通圖中,必然存在起點到v的路徑
// 現在marked數組中的值都是true,所以數組中若有這個v(在這個連通圖中), 返回true就表示路徑存在
public boolean hasPathTo(int v) {
return marked[v];
}
public Iterable<Integer> pathTo(int v) {
if (hasPathTo(v)) {
LinkedList<Integer> path = new LinkedList<>();
for (int i = v; i != s; i = edgeTo[i]) {
path.push(i);
}
// 最后將根結點壓入
path.push(s);
return path;
}
// 到v不存在路徑,就返回null
return null;
}
public void printPathTo(int v) {
System.out.print(s+" to "+ v+": ");
if (hasPathTo(v)) {
for (int i : pathTo(v)) {
if (i == s) {
System.out.print(i);
} else {
System.out.print("-" + i);
}
}
System.out.println();
} else {
System.out.println("不存在路徑!");
}
}
public static void main(String[] args) {
List<String> vertexInfo = Arrays.asList("v0", "v1", "v2", "v3", "v4", "v5");
int[][] edges = {{3, 5},{0, 2}, {0, 1}, {0, 5},
{1, 2}, {3, 4}, {2, 3}, {2, 4}};
UndiGraph<String> graph = new UndiGraph<>(vertexInfo, edges);
DepthFirstSearch search = new DepthFirstSearch(graph, 0);
search.printPathTo(4);
}
}
/* Output
0 to 5: 0-2-3-5
*/
只是在深度優先搜索的實現中新加了一些東西,最重要的是在dfs
遞歸方法中插入了一行edgeTo[w] = v;
,我們知道w是v的一個鄰接點,那么這行的字面意思就是到w的頂點是v,即路徑v-w。上面提到過這是起點s到w的最后一條邊。
擴展的方法hasPathTo
用來判斷是否有到某頂點的路徑,由于這是連通圖,任意一個頂點(包括起點s)都有某條路徑能到達任意一個頂點,在初始化該類時,已經調用過深度優先搜索,所以marked數組里都是true,這意味著只要某個頂點能在marked數組中找到對應的下標,那么返回true,表示肯定存在到它的路徑。
我們的pathTo
用來確定一條從起點到指定頂點的路徑,注意這條路徑不一定是最短的,也可能并非是唯一路徑。必須先判斷是否存在到該指定頂點的路徑,如果不存在則返回null;若存在,則從查找的頂點開始入棧,i = edgeTo[i]
表示樹向上一層,更新當前值為結點i的父結點,直到根結點停止,由條件i != s
可知,根結點并沒有入棧,所以在循環之后要將根結點入棧。
printPathTo
就是將pathTo
返回的內容格式化輸出,就像這樣。表示頂點0到頂點5的路徑是0-2-3-5。
0 to 5: 0-2-3-5
可見這路徑并不是最短路徑,0-5直接可達才是最短的。
現在我們來看下深度優先搜索的詳細軌跡,注意對照著上圖的鄰接表:先是從起點0開始
- 因為2是0的鄰接表第一個元素且沒有被標記訪問,則遞歸調用它并標記。
edgeTo[2] = 0
表示0-2這條路徑。 - 現在頂點0是頂點2的鄰接表第一個元素,但是0已經被標記了,所以跳過它,看下一個。1沒有被標記,所以遞歸調用它,并標記。
edgeTo[1] = 2
,表示0-2-1這條路徑。 - 頂點1的鄰接表元素都被標記過了,所以不再遞歸,方法從
dfs(1)
中返回到上一個頂點2,現在檢查2的下一個鄰接頂點,3沒被標記所以遞歸它并標記,edgeTo[3] = 2
表示0-2-3這條路徑。 - 頂點5是3的鄰接表第一個元素沒被標記,遞歸調用它并標記,
edgeTo[5] = 3
表示0-2-3-5這條路徑。 - 頂點5的鄰接表元素都被標記過了,方法從
dfs (5)
中返回到帶上一個頂點3,檢查3的鄰接表下一個元素,4沒有被標記,所以遞歸它并標記,edgeTo[4] = 3
表示0-2-3-4。至此,所有頂點都被標記過。搜索算是完成了。
DFS的非遞歸版本
DFS也可以自己設一個棧模擬系統棧,下面是非遞歸版本。
/**
* 非遞歸實現DFS
*
* @param graph 圖
* @param s 起點
*/
public void dfs(UndiGraph<?> graph, int s) {
boolean[] marked = new boolean[graph.vertexNum()];
// 模擬系統棧
LinkedList<Integer> stack = new LinkedList<>();
// 起點先入棧
stack.push(s);
// 標記訪問
marked[s] = true;
System.out.print(s);
while (!stack.isEmpty()) {
// 取出剛訪問的頂點
int v = stack.peek();
for (int w : graph.adj(v)) {
if (!marked[w]) {
marked[w] = true;
System.out.print(w);
stack.push(w);
// 模擬DFS只存入一個就好,一定要break
break;
}
}
// 所有鄰接點都被訪問過了,模擬遞歸的返回
stack.pop();
}
}
廣度優先搜索
深度優先搜索得到的路徑上面已經看到,并不是最短路徑,很自然地我們對下面的問題感到興趣:單點最短路徑,即給定一個圖和一個起點s,是否存在到給定頂點v的一條路徑,如果有找出最短的那條。
解決這個問題方法是廣度優先搜索(Breadth First Search),簡稱BFS。
這個算法的思想大體是:
- 從起點開始,標記之并加入隊列。
- 起點出列,其所有未被標記的鄰接點在被標記后,入列。
- 隊列頭的元素出列,將該元素的所有未被標記的鄰接點標記后,入列。
如此反復,當隊列為空時,所有頂點也都被標記過了。不像DFS的遞歸那樣隱式地使用棧(系統管理的,以支持遞歸),BFS使用了隊列。
package Chap7;
import java.util.Arrays;
import java.util.LinkedList;
import java.util.List;
import java.util.Queue;
public class BreadthFirstSearch {
// 用來標記已經訪問過的頂點,保證每個頂點值訪問一次
private boolean[] marked;
// 起點
private final int s;
// 到該頂點的路徑上的最后一條邊
private int[] edgeTo;
public BreadthFirstSearch(UndiGraph<?> graph, int s) {
this.s = s;
marked = new boolean[graph.vertexNum()];
edgeTo = new int[graph.vertexNum()];
bfs(graph, s);
}
public void bfs(UndiGraph<?> graph, int s) {
marked[s] = true;
// offer入列, poll出列
Queue<Integer> queue = new LinkedList<>();
queue.offer(s);
while (!queue.isEmpty()) {
int v = queue.poll();
// System.out.print(v+" ");
for (int w: graph.adj(v)) {
if (!marked[w]) {
edgeTo[w] = v;
marked[w] = true;
queue.offer(w);
}
}
}
}
public boolean hasPathTo(int v) {
return marked[v];
}
public Iterable<Integer> pathTo(int v) {
if (hasPathTo(v)) {
LinkedList<Integer> path = new LinkedList<>();
for (int i = v; i != s; i = edgeTo[i]) {
path.push(i);
}
// 最后將根結點壓入
path.push(s);
return path;
}
// 到v不存在路徑,就返回null
return null;
}
public void printPathTo(int v) {
System.out.print(s+" to "+ v+": ");
if (hasPathTo(v)) {
for (int i : pathTo(v)) {
if (i == s) {
System.out.print(i);
} else {
System.out.print("-" + i);
}
}
System.out.println();
} else {
System.out.println("不存在路徑!");
}
}
public static void main(String[] args) {
List<String> vertexInfo = Arrays.asList("v0", "v1", "v2", "v3", "v4", "v5");
int[][] edges = {{3, 5},{0, 2}, {0, 1}, {0, 5},
{1, 2}, {3, 4}, {2, 3}, {2, 4}};
UndiGraph<String> graph = new UndiGraph<>(vertexInfo, edges);
BreadthFirstSearch search = new BreadthFirstSearch(graph, 0);
search.printPathTo(5);
}
}
/* Output
0 to 5: 0-5
*/
我把打印操作注釋了,實際上它會輸出如下內容
0 2 1 5 3 4
先是打印了起點,然后依次打印了0的所有鄰接點2、1、5,之后按照隊列的出列順序,打印2的所有未被標記的鄰接點,實際上這已經打印完了所有頂點。而且從代碼里也能看出,不像DFS那樣每次只標記一個頂點,BFS每次都標記了若干頂點。
上面的代碼中,除了bfs的實現代碼,其余有關path的方法可以直接使用DFS中的實現。還是來看下詳細的搜索軌跡。
- 首先頂點0入列
- 頂點0出列,將它所有鄰接點2、1、5(參考DFS中的鄰接表圖片),標記他們。且
edgeTo[2]、edgeTo[1]、edgeTo[5]
的值都設為0 - 頂點2出列,檢查它的鄰接點,0、1已經被標記,于是將3、4入列,并標記它們。
edgeTo[3]、edgeTo[4]
的值都設為2 - 頂點1出列,其鄰接點均已被標記
- 頂點5出列,其鄰接點均已被標記
- 頂點3出列,其鄰接點均已被標記
- 頂點4出列,其鄰接點均已被標記
可以發現,實際上標記工作和edgeTo數組在第三步之后就已經完成,之后的工作只是檢查出列的頂點的鄰接點是否被標記過而已。
我們不妨打印下起點到其余各個頂點的路徑
0 to 5: 0-5
0 to 4: 0-2-4
0 to 3: 0-2-3
0 to 2: 0-2
0 to 1: 0-1
不難發現,這些路徑都是最短路徑。實際上有這么一個命題:對于從s可達的任意頂點v,廣度優先搜索都能找到一條從s到v的最短路徑。
廣度優先搜索是先覆蓋起點附近的頂點,只在鄰近的所有頂點都被訪問后才向前進,其搜索路徑短而直接;而深度優先搜索是尋找離起點更遠的頂點,只有在碰到周圍的鄰接點都被訪問過了才往回退,選一個近處的頂點,繼續深入到更遠的地方,其路徑長而曲折。
以上深度優先和廣度優先的實現對于有向圖也是適用的,把接收的參數的換成有向圖即可。
by @sunhaiyu
2017.9.19