다익스트라는 source에서 각 정점까지 비음수 edge weight 합의 최솟값을 구한다. priority queue에서 현재 tentative distance가 가장 작은 정점을 꺼내 outgoing edge를 relaxation한다. 음수 edge가 하나라도 있으면 이미 확정했다고 믿은 거리가 나중에 더 작아질 수 있어 핵심 증명이 깨진다.
1. 문제 계약과 결과
정점 0..V-1, directed Edge(to,weight) adjacency와 source를 받는다. weight는 0 이상 long이다. 결과 distance는 도달 불가능하면 Long.MAX_VALUE, parent로 실제 경로를 복원한다. 범위 밖 edge와 음수 weight는 예외다.
distance와 weight를 int로 두면 긴 경로 합이 wraparound해 더 짧은 음수처럼 보일 수 있다. long도 overflow할 수 있으므로 current > Long.MAX_VALUE-weight이면 그 후보 덧셈을 건너뛴다. 도메인에서 그 경로를 오류로 보아야 한다면 예외 정책으로 바꾼다.
2. relaxation 상태 추적
edge 0->1(4), 0->2(1), 2->1(2), 1->3(1) 일부를 본다.
| poll | 현재 거리 | relaxation | distance 변화 | PQ |
|---|---|---|---|---|
| 0 | 0 | 0→1, 0→2 | d1=4,d2=1 | (2,1),(1,4) |
| 2 | 1 | 2→1 | d1:4→3 | (1,3),(1,4) |
| 1 | 3 | 1→3 | d3=4 | (1,4 stale),(3,4) |
| 1 | 4 | stale 검사 | 무시 | (3,4) |
Java PriorityQueue에는 decrease-key API가 없다. 더 짧은 candidate를 새 Node로 넣고, 꺼낼 때 현재 distance[vertex]와 다르면 stale entry로 버린다.
3. 불변식과 거리 확정 증명
불변식 1: distance[v]는 지금까지 발견한 source→v 경로 중 최소 길이다. relaxation이 실제 edge를 붙인 경로만 기록하므로 항상 유효한 경로 길이다.
불변식 2: non-stale 최소 Node (u,d)를 꺼낼 때 d보다 짧은 미발견 source→u 경로는 없다. 있다고 가정하고 그 경로에서 확정 집합을 처음 벗어나는 edge x->y를 잡자. x까지는 이미 처리됐고 weight가 비음수이므로 y의 candidate는 전체 경로 길이 이하로 PQ에 들어갔어야 한다. 그런데 u가 최소로 먼저 나왔다는 사실과 모순이다.
따라서 non-stale poll의 거리는 확정 가능하다. 음수 edge가 있으면 x 이후의 suffix가 거리를 낮출 수 있어 이 부등식이 성립하지 않는다.
실행 재현
아래 코드는 설명용 조각이 아니라 main에 정상·빈 입력·중복·경계·실패 계약을 함께 넣은 완전한 Java 21 프로그램이다.
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.PriorityQueue;
public final class DijkstraDemo {
record Edge(int to, long weight) {}
record Node(int vertex, long distance) implements Comparable<Node> {
public int compareTo(Node other) { return Long.compare(distance, other.distance); }
}
record Result(long[] distance, int[] parent) {
List<Integer> pathTo(int target) {
if (target < 0 || target >= distance.length) throw new IllegalArgumentException("invalid target");
if (distance[target] == Long.MAX_VALUE) return List.of();
List<Integer> reversed = new ArrayList<>();
for (int at = target; at != -1; at = parent[at]) reversed.add(at);
Collections.reverse(reversed);
return List.copyOf(reversed);
}
}
static Result shortestPaths(List<List<Edge>> graph, int source) {
if (graph == null || source < 0 || source >= graph.size()) throw new IllegalArgumentException("invalid graph/source");
long[] distance = new long[graph.size()];
int[] parent = new int[graph.size()];
Arrays.fill(distance, Long.MAX_VALUE);
Arrays.fill(parent, -1);
PriorityQueue<Node> pq = new PriorityQueue<>();
distance[source] = 0;
pq.offer(new Node(source, 0));
while (!pq.isEmpty()) {
Node current = pq.poll();
if (current.distance() != distance[current.vertex()]) continue;
for (Edge edge : graph.get(current.vertex())) {
if (edge.to() < 0 || edge.to() >= graph.size()) throw new IllegalArgumentException("invalid edge");
if (edge.weight() < 0) throw new IllegalArgumentException("negative edge");
if (current.distance() > Long.MAX_VALUE - edge.weight()) continue;
long candidate = current.distance() + edge.weight();
if (candidate < distance[edge.to()]) {
distance[edge.to()] = candidate;
parent[edge.to()] = current.vertex();
pq.offer(new Node(edge.to(), candidate));
}
}
}
return new Result(distance, parent);
}
static List<List<Edge>> graph(int vertices, long[][] edges) {
List<List<Edge>> graph = new ArrayList<>();
for (int i = 0; i < vertices; i++) graph.add(new ArrayList<>());
for (long[] edge : edges) graph.get((int) edge[0]).add(new Edge((int) edge[1], edge[2]));
return graph;
}
static void check(boolean condition, String message) {
if (!condition) throw new AssertionError(message);
}
public static void main(String[] args) {
List<List<Edge>> graph = graph(6, new long[][] {{0,1,4},{0,2,1},{2,1,2},{1,3,1},{2,3,5},{3,4,3}});
Result result = shortestPaths(graph, 0);
check(result.distance()[4] == 7, "distance");
check(result.pathTo(4).equals(List.of(0, 2, 1, 3, 4)), "path");
check(result.distance()[5] == Long.MAX_VALUE, "unreachable");
try {
shortestPaths(graph(2, new long[][] {{0,1,-1}}), 0);
throw new AssertionError("negative edge expected");
} catch (IllegalArgumentException expected) {
check(expected.getMessage().equals("negative edge"), "negative reason");
}
System.out.println("distance(4)=7, path=" + result.pathTo(4) + ", negative=rejected");
}
}
javac --release 21 DijkstraDemo.java
java DijkstraDemo
직접 실행한 출력은 다음과 같다.
distance(4)=7, path=[0, 2, 1, 3, 4], negative=rejected
컴파일러와 런타임은 javac 21.0.11, OpenJDK 21.0.11을 사용했다. assert 옵션에 의존하지 않고 실패 시 AssertionError를 던지므로 위 명령 그대로 검증된다.
4. 음수 반례
0->A(2), 0->B(5), B->A(-10)에서 A=2를 먼저 확정하면 나중 경로 0->B->A=-5가 더 짧다. 예제는 음수 edge를 발견 즉시 거절한다. 음수 edge가 있고 음수 cycle이 없을 때는 Bellman-Ford, DAG라면 위상 순서 relaxation을 검토한다.
5. 시간·공간 복잡도
binary heap PriorityQueue에서 offer/poll은 API 구현 note상 이다. decrease-key 대신 edge relaxation 성공마다 새 entry를 넣으므로 PQ entry가 까지 생길 수 있다. 각 edge를 한 번 검사하고 각 성공 offer/poll이 로그 비용을 가져 거친 bound는 , 흔히 계열로 표기할 때는 heap 크기/구현 가정을 밝혀야 한다.
distance와 parent는 , adjacency는 입력 , PQ는 stale entry 포함 최악 다. “Dijkstra 보조 공간 ”라고 단정하면 이 Java 구현과 맞지 않는다.
6. JVM allocation과 comparator
각 new Node(vertex,candidate) record는 heap allocation 후보이고 PriorityQueue backing reference array도 heap에 있다. stale 전략은 구현을 단순화하지만 successful relaxation 수만큼 임시 Node를 만들어 allocation/GC pressure가 될 수 있다. escape analysis로 실제 할당이 제거될지는 JVM 최적화 결과이지 Java 계약이 아니다. JFR allocation event로 확인한다.
compareTo는 Long.compare를 사용한다. (int)(distance-other.distance) 같은 뺄셈 comparator는 overflow와 comparator 계약 위반을 일으킬 수 있다.
7. BFS·Bellman-Ford·A*와 비교
| 입력/목표 | 선택 | 핵심 조건 |
|---|---|---|
| 모든 weight 동일 | BFS | FIFO layer가 더 단순 |
| 비음수 weight | Dijkstra | 최소 tentative 확정 |
| 음수 edge, 음수 cycle 검출 | Bellman-Ford | 반복 relaxation |
| 단일 target + admissible heuristic | A* | heuristic 품질/정당성 |
| DAG weight | topological relaxation | cycle 없음 |
A*는 “더 빠른 Dijkstra”가 자동으로 아니다. heuristic이 admissible/consistent해야 최적성 계약을 지키며 도메인별 설계가 필요하다.
8. 실제 도메인 적용
network routing 비용, road travel time snapshot, service dependency의 누적 latency 후보에 쓸 수 있다. weight가 request 중 바뀌면 계산 snapshot 일관성이 깨진다. 관측 지표는 visited vertices, relaxations, stale poll ratio, max PQ size, query timeout, graph version이다. 음수 rebate/credit가 있는 가격 graph에는 그대로 쓰지 않는다.
9. 이건 피한다
- 음수 edge를 허용한다. 거리 확정 증명이 깨져 오답이 된다. 입력 검증 후 Bellman-Ford/DAG 알고리즘을 선택한다.
- distance를 int로 둔다. 합 overflow가 더 짧은 경로처럼 보인다. long과 덧셈 overflow guard를 쓴다.
- stale entry를 처리하면서도 무조건 edge를 다시 훑는다. CPU와 allocation이 증가한다. 현재 distance와 다르면 즉시 continue한다.
- PriorityQueue iterator가 정렬됐다고 믿는다. API는 iterator 순서를 보장하지 않는다. 순차 최소 추출은 poll을 사용한다.
- 동적 weight를 한 snapshot처럼 계산한다. 경로가 어느 시점에도 존재하지 않는 조합이 될 수 있다. graph version/snapshot을 고정한다.
Reference
- E. W. Dijkstra, A note on two problems in connexion with graphs, Numerische Mathematik 1, 1959
- Robert Sedgewick, Kevin Wayne, Algorithms, 4th Edition — Shortest Paths
- Oracle, PriorityQueue — Java SE 21 API
- Oracle, The Java Language Specification, Java SE 21 — Integer Operations
- Oracle, The Java Virtual Machine Specification, Java SE 21 — Heap and Frames
댓글