Building Scanners with Finite Automata
Every compiler begins by turning a raw stream of characters into a stream of meaningful tokens. Before a parser can ask "is this a valid expression," something must first act. It must decide that the characters `i`, `f` form the keyword `if`. It must recognize that `3.14e-2` is a single floating-point literal. It must also see that `//` begins a comment. That comment swallows everything until the next newline. This is the job of the scanner (lexer). The astonishing fact is one of the cleanest pieces of engineering in all of compiler construction. This job can be done by machinery with no memory beyond "which state am I in." It runs in time strictly proportional to the length of the input. The bridge from specification to implementation runs through Regular Expressions for Lexing. A lexical specification is a set of regular expressions, one per token class. Each one is tagged with an action. The compiler-construction problem is to convert that specification into an executable recognizer. The classical solution converts each regex to a nondeterministic finite automaton (NFA). It merges all the token NFAs into one machine. Then it determinizes that machine into a deterministic finite automaton (DFA). Finally, it drives that DFA with a disciplined rule called maximal munch. This rule says: always consume the longest prefix of the remaining input that matches some token. This matters far beyond textbook exercises. Every real compiler front end implements exactly this pipeline. Examples include GCC, LLVM/Clang, javac, the Python and JavaScript interpreters, and tools like `lex`/`flex` and ANTLR. Sometimes a table-driven DFA is generated at compiler-compiler time. The resulting scanner is then nothing more than a tight loop indexing into a two-dimensional array. Regex engines used for text search, log parsing, and network intrusion detection rely on the same automaton theory. Snort rules are one such example. Understanding this pipeline means understanding why lexing is so fast and mechanical. This is especially clear compared to parsing. Parsing needs a stack and cannot be done by a finite-state machine alone. This article gives the full construction. It covers Thompson's construction from regex to NFA, the subset construction from NFA to DFA, DFA minimization, and the maximal-munch scanning loop with tie-breaking by rule priority. It proves the constructions correct. It analyzes their complexity honestly, including the exponential worst case of determinization. It also works a nontrivial example end to end — a mini scanner for identifiers, numbers, and keywords.
- A scanner recognizes tokens by running a deterministic finite automaton (DFA) built from the language's token regexes.
- Thompson's construction turns any regex into an NFA with O(n) states in the regex length n, using ε-transitions to compose pieces.
- The subset construction (powerset construction) turns an NFA into an equivalent DFA; each DFA state is a set of NFA states.
- Maximal munch: the scanner always extends the current match as long as possible, then picks the highest-priority rule among those matched at that maximal length.
- DFA-based scanning runs in O(n) time on input of length n — one table lookup per character, no backtracking.
- Determinization can blow up state count exponentially in theory, but for real lexical specifications the DFA stays small in practice, especially after minimization.
A finite union of regular languages can always be recognized by a single deterministic automaton that reads the input exactly once, left to right, which is what makes lexing linear-time and mechanical.
The idea
A token class is a regular language — the set of strings that count as, say, an identifier or an integer literal. A lexical specification pairs each token class's regex with an action (usually: "emit a token of this kind"). The scanner-generation problem is: given such a specification, build a program that, reading input left to right, repeatedly strips off the next token. The key theoretical fact making this tractable is the equivalence of three formalisms: regular expressions, nondeterministic finite automata (NFAs, which may have multiple candidate transitions or ε-moves that consume no input), and deterministic finite automata (DFAs, which have exactly one transition per state per symbol). Kleene's theorem states these define the same class of languages. Regexes are convenient to write; DFAs are efficient to run. NFAs are the algorithmic bridge between them — every regex compiles directly to an NFA via Thompson's construction, and every NFA can be mechanically converted to an equivalent DFA via the subset construction. A DFA is formally a 5-tuple : a finite set of states , an alphabet , a total transition function , a start state , and accepting states . Determinism means is a function, not a relation — from any state, on any symbol, there is exactly one next state (possibly a dead/trap state). This is precisely what allows execution without backtracking: the automaton never needs to guess or retry. For a scanner built from several token regexes at once, each accepting state must remember which token class it accepts, and — crucially — the scanning discipline must resolve two ambiguities that a single-regex recognizer never faces: (1) length ambiguity, resolved by maximal munch — always prefer the longest match; and (2) class ambiguity, when several rules match the same longest prefix (e.g., `if` matches both the keyword rule and the general-identifier rule) — resolved by rule priority, typically "earlier-declared rule wins," which is exactly how keyword-vs-identifier conflicts are handled in real lexer generators.
How it works
The pipeline has four stages: (1) build one NFA per token regex, (2) combine them under a common start state, (3) determinize via the subset construction, tagging each DFA state with the highest-priority accepting NFA state it contains, and (4) drive the DFA with the maximal-munch loop.
Stage 1 — Thompson's construction. Each regex operator has a fixed NFA fragment with a single start state and single accept state, and ε-transitions glue fragments together:
- A symbol : two states linked by an edge labeled .
- Concatenation : accept state of 's fragment gets an ε-edge to start state of 's fragment.
- Union : a new start state ε-branches to both fragments' starts; both accepts ε-converge to a new accept state.
- Kleene star : a new start/accept pair with ε-edges allowing zero passes (start→accept directly) or looping through arbitrarily many times.
Each construction step adds at most 2 new states and a constant number of edges, so a regex of length (counting operators and symbols) yields an NFA with states and edges.
Stage 2 — merge into one NFA. Introduce a fresh super-start state with an ε-edge to the start of every token regex's NFA. Tag each token's accept state with that token's name and priority (rule order).
Stage 3 — subset construction (determinize). A DFA state is a set of NFA states — specifically, the set of NFA states reachable from the current DFA state's underlying set by reading one symbol, then closing under ε-transitions. The DFA start state is the ε-closure of . A DFA state is accepting if its underlying set contains any NFA accept state; it is tagged with the highest-priority token whose accept state appears in that set (this is where rule-order tie-breaking is baked directly into the automaton).
Stage 4 — maximal-munch scanning loop. Run the DFA from its start state, consuming one input character per transition, and remember the most recent state that was accepting along with the position where that acceptance occurred. On reaching the dead/trap state (or end of input) with no further progress possible, roll back to that last-recorded accepting position, emit a token of the recorded class, and restart the DFA from its start state at that position. ```text function ε-closure(S): // S: set of NFA states stack ← S; result ← S while stack not empty: pop state t for each ε-edge (t → u): if u not in result: add u to result; push u return result function move(S, c): // one-symbol transition on a set return { u | ∃ t in S with edge t --c--> u } function subset_construction(NFA): start ← ε-closure({nfa.start}) Dstates ← { start }, unmarked Dtrans ← {} while ∃ unmarked T in Dstates: mark T for each symbol c in Σ: U ← ε-closure(move(T, c)) if U is empty: continue // implicit dead state if U not in Dstates: add U as unmarked Dtrans[T, c] ← U accepting(T) ← true if T contains any NFA accept state tag(T) ← token of the highest-priority accept state in T return (Dstates, Dtrans, start, accepting, tag) function scan(input, DFA): tokens ← [] pos ← 0 while pos < length(input): state ← DFA.start lastAcceptPos ← -1 lastAcceptTag ← none i ← pos while true: if DFA.accepting(state): lastAcceptPos ← i lastAcceptTag ← DFA.tag(state) if i == length(input) or DFA.trans(state, input[i]) undefined: break state ← DFA.trans(state, input[i]) i ← i + 1 if lastAcceptPos == -1: error("no token matches at position", pos) tokens.append( (lastAcceptTag, input[pos .. lastAcceptPos]) ) pos ← lastAcceptPos + 1 return tokens ``` The inner `while true` loop is the maximal-munch scan: it keeps consuming characters past the last accepting state, hoping a longer match exists, only stopping when the DFA can go no further. It then backs up to the last point where it was in an accepting state — never to the first.
Complexity & correctness
Construction complexity. For a lexical spec with total regex length (sum of all token regex sizes) and token rules:
- Thompson's construction produces an NFA with states and transitions — each of the constant-size operator rules (symbol, concatenation, union, star) adds states, and there are operators total. Merging NFAs adds one super-start state and ε-edges.
- The subset construction, in the worst case, produces a DFA with up to states, since a DFA state is a subset of the up-to- NFA states. This exponential blowup is real and provable: for the family of regexes ("some symbol , positions from the end"), any DFA needs at least states, because the DFA must remember the last symbols seen, and there are distinct such histories that require distinguishable future behavior — a direct consequence of the Myhill–Nerode theorem, which says the minimum DFA size equals the number of equivalence classes of the relation " iff for all , ."
- In practice, lexical specifications (identifiers, numbers, keywords, operators) do not exercise this pathology; real scanner-generator DFAs typically have a number of states within a small constant factor of the NFA size, and after minimization (via Hopcroft's algorithm or the simpler partition-refinement), they shrink further by merging states with identical future behavior.
Running-time complexity. Once built, the DFA-driven scanner runs in time on an input of length : each input character triggers exactly one table lookup (), and the maximal-munch loop never revisits a character more than once per emitted token — the outer loop's total work across all tokens is bounded by the number of characters consumed, which is . (There is no re-scanning: the inner loop, restarted at `pos`, only walks forward past positions it has not yet looked at as part of the current token attempt; across the whole run each character is examined a bounded number of times — once when tentatively extending a match, and never again once a token boundary commits.) Space is for the transition table, independent of input size.
Correctness — determinization preserves the language. Claim: . Proof sketch by invariant: after processing a prefix , the DFA is in the state equal to . This holds by induction on : base case is the definition of the DFA start state; inductive step, reading one more symbol , the `move`-then-`ε-closure` step by construction computes exactly the set of NFA states reachable after . Since the DFA state after is accepting iff it contains an NFA accept state, and by the invariant that set is exactly the reachable-NFA-states-after-, the DFA accepts iff some run of the NFA accepts — which is precisely the definition of NFA language acceptance.
Correctness — maximal munch finds the longest match. The scanning loop maintains the invariant that `lastAcceptPos` always holds the greatest index seen so far such that `input[pos..i]` is accepted by the merged DFA (equivalently, matches some token regex). Since the loop only advances `i` and only overwrites `lastAcceptPos` when the current, larger `i` is accepting, `lastAcceptPos` is monotonically nondecreasing and, at loop exit, holds the largest such index reachable by any accepting path — because the DFA explores the unique deterministic continuation of the prefix, and any longer accepting string sharing that prefix must pass through the same deterministic states (determinism guarantees there is only one path to check, so nothing is missed). Hence the emitted token is the longest one matching at `pos`, and the tag attached is the highest-priority rule among those active in that accepting state, by construction of the tagging in Stage 3.
Honest tradeoffs. The subset construction trades exponential worst-case state blowup for O(n) runtime with no backtracking — for typical lexical specs this is a clear win, but pathological regex sets (as in some regex-heavy log-parsing or firewall-rule DFAs) can produce enormous tables, motivating either accepting a bigger table, keeping the NFA and simulating it directly in time (Thompson's own runtime NFA simulation, used by `grep -E` style engines to avoid blowup), or using lazy/on-the-fly determinization, which builds only the DFA states actually visited on real input.
Worked example
Consider a tiny lexical spec, in priority order: 1. `IF` → `if` 2. `ID` → `[a-zA-Z][a-zA-Z0-9]*` 3. `NUM` → `[0-9]+` 4. (whitespace skipped, omitted for brevity) Scan the input `if9 x`.
| Step | pos | Chars examined this attempt | States visited (conceptually) | Last accepting index | Emitted token |
|---|---|---|---|---|---|
| 1 | 0 | `i` | start → {after-i-of-IF, after-i-of-ID} | none yet (not accepting) | — |
| 2 | 0 | `if` | {IF-accept, ID-continuing} | i=1 accepting, tag = IF (priority) but scan does not stop | — |
| 3 | 0 | `if9` | {ID-accept only — IF-branch dead, `9` not `f`-successor} | i=2 accepting, tag = ID (overwrites) | — |
| 4 | 0 | `if9␣` | trap on space, no transition | loop breaks | `ID("if9")` emitted, pos → 3 |
| 5 | 3 | (skip space) | — | — | pos → 4 |
| 6 | 4 | `x` | ID-accept | i=4 accepting, tag ID | — |
| 7 | 4 | end of input | loop ends (EOF) | last accept at i=4 | `ID("x")` emitted |
Two subtleties this trace exposes:
- At step 2 the DFA state is accepting and tagged `IF` — this is the classic trap for a naive implementation that stops scanning the moment it sees an accepting state. Maximal munch instead keeps going, and at step 3 finds a longer match (`if9`), which is tagged `ID` because the `IF`-branch has died (there is no transition for `9` after matching `f`). The final answer, correctly, is that `if9` is one identifier token, not the keyword `if` followed by garbage `9`.
- Rule priority (`IF` before `ID`) only matters when both rules accept the same longest string — e.g., input `if` alone would tie at length 2 between `IF` and `ID`, and priority correctly picks `IF`. The example shows priority is a tie-break at equal length, never a substitute for maximal munch.
Common pitfalls & traps
| Pitfall | Why it happens | Fix |
|---|---|---|
| Stopping at the first accepting state instead of the longest | Confusing "found a match" with "found the token" | Always continue until the DFA can make no further transition, then back up to the last accept |
| Applying priority before checking length | Treating keyword lists as a special case checked first | Determinize keywords and identifiers into the same DFA; let length win, priority only breaks length ties |
| Forgetting the implicit dead/trap state | Partial transition tables silently "get stuck" without signaling failure | Treat an undefined `δ(q,c)` as a transition to an explicit non-accepting trap state; loop exit condition must detect this |
| Off-by-one in restarting position | Restarting the scan at `lastAcceptPos` instead of `lastAcceptPos + 1` | The next token must start strictly after the consumed one; re-derive the invariant carefully |
| Assuming minimization changes accepted language | Confusing DFA minimization (same language, fewer states) with altering semantics | Minimization is language-preserving by Myhill–Nerode; verify by checking accepted strings before/after on samples |
| Ignoring maximal-munch interaction with greedy regex operators like `+`/`*` inside one token | Nested greediness inside a token regex is a different concern from choosing between tokens | Keep intra-token regex semantics (already resolved by NFA structure) separate from inter-token maximal munch |
Pioneers & history
The theory underlying scanner construction traces back to the origins of automata theory and formal language theory in the mid-20th century, later crystallized into an engineering discipline by the Unix tool-building tradition.
Origins · 1 minStephen Cole Kleene and how this was discovered
Kleene proved the fundamental equivalence between regular expressions, finite automata, and regular languages — the theorem that makes it meaningful to speak of "the" language a regex or a DFA defines, and that guarantees the two are always interchangeable representations of the same set of strings.
Thompson gave the direct, compositional construction from a regular expression to an equivalent NFA — the algorithm used in this article's Stage 1 — originally to implement fast regular-expression search in a text editor, seeding the entire lineage of `grep`-style tools.
Where this matters
Every production compiler front end uses this pipeline in one of two modes. Generated-table mode: tools like `flex`, ANTLR's lexer stage, and the JVM's `javac` lexer perform the NFA-to-DFA construction (often followed by minimization) at build time, emitting a transition table baked directly into the compiled scanner, so runtime cost is a tight loop of array lookups. Hand-written or hybrid mode: production compilers such as GCC and Clang/LLVM often hand-code the equivalent DFA logic directly in C/C++ for maximum control over error messages and performance, but the underlying state machine is the same automaton-theoretic object; some, like Clang, also special-case very hot paths (identifiers, whitespace) while still falling back to systematic DFA-style dispatch for the long tail of token kinds. Beyond compilers, the exact same theory powers text-search tools (`grep`, `ripgrep`), regex engines embedded in scripting languages (though many, like PCRE, trade the guaranteed-linear DFA approach for backtracking NFA simulation to support extra features such as backreferences), network intrusion detection systems that match packet payloads against thousands of signature regexes simultaneously (compiled into one large combined automaton, exactly as in Stage 2 of this article), and streaming log-analysis pipelines that must classify lines in a single linear pass. Anywhere a system must classify a prefix of a stream against a fixed menu of patterns in real time and bounded memory, this construction — or a close descendant of it — is doing the work. The token stream this machinery produces is exactly the input consumed by the next stage of compilation, covered under Context-Free Grammars for Parsing.
Ask the tutor: Can you show how the subset construction handles a regex with nested Kleene stars, like `(a|b)*abb`, step by step?
Why can't a scanner just try each token regex one at a time on the input?
It could, but it would be slow and semantically awkward. Testing each regex separately against every position requires either re-scanning the input once per rule (multiplying cost by the number of rules) or maintaining separate automata and manually comparing match lengths and priorities after the fact. Merging all token NFAs into one machine and determinizing lets a single pass, one DFA, decide simultaneously which rules are still alive at each position and which are the longest match so far. It also makes "maximal munch across rules" a built-in property of the automaton's structure rather than something the programmer must separately implement and get right, closing off an entire class of subtle prefix-matching bugs.
Does every regular expression really have a finite automaton, no matter how complicated?
Yes — this is exactly Kleene's theorem, and it is constructive, not just an existence proof. Thompson's construction shows explicitly how to build an NFA of size linear in the regex's length for any regex built from symbols, concatenation, union, and Kleene star, and the subset construction shows how to convert any NFA into an equivalent DFA. The only caveat is size: the resulting DFA can in the worst case have exponentially many states relative to the regex, though this is rare for the kinds of regexes used to define programming-language tokens (identifiers, numbers, operators, keywords).
What happens to whitespace and comments in this framework?
They are treated as ordinary token rules whose action is "discard, do not emit a token." A whitespace or comment rule participates in the same merged NFA, the same DFA, and the same maximal-munch matching as every other rule — a `//`-to-end-of-line comment regex, for instance, competes for the longest match exactly like an identifier would. The only difference is downstream: once the scanner determines the winning rule was "comment" or "whitespace," it simply advances past the matched text without producing a token for the parser to see.
Why does rule priority matter if maximal munch already picks the longest match?
Because two different token rules can match strings of the identical maximum length starting at the same position — the canonical example is a keyword like `if`, which matches both the specific `IF` rule and the general `ID` (identifier) rule, both consuming exactly two characters. Length alone cannot break this tie, so lexer generators adopt a convention, usually "the rule that appears earlier in the specification wins." This is why keyword rules are conventionally listed before the generic identifier rule in tools like `flex` — the ordering encodes intended priority.
Is it ever better to skip determinization and just run the NFA directly?
Sometimes, yes. Simulating an NFA directly (tracking the current set of active NFA states as input is read, without ever materializing it as a single DFA state ahead of time) costs time for input length and NFA size , since each step potentially updates all NFA states. This avoids the risk of exponential DFA blowup entirely, at the cost of being slower per character than a true O(n) DFA. Engines that must support pathological or extremely large pattern sets — some regex libraries and intrusion-detection systems — sometimes use this NFA-simulation approach, or a lazy/on-the-fly DFA that only builds the subset-construction states actually visited on real traffic, trading upfront determinization cost for bounded memory.
How does this connect to what a parser does next?
The scanner's job ends the moment it has produced a linear sequence of tokens, each tagged with a class and a lexeme (the matched text). A finite automaton is fundamentally incapable of the next job — checking that tokens are arranged in a nested, recursively structured way, such as matching parentheses or nested expressions — because finite automata have no memory of unbounded depth; this requires the added power of a stack, which is exactly what Context-Free Grammars for Parsing and pushdown automata provide. The token stream produced here is the sole interface between the two stages.
What's the practical difference between using flex/lex-style generated scanners and hand-writing a scanner in a fast production compiler?
Generated scanners emit exactly the DFA-table-driven code this article describes, guaranteeing correctness by construction and making changes to the lexical spec trivial. Hand-written scanners, common in performance-critical or diagnostics-heavy compilers, manually encode equivalent state-machine logic in the host language, often specializing hot paths (like scanning identifiers character-by-character with inline ASCII-class checks) for speed and to produce better error messages tied to source positions. Both approaches implement the same underlying automaton theory; the difference is engineering control versus generation convenience, not a difference in what is computationally happening.