Algorithm Search⌘K

Thompson's construction in Python

Builds an epsilon-NFA with O(m) states from a regex of size m by composing fragments.

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

"""Thompson's construction: a regular expression to an epsilon-NFA.

Syntax: union |, concatenation by juxtaposition, postfix star *, the
constants ε (empty string) and ∅ (empty set), and parentheses. Every other
character is a symbol. Star binds tighter than concatenation, which binds
tighter than union. Parsing and construction use explicit stacks, so deeply
nested expressions need no 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)  @line L1
    for kind, v in to_postfix(pattern):  # @line L2
        if kind != "op":  # @line L3
            s, f = nfa.new_state(), nfa.new_state()  # @line L4
            if kind == "sym":
                nfa.sym[s] = (v, f)  # @line L5
            elif kind == "eps":
                nfa.eps[s].append(f)  # @line L6
            stack.append((s, f))  # @line L7
        elif v == "|":  # @line L8
            s2, f2 = stack.pop()  # @line L9
            s1, f1 = stack.pop()
            s, f = nfa.new_state(), nfa.new_state()
            nfa.eps[s] += [s1, s2]  # @line L10
            nfa.eps[f1].append(f)
            nfa.eps[f2].append(f)
            stack.append((s, f))
        elif v == CAT:  # @line L11
            s2, f2 = stack.pop()  # @line L12
            s1, f1 = stack.pop()
            nfa.eps[f1].append(s2)
            stack.append((s1, f2))
        else:  # star  @line L13
            s1, f1 = stack.pop()  # @line L14
            s, f = nfa.new_state(), nfa.new_state()
            nfa.eps[s] += [s1, f]  # @line L15
            nfa.eps[f1] += [s1, f]
            stack.append((s, f))
    nfa.start, nfa.accept = stack.pop()  # @line L16
    assert not stack
    return nfa


def accepts(nfa: NFA, word: str) -> bool:
    """Run the NFA on a word by the set-of-states simulation."""

    def close(states: set[int]) -> set[int]:
        todo = list(states)
        while todo:
            q = todo.pop()
            for r in nfa.eps[q]:
                if r not in states:
                    states.add(r)
                    todo.append(r)
        return states

    cur = close({nfa.start})
    for a in word:
        nxt = set()
        for q in cur:
            m = nfa.sym[q]
            if m is not None and m[0] == a:
                nxt.add(m[1])
        cur = close(nxt)
    return nfa.accept in cur
test_thompson_construction.py 165 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/thompson-construction/. The authors are listed in prov/WDNKY8.jsonl.

import itertools
import random

import pytest

from thompson_construction import accepts, thompson, to_postfix

# 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 check_shape(nfa, m):
    assert nfa.size <= 2 * m
    moves = sum(len(e) for e in nfa.eps) + sum(x is not None for x in nfa.sym)
    assert moves <= 4 * m
    incoming = [0] * nfa.size
    for q in range(nfa.size):
        out = list(nfa.eps[q]) + ([nfa.sym[q][1]] if nfa.sym[q] else [])
        assert len(out) <= 2
        assert not (nfa.sym[q] and nfa.eps[q])
        for r in out:
            incoming[r] += 1
    assert incoming[nfa.start] == 0
    assert not nfa.eps[nfa.accept] and nfa.sym[nfa.accept] is None


def test_example():
    nfa = thompson("(a|b)*abb")
    assert nfa.size == 14 and nfa.start == 6 and nfa.accept == 13
    assert nfa.sym[0] == ("a", 1) and nfa.sym[2] == ("b", 3)
    assert nfa.eps[4] == [0, 2] and nfa.eps[5] == [4, 7] and nfa.eps[6] == [4, 7]
    assert nfa.eps[7] == [8] and nfa.eps[9] == [10] and nfa.eps[11] == [12]
    for w in words("ab", 8):
        assert accepts(nfa, w) == w.endswith("abb")


def test_edges():
    assert not accepts(thompson("∅"), "")
    assert accepts(thompson("ε"), "") and not accepts(thompson("ε"), "a")
    assert accepts(thompson("∅*"), "") and not accepts(thompson("∅*"), "a")
    assert accepts(thompson("((a*)*)*"), "aaaa")
    assert not accepts(thompson("(ab*)*"), "b")  # the shortcut star would accept it
    for bad in ["", "|a", "a|", "*", "(a", "a)", "()", "a(|b)"]:
        with pytest.raises(ValueError):
            to_postfix(bad)


@pytest.mark.parametrize("alpha", ["ab", "abc"])
def test_random_against_brute_force(alpha):
    rng = random.Random(len(alpha))
    for _ in range(400):
        r = rand_re(rng, alpha, 4)
        p = show(r)
        assert parse(p) is not None
        nfa = thompson(p)
        check_shape(nfa, size(parse(p)))
        want = lang(r, 5)
        for w in words(alpha, 5):
            assert accepts(nfa, w) == (w in want), (p, w)


def test_deep_nesting_needs_no_recursion():
    p = "(" * 3000 + "a" + ")*" * 3000
    nfa = thompson(p)
    assert accepts(nfa, "aaa") and not accepts(nfa, "b")
    long = "a" * 5000
    assert accepts(thompson(long), long)

What the next level needs

  • the tests did not run in this build

Read the full page about Thompson's construction