Derivative-based regex matching in Python
Matches a string by taking successive derivatives of the regex and checking nullability at the end.
- File
brzozowski_derivatives.py, 231 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/brzozowski-derivatives/. The authors are listed in prov/XAA52M.jsonl.
"""Regex matching by Brzozowski derivatives.
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).
Inside, expressions are hash-consed: each distinct node gets an integer id,
a union is stored as a frozenset of ids (so it is associative, commutative
and idempotent), and the constructors apply the rules for the empty language
and the empty string. Derivatives are memoized by (id, symbol).
"""
from __future__ import annotations
from collections.abc import Iterable
from typing import Any
Regex = tuple[Any, ...]
EMPTY = 0
EPS = 1
class Builder:
"""A table of normalized expressions, with nullability and derivative caches."""
def __init__(self) -> None:
self.nodes: list[tuple[Any, ...]] = [("empty",), ("eps",)]
self.index: dict[tuple[Any, ...], int] = {("empty",): EMPTY, ("eps",): EPS}
self.nullable: list[bool] = [False, True]
self.cache: dict[tuple[int, str], int] = {}
def _intern(self, node: tuple[Any, ...], nullable: bool) -> int:
found = self.index.get(node)
if found is not None:
return found
self.nodes.append(node)
self.nullable.append(nullable)
self.index[node] = len(self.nodes) - 1
return len(self.nodes) - 1
def sym(self, a: str) -> int:
return self._intern(("sym", a), False)
def cat(self, x: int, y: int) -> int:
"""Concatenation with ∅x = x∅ = ∅ and εx = xε = x."""
if x == EMPTY or y == EMPTY: # @line L10
return EMPTY
if x == EPS:
return y
if y == EPS:
return x
return self._intern(("cat", x, y), self.nullable[x] and self.nullable[y])
def alt(self, x: int, y: int) -> int:
"""Union as a set of alternatives, without ∅ and without repeats."""
return self.alt_many((x, y))
def alt_many(self, parts: Iterable[int]) -> int:
"""The union of all `parts`, flattened into one set of alternatives."""
members: set[int] = set() # @line L11
for z in parts:
node = self.nodes[z]
if node[0] == "alt":
members |= node[1]
elif z != EMPTY:
members.add(z)
if not members:
return EMPTY
if len(members) == 1:
return next(iter(members))
nullable = any(self.nullable[z] for z in members)
return self._intern(("alt", frozenset(members)), nullable)
def star(self, x: int) -> int:
"""Star with ∅* = ε* = ε and (x*)* = x*."""
if x == EMPTY or x == EPS: # @line L12
return EPS
if self.nodes[x][0] == "star":
return x
return self._intern(("star", x), True)
def build(self, regex: Regex) -> int:
"""Intern a tuple expression, in postorder with an explicit stack."""
out: list[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 == "empty":
out.append(EMPTY)
elif tag == "eps":
out.append(EPS)
elif tag == "sym":
out.append(self.sym(node[1]))
elif tag == "star":
out.append(self.star(out.pop()))
elif tag in ("alt", "cat"):
y = out.pop()
x = out.pop()
out.append(self.alt(x, y) if tag == "alt" else self.cat(x, y))
else:
raise ValueError(f"unknown node {node!r}")
return out.pop()
def derivative(self, r: int, a: str) -> int:
"""The normalized derivative of r by the symbol a.
Postorder over the nodes that the rules need, with an explicit stack;
results are kept in self.cache.
"""
cache = self.cache
todo: list[tuple[int, bool]] = [(r, False)]
while todo:
x, ready = todo.pop()
if (x, a) in cache:
continue
node = self.nodes[x]
tag = node[0]
if tag in ("empty", "eps"): # @line L5
cache[(x, a)] = EMPTY
continue
if tag == "sym": # @line L6
cache[(x, a)] = EPS if node[1] == a else EMPTY
continue
if tag == "alt":
needed = list(node[1])
elif tag == "cat":
needed = [node[1], node[2]] if self.nullable[node[1]] else [node[1]]
else:
needed = [node[1]]
if not ready:
todo.append((x, True))
todo.extend((z, False) for z in needed if (z, a) not in cache)
continue
if tag == "alt": # @line L7
d = self.alt_many(cache[(z, a)] for z in node[1])
elif tag == "cat": # @line L8
d = self.cat(cache[(node[1], a)], node[2])
if self.nullable[node[1]]:
d = self.alt(d, cache[(node[2], a)])
else: # @line L9
d = self.cat(cache[(node[1], a)], x)
cache[(x, a)] = d
return cache[(r, a)]
def show(self, x: int) -> str:
"""Render an expression with | , concatenation, *, ε and ∅ (for examples)."""
out: dict[int, str] = {}
prec: dict[int, int] = {}
todo: list[tuple[int, bool]] = [(x, False)]
while todo:
y, ready = todo.pop()
if y in out:
continue
node = self.nodes[y]
kids = sorted(node[1], key=self._key) if node[0] == "alt" else [z for z in node[1:] if isinstance(z, int)]
if node[0] == "sym":
kids = []
if not ready and kids:
todo.append((y, True))
todo.extend((z, False) for z in kids)
continue
tag = node[0]
if tag == "empty":
out[y], prec[y] = "∅", 3
elif tag == "eps":
out[y], prec[y] = "ε", 3
elif tag == "sym":
out[y], prec[y] = node[1], 3
elif tag == "star":
s = out[node[1]] if prec[node[1]] == 3 else "(" + out[node[1]] + ")"
out[y], prec[y] = s + "*", 3
elif tag == "cat":
parts = [out[z] if prec[z] >= 2 else "(" + out[z] + ")" for z in node[1:]]
out[y], prec[y] = "".join(parts), 2
else:
out[y], prec[y] = "|".join(out[z] for z in kids), 1
return out[x]
def _key(self, z: int) -> tuple[int, int]:
return (len(self.nodes[z]), z)
def derivative_match(regex: Regex, word: str, builder: Builder | None = None) -> bool:
"""Decide whether `word` is in L(regex) by taking one derivative per symbol.
Cost: per symbol, O(size of the current derivative) hash operations the
first time a (derivative, symbol) pair occurs, and O(1) after that.
"""
b = builder if builder is not None else Builder()
r = b.build(regex)
for c in word: # @line L1
r = b.derivative(r, c) # @line L2
if r == EMPTY: # @line L3
return False
return b.nullable[r] # @line L4
def derivative_dfa(regex: Regex, alphabet: str) -> tuple[list[int], list[dict[str, int]], set[int]]:
"""Build the DFA whose states are the normalized derivatives of `regex`.
Returns the states (expression ids, the first is the start), the
transition table and the set of accepting state numbers.
"""
b = Builder()
start = b.build(regex)
states = [start]
number = {start: 0}
delta: list[dict[str, int]] = []
i = 0
while i < len(states):
row = {}
for c in alphabet:
d = b.derivative(states[i], c)
if d not in number:
number[d] = len(states)
states.append(d)
row[c] = number[d]
delta.append(row)
i += 1
accepting = {number[s] for s in states if b.nullable[s]}
return states, delta, accepting
test_brzozowski_derivatives.py 191 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/brzozowski-derivatives/. The authors are listed in prov/XAA52M.jsonl.
import itertools
import random
from brzozowski_derivatives import Builder, derivative_dfa, derivative_match
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 test_example_page():
b = Builder()
r = b.build(parse("(a|b)*ab"))
shown = []
for c in "bab":
r = b.derivative(r, c)
shown.append((b.show(r), b.nullable[r]))
assert shown == [("(a|b)*ab", False), ("(ε|(a|b)*a)b", False), ("ε|(a|b)*ab", True)]
assert derivative_match(parse("(a|b)*ab"), "bab")
assert not derivative_match(parse("(a|b)*ab"), "ba")
def test_example_dfa_sizes():
sizes = {}
for text in ["(a|b)*ab", "(a|b)*a(a|b)(a|b)", "(a*)*b", "(a|a)*"]:
states, delta, accepting = derivative_dfa(parse(text), "ab")
sizes[text] = len(states)
assert sizes == {"(a|b)*ab": 3, "(a|b)*a(a|b)(a|b)": 8, "(a*)*b": 3, "(a|a)*": 2}
def test_edge_cases():
for text, yes, no in [
("∅", [], ["", "a"]),
("ε", [""], ["a"]),
("∅*", [""], ["a"]),
("ε*", [""], ["b"]),
("((a*)*)*", ["", "aaaa"], ["ab"]),
("(ε|∅)*a", ["a"], ["", "aa"]),
("a∅|∅", [], ["a", ""]),
]:
r = parse(text)
assert all(derivative_match(r, w) for w in yes)
assert not any(derivative_match(r, w) for w in no)
assert not derivative_match(parse("ab"), "ac")
def test_deep_and_long_without_recursion():
right = ("sym", "a")
for _ in range(20000):
right = ("cat", ("sym", "a"), right)
assert derivative_match(right, "a" * 20001)
assert not derivative_match(right, "a" * 20000)
left = ("sym", "a")
for _ in range(1500):
left = ("cat", left, ("sym", "a"))
assert derivative_match(left, "a" * 1501)
assert derivative_match(parse("(a|b)*a(a|b)(a|b)"), "ab" * 20000 + "abb")
def dfa_accepts(dfa, w):
states, delta, accepting = dfa
q = 0
for c in w:
q = delta[q][c]
return q in accepting
def test_random_against_enumeration():
rng = random.Random(20261106)
for trial in range(900):
alphabet = "ab" if trial % 2 else "abc"
bound = 6 if alphabet == "ab" else 4
r = random_regex(rng, alphabet, rng.randint(0, 5))
expected = language(r, bound)
b = Builder()
dfa = derivative_dfa(r, alphabet)
for w in words(alphabet, bound):
assert derivative_match(r, w, b) == (w in expected)
assert dfa_accepts(dfa, w) == (w in expected)
def test_random_nested_stars():
rng = random.Random(7)
for _ in range(300):
r = random_regex(rng, "ab", 3)
for _ in range(rng.randint(1, 3)):
r = ("star", ("alt", r, EPS)) if rng.random() < 0.5 else ("star", r)
expected = language(r, 6)
b = Builder()
for w in words("ab", 6):
assert derivative_match(r, w, b) == (w in expected)
What the next level needs
- the tests did not run in this build