최단 경로 — Dijkstra부터 Bellman-Ford까지
네비게이션은 어떻게 최단 경로를 찾을까요? 그 근본에는 Dijkstra 알고리즘이 있습니다.
최단 경로 문제 분류
| 문제 유형 | 알고리즘 | 음수 간선 | 시간 복잡도 |
|---|---|---|---|
| 단일 출발점 (양수) | Dijkstra | X | O((V+E) log V) |
| 단일 출발점 (음수 허용) | Bellman-Ford | O | O(VE) |
| 모든 쌍 | Floyd-Warshall | O | O(V³) |
Dijkstra 알고리즘
핵심 아이디어
시작 노드에서 가장 가까운 확정되지 않은 노드를 선택하고, 그 노드를 통해 이웃 노드의 거리를 갱신합니다. 그리디 알고리즘 입니다.
동작 과정
시작: S, 목표: 모든 노드까지의 최단 거리
1. dist[S] = 0, 나머지 = ∞
2. 가장 가까운 미확정 노드 u를 선택 (그리디)
3. u의 이웃 v에 대해: dist[v] = min(dist[v], dist[u] + w(u,v))
4. u를 확정 (다시 방문하지 않음)
5. 모든 노드가 확정될 때까지 반복
우선순위 큐 구현
public static int[] dijkstra(List<List<int[]>> graph, int start) {
int n = graph.size();
int[] dist = new int[n];
Arrays.fill(dist, Integer.MAX_VALUE);
dist[start] = 0;
// (거리, 노드) min-heap
PriorityQueue<int[]> pq = new PriorityQueue<>(
Comparator.comparingInt(a -> a[0])
);
pq.offer(new int[]{0, start});
while (!pq.isEmpty()) {
int[] curr = pq.poll();
int d = curr[0], u = curr[1];
// 이미 더 짧은 경로로 확정되었으면 건너뜀
if (d > dist[u]) continue;
for (int[] edge : graph.get(u)) {
int v = edge[0], w = edge[1];
int newDist = dist[u] + w;
if (newDist < dist[v]) {
dist[v] = newDist;
pq.offer(new int[]{newDist, v});
}
}
}
return dist;
}
경로 추적
최단 거리뿐만 아니라 경로도 필요하면 prev 배열을 유지합니다.
int[] prev = new int[n];
Arrays.fill(prev, -1);
// 갱신 시
if (newDist < dist[v]) {
dist[v] = newDist;
prev[v] = u; // v의 이전 노드는 u
pq.offer(new int[]{newDist, v});
}
// 경로 복원
public static List<Integer> getPath(int[] prev, int target) {
List<Integer> path = new ArrayList<>();
for (int node = target; node != -1; node = prev[node]) {
path.add(node);
}
Collections.reverse(path);
return path;
}
Dijkstra가 음수 간선에서 실패하는 이유
A --1--> B
| |
5 -4
| |
v v
C --2--> D
dist[B] = 1 (확정) → 하지만 A→C→D→B = 5+2+(-4) = 3... 아니, 이건 -4가 있어도 1이 더 짧네요.
실제 문제가 되는 경우:
A --1--> B --(-10)--> C
| ^
+--------5-------------+
A→B→C = 1+(-10) = -9
A→C = 5
Dijkstra: A→C를 5로 확정한 후 B를 처리 → A→B→C = -9인데 이미 확정됨!
Bellman-Ford 알고리즘
모든 간선을 V-1번 반복하여 최단 거리를 갱신합니다. 음수 간선을 처리할 수 있고, 음수 사이클도 탐지합니다.
핵심 아이디어
최단 경로는 최대 V-1개의 간선을 포함합니다. 따라서 모든 간선을 V-1번 완화(relax)하면 모든 최단 경로가 구해집니다.
public static int[] bellmanFord(int n, int[][] edges, int start) {
int[] dist = new int[n];
Arrays.fill(dist, Integer.MAX_VALUE);
dist[start] = 0;
// V-1번 반복
for (int i = 0; i < n - 1; i++) {
boolean updated = false;
for (int[] edge : edges) {
int u = edge[0], v = edge[1], w = edge[2];
if (dist[u] != Integer.MAX_VALUE && dist[u] + w < dist[v]) {
dist[v] = dist[u] + w;
updated = true;
}
}
if (!updated) break; // 조기 종료 (더 이상 갱신 없으면)
}
// 음수 사이클 탐지: V번째 반복에서도 갱신이 일어나면 음수 사이클 존재
for (int[] edge : edges) {
int u = edge[0], v = edge[1], w = edge[2];
if (dist[u] != Integer.MAX_VALUE && dist[u] + w < dist[v]) {
throw new IllegalStateException("음수 사이클이 존재합니다!");
}
}
return dist;
}
시간 복잡도
- O(VE): V-1번 반복 × E개 간선
- Dijkstra보다 느리지만, 음수 간선을 처리할 수 있습니다
SPFA (Shortest Path Faster Algorithm)
Bellman-Ford를 큐로 최적화한 버전입니다. 갱신된 노드만 큐에 넣어 불필요한 반복을 줄입니다.
public static int[] spfa(List<List<int[]>> graph, int start) {
int n = graph.size();
int[] dist = new int[n];
Arrays.fill(dist, Integer.MAX_VALUE);
dist[start] = 0;
boolean[] inQueue = new boolean[n];
Queue<Integer> queue = new LinkedList<>();
queue.offer(start);
inQueue[start] = true;
while (!queue.isEmpty()) {
int u = queue.poll();
inQueue[u] = false;
for (int[] edge : graph.get(u)) {
int v = edge[0], w = edge[1];
if (dist[u] + w < dist[v]) {
dist[v] = dist[u] + w;
if (!inQueue[v]) {
queue.offer(v);
inQueue[v] = true;
}
}
}
}
return dist;
}
평균적으로 Bellman-Ford보다 빠르지만, 최악의 경우 여전히 O(VE)입니다.
A* 알고리즘 (Dijkstra의 확장)
A*는 Dijkstra에 휴리스틱(추정 거리) 을 더한 알고리즘입니다.
// 우선순위 = 현재까지의 실제 거리 + 목표까지의 추정 거리
// f(n) = g(n) + h(n)
// g(n): 시작→n 실제 거리
// h(n): n→목표 추정 거리 (휴리스틱)
네비게이션에서는 직선 거리를 휴리스틱으로 사용합니다. 목표 방향으로의 탐색을 우선시하여 불필요한 탐색을 줄입니다.
음수 사이클의 의미
A --(-1)--> B --(-1)--> C --(-1)--> A
이 사이클을 돌 때마다 거리가 -3씩 줄어듦
→ 무한히 돌면 거리가 -∞
→ "최단 경로" 자체가 정의되지 않음
백엔드/실무에서의 연결
OSPF 라우팅
OSPF(Open Shortest Path First)는 인터넷 라우팅 프로토콜입니다. 각 라우터가 네트워크 토폴로지를 파악하고 Dijkstra를 실행 하여 최단 경로 트리를 구축합니다.
라우터 A → (cost 10) → 라우터 B → (cost 5) → 라우터 C
라우터 A → (cost 20) → 라우터 C
Dijkstra: A→B→C (cost 15) < A→C (cost 20)
→ C로의 패킷은 B를 경유
RIP 라우팅
RIP(Routing Information Protocol)은 Bellman-Ford 기반입니다. 각 라우터가 이웃에게 자신의 거리 벡터를 전파합니다.
네비게이션
Google Maps, Kakao 네비 등은 Dijkstra/A*의 변형을 사용합니다. 실제로는 도로 네트워크의 특성을 활용한 Contraction Hierarchies 같은 전처리 기법도 함께 사용합니다.
서비스 메쉬의 라우팅
MSA에서 서비스 간 호출 경로를 결정할 때, 지연 시간(latency)을 가중치로 한 최단 경로가 사용됩니다.
알고리즘 선택 가이드
가중치가 없는가? → BFS
가중치가 0 또는 1? → 0-1 BFS
양수 가중치만? → Dijkstra
음수 가중치 있음? → Bellman-Ford / SPFA
모든 쌍 최단 경로? → Floyd-Warshall
단일 목표 + 휴리스틱? → A*
주의할 점
음수 간선이 있는 그래프에서 Dijkstra를 사용하는 실수
Dijkstra는 음수 간선에서 최적해를 보장하지 않습니다. "이미 확정된 최단 거리"가 나중에 음수 간선으로 더 짧아질 수 있기 때문입니다. 음수 간선이 있으면 Bellman-Ford를 사용해야 합니다.
Bellman-Ford에서 음수 사이클 탐지를 빠뜨리는 실수
V-1번 반복 후에도 갱신이 발생하면 음수 사이클이 존재합니다. 이 체크를 빠뜨리면 무한 루프에 빠지거나 잘못된 결과를 반환합니다.
정리
| 알고리즘 | 시간 복잡도 | 음수 간선 | 음수 사이클 탐지 | 사용처 |
|---|---|---|---|---|
| Dijkstra | O((V+E) log V) | X | X | OSPF, 네비게이션 |
| Bellman-Ford | O(VE) | O | O | RIP, 음수 간선 그래프 |
| SPFA | O(VE) 최악 | O | O | Bellman-Ford 최적화 |
| A* | O(b^d) | X | X | 게임, 네비게이션 |
기억할 포인트:
- Dijkstra는 양수 가중치 그래프에서 가장 효율적인 단일 출발점 최단 경로 알고리즘입니다.
- Bellman-Ford는 ** 음수 간선 **을 처리할 수 있고, V번째 반복에서 갱신이 일어나면 ** 음수 사이클 **입니다.
- OSPF = Dijkstra, RIP = Bellman-Ford. 네트워크 라우팅 프로토콜이 이 알고리즘들의 실제 응용입니다.
- Dijkstra에서
d > dist[u]체크를 빼먹으면 성능이 크게 저하됩니다.