Hopcroft-Tarjan planar isomorphism algorithm in Python
Tests isomorphism of 3-connected planar graphs by partition refinement on the darts of their unique embeddings, in O(n log n) time; Hopcroft and Wong later reached linear time.
- File
hopcroft_tarjan_isomorphism.py, 176 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/hopcroft-tarjan-isomorphism/. The authors are listed in prov/NBDRZ3.jsonl.
"""Isomorphism of triconnected planar graphs by refining darts, after Hopcroft and Tarjan.
A planar map is given by a rotation system: rot[v] lists the neighbors of v
in clockwise order around v. Graphs are simple, undirected and connected.
A dart is an ordered pair (u, v) with uv an edge. Two permutations act on
darts: sigma turns a dart around its tail, sigma(u, v) = (u, next neighbor
after v in rot[u]), and alpha reverses it, alpha(u, v) = (v, u).
By Whitney's theorem a 3-connected planar graph has one embedding up to
mirror image, so two such graphs are isomorphic exactly when their maps are
isomorphic, possibly after reversing every rotation of one of them. Map
isomorphism is a bijection of darts that commutes with sigma and alpha.
The test refines a partition of the darts of both maps with Hopcroft's
"smaller half" rule, then individualizes one pair of darts at a time.
"""
Rotation = list[list[int]]
class _Maps:
"""The darts of two maps as one set, with sigma, its inverse and alpha."""
def __init__(self, r1: Rotation, r2: Rotation) -> None:
self.tail: list[int] = []
self.side: list[int] = []
index: dict[tuple[int, int, int], int] = {}
for s, rot in enumerate((r1, r2)):
for u, nbrs in enumerate(rot):
for v in nbrs:
index[(s, u, v)] = len(self.tail)
self.tail.append(u)
self.side.append(s)
n = len(self.tail)
self.sigma = [0] * n
self.alpha = [0] * n
for s, rot in enumerate((r1, r2)):
for u, nbrs in enumerate(rot):
for i, v in enumerate(nbrs):
d = index[(s, u, v)]
self.sigma[d] = index[(s, u, nbrs[(i + 1) % len(nbrs)])]
self.alpha[d] = index[(s, v, u)]
self.sigma_inv = [0] * n
for d in range(n):
self.sigma_inv[self.sigma[d]] = d
def labels(self) -> list[tuple[int, int]]:
"""(degree of the tail, length of the face to the left) for each dart."""
n = len(self.tail)
deg = [0] * n
face = [0] * n
for d in range(n):
k, e = 1, self.sigma[d]
while e != d:
k, e = k + 1, self.sigma[e]
deg[d] = k
seen = [False] * n
for d in range(n):
if seen[d]:
continue
cyc, e = [], d
while not seen[e]:
seen[e] = True
cyc.append(e)
e = self.sigma[self.alpha[e]]
for e in cyc:
face[e] = len(cyc)
return list(zip(deg, face))
def refine(maps: _Maps, block_of: list[int], blocks: list[list[int]], queue: list[int]) -> None:
"""Coarsest refinement stable under sigma and alpha, in place.
A partition is stable when the preimage of every block under sigma and
under alpha is a union of blocks. Splitters come from the queue; after
a split, the smaller piece is queued, or both if the block was queued.
Cost: O(N log N) for N darts.
"""
queued = [False] * len(blocks)
for b in queue:
queued[b] = True
while queue:
s = queue.pop()
queued[s] = False
splitter = list(blocks[s])
for inverse in (maps.sigma_inv, maps.alpha):
pre: dict[int, list[int]] = {}
for d in splitter:
p = inverse[d]
pre.setdefault(block_of[p], []).append(p)
for b, part in pre.items():
if len(part) == len(blocks[b]):
continue
mark = set(part)
rest = [d for d in blocks[b] if d not in mark]
blocks[b] = rest
nb = len(blocks)
blocks.append(part)
queued.append(False)
for d in part:
block_of[d] = nb
if queued[b]:
queue.append(nb)
queued[nb] = True
else:
small = nb if len(part) <= len(rest) else b
queue.append(small)
queued[small] = True
def map_isomorphism(r1: Rotation, r2: Rotation) -> list[int] | None:
"""A vertex bijection that maps the map r1 onto the map r2, or None.
Both maps must be connected. Cost: O(N log N) for the first refinement
plus O(N log N) per trial, at most N trials, for N darts.
"""
if len(r1) != len(r2):
return None
if sorted(len(x) for x in r1) != sorted(len(x) for x in r2):
return None
maps = _Maps(r1, r2)
total = len(maps.tail)
if total == 0:
return [0] * len(r1) if len(r1) <= 1 else None
# @line L1
lab = maps.labels()
keys = sorted(set(lab))
blocks: list[list[int]] = [[] for _ in keys]
pos = {k: i for i, k in enumerate(keys)}
block_of = [0] * total
for d in range(total):
block_of[d] = pos[lab[d]]
blocks[block_of[d]].append(d)
# @line L2
refine(maps, block_of, blocks, list(range(len(blocks))))
# @line L3
for b in blocks:
if 2 * sum(maps.side[d] for d in b) != len(b):
return None
# @line L4
b0 = min((b for b in blocks if b), key=len)
x = next(d for d in b0 if maps.side[d] == 0)
# @line L5
for y in [d for d in b0 if maps.side[d] == 1]:
bo = list(block_of)
bl = [list(b) for b in blocks]
k = bo[x]
bl[k] = [d for d in bl[k] if d != x and d != y]
bl.append([x, y])
bo[x] = bo[y] = len(bl) - 1
# @line L6
refine(maps, bo, bl, [len(bl) - 1])
# @line L7
if all(len(b) == 2 and maps.side[b[0]] != maps.side[b[1]] for b in bl if b):
phi = [0] * len(r1)
for b in bl:
if b:
a, c = sorted(b, key=lambda d: maps.side[d])
phi[maps.tail[a]] = maps.tail[c]
return phi
# @line L8
return None
def mirror(rot: Rotation) -> Rotation:
"""The mirror image: every rotation reversed. Cost O(n + m)."""
return [list(reversed(x)) for x in rot]
def triconnected_planar_isomorphism(r1: Rotation, r2: Rotation) -> list[int] | None:
"""An isomorphism of two 3-connected planar graphs given by planar
rotation systems, or None. Cost: that of two calls of map_isomorphism.
"""
phi = map_isomorphism(r1, r2)
return phi if phi is not None else map_isomorphism(r1, mirror(r2))
test_hopcroft_tarjan_isomorphism.py 169 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/hopcroft-tarjan-isomorphism/. The authors are listed in prov/NBDRZ3.jsonl.
import itertools
import random
from hopcroft_tarjan_isomorphism import (_Maps, map_isomorphism, mirror, refine,
triconnected_planar_isomorphism)
Rotation = list[list[int]]
Face = tuple[int, int, int]
BIPYRAMID = [[1, 2, 3], [2, 0, 3, 4], [0, 1, 4, 3], [0, 2, 4, 1], [2, 1, 3]]
CHIRAL7 = [[1, 2, 5, 4], [2, 0, 4], [0, 1, 4, 3, 6, 5], [5, 6, 2, 4],
[1, 0, 5, 3, 2], [0, 2, 6, 3, 4], [3, 5, 2]]
def rot_from_faces(n: int, faces: list[Face]) -> Rotation:
succ: list[dict[int, int]] = [dict() for _ in range(n)]
for f in faces:
for i in range(3):
succ[f[i]][f[(i + 1) % 3]] = f[(i + 2) % 3]
rot = []
for v in range(n):
a0 = next(iter(succ[v]))
out, a = [a0], succ[v][a0]
while a != a0:
out.append(a)
a = succ[v][a]
rot.append(out)
return rot
def random_triangulation(rng: random.Random, n: int, flips: int) -> tuple[int, list[Face]]:
"""A random simple triangulation of the sphere: face splits, then edge flips."""
faces = [(0, 1, 2), (0, 2, 3), (0, 3, 1), (1, 3, 2)]
k = 4
while k < n:
a, b, c = faces.pop(rng.randrange(len(faces)))
faces += [(a, b, k), (b, c, k), (c, a, k)]
k += 1
for _ in range(flips):
rot = rot_from_faces(k, faces)
f1 = rng.choice(faces)
i = rng.randrange(3)
a, b, c = f1[i], f1[(i + 1) % 3], f1[(i + 2) % 3]
f2 = next(f for f in faces for j in range(3) if f[j] == b and f[(j + 1) % 3] == a)
d = next(x for x in f2 if x not in (a, b))
if len(rot[a]) <= 3 or len(rot[b]) <= 3 or d in rot[c]:
continue
faces.remove(f1)
faces.remove(f2)
faces += [(a, d, c), (d, b, c)]
return k, faces
def dual(faces: list[Face]) -> Rotation:
"""The dual map: a 3-connected cubic planar graph."""
edge_face = {}
for i, f in enumerate(faces):
for j in range(3):
edge_face[(f[j], f[(j + 1) % 3])] = i
return [[edge_face[(f[(j + 1) % 3], f[j])] for j in range(3)] for f in faces]
def relabel(rng: random.Random, rot: Rotation) -> Rotation:
n = len(rot)
p = list(range(n))
rng.shuffle(p)
out: Rotation = [[] for _ in range(n)]
for v in range(n):
lst = [p[x] for x in rot[v]]
s = rng.randrange(len(lst))
out[p[v]] = lst[s:] + lst[:s]
return out
def edges(rot: Rotation) -> list[tuple[int, int]]:
return [(u, v) for u in range(len(rot)) for v in rot[u] if u < v]
def is_isomorphism(phi: list[int], r1: Rotation, r2: Rotation) -> bool:
e2 = {frozenset(e) for e in edges(r2)}
return sorted(phi) == list(range(len(r1))) and len(edges(r1)) == len(e2) and \
all(frozenset((phi[u], phi[v])) in e2 for u, v in edges(r1))
def brute_iso(r1: Rotation, r2: Rotation) -> bool:
n = len(r1)
if n != len(r2) or len(edges(r1)) != len(edges(r2)):
return False
return any(is_isomorphism(list(p), r1, r2) for p in itertools.permutations(range(n)))
def classes(r1: Rotation, r2: Rotation) -> list[int]:
maps = _Maps(r1, r2)
lab = maps.labels()
keys = sorted(set(lab))
block_of = [keys.index(x) for x in lab]
blocks = [[d for d in range(len(lab)) if block_of[d] == i] for i in range(len(keys))]
refine(maps, block_of, blocks, list(range(len(blocks))))
return sorted(len(b) for b in blocks if b)
def test_page_example() -> None:
p = [2, 0, 4, 1, 3]
r2: Rotation = [[] for _ in range(5)]
for v in range(5):
r2[p[v]] = [p[x] for x in BIPYRAMID[v]]
assert r2 == [[4, 2, 1, 3], [2, 4, 3, 0], [0, 4, 1], [4, 0, 1], [2, 0, 3, 1]]
assert classes(BIPYRAMID, r2) == [12, 12, 12]
assert map_isomorphism(BIPYRAMID, r2) == [2, 0, 4, 1, 3]
assert map_isomorphism(BIPYRAMID, mirror(r2)) == [2, 1, 4, 0, 3]
assert map_isomorphism(CHIRAL7, mirror(CHIRAL7)) is None
phi = triconnected_planar_isomorphism(CHIRAL7, mirror(CHIRAL7))
assert phi is not None and is_isomorphism(phi, CHIRAL7, CHIRAL7)
def test_edge_cases() -> None:
k4 = rot_from_faces(4, [(0, 1, 2), (0, 2, 3), (0, 3, 1), (1, 3, 2)])
assert classes(k4, k4) == [24]
assert triconnected_planar_isomorphism(k4, mirror(k4)) is not None
assert map_isomorphism(k4, BIPYRAMID) is None
assert map_isomorphism([[]], [[]]) == [0]
assert map_isomorphism([[1], [0]], [[1], [0]]) is not None
def test_small_against_brute_force() -> None:
rng = random.Random(20261105)
for trial in range(300):
n = rng.randint(4, 7)
k, f = random_triangulation(rng, n, rng.randint(0, 20))
r1 = rot_from_faces(k, f)
if trial % 2 == 0:
r2 = relabel(rng, mirror(r1) if rng.random() < 0.5 else r1)
else:
k2, f2 = random_triangulation(rng, n, rng.randint(0, 20))
r2 = relabel(rng, rot_from_faces(k2, f2))
phi = triconnected_planar_isomorphism(r1, r2)
assert (phi is not None) == brute_iso(r1, r2)
if phi is not None:
assert is_isomorphism(phi, r1, r2)
def test_cubic_duals_against_brute_force() -> None:
rng = random.Random(77)
for trial in range(200):
n = rng.randint(4, 6)
k, f = random_triangulation(rng, n, rng.randint(0, 20))
r1 = dual(f)
if trial % 2 == 0:
r2 = relabel(rng, mirror(r1) if rng.random() < 0.5 else r1)
else:
_, f2 = random_triangulation(rng, n, rng.randint(0, 20))
r2 = relabel(rng, dual(f2))
phi = triconnected_planar_isomorphism(r1, r2)
assert (phi is not None) == brute_iso(r1, r2)
if phi is not None:
assert is_isomorphism(phi, r1, r2)
def test_larger_relabelings() -> None:
rng = random.Random(5)
for _ in range(100):
k, f = random_triangulation(rng, rng.randint(8, 30), 60)
r1 = rot_from_faces(k, f)
r2 = relabel(rng, mirror(r1))
phi = triconnected_planar_isomorphism(r1, r2)
assert phi is not None and is_isomorphism(phi, r1, r2)
What the next level needs
- the tests did not run in this build
Read the full page about Hopcroft-Tarjan planar isomorphism algorithm