Lazy DFA construction in Python
Builds DFA states during matching and caches them, avoiding the full exponential DFA.
- File
lazy_dfa_construction.py, 234 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/lazy-dfa-construction/. The authors are listed in prov/1VRZ3Y.jsonl.
"""Lazy DFA construction: build DFA states while reading the text, and cache them.
A regular expression is a tuple:
("empty",) for the empty language, ("eps",) for the empty string,
("sym", a) for one symbol, ("alt", r, s), ("cat", r, s) and ("star", r).
The DFA is the subset construction of an NFA, but a DFA state is made only
when the text reaches it. The cache of DFA states has a capacity; when a new
state is needed and the cache is full, the whole cache is flushed.
"""
from __future__ import annotations
from collections.abc import Iterable
from dataclasses import dataclass, field
from typing import Any
Regex = tuple[Any, ...]
@dataclass
class NFA:
"""An NFA with epsilon-moves. moves[q] lists (symbol, target) pairs."""
eps: list[list[int]]
moves: list[list[tuple[str, int]]]
start: int
accepting: frozenset[int]
def thompson_nfa(regex: Regex) -> NFA:
"""Thompson's construction, with two states per node and an explicit stack."""
eps: list[list[int]] = []
moves: list[list[tuple[str, int]]] = []
def new() -> int:
eps.append([])
moves.append([])
return len(eps) - 1
frags: list[tuple[int, int]] = []
todo: list[tuple[Regex, bool]] = [(regex, False)]
while todo:
node, ready = todo.pop()
tag = node[0]
if not ready and tag in ("alt", "cat", "star"):
todo.append((node, True))
for child in reversed(node[1:]):
todo.append((child, False))
continue
s, f = new(), new()
if tag == "eps":
eps[s].append(f)
elif tag == "sym":
moves[s].append((node[1], f))
elif tag == "alt":
s2, f2 = frags.pop()
s1, f1 = frags.pop()
eps[s] += [s1, s2]
eps[f1].append(f)
eps[f2].append(f)
elif tag == "cat":
s2, f2 = frags.pop()
s1, f1 = frags.pop()
eps[s].append(s1)
eps[f1].append(s2)
eps[f2].append(f)
elif tag == "star":
s1, f1 = frags.pop()
eps[s] += [s1, f]
eps[f1] += [s1, f]
elif tag != "empty":
raise ValueError(f"unknown node {node!r}")
frags.append((s, f))
start, final = frags.pop()
return NFA(eps, moves, start, frozenset([final]))
def glushkov_nfa(regex: Regex) -> NFA:
"""The Glushkov (position) automaton: no epsilon-moves, state 0 is initial."""
symbol = [""]
follow: list[set[int]] = [set()]
done: list[tuple[bool, set[int], set[int]]] = []
todo: list[tuple[Regex, bool]] = [(regex, False)]
while todo:
node, ready = todo.pop()
tag = node[0]
if not ready and tag in ("alt", "cat", "star"):
todo.append((node, True))
for child in reversed(node[1:]):
todo.append((child, False))
continue
if tag in ("empty", "eps"):
done.append((tag == "eps", set(), set()))
elif tag == "sym":
symbol.append(node[1])
follow.append(set())
p = len(symbol) - 1
done.append((False, {p}, {p}))
elif tag == "alt":
n2, f2, l2 = done.pop()
n1, f1, l1 = done.pop()
done.append((n1 or n2, f1 | f2, l1 | l2))
elif tag == "cat":
n2, f2, l2 = done.pop()
n1, f1, l1 = done.pop()
for p in l1:
follow[p] |= f2
done.append((n1 and n2, f1 | f2 if n1 else f1, l1 | l2 if n2 else l2))
elif tag == "star":
n1, f1, l1 = done.pop()
for p in l1:
follow[p] |= f1
done.append((True, f1, l1))
else:
raise ValueError(f"unknown node {node!r}")
nullable, first, last = done.pop()
follow[0] = first
moves = [sorted((symbol[q], q) for q in follow[p]) for p in range(len(symbol))]
accepting = set(last) | ({0} if nullable else set())
return NFA([[] for _ in symbol], moves, 0, frozenset(accepting))
@dataclass
class DState:
"""A cached DFA state: a set of NFA states and a partial transition table."""
nfa_set: frozenset[int]
accepting: bool
next: dict[str, DState] = field(default_factory=dict)
class LazyDFA:
"""The subset DFA of `nfa`, built on demand.
capacity is the largest number of cached DFA states (None for no limit).
With unanchored=True, the NFA start state is added after every symbol,
so a state is accepting when some substring ending here is accepted.
"""
def __init__(self, nfa: NFA, capacity: int | None = None, unanchored: bool = False) -> None:
if capacity is not None and capacity < 1:
raise ValueError("capacity must be at least 1")
self.nfa = nfa
self.capacity = capacity
self.unanchored = unanchored
self.states: dict[frozenset[int], DState] = {} # @line L1
self.built = 0 # DFA states made, over all flushes
self.computed = 0 # transitions computed from the NFA
self.flushes = 0
self.start_set = self.closure([nfa.start])
def closure(self, qs: Iterable[int]) -> frozenset[int]:
"""All NFA states reachable from qs by epsilon-moves, with an explicit stack."""
seen = set(qs)
stack = list(seen)
while stack:
q = stack.pop()
for r in self.nfa.eps[q]:
if r not in seen:
seen.add(r)
stack.append(r)
return frozenset(seen)
def state(self, u: frozenset[int]) -> DState:
"""The cached DFA state for the NFA-state set u, made if needed."""
d = self.states.get(u)
if d is None: # @line L12
d = DState(u, not u.isdisjoint(self.nfa.accepting))
self.states[u] = d
self.built += 1
return d # @line L13
def step(self, s: DState, c: str) -> DState:
"""The DFA state after s on the symbol c."""
t = s.next.get(c) # @line L4
if t is None: # @line L5
targets = [q2 for q in s.nfa_set for (a, q2) in self.nfa.moves[q] if a == c]
if self.unanchored:
targets.append(self.nfa.start)
u = self.closure(targets) # @line L6
self.computed += 1
if self.capacity is not None and u not in self.states and len(self.states) >= self.capacity: # @line L7
self.states.clear()
self.flushes += 1
t = self.state(u) # @line L8
s.next[c] = t # @line L9
return t
def start(self) -> DState:
return self.state(self.start_set) # @line L2
def accepts(self, word: str) -> bool:
"""Decide whether the NFA accepts `word` (anchored at both ends)."""
s = self.start()
for c in word: # @line L3
s = self.step(s, c) # @line L10
return s.accepting # @line L11
def trace(self, word: str) -> list[frozenset[int]]:
"""The NFA-state sets after each prefix of `word` (for examples)."""
s = self.start()
out = [s.nfa_set]
for c in word:
s = self.step(s, c)
out.append(s.nfa_set)
return out
def match_ends(self, text: str) -> list[int]:
"""With unanchored=True: every i such that some text[j:i] is accepted."""
s = self.start()
out = [0] if s.accepting else []
for i, c in enumerate(text, 1):
s = self.step(s, c)
if s.accepting:
out.append(i)
return out
def lazy_dfa_match(regex: Regex, word: str, capacity: int | None = None) -> bool:
"""Decide whether `word` is in L(regex) with a lazy DFA over the Thompson NFA.
Cost: O(m) time per transition computed and O(1) per cached transition,
so O(m n) in the worst case for m nodes and n symbols.
"""
return LazyDFA(thompson_nfa(regex), capacity).accepts(word)
def lazy_dfa_search(regex: Regex, text: str, capacity: int | None = None) -> list[int]:
"""The end positions i of the substrings text[j:i] that are in L(regex)."""
return LazyDFA(thompson_nfa(regex), capacity, unanchored=True).match_ends(text)
test_lazy_dfa_construction.py 240 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/lazy-dfa-construction/. The authors are listed in prov/1VRZ3Y.jsonl.
import itertools
import random
from lazy_dfa_construction import (
LazyDFA,
glushkov_nfa,
lazy_dfa_match,
lazy_dfa_search,
thompson_nfa,
)
EMPTY = ("empty",)
EPS = ("eps",)
def parse(text):
"""Parse | , concatenation, *, parentheses, ε and ∅ into the tuple form."""
pos = 0
def peek():
return text[pos] if pos < len(text) else None
def p_alt():
nonlocal pos
r = p_cat()
while peek() == "|":
pos += 1
r = ("alt", r, p_cat())
return r
def p_cat():
items = []
while peek() is not None and peek() not in "|)":
items.append(p_rep())
if not items:
return EPS
r = items[0]
for x in items[1:]:
r = ("cat", r, x)
return r
def p_rep():
nonlocal pos
r = p_atom()
while peek() == "*":
pos += 1
r = ("star", r)
return r
def p_atom():
nonlocal pos
c = peek()
pos += 1
if c == "(":
r = p_alt()
assert peek() == ")"
pos += 1
return r
if c == "ε":
return EPS
if c == "∅":
return EMPTY
return ("sym", c)
r = p_alt()
assert pos == len(text)
return r
def language(r, bound):
"""All strings of L(r) of length at most bound, by enumeration."""
t = r[0]
if t == "empty":
return set()
if t == "eps":
return {""}
if t == "sym":
return {r[1]} if bound >= 1 else set()
if t == "alt":
return language(r[1], bound) | language(r[2], bound)
if t == "cat":
a, b = language(r[1], bound), language(r[2], bound)
return {x + y for x in a for y in b if len(x) + len(y) <= bound}
body = language(r[1], bound) - {""}
result, frontier = {""}, {""}
while frontier:
frontier = {x + y for x in frontier for y in body if len(x) + len(y) <= bound} - result
result |= frontier
return result
def words(alphabet, bound):
for k in range(bound + 1):
for t in itertools.product(alphabet, repeat=k):
yield "".join(t)
def random_regex(rng, alphabet, depth):
if depth == 0 or rng.random() < 0.25:
x = rng.random()
if x < 0.08:
return EMPTY
if x < 0.18:
return EPS
return ("sym", rng.choice(alphabet))
k = rng.random()
if k < 0.35:
return ("alt", random_regex(rng, alphabet, depth - 1), random_regex(rng, alphabet, depth - 1))
if k < 0.75:
return ("cat", random_regex(rng, alphabet, depth - 1), random_regex(rng, alphabet, depth - 1))
return ("star", random_regex(rng, alphabet, depth - 1))
def l_n(n):
ab = ("alt", ("sym", "a"), ("sym", "b"))
r = ("cat", ("star", ab), ("sym", "a"))
for _ in range(n - 1):
r = ("cat", r, ab)
return r
def test_example_glushkov_trace():
d = LazyDFA(glushkov_nfa(parse("(a|b)*ab")))
assert [sorted(s) for s in d.trace("abab")] == [[0], [1, 3], [2, 4], [1, 3], [2, 4]]
assert d.built == 3
assert d.computed == 3
assert d.accepts("abab")
assert d.computed == 3 # the second run uses only cached transitions
def test_example_thompson():
r = parse("(a|b)*ab")
nfa = thompson_nfa(r)
assert len(nfa.eps) == 16
d = LazyDFA(nfa)
assert d.accepts("abab")
assert d.built == 3
assert not d.accepts("aba")
def test_example_blowup_and_flush():
rng = random.Random(7)
text = "".join(rng.choice("ab") for _ in range(3000))
r = l_n(12)
full = LazyDFA(glushkov_nfa(r))
assert full.accepts(text) == (text[-12] == "a")
assert full.built <= len(text) + 1
small = LazyDFA(glushkov_nfa(r), capacity=100)
assert small.accepts(text) == full.accepts(text)
assert small.flushes > 0
assert len(small.states) <= 100
def test_full_dfa_size_of_l_n():
for n in range(1, 9):
d = LazyDFA(glushkov_nfa(l_n(n)))
todo = [d.start()]
seen = {todo[0].nfa_set}
while todo:
s = todo.pop()
for c in "ab":
t = d.step(s, c)
if t.nfa_set not in seen:
seen.add(t.nfa_set)
todo.append(t)
assert len(seen) == 2**n + 1
def test_edge_cases():
for nfa_of in (thompson_nfa, glushkov_nfa):
assert not LazyDFA(nfa_of(EMPTY)).accepts("")
assert not LazyDFA(nfa_of(EMPTY)).accepts("a")
assert LazyDFA(nfa_of(EPS)).accepts("")
assert not LazyDFA(nfa_of(EPS)).accepts("a")
assert LazyDFA(nfa_of(parse("(a*)*"))).accepts("")
assert LazyDFA(nfa_of(parse("(a*)*"))).accepts("aaa")
assert not LazyDFA(nfa_of(parse("(a*)*b"))).accepts("aaa")
assert lazy_dfa_search(EPS, "ab") == [0, 1, 2]
assert lazy_dfa_search(EMPTY, "ab") == []
assert lazy_dfa_search(parse("ab*a"), "babbaa") == [5, 6]
for cap in (1, 2, 3):
assert lazy_dfa_match(parse("(a|b)*ab"), "abab", capacity=cap)
def test_long_inputs_without_recursion():
chain = ("sym", "a")
for _ in range(3000):
chain = ("cat", chain, ("sym", "a"))
assert lazy_dfa_match(chain, "a" * 3001)
assert not lazy_dfa_match(chain, "a" * 3000)
right = ("sym", "a")
for _ in range(3000):
right = ("cat", ("sym", "a"), right)
assert lazy_dfa_match(right, "a" * 3001)
assert lazy_dfa_match(parse("(a|b)*a"), "ab" * 50000 + "a")
def test_random_against_enumeration():
rng = random.Random(2024)
cases = 0
for alphabet in ("ab", "abc"):
for _ in range(350):
r = random_regex(rng, alphabet, rng.randint(0, 4))
lang = language(r, 5)
nfas = (thompson_nfa(r), glushkov_nfa(r))
for cap in (None, 1, 2, 5):
ds = [LazyDFA(nfa, cap) for nfa in nfas]
for w in words(alphabet, 5):
for d in ds:
assert d.accepts(w) == (w in lang), (r, w, cap)
cases += 1
assert cases >= 500
def test_random_search_against_enumeration():
rng = random.Random(99)
for _ in range(300):
alphabet = rng.choice(("ab", "abc"))
r = random_regex(rng, alphabet, rng.randint(0, 4))
lang = language(r, 7)
text = "".join(rng.choice(alphabet) for _ in range(rng.randint(0, 7)))
want = [i for i in range(len(text) + 1) if any(text[j:i] in lang for j in range(i + 1))]
for cap in (None, 1, 3):
assert lazy_dfa_search(r, text, capacity=cap) == want, (r, text, cap)
def test_random_nested_stars():
rng = random.Random(5)
for _ in range(200):
alphabet = rng.choice(("ab", "abc"))
r = random_regex(rng, alphabet, 3)
for _ in range(rng.randint(1, 3)):
r = ("star", ("alt", r, EPS) if rng.random() < 0.5 else r)
lang = language(r, 5)
d = LazyDFA(thompson_nfa(r), capacity=2)
for w in words(alphabet, 5):
assert d.accepts(w) == (w in lang), (r, w)
What the next level needs
- the tests did not run in this build