Babai's quasipolynomial graph isomorphism algorithm in Python
Solves graph isomorphism in quasipolynomial time n^(polylog n), the best known general bound.
- File
babai_quasipolynomial_algorithm.py, 358 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/babai-quasipolynomial-algorithm/. The authors are listed in prov/E8W04N.jsonl.
"""A model of the framework of Babai's graph isomorphism algorithm.
Babai's algorithm (2015) solves string isomorphism, and through it graph
isomorphism, in quasipolynomial time. Its outer structure is Luks's
recursion: split along orbits, and for a transitive group along a system of
maximal blocks. The only case where Luks's recursion is too slow is when the
group induced on the blocks is a giant, the alternating or symmetric group
on the m blocks, with m large. Babai's new tools (local certificates,
aggregation of certificates, and the split-or-Johnson routine) handle that
case. They are not implemented here.
This module implements the framework exactly: the canonical first step
(color refinement on both graphs together), Luks's recursion, and a
polynomial-time test that detects the giant case and records it. In the
giant case it falls back to running over all cosets, so the answer is exact
but the time is exponential there; outside the giant case the recursion is
the same as in the paper.
A permutation of {0, ..., n-1} is a tuple p, with p[i] the image of i.
Products apply the left factor first: mul(p, q)[i] = q[p[i]].
"""
Perm = tuple[int, ...]
Coset = tuple[list[Perm], Perm]
def identity(n: int) -> Perm:
return tuple(range(n))
def mul(p: Perm, q: Perm) -> Perm:
return tuple(q[i] for i in p)
def inverse(p: Perm) -> Perm:
out = [0] * len(p)
for i, j in enumerate(p):
out[j] = i
return tuple(out)
def _transversal(base_point: int, gens: list[Perm], n: int) -> dict[int, Perm]:
"""For each point of the orbit of base_point, a group element sending base_point there."""
trans = {base_point: identity(n)}
queue = [base_point]
for p in queue:
for g in gens:
q = g[p]
if q not in trans:
trans[q] = mul(trans[p], g)
queue.append(q)
return trans
class Group:
"""A permutation group with a base and strong generating set (Schreier-Sims)."""
def __init__(self, gens: list[Perm], n: int) -> None:
self.n = n
e = identity(n)
self.gens = list(dict.fromkeys(g for g in gens if g != e))
self.base: list[int] = []
self.levels: list[list[Perm]] = []
self.trans: list[dict[int, Perm]] = []
for g in self.gens:
self._add(g)
def _sift(self, g: Perm, start: int) -> tuple[Perm, int]:
for i in range(start, len(self.base)):
t = self.trans[i].get(g[self.base[i]])
if t is None:
return g, i
g = mul(g, inverse(t))
return g, len(self.base)
def _add(self, g: Perm) -> None:
"""Add g and restore the strong generating property (iterative Schreier-Sims)."""
e = identity(self.n)
g, level = self._sift(g, 0)
if g == e:
return
todo = [(g, level)]
while todo:
h, level = todo.pop()
h, level = self._sift(h, level)
if h == e:
continue
if level == len(self.base):
self.base.append(next(i for i in range(self.n) if h[i] != i))
self.levels.append([])
self.trans.append({})
for j in range(level + 1):
self.levels[j].append(h)
for j in range(level, -1, -1):
self.trans[j] = _transversal(self.base[j], self.levels[j], self.n)
# Schreier generators of every level touched must sift through.
for j in range(level, -1, -1):
fresh = False
for p, u in list(self.trans[j].items()):
for s in self.levels[j]:
w = mul(mul(u, s), inverse(self.trans[j][s[p]]))
r, lv = self._sift(w, j + 1)
if r != e:
todo.append((r, lv))
fresh = True
if fresh:
break
def order(self) -> int:
out = 1
for t in self.trans:
out *= len(t)
return out
def contains(self, g: Perm) -> bool:
return self._sift(g, 0)[0] == identity(self.n)
def strong_gens(self) -> list[Perm]:
return list(self.levels[0]) if self.levels else []
def orbits(gens: list[Perm], window: list[int]) -> list[list[int]]:
seen: set[int] = set()
out = []
for p in window:
if p in seen:
continue
seen.add(p)
orb = [p]
for q in orb:
for g in gens:
if g[q] not in seen:
seen.add(g[q])
orb.append(g[q])
out.append(sorted(orb))
return out
def _min_block(gens: list[Perm], window: list[int], seeds: list[int]) -> list[list[int]]:
"""The finest block system in which all seeds lie in one block (Atkinson)."""
parent = {p: p for p in window}
def find(p: int) -> int:
while parent[p] != p:
parent[p] = parent[parent[p]]
p = parent[p]
return p
pairs = [(seeds[0], s) for s in seeds[1:]]
while pairs:
a, b = pairs.pop()
ra, rb = find(a), find(b)
if ra == rb:
continue
parent[rb] = ra
for g in gens:
pairs.append((g[a], g[b]))
classes: dict[int, list[int]] = {}
for p in window:
classes.setdefault(find(p), []).append(p)
return sorted(classes.values())
def maximal_blocks(gens: list[Perm], window: list[int]) -> list[list[int]]:
"""A block system of a transitive group on which it acts primitively."""
alpha = window[0]
system = [[p] for p in window]
block = [alpha]
for beta in window:
if beta in block:
continue
cand = _min_block(gens, window, block + [beta])
if len(cand) > 1:
system = cand
block = next(b for b in cand if alpha in b)
return system
def _block_action(gens: list[Perm], system: list[list[int]]) -> dict[Perm, Perm]:
"""The image group on the blocks, each image with one preimage in G."""
where = {p: i for i, b in enumerate(system) for p in b}
images = [tuple(where[g[b[0]]] for b in system) for g in gens]
k = len(system)
n = len(gens[0]) if gens else len(where)
reps = {identity(k): identity(n)}
queue = [identity(k)]
for img in queue:
for g, gi in zip(gens, images):
nxt = mul(img, gi)
if nxt not in reps:
reps[nxt] = mul(reps[img], g)
queue.append(nxt)
return reps
def _shift(y: list[int], t: Perm) -> list[int]:
"""The string y composed with t: position d gets y[t[d]]."""
return [y[t[d]] for d in range(len(y))]
def is_giant(image_gens: list[Perm], m: int) -> bool:
"""Whether the group generated on m points contains the alternating group.
Uses the order from Schreier-Sims, which is polynomial: |H| >= m!/2.
"""
if m <= 2:
return False
full = 1
for i in range(2, m + 1):
full *= i
return 2 * Group(image_gens, m).order() >= full
def string_isomorphism(gens: list[Perm], n: int, window: list[int], x: list[int], y: list[int],
stats: dict[str, int] | None = None, threshold: int = 4) -> Coset | None:
"""All g in <gens> with x[d] == y[g[d]] on the window, as (generators of A, tau), or None.
The window must be invariant under the group. stats, if given, counts the
transitive cases by kind: "small" (Luks's recursion applies) and "giant"
(the bottleneck that Babai's algorithm handles with new tools; here the
fallback runs over all cosets). Giants on at most `threshold` blocks are
counted as small, since their size is bounded.
"""
e = identity(n)
gens = Group(gens, n).strong_gens()
# @line L1
if len(window) == 1:
d = window[0]
return (gens, e) if x[d] == y[d] else None
orbs = orbits(gens, window)
# @line L2
if len(orbs) > 1:
a_gens, tau = gens, e
for orb in orbs:
res = string_isomorphism(a_gens, n, orb, x, _shift(y, tau), stats, threshold)
if res is None:
return None
a_gens, g = res
tau = mul(g, tau)
return a_gens, tau
# @line L3
system = maximal_blocks(gens, window)
where = {p: i for i, b in enumerate(system) for p in b}
images = [tuple(where[g[b[0]]] for b in system) for g in gens]
# @line L4
giant = len(system) > threshold and is_giant(images, len(system))
if stats is not None:
key = "giant" if giant else "small"
stats[key] = stats.get(key, 0) + 1
# @line L5
reps = _block_action(gens, system)
schreier = [mul(mul(t, g), inverse(reps[mul(img, gi)]))
for img, t in reps.items() for g, gi in zip(gens, images)]
kernel = Group(schreier, n).strong_gens()
# @line L6
found: list[Perm] = []
a_gens: list[Perm] = []
for t in reps.values():
res = string_isomorphism(kernel, n, window, x, _shift(y, t), stats, threshold)
if res is not None:
a_gens = res[0]
found.append(mul(res[1], t))
# @line L7
if not found:
return None
t1_inv = inverse(found[0])
return Group(a_gens + [mul(t, t1_inv) for t in found[1:]], n).strong_gens(), found[0]
def joint_color_refinement(n: int, e1: list[tuple[int, int]], e2: list[tuple[int, int]],
c1: list[int], c2: list[int]) -> tuple[list[int], list[int]] | None:
"""Color refinement run on both graphs with shared color names.
Returns the stable colors of both graphs, or None if the color counts differ
in some round, which proves that the graphs are not isomorphic.
"""
adj = [[] for _ in range(2 * n)]
for u, v in e1:
adj[u].append(v)
adj[v].append(u)
for u, v in e2:
adj[n + u].append(n + v)
adj[n + v].append(n + u)
col = list(c1) + list(c2)
while True:
sig = [(col[v], tuple(sorted(col[w] for w in adj[v]))) for v in range(2 * n)]
names = {s: i for i, s in enumerate(sorted(set(sig)))}
new = [names[s] for s in sig]
if sorted(new[:n]) != sorted(new[n:]):
return None
if len(names) == len(set(col)):
return new[:n], new[n:]
col = new
def graph_isomorphism(n: int, e1: list[tuple[int, int]], e2: list[tuple[int, int]],
c1: list[int] | None = None, c2: list[int] | None = None,
stats: dict[str, int] | None = None) -> tuple[list[int], int] | None:
"""An isomorphism of two vertex-colored simple undirected graphs, and the
number of automorphisms of the first; or None.
Step 1 refines colors canonically on both graphs. Step 2 renames the
second graph so that equal colors sit on the same vertices. Step 3 solves
string isomorphism for the product of the symmetric groups on the color
classes, acting on vertex pairs.
"""
c1 = list(c1) if c1 is not None else [0] * n
c2 = list(c2) if c2 is not None else [0] * n
if len(e1) != len(e2):
return None
ref = joint_color_refinement(n, e1, e2, c1, c2)
if ref is None:
return None
col1, col2 = ref
# rename the second graph: the k-th vertex of color c in H becomes the k-th of color c in G
slots: dict[int, list[int]] = {}
for v in range(n):
slots.setdefault(col1[v], []).append(v)
rename = [0] * n
used: dict[int, int] = {}
for v in range(n):
c = col2[v]
rename[v] = slots[c][used.get(c, 0)]
used[c] = used.get(c, 0) + 1
e2r = [(rename[u], rename[v]) for u, v in e2]
pairs = [(u, v) for u in range(n) for v in range(u + 1, n)]
pid = {p: n + i for i, p in enumerate(pairs)}
size = n + len(pairs)
def lift(vp: list[int]) -> Perm:
out = list(vp) + [0] * len(pairs)
for (u, v), i in pid.items():
a, b = vp[u], vp[v]
out[i] = pid[(a, b) if a < b else (b, a)]
return tuple(out)
gens = []
for cls in slots.values():
for a, b in zip(cls, cls[1:]):
vp = list(range(n))
vp[a], vp[b] = b, a
gens.append(lift(vp))
s1 = {tuple(sorted(e)) for e in e1}
s2 = {tuple(sorted(e)) for e in e2r}
x = [0] * n + [1 if p in s1 else 0 for p in pairs]
y = [0] * n + [1 if p in s2 else 0 for p in pairs]
if not pairs:
return list(range(n)), 1
res = string_isomorphism(gens, size, list(range(n, size)), x, y, stats)
if res is None:
return None
a_gens, tau = res
back = [0] * n
for v in range(n):
back[rename[v]] = v
return [back[tau[v]] for v in range(n)], Group(a_gens, size).order()
test_babai_quasipolynomial_algorithm.py 147 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/babai-quasipolynomial-algorithm/. The authors are listed in prov/E8W04N.jsonl.
import itertools
import random
from babai_quasipolynomial_algorithm import (Perm, graph_isomorphism, identity, is_giant,
joint_color_refinement, mul, orbits, string_isomorphism)
Edges = list[tuple[int, int]]
def closure(gens: list[Perm], n: int) -> set[Perm]:
e = identity(n)
seen = {e}
queue = [e]
for p in queue:
for g in gens:
q = mul(p, g)
if q not in seen:
seen.add(q)
queue.append(q)
return seen
def brute_graph(n: int, e1: Edges, e2: Edges, c1: list[int], c2: list[int]) -> list[tuple[int, ...]]:
"""All color-preserving isomorphisms, over all vertex permutations."""
s1 = {tuple(sorted(e)) for e in e1}
s2 = {tuple(sorted(e)) for e in e2}
if len(s1) != len(s2):
return []
return [p for p in itertools.permutations(range(n))
if all(c2[p[v]] == c1[v] for v in range(n))
and all(tuple(sorted((p[u], p[v]))) in s2 for u, v in s1)]
def random_perm(rng: random.Random, n: int) -> Perm:
p = list(range(n))
if rng.random() < 0.5:
rng.shuffle(p)
else:
idx = rng.sample(range(n), rng.randint(1, n))
for a, b in zip(idx, idx[1:] + idx[:1]):
p[a] = b
return tuple(p)
HUB = [(0, i) for i in range(1, 7)]
WHEEL = HUB + [(i, i % 6 + 1) for i in range(1, 7)]
HUB_2K3 = HUB + [(1, 2), (2, 3), (1, 3), (4, 5), (5, 6), (4, 6)]
WHEEL_RENAMED = HUB + [(1, 3), (3, 5), (5, 2), (2, 4), (4, 6), (6, 1)]
def test_page_example() -> None:
stats: dict[str, int] = {}
assert graph_isomorphism(7, WHEEL, HUB_2K3, stats=stats) is None
assert stats == {"giant": 1, "small": 1}
res = graph_isomorphism(7, WHEEL, WHEEL_RENAMED)
assert res == ([0, 1, 3, 5, 2, 4, 6], 12)
c6 = [(i, (i + 1) % 6) for i in range(6)]
two_k3 = [(0, 1), (1, 2), (0, 2), (3, 4), (4, 5), (3, 5)]
assert joint_color_refinement(6, c6, two_k3, [0] * 6, [0] * 6) is not None
assert graph_isomorphism(6, c6, two_k3) is None
def test_edge_cases() -> None:
assert graph_isomorphism(0, [], []) == ([], 1)
assert graph_isomorphism(1, [], []) == ([0], 1)
assert graph_isomorphism(2, [(0, 1)], []) is None
assert graph_isomorphism(2, [], [], [0, 1], [1, 0]) == ([1, 0], 1)
assert graph_isomorphism(4, [(0, 1), (1, 2), (2, 3)], [(0, 1), (0, 2), (0, 3)]) is None
def test_giant_detection() -> None:
s5 = [(1, 2, 3, 4, 0), (1, 0, 2, 3, 4)]
a5 = [(1, 2, 3, 4, 0), (1, 2, 0, 3, 4)]
c5 = [(1, 2, 3, 4, 0)]
d5 = [(1, 2, 3, 4, 0), (0, 4, 3, 2, 1)]
assert is_giant(s5, 5) and is_giant(a5, 5)
assert not is_giant(c5, 5) and not is_giant(d5, 5)
def test_string_isomorphism_against_brute_force() -> None:
rng = random.Random(20261105)
for _ in range(300):
n = rng.randint(1, 6)
gens = [random_perm(rng, n) for _ in range(rng.randint(0, 3))]
elems = closure(gens, n)
orbs = orbits(gens, list(range(n)))
window = sorted(p for o in orbs if rng.random() < 0.7 for p in o) or orbs[0]
colors = rng.randint(1, 3)
x = [rng.randrange(colors) for _ in range(n)]
if rng.random() < 0.5:
g = rng.choice(sorted(elems))
y = [0] * n
for d in range(n):
y[g[d]] = x[d]
else:
y = [rng.randrange(colors) for _ in range(n)]
want = {g for g in elems if all(x[d] == y[g[d]] for d in window)}
res = string_isomorphism(gens, n, window, x, y, threshold=rng.randint(2, 5))
if res is None:
assert not want
else:
a_gens, tau = res
assert {mul(a, tau) for a in closure(a_gens, n)} == want
def test_graphs_against_brute_force() -> None:
rng = random.Random(3)
for trial in range(300):
n = rng.randint(1, 7)
k = rng.randint(1, 3)
c1 = [rng.randrange(k) for _ in range(n)]
e1 = [(u, v) for u in range(n) for v in range(u + 1, n) if rng.random() < 0.5]
if trial % 2 == 0:
p = list(range(n))
rng.shuffle(p)
e2 = [(p[u], p[v]) for u, v in e1]
c2 = [0] * n
for v in range(n):
c2[p[v]] = c1[v]
else:
e2 = [(u, v) for u in range(n) for v in range(u + 1, n) if rng.random() < 0.5]
c2 = list(c1)
rng.shuffle(c2)
res = graph_isomorphism(n, e1, e2, c1, c2)
isos = brute_graph(n, e1, e2, c1, c2)
assert (res is None) == (not isos)
if res is not None:
assert tuple(res[0]) in isos
assert res[1] == len(brute_graph(n, e1, e1, c1, c1))
def test_regular_pairs_hard_for_color_refinement() -> None:
rng = random.Random(9)
k33 = [(i, j) for i in range(3) for j in range(3, 6)]
prism = [(0, 1), (1, 2), (0, 2), (3, 4), (4, 5), (3, 5), (0, 3), (1, 4), (2, 5)]
c7 = [(i, (i + 1) % 7) for i in range(7)]
c3_c4 = [(0, 1), (1, 2), (0, 2), (3, 4), (4, 5), (5, 6), (3, 6)]
for a, b, n in ((k33, prism, 6), (c7, c3_c4, 7)):
assert joint_color_refinement(n, a, b, [0] * n, [0] * n) is not None
assert graph_isomorphism(n, a, b) is None
p = list(range(n))
rng.shuffle(p)
res = graph_isomorphism(n, a, [(p[u], p[v]) for u, v in a])
assert res is not None and res[1] == len(brute_graph(n, a, a, [0] * n, [0] * n))
What the next level needs
- the tests did not run in this build
Read the full page about Babai's quasipolynomial graph isomorphism algorithm