Algorithm Search⌘K

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
rnc_perfect_matching.pyRawGitHub
# 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