Algorithm Search⌘K

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