編碼 · 演算法
廣度優先搜尋(BFS)教學:原理、程式碼與時間複雜度
簡答
廣度優先搜尋(BFS)是一種圖的走訪方式:先拜訪起點,再拜訪起點的所有鄰居,接著是鄰居的鄰居,依此類推,一次推進一層。它使用先進先出的佇列和一個已拜訪節點的集合。在無權重圖上,BFS 能找出起點到每個可到達節點的最短路徑(邊數最少),時間複雜度為 O(V + E)。
BFS 解決什麼問題
許多問題其實都是圖:網頁與連結、社群網路中的朋友、棋盤上的格子、謎題的各種狀態。對這類圖,BFS 能回答兩個常見問題:從起點出發可以到達哪些節點?到達每個節點最少需要幾步?
BFS 是以距離遞增的一圈圈範圍向外探索,所以第一次到達某個節點時,走的一定是邊數最少的路徑。因此當每條邊的成本相同時,BFS 就是求最短路徑的標準演算法。
用白話說明原理
- 把起點放進佇列,並標記為已拜訪。
- 取出佇列最前面的節點。
- 對每個尚未拜訪的鄰居,標記為已拜訪,並加到佇列尾端。
- 重複步驟 2 和 3,直到佇列為空。
請在節點加入佇列時就標記為已拜訪,而不是取出時才標記。否則同一個節點在被處理之前可能被重複加入許多次。
逐步範例
考慮這個無向圖:A–B、A–C、B–D、C–D、C–E、D–F。從 A 出發,並依字母順序拜訪鄰居。
| 步驟 | 從佇列取出 | 新發現的節點 | 該步驟後的佇列 |
|---|---|---|---|
| 0 | — | A(距離 0) | A |
| 1 | A | B、C(距離 1) | B, C |
| 2 | B | D(距離 2) | C, D |
| 3 | C | E(距離 2) | D, E |
| 4 | D | F(距離 3) | E, F |
| 5 | E | 無 | F |
| 6 | F | 無 | 空 |
拜訪順序是 A、B、C、D、E、F。B 和 C 都能到達 D,但 D 只會被發現一次(從 B 發現),因為當下它就已被標記為已拜訪。
Python 實作
佇列請使用 collections.deque:popleft() 是 O(1),而 list.pop(0) 是 O(n)。圖以字典表示,每個節點對應到一個鄰居串列(鄰接串列)。
from collections import deque
def bfs(graph, start):
"""Return the distance (number of edges) from start to each reachable node."""
dist = {start: 0}
queue = deque([start])
while queue:
node = queue.popleft()
for nxt in graph[node]:
if nxt not in dist: # not visited yet
dist[nxt] = dist[node] + 1
queue.append(nxt)
return dist
graph = {
"A": ["B", "C"], "B": ["A", "D"], "C": ["A", "D", "E"],
"D": ["B", "C", "F"], "E": ["C"], "F": ["D"],
}
print(bfs(graph, "A")) # {'A': 0, 'B': 1, 'C': 1, 'D': 2, 'E': 2, 'F': 3}這裡的 dist 字典身兼兩職:既是已拜訪集合,也儲存答案。若要重建實際路徑,只要另外記錄每個節點的父節點,再從目標往回走即可。
def shortest_path(graph, start, goal):
parent = {start: None}
queue = deque([start])
while queue:
node = queue.popleft()
if node == goal:
path = []
while node is not None:
path.append(node)
node = parent[node]
return path[::-1]
for nxt in graph[node]:
if nxt not in parent:
parent[nxt] = node
queue.append(nxt)
return None # goal not reachable
print(shortest_path(graph, "A", "F")) # ['A', 'B', 'D', 'F']時間與空間複雜度
| 項目 | 成本 | 原因 |
|---|---|---|
| 時間 | O(V + E) | 每個節點只會入佇列一次,每條邊只會檢查一次(無向圖中為兩次)。 |
| 空間 | O(V) | 已拜訪集合、佇列和父節點對應表,各自最多存放 V 筆資料。 |
| R × C 的網格 | O(R × C) | 每個格子是一個節點,最多有 4 條邊。 |
V 是頂點(節點)數,E 是邊數。若改用鄰接矩陣而非串列,時間會變成 O(V²),因為找出一個節點的鄰居需要 O(V)。
變化版本與常見錯誤
- 網格 BFS:把每個格子視為節點,上下左右(或八個方向)的相鄰格子視為邊。加入格子前要先檢查邊界與牆壁。
- 多源 BFS:把多個起點以距離 0 一起放進佇列,可求出到最近起點的距離,例如迷宮中最近的出口。
- 0-1 BFS:當邊權重只有 0 或 1 時,使用雙端佇列,並把權重為 0 的鄰居推到前端,即可在 O(V + E) 內求出最短路徑。
- 雙向 BFS:同時從起點和終點開始搜尋,兩邊的搜尋前線相遇時停止,通常能少探索許多節點。
- 逐層處理:每一輪開始時記錄
len(queue),並剛好取出這麼多個節點,就能一次處理一整層。
何時使用 BFS 與實際應用
| 情境 | 最佳選擇 |
|---|---|
| 最短路徑,所有邊成本相同 | BFS |
| 最短路徑,權重非負 | Dijkstra 演算法 |
| 最短路徑,且有良好的距離估計 | A* 搜尋 |
| 偵測環、拓樸順序、連通元件 | DFS(連通元件也可用 BFS) |
| 圖很深且記憶體有限 | DFS 或迭代加深搜尋 |
- 面試題目:迷宮或網格中的最短路徑、單字接龍(word ladder)、腐爛的橘子(多源 BFS)、二元樹的層序走訪、騎士的最少步數。
- 網路應用:找出 k 跳以內的所有裝置、點對點網路中的廣播、社群圖中距離為 2 的「你可能認識的人」。
- 網路爬蟲,依與起始頁面的連結距離順序拜訪網頁。
- 垃圾回收器(例如 Cheney 複製式回收器)以及需要最少步數的解謎程式。
常見問題
BFS 使用什麼資料結構?
BFS 用先進先出(FIFO)的佇列決定下一個要處理的節點,並用集合(或字典)記錄已拜訪的節點。在 Python 中,佇列請用 collections.deque。
BFS 一定能找到最短路徑嗎?
在無權重圖,或所有邊權重相同時,是的,BFS 能找到邊數最少的路徑。在有權重的圖上則不行,請改用 Dijkstra 演算法或 Bellman-Ford。
BFS 和 DFS 有什麼差別?
BFS 用佇列逐層探索,所以能在無權重圖中找到最短路徑。DFS 則用堆疊或遞迴盡可能往深處走,適合偵測環、拓樸排序與窮舉搜尋。
為什麼我的 BFS 在 Python 中很慢?
常見原因是 list.pop(0),每次呼叫都要搬移整個串列,以及用串列而不是集合檢查已拜訪節點。請改用 deque.popleft() 和集合或字典。
BFS 可以用在樹上嗎?
可以。在樹上,BFS 就是層序走訪。樹沒有環,如果只從父節點走向子節點,就不需要已拜訪集合。