# BFS 與 DFS
BFS 與 DFS 本質上是走訪一張圖的兩個演算法。事實上,我們也可以把一些問題轉化成圖論問題來走訪,而 BFS 與 DFS 有許多性質很適合來解決這些問題
<center>
<img src="https://miro.medium.com/v2/resize:fit:1280/1*GT9oSo0agIeIj6nTg3jFEA.gif" style="margin: 0 auto; display: block; width: 400px"></br>
圖源 : <a href="https://medium.com/analytics-vidhya/a-quick-explanation-of-dfs-bfs-depth-first-search-breadth-first-search-b9ef4caf952c">
Sebastian De Lima - A quick explanation of DFS & BFS </a>
</center>
## 廣度優先搜尋 Breadth-first Search
### 簡述
廣度優先搜尋簡稱 BFS,走訪順序如以下圖示:
<center>
<img src="https://upload.wikimedia.org/wikipedia/commons/1/1b/Breadth-first_tree.svg" style="margin: 0 auto; display: block; width: 400px">
</br>
<a href="https://zh.wikipedia.org/zh-tw/%E5%B9%BF%E5%BA%A6%E4%BC%98%E5%85%88%E6%90%9C%E7%B4%A2">圖源: 維基百科</a>
</center>
### 性質
1. 輸入一張圖 $G$ 與一個起點 $s$
2. 每到一個新的節點,就優先拜訪他的所有鄰居
3. 如果是連通圖,就會把所有點都走過一遍,邊則最多走過兩遍
4. 如果是連通圖,走訪每個點 $v$ 時所走的路徑,就是 $s$ 到 $v$ 的最短路徑
5. 如果圖不是連通圖,則會有連通塊 (connect conponent) 未被走訪
### C++ 實作
- 圖可以用鄰接串列 (adjacency list) 來儲存,比較方便
- 根據性質 2 ,發現符合「先進先出」性質,可用一個佇列 (queue) 來維護走訪清單
- 根據性質 3,為了避免拜訪重複的點,要用一個布林陣列來記錄節點是否拜訪過
- 根據性質 4,可以用一個陣列 $dis[~]$ 來記錄最短路徑
- 最短路徑的迭代方式: 未走訪鄰居 $v$ 一定會比現在走訪的點 $u$ 距離多 $1$
即 $dis[v]=dis[u]+1$
```cpp
const int MAXN = 2e5 + 5; // 點的數量
vector<int> g[MAXN]; // 鄰接串列儲存圖
bool vis[MAXN]; // 紀錄每個節點是否走訪過(在全域宣告會自動變成 false)
int dis[MAXN]; // 儲存從起點 s 到每個點的最短距離
void bfs(int s) { // 起點
queue<int> q; // 這個存走訪名單
q.push(s); // s 第一個放入要走訪的名單
while (!q.empty()) {
int u = q.front(); // u 是現在走訪的節點
q.pop(); // 把 u 從走訪名單上踢掉
if(vis[u]) continue; // 走過就別再走
vis[u] = true; // 這樣子 u 就算是走訪過囉
for (int v : g[u]) { // 把 u 的每個鄰居 v 都放進名單中
if (vis[v] == false) { // 如果沒有走訪過這個鄰居
q.push(v); // 就把這個鄰居丟進去名單裡面
dis[v] = dis[u] + 1; // 順便算一下 s 到 v 的距離是多少
}
}
}
}
```
其中,$dis[~]$ 並非執行 BFS 的必要條件,需要時在寫上去即可
### 時間複雜度
令節點數 $|V|=n$、邊數 $|E|=m$
- 每個節點都被拜訪過一次: $O(n)$
- 每條邊最多被走過兩次: $2m=O(m)$
- 總時間複雜度: $O(m+n)$
## 深度優先搜尋 Depth-first Search
### 簡述
深度優先搜尋簡稱 DFS,走訪順序如以下圖示:
<center>
<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/1/1f/Depth-first-tree.svg/585px-Depth-first-tree.svg.png" style="margin: 0 auto; display: block; width: 400px">
</br>
<a href="https://zh.wikipedia.org/zh-tw/%E6%B7%B1%E5%BA%A6%E4%BC%98%E5%85%88%E6%90%9C%E7%B4%A2">
<p>圖源: 維基百科</p>
</a>
</center>
### 性質
1. 輸入僅有一張圖 $G$,沒有「起點」
2. 不管是否為連通圖,每個點都會被走訪過一遍,邊則最多走過兩遍
3. 可以利用「時間戳記」來判斷子節點
4. 每拜訪一個鄰居,就往該鄰居的鄰居拜訪
5. 走訪完的路徑會形成一棵 DFS 樹 (tree) 或是森林 (forest)
### C++ 實作
- 圖可以用鄰接串列 (adjacency list) 來儲存,比較方便
- 根據性質 1,必須用一個迴圈來跑過所有節點
- 根據性質 2,為了避免拜訪重複的點,要用一個布林陣列來記錄節點是否拜訪過
- 根據性質 4,符合「後進先出」性質,可以用一個 stack 來維護,亦可使用遞迴
#### stack 版本
```cpp
const int MAXN = 2e5 + 5; // 點的數量
vector<int> g[MAXN]; // 鄰接串列儲存圖
bool vis[MAXN]; // 紀錄每個節點是否走訪過(在全域宣告會自動變成 false)
void run_dfs(int s) { // 起點
stack<int> stk; // 這個存走訪名單
stk.push(s); // s 第一個放入要走訪的名單
while (!stk.empty()) {
int u = stk.top(); // u 是現在走訪的節點
stk.pop(); // 把 u 從走訪名單上踢掉
vis[u] = true; // 這樣子 u 就算是走訪過囉
for (int v : g[u]) { // 把 u 的每個鄰居 v 都放進名單中
if (vis[v] == false) // 如果沒有走訪過這個鄰居
stk.push(v); // 就把這個鄰居丟進去名單裡面
}
}
}
void dfs(int n) { // 塞入節點個數
for (int i = 1; i <= n; i++) { // 窮舉所有節點
if (vis[i] == false) // 如果該鄰居沒拜訪過
run_dfs(i); // 就去他家逛逛吧
}
}
```
#### 遞迴版本
```cpp
const int MAXN = 2e5 + 5; // 點的數量
vector<int> g[MAXN]; // 鄰接串列儲存圖
bool vis[MAXN]; // 紀錄每個節點是否走訪過(在全域宣告會自動變成 false)
void run_dfs(int u) {
vis[u] = true; // 這樣就算是走過囉
for (int v : g[u]) {
if (vis[v] == false) // 如果該鄰居沒拜訪過
run_dfs(v); // 就去他家逛逛吧
}
}
void dfs(int n) { // 塞入節點個數
for (int i = 1; i <= n; i++) { // 窮舉所有節點
if (vis[i] == false) // 如果該鄰居沒拜訪過
run_dfs(i); // 就去他家逛逛吧
}
}
```
### 時間戳記
我們可以在上述遞迴版本的程式碼中加入:
- $d[i]$ : 發現節點 $i$ 的時間 (discovery time)
- $f[i]$ : 節點 $i$ 完成所有鄰居拜訪的時間 (finishing time)
```cpp
// DFS 遞迴版本程式片段
void run_dfs(int u) {
vis[u] = true;
d[u] = ++timestamp; // 紀錄發現時間
for (int v : g[u]) {
if (vis[v] == false)
run_dfs(v);
}
f[u] = ++timestamp; // 紀錄結束時間
}
```
會發現時間戳記有以下性質:
1. 對於所有節點 $v$,每個 $d[v]$ 與 $f[v]$ 都有獨一無二的數字
2. 對於所有節點 $v$,$1\leq d[v]<f[v]\leq 2n$
### 括號定理
當我們把所有節點 $v$ 的發現與結束時間化在數線上,會形成好幾個區間
<center>
<img src="https://hackmd.io/_uploads/B1cGUux-Je.png" style="margin: 0 auto; display: block; width: 600px">
圖源 : CLRS 演算法課本
</center>
</br>
以上圖為例,左邊是原本的圖;右邊則是把時間戳記畫在數線上
藉由上圖,會發現以下性質:
對於任兩節點 $u$ 與 $v$ 在 DFS 森林中
1. 如果區間 $[d[u], f[u]]$ 與 $[d[v], f[v]]$ 沒有交集,則 $u$ 與 $v$ 沒有血緣關係
2. 如果區間 $[d[u], f[u]]$ 包含 $[d[v], f[v]]$,則 $u$ 為 $v$ 的祖先
3. 如果區間 $[d[v], f[v]]$ 包含 $[d[u], f[u]]$,則 $u$ 為 $v$ 的後代
可以得到推論: 在一個 DFS 森林中,$d[u]<d[v]<f[v]<f[u]$ 若且唯若,$v$ 是 $u$ 的後代
### 白路徑定理
在一張圖 $G$ 的 DFS 森林中,在時間處於 $d[u]$ 時,有一條從 $u$ 到 $v$ 的路徑是完全由白點組成若且為若 $v$ 是 $u$ 的後代
這些定理與性質會在樹的演算法、[Kosaraju 演算法](https://hackmd.io/@ShanC/Kosaraju)、[拓樸排序 (Topological Sort)](https://hackmd.io/@ShanC/topological_sort) 中有更多討論與應用
### 時間複雜度
令節點數 $|V|=n$、邊數 $|E|=m$
- 每個節點都被拜訪過一次: $O(n)$
- 每條邊最多被走過兩次: $2m=O(m)$
- 總時間複雜度: $O(m+n)$
## 例題說明: [迷宮問題](https://zerojudge.tw/ShowProblem?problemid=a982)
### 題目
給一個 $N\times N$ 的迷宮,求從 $(2, 2)$ 到 $(n - 1, n - 1)$ 的最短路徑
### 題解
這題顯然是要先將網格圖轉化成圖論的圖,其實把每個點 (point) 視為節點 (vertex) 就很好去想要怎麼找最短路徑。找最短路徑可以使用 BFS,因為他走訪的點都會是相對於起點的最短路徑。
### 程式碼
```cpp
#include <bits/stdc++.h> // 萬用標頭檔
using namespace std;
const int N = 105;
struct Point {
int x, y;
};
int n, dis[N][N];
int dir[4][2] = {
{1, 0 },
{0, 1 },
{-1, 0 },
{0, -1}
};
int main() {
cin >> n;
char c;
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
cin >> c;
if (c == '#')
dis[i][j] = -1;
else
dis[i][j] = 0;
}
}
queue<Point> q;
q.push({1, 1});
dis[1][1] = 1;
while (!q.empty()) {
Point u = q.front();
q.pop();
if (u.x == n - 2 && u.y == n - 2)
break; // 找到終點就結束迴圈
for (int i = 0; i < 4; i++) {
Point v = {u.x + dir[i][0], u.y + dir[i][1]};
if (dis[v.x][v.y] == 0) {
q.push({v.x, v.y});
dis[v.x][v.y] = dis[u.x][u.y] + 1;
}
}
}
if (dis[n - 2][n - 2])
cout << dis[n - 2][n - 2] << '\n';
else
cout << "No solution!\n";
return 0;
}
```
## 例題說明: [排列](https://zerojudge.tw/ShowProblem?problemid=i646)
### 題目
把英文小寫字母前 $n$ 項的所有排列方法列出來,其中 $n \leq 7$
### 題解
以 $n=3$ 為例,可以用樹狀圖找出以下可能
<center>
<img src="https://hackmd.io/_uploads/Bk1S4qeWye.png" style="margin: 0 auto; display: block; width: 600px">
</center>
把問題轉化為樹狀圖之後,就可以用 DFS 走訪每個葉結點
### 程式碼
```cpp
#include <bits/stdc++.h>
using namespace std;
int n;
map<char, bool> mp;
void dfs(string str) {
if (str.length() == n) {
cout << str << '\n';
return;
}
for (int i = 0; i < n; i++) {
char c = i + 'a';
if (mp[c] == 0) {
mp[c] = true;
dfs(str + c);
mp[c] = false;
}
}
}
int main() {
while (cin >> n && n)
dfs("");
return 0;
}
```
## 小結
### BFS
- 優點:
適合找出最短路徑
如果是要求出其中一條路徑,BFS 較穩定且有效率
- 缺點:
耗費太多記憶體 (其實也還好
### DFS
- 優點:
適合窮舉所有可能
有時間戳記的性質,在走訪樹的時候比較好去做擴充
- 缺點:
如果要找出最短路徑比較沒效率,因為必須跑完整張圖
**遇到題目時要先分析到底哪個方法更適合**
## 題目練習
[Zerojudge a982. 迷宮問題#1](https://zerojudge.tw/ShowProblem?problemid=a982)
[CSES Subordinates](https://cses.fi/problemset/task/1674/)
[Zerojudge a290. 新手訓練系列 ~ 圖論](https://zerojudge.tw/ShowProblem?problemid=a290)
[CSES Counting Rooms](https://cses.fi/problemset/task/1192)
[Zerojudge l747. 連線](https://zerojudge.tw/ShowProblem?problemid=l747)
[Zerojudge j124. APCS 3. 石窟探險](https://zerojudge.tw/ShowProblem?problemid=j124)
[Zerojudge b967. APCS 4. 血緣關係](https://zerojudge.tw/ShowProblem?problemid=b967)
[Zerojudge i646. 排列組合-排列](https://zerojudge.tw/ShowProblem?problemid=i646)
[CSES Tree Matching](https://cses.fi/problemset/task/1130/) (跟樹的葉子有關)
[Zerojudge c291. APCS 2017-0304-2小群體](https://zerojudge.tw/ShowProblem?problemid=c291)
[CSES Message Route](https://cses.fi/problemset/task/1667/)
[Zerojudge n751. 00941 - Permutations](https://zerojudge.tw/ShowProblem?problemid=n751)
[Zerojudge a469. 10063 - Knuth's Permutation](https://zerojudge.tw/ShowProblem?problemid=a469)
----
## 參考資料
- Introduction to Algorithms, Fourth Edition
- [geeksforgeeks](https://www.geeksforgeeks.org/iterative-depth-first-traversal/)
- [DFS與BFS by Darth-Phoenix](https://hackmd.io/@rd2865OAQZSLjri24DYcow/B1zalLE6u)
- [維基百科](https://zh.wikipedia.org/zh-tw/%E6%B7%B1%E5%BA%A6%E4%BC%98%E5%85%88%E6%90%9C%E7%B4%A2)
----
> [ShanC 程式競賽筆記](https://hackmd.io/@ShanC/B1ouGxqcC)
> 作者: ShanC
> 更新: 2024/10/31