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