Randomized parallel perfect matching in Python
Decides and finds perfect matchings in randomized NC using the Tutte matrix determinant.
- File
rnc_perfect_matching.py, 103 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/rnc-perfect-matching/. The authors are listed in prov/7TA0S6.jsonl.
import random
from fractions import Fraction
def rnc_perfect_matching(
n: int, edges: list[tuple[int, int]], trials: int = 20, rng: random.Random | None = None
) -> list[int] | None:
"""A perfect matching of a general graph, unweighted, by the Mulmuley-Vazirani-Vazirani method.
The vertices are 0 .. n-1 and edges lists undirected edges (no loops; repeats
are ignored). Returns mate with mate[v] the partner of v, or None. A returned
matching is always checked and correct. None is always correct when the graph
has no perfect matching; when it has one, None is returned with probability
at most 2^(-trials). This is a sequential simulation: each trial does exact
integer linear algebra, which the parallel algorithm does in O(log^2 n) depth.
"""
rng = rng or random.Random()
adj: set[tuple[int, int]] = set()
for a, b in edges:
if a == b or not (0 <= a < n and 0 <= b < n):
raise ValueError("edges must join two different vertices in range")
adj.add((min(a, b), max(a, b)))
pairs = sorted(adj)
m = len(pairs)
for _ in range(trials): # @line L1
w = {e: rng.randint(1, max(1, 2 * m)) for e in pairs} # @line L2
mat = [[0] * n for _ in range(n)] # @line L3
for (i, j), wt in w.items():
mat[i][j] = 1 << wt
mat[j][i] = -(1 << wt)
det = _determinant(mat) # @line L4
if det == 0:
continue
twice_w = _two_adic_valuation(det) # @line L5
if twice_w % 2:
continue
adjugate = _adjugate(mat, det) # @line L6
mate = [-1] * n
ok = True
for (i, j), wt in w.items(): # @line L7
val = abs(adjugate[j][i]) << wt
if val % (1 << twice_w) == 0 and (val >> twice_w) % 2 == 1:
if mate[i] >= 0 or mate[j] >= 0:
ok = False
break
mate[i], mate[j] = j, i
if ok and all(x >= 0 for x in mate): # @line L8
return mate
return None # @line L9
def _two_adic_valuation(x: int) -> int:
"""The largest t with 2^t dividing the nonzero integer x."""
x = abs(x)
return (x & -x).bit_length() - 1
def _determinant(a: list[list[int]]) -> int:
"""Exact determinant of an integer matrix by Bareiss fraction-free elimination."""
n = len(a)
m = [row[:] for row in a]
sign, prev = 1, 1
for k in range(n - 1):
if m[k][k] == 0:
for r in range(k + 1, n):
if m[r][k] != 0:
m[k], m[r] = m[r], m[k]
sign = -sign
break
else:
return 0
for i in range(k + 1, n):
for j in range(k + 1, n):
m[i][j] = (m[i][j] * m[k][k] - m[i][k] * m[k][j]) // prev
prev = m[k][k]
return sign * m[n - 1][n - 1] if n else 1
def _adjugate(a: list[list[int]], det: int) -> list[list[int]]:
"""adj(a) = det(a) * a^(-1) for an invertible integer matrix, by exact Gauss-Jordan."""
n = len(a)
m = [[Fraction(x) for x in row] + [Fraction(int(i == j)) for j in range(n)] for i, row in enumerate(a)]
for k in range(n):
p = next(r for r in range(k, n) if m[r][k] != 0)
m[k], m[p] = m[p], m[k]
piv = m[k][k]
m[k] = [x / piv for x in m[k]]
for r in range(n):
if r != k and m[r][k] != 0:
f = m[r][k]
m[r] = [x - f * y for x, y in zip(m[r], m[k])]
out = []
for i in range(n):
row = []
for j in range(n):
v = m[i][n + j] * det
assert v.denominator == 1
row.append(int(v))
out.append(row)
return out
test_rnc_perfect_matching.py 115 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/rnc-perfect-matching/. The authors are listed in prov/7TA0S6.jsonl.
import itertools
import random
from rnc_perfect_matching import _adjugate, _determinant, rnc_perfect_matching
def has_perfect_matching(n, edges):
"""Oracle: match the lowest vertex with each neighbour in turn."""
adj = {v: set() for v in range(n)}
for a, b in edges:
adj[a].add(b)
adj[b].add(a)
def rec(left):
if not left:
return True
v = min(left)
return any(rec(left - {v, u}) for u in adj[v] if u in left)
return rec(frozenset(range(n)))
def check(n, edges, seed):
mate = rnc_perfect_matching(n, edges, rng=random.Random(seed))
exists = has_perfect_matching(n, edges)
if mate is None:
assert not exists
return None
assert exists
es = {(min(a, b), max(a, b)) for a, b in edges}
for v in range(n):
assert mate[mate[v]] == v and mate[v] != v
assert (min(v, mate[v]), max(v, mate[v])) in es
return mate
def test_empty_graph():
assert rnc_perfect_matching(0, []) == []
assert rnc_perfect_matching(2, [], trials=3) is None
def test_odd_cycles_and_odd_order():
triangle = [(0, 1), (1, 2), (2, 0)]
assert check(3, triangle, 1) is None
c5 = [(i, (i + 1) % 5) for i in range(5)]
assert check(5, c5, 2) is None
# two triangles joined by an edge: 6 vertices, perfect matching through the bridge
g = triangle + [(3, 4), (4, 5), (5, 3), (2, 3)]
assert check(6, g, 3) is not None
def test_no_perfect_matching_even_order():
# a center joined to three triangles: 10 vertices, deleting the center leaves 3 odd parts
edges = []
for t in range(3):
a, b, c = 1 + 3 * t, 2 + 3 * t, 3 + 3 * t
edges += [(a, b), (b, c), (c, a), (0, a)]
assert check(10, edges, 4) is None
def test_worked_example():
# the 4-cycle 0-1-2-3 plus the chord 0-2
edges = [(0, 1), (1, 2), (2, 3), (3, 0), (0, 2)]
assert check(4, edges, 5) is not None
def test_petersen_graph():
outer = [(i, (i + 1) % 5) for i in range(5)]
spokes = [(i, i + 5) for i in range(5)]
inner = [(5 + i, 5 + (i + 2) % 5) for i in range(5)]
assert check(10, outer + spokes + inner, 6) is not None
def test_determinant_and_adjugate():
rng = random.Random(9)
for _ in range(200):
n = rng.randint(1, 5)
a = [[rng.randint(-4, 4) for _ in range(n)] for _ in range(n)]
det = sum(
(-1) ** sum(1 for i in range(n) for j in range(i) if p[j] > p[i])
* eval("*".join(str(a[i][p[i]]) for i in range(n)) or "1")
for p in itertools.permutations(range(n))
)
assert _determinant(a) == det
if det != 0:
adj = _adjugate(a, det)
for i in range(n):
for j in range(n):
assert sum(a[i][k] * adj[k][j] for k in range(n)) == (det if i == j else 0)
def test_bad_input():
for bad in ([(0, 0)], [(0, 5)]):
try:
rnc_perfect_matching(2, bad)
except ValueError:
continue
raise AssertionError("expected ValueError")
def test_random_against_brute_force():
rng = random.Random(1987)
found = none = 0
for case in range(600):
n = rng.randint(0, 10)
p = rng.random()
edges = [(a, b) for a in range(n) for b in range(a + 1, n) if rng.random() < p]
if check(n, edges, case) is None:
none += 1
else:
found += 1
assert found > 100 and none > 100
What the next level needs
- the tests did not run in this build
Read the full page about Randomized parallel perfect matching