LR(0) Items and the Canonical Automaton

Shift-reduce parsing left us with one hard question: at each step, is the top of the stack a complete handle ready to reduce, or merely a prefix of one that still needs more symbols shifted? Answering that deterministically — no guessing, no backtracking — is the whole game. Knuth's insight was that a finite automaton, run over the parse stack, can track "how far into some production body we currently are" and thus recognise exactly when a handle is complete. Building that automaton starts with a tiny, elegant object: the LR(0) item.

An LR(0) item is a production with a dot marking how much of the right-hand side we have already seen on the stack. The production A \to XYZ yields four items:

A \to {\cdot}\,XYZ, \qquad A \to X\,{\cdot}\,YZ, \qquad A \to XY\,{\cdot}\,Z, \qquad A \to XYZ\,{\cdot}

Read the dot as a cursor. A \to {\cdot}XYZ means "I hope to build an A here and have seen none of it yet"; A \to XY{\cdot}Z means "I've seen XY, I still need a Z"; and the complete item A \to XYZ{\cdot} means "the entire body is on the stack — this is a handle, reduce now". A production of length n gives n+1 items (the empty production A\to\varepsilon gives just A\to{\cdot}).

Augment the grammar first

Before anything else, add a fresh start symbol. If S is the old start, invent S' and the production S' \to S. This augmented grammar gives the parser a single, unambiguous way to say "I'm done": a reduction by S' \to S is the accept action, and nothing is ever reduced past the real start symbol by accident. It costs one extra production and buys a clean acceptance condition.

Two operations: CLOSURE and GOTO

States of the automaton are sets of items, and two operations generate them all.

The canonical collection of LR(0) item sets is then just: start from \text{CLOSURE}(\{S' \to {\cdot}S\}) and keep applying GOTO on every grammar symbol until no new item set is produced. Those item sets are the automaton's states; the GOTO edges are its transitions. This is a worklist/BFS exactly like the subset construction for NFA→DFA — and for the same reason it must terminate: there are only finitely many subsets of the (finite) set of items.

The automaton for a tiny grammar

Take the augmented grammar

S' \to S, \qquad S \to A\,A, \qquad A \to a\,A \;\mid\; b.

(It generates strings of the form a^{*}b\,a^{*}b — two "runs of as ending in b".) Running CLOSURE and GOTO to exhaustion produces seven item sets, I_0 through I_6. Reveal the automaton one state at a time:

Each state is the full set of items listed below. States with a complete item (dot at the far right) are where reductions happen: I_4 (A \to b{\cdot}), I_5 (S \to AA{\cdot}), I_6 (A \to aA{\cdot}), and I_1 (S' \to S{\cdot}, the accept state).

StateItemsGOTO / shift edges
I_0S'\!\to{\cdot}S,\; S\to{\cdot}AA,\; A\to{\cdot}aA,\; A\to{\cdot}bS→I₁, A→I₂, a→I₃, b→I₄
I_1S'\!\to S{\cdot}accept
I_2S\to A{\cdot}A,\; A\to{\cdot}aA,\; A\to{\cdot}bA→I₅, a→I₃, b→I₄
I_3A\to a{\cdot}A,\; A\to{\cdot}aA,\; A\to{\cdot}bA→I₆, a→I₃, b→I₄
I_4A\to b{\cdot}reduce A\to b
I_5S\to AA{\cdot}reduce S\to AA
I_6A\to aA{\cdot}reduce A\to aA

Computing it yourself

CLOSURE and GOTO are short set operations, and the canonical collection is a BFS over them. Here is the whole construction for the grammar above — it prints the seven item sets and every transition, matching the table exactly.

type Prod = { lhs: string; rhs: string[] }; // 0: S'->S 1: S->A A 2: A->a A 3: A->b const G: Prod[] = [ { lhs: "S'", rhs: ["S"] }, { lhs: "S", rhs: ["A", "A"] }, { lhs: "A", rhs: ["a", "A"] }, { lhs: "A", rhs: ["b"] }, ]; const NONTERM = new Set(["S'", "S", "A"]); const SYMBOLS = ["S", "A", "a", "b"]; type Item = { p: number; dot: number }; const itemKey = (it: Item) => `${it.p}:${it.dot}`; const setKey = (its: Item[]) => its.map(itemKey).sort().join("|"); function closure(seed: Item[]): Item[] { const out = [...seed]; const seen = new Set(out.map(itemKey)); for (let i = 0; i < out.length; i++) { // worklist grows in place const { p, dot } = out[i]; const sym = G[p].rhs[dot]; if (sym !== undefined && NONTERM.has(sym)) { G.forEach((prod, q) => { if (prod.lhs === sym) { const ni = { p: q, dot: 0 }; if (!seen.has(itemKey(ni))) { seen.add(itemKey(ni)); out.push(ni); } } }); } } return out; } function goto(items: Item[], X: string): Item[] { const moved: Item[] = []; for (const it of items) if (G[it.p].rhs[it.dot] === X) moved.push({ p: it.p, dot: it.dot + 1 }); return moved.length ? closure(moved) : []; } // Canonical collection of LR(0) item sets. const start = closure([{ p: 0, dot: 0 }]); const states: Item[][] = [start]; const index = new Map<string, number>([[setKey(start), 0]]); const edges: [number, string, number][] = []; for (let i = 0; i < states.length; i++) { for (const X of SYMBOLS) { const g = goto(states[i], X); if (g.length === 0) continue; const k = setKey(g); let j = index.get(k); if (j === undefined) { j = states.length; index.set(k, j); states.push(g); } edges.push([i, X, j]); } } const show = (it: Item) => { const body = [...G[it.p].rhs]; body.splice(it.dot, 0, "."); return `${G[it.p].lhs} -> ${body.join(" ")}`; }; states.forEach((s, i) => console.log(`I${i}: { ${s.map(show).join(", ")} }`)); console.log("--- transitions ---"); for (const [i, X, j] of edges) console.log(`I${i} --${X}--> I${j}`);

Notice I_3 has a self-loop on a: from A \to a{\cdot}A the closure re-introduces A\to{\cdot}aA, so another a keeps you in the same state. That single loop is the automaton's way of counting an arbitrarily long run of as with a finite number of states — precisely why a regular device can police a stack.

What the automaton actually recognises

The states are not "the state of the parse" and they are not stack contents. They answer a subtler question about the string of grammar symbols currently on the stack.

This is the payoff. The stack can grow without bound, but "everything about the stack that could matter for the next parsing decision" is captured by a single automaton state. The parser never re-scans the stack; it just remembers the state it is in, which is why LR parsing runs in linear time.

It feels like cheating: parsing is more powerful than regular-language recognition, yet here is a mere DFA at the heart of it. The resolution is that the automaton does not recognise the language of the grammar. It recognises the viable prefixes — the legal stack contents — and that set really is regular, even when the language itself is not. The context-free power comes from the second ingredient: when the automaton reports a complete item, the parser pops the handle and consults GOTO to jump to the state for the shortened stack. The stack supplies the unbounded memory; the automaton supplies the finite, instant decision. Neither alone is enough; together they are exactly a deterministic pushdown automaton.

The commonest conceptual error is to picture each state as "the parser is at A \to a{\cdot}A". A state is the whole set the closure produced — I_3 = \{\,A \to a{\cdot}A,\; A \to {\cdot}aA,\; A \to {\cdot}b\,\} — and it must be, because the parser genuinely does not yet know which production it is in the middle of. The kernel item A \to a{\cdot}A says "I've committed to A \to aA and consumed the a", while the closure items A \to {\cdot}aA and A \to {\cdot}b say "…and the next A could itself start with either an a or a b." All of those possibilities are live at once. Collapse the set to one item and you have thrown away the very superposition that makes the parser deterministic without backtracking — the same lesson as subset construction, where a DFA state is a set of NFA states, never a single one.