Automata Theory:
DFA, NFA & Regular Expressions

The simplest model of a computer: finite memory, read input once, say yes or no.
It powers every lexer, grep, and regex engine you use.

DFA

One state at a time. One move per symbol.

NFA

Many states at once. Free ε-moves.

Regex

A tiny algebra: union, concat, star.

Kleene

All three describe exactly the same languages.

Formal definitions and proofs, plus runnable Python for every construction. All code was tested with Python 3.11+.

Roadmap WHERE WE GO

  1. Why automata matter
  2. Alphabets, strings, languages
  3. DFA: formal definition
  4. DFA example: divisible by 3
  5. DFA in Python
  6. NFA: formal definition
  7. NFA example & ε-closure
  8. NFA simulation in Python
  9. Subset construction (NFA → DFA)
  10. The exponential blow-up
  11. Regular expressions: syntax & meaning
  12. Thompson's construction (regex → NFA)
  13. A regex engine in 50 lines
  14. State elimination (DFA → regex)
  15. Kleene's theorem
  16. Closure properties & product DFA
  17. Myhill–Nerode & minimization
  18. Pumping lemma
  19. Decision problems
  20. Real regex engines & backtracking
  21. Chomsky hierarchy

Why Automata? MOTIVATION

Where finite automata run today

  • Lexers: every compiler splits source into tokens with a DFA (lex, flex, re2c).
  • Search: grep, RE2, Rust's regex, Hyperscan.
  • Protocols: TCP connection states, TLS handshakes, UI flows.
  • Hardware: every sequential circuit is a finite state machine.
  • Verification: model checkers use automata over infinite words.

The big question

What can a machine compute if it has only a fixed, finite memory and reads its input once, left to right?

Answer: exactly the regular languages. We will describe them three ways and prove the three agree.

Regex ε-NFA DFA Thompsonsubsetsstate elimination

Alphabets, Strings, Languages VOCABULARY

Alphabet Σ: a finite, non-empty set of symbols. Example: Σ = {0, 1}.

String: a finite sequence over Σ. The empty string is ε. Length is |w|.

Σ*: the set of all strings, including ε. It is infinite but countable.

Language L: any subset L ⊆ Σ*.

There are uncountably many languages (2Σ*) but only countably many machines. So most languages have no machine at all.

Operations on languages

L ∪ M    union

L M = { xy : x ∈ L, y ∈ M }    concatenation

L0 = {ε},   Lk+1 = L Lk

L* = ⋃k≥0 Lk    Kleene star

L = {"a", "ab"}
M = {"b", ""}
concat = {x + y for x in L for y in M}
print(sorted(concat))
['a', 'ab', 'abb']

Note {"a"}{"b"} ≠ {"b"}{"a"}. Concatenation is associative but not commutative. {ε} is its identity. ∅ is its zero.

Deterministic Finite Automaton FORMAL DEFINITION

A DFA is a 5-tuple M = (Q, Σ, δ, q0, F):

  • Q — finite set of states
  • Σ — input alphabet
  • δ : Q × Σ → Q — total transition function
  • q0 ∈ Q — start state
  • F ⊆ Q — accepting states

Extended transition δ̂ : Q × Σ* → Q, by recursion on the string:

δ̂(q, ε) = q     δ̂(q, wa) = δ(δ̂(q, w), a)

Language of M

L(M) = { w ∈ Σ* : δ̂(q0, w) ∈ F }

A language is regular if some DFA accepts it.

Key properties

  • Deterministic: exactly one next state for each (state, symbol).
  • Total: missing moves go to a “dead” (trap) state.
  • Fast: one table lookup per character, so O(|w|) time and O(1) extra space.
  • Memory = which state you are in. |Q| states store log2|Q| bits.

Example: Binary Numbers Divisible by 3 DFA

Idea: the state is the remainder so far. Reading bit b turns value v into 2v + b. So remainder r becomes (2r + b) mod 3.

012 01 11 00 double circle = accept · arrow from left = start
state (r)on 0on 1
→ * 001
120
212

Trace: 110 (= 6)

0 ⟶1 1 ⟶1 0 ⟶0 0    accept ✓

Trace: 111 (= 7)

0 ⟶1 1 ⟶1 0 ⟶1 1    reject ✗

Correctness by induction on |w|: δ̂(0, w) = val(w) mod 3. The base case is val(ε) = 0. The step uses val(wb) = 2·val(w) + b.

A DFA in Python CODE

class DFA:
    def __init__(self, states, alphabet, delta, start, accept):
        self.states, self.alphabet = set(states), set(alphabet)
        self.delta, self.start, self.accept = delta, start, set(accept)

    def accepts(self, w):
        q = self.start
        for c in w:
            q = self.delta[(q, c)]   # exactly one move
        return q in self.accept

div3 = DFA(states={0, 1, 2}, alphabet="01",
           delta={(r, b): (2 * r + int(b)) % 3
                  for r in range(3) for b in "01"},
           start=0, accept={0})

print([n for n in range(20) if div3.accepts(bin(n)[2:])])
assert all(div3.accepts(bin(n)[2:]) == (n % 3 == 0)
           for n in range(1000))
[0, 3, 6, 9, 12, 15, 18]

Notes

  • delta is a dict keyed by (state, symbol). That is the transition table.
  • The loop body does one lookup. Real lexers compile this into a 2-D array or a switch.
  • Generalize: a DFA for “divisible by k in base b” has k states with δ(r, d) = (b·r + d) mod k.

Try it

Build a DFA over {a, b} that accepts strings with an even number of as and an odd number of bs. It needs 4 states: one for each (parity of a, parity of b) pair.

Designing a DFA: A Recipe HOW TO THINK

Four questions

  1. What must I remember about the input so far? Keep it to a finite summary.
  2. Make one state per summary value.
  3. For each state and symbol, ask: how does the summary change? That is δ.
  4. Start = the summary of the empty string. Accept = summaries that mean "yes".

Worked: binary strings containing 11

Summary: "how much of 11 have I just seen?" Three answers: nothing (q0), one 1 (q1), all of it (q2).

statemeaningon 0on 1
→ q0no progressq0q1
q1last symbol was 1q0q2
* q2saw 11 alreadyq2q2

q2 is a trap: once you have seen 11, nothing can undo it.

Trace it by hand

inputstates visitedresult
0110q0 → q0 → q1 → q2 → q2accept
1010q0 → q1 → q0 → q1 → q0reject
111q0 → q1 → q2 → q2accept

Common mistakes

  • Missing moves. A DFA needs a move for every state and symbol. If you leave one out, you have silently added a dead state.
  • Forgetting ε. Ask whether the empty string should be accepted. It decides if the start state is an accept state.
  • Remembering too much. Storing the whole input is not finite. Keep only what the future needs.
Answer to "Try it" on the previous slide

States are pairs (a-parity, b-parity): EE, EO, OE, OO. Start at EE. Reading a flips the first letter, b flips the second. Accept only EO.

Nondeterministic Finite Automaton FORMAL DEFINITION

An ε-NFA is N = (Q, Σ, δ, q0, F) where only δ changes:

δ : Q × (Σ ∪ {ε}) → 𝒫(Q)

Each move leads to a set of states, which may be empty. An ε-move changes state without reading input.

ε-closure E(S): all states reachable from S using only ε-moves (including S itself).

δ̂(S, ε) = E(S)

δ̂(S, wa) = E( ⋃q ∈ δ̂(S, w) δ(q, a) )

Acceptance: w is accepted if some path ends in F.

L(N) = { w : δ̂({q0}, w) ∩ F ≠ ∅ }

Two ways to picture it

  • Guessing: the machine magically picks the right branch whenever one exists.
  • Parallel: it runs every branch at once and tracks the set of live states. This view gives the algorithm.

NFAs are often much smaller and far easier to build than DFAs. They are never more powerful, as the subset construction will show.

Example: Strings Ending in “01” NFA

ABC 0, 101 On 0, state A has two choices: stay, or guess “this 0 starts the final 01”.
stateon 0on 1
→ A{A, B}{A}
B∅{C}
* C∅∅

Parallel run on 1101

readlive set
(start){A}
1{A}
1{A}
0{A, B}
1{A, C}   ← contains C: accept ✓

Branches die quietly

On 010, the B→C branch reaches C after 01. Then C has no move on the final 0, so that branch dies. The final set is {A, B}, with no C. Reject.

Simulating an NFA in Python SET OF STATES

EPS = ""                      # label for epsilon moves

class NFA:
    def __init__(self, delta, start, accept):
        self.delta, self.start = delta, start
        self.accept = frozenset(accept)

    def closure(self, states):    # E(S), a DFS on eps-edges
        seen, stack = set(states), list(states)
        while stack:
            q = stack.pop()
            for r in self.delta.get((q, EPS), ()):
                if r not in seen:
                    seen.add(r); stack.append(r)
        return frozenset(seen)

    def step(self, states, c):
        return self.closure({r for q in states
                             for r in self.delta.get((q, c), ())})

    def accepts(self, w):
        cur = self.closure({self.start})
        for c in w:
            cur = self.step(cur, c)
        return bool(cur & self.accept)
ends01 = NFA(delta={("A", "0"): {"A", "B"},
                    ("A", "1"): {"A"},
                    ("B", "1"): {"C"}},
             start="A", accept={"C"})

print([w for w in ["01", "1101", "010", "0", ""]
       if ends01.accepts(w)])
['01', '1101']

Cost: with m states and edges, each step is O(m). So the whole run is O(m · |w|). That is linear in the input and never exponential.

This is Ken Thompson's 1968 algorithm. It is the core of grep, RE2, and Go's regexp.

Subset Construction: NFA → DFA RABIN & SCOTT 1959

Theorem. For every ε-NFA N there is a DFA D with L(D) = L(N).

Construction. Each DFA state is a set of NFA states.

  • QD ⊆ 𝒫(QN)
  • qD = E({q0})
  • δD(S, a) = E(⋃q∈S δN(q, a))
  • FD = { S : S ∩ FN ≠ ∅ }

Proof idea: by induction on |w|, δ̂D(qD, w) = δ̂N({q0}, w). So D is in state S exactly when S is N's live set.

def to_dfa(nfa, alphabet):
    start = nfa.closure({nfa.start})
    delta, seen, todo = {}, {start}, [start]
    while todo:                    # only reachable subsets
        S = todo.pop()
        for c in alphabet:
            T = nfa.step(S, c)
            delta[(S, c)] = T
            if T not in seen:
                seen.add(T); todo.append(T)
    accept = {S for S in seen if S & nfa.accept}
    return DFA(seen, alphabet, delta, start, accept)

d = to_dfa(ends01, "01")
print(sorted(sorted(s) for s in d.states))
[['A'], ['A', 'B'], ['A', 'C']]
DFA stateon 0on 1
→ {A}{A,B}{A}
{A,B}{A,B}{A,C}
* {A,C}{A,B}{A}

Worked Example: ε-NFA → DFA for a*b* STEP BY STEP

q0q1 abε

The steps

  1. Start = E({q0}) = {q0, q1}. It holds q1, so it accepts.
  2. From {q0,q1} on a: only q0 moves, to q0. Close it: {q0,q1}.
  3. From {q0,q1} on b: only q1 moves, to q1. Close it: {q1}. New state.
  4. From {q1} on a: no moves, so ∅. New state.
  5. From {q1} on b: {q1}. From ∅: always ∅. Done.
DFA stateon aon baccept?
→ {q0, q1}{q0, q1}{q1}yes
{q1}∅{q1}yes
∅∅∅no (dead)

Read the result

{q0,q1} means "still reading a's". {q1} means "now reading b's". ∅ means "saw an a after a b". Only 3 of the 4 subsets are reachable, so we never build {q0}.

Common mistakes

  • Starting at {q0} instead of E({q0}). Then the DFA rejects ε, which is wrong.
  • Taking the closure only once. Close after every move.
  • Dropping ∅. It is a real DFA state: the dead state.

The Exponential Blow-Up Is Real LOWER BOUND

Subsets of an n-state NFA: up to 2n. Sometimes you truly need all of them.

Language Ln = strings over {0,1} whose n-th symbol from the end is 1.

Ln = (0|1)* 1 (0|1)n−1

An NFA needs only n + 1 states. It guesses the right 1, then counts n − 1 more symbols.

Claim. Every DFA for Ln has at least 2n states.

Proof. Take two different strings x ≠ y of length n. They differ at some position i, say xi = 1 and yi = 0. Append z = 0i−1. Then xz ∈ Ln but yz ∉ Ln. So a DFA must reach different states on x and y. There are 2n such strings. ∎

for n in range(1, 9):
    nfa = compile_regex("(0|1)*1" + "(0|1)" * (n - 1))
    dfa = to_dfa(nfa, "01")
    print(n, len(dfa.states), len(minimize(dfa).states))
1 3 2
2 5 4
3 9 8
4 17 16
5 33 32
6 65 64
7 129 128
8 257 256

Columns: n, subset DFA size, minimal DFA size. The minimal DFA hits exactly 2n. This is why real engines simulate NFAs lazily and cache DFA states on demand.

Regular Expressions SYNTAX & SEMANTICS

Syntax (inductive). Over alphabet Σ:

R ::= ∅  |  ε  |  a  |  R1 | R2  |  R1R2  |  R*

Precedence: * binds tightest, then concatenation, then |.

Semantics L(R) ⊆ Σ*

L(∅) = ∅    L(ε) = {ε}    L(a) = {a}

L(R|S) = L(R) ∪ L(S)

L(RS) = L(R) L(S)    L(R*) = L(R)*

Sugar: R+ = RR*, R? = R|ε, [abc] = a|b|c, . = any symbol. None adds power.

RegexLanguage
(0|1)*01binary strings ending in 01
(a|b)*abbstrings ending in abb
1*(01+)*every 0 is immediately followed by a 1
(0|1(01*0)*1)*binary multiples of 3 (from our DFA)
[a-z_][a-z0-9_]*identifiers

Algebraic laws (Kleene algebra)

R|S = S|R    R|R = R    R∅ = ∅

R(S|T) = RS|RT    (R*)* = R*

R* = ε | RR*    (R|S)* = (R*S*)*

Thompson's Construction: Regex → ε-NFA STRUCTURAL INDUCTION

Build one small fragment per regex case. Each fragment has one start state and one accept state. Glue fragments with ε-edges.

symbol aconcat RSunion R|Sstar R* a R S ε R S εεεε R εεε (repeat)ε (skip) • Each case adds at most 2 states and 4 edges. • So a regex of length n gives an NFA with ≤ 2n states. • Every state has at most 2 outgoing edges. • Correctness: induction on the regex structure.

A Regex Engine in ~50 Lines PARSER + THOMPSON

import itertools

def compile_regex(pattern):
    """Thompson: literals, |, *, +, ?, ( )."""
    delta, ids, pos = {}, itertools.count(), 0
    def new(): return next(ids)
    def edge(a, sym, b): delta.setdefault((a, sym), set()).add(b)
    def peek(): return pattern[pos] if pos < len(pattern) else None
    def eat():
        nonlocal pos
        pos += 1
        return pattern[pos - 1]

    def alt():                     # alt := cat ('|' cat)*
        s, e = cat()
        while peek() == "|":
            eat(); s2, e2 = cat()
            ns, ne = new(), new()
            edge(ns, EPS, s); edge(ns, EPS, s2)
            edge(e, EPS, ne); edge(e2, EPS, ne)
            s, e = ns, ne
        return s, e
    def cat():                     # cat := post*
        s = e = new()
        while peek() not in (None, "|", ")"):
            s2, e2 = post()
            edge(e, EPS, s2); e = e2
        return s, e

    def post():                    # post := atom ('*'|'+'|'?')*
        s, e = atom()
        while peek() in ("*", "+", "?"):
            op = eat()
            ns, ne = new(), new()
            edge(ns, EPS, s); edge(e, EPS, ne)
            if op in "*?": edge(ns, EPS, ne)   # may skip
            if op in "*+": edge(e, EPS, s)     # may repeat
            s, e = ns, ne
        return s, e

    def atom():
        if peek() == "(":
            eat(); s, e = alt(); eat()   # consume ')'
            return s, e
        s, e = new(), new()
        edge(s, eat(), e)
        return s, e

    start, end = alt()
    return NFA(delta, start, {end})

Tested against Python's re.fullmatch on every string up to length 6 over {a,b,c} for (a|b)*abb, a+b?, (ab|c)*, a(b|)c, ((a|b)(a|b))*.

Back Again: Automaton → Regex STATE ELIMINATION

Kleene's recursion (McNaughton–Yamada). Number the states 1..n. Let R(k)ij be the strings that go from i to j using only states ≤ k in the middle.

R(k)ij = R(k−1)ij  |  R(k−1)ik (R(k−1)kk)* R(k−1)kj

Then L(M) = ⋃f ∈ F R(n)1f. Same shape as Floyd–Warshall.

Arden's lemma. If ε ∉ L(A), then X = AX | B has the unique solution X = A*B. Solve the state equations like linear algebra.

By hand: eliminate one state at a time

To remove state k with self-loop S: for each path p ⟶A k ⟶B q, add edge p ⟶A S* B q. Union parallel edges.

Divisible-by-3 DFA

Remove state 2. It has self-loop 1, entry 0 from 1, and exit 0 back to 1. So state 1 gets a new loop 01*0. Now remove state 1. Its loop from 0 is 1(01*0)*1. Result:

(0 | 1(01*0)*1)*

import re
pat = re.compile(r"(0|1(01*0)*1)*")
assert all(bool(pat.fullmatch(bin(n)[2:])) == (n % 3 == 0)
           for n in range(5000))

Kleene's Theorem (1956) THE BIG EQUIVALENCE

Theorem. For any language L ⊆ Σ*, the following are equivalent:

  1. L = L(R) for some regular expression R.
  2. L = L(N) for some ε-NFA N.
  3. L = L(D) for some DFA D.

1 ⇒ 2

Thompson's construction. Induction on regex structure. Size O(|R|).

2 ⇒ 3

Subset construction. Size up to 2|Q|, and that bound is tight.

3 ⇒ 1

State elimination or Kleene's R(k)ij. The regex can be exponentially long.

Why it matters

Pick the view that suits the job. Write patterns as regexes. Build and combine as NFAs. Run and minimize as DFAs.

Other characterizations

Regular languages are also exactly: right-linear grammars (Type 3), finite-index Myhill–Nerode relations, languages recognized by finite monoids, and sets definable in monadic second-order logic over strings (Büchi–Elgot–Trakhtenbrot).

Closure Properties & the Product DFA BUILD BIG FROM SMALL

OperationConstruction
Union L ∪ MRegex |, or product DFA with OR
Intersection L ∩ MProduct DFA with AND
Complement Σ* − LTotal DFA, swap F and Q − F
Difference L − ML ∩ ¬M
Concat, starThompson fragments
Reversal LRFlip every edge. Swap start and accept. With several accept states, add a new start with ε-edges to each.
HomomorphismSubstitute a regex for each symbol

Product: Q = QA × QB, δ((p,q), a) = (δA(p,a), δB(q,a)). Run both machines in lock-step.

def product(A, B, op):
    states = {(p, q) for p in A.states for q in B.states}
    delta = {((p, q), c): (A.delta[(p, c)], B.delta[(q, c)])
             for (p, q) in states for c in A.alphabet}
    accept = {(p, q) for (p, q) in states
              if op(p in A.accept, q in B.accept)}
    return DFA(states, A.alphabet, delta,
               (A.start, B.start), accept)

even_len = DFA({0, 1}, "01",
               {(q, c): 1 - q for q in (0, 1) for c in "01"},
               0, {0})
both = product(div3, even_len, lambda x, y: x and y)
print([bin(n)[2:] for n in range(40)
       if both.accepts(bin(n)[2:])][:6])
['11', '1001', '1100', '1111', '100001', '100100']

Multiples of 3 whose binary form has even length. Intersection is easy for DFAs but has no direct regex operator.

Myhill–Nerode & DFA Minimization THE CANONICAL DFA

Nerode equivalence. Two strings are equivalent for L if no suffix can tell them apart:

x ≡L y  ⇔  ∀z ∈ Σ*: (xz ∈ L ⇔ yz ∈ L)

Myhill–Nerode Theorem (1958).

  1. L is regular iff ≡L has finitely many classes.
  2. The number of classes equals the size of the minimal DFA.
  3. The minimal DFA is unique up to renaming states.

So you can test regex equality by minimizing both DFAs and comparing. It also proves non-regularity: a, aa, aaa, … are pairwise inequivalent for {anbn}.

Moore's partition refinement

def minimize(dfa):             # assumes all states reachable
    alpha = sorted(dfa.alphabet)
    P = [b for b in (set(dfa.accept), dfa.states - dfa.accept) if b]
    while True:
        where = {q: i for i, blk in enumerate(P) for q in blk}
        newP = []
        for blk in P:          # split by "where do my moves go?"
            groups = {}
            for q in blk:
                sig = tuple(where[dfa.delta[(q, c)]] for c in alpha)
                groups.setdefault(sig, set()).add(q)
            newP.extend(groups.values())
        if len(newP) == len(P):
            break
        P = newP
    where = {q: i for i, blk in enumerate(P) for q in blk}
    delta = {(where[q], c): where[dfa.delta[(q, c)]]
             for q in dfa.states for c in alpha}
    return DFA(range(len(P)), alpha, delta, where[dfa.start],
               {where[q] for q in dfa.accept})

big = to_dfa(compile_regex("(a|b)*abb"), "ab")
print(len(big.states), "->", len(minimize(big).states))
5 -> 4

Moore: O(n2|Σ|). Hopcroft (1971): O(n |Σ| log n).

Minimization by Hand WORKED EXAMPLE

This 5-state DFA accepts strings over {a, b} that end in ab. It has extra states. Let us find them.

stateon aon b
→ ABA
BDC
* CDA
DBE
* EBA

Idea

Start by trusting only one fact: accept states differ from the rest. Then split any group whose members move to different groups on some symbol. Stop when nothing splits.

Round 0

Accept vs. non-accept: F = {C, E}, N = {A, B, D}.

Round 1: where does each state go?

statea →b →group pattern
AB (N)A (N)N, N
BD (N)C (F)N, F
DB (N)E (F)N, F
CD (N)A (N)N, N
EB (N)A (N)N, N

A differs from B and D, so N splits: {A}, {B, D}, {C, E}.

Round 2

B and D both go to {B,D} on a and to {C,E} on b. C and E both go to {B,D}, then {A}. No split. Done: 3 states.

Meaning of the 3 states: "no progress", "just read a", "just read ab". The minimize code above gives the same result.

The Pumping Lemma PROVING A LANGUAGE IS NOT REGULAR

Lemma. If L is regular, there is a p ≥ 1 such that every w ∈ L with |w| ≥ p splits as w = xyz with:

|xy| ≤ p,    |y| ≥ 1,    ∀i ≥ 0: xyiz ∈ L

Proof. Let p = |Q| of a DFA for L. Reading the first p symbols of w visits p + 1 states. By pigeonhole, some state q repeats. Let y be the part read between the two visits. It is a loop at q. So you can go round it 0, 1, 2, … times and still end in the same accept state. ∎

q₀qf xyz

Example: L = { anbn : n ≥ 0 } is not regular

  1. Suppose it is, with pumping length p.
  2. Pick w = apbp ∈ L.
  3. Since |xy| ≤ p, both x and y are all as. So y = ak with k ≥ 1.
  4. Pump down: xz = ap−kbp ∉ L.
  5. Contradiction. ∎

Careful

The lemma is only a necessary condition. Some non-regular languages still pump. Myhill–Nerode gives an exact test.

Also not regular: balanced parentheses, {ww}, primes in unary, {an²}. Moral: a finite machine cannot count without limit.

The Pumping Lemma as a Game YOU VS. THE ADVERSARY

Four moves

  1. Adversary picks the pumping length p.
  2. You pick a string s ∈ L with |s| ≥ p.
  3. Adversary splits s = xyz with |xy| ≤ p and |y| ≥ 1.
  4. You pick i ≥ 0. You win if xyiz ∉ L.

If you can win against every p and every split, L is not regular.

Common mistakes

  • Choosing the split yourself. The adversary picks x, y, z. You must beat all of them.
  • Picking a number for p. Keep p as a symbol, like apbp.
  • Using it to prove a language regular. Passing the pumping test proves nothing.

Worked: L = { aibj : i > j }

  1. Adversary says p.
  2. You pick s = ap+1bp. It is in L and long enough.
  3. Any split has |xy| ≤ p. So y = ak with k ≥ 1.
  4. Try i = 2: ap+1+kbp. Still in L. That move loses.
  5. Try i = 0: ap+1−kbp. Now p+1−k ≤ p, so it is not in L. You win. ∎

Lesson: pumping up is not always the answer. Here, only pumping down works.

Check yourself: is {anbm : n, m ≥ 0} regular?

Yes. It is just a*b*. Nothing ties n to m, so there is nothing to count. The pumping lemma cannot be used against it.

Check yourself: for {anbn}, why is s = ap/2bp/2 a bad pick?

The adversary may then put y across the border, like ab. Pumping gives ...abab..., which is out of L, but you must also handle every other split. With apbp, the rule |xy| ≤ p forces y into the a's, so there is just one case.

Decision Problems EVERYTHING IS DECIDABLE

QuestionAlgorithmCost
Membership w ∈ L?Run the DFA / simulate the NFAO(|w|) / O(m|w|)
Emptiness L = ∅?Can start reach any accept state?linear (BFS)
FinitenessIs there a cycle on some start→accept path?linear
Equivalence (DFA)(A ⊕ B) = ∅, or compare minimal DFAspolynomial
Universality (NFA)L = Σ*?PSPACE-complete
Equivalence (regex)via subset constructionPSPACE-complete
def is_empty(dfa):
    seen, todo = {dfa.start}, [dfa.start]
    while todo:
        q = todo.pop()
        if q in dfa.accept:
            return False
        for c in dfa.alphabet:
            r = dfa.delta[(q, c)]
            if r not in seen:
                seen.add(r); todo.append(r)
    return True

def equivalent(A, B):          # symmetric difference is empty
    return is_empty(product(A, B, lambda x, y: x != y))

abb = to_dfa(compile_regex("(a|b)*abb"), "ab")
ab  = to_dfa(compile_regex("(a|b)*ab"),  "ab")
print(equivalent(abb, minimize(abb)), equivalent(abb, ab))
True False

Compare Turing machines: none of these questions is decidable there (Rice's theorem). Finite memory buys you complete analyzability.

Real Regex Engines: Automata vs. Backtracking PRACTICE

Backtracking (Python re, PCRE, Java, JS)

Tries one branch at a time, depth-first. On failure it rewinds. Worst case is exponential in the input length.

import re, time
for n in (18, 20, 22):
    t = time.perf_counter()
    re.match(r"(a+)+$", "a" * n + "b")
    print(n, f"{time.perf_counter() - t:.3f}s")
18 0.010s
20 0.038s
22 0.150s      # x4 every 2 chars; n=40 takes days

This is “ReDoS”. It took down Stack Overflow in 2016 and Cloudflare in 2019.

Automata (RE2, Go, Rust regex, grep)

Thompson NFA simulation plus a lazily built DFA cache. Guaranteed O(m · |w|).

nfa = compile_regex("(a+)+")
nfa.accepts("a" * 22 + "b")   # ~0.05 ms, and linear in n

Why backtrack at all?

Features beyond regular languages. Backreferences like (a*)b\1 match {anban}, which is not regular. Matching with backreferences is NP-complete. Lookaround and lazy quantifiers are also easier to add to a backtracker.

re.fullmatch(r"(a*)b\1", "aaabaaa")   # match
re.fullmatch(r"(a*)b\1", "aaabaa")    # None

Beyond Regular: The Chomsky Hierarchy MORE MEMORY, MORE POWER

TypeLanguagesMachineExample
3RegularDFA / NFA(ab)*
2Context-freePushdown automaton (NFA + stack)anbn, balanced parens
1Context-sensitiveLinear-bounded automatonanbncn
0Recursively enumerableTuring machinehalting set

Each level strictly contains the one above. Regular ⊂ CFL ⊂ CSL ⊂ RE.

Recursively enumerable (TM) Context-sensitive (LBA) Context-free (PDA) Regular (DFA)

Practical link: a lexer (regular) splits code into tokens. A parser (context-free) builds the syntax tree. Nested structure needs a stack. That is exactly why you should not parse HTML with a regex.

Check Yourself PRACTICE

Try each one before you open the answer.

1. How many states does a DFA need for binary numbers divisible by 5?

Five, one per remainder 0–4. Reading bit c sends remainder r to (2r + c) mod 5. All five remainders are distinguishable, so none can merge.

2. Over {0,1}, is "as many 01s as 10s" regular?

Yes, surprisingly. The two counts can differ by at most 1. They are equal exactly when the string is empty or starts and ends with the same symbol. That needs only a few states.

3. To complement an NFA, can you just swap accept and non-accept states?

No. An NFA accepts if some path accepts. After the swap, a string with one accepting and one rejecting path is still accepted. Convert to a DFA first, then swap.

4. Write a regex for binary strings with no two 1s in a row.

(0 | 10)* (ε | 1). Every 1 is followed by a 0, except maybe a final 1.

5. L is regular and M is not. Is L ∪ M ever regular?

Yes, it can be. Take L = Σ*. Then L ∪ M = Σ*, which is regular. Closure only works when both inputs are regular.

Mistakes students make

MistakeFix
"NFAs are more powerful than DFAs."Same power. NFAs are only smaller.
"Every subset of a regular language is regular."No: {anbn} ⊆ a*b*.
"Infinite language means not regular."a* is infinite and regular. Unbounded counting is the problem.
"The pumping lemma shows L is regular."It can only show non-regularity.
Using \1 backreferences as "regex".They go beyond regular. (a*)b\1 is not regular.
6. Is {w : |w| is a multiple of 3 or of 5} regular?

Yes. Build a DFA for each part (3 and 5 states). The union is regular by closure. The product DFA has 15 states, one per (length mod 3, length mod 5).

Summary TAKE-AWAYS

Remember

  • DFA: one state, one move per symbol, linear time.
  • NFA: sets of states and ε-moves. Same power, can be exponentially smaller.
  • Regex ↔ NFA ↔ DFA (Kleene's theorem).
  • Myhill–Nerode gives a unique minimal DFA.
  • Pumping lemma: finite memory cannot count without limit.
ConversionAlgorithmSize
Regex → NFAThompsonO(n)
NFA → DFASubset constructionO(2n)
DFA → min DFAMoore / Hopcroft≤ n
DFA → RegexState eliminationO(4n) worst

Further reading

  • Hopcroft, Motwani, Ullman, Introduction to Automata Theory
  • Sipser, Introduction to the Theory of Computation, ch. 1
  • Russ Cox, Regular Expression Matching Can Be Simple And Fast (2007)
  • Thompson, Regular Expression Search Algorithm (CACM 1968)

Glossary QUICK REFERENCE

TermMeaning
Alphabet ΣA finite set of symbols, like {0, 1}.
String / wordA finite sequence of symbols. ε is the empty string.
Σ*All strings over Σ, including ε.
LanguageAny set of strings, so a subset of Σ*.
DFAFinite automaton with exactly one move per state and symbol.
NFA / ε-NFAMay have 0, 1 or many moves, and ε-moves. Accepts if some path does.
ε-closure E(S)States reachable from S by ε-moves alone.
Dead (trap) stateA non-accepting state you can never leave.
Regular languageAccepted by some DFA. Same as: described by a regex.
TermMeaning
Subset constructionNFA → DFA. Each DFA state is a set of NFA states.
Thompson's constructionRegex → ε-NFA, one small piece per operator.
State eliminationAutomaton → regex, removing one state at a time.
Kleene's theoremRegex, NFA and DFA describe the same languages.
Closure propertyAn operation that keeps regular languages regular.
Product DFARuns two DFAs at once. Gives ∩, ∪, difference.
Nerode equivalencex ≡ y if no suffix tells them apart.
Minimal DFAFewest states for a language. Unique up to renaming.
Pumping length pLong strings (|s| ≥ p) have a loop y near the start.