← All structures

Dijkstra's Algorithm

Graphs · view source on GitHub

Read this in: English · Português · Español

Category: Graphs

The problem

A graph with weighted edges doesn't have a single "distance" between two nodes the way a grid does — the cheapest path might have more hops than a more expensive one. Brute-forcing every path between a source and every other node is combinatorially hopeless past a handful of nodes. What's needed is a way to build up the true shortest distance to every node incrementally, without ever re-examining a node once its shortest distance is known for certain.

The solution

Greedily settle the closest not-yet-settled node on every step, then relax (potentially lower) the tentative distance of each of its neighbors through it. Because every edge weight is non-negative, once a node is settled — its shortest distance is fixed — nothing settled later could ever offer it a cheaper path, since any such path would have to go through a node that's farther away than it already is. That single guarantee is the entire correctness argument, and it's also exactly why this algorithm breaks the moment a negative edge weight is allowed.

flowchart LR
    A((A)) -- 1 --> B((B))
    A -- 4 --> C((C))
    B -- 1 --> C
    B -- 5 --> D((D))
    C -- 1 --> D
    A -. 10 .-> D
Operation Cost Why
shortestPathFrom(source) O((V+E) log V) every node is settled once, every edge is relaxed once, each queue operation is O(log V)
single relaxation check O(1) amortized a map lookup plus a comparison

Classic example

classic/WeightedGraph is an undirected, non-negative-weight graph stored as a hand-rolled adjacency list, carrying shortestPathFrom(source) — Dijkstra's algorithm — as its core operation. The frontier is a plain java.util.PriorityQueue, a deliberate, documented exception to this repo's usual "no java.util shortcut" rule: the data structure this module showcases is the graph algorithm itself — Dijkstra's greedy relaxation strategy — not heap mechanics, which is its own separate structure with its own (future) dedicated module in this repo's roadmap. WeightedGraphTest covers an isolated unreachable node, a node whose shortest distance has to be relaxed downward more than once (forcing the algorithm to skip a stale, already-settled queue entry), and a worse alternate path that must not overwrite an already-better known distance.

Applied example: interbank settlement routing

applied/InterbankSettlementRouter routes a settlement across correspondent-bank rails — PIX, TED, and Boleto-style hops, each carrying its own fee — instead of assuming a single fixed path or the fewest hops. Moving funds from a source account to a destination rarely happens over one direct rail: it hops through intermediate correspondent accounts, and the cheapest chain of hops isn't always the one with the fewest hops or the cheapest first step. Modeling every known rail as a weighted edge and running Dijkstra from the source account finds the minimum-total-fee route in one pass. InterbankSettlementRouterTest covers a case where a two-hop correspondent route beats a more expensive direct rail, and a case where no known chain of rails reaches the destination at all.

Benchmark

./gradlew :graphs:dijkstra:jmh

Real run (JMH 1.37, JDK 26.0.2, 2 warmup + 3 measurement iterations, 1 fork). A random connected graph, edge density held at roughly 4 edges per node as node count scales up, so V and E grow together:

shortestPathFrom cost nodes=100 (~400 edges) nodes=1,000 (~4,000 edges) nodes=10,000 (~40,000 edges)
ns/op 34,839.05 ns 640,390.84 ns 18,694,286.03 ns

Cost grows noticeably faster than the node count alone (100x nodes -> ~536x slower), which tracks with what's actually being measured: both V and E scale together here (edge density held at ~4 per node), so the workload itself grows faster than V, and the lazy-deletion priority queue used here (no decrease-key; a relaxed node gets a fresh queue entry instead) means the queue can hold on the order of E entries rather than V, pushing the real constant closer to O(E log E) than the idealized O((V+E) log V). Either way, the shape is unmistakably far better-than-quadratic and worse-than-linear — exactly the "smarter than brute force, not free" territory this algorithm is supposed to occupy.

When not to use it

Test coverage

100% instruction coverage, 100% branch coverage (JaCoCo). Reproduce it yourself:

./gradlew :graphs:dijkstra:jacocoTestReport

Report at graphs/dijkstra/build/reports/jacoco/test/html/index.html.

Unit tests

src/test/java/com/datastructures/graphs/dijkstra/classic/WeightedGraphTest.java
package com.datastructures.graphs.dijkstra.classic;

import org.junit.jupiter.api.Test;

import java.util.Map;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class WeightedGraphTest {

    @Test
    void singleNodeGraphReachesOnlyItself() {
        WeightedGraph<String> graph = new WeightedGraph<>();
        graph.addNode("A");

        Map<String, Long> distances = graph.shortestPathFrom("A");

        assertThat(distances).containsExactly(Map.entry("A", 0L));
        assertThat(graph.nodeCount()).isEqualTo(1);
    }

    @Test
    void addEdgeRejectsANegativeWeight() {
        WeightedGraph<String> graph = new WeightedGraph<>();

        assertThatThrownBy(() -> graph.addEdge("A", "B", -1))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("-1");
    }

    @Test
    void aNodeWithNoPathToTheSourceIsAbsentFromTheResult() {
        WeightedGraph<String> graph = new WeightedGraph<>();
        graph.addEdge("A", "B", 1);
        graph.addNode("Z"); // isolated: no edge connects it to anything

        Map<String, Long> distances = graph.shortestPathFrom("A");

        assertThat(distances).containsOnlyKeys("A", "B");
        assertThat(distances).doesNotContainKey("Z");
    }

    /**
     * A graph shaped so Dijkstra must relax B's and D's tentative distance downward more than
     * once (forcing multiple, decreasing queue entries for the same node — i.e. stale entries
     * that get skipped once the node is already settled) before settling on the true shortest
     * distances.
     */
    @Test
    void dijkstraFindsTheTrueShortestDistancesThroughRelaxationAndSkipsStaleQueueEntries() {
        WeightedGraph<String> graph = new WeightedGraph<>();
        graph.addEdge("A", "B", 1);
        graph.addEdge("A", "C", 4);
        graph.addEdge("B", "C", 1);
        graph.addEdge("C", "D", 1);
        graph.addEdge("B", "D", 5);
        graph.addEdge("A", "D", 10);

        Map<String, Long> distances = graph.shortestPathFrom("A");

        assertThat(distances)
                .containsEntry("A", 0L)
                .containsEntry("B", 1L)
                .containsEntry("C", 2L)
                .containsEntry("D", 3L);
    }

    /**
     * B is settled before C (distance 1 < 2), and B has a very expensive edge to C. That
     * relaxation attempt must be rejected because it's worse than C's already-known distance —
     * the "not an improvement" branch of the relaxation check.
     */
    @Test
    void aWorseAlternatePathDoesNotOverwriteAnAlreadyBetterKnownDistance() {
        WeightedGraph<String> graph = new WeightedGraph<>();
        graph.addEdge("A", "B", 1);
        graph.addEdge("A", "C", 2);
        graph.addEdge("B", "C", 100);

        Map<String, Long> distances = graph.shortestPathFrom("A");

        assertThat(distances).containsEntry("C", 2L);
    }
}
src/test/java/com/datastructures/graphs/dijkstra/applied/InterbankSettlementRouterTest.java
package com.datastructures.graphs.dijkstra.applied;

import org.junit.jupiter.api.Test;

import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;

class InterbankSettlementRouterTest {

    private final BankAccount originAccount = new BankAccount("BANK-A", "0001");
    private final BankAccount correspondentAccount = new BankAccount("BANK-B", "0002");
    private final BankAccount destinationAccount = new BankAccount("BANK-C", "0003");
    private final BankAccount unreachableAccount = new BankAccount("BANK-Z", "9999");

    @Test
    void findsTheCheapestFeeAcrossACorrespondentBankHopEvenWhenADirectRailIsMoreExpensive() {
        InterbankSettlementRouter router = new InterbankSettlementRouter();
        router.registerRail(originAccount, destinationAccount, 900); // expensive direct PIX rail
        router.registerRail(originAccount, correspondentAccount, 100); // TED hop
        router.registerRail(correspondentAccount, destinationAccount, 150); // Boleto hop

        Optional<Long> cheapestFee = router.cheapestSettlementFee(originAccount, destinationAccount);

        assertThat(cheapestFee).contains(250L);
    }

    @Test
    void noKnownChainOfRailsReturnsAnEmptyResult() {
        InterbankSettlementRouter router = new InterbankSettlementRouter();
        router.registerRail(originAccount, correspondentAccount, 100);

        Optional<Long> cheapestFee = router.cheapestSettlementFee(originAccount, unreachableAccount);

        assertThat(cheapestFee).isEmpty();
    }
}

View full JaCoCo coverage report →