Deep DiveDijkstra 1959 원 논문·Princeton Algorithms 4/e shortest paths·Java SE 21 PriorityQueue API를 대조하고 DijkstraDemo를 OpenJDK 21.0.11에서 실행
검증 근거 보기

Dijkstra 1959 원 논문·Princeton Algorithms 4/e shortest paths·Java SE 21 PriorityQueue API를 대조하고 DijkstraDemo를 OpenJDK 21.0.11에서 실행

다익스트라: 비음수 edge에서 거리 확정을 증명하는 Java 최단 경로

relaxation·최소 우선순위·stale entry가 만드는 거리 불변식을 증명하고, 음수 반례·long overflow·객체 allocation·대안 선택까지 추적한다.

On this page목차 11원문 Markdown ↗

다익스트라는 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현재 거리relaxationdistance 변화PQ
000→1, 0→2d1=4,d2=1(2,1),(1,4)
212→1d1:4→3(1,3),(1,4)
131→3d3=4(1,4 stale),(3,4)
14stale 검사무시(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상 O(logn)O(\log n)이다. decrease-key 대신 edge relaxation 성공마다 새 entry를 넣으므로 PQ entry가 O(E)O(E)까지 생길 수 있다. 각 edge를 한 번 검사하고 각 성공 offer/poll이 로그 비용을 가져 거친 bound는 O((V+E)logE)O((V+E)\log E), 흔히 O((V+E)logV)O((V+E)\log V) 계열로 표기할 때는 heap 크기/구현 가정을 밝혀야 한다.

distance와 parent는 O(V)O(V), adjacency는 입력 O(V+E)O(V+E), PQ는 stale entry 포함 최악 O(E)O(E)다. “Dijkstra 보조 공간 O(V)O(V)”라고 단정하면 이 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로 확인한다.

compareToLong.compare를 사용한다. (int)(distance-other.distance) 같은 뺄셈 comparator는 overflow와 comparator 계약 위반을 일으킬 수 있다.

7. BFS·Bellman-Ford·A*와 비교

입력/목표선택핵심 조건
모든 weight 동일BFSFIFO layer가 더 단순
비음수 weightDijkstra최소 tentative 확정
음수 edge, 음수 cycle 검출Bellman-Ford반복 relaxation
단일 target + admissible heuristicA*heuristic 품질/정당성
DAG weighttopological relaxationcycle 없음

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

대화

댓글

0
댓글을 불러오는 중입니다.