Algorithm Search⌘K

Jonker-Volgenant algorithm in Python

A fast shortest-augmenting-path solver for the linear assignment problem.

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

from collections import deque


def jonker_volgenant_algorithm(
    cost: list[list[float]], reduction_passes: int = 2
) -> tuple[list[int], list[float], list[float]]:
    """Minimum-cost perfect assignment of an n x n cost matrix (LAPJV, dense form).

    The graph is the complete bipartite graph of rows and columns, with weights
    cost[i][j], which may be negative. Returns (x, u, v): x[i] is the column of
    row i, and u, v are dual values with u[i] + v[j] <= cost[i][j] for all i, j,
    and equality on the pairs (i, x[i]). The total cost is sum(u) + sum(v).
    reduction_passes is the number of passes of augmenting row reduction (2 in
    the paper); 0 skips it, and the augmentation phase then does all the work.
    Time O(n^3), extra space O(n). Exact for integer costs.
    """
    n = len(cost)
    if any(len(row) != n for row in cost):
        raise ValueError("the cost matrix must be square")
    if n == 0:
        return [], [], []
    if n == 1:
        return [0], [cost[0][0]], [0]
    c = cost
    x: list[int] = [-1] * n  # @line L1
    y: list[int] = [-1] * n
    v: list[float] = [0] * n
    times = [0] * n

    # Column reduction.
    for j in range(n - 1, -1, -1):  # @line L2
        i_min = min(range(n), key=lambda i: c[i][j])  # @line L3
        v[j] = c[i_min][j]
        times[i_min] += 1
        if x[i_min] < 0:  # @line L4
            x[i_min] = j
            y[j] = i_min

    # Reduction transfer.
    free: list[int] = []
    for i in range(n):  # @line L5
        if times[i] == 0:
            free.append(i)
        elif times[i] == 1:
            j1 = x[i]
            v[j1] -= min(c[i][j] - v[j] for j in range(n) if j != j1)  # @line L6

    # Augmenting row reduction, two passes, at most 2n bids per pass.
    for _ in range(reduction_passes):  # @line L7
        todo = deque(free)
        free = []
        bids = 0
        while todo:
            i = todo.popleft()  # @line L8
            if bids >= 2 * n:
                free.append(i)
                continue
            bids += 1
            j1, j2 = -1, -1
            a = b = None
            for j in range(n):
                h = c[i][j] - v[j]
                if a is None or h < a:
                    b, j2 = a, j1
                    a, j1 = h, j
                elif b is None or h < b:
                    b, j2 = h, j
            i0 = y[j1]
            if a < b:  # @line L9
                v[j1] -= b - a
            elif i0 >= 0:
                j1 = j2
                i0 = y[j1]
            x[i] = j1  # @line L10
            y[j1] = i
            if i0 >= 0:
                x[i0] = -1
                if a < b:
                    todo.appendleft(i0)
                else:
                    free.append(i0)

    # Augmentation by shortest paths.
    d: list[float] = [0] * n
    pred = [0] * n
    for f in free:  # @line L11
        for j in range(n):  # @line L12
            d[j] = c[f][j] - v[j]
            pred[j] = f
        cols = list(range(n))  # cols[:low] scanned, cols[low:up] at distance mu, cols[up:] the rest
        low = up = 0
        mu: float = 0
        end = -1
        while end < 0:  # @line L13
            if low == up:
                mu = min(d[cols[k]] for k in range(low, n))  # @line L14
                for k in range(low, n):
                    j = cols[k]
                    if d[j] == mu:
                        cols[k], cols[up] = cols[up], j
                        up += 1
                for k in range(low, up):  # @line L15
                    if y[cols[k]] < 0:
                        end = cols[k]
                        break
                if end >= 0:
                    break
            j1 = cols[low]  # @line L16
            low += 1
            i = y[j1]
            base = c[i][j1] - v[j1] - mu
            k = up
            while k < n:  # @line L17
                j = cols[k]
                h = c[i][j] - v[j] - base
                if h < d[j]:
                    d[j] = h
                    pred[j] = i
                    if h == mu:
                        if y[j] < 0:
                            end = j
                            break
                        cols[k], cols[up] = cols[up], j
                        up += 1
                k += 1
        for k in range(low):  # @line L18
            j = cols[k]
            v[j] += d[j] - mu
        while True:  # @line L19
            i = pred[end]
            y[end] = i
            end, x[i] = x[i], end
            if i == f:
                break
    u = [c[i][x[i]] - v[x[i]] for i in range(n)]
    return x, u, v  # @line L20
test_jonker_volgenant_algorithm.py 79 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/jonker-volgenant-algorithm/. The authors are listed in prov/TN0EFM.jsonl.

import itertools
import random

from jonker_volgenant_algorithm import jonker_volgenant_algorithm


def check(cost, passes=2):
    """The result is a permutation with a feasible dual of equal value. Returns its cost."""
    n = len(cost)
    x, u, v = jonker_volgenant_algorithm(cost, passes)
    assert sorted(x) == list(range(n))
    for i in range(n):
        for j in range(n):
            assert u[i] + v[j] <= cost[i][j]
        assert u[i] + v[x[i]] == cost[i][x[i]]
    total = sum(cost[i][x[i]] for i in range(n))
    assert total == sum(u) + sum(v)
    return total


def brute(cost):
    n = len(cost)
    return min((sum(cost[i][p[i]] for i in range(n)) for p in itertools.permutations(range(n))), default=0)


def test_empty_and_one():
    assert jonker_volgenant_algorithm([]) == ([], [], [])
    assert check([[7]]) == 7
    assert check([[-3]]) == -3


def test_worked_example():
    cost = [[4, 1, 3], [2, 0, 5], [3, 2, 2]]
    assert check(cost) == 5
    assert jonker_volgenant_algorithm(cost)[0] == [1, 0, 2]


def test_all_equal_and_ties():
    assert check([[5] * 4 for _ in range(4)]) == 20
    assert check([[0, 0], [0, 0]]) == 0


def test_forbidden_pairs_by_large_cost():
    # a large cost stands for a missing edge; this graph has no perfect matching
    # of finite pairs, so the optimum must use one large pair
    big = 10**6
    cost = [[1, big, big], [2, big, big], [big, 3, 4]]
    assert check(cost) == brute(cost) == 1 + big + 3


def test_not_square():
    try:
        jonker_volgenant_algorithm([[1, 2]])
    except ValueError:
        return
    raise AssertionError("expected ValueError")


def test_random_against_brute_force():
    rng = random.Random(1987)
    for _ in range(1500):
        n = rng.randint(0, 6)
        hi = rng.choice([1, 3, 10, 1000])
        cost = [[rng.randint(-hi, hi) for _ in range(n)] for _ in range(n)]
        best = brute(cost)
        for passes in (0, 1, 2):
            assert check(cost, passes) == best


def test_larger_random_dual_certificate():
    rng = random.Random(5)
    for _ in range(20):
        n = rng.randint(20, 60)
        cost = [[rng.randint(0, 50) for _ in range(n)] for _ in range(n)]
        check(cost)
        check(cost, 0)

What the next level needs

  • the tests did not run in this build

Read the full page about Jonker-Volgenant algorithm