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