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
# 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