Algorithm Search⌘K

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
greibach_normal_form_conversion.pyRawGitHub
# 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

Read the full page about Greibach normal form conversion