Table-filling DFA minimization in Python
Marks pairs of distinguishable states in a table, propagating distinctions backwards until no more are found, then merges the unmarked pairs.
- File
table_filling_minimization.py, 86 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/table-filling-minimization/. The authors are listed in prov/ZY1X60.jsonl.
from collections import deque
from collections.abc import Hashable, Iterable, Mapping
State = Hashable
Dfa = Mapping[tuple[State, str], State]
def table_filling_minimization(
alphabet: Iterable[str],
delta: Dfa,
start: State,
accepting: Iterable[State],
) -> tuple[list[list[State]], list[dict[str, int]], set[int]]:
"""Minimize a complete DFA by marking the pairs of distinguishable states.
The DFA has the moves delta[(q, a)] -> state, the start state `start` and the
accepting states `accepting`; it must have a move for each reachable state and
symbol. Unreachable states are dropped. The result is (blocks, table, final):
state i of the minimal DFA is the class blocks[i] of equivalent input states,
its move on a is table[i][a], the start is state 0, and `final` holds the
accepting states. The classes are listed in breadth-first order from the start.
Cost: O(sigma * n^2) time and space for n reachable states.
"""
sigma = list(dict.fromkeys(alphabet))
acc = set(accepting)
# L1: the reachable states, numbered 0..r-1 in breadth-first order
num: dict[State, int] = {start: 0} # @line L1
states: list[State] = [start]
work = deque([start])
while work:
q = work.popleft()
for a in sigma:
t = delta[(q, a)]
if t not in num:
num[t] = len(states)
states.append(t)
work.append(t)
r = len(states)
nxt = [[num[delta[(q, a)]] for a in sigma] for q in states]
fin = [q in acc for q in states]
def key(i: int, j: int) -> int:
return i * r + j if i < j else j * r + i
marked = bytearray(r * r) # @line L2
waiting: list[list[int]] = [[] for _ in range(r * r)]
stack: list[int] = [] # @line L3
for i in range(r): # @line L4
for j in range(i + 1, r):
if fin[i] != fin[j]:
marked[key(i, j)] = 1
stack.append(key(i, j))
for i in range(r): # @line L5
for j in range(i + 1, r):
for c in range(len(sigma)):
x, y = nxt[i][c], nxt[j][c]
if x != y:
waiting[key(x, y)].append(key(i, j))
while stack: # @line L6
rs = stack.pop() # @line L7
for pq in waiting[rs]: # @line L8
if not marked[pq]:
marked[pq] = 1 # @line L9
stack.append(pq)
rep = list(range(r)) # @line L10
for i in range(r):
for j in range(i):
if not marked[key(i, j)]:
rep[i] = rep[j]
break
ids: dict[int, int] = {} # @line L11
blocks: list[list[State]] = []
for i in range(r):
if rep[i] not in ids:
ids[rep[i]] = len(blocks)
blocks.append([])
blocks[ids[rep[i]]].append(states[i])
table = [dict.fromkeys(sigma, 0) for _ in blocks]
for i in range(r):
for c, a in enumerate(sigma):
table[ids[rep[i]]][a] = ids[rep[nxt[i][c]]]
final = {ids[rep[i]] for i in range(r) if fin[i]}
return blocks, table, final # @line L12
test_table_filling_minimization.py 135 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/table-filling-minimization/. The authors are listed in prov/ZY1X60.jsonl.
import itertools
import random
from table_filling_minimization import table_filling_minimization
def words(alphabet, max_len):
for k in range(max_len + 1):
for t in itertools.product(alphabet, repeat=k):
yield "".join(t)
def run(delta, q, w):
for a in w:
q = delta[(q, a)]
return q
def run_table(table, w):
s = 0
for a in w:
s = table[s][a]
return s
def reachable(alphabet, delta, start):
seen, stack = {start}, [start]
while stack:
q = stack.pop()
for a in alphabet:
t = delta[(q, a)]
if t not in seen:
seen.add(t)
stack.append(t)
return seen
def brute_classes(alphabet, delta, start, accepting):
"""Group the reachable states by their behavior on all words of length <= n - 2.
Two states of an n-state DFA that differ on some word differ on a word of
length at most n - 2, so this is the exact equivalence of states.
"""
reach = reachable(alphabet, delta, start)
tests = list(words(alphabet, max(len(reach) - 2, 0)))
groups = {}
for q in reach:
sig = tuple(run(delta, q, z) in accepting for z in tests)
groups.setdefault(sig, set()).add(q)
return sorted(sorted(g, key=repr) for g in groups.values())
def brute_index(alphabet, delta, start, accepting, n):
"""Number of Myhill-Nerode classes, from strings and suffixes of length <= n."""
tests = list(words(alphabet, n))
return len({tuple(run(delta, start, x + z) in accepting for z in tests) for x in words(alphabet, n)})
# The example of the page: "ends in ab" with copies and one unreachable state.
EXAMPLE = {
(0, "a"): 1, (0, "b"): 3,
(1, "a"): 4, (1, "b"): 2,
(2, "a"): 4, (2, "b"): 3,
(3, "a"): 1, (3, "b"): 0,
(4, "a"): 1, (4, "b"): 5,
(5, "a"): 1, (5, "b"): 0,
(6, "a"): 6, (6, "b"): 2,
}
def test_example_from_the_page():
blocks, table, final = table_filling_minimization("ab", EXAMPLE, 0, {2, 5})
assert blocks == [[0, 3], [1, 4], [2, 5]]
assert table == [{"a": 1, "b": 0}, {"a": 1, "b": 2}, {"a": 1, "b": 0}]
assert final == {2}
for w in words("ab", 8):
assert (run_table(table, w) in final) == w.endswith("ab")
def test_edge_cases():
# empty language and Sigma*, with extra copies of the single class
d = {(q, a): (q + 1) % 4 for q in range(4) for a in "ab"}
assert table_filling_minimization("ab", d, 0, set()) == ([[0, 1, 2, 3]], [{"a": 0, "b": 0}], set())
assert table_filling_minimization("ab", d, 0, {0, 1, 2, 3}) == ([[0, 1, 2, 3]], [{"a": 0, "b": 0}], {0})
# a one-state DFA
assert table_filling_minimization("a", {(7, "a"): 7}, 7, {7}) == ([[7]], [{"a": 0}], {0})
# a dead state is kept, since the output is complete
d = {(0, "a"): 1, (0, "b"): 2, (1, "a"): 2, (1, "b"): 2, (2, "a"): 2, (2, "b"): 2}
blocks, table, final = table_filling_minimization("ab", d, 0, {1})
assert blocks == [[0], [1], [2]] and final == {1}
# unreachable states are dropped even when they look equivalent
d = {(0, "a"): 0, (1, "a"): 0, (2, "a"): 2}
assert table_filling_minimization("a", d, 0, {0, 2}) == ([[0]], [{"a": 0}], {0})
# a minimal DFA stays the same: counting modulo 5
d = {(q, "a"): (q + 1) % 5 for q in range(5)}
blocks, _, _ = table_filling_minimization("a", d, 0, {0})
assert blocks == [[0], [1], [2], [3], [4]]
# the distinguishing word can have length n - 2
n = 9
d = {(q, "a"): min(q + 1, n - 1) for q in range(n)}
blocks, _, _ = table_filling_minimization("a", d, 0, {n - 1})
assert len(blocks) == n
def random_dfa(rng, n, alphabet):
delta = {(q, a): rng.randrange(n) for q in range(n) for a in alphabet}
p = rng.choice([0.0, 0.3, 0.5, 1.0])
return delta, {q for q in range(n) if rng.random() < p}
def test_random_against_brute_force():
rng = random.Random(20261106)
for trial in range(700):
alphabet = "ab" if trial % 4 else "abc"
copies = trial % 5 == 0 and alphabet == "ab"
n = rng.randint(1, 4 if copies else (8 if alphabet == "ab" else 6))
delta, acc = random_dfa(rng, n, alphabet)
if copies: # make copies of the states, so that many merge
delta = {(q + s * n, a): t + rng.randrange(2) * n for (q, a), t in delta.items() for s in range(2)}
acc = acc | {q + n for q in acc}
blocks, table, final = table_filling_minimization(alphabet, delta, 0, acc)
assert sorted(sorted(b, key=repr) for b in blocks) == brute_classes(alphabet, delta, 0, acc)
assert 0 in blocks[0]
for b in blocks:
for q in b:
for a in alphabet:
assert delta[(q, a)] in blocks[table[blocks.index(b)][a]]
assert final == {i for i, b in enumerate(blocks) if b[0] in acc}
for w in words(alphabet, 6 if alphabet == "ab" else 4):
assert (run_table(table, w) in final) == (run(delta, 0, w) in acc)
if trial % 10 == 0 and alphabet == "ab":
assert len(blocks) == brute_index(alphabet, delta, 0, acc, 7)
What the next level needs
- the tests did not run in this build