Algorithm Search⌘K

Thompson's NFA simulation in Python

Simulates an NFA on the input by tracking the set of active states, in O(mn) time.

File
thompson_nfa_simulation.py, 185 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
thompson_nfa_simulation.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/thompson-nfa-simulation/. The authors are listed in prov/X9HKD8.jsonl.

"""Thompson's NFA simulation: match a regular expression in O(mn) time.

Syntax: union |, concatenation by juxtaposition, postfix star *, the
constants ε (empty string) and ∅ (empty set), and parentheses. The pattern is
first compiled by Thompson's construction (copied here so that the module is
self-contained), then the NFA is run on the text with a set of active states.
No step uses recursion.
"""

from dataclasses import dataclass, field

EPS = "ε"
EMPTY = "∅"
CAT = "."  # the concatenation operator in the postfix form


def to_postfix(pattern: str) -> list[tuple[str, str]]:
    """Return the expression in postfix form as (kind, value) tokens.

    kind is "sym", "eps", "empty" or "op"; ops are "|", CAT and "*".
    Raises ValueError on a malformed pattern.
    """
    prec = {"|": 1, CAT: 2}
    out: list[tuple[str, str]] = []
    ops: list[str] = []
    prev_operand = False  # True if the last item can end an operand
    for c in pattern:
        if c == " ":
            continue
        if c in "(" or c not in "|*)":
            if prev_operand:  # implicit concatenation
                while ops and ops[-1] != "(" and prec[ops[-1]] >= prec[CAT]:
                    out.append(("op", ops.pop()))
                ops.append(CAT)
            if c == "(":
                ops.append("(")
                prev_operand = False
                continue
            kind = "eps" if c == EPS else "empty" if c == EMPTY else "sym"
            out.append((kind, c))
            prev_operand = True
        elif c == "*":
            if not prev_operand:
                raise ValueError("* without an operand")
            out.append(("op", "*"))
        elif c == "|":
            if not prev_operand:
                raise ValueError("| without a left operand")
            while ops and ops[-1] != "(" and prec[ops[-1]] >= prec["|"]:
                out.append(("op", ops.pop()))
            ops.append("|")
            prev_operand = False
        else:  # ")"
            if not prev_operand:
                raise ValueError("empty operand before )")
            while ops and ops[-1] != "(":
                out.append(("op", ops.pop()))
            if not ops:
                raise ValueError("unbalanced )")
            ops.pop()
            prev_operand = True
    if not prev_operand:
        raise ValueError("missing operand at the end")
    while ops:
        op = ops.pop()
        if op == "(":
            raise ValueError("unbalanced (")
        out.append(("op", op))
    return out


@dataclass
class NFA:
    """An epsilon-NFA with states 0 .. n-1, one start and one accepting state.

    sym[q] is None or a pair (a, r) for the move q --a--> r.
    eps[q] lists the targets of the epsilon-moves of q (at most two).
    """

    start: int = 0
    accept: int = 0
    sym: list[tuple[str, int] | None] = field(default_factory=list)
    eps: list[list[int]] = field(default_factory=list)

    def new_state(self) -> int:
        self.sym.append(None)
        self.eps.append([])
        return len(self.sym) - 1

    @property
    def size(self) -> int:
        return len(self.sym)


def thompson(pattern: str) -> NFA:
    """Build the Thompson epsilon-NFA of a regular expression.

    The NFA has at most 2m states and 4m moves for an expression of size m.
    """
    nfa = NFA()
    stack: list[tuple[int, int]] = []  # fragments (start, accept)
    for kind, v in to_postfix(pattern):
        if kind != "op":
            s, f = nfa.new_state(), nfa.new_state()
            if kind == "sym":
                nfa.sym[s] = (v, f)
            elif kind == "eps":
                nfa.eps[s].append(f)
            stack.append((s, f))
        elif v == "|":
            s2, f2 = stack.pop()
            s1, f1 = stack.pop()
            s, f = nfa.new_state(), nfa.new_state()
            nfa.eps[s] += [s1, s2]
            nfa.eps[f1].append(f)
            nfa.eps[f2].append(f)
            stack.append((s, f))
        elif v == CAT:
            s2, f2 = stack.pop()
            s1, f1 = stack.pop()
            nfa.eps[f1].append(s2)
            stack.append((s1, f2))
        else:  # star
            s1, f1 = stack.pop()
            s, f = nfa.new_state(), nfa.new_state()
            nfa.eps[s] += [s1, f]
            nfa.eps[f1] += [s1, f]
            stack.append((s, f))
    nfa.start, nfa.accept = stack.pop()
    assert not stack
    return nfa


def _add(nfa: NFA, lst: list[int], mark: list[int], q: int, step: int) -> None:
    """Add q and every state that epsilon-moves reach from it to lst, once per step."""
    stack = [q]  # @line L10
    while stack:  # @line L11
        p = stack.pop()  # @line L12
        if mark[p] == step:  # @line L13
            continue
        mark[p] = step  # @line L14
        lst.append(p)
        stack.extend(reversed(nfa.eps[p]))  # @line L15


def run(nfa: NFA, text: str, search: bool = False) -> bool:
    """Run the Thompson NFA on text.

    With search=False, decide whether the whole text is in the language.
    With search=True, decide whether some substring of the text is in the
    language, by starting a new run at each position.
    Cost: O(m) per symbol, O(m n) in total, O(m) extra space.
    """
    mark = [-1] * nfa.size
    cur: list[int] = []
    _add(nfa, cur, mark, nfa.start, 0)  # @line L2
    if search and mark[nfa.accept] == 0:
        return True
    for i, a in enumerate(text, start=1):  # @line L3
        nxt: list[int] = []  # @line L4
        for q in cur:  # @line L5
            m = nfa.sym[q]
            if m is not None and m[0] == a:  # @line L6
                _add(nfa, nxt, mark, m[1], i)
        if search:
            _add(nfa, nxt, mark, nfa.start, i)
            if mark[nfa.accept] == i:
                return True
        cur = nxt  # @line L7
        if not cur:  # @line L8
            return False
    return mark[nfa.accept] == len(text)  # @line L9


def full_match(pattern: str, text: str) -> bool:
    """Decide whether text is in the language of pattern."""
    return run(thompson(pattern), text)  # @line L1


def search(pattern: str, text: str) -> bool:
    """Decide whether some substring of text is in the language of pattern."""
    return run(thompson(pattern), text, search=True)
test_thompson_nfa_simulation.py 180 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/thompson-nfa-simulation/. The authors are listed in prov/X9HKD8.jsonl.

import itertools
import random

import pytest

from thompson_nfa_simulation import _add, full_match, run, search, thompson

# A brute-force reference: the language of an expression up to a length bound,
# computed from a small recursive parser on short random patterns only.


def parse(p):
    p = p.replace(" ", "")
    i = 0

    def alt():
        nonlocal i
        r = cat()
        while i < len(p) and p[i] == "|":
            i += 1
            r = ("alt", r, cat())
        return r

    def cat():
        r = None
        while i < len(p) and p[i] not in "|)":
            x = star()
            r = x if r is None else ("cat", r, x)
        return r

    def star():
        nonlocal i
        r = atom()
        while i < len(p) and p[i] == "*":
            i += 1
            r = ("star", r)
        return r

    def atom():
        nonlocal i
        c = p[i]
        i += 1
        if c == "(":
            r = alt()
            i += 1
            return r
        return ("eps",) if c == "ε" else ("empty",) if c == "∅" else ("sym", c)

    return alt()


def lang(r, n):
    t = r[0]
    if t == "empty":
        return set()
    if t == "eps":
        return {""}
    if t == "sym":
        return {r[1]}
    if t == "alt":
        return lang(r[1], n) | lang(r[2], n)
    if t == "cat":
        a, b = lang(r[1], n), lang(r[2], n)
        return {x + y for x in a for y in b if len(x + y) <= n}
    base = lang(r[1], n) - {""}
    out, frontier = {""}, {""}
    while frontier:
        frontier = {x + y for x in frontier for y in base if len(x + y) <= n} - out
        out |= frontier
    return out


def size(r):
    return 1 + sum(size(x) for x in r[1:] if isinstance(x, tuple))


def show(r):
    t = r[0]
    if t in ("eps", "empty"):
        return "ε" if t == "eps" else "∅"
    if t == "sym":
        return r[1]
    if t == "star":
        return "(" + show(r[1]) + ")*"
    op = "|" if t == "alt" else ""
    return "(" + show(r[1]) + op + show(r[2]) + ")"


def rand_re(rng, alpha, depth):
    if depth == 0 or rng.random() < 0.25:
        k = rng.random()
        return ("empty",) if k < 0.1 else ("eps",) if k < 0.2 else ("sym", rng.choice(alpha))
    k = rng.random()
    if k < 0.35:
        return ("alt", rand_re(rng, alpha, depth - 1), rand_re(rng, alpha, depth - 1))
    if k < 0.7:
        return ("cat", rand_re(rng, alpha, depth - 1), rand_re(rng, alpha, depth - 1))
    return ("star", rand_re(rng, alpha, depth - 1))


def words(alpha, n):
    for k in range(n + 1):
        for t in itertools.product(alpha, repeat=k):
            yield "".join(t)


def trace(pattern, text):
    nfa = thompson(pattern)
    mark = [-1] * nfa.size
    cur = []
    _add(nfa, cur, mark, nfa.start, 0)
    out = [sorted(cur)]
    for i, a in enumerate(text, start=1):
        nxt = []
        for q in cur:
            m = nfa.sym[q]
            if m is not None and m[0] == a:
                _add(nfa, nxt, mark, m[1], i)
        cur = nxt
        out.append(sorted(cur))
    return out


def test_example():
    t = trace("(a|b)*abb", "abab")
    assert t == [
        [0, 2, 4, 6, 7, 8],
        [0, 1, 2, 4, 5, 7, 8, 9, 10],
        [0, 2, 3, 4, 5, 7, 8, 11, 12],
        [0, 1, 2, 4, 5, 7, 8, 9, 10],
        [0, 2, 3, 4, 5, 7, 8, 11, 12],
    ]
    assert not full_match("(a|b)*abb", "abab")
    assert full_match("(a|b)*abb", "babb")
    for w in words("ab", 9):
        assert full_match("(a|b)*abb", w) == w.endswith("abb")
        assert search("abb", w) == ("abb" in w)


def test_pathological_is_fast():
    n = 30
    p = "(a|ε)" * n + "a" * n
    assert full_match(p, "a" * n)
    assert not full_match(p, "a" * (n - 1))
    assert not full_match(p, "a" * (2 * n + 1))
    assert full_match("(a|a)*b", "a" * 5000 + "b")
    assert not full_match("(a|a)*b", "a" * 5000)


def test_edges():
    assert not full_match("∅", "") and not search("∅", "abc")
    assert full_match("ε", "") and not full_match("ε", "a")
    assert search("ε", "") and search("ε", "abc")
    assert full_match("∅*", "") and not full_match("∅*", "a")
    assert full_match("((a*)*)*", "") and full_match("((a*)*)*", "aaa")
    assert not full_match("a", "")
    assert search("b*", "") and not search("a∅", "aaa")


@pytest.mark.parametrize("alpha", ["ab", "abc"])
def test_random_against_brute_force(alpha):
    rng = random.Random(7 + len(alpha))
    for _ in range(400):
        r = rand_re(rng, alpha, 4)
        p = show(r)
        nfa = thompson(p)
        want = lang(r, 5)
        for w in words(alpha, 5):
            assert run(nfa, w) == (w in want), (p, w)
            subs = {w[i:j] for i in range(len(w) + 1) for j in range(i, len(w) + 1)}
            assert run(nfa, w, search=True) == bool(subs & want), (p, w)


def test_long_text_and_deep_pattern():
    p = "(" * 2000 + "ab" + ")*" * 2000
    assert full_match(p, "ab" * 3000)
    assert not full_match(p, "ab" * 3000 + "a")

What the next level needs

  • the tests did not run in this build

Read the full page about Thompson's NFA simulation