Algorithm Search⌘K

Aho-Hopcroft-Ullman tree isomorphism algorithm in Python

Tests whether two rooted trees are isomorphic by giving each vertex an integer code built from the sorted codes of its children, level by level from the leaves; linear time with radix sorting.

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

"""Aho-Hopcroft-Ullman isomorphism test for rooted trees, and its use on free trees.

A tree on the vertices 0 .. n-1 is given as a list of undirected edges. Trees are
unlabeled and uncolored: only the shape counts. Rooted trees must map root to root.
"""

from collections import deque


def _children_and_levels(n: int, edges: list[tuple[int, int]], root: int) -> tuple[list[list[int]], list[list[int]]]:
    """Return the children lists and the vertices of each depth, by BFS from root."""
    adj: list[list[int]] = [[] for _ in range(n)]
    for u, v in edges:
        adj[u].append(v)
        adj[v].append(u)
    children: list[list[int]] = [[] for _ in range(n)]
    depth = [-1] * n
    depth[root] = 0
    levels: list[list[int]] = [[root]]
    queue = deque([root])
    while queue:
        u = queue.popleft()
        for w in adj[u]:
            if depth[w] < 0:
                depth[w] = depth[u] + 1
                children[u].append(w)
                if depth[w] == len(levels):
                    levels.append([])
                levels[depth[w]].append(w)
                queue.append(w)
    return children, levels


def ahu_isomorphic(t1: tuple[int, list[tuple[int, int]], int], t2: tuple[int, list[tuple[int, int]], int]) -> bool:
    """Decide whether the rooted trees t1 = (n1, edges1, root1) and t2 are isomorphic.

    Precondition: each edge list forms a tree on its n vertices. Cost O(n log n) here.
    """
    n1, e1, r1 = t1
    n2, e2, r2 = t2
    if n1 != n2:  # @line L1
        return False
    ch1, lv1 = _children_and_levels(n1, e1, r1)  # @line L2
    ch2, lv2 = _children_and_levels(n2, e2, r2)
    if len(lv1) != len(lv2):  # @line L3
        return False
    code1 = [0] * n1
    code2 = [0] * n2
    for i in range(len(lv1) - 1, -1, -1):  # @line L4
        key1 = {v: tuple(sorted(code1[c] for c in ch1[v])) for v in lv1[i]}  # @line L5
        key2 = {v: tuple(sorted(code2[c] for c in ch2[v])) for v in lv2[i]}
        distinct = sorted(set(key1.values()) | set(key2.values()))  # @line L6
        rank = {k: j for j, k in enumerate(distinct)}
        for v in lv1[i]:  # @line L7
            code1[v] = rank[key1[v]]
        for v in lv2[i]:
            code2[v] = rank[key2[v]]
        if sorted(code1[v] for v in lv1[i]) != sorted(code2[v] for v in lv2[i]):  # @line L8
            return False
    return True  # @line L9


def tree_centers(n: int, edges: list[tuple[int, int]]) -> list[int]:
    """Return the one or two centers of a free tree by peeling leaves. Cost O(n)."""
    if n == 1:
        return [0]
    deg = [0] * n
    adj: list[list[int]] = [[] for _ in range(n)]
    for u, v in edges:
        adj[u].append(v)
        adj[v].append(u)
        deg[u] += 1
        deg[v] += 1
    layer = [v for v in range(n) if deg[v] == 1]
    left = n
    while left > 2:
        left -= len(layer)
        nxt = []
        for u in layer:
            for w in adj[u]:
                deg[w] -= 1
                if deg[w] == 1:
                    nxt.append(w)
        layer = nxt
    return sorted(layer)


def free_trees_isomorphic(t1: tuple[int, list[tuple[int, int]]], t2: tuple[int, list[tuple[int, int]]]) -> bool:
    """Decide whether two free (unrooted) trees are isomorphic, by rooting them at centers."""
    n1, e1 = t1
    n2, e2 = t2
    if n1 != n2:
        return False
    c1 = tree_centers(n1, e1)
    c2 = tree_centers(n2, e2)
    if len(c1) != len(c2):
        return False
    return any(ahu_isomorphic((n1, e1, c1[0]), (n2, e2, r)) for r in c2)
test_ahu_algorithm.py 88 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/ahu-algorithm/. The authors are listed in prov/9MMJGB.jsonl.

import itertools
import random

from ahu_algorithm import ahu_isomorphic, free_trees_isomorphic, tree_centers


def brute_rooted(n: int, e1: list[tuple[int, int]], r1: int, e2: list[tuple[int, int]], r2: int) -> bool:
    s2 = {frozenset(e) for e in e2}
    for p in itertools.permutations(range(n)):
        if p[r1] == r2 and all(frozenset((p[u], p[v])) in s2 for u, v in e1):
            return True
    return False


def random_tree(rng: random.Random, n: int) -> list[tuple[int, int]]:
    return [(v, rng.randrange(v)) for v in range(1, n)]


def relabel(rng: random.Random, n: int, edges: list[tuple[int, int]]) -> tuple[list[int], list[tuple[int, int]]]:
    p = list(range(n))
    rng.shuffle(p)
    return p, [(p[u], p[v]) for u, v in edges]


def test_page_example() -> None:
    t1 = (8, [(0, 1), (0, 2), (0, 3), (1, 4), (1, 5), (2, 6), (6, 7)], 0)
    t2 = (8, [(7, 5), (7, 3), (7, 0), (5, 1), (5, 6), (3, 2), (2, 4)], 7)
    t3 = (8, [(0, 1), (0, 2), (0, 3), (1, 4), (1, 5), (1, 6), (4, 7)], 0)
    assert ahu_isomorphic(t1, t2)
    assert not ahu_isomorphic(t1, t3)
    assert brute_rooted(8, t1[1], 0, t2[1], 7)
    assert not brute_rooted(8, t1[1], 0, t3[1], 0)


def test_edges() -> None:
    assert ahu_isomorphic((1, [], 0), (1, [], 0))
    assert not ahu_isomorphic((1, [], 0), (2, [(0, 1)], 0))
    path = (4, [(0, 1), (1, 2), (2, 3)], 0)
    star = (4, [(0, 1), (0, 2), (0, 3)], 0)
    assert not ahu_isomorphic(path, star)
    # The same free path rooted at an end and at an inner vertex.
    assert not ahu_isomorphic(path, (4, [(0, 1), (1, 2), (2, 3)], 1))
    assert free_trees_isomorphic((4, path[1]), (4, [(2, 0), (0, 3), (3, 1)]))
    assert not free_trees_isomorphic((4, path[1]), (4, star[1]))
    assert tree_centers(4, path[1]) == [1, 2]
    assert tree_centers(5, [(0, 1), (1, 2), (2, 3), (3, 4)]) == [2]


def test_random_rooted_against_brute_force() -> None:
    rng = random.Random(9031)
    for _ in range(600):
        n = rng.randint(1, 7)
        e1 = random_tree(rng, n)
        r1 = rng.randrange(n)
        if rng.random() < 0.5:
            p, e2 = relabel(rng, n, e1)
            r2 = p[r1]
        else:
            e2 = random_tree(rng, n)
            r2 = rng.randrange(n)
        assert ahu_isomorphic((n, e1, r1), (n, e2, r2)) == brute_rooted(n, e1, r1, e2, r2)


def test_random_free_against_brute_force() -> None:
    rng = random.Random(4417)
    for _ in range(500):
        n = rng.randint(1, 7)
        e1 = random_tree(rng, n)
        e2 = relabel(rng, n, e1)[1] if rng.random() < 0.5 else random_tree(rng, n)
        want = any(brute_rooted(n, e1, 0, e2, r) for r in range(n))
        assert free_trees_isomorphic((n, e1), (n, e2)) == want


def test_large_relabeled() -> None:
    rng = random.Random(77)
    for _ in range(20):
        n = rng.randint(50, 400)
        e1 = random_tree(rng, n)
        p, e2 = relabel(rng, n, e1)
        assert ahu_isomorphic((n, e1, 0), (n, e2, p[0]))
        assert free_trees_isomorphic((n, e1), (n, e2))
        # a deep path does not overflow the stack
    n = 5000
    path = [(i, i + 1) for i in range(n - 1)]
    assert ahu_isomorphic((n, path, 0), (n, list(reversed(path)), n - 1))

What the next level needs

  • the tests did not run in this build

Read the full page about Aho-Hopcroft-Ullman tree isomorphism algorithm