코딩 · 알고리즘
다익스트라 알고리즘 쉽게 이해하기: 동작 원리, 코드, 시간 복잡도
핵심 답변
다익스트라 알고리즘은 간선 가중치가 모두 음수가 아닌 그래프에서 한 시작 노드로부터 다른 모든 노드까지의 최단 경로를 구합니다. 아직 확정되지 않은 노드 중 알려진 거리가 가장 작은 노드를 골라 확정하고, 그 노드에서 나가는 간선을 완화(relax)하는 과정을 반복합니다. 이진 힙 우선순위 큐를 쓰면 O((V + E) log V) 시간에 동작합니다. 음수 가중치가 하나라도 있으면 잘못된 답을 냅니다.
다익스트라 알고리즘이 해결하는 문제
너비 우선 탐색은 간선 수가 가장 적은 경로를 찾지만, 도로, 네트워크 링크, 항공편은 길이, 비용, 지연 시간이 제각각입니다. 다익스트라 알고리즘은 이런 가중치 그래프에서 단일 출발점 최단 경로 문제를 풉니다. 시작 노드가 주어지면 다른 모든 노드까지의 최소 총비용과 그 비용을 만드는 경로를 찾습니다.
에츠허르 다익스트라(Edsger W. Dijkstra)는 1959년 논문 "A note on two problems in connexion with graphs"에서 이 알고리즘을 발표했습니다.
핵심 아이디어
- 시작 노드의 거리를 0으로, 나머지 노드의 거리는 모두 무한대로 설정합니다.
- 확정되지 않은 노드 중 거리가 가장 작은 노드를 고릅니다. 이제 그 노드의 거리는 확정입니다.
- 가중치 w인 나가는 간선 u → v마다 완화를 수행합니다.
dist[u] + w < dist[v]이면dist[v] = dist[u] + w로 갱신하고 u를 v의 부모로 기억합니다. - 도달 가능한 모든 노드가 확정될 때까지 2번과 3번을 반복합니다.
가장 작은 거리가 왜 확정일까요? 그 노드로 가는 다른 경로는 모두 거리가 이미 그 이상인 노드를 거쳐야 하고, 가중치가 음수가 아니면 남은 간선은 비용을 더할 뿐이기 때문입니다.
단계별 예제
간선이 A→B (4), A→C (1), C→B (2), B→D (1), C→D (5), D→E (3)인 방향 그래프입니다. A에서 시작합니다.
| 단계 | 확정 | A | B | C | D | E |
|---|---|---|---|---|---|---|
| 시작 | — | 0 | ∞ | ∞ | ∞ | ∞ |
| 1 | A (0) | 0 | 4 | 1 | ∞ | ∞ |
| 2 | C (1) | 0 | C 경유 3 | 1 | C 경유 6 | ∞ |
| 3 | B (3) | 0 | 3 | 1 | B 경유 4 | ∞ |
| 4 | D (4) | 0 | 3 | 1 | 4 | 7 |
| 5 | E (7) | 0 | 3 | 1 | 4 | 7 |
B는 처음에 직접 간선으로 거리 4를 얻지만 C를 거치면서 3으로 개선됩니다. E까지의 최단 경로는 A → C → B → D → E이고 총비용은 1 + 2 + 1 + 3 = 7입니다.
heapq를 사용한 파이썬 구현
파이썬의 heapq 모듈은 이진 최소 힙을 제공합니다. "키 감소" 연산 대신 거리가 개선될 때마다 새 (distance, node) 쌍을 넣고, 꺼냈을 때 오래된 쌍은 건너뜁니다. 이를 지연 삭제(lazy deletion)라고 합니다.
import heapq
def dijkstra(graph, start):
dist = {start: 0}
parent = {start: None}
heap = [(0, start)]
while heap:
d, u = heapq.heappop(heap)
if d > dist[u]:
continue # outdated entry
for v, w in graph[u]:
nd = d + w
if nd < dist.get(v, float("inf")):
dist[v] = nd
parent[v] = u
heapq.heappush(heap, (nd, v))
return dist, parent
graph = {
"A": [("B", 4), ("C", 1)], "B": [("D", 1)],
"C": [("B", 2), ("D", 5)], "D": [("E", 3)], "E": [],
}
dist, parent = dijkstra(graph, "A")
print(dist) # {'A': 0, 'B': 3, 'C': 1, 'D': 4, 'E': 7}특정 목표까지의 경로를 얻으려면 부모 링크를 따라 시작 노드까지 거슬러 올라갑니다.
def path_to(parent, target):
path = []
while target is not None:
path.append(target)
target = parent[target]
return path[::-1]
print(path_to(parent, "E")) # ['A', 'C', 'B', 'D', 'E']한 목표까지의 거리만 필요하다면 그 목표가 힙에서 꺼내지는 즉시 멈춰도 됩니다. 그 시점에 그 노드의 거리는 확정이기 때문입니다.
시간 복잡도와 공간 복잡도
| 우선순위 큐 | 시간 | 비고 |
|---|---|---|
| 이진 힙 (heapq) | O((V + E) log V) | 가장 흔한 선택입니다. 지연 삭제를 쓰면 힙에 최대 E개의 항목이 들어갈 수 있지만, log E가 2 log V 이하이므로 연산당 O(log V)입니다. |
| 일반 배열, 최솟값 선형 탐색 | O(V²) | E가 V²에 가까운 매우 조밀한 그래프에서 더 낫습니다. |
| 피보나치 힙 | O(E + V log V) | 이론상 최선이지만 상수가 커서 실제로는 더 빠른 경우가 드뭅니다. |
공간 복잡도는 그래프, 거리 맵과 부모 맵, 힙을 합쳐 O(V + E)입니다.
변형과 흔한 실수
if d > dist[u]: continue검사를 빼먹어도 틀리지는 않지만, 오래된 항목까지 처리하게 되어 크게 느려질 수 있습니다.(distance, node)가 아니라 노드만 넣으면 힙의 순서가 의미를 잃습니다.- 노드가 서로 비교할 수 없는 객체라면 거리가 같을 때
heapq가 노드끼리 비교하려다TypeError를 일으킵니다. 고유한 카운터를 넣어(distance, counter, node)를 사용하세요. - A* 탐색은 우선순위에 휴리스틱을 더한 다익스트라입니다. 남은 거리를 잘 추정할 수 있으면 더 적은 노드를 탐색합니다.
- 양방향 다익스트라는 시작점과 목표점에서 동시에 탐색하다가 두 탐색이 만나면 멈춥니다.
언제 쓰는지와 실제 활용
| 그래프 | 알고리즘 |
|---|---|
| 가중치가 없거나 모두 같음 | BFS, O(V + E) |
| 음수가 아닌 가중치, 단일 출발점 | 다익스트라 |
| 음수 가중치가 있고 음수 사이클은 없음 | Bellman-Ford, O(V × E) |
| 모든 쌍, 작고 조밀한 그래프 | Floyd-Warshall, O(V³) |
| 거리 추정값이 있는 단일 목표 | A* 탐색 |
- 라우팅: OSPF, IS-IS 같은 링크 상태 라우팅 프로토콜은 다익스트라 알고리즘에 기반한 최단 경로 우선 계산을 수행합니다.
- 지도와 내비게이션: 실제 서비스는 다익스트라 방식의 탐색 위에 전처리를 더해 사용합니다.
- 코딩 인터뷰 문제: 네트워크 지연 시간, 최저가 항공권(경유 횟수 제한), 격자에서 최소 노력 경로, 그래프에서 "최소 총비용"을 묻는 모든 문제.
자주 묻는 질문
다익스트라 알고리즘은 음수 가중치에서도 동작하나요?
아니요. 이 알고리즘은 어떤 노드가 가장 작은 임시 거리를 가지면 이후의 어떤 경로도 그 노드를 더 싸게 만들 수 없다고 가정하는데, 음수 간선이 이 가정을 깨뜨립니다. 대신 벨만-포드를 사용하세요.
다익스트라 알고리즘의 시간 복잡도는 얼마인가요?
이진 힙을 쓰면 O((V + E) log V)입니다. 단순 배열을 쓰면 O(V²)이며, 조밀한 그래프에서는 이쪽이 더 나을 수 있습니다.
다익스트라 알고리즘은 그리디 알고리즘인가요?
네. 매 단계에서 아직 확정되지 않은 가장 가까운 노드를 확정하는 지역적 최선의 선택을 하며, 모든 가중치가 음수가 아닐 때 이 선택이 옳다는 것이 증명되어 있습니다.
다익스트라와 BFS의 차이는 무엇인가요?
BFS는 일반 큐를 쓰고 간선 수를 세므로 모든 간선의 가중치가 같을 때만 올바릅니다. 다익스트라는 총 거리 순으로 정렬되는 우선순위 큐를 쓰므로 서로 다른 음수가 아닌 가중치를 다룰 수 있습니다.
다익스트라로 모든 노드 쌍의 최단 경로를 구할 수 있나요?
한 번 실행하면 한 출발점으로부터의 최단 경로를 얻습니다. 모든 쌍이 필요하면 모든 노드에서 각각 실행하거나, 작고 조밀한 그래프에는 플로이드-워셜을 사용하세요.