Hopcroft-Karp equivalence algorithm in Python
Decides DFA equivalence in near-linear time using union-find to merge state pairs.
- File
hopcroft_karp_equivalence.py, 67 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-karp-equivalence/. The authors are listed in prov/R0H62M.jsonl.
from collections.abc import Sequence
class DisjointSet:
"""Union by size and path halving; m operations on n elements take O(m alpha(n)) time."""
def __init__(self, n: int) -> None:
self.parent = list(range(n))
self.size = [1] * n
def find(self, x: int) -> int:
parent = self.parent
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
def union(self, x: int, y: int) -> None:
x, y = self.find(x), self.find(y)
if x == y:
return
if self.size[x] < self.size[y]:
x, y = y, x
self.parent[y] = x
self.size[x] += self.size[y]
def hopcroft_karp_equivalent(
delta1: Sequence[Sequence[int]],
start1: int,
finals1: set[int] | frozenset[int],
delta2: Sequence[Sequence[int]],
start2: int,
finals2: set[int] | frozenset[int],
) -> tuple[bool, tuple[int, ...] | None]:
"""Decide whether two complete DFAs over symbols 0..sigma-1 accept the same language.
Returns (True, None) if they do, and (False, w) with w a string accepted by exactly
one of them otherwise. With n = n1 + n2 states the time is O(sigma n alpha(n)), and
|w| <= n - 2.
"""
n1 = len(delta1)
sigma = len(delta1[0]) if delta1 else 0
ds = DisjointSet(n1 + len(delta2)) # @line L1
# each pushed pair stores (state of M1, state of M2, index of its parent pair, symbol)
pairs: list[tuple[int, int, int, int]] = [(start1, start2, -1, -1)]
ds.union(start1, n1 + start2) # @line L2
stack = [0]
while stack: # @line L3
i = stack.pop() # @line L4
p, r, _, _ = pairs[i]
if (p in finals1) != (r in finals2): # @line L5
word = []
while pairs[i][2] >= 0:
word.append(pairs[i][3])
i = pairs[i][2]
return False, tuple(reversed(word))
for a in range(sigma): # @line L6
p2, r2 = delta1[p][a], delta2[r][a] # @line L7
if ds.find(p2) != ds.find(n1 + r2): # @line L8
ds.union(p2, n1 + r2) # @line L9
pairs.append((p2, r2, i, a))
stack.append(len(pairs) - 1)
return True, None # @line L10
test_hopcroft_karp_equivalence.py 112 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/hopcroft-karp-equivalence/. The authors are listed in prov/R0H62M.jsonl.
import itertools
import random
from hopcroft_karp_equivalence import DisjointSet, hopcroft_karp_equivalent
def accepts(delta, start, finals, word) -> bool:
q = start
for a in word:
q = delta[q][a]
return q in finals
def words(sigma: int, max_len: int):
for n in range(max_len + 1):
yield from itertools.product(range(sigma), repeat=n)
def brute_equivalent(d1, s1, f1, d2, s2, f2) -> bool:
# two DFAs with n1 and n2 states that differ also differ on a word of length <= n1 + n2 - 2
sigma = len(d1[0])
bound = max(0, len(d1) + len(d2) - 2)
return all(accepts(d1, s1, f1, w) == accepts(d2, s2, f2, w) for w in words(sigma, bound))
def check(d1, s1, f1, d2, s2, f2) -> bool:
eq, w = hopcroft_karp_equivalent(d1, s1, f1, d2, s2, f2)
assert eq == brute_equivalent(d1, s1, f1, d2, s2, f2)
if eq:
assert w is None
else:
assert accepts(d1, s1, f1, w) != accepts(d2, s2, f2, w)
assert len(w) <= len(d1) + len(d2) - 2
return eq
def counter(m: int, finals) -> list[list[int]]:
# count the a's modulo m over {a, b}
return [[(q + 1) % m, q] for q in range(m)]
def test_examples() -> None:
assert hopcroft_karp_equivalent(counter(2, None), 0, {0}, counter(3, None), 0, {0}) == (False, (0, 0))
assert hopcroft_karp_equivalent(counter(2, None), 0, {0}, counter(4, None), 0, {0, 2}) == (True, None)
def test_edges() -> None:
empty, full = ([[0, 0]], 0, set()), ([[0, 0]], 0, {0})
assert check(*empty, *empty)
assert check(*full, *full)
assert not check(*empty, *full)
# unreachable accepting state does not matter
assert check([[0], [1]], 0, {1}, [[0]], 0, set())
# dead states: "exactly one symbol" with and without a dead sink of size 2
assert check([[1], [2], [2]], 0, {1}, [[1], [2], [3], [2]], 0, {1})
# one-letter alphabet, the tight family for the length of a counterexample
for m in range(2, 7):
cycle = [[(q + 1) % m] for q in range(m)]
chain = [[min(q + 1, m)] for q in range(m + 1)]
fchain = {k for k in range(m) if k % m == m - 1}
eq, w = hopcroft_karp_equivalent(cycle, 0, {m - 1}, chain, 0, fchain)
assert not eq and len(w) == 2 * m - 1
def test_disjoint_set() -> None:
ds = DisjointSet(6)
ds.union(0, 1)
ds.union(2, 3)
ds.union(1, 3)
assert ds.find(0) == ds.find(2) and ds.find(4) != ds.find(0)
def random_dfa(rng: random.Random, n: int, sigma: int):
return [[rng.randrange(n) for _ in range(sigma)] for _ in range(n)]
def split_copy(rng: random.Random, delta, start, finals):
"""An equivalent DFA with each state doubled and the copies chosen at random."""
n = len(delta)
d2 = [[delta[q % n][a] + n * rng.randrange(2) for a in range(len(delta[0]))] for q in range(2 * n)]
return d2, start, {q for q in range(2 * n) if q % n in finals}
def test_random_against_brute_force() -> None:
rng = random.Random(1971)
equal = 0
for t in range(800):
sigma = rng.randint(1, 3)
n1 = rng.randint(1, 4)
d1 = random_dfa(rng, n1, sigma)
s1 = rng.randrange(n1)
mode = t % 5
if mode == 0:
f1 = set()
elif mode == 1:
f1 = set(range(n1))
else:
f1 = {q for q in range(n1) if rng.random() < 0.5}
if t % 2 == 0 and n1 <= 3:
d2, s2, f2 = split_copy(rng, d1, s1, f1)
else:
n2 = rng.randint(1, 4)
d2 = random_dfa(rng, n2, sigma)
s2 = rng.randrange(n2)
f2 = set() if mode == 0 else {q for q in range(n2) if rng.random() < 0.5}
if sigma == 3 and len(d1) + len(d2) > 7:
continue
equal += check(d1, s1, f1, d2, s2, f2)
assert equal >= 100
What the next level needs
- the tests did not run in this build
Read the full page about Hopcroft-Karp equivalence algorithm