코딩 · 알고리즘
너비 우선 탐색(BFS) 쉽게 이해하기: 동작 원리, 코드, 시간 복잡도
핵심 답변
너비 우선 탐색(BFS)은 시작 노드를 먼저 방문한 뒤 그 이웃을, 다시 그 이웃의 이웃을 방문하는 식으로 한 레벨씩 넓혀 가는 그래프 순회 알고리즘입니다. 선입선출(FIFO) 큐와 방문한 노드의 집합을 사용합니다. 가중치가 없는 그래프에서 BFS는 시작 노드에서 도달 가능한 모든 노드까지의 최단 경로(간선 수가 가장 적은 경로)를 찾아 주며, 시간 복잡도는 O(V + E)입니다.
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입니다. D는 B와 C 양쪽에서 도달할 수 있지만, B에서 처음 발견되는 순간 방문 처리되므로 한 번만 발견됩니다.
파이썬 구현
큐에는 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 |
| 음수가 아닌 가중치의 최단 경로 | 다익스트라 알고리즘 |
| 좋은 거리 추정값이 있는 최단 경로 | A* 탐색 |
| 사이클 탐지, 위상 정렬, 연결 요소 | DFS (연결 요소는 BFS로도 가능) |
| 매우 깊은 그래프이면서 메모리가 부족한 경우 | DFS 또는 반복 깊이 증가 탐색 |
- 코딩 인터뷰 문제: 미로나 격자의 최단 경로, 단어 사다리, 썩은 오렌지(다중 시작점 BFS), 이진 트리 레벨 순서 순회, 나이트의 최소 이동 횟수.
- 네트워크: k홉 이내의 모든 기기 찾기, P2P 네트워크의 브로드캐스트, 소셜 그래프에서 거리 2의 "알 수도 있는 사람".
- 시드 페이지로부터의 링크 거리 순서대로 페이지를 방문하는 웹 크롤러.
- 가비지 컬렉터(예: 체니의 복사 수집기)와 최소 이동 횟수가 필요한 퍼즐 풀이기.
자주 묻는 질문
BFS는 어떤 자료구조를 사용하나요?
BFS는 다음에 처리할 노드를 정하기 위해 FIFO 큐를, 방문한 노드를 기억하기 위해 집합(또는 딕셔너리)을 사용합니다. 파이썬에서는 큐로 collections.deque를 쓰면 됩니다.
BFS는 항상 최단 경로를 찾나요?
가중치가 없는 그래프이거나 모든 간선의 가중치가 같다면 BFS는 간선 수가 가장 적은 경로를 찾습니다. 가중치 그래프에서는 그렇지 않으므로 다익스트라 알고리즘이나 벨만-포드 알고리즘을 사용하세요.
BFS와 DFS의 차이는 무엇인가요?
BFS는 큐로 레벨 단위로 탐색하므로 가중치 없는 그래프에서 최단 경로를 찾습니다. DFS는 스택이나 재귀로 최대한 깊이 들어가며 탐색하므로 사이클 탐지, 위상 정렬, 완전 탐색에 적합합니다.
파이썬에서 BFS가 느린 이유는 무엇인가요?
흔한 원인은 호출할 때마다 리스트 전체를 밀어내는 list.pop(0)과, 방문 여부를 집합이 아닌 리스트로 확인하는 것입니다. deque.popleft()와 집합 또는 딕셔너리를 사용하세요.
트리에서도 BFS를 쓸 수 있나요?
네. 트리에서 BFS는 레벨 순서 순회입니다. 트리에는 사이클이 없으므로 부모에서 자식으로만 이동한다면 방문 집합을 생략해도 됩니다.