Algorithm Search⌘K

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

Read the full page about Lazy DFA construction