Algorithm Search⌘K

Edmonds-Johnson algorithm in Python

Solves the undirected Chinese postman problem by pairing the odd vertices with a minimum weight perfect matching on shortest path distances, then taking an Euler circuit.

File
edmonds_johnson_algorithm.py, 134 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
edmonds_johnson_algorithm.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/edmonds-johnson-algorithm/. The authors are listed in prov/Y06F76.jsonl.

import heapq

INF = float("inf")


def _dijkstra(inc: list[list[int]], edges: list[tuple[int, int, float]], s: int) -> tuple[list[float], list[int]]:
    """Shortest distances from s and the last edge of a shortest path to each vertex."""
    n = len(inc)
    dist = [INF] * n
    last = [-1] * n
    dist[s] = 0
    heap = [(0.0, s)]
    while heap:
        d, x = heapq.heappop(heap)
        if d > dist[x]:
            continue
        for e in inc[x]:
            a, b, w = edges[e]
            y = b if a == x else a
            if d + w < dist[y]:
                dist[y] = d + w
                last[y] = e
                heapq.heappush(heap, (dist[y], y))
    return dist, last


def _min_perfect_matching(k: int, cost: list[list[float]]) -> list[tuple[int, int]]:
    """Minimum weight perfect matching of k points (k even) by dynamic programming over
    subsets: the lowest unmatched point is paired with each other unmatched point.
    Time O(2^k k). This replaces the blossom algorithm, which is polynomial, so the whole
    implementation is exponential in the number of odd vertices and meant for small k."""
    full = (1 << k) - 1
    best = [INF] * (1 << k)
    choice = [0] * (1 << k)
    best[full] = 0
    for mask in range(full - 1, -1, -1):
        if bin(mask).count("1") % 2:
            continue
        i = (~mask & (mask + 1)).bit_length() - 1  # the lowest point not yet matched
        for j in range(i + 1, k):
            if not mask >> j & 1:
                nxt = mask | 1 << i | 1 << j
                c = cost[i][j] + best[nxt]
                if c < best[mask]:
                    best[mask] = c
                    choice[mask] = j
    pairs = []
    mask = 0
    while mask != full:
        i = (~mask & (mask + 1)).bit_length() - 1
        j = choice[mask]
        pairs.append((i, j))
        mask |= 1 << i | 1 << j
    return pairs


def _euler_circuit(n: int, ends: list[tuple[int, int]], start: int) -> tuple[list[int], list[int]]:
    """Hierholzer's algorithm on an undirected multigraph with all degrees even."""
    inc: list[list[int]] = [[] for _ in range(n)]
    for e, (a, b) in enumerate(ends):
        inc[a].append(e)
        if a != b:
            inc[b].append(e)
    used = [False] * len(ends)
    ptr = [0] * n
    stack = [(start, -1)]
    out: list[tuple[int, int]] = []
    while stack:
        x, via = stack[-1]
        while ptr[x] < len(inc[x]) and used[inc[x][ptr[x]]]:
            ptr[x] += 1
        if ptr[x] == len(inc[x]):
            out.append(stack.pop())
        else:
            e = inc[x][ptr[x]]
            used[e] = True
            a, b = ends[e]
            stack.append((b if a == x else a, e))
    out.reverse()
    return [v for v, _ in out], [e for _, e in out[1:]]


def edmonds_johnson_algorithm(
    n: int, edges: list[tuple[int, int, float]]
) -> tuple[float, list[int], list[int]] | None:
    """Solve the undirected Chinese postman problem.

    The vertices are 0 .. n-1 and edge i is edges[i] = (u, v, w) with weight w >= 0.
    Parallel edges, self-loops and isolated vertices are allowed. Returns (cost, walk,
    trail): a shortest closed walk that uses every edge at least once, with walk its
    vertices and trail its edge numbers (an edge may appear more than once), or None if
    the edges are not all in one component. Raises ValueError on a negative weight.
    Time O(k (n + m) log n) for the shortest paths plus O(2^k k) for the matching,
    where k is the number of odd vertices.
    """
    m = len(edges)
    if any(w < 0 for _, _, w in edges):
        raise ValueError("negative weights make the shortest covering walk undefined")
    if m == 0:
        return (0, [0], []) if n > 0 else (0, [], [])
    inc: list[list[int]] = [[] for _ in range(n)]
    deg = [0] * n
    for e, (u, v, _) in enumerate(edges):
        inc[u].append(e)
        if u != v:
            inc[v].append(e)
        deg[u] += 1
        deg[v] += 1
    start = edges[0][0]
    reach, _ = _dijkstra(inc, edges, start)
    if any(reach[u] == INF for u, _, _ in edges):  # @line L1
        return None
    odd = [v for v in range(n) if deg[v] % 2 == 1]  # @line L2
    k = len(odd)
    sp = [_dijkstra(inc, edges, t) for t in odd]  # @line L3
    cost = [[sp[i][0][odd[j]] for j in range(k)] for i in range(k)]
    pairs = _min_perfect_matching(k, cost)  # @line L4
    extra: list[int] = []  # @line L5
    for i, j in pairs:
        _, last = sp[i]
        x = odd[j]
        while x != odd[i]:  # walk the shortest path from odd[j] back to odd[i]
            e = last[x]
            extra.append(e)
            a, b, _ = edges[e]
            x = b if a == x else a
    ends = [(u, v) for u, v, _ in edges] + [(edges[e][0], edges[e][1]) for e in extra]
    walk, trail = _euler_circuit(n, ends, start)  # @line L6
    trail = [e if e < m else extra[e - m] for e in trail]
    total = sum(w for _, _, w in edges) + sum(edges[e][2] for e in extra)
    return total, walk, trail  # @line L7
test_edmonds_johnson_algorithm.py 88 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/edmonds-johnson-algorithm/. The authors are listed in prov/Y06F76.jsonl.

import heapq
import random

import pytest

from edmonds_johnson_algorithm import edmonds_johnson_algorithm

EXAMPLE = [(0, 1, 1), (1, 2, 2), (2, 3, 1), (3, 0, 3), (0, 2, 4), (1, 3, 5)]


def check_walk(n: int, edges: list[tuple[int, int, float]], walk: list[int], trail: list[int]) -> float:
    """Check that (walk, trail) is a closed walk using every edge; return its weight."""
    assert len(walk) == len(trail) + 1 and walk[0] == walk[-1]
    assert set(trail) == set(range(len(edges)))
    for i, e in enumerate(trail):
        u, v, _ = edges[e]
        assert sorted((u, v)) == sorted((walk[i], walk[i + 1]))
    return sum(edges[e][2] for e in trail)


def brute_force(n: int, edges: list[tuple[int, int, float]]) -> float | None:
    """Dijkstra over the states (vertex, set of edges used so far), from an end of edge 0."""
    m = len(edges)
    if m == 0:
        return 0
    full = (1 << m) - 1
    s = edges[0][0]
    dist = {(s, 0): 0.0}
    heap = [(0.0, s, 0)]
    while heap:
        d, x, mask = heapq.heappop(heap)
        if (x, mask) == (s, full):
            return d
        if d > dist[(x, mask)]:
            continue
        for e, (a, b, w) in enumerate(edges):
            if x in (a, b):
                y = b if a == x else a
                state = (y, mask | 1 << e)
                if d + w < dist.get(state, float("inf")):
                    dist[state] = d + w
                    heapq.heappush(heap, (d + w, y, mask | 1 << e))
    return None


def test_example() -> None:
    cost, walk, trail = edmonds_johnson_algorithm(4, EXAMPLE)
    assert cost == 18
    assert check_walk(4, EXAMPLE, walk, trail) == 18


def test_edge_cases() -> None:
    assert edmonds_johnson_algorithm(0, []) == (0, [], [])
    assert edmonds_johnson_algorithm(3, []) == (0, [0], [])
    # Eulerian input: nothing is repeated
    tri = [(0, 1, 2), (1, 2, 3), (2, 0, 4)]
    assert edmonds_johnson_algorithm(5, tri)[0] == 9  # isolated vertices 3 and 4
    # a self-loop alone, and a loop on a single edge
    assert edmonds_johnson_algorithm(1, [(0, 0, 5)]) == (5, [0, 0], [0])
    assert edmonds_johnson_algorithm(2, [(0, 1, 2), (1, 1, 1)])[0] == 5
    # parallel edges: two odd vertices joined three times
    assert edmonds_johnson_algorithm(2, [(0, 1, 1), (0, 1, 7), (0, 1, 3)])[0] == 12
    # K4 with unit weights
    k4 = [(a, b, 1) for a in range(4) for b in range(a + 1, 4)]
    assert edmonds_johnson_algorithm(4, k4)[0] == 8
    # edges in two components
    assert edmonds_johnson_algorithm(4, [(0, 1, 1), (2, 3, 1)]) is None
    with pytest.raises(ValueError):
        edmonds_johnson_algorithm(2, [(0, 1, -1)])


def test_random_against_brute_force() -> None:
    rng = random.Random(20261105)
    for _ in range(600):
        n = rng.randint(1, 6)
        m = rng.randint(0, 8)
        edges = [(rng.randrange(n), rng.randrange(n), rng.randint(0, 9)) for _ in range(m)]
        res = edmonds_johnson_algorithm(n, edges)
        expected = brute_force(n, edges)
        if expected is None:
            assert res is None
        else:
            assert res is not None and res[0] == expected
            if m:
                assert check_walk(n, edges, res[1], res[2]) == expected

What the next level needs

  • the tests did not run in this build

Read the full page about Edmonds-Johnson algorithm