Algorithm Search⌘K

Micali-Vazirani algorithm in Python

Finds maximum matchings in general graphs in O(E√V) time.

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

INF = float("inf")


def micali_vazirani_algorithm(n: int, edges: list[tuple[int, int]]) -> list[int]:
    """A maximum matching of a general graph without weights.

    The vertices are 0, ..., n - 1. The result is the list of mates, with -1
    for an unmatched vertex. Each phase finds a maximal set of vertex-disjoint
    shortest augmenting paths, as in the algorithm of Micali and Vazirani.
    """
    if n < 0:
        raise ValueError("n must be non-negative")
    adj: list[list[int]] = [[] for _ in range(n)]
    seen = set()
    for u, v in edges:
        if not (0 <= u < n and 0 <= v < n):
            raise ValueError("edge endpoint out of range")
        if u == v:
            raise ValueError("self-loops are not allowed")
        key = (min(u, v), max(u, v))
        if key not in seen:
            seen.add(key)
            adj[u].append(v)
            adj[v].append(u)
    mate = [-1] * n
    while _Phase(n, adj, mate).run():  # @line L1
        pass
    return mate  # @line L10


class _Phase:
    """One phase: level search, bridges, double depth-first search, augmentation."""

    def __init__(self, n, adj, mate):
        self.n, self.adj, self.mate = n, adj, mate
        self.lev = [[INF, INF] for _ in range(n)]  # [evenlevel, oddlevel]
        self.pred = [[] for _ in range(n)]
        self.succ = [[] for _ in range(n)]
        self.pred_left = [0] * n
        self.erased = [False] * n
        self.scanned = set()
        self.cand: dict[int, list[int]] = {}
        self.bridges: dict[int, list[tuple[int, int]]] = {}
        self.pending = [[] for _ in range(n)]  # bridges waiting for a level of this vertex
        self.bud = [-1] * n  # bud of the petal of a vertex, -1 if in no petal
        self.petal_of = [-1] * n
        self.petals = []  # (bud, red endpoint, green endpoint, parity of bridge, arrivals at bud)
        self.color = [0] * n  # 1 red, 2 green, inside a petal
        self.tree = [None] * n  # (parent, witness predecessor) inside the petal's DFS tree
        self.star = list(range(n))  # union-find for bud*

    # ---- levels -------------------------------------------------------
    def minlevel(self, v):
        return min(self.lev[v])

    def set_level(self, v, parity, value):
        self.lev[v][parity] = value
        self.cand.setdefault(value, []).append(v)
        waiting, self.pending[v] = self.pending[v], []
        for u, w, p in waiting:
            self.add_bridge(u, w, p)

    def add_bridge(self, u, w, parity):
        """Insert the bridge uw (parity 0: unmatched, 1: matched) once both levels are known."""
        a, b = self.lev[u][parity], self.lev[w][parity]
        if a == INF:
            self.pending[u].append((u, w, parity))
        elif b == INF:
            self.pending[w].append((u, w, parity))
        else:
            self.bridges.setdefault((a + b) // 2, []).append((u, w))

    def find(self, v):
        root = v
        while self.star[root] != root:
            root = self.star[root]
        while self.star[v] != root:
            self.star[v], v = root, self.star[v]
        return root

    # ---- the phase ----------------------------------------------------
    def run(self):
        for v in range(self.n):  # @line L2
            if self.mate[v] == -1:
                self.set_level(v, 0, 0)
        for i in range(2 * self.n + 2):  # @line L3
            self.min_step(i)  # @line L4
            found = self.max_step(i)  # @line L5
            if found:  # @line L9
                return True
        return False

    def min_step(self, i):
        par = i % 2
        for u in self.cand.get(i, []):
            if par == 0:
                nbrs = [v for v in self.adj[u] if v != self.mate[u]]
            else:
                nbrs = [self.mate[u]] if self.mate[u] != -1 else []
            for v in nbrs:
                key = (min(u, v), max(u, v))
                if key in self.scanned:
                    continue
                self.scanned.add(key)
                if self.minlevel(v) >= i + 1:  # a prop
                    if self.lev[v][1 - par] == INF:
                        self.set_level(v, 1 - par, i + 1)
                    self.pred[v].append(u)
                    self.succ[u].append(v)
                    self.pred_left[v] += 1
                else:  # a bridge
                    self.add_bridge(u, v, par)

    def max_step(self, i):
        found = False
        lst = self.bridges.get(i, [])
        k = 0
        while k < len(lst):
            u, v = lst[k]
            k += 1
            if self.erased[u] or self.erased[v]:
                continue
            parity = 0 if self.mate[u] != v else 1
            result = self.ddfs(u, v)
            if result is None:
                continue
            if result[0] == "petal":  # @line L6
                self.make_petal(i, u, v, parity, result)
            else:  # @line L7
                path = self.augmenting_path(u, v, parity, result)
                self.augment(path)  # @line L8
                found = True
        return found

    # ---- double depth-first search -----------------------------------
    def ddfs(self, s_red, s_green):
        r0, g0 = self.find(s_red), self.find(s_green)
        if r0 == g0 or self.erased[r0] or self.erased[g0]:
            return None
        color = {r0: 1, g0: 2}
        parent = {r0: None, g0: None}
        stack = {1: [r0], 2: [g0]}
        ptr = {}
        lv = self.minlevel

        def step(side):
            """One DFS move. Returns ('met', w, arrival) on a meeting, None otherwise."""
            st = stack[side]
            c = st[-1]
            preds = self.pred[c]
            j = ptr.get(c, 0)
            while j < len(preds):
                p = preds[j]
                j += 1
                ptr[c] = j
                if self.erased[p]:
                    continue
                w = self.find(p)
                if self.erased[w]:
                    continue
                if w not in color:
                    color[w] = side
                    parent[w] = (c, p)
                    st.append(w)
                    return None
                other = stack[3 - side]
                if other and other[-1] == w:
                    return ("met", w, (c, p))
            ptr[c] = j
            st.pop()
            return None

        def search(side, w):
            """Backtrack from w and look for another vertex at level <= level(w)."""
            st = stack[side]
            while st:
                c = st[-1]
                if c != w and lv(c) <= lv(w):
                    return True
                step(side)
            return False

        while True:
            cr, cg = stack[1][-1], stack[2][-1]
            if cr != cg and lv(cr) == 0 and lv(cg) == 0:
                return ("path", parent, cr, cg)
            side = 1 if lv(cr) >= lv(cg) else 2
            res = step(side)
            if not stack[side]:
                raise AssertionError("a DFS lost its stack outside a meeting")
            if res is None:
                continue
            _, w, arrival = res
            owner = 3 - side
            arr = {side: arrival, owner: parent[w]}
            stack[owner].pop()
            if search(2, w):
                color[w], parent[w] = 1, arr[1]
                stack[1].append(w)
            elif search(1, w):
                color[w], parent[w] = 2, arr[2]
                stack[2].append(w)
            else:
                del color[w]
                return ("petal", w, color, parent, arr)

    def make_petal(self, i, s_red, s_green, parity, result):
        _, b, color, parent, arr = result
        pid = len(self.petals)
        self.petals.append((b, s_red, s_green, parity, arr))
        for w, c in color.items():
            self.bud[w] = b
            self.petal_of[w] = pid
            self.color[w] = c
            self.tree[w] = parent[w]
            self.star[w] = b
        for w in color:
            even, odd = self.lev[w]
            if even == INF:
                self.set_level(w, 0, 2 * i + 1 - odd)
            elif odd == INF:
                self.set_level(w, 1, 2 * i + 1 - even)

    # ---- paths --------------------------------------------------------
    def rep(self, v, pid, b):
        """Follow buds from v up to a vertex of petal pid or its bud b."""
        while v != b and self.petal_of[v] != pid:
            v = self.bud[v]
            if v == -1:
                raise AssertionError("left the petal")
        return v

    def open(self, x, b, par):
        """An alternating path x, ..., b on which x has parity par and b is used as even."""
        path = [x]
        while x != b:
            pid = self.petal_of[x]
            seg = self.open_petal(x, pid, par)
            path.extend(seg[1:])
            x, par = self.petals[pid][0], 0
        return path

    def down(self, x, b):
        """Follow predecessors from x down to the bud b of its petal."""
        pid = self.petal_of[x]
        path = [x]
        while x != b:
            nxt = None
            for p in self.pred[x]:
                if self.erased[p]:
                    continue
                r = self.rep(p, pid, b)
                if not self.erased[r]:
                    nxt = (p, r)
                    break
            if nxt is None:
                raise AssertionError("no predecessor left")
            p, r = nxt
            path.extend(self.open(p, r, 1 - self.minlevel(x) % 2))
            x = r
        return path

    def tree_path(self, top, x, parent):
        """The DFS tree path from top down to x, with petals opened."""
        chain = [x]
        while chain[-1] != top:
            chain.append(parent[chain[-1]][0])
        chain.reverse()
        path = [top]
        for k in range(1, len(chain)):
            c, p = parent[chain[k]]
            path.extend(self.open(p, chain[k], 1 - self.minlevel(c) % 2))
        return path

    def open_petal(self, x, pid, par):
        b, s_red, s_green, bpar, arr = self.petals[pid]
        if par == self.minlevel(x) % 2:
            return self.down(x, b)
        if self.lev[x][par] == INF:
            raise AssertionError("wrong parity")
        parent = self.tree
        mine = self.color[x]
        s_mine, s_other = (s_red, s_green) if mine == 1 else (s_green, s_red)
        r_mine = self.rep(s_mine, pid, b)
        r_other = self.rep(s_other, pid, b)
        up = self.open(s_mine, r_mine, bpar)[:-1] + self.tree_path(r_mine, x, parent)
        up.reverse()
        if r_other == b:
            rest = self.open(s_other, b, bpar)
        else:
            c, p = arr[3 - mine]
            rest = self.open(s_other, r_other, bpar)[:-1] + self.tree_path(r_other, c, parent)
            rest += self.open(p, b, 1 - self.minlevel(c) % 2)
        return up + rest

    def augmenting_path(self, s_red, s_green, bpar, result):
        _, parent, f_red, f_green = result
        sides = []
        for s, free in ((s_red, f_red), (s_green, f_green)):
            root = self.find(s)
            sides.append(self.open(s, root, bpar)[:-1] + self.tree_path(root, free, parent))
        return sides[0][::-1] + sides[1]

    def augment(self, path):
        mate = self.mate
        if len(set(path)) != len(path) or mate[path[0]] != -1 or mate[path[-1]] != -1:
            raise AssertionError("not an augmenting path")
        for k in range(0, len(path) - 1):
            a, b = path[k], path[k + 1]
            if b not in self.adj[a] or (mate[a] == b) != (k % 2 == 1):
                raise AssertionError("not alternating")
        for k in range(0, len(path) - 1, 2):
            a, b = path[k], path[k + 1]
            mate[a], mate[b] = b, a
        stack = list(path)
        while stack:
            v = stack.pop()
            if self.erased[v]:
                continue
            self.erased[v] = True
            for s in self.succ[v]:
                self.pred_left[s] -= 1
                if self.pred_left[s] == 0:
                    stack.append(s)
test_micali_vazirani_algorithm.py 160 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/micali-vazirani-algorithm/. The authors are listed in prov/ZG9QWA.jsonl.

import random
from functools import lru_cache

import micali_vazirani_algorithm as mv
from micali_vazirani_algorithm import micali_vazirani_algorithm


def brute_force(n, edges):
    """Oracle: the size of a maximum matching, by recursion on the lowest free vertex."""
    adj = [set() for _ in range(n)]
    for a, b in edges:
        adj[a].add(b)
        adj[b].add(a)

    @lru_cache(maxsize=None)
    def best(used):
        v = 0
        while v < n and used >> v & 1:
            v += 1
        if v == n:
            return 0
        result = best(used | 1 << v)
        for u in adj[v]:
            if not used >> u & 1:
                result = max(result, 1 + best(used | 1 << v | 1 << u))
        return result

    return best(0)


def check(n, edges):
    """Return the size of the matching after checking that it is a matching of the graph."""
    mate = micali_vazirani_algorithm(n, edges)
    es = {(min(a, b), max(a, b)) for a, b in edges}
    assert len(mate) == n
    for v in range(n):
        if mate[v] != -1:
            assert mate[mate[v]] == v
            assert (min(v, mate[v]), max(v, mate[v])) in es
    return sum(x != -1 for x in mate) // 2


def run_phase(n, edges, mate):
    """Run one phase from a given matching and return the paths it augments along."""
    adj = [[] for _ in range(n)]
    for a, b in edges:
        adj[a].append(b)
        adj[b].append(a)
    phase = mv._Phase(n, adj, mate)
    paths = []
    original = phase.augment

    def record(path):
        paths.append(list(path))
        original(path)

    phase.augment = record
    phase.run()
    return phase, paths


def test_empty_graph():
    assert micali_vazirani_algorithm(0, []) == []
    assert micali_vazirani_algorithm(3, []) == [-1, -1, -1]


def test_single_edge_and_path():
    assert micali_vazirani_algorithm(2, [(0, 1)]) == [1, 0]
    assert check(4, [(0, 1), (1, 2), (2, 3)]) == 2


def test_odd_cycles():
    assert check(3, [(0, 1), (1, 2), (2, 0)]) == 1
    assert check(5, [(i, (i + 1) % 5) for i in range(5)]) == 2
    assert check(7, [(i, (i + 1) % 7) for i in range(7)]) == 3


def test_no_perfect_matching():
    # A centre joined to one vertex of each of three triangles: 10 vertices, at most 4 edges.
    edges = []
    for k in range(3):
        a, b, c = 1 + 3 * k, 2 + 3 * k, 3 + 3 * k
        edges += [(a, b), (b, c), (c, a), (0, a)]
    assert check(10, edges) == 4
    # A star has only one edge in any matching.
    assert check(6, [(0, k) for k in range(1, 6)]) == 1


def test_petersen_graph_has_a_perfect_matching():
    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) == 5


def test_worked_example_one_phase():
    # Triangle 0-1-2 with 1=2 matched, then 2-3=4-5; free vertices 0 and 5.
    edges = [(0, 1), (0, 2), (1, 2), (2, 3), (3, 4), (4, 5)]
    mate = [-1, 2, 1, 4, 3, -1]
    phase, paths = run_phase(6, edges, mate)
    assert phase.petals[0][0] == 0  # the petal {1, 2} has bud 0
    assert phase.lev[1] == [2, 1] and phase.lev[2] == [2, 1]
    assert paths == [[5, 4, 3, 2, 1, 0]]
    assert mate == [1, 0, 3, 2, 5, 4]


def test_blossom_split_by_path():
    # The 5-cycle 2-3-5-6-4 with matched edges 3=5 and 4=6, entered from 0-1=2 and left to 7.
    edges = [(0, 1), (1, 2), (2, 3), (2, 4), (3, 5), (5, 6), (4, 6), (3, 7)]
    mate = [-1, 2, 1, 5, 6, 3, 4, -1]
    _, paths = run_phase(8, edges, mate)
    assert len(paths) == 1 and len(paths[0]) == 8
    assert sorted((paths[0][0], paths[0][-1])) == [0, 7]
    assert check(8, edges) == 4


def test_bipartite_graph():
    edges = [(0, 3), (0, 4), (1, 3), (2, 4), (2, 5)]
    assert check(6, edges) == 3


def test_bad_input():
    for n, edges in ((2, [(0, 0)]), (2, [(0, 2)]), (-1, [])):
        try:
            micali_vazirani_algorithm(n, edges)
        except ValueError:
            pass
        else:
            raise AssertionError("expected ValueError")
    assert micali_vazirani_algorithm(2, [(0, 1), (1, 0), (0, 1)]) == [1, 0]


def test_random_against_brute_force():
    rng = random.Random(20260511)
    for _ in range(600):
        n = rng.randint(0, 12)
        p = rng.random()
        edges = [(a, b) for a in range(n) for b in range(a + 1, n) if rng.random() < p]
        assert check(n, edges) == brute_force(n, edges)


def test_phases_use_shortest_paths_of_growing_length():
    rng = random.Random(7)
    for _ in range(300):
        n = rng.randint(2, 14)
        d = rng.uniform(1.5, 4)
        edges = [(a, b) for a in range(n) for b in range(a + 1, n) if rng.random() < d / n]
        mate = [-1] * n
        lengths = []
        while True:
            _, paths = run_phase(n, edges, mate)
            if not paths:
                break
            assert len({len(p) for p in paths}) == 1
            lengths.append(len(paths[0]))
        assert lengths == sorted(set(lengths))
        assert sum(x != -1 for x in mate) // 2 == brute_force(n, edges)

What the next level needs

  • the tests did not run in this build

Read the full page about Micali-Vazirani algorithm