Algorithm Search⌘K

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