編碼 · 演算法
深度優先搜尋(DFS)教學:原理、程式碼與時間複雜度
簡答
深度優先搜尋(DFS)是一種圖的走訪方式:沿著一條路徑盡可能往深處走,走不下去時就退回最近一個仍有未拜訪鄰居的節點,再從那裡繼續。它使用遞迴或明確的堆疊,加上一個已拜訪集合。DFS 能在 O(V + E) 時間內拜訪所有可到達的節點,是偵測環、拓樸排序、連通元件與回溯法的基礎。
DFS 解決什麼問題
DFS 用「先把一條分支完全探索完,再試下一條」的方式,回答「從這裡出發能到達哪些地方?」這個問題。當路徑的結構比長度更重要時,這種順序特別有用:找出迷宮的出路、檢查圖中有沒有環、安排彼此相依的任務順序,或列出謎題的所有解。
與廣度優先搜尋不同,DFS 無法找到最短路徑。它的優點是記憶體中只需保留目前這條路徑,並且能自然地記錄每個節點「進入」和「完成」的時間點。
用白話說明原理
- 把目前的節點標記為已拜訪。
- 對每個尚未拜訪的鄰居,移動過去,並從那裡重複步驟 1。
- 當一個節點已沒有未拜訪的鄰居,就算完成:回到你來的那個節點。
- 回到起點且起點也沒有未拜訪的鄰居時,搜尋就結束了。
回到上一個節點稱為回溯(backtracking)。使用遞迴時,這件事由呼叫堆疊自動幫你完成。
逐步範例
使用與 BFS 文章相同的無向圖:A–B、A–C、B–D、C–D、C–E、D–F。從 A 出發,並依字母順序嘗試鄰居。
| 步驟 | 動作 | 目前路徑(堆疊) |
|---|---|---|
| 1 | 拜訪 A | A |
| 2 | A → B(第一個未拜訪的鄰居) | A, B |
| 3 | B → D | A, B, D |
| 4 | D → C(B 已拜訪) | A, B, D, C |
| 5 | C → E(A 和 D 已拜訪) | A, B, D, C, E |
| 6 | E 沒有新的鄰居:完成 E,回到 C;完成 C,回到 D | A, B, D |
| 7 | D → F | A, B, D, F |
| 8 | 依序完成 F、D、B、A | 空 |
拜訪順序是 A、B、D、C、E、F,完成順序是 E、C、F、D、B、A。拓樸排序和強連通元件演算法用的就是完成順序。
Python 實作
遞迴版本最短,也最貼近原理:
def dfs(graph, node, visited=None):
if visited is None:
visited = []
visited.append(node)
for nxt in graph[node]:
if nxt not in visited:
dfs(graph, nxt, visited)
return visited
graph = {
"A": ["B", "C"], "B": ["A", "D"], "C": ["A", "D", "E"],
"D": ["B", "C", "F"], "E": ["C"], "F": ["D"],
}
print(dfs(graph, "A")) # ['A', 'B', 'D', 'C', 'E', 'F']用串列能讓範例比較好讀,但 in 檢查是 O(n)。處理大型圖時,請另外用 set 判斷是否已拜訪。迭代版本使用明確的堆疊,可避開 Python 的遞迴深度限制;鄰居以反向順序推入,這樣彈出時就會維持原本的順序:
def dfs_iterative(graph, start):
visited, order = set(), []
stack = [start]
while stack:
node = stack.pop()
if node in visited:
continue
visited.add(node)
order.append(node)
for nxt in reversed(graph[node]):
if nxt not in visited:
stack.append(nxt)
return order
print(dfs_iterative(graph, "A")) # ['A', 'B', 'D', 'C', 'E', 'F']請注意與 BFS 的差別:這裡是在節點被彈出時才標記,所以同一個節點可能在堆疊中出現不只一次。這對 DFS 是正確的,也能維持真正的深度優先順序。
偵測有向圖中的環
DFS 的經典應用是為每個節點著色:白色(尚未見過)、灰色(在目前路徑上)、黑色(已完成)。一旦走到灰色節點,就代表有一條邊指回目前的路徑,也就是出現了環。
def has_cycle(graph):
WHITE, GRAY, BLACK = 0, 1, 2
color = {node: WHITE for node in graph}
def visit(node):
color[node] = GRAY
for nxt in graph[node]:
if color[nxt] == GRAY:
return True # back edge
if color[nxt] == WHITE and visit(nxt):
return True
color[node] = BLACK
return False
return any(color[n] == WHITE and visit(n) for n in graph)
print(has_cycle({"a": ["b"], "b": ["c"], "c": ["a"]})) # True
print(has_cycle({"a": ["b"], "b": ["c"], "c": []})) # False時間與空間複雜度
| 項目 | 成本 | 原因 |
|---|---|---|
| 時間 | O(V + E) | 每個節點只拜訪一次,每條邊在每個方向各檢查一次。 |
| 空間 | O(V) | 已拜訪集合,加上遞迴或明確堆疊,其大小最多可達最長路徑的長度。 |
| 鄰接矩陣 | O(V²) 時間 | 列出每個節點的鄰居需要 O(V)。 |
變化版本與常見錯誤
- 前序與後序:進入節點時記錄(前序),或完成節點時記錄(後序)。有向無環圖的反向後序就是一個拓樸順序。
- 連通元件:從每個仍未拜訪的節點各啟動一次新的 DFS;每次啟動就是一個元件。
- 迭代加深 DFS:以深度限制 0、1、2、… 重複執行 DFS。它像 BFS 一樣能找到最短路徑,又有 DFS 的低記憶體用量。
- 網格上的填色(油漆桶工具)就是對相同顏色的相鄰格子做 DFS 或 BFS。
其他常見錯誤:忘了用已拜訪集合(遇到任何環就會無窮迴圈)、使用可變的預設參數如 visited=[](會在多次呼叫間共用),以及把無向邊指回父節點誤判為環。
何時使用 DFS 與實際應用
- DFS 適用於可達性判斷、偵測環、拓樸排序、連通元件與強連通元件、橋與關節點,以及搭配回溯法的窮舉搜尋。
- 需要最少步數時請改用 BFS,邊有權重時則用 Dijkstra 或 A*。
- 面試題目:島嶼數量、複製圖、課程表(偵測環)、二元樹的路徑總和、產生排列。
- 實際應用:建置系統與套件管理工具解析相依順序、編譯器分析控制流程、迷宮生成,以及走訪檔案系統或 HTML 文件的樹狀結構。
常見問題
DFS 是用遞迴還是迭代?
兩種都可以。遞迴比較簡短,並隱含地使用呼叫堆疊;明確的堆疊可避開 Python 等語言的遞迴限制,只要把鄰居反向推入,拜訪順序也相同。
DFS 能找到最短路徑嗎?
不能。DFS 只會找到某一條路徑,不一定是邊數最少的。無權重圖請用 BFS,有權重圖請用 Dijkstra 演算法。
DFS 的時間複雜度是多少?
使用鄰接串列時為 O(V + E),因為每個節點和每條邊都只會被處理常數次。使用鄰接矩陣則為 O(V²)。
前序、中序和後序走訪與 DFS 有什麼關係?
它們都是樹的深度優先走訪,差別只在處理目前節點的時機:在子節點之前、左右子節點之間,或在子節點之後。