Greibach normal form conversion in Python
Transforms a context-free grammar into an equivalent grammar in Greibach normal form.
- File
greibach_normal_form_conversion.py, 199 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/greibach-normal-form-conversion/. The authors are listed in prov/0QNXET.jsonl.
"""Convert a context-free grammar to Greibach normal form.
A grammar is a list of productions (A, rhs), where rhs is a tuple of symbols.
The nonterminals are the left sides; every other symbol is a terminal. The
result has only productions A -> a B1 ... Bk with a terminal a and nonterminals
Bi, plus S0 -> () for a fresh start S0 when the empty string is in the
language; S0 then occurs on no right side.
The method is the classical one: clean the grammar (no empty productions, no
unit productions, no useless symbols, terminals only in first position), order
the nonterminals, substitute and remove immediate left recursion so that each
A_i -> A_j ... has j > i, then substitute back from the last nonterminal to the
first. The output can be exponentially larger than the input.
"""
from itertools import product
from typing import Sequence
Production = tuple[str, tuple[str, ...]]
Grammar = dict[str, list[tuple[str, ...]]]
def _fresh(base: str, used: set[str]) -> str:
name = base
k = 0
while name in used:
k += 1
name = f"{base}{k}"
used.add(name)
return name
def _nullable(g: Grammar) -> set[str]:
null: set[str] = set()
changed = True
while changed:
changed = False
for a, rhss in g.items():
if a not in null and any(all(x in null for x in r) for r in rhss):
null.add(a)
changed = True
return null
def _add(g: Grammar, a: str, r: tuple[str, ...]) -> None:
if r not in g[a]:
g[a].append(r)
def _clean(g: Grammar, start: str, nts: set[str]) -> Grammar:
"""Remove empty productions, unit productions and useless symbols."""
null = _nullable(g)
h: Grammar = {a: [] for a in g}
for a, rhss in g.items():
for r in rhss:
opts = [((x,), ()) if x in null else ((x,),) for x in r]
for pick in product(*opts):
nr = tuple(s for part in pick for s in part)
if nr:
_add(h, a, nr)
# unit pairs (A, B) with A =>* B by unit productions only
unit: dict[str, set[str]] = {}
for a in h:
seen = {a}
stack = [a]
while stack:
b = stack.pop()
for r in h[b]:
if len(r) == 1 and r[0] in nts and r[0] not in seen:
seen.add(r[0])
stack.append(r[0])
unit[a] = seen
k: Grammar = {a: [] for a in h}
for a in h:
for b in unit[a]:
for r in h[b]:
if not (len(r) == 1 and r[0] in nts):
_add(k, a, r)
# generating, then reachable
gen: set[str] = set()
changed = True
while changed:
changed = False
for a, rhss in k.items():
if a not in gen and any(all(x not in nts or x in gen for x in r) for r in rhss):
gen.add(a)
changed = True
k = {a: [r for r in rhss if all(x not in nts or x in gen for x in r)] for a, rhss in k.items() if a in gen}
if start not in k:
return {}
reach = {start}
stack = [start]
while stack:
b = stack.pop()
for r in k[b]:
for x in r:
if x in k and x not in reach:
reach.add(x)
stack.append(x)
return {a: k[a] for a in k if a in reach}
def to_gnf(productions: Sequence[Production], start: str) -> tuple[list[Production], str]:
"""Return (gnf_productions, gnf_start) generating the same language."""
nts = {a for a, _ in productions}
used = set(nts) | {x for _, r in productions for x in r}
g: Grammar = {}
for a, _ in productions: # nonterminals in order of first appearance
g.setdefault(a, [])
for a, r in productions:
_add(g, a, tuple(r))
has_empty = start in _nullable(g) # @line L1
g = _clean(g, start, nts) # @line L2
term_nt: dict[str, str] = {}
order: list[str] = list(g) # @line L3
for a in list(g): # @line L4
new = []
for r in g[a]:
tail = []
for x in r[1:]:
if x not in g:
if x not in term_nt:
term_nt[x] = _fresh("T_" + x, used)
order.append(term_nt[x])
x = term_nt[x]
tail.append(x)
new.append((r[0],) + tuple(tail))
g[a] = new
for x, t in term_nt.items():
g[t] = [(x,)]
rank = {a: i for i, a in enumerate(order)}
extra: list[str] = []
for i, ai in enumerate(order): # @line L5
for j in range(i): # @line L6
aj = order[j]
new: dict[tuple[str, ...], None] = {}
for r in g[ai]:
if r[0] == aj: # @line L7
for d in g[aj]:
new[d + r[1:]] = None
else:
new[r] = None
g[ai] = list(new)
rec = [r[1:] for r in g[ai] if r[0] == ai] # @line L8
if rec:
rest = [r for r in g[ai] if r[0] != ai]
b = _fresh(ai + "_rec", used)
extra.append(b)
g[ai] = rest + [r + (b,) for r in rest]
g[b] = rec + [r + (b,) for r in rec]
for i in range(len(order) - 2, -1, -1): # @line L9
ai = order[i]
new = {}
for r in g[ai]:
if r[0] in rank: # leading A_j with j > i
for d in g[r[0]]:
new[d + r[1:]] = None
else:
new[r] = None
g[ai] = list(new)
pending = list(extra) # @line L10
while pending:
b = pending.pop(0)
if any(r[0] in g and r[0] in pending for r in g[b]):
pending.append(b) # a leading B that is not yet in Greibach form
continue
new = {}
for r in g[b]:
if r[0] in g:
for d in g[r[0]]:
new[d + r[1:]] = None
else:
new[r] = None
g[b] = list(new)
out: list[Production] = [(a, r) for a in order + extra for r in g.get(a, [])]
new_start = start
if has_empty: # @line L11
new_start = _fresh(start + "0", used)
out = [(new_start, ())] + [(new_start, r) for a, r in out if a == start] + out
return out, new_start # @line L12
def is_gnf(productions: Sequence[Production], start: str) -> bool:
"""Check the form: A -> a B1 ... Bk, plus start -> () with start on no right side."""
nts = {a for a, _ in productions}
for a, r in productions:
if not r:
if a != start:
return False
continue
if r[0] in nts or any(x not in nts for x in r[1:]):
return False
if any(not r for a, r in productions if a == start):
if any(start in r for _, r in productions):
return False
return True
test_greibach_normal_form_conversion.py 133 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/greibach-normal-form-conversion/. The authors are listed in prov/0QNXET.jsonl.
import itertools
import random
from greibach_normal_form_conversion import is_gnf, to_gnf
def language(productions, start, max_len):
"""Independent oracle: least fixed point of the sets of terminal strings
of length at most max_len derived by each nonterminal. Handles empty,
unit and cyclic productions directly."""
nts = {a for a, _ in productions}
lang = {a: set() for a in nts}
changed = True
while changed:
changed = False
for a, rhs in productions:
cur = {()}
for x in rhs:
part = lang[x] if x in nts else {(x,)}
cur = {u + v for u in cur for v in part if len(u) + len(v) <= max_len}
if not cur <= lang[a]:
lang[a] |= cur
changed = True
return lang.get(start, set())
def gnf_derives(productions, start, w):
"""Membership for a grammar in Greibach normal form: each step reads one
terminal, so a set of stacks of nonterminals is enough."""
if not w:
return (start, ()) in productions
by = {}
for a, r in productions:
if r:
by.setdefault((a, r[0]), []).append(r[1:])
stacks = {(start,)}
for x in w:
nxt = set()
for st in stacks:
if st:
for tail in by.get((st[0], x), []):
if len(tail) + len(st) - 1 <= len(w):
nxt.add(tuple(tail) + st[1:])
stacks = nxt
return () in stacks
def random_grammar(rng, nts, maxlen, eps=0.15):
names = ["S", "A", "B"][:nts]
prods = []
for a in names:
for _ in range(rng.randint(1, 3)):
if rng.random() < eps:
prods.append((a, ()))
else:
prods.append((a, tuple(rng.choice(names + ["a", "b"]) for _ in range(rng.randint(1, maxlen)))))
return list(dict.fromkeys(prods))
def words(max_len):
for m in range(max_len + 1):
yield from itertools.product("ab", repeat=m)
def check(g, start, max_len, self_check=False):
out, s0 = to_gnf(g, start)
assert is_gnf(out, s0)
ref = language(g, start, max_len)
if self_check: # the GNF membership test against the general oracle
assert language(out, s0, max_len) == ref
count = 0
for w in words(max_len):
assert (w in ref) == gnf_derives(out, s0, w), (g, w)
count += 1
return count
def test_hopcroft_ullman_example():
g = [("A1", ("A2", "A3")), ("A2", ("A3", "A1")), ("A2", ("b",)), ("A3", ("A1", "A2")), ("A3", ("a",))]
out, s0 = to_gnf(g, "A1")
assert s0 == "A1" and is_gnf(out, s0)
counts = {}
for a, _ in out:
counts[a] = counts.get(a, 0) + 1
assert counts == {"A1": 5, "A2": 5, "A3": 4, "A3_rec": 10}
assert ("A3", ("b", "A3", "A2", "A3_rec")) in out
check(g, "A1", 9, self_check=True)
assert {w for w in language(g, "A1", 5)} == {tuple("ba"), tuple("abaa"), tuple("bbab")}
def test_empty_string_and_edges():
out, s0 = to_gnf([("S", ())], "S")
assert out == [(s0, ())] and s0 != "S"
out, s0 = to_gnf([("S", ("S",))], "S")
assert out == []
for g in (
[("S", ("a", "S", "b")), ("S", ())],
[("S", ("S", "S")), ("S", ("a",)), ("S", ())],
[("S", ("A",)), ("A", ("B",)), ("B", ("S",)), ("B", ("a",))],
[("E", ("E", "+", "T")), ("E", ("T",)), ("T", ("a",))],
):
out, s0 = to_gnf(g, g[0][0])
assert is_gnf(out, s0)
ref = language(g, g[0][0], 6)
assert language(out, s0, 6) == ref
for w in words(6):
assert (w in ref) == gnf_derives(out, s0, w)
g = [("E", ("E", "+", "T")), ("E", ("T",)), ("T", ("a",))]
out, s0 = to_gnf(g, "E")
assert gnf_derives(out, s0, tuple("a+a+a+a"))
assert not gnf_derives(out, s0, tuple("a++a"))
def test_exponential_family():
for m in range(2, 8):
g = [(f"A{m}", (f"A{m-1}", x)) for x in "ab"]
g += [("A1", ("a",)), ("A1", ("b",))]
g += [(f"A{i}", (f"A{i-1}", x)) for i in range(2, m) for x in "ab"]
out, s0 = to_gnf(g, f"A{m}")
assert len(out) == 2 ** (m + 1)
def test_random_against_oracle():
rng = random.Random(20261106)
total = 0
for nts, maxlen in ((1, 3), (2, 3), (3, 2)):
for _ in range(250):
g = random_grammar(rng, nts, maxlen)
total += check(g, "S", 5, self_check=True)
assert total >= 500
What the next level needs
- the tests did not run in this build