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