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
# 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