š§ NFA Fundamentals
šÆ What is an NFA?
A Nondeterministic Finite Automaton (NFA) is a theoretical machine that generalizes the DFA. For a given state and input symbol, an NFA can have zero, one, or multiple possible next states. It can also have ε-transitions (spontaneous moves without reading input). Despite this flexibility, NFAs recognize exactly the same class of languages as DFAs ā the regular languages.
Why NFAs Matter
Design ease: Multiple choices are natural. Concise: Often exponentially smaller than DFA. Regex: Thompson construction builds NFA from regex. Closure proofs: Union, concat, star are trivial. Same power: Equivalent to DFA. Guessing: "Guess" the right path.
# NFA ā the big picture
# An NFA is like a DFA that can "guess" or "branch"
# REAL-LIFE EXAMPLE: Coin-operated turnstile with choices
# States: Locked, Unlocked, Broken
# Inputs: Push, Coin
class TurnstileNFA:
"""
Turnstile NFA (nondeterministic):
States: {Locked, Unlocked, Broken}
Alphabet: {Push, Coin}
Start: Locked
Accept: Unlocked
Nondeterminism:
- From Locked, a Coin can go to Unlocked OR Broken (fault)
- From Unlocked, a Push can go to Locked OR stay (jammed)
"""
def __init__(self):
self.states = {'Locked', 'Unlocked', 'Broken'}
self.alphabet = {'Push', 'Coin'}
self.start = 'Locked'
self.accept = {'Unlocked'}
# NFA transitions: (state, symbol) -> set of next states
self.transitions = {
('Locked', 'Push'): {'Locked'},
('Locked', 'Coin'): {'Unlocked', 'Broken'}, # nondeterministic!
('Unlocked', 'Push'): {'Locked', 'Unlocked'}, # nondeterministic!
('Unlocked', 'Coin'): {'Unlocked'},
('Broken', 'Push'): {'Broken'},
('Broken', 'Coin'): {'Broken'},
}
def accepts(self, inputs):
"""Simulate NFA ā track set of possible states."""
current = {self.start}
for symbol in inputs:
next_states = set()
for state in current:
next_states |= self.transitions.get((state, symbol), set())
current = next_states
return bool(current & self.accept)
# Test
turnstile = TurnstileNFA()
print("Turnstile NFA simulation:")
for seq in [['Coin'], ['Coin', 'Push'], ['Push'], ['Coin', 'Coin'], ['Push', 'Coin']]:
result = turnstile.accepts(seq)
print(f" {seq} ā {'ACCEPT' if result else 'REJECT'}")
# KEY PROPERTIES OF NFA
print("""
=== NFA Key Properties ===
1. NONDETERMINISM
- Zero, one, or multiple next states
- No single "correct" path
- Accepts if ANY path accepts
2. ε-TRANSITIONS
- Move without reading input
- Spontaneous state changes
- Optional in NFA definition
3. INPUT PROCESSING
- Read input left to right
- Can branch to multiple states
- Can take ε-moves anytime
4. ACCEPTANCE
- Accept if ANY possible path ends in accept
- Reject if ALL paths fail to accept
- Input consumed entirely
5. COMPLEXITY
- Simulation: O(n Ć |Q|) ā track state sets
- Subset construction: O(2^|Q|) worst case
- Often much smaller than equivalent DFA
6. EXPRESSIVE POWER
- Exactly regular languages
- Equivalent to DFA, regex, right-linear grammars
""")š Formal Definition
šÆ The 5-Tuple
An NFA is formally defined as a 5-tuple M = (Q, Ī£, Ī“, qā, F), where Ī“ maps to sets of states rather than single states.
The Components
Q: Finite set of states. Ī£: Input alphabet. Ī“: Transition function Q Ć (Ī£ āŖ {ε}) ā P(Q). qā: Start state (qā ā Q). F: Set of accepting states (F ā Q). Recognition: String w accepted if some path from qā on w ends in F.
# Formal definition of NFA in code
from dataclasses import dataclass
from typing import Set, Dict, Tuple, FrozenSet
from collections import deque
@dataclass
class NFA:
"""
Formal NFA as 5-tuple M = (Q, Ī£, Ī“, qā, F)
where Ī“: Q Ć (Ī£ āŖ {ε}) ā P(Q)
"""
Q: FrozenSet[str] # States
Sigma: FrozenSet[str] # Alphabet (no ε)
delta: Dict[Tuple[str, str], FrozenSet[str]] # Transitions
q0: str # Start state
F: FrozenSet[str] # Accept states
def __post_init__(self):
# Validate
assert self.q0 in self.Q, "Start state must be in Q"
assert self.F.issubset(self.Q), "F must be subset of Q"
for (q, s), next_set in self.delta.items():
assert q in self.Q, f"State {q} not in Q"
assert s in self.Sigma or s == 'ε', f"Symbol {s} not valid"
assert next_set.issubset(self.Q), f"Next states not in Q"
def epsilon_closure(self, states: FrozenSet[str]) -> FrozenSet[str]:
"""Compute ε-closure of a set of states."""
closure = set(states)
stack = list(states)
while stack:
state = stack.pop()
for next_state in self.delta.get((state, 'ε'), frozenset()):
if next_state not in closure:
closure.add(next_state)
stack.append(next_state)
return frozenset(closure)
def accepts(self, input_string: str) -> bool:
"""Check if NFA accepts input_string."""
current = self.epsilon_closure(frozenset({self.q0}))
for symbol in input_string:
if symbol not in self.Sigma:
return False # Invalid symbol
next_states = set()
for state in current:
next_states |= self.delta.get((state, symbol), frozenset())
current = self.epsilon_closure(frozenset(next_states))
return bool(current & self.F)
def trace(self, input_string: str) -> list:
"""Trace execution step by step (showing state sets)."""
current = self.epsilon_closure(frozenset({self.q0}))
trace = [f"Start ε-closure: {sorted(current)}"]
for symbol in input_string:
next_states = set()
for state in current:
next_states |= self.delta.get((state, symbol), frozenset())
current = self.epsilon_closure(frozenset(next_states))
trace.append(f"Read '{symbol}' ā {sorted(current)}")
result = "ACCEPT" if (current & self.F) else "REJECT"
trace.append(f"Result: {result}")
return trace
# EXAMPLE: NFA for strings ending in "01"
nfa_ends_01 = NFA(
Q=frozenset({'q0', 'q1', 'q2'}),
Sigma=frozenset({'0', '1'}),
delta={
('q0', '0'): frozenset({'q0', 'q1'}), # nondeterministic choice!
('q0', '1'): frozenset({'q0'}),
('q1', '0'): frozenset(),
('q1', '1'): frozenset({'q2'}),
('q2', '0'): frozenset(),
('q2', '1'): frozenset(),
},
q0='q0',
F=frozenset({'q2'})
)
# Test
print("NFA for strings ending in '01':")
for s in ['01', '101', '001', '10', '11', '1', '0', '']:
result = nfa_ends_01.accepts(s)
print(f" '{s}' ā {result}")
# Trace
print("\n=== Trace for '101' ===")
for line in nfa_ends_01.trace('101'):
print(f" {line}")
# ε-NFA EXAMPLE
print("\n=== ε-NFA Example: a*b* ===")
nfa_astar_bstar = NFA(
Q=frozenset({'q0', 'q1'}),
Sigma=frozenset({'a', 'b'}),
delta={
('q0', 'a'): frozenset({'q0'}),
('q0', 'ε'): frozenset({'q1'}), # ε-transition!
('q1', 'b'): frozenset({'q1'}),
},
q0='q0',
F=frozenset({'q0', 'q1'})
)
print("Accepts strings of form a*b*:")
for s in ['', 'a', 'b', 'ab', 'aabb', 'aa', 'bb', 'ba', 'aba']:
result = nfa_astar_bstar.accepts(s)
print(f" '{s}' ā {result}")
# MORE EXAMPLES
print("""
=== Formal Definition Examples ===
Example 1: Strings containing "ab"
Q = {q0, q1, q2}
Ī£ = {a, b}
Ī“: (q0,a)ā{q0,q1}, (q0,b)ā{q0}
(q1,b)ā{q2}
(q2,a)ā{q2}, (q2,b)ā{q2}
qā = q0
F = {q2}
Example 2: Strings with even 0s OR even 1s
Q = {q0, q1, q2, q3}
Ī£ = {0, 1}
Ī“: (q0,0)ā{q1}, (q0,1)ā{q2}
(q1,0)ā{q0}, (q1,1)ā{q1}
(q2,0)ā{q2}, (q2,1)ā{q0}
(q3,Ā·)ā{q3}
qā = q0
F = {q0}
But wait ā this is deterministic. NFA can guess.
Example 3: Strings of length divisible by 3 or ending in "ab"
(Union via NFA with ε-transitions)
""")š Nondeterminism
šÆ The Power of Choice
Nondeterminism means the machine can have multiple possible next states for the same input. Think of it as "guessing" or "branching" ā the NFA accepts if any branch leads to acceptance.
Understanding Nondeterminism
Guessing: "Guess" the correct path. Branching: Explore all paths in parallel. Existential: Accept if ANY path accepts. No cost: Branching is free (theoretical). Simulation: Track set of possible states. Power: Same as DFA for regular languages.
# Nondeterminism in detail
print("=== Understanding Nondeterminism ===")
print("""
DFA vs NFA transitions:
DFA: Ī“(q, a) = q' (exactly one next state)
NFA: Ī“(q, a) = {q1, q2} (set of next states)
NFA can also have:
Ī“(q, a) = {} (no transition ā dead)
Ī“(q, a) = {q} (self-loop)
Ī“(q, a) = {q1, q2, q3} (multiple choices)
INTUITION: "Guessing"
Think of NFA as magically guessing the right path.
If there EXISTS a path to accept, it accepts.
This is like "parallel exploration" of all paths.
IMPORTANT: Nondeterminism does NOT add power!
- NFA recognizes exactly regular languages
- Any NFA can be converted to equivalent DFA
- But NFA can be exponentially smaller
""")
# 1. SIMULATING NFA: TRACKING SETS
class NFASimulator:
"""
Simulate an NFA by tracking the set of all possible states.
This is the "subset simulation" ā basis of subset construction.
"""
def __init__(self, states, alphabet, delta, q0, F):
self.Q = states
self.Sigma = alphabet
self.delta = delta # (state, symbol) -> set of states
self.q0 = q0
self.F = F
def simulate(self, word):
"""Return set of states after reading word."""
current = {self.q0}
# Apply epsilon closure initially
current = self.epsilon_closure(current)
for symbol in word:
next_states = set()
for state in current:
next_states |= self.delta.get((state, symbol), set())
current = self.epsilon_closure(next_states)
return current
def epsilon_closure(self, states):
"""Compute ε-closure."""
closure = set(states)
stack = list(states)
while stack:
state = stack.pop()
for next_state in self.delta.get((state, 'ε'), set()):
if next_state not in closure:
closure.add(next_state)
stack.append(next_state)
return closure
def accepts(self, word):
final_states = self.simulate(word)
return bool(final_states & self.F)
# Example: NFA for (a|b)*abb
nfa = NFASimulator(
states={'q0', 'q1', 'q2', 'q3'},
alphabet={'a', 'b'},
delta={
('q0', 'a'): {'q0', 'q1'},
('q0', 'b'): {'q0'},
('q1', 'b'): {'q2'},
('q2', 'b'): {'q3'},
('q3', 'a'): {'q3'},
('q3', 'b'): {'q3'},
},
q0='q0',
F={'q3'}
)
print("=== NFA Simulation: (a|b)*abb ===")
for s in ['abb', 'aabb', 'babb', 'abab', 'ab', 'abbb', 'bb']:
states = nfa.simulate(s)
result = nfa.accepts(s)
print(f" '{s:6s}' ā states={sorted(states)} ā {'ACCEPT' if result else 'REJECT'}")
# 2. NONDETERMINISM AS PARALLELISM
print("\n=== Nondeterminism as Parallelism ===")
print("""
Visualize NFA execution for 'aab' on (a|b)*abb:
Start: {q0}
Read 'a':
q0 --a--> q0, q1
So: {q0, q1}
Read 'a':
q0 --a--> q0, q1
q1 --a--> (none)
So: {q0, q1}
Read 'b':
q0 --b--> q0
q1 --b--> q2
So: {q0, q2}
Read 'b':
q0 --b--> q0
q2 --b--> q3
So: {q0, q3} ā contains accept state q3!
Result: ACCEPT ā
The NFA "guessed" to follow q1āq2āq3 path.
""")
# 3. WHY NONDETERMINISM IS USEFUL
print("=== Why Nondeterminism is Useful ===")
print("""
1. EASIER DESIGN
Problem: strings containing "ab" as substring
DFA: Need to track how much of "ab" matched
NFA: Just "guess" where "ab" starts!
NFA for "contains ab":
q0 --a--> q0, q1
q0 --b--> q0
q1 --b--> q2 (accept)
q2 --a,b--> q2
Much simpler than DFA!
2. CONCISE REPRESENTATION
Some languages need exponentially more DFA states.
Example: L = {w | k-th symbol from end is 'a'}
- DFA: 2^k states
- NFA: k+1 states
3. CLOSURE PROPERTIES
Union: NFA with new start and ε-transitions
Concatenation: connect via ε-transitions
Star: ε-loops
4. REGEX COMPILATION
Thompson's construction builds NFA from regex.
Then convert to DFA for implementation.
""")
# 4. DEMONSTRATION: WHY NFA IS CONCISE
print("=== NFA vs DFA Size ===")
print("""
Language: Strings where the 3rd symbol from the end is 'a'
Ī£ = {a, b}
DFA approach: Need to remember last 3 symbols
States = 2^3 = 8 states (all possible 3-symbol suffixes)
NFA approach: Guess where 3rd-from-last 'a' is
States = 4 states (q0, q1, q2, q3)
q0: initial, can stay or guess
q1: seen 'a' (3rd from end)
q2: seen 'a' + one symbol
q3: seen 'a' + two symbols (accept)
NFA transitions:
q0 --a--> q0, q1
q0 --b--> q0
q1 --a,b--> q2
q2 --a,b--> q3
q3 --a,b--> q3
F = {q3}
For k=10:
DFA: 2^10 = 1024 states
NFA: 11 states
Exponential difference!
""")
# 5. PROVING NFA CORRECTNESS
print("\n=== Proving NFA Correctness ===")
print("""
To prove an NFA M accepts language L:
Part 1: L ā L(M)
For every w ā L, show there exists an accepting path.
ā Constructive: describe the path the NFA takes.
Part 2: L(M) ā L
For every accepting path in M, show w ā L.
ā Show every accepting path implies w has property.
Example: NFA for "contains ab"
Part 1: If w contains "ab" at position i,
NFA guesses i, follows q0āq1āq2, accepts.
Part 2: If NFA accepts, path must be:
q0* ā q1 ā q2 ā q2*
So w contains "ab". ā
""")
# 6. COMMON NFA PATTERNS
print("=== Common NFA Patterns ===")
print("""
1. CONTAINS PATTERN
"Guess" where pattern starts
ā Initial state loops, non-deterministically enters pattern
2. ENDS WITH PATTERN
Reverse of contains ā pattern must reach end
ā States track pattern progress, accept only at end
3. UNION (R1 | R2)
ā New start state with ε-transitions to R1 and R2
4. CONCATENATION (R1 R2)
ā ε-transitions from R1 accept states to R2 start
5. KLEENE STAR (R*)
ā ε-loop from accept back to start
ā New start/accept with ε-transitions
6. OPTIONAL (R?)
ā R | ε
7. REPETITION (R+)
ā R R*
""")
# 7. BOUNDED VS UNBOUNDED NONDETERMINISM
print("\n=== Types of Nondeterminism ===")
print("""
1. FINITE BRANCHING
Each (state, symbol) has finitely many next states.
ā Standard NFA.
2. ε-TRANSITIONS
Additional nondeterminism: move without input.
ā ε-NFA. Equivalent to NFA.
3. UNBOUNDED NONDETERMINISM
Not allowed in NFA!
Each transition has a finite set of next states.
(This would be a different model ā e.g.,
nondeterministic Turing machine)
NFA always has:
- Finite states
- Finite alphabet
- Finite transitions (each Ī“(q,a) is finite set)
So NFA computation always terminates.
""")ε Epsilon Transitions
šÆ Spontaneous Moves
An ε-transition allows an NFA to change state without reading any input. This adds convenience but no extra power ā every ε-NFA can be converted to an equivalent NFA (and then DFA).
Understanding ε-Transitions
Free move: Change state without consuming input. ε-closure: All states reachable via ε-moves. Convenience: Simplify design (union, concat, star). Optional: Can always be eliminated. Closure: Must compute ε-closure after every step. No power: Equivalent to regular NFA.
# Epsilon transitions ā theory and implementation
from collections import defaultdict, deque
# 1. WHAT IS ε-CLOSURE?
print("=== ε-Closure Definition ===")
print("""
For a state q, ε-closure(q) = set of all states reachable
from q using ONLY ε-transitions (zero or more).
Formal definition:
ε-closure(q) = {q} āŖ {p | q āεā ... āεā p}
For a set S of states:
ε-closure(S) = āŖ_{q ā S} ε-closure(q)
Computing ε-closure:
BFS/DFS from q following only ε-transitions.
""")
class EpsilonNFA:
"""ε-NFA implementation."""
def __init__(self, states, alphabet, delta, q0, F):
self.Q = states
self.Sigma = alphabet
self.delta = delta # (state, symbol) -> set of states
self.q0 = q0
self.F = F
def epsilon_closure(self, states):
"""Compute ε-closure of a set of states."""
closure = set(states)
stack = list(states)
while stack:
state = stack.pop()
for next_state in self.delta.get((state, 'ε'), set()):
if next_state not in closure:
closure.add(next_state)
stack.append(next_state)
return closure
def accepts(self, word):
"""Simulate ε-NFA."""
current = self.epsilon_closure({self.q0})
for symbol in word:
next_states = set()
for state in current:
next_states |= self.delta.get((state, symbol), set())
current = self.epsilon_closure(next_states)
return bool(current & self.F)
def trace(self, word):
"""Trace with ε-closures."""
current = self.epsilon_closure({self.q0})
trace = [f"Start ε-closure: {sorted(current)}"]
for symbol in word:
next_states = set()
for state in current:
next_states |= self.delta.get((state, symbol), set())
current = self.epsilon_closure(next_states)
trace.append(f"Read '{symbol}' ā {sorted(current)}")
result = "ACCEPT" if (current & self.F) else "REJECT"
trace.append(f"Result: {result}")
return trace
# 2. EXAMPLE: ε-NFA FOR a*b*
print("=== Example: ε-NFA for a*b* ===")
print("""
States: {q0, q1, q2}
q0: reading a's
q1: transitioning (via ε) to b's
q2: reading b's
Transitions:
(q0, 'a') ā {q0}
(q0, 'ε') ā {q1}
(q1, 'b') ā {q1}
(q1, 'ε') ā {q2}
q0 = q0
F = {q0, q1, q2}
Accepts: '', 'a', 'aa', 'b', 'bb', 'ab', 'aab', 'abb', 'aaabbb'
Rejects: 'ba', 'bab', 'aba'
""")
nfa_astar_bstar = EpsilonNFA(
states={'q0', 'q1', 'q2'},
alphabet={'a', 'b'},
delta={
('q0', 'a'): {'q0'},
('q0', 'ε'): {'q1'},
('q1', 'b'): {'q1'},
('q1', 'ε'): {'q2'},
},
q0='q0',
F={'q0', 'q1', 'q2'}
)
print("ε-NFA for a*b*:")
for s in ['', 'a', 'b', 'ab', 'aabb', 'ba', 'aba', 'aa']:
result = nfa_astar_bstar.accepts(s)
print(f" '{s}' ā {result}")
# 3. ε-CLOSURE COMPUTATION
print("\n=== ε-Closure Computation ===")
def compute_epsilon_closures(nfa):
"""Compute ε-closure for every state."""
closures = {}
for state in nfa.Q:
closures[state] = nfa.epsilon_closure({state})
return closures
closures = compute_epsilon_closures(nfa_astar_bstar)
for state, closure in sorted(closures.items()):
print(f" ε-closure({state}) = {sorted(closure)}")
# 4. ε-NFA SIMULATION DETAIL
print("\n=== ε-NFA Simulation: 'aab' ===")
for line in nfa_astar_bstar.trace('aab'):
print(f" {line}")
# 5. ELIMINATING ε-TRANSITIONS
print("\n=== Eliminating ε-Transitions ===")
print("""
Convert ε-NFA to NFA (without ε):
Method:
1. For each state q and symbol a:
Ī“'(q, a) = ε-closure( āŖ_{p ā ε-closure(q)} Ī“(p, a) )
2. New accept states:
F' = {q | ε-closure(q) ā© F ā ā
}
3. Start state: ε-closure(q0) ā but keep q0 as start
4. Remove all ε-transitions.
Result: Equivalent NFA without ε-transitions.
Complexity: O(|Q|² à |Σ|) worst case.
""")
def eliminate_epsilon(nfa):
"""Convert ε-NFA to NFA without ε-transitions."""
# Compute ε-closures
closures = {state: nfa.epsilon_closure({state}) for state in nfa.Q}
# New transitions
new_delta = {}
for q in nfa.Q:
for a in nfa.Sigma:
next_states = set()
# From all states in ε-closure(q)
for p in closures[q]:
next_states |= nfa.delta.get((p, a), set())
# Apply ε-closure to result
next_states = nfa.epsilon_closure(next_states)
if next_states:
new_delta[(q, a)] = new_states
# New accept states
new_F = {q for q in nfa.Q if closures[q] & nfa.F}
return NFA_NoEps(nfa.Q, nfa.Sigma, new_delta, nfa.q0, new_F)
class NFA_NoEps:
"""NFA without ε-transitions."""
def __init__(self, states, alphabet, delta, q0, F):
self.Q = states
self.Sigma = alphabet
self.delta = delta
self.q0 = q0
self.F = F
def accepts(self, word):
current = {self.q0}
for symbol in word:
next_states = set()
for state in current:
next_states |= self.delta.get((state, symbol), set())
current = next_states
return bool(current & self.F)
# Convert
nfa_no_eps = eliminate_epsilon(nfa_astar_bstar)
print("After eliminating ε-transitions:")
for s in ['', 'a', 'b', 'ab', 'aabb', 'ba']:
r1 = nfa_astar_bstar.accepts(s)
r2 = nfa_no_eps.accepts(s)
match = "ā" if r1 == r2 else "ā"
print(f" '{s:6s}' ε-NFA={r1}, NFA={r2} {match}")
# 6. WHY ε-TRANSITIONS ARE USEFUL
print("\n=== Why ε-Transitions Are Useful ===")
print("""
1. UNION (R1 | R2)
New start state with ε to both starts:
ā(q_new) --ε--> start(R1)
--ε--> start(R2)
2. CONCATENATION (R1 R2)
ε from R1 accept to R2 start:
accept(R1) --ε--> start(R2)
3. KLEENE STAR (R*)
ε from accept back to start:
accept(R) --ε--> start(R)
Plus new start/accept states
4. OPTIONAL (R?)
R | ε ā use ε-transition
5. SIMPLIFICATION
Avoids complex transition combinations.
Makes proofs and constructions cleaner.
All these can be done without ε,
but ε makes the construction trivial.
""")
# 7. PRACTICAL NOTE
print("=== Practical Note ===")
print("""
In practice:
- Design with ε (easier)
- Eliminate ε (or go directly to DFA via subset construction)
- Minimize DFA
- Implement
Subset construction handles ε directly:
- DFA state = ε-closure of subset of NFA states
- Ī“_DFA(S, a) = ε-closure(āŖ_{qāS} Ī“_NFA(q, a))
This is the standard pipeline:
Regex ā ε-NFA ā NFA ā DFA ā Minimal DFA
""")ā String Acceptance
šÆ Does the NFA Accept This String?
An NFA accepts a string if there exists at least one path from the start state to an accept state that consumes the entire input. The language of an NFA is the set of all strings it accepts.
Acceptance Process
1. Start: ε-closure({qā}). 2. Read: For each symbol, compute next state set. 3. ε-closure: Apply after each step. 4. End: Check if set contains accept state. Accept: Any accept state in final set. Reject: No accept state in final set.
# String acceptance for NFAs
class NFA:
def __init__(self, states, alphabet, delta, q0, F):
self.Q = states
self.Sigma = alphabet
self.delta = delta
self.q0 = q0
self.F = F
def epsilon_closure(self, states):
"""Compute ε-closure."""
closure = set(states)
stack = list(states)
while stack:
state = stack.pop()
for next_state in self.delta.get((state, 'ε'), set()):
if next_state not in closure:
closure.add(next_state)
stack.append(next_state)
return closure
def accepts(self, word):
"""Return True if NFA accepts word."""
current = self.epsilon_closure({self.q0})
for symbol in word:
if symbol not in self.Sigma:
return False
next_states = set()
for state in current:
next_states |= self.delta.get((state, symbol), set())
current = self.epsilon_closure(next_states)
return bool(current & self.F)
def run_trace(self, word):
"""Return detailed execution trace."""
current = self.epsilon_closure({self.q0})
trace = [('start', None, frozenset(current))]
for symbol in word:
next_states = set()
for state in current:
next_states |= self.delta.get((state, symbol), set())
current = self.epsilon_closure(next_states)
trace.append(('read', symbol, frozenset(current)))
accepted = bool(current & self.F)
return trace, accepted
# Example: NFA for strings ending in "01"
nfa = NFA(
states={'q0', 'q1', 'q2'},
alphabet={'0', '1'},
delta={
('q0', '0'): {'q0', 'q1'},
('q0', '1'): {'q0'},
('q1', '1'): {'q2'},
},
q0='q0',
F={'q2'}
)
# 1. ACCEPTANCE TESTING
print("=== Acceptance Testing ===")
test_strings = ['', '0', '1', '01', '10', '11', '001', '101', '1101', '111']
for s in test_strings:
result = "ā ACCEPT" if nfa.accepts(s) else "ā REJECT"
print(f"'{s:6s}' ā {result}")
# 2. STEP-BY-STEP TRACE
print("\n=== Step-by-Step Trace for '101' ===")
trace, accepted = nfa.run_trace('101')
print(f"{'Step':<6} {'Action':<8} {'Symbol':<8} {'State Set':<30}")
print("-" * 60)
for i, (action, symbol, state_set) in enumerate(trace):
symbol_str = symbol if symbol else 'ā'
states_str = '{' + ', '.join(sorted(state_set)) + '}'
print(f"{i:<6} {action:<8} {symbol_str:<8} {states_str:<30}")
print(f"\nFinal result: {'ACCEPT ā' if accepted else 'REJECT ā'}")
# 3. NFA ACCEPTANCE IS EXISTENTIAL
print("\n=== NFA Acceptance is Existential ===")
print("""
Key insight: NFA accepts if ANY path accepts.
For string '001' on NFA ending in '01':
Possible paths:
Path 1: q0 --0--> q0 --0--> q0 --1--> q0 ā (q0 not accept)
Path 2: q0 --0--> q1 --0--> (dead) ā
Path 3: q0 --0--> q0 --0--> q1 --1--> q2 ā ACCEPT!
Path 4: q0 --0--> q1 --0--> (dead) ā
Since Path 3 accepts, the NFA accepts '001'.
The NFA "guessed" to take the transition q0āq1
on the second '0' (not the first).
""")
# 4. LANGUAGE OF AN NFA
print("=== Language of an NFA ===")
print("""
L(M) = { w ā Ī£* | there exists a path from qā to some q ā F
that consumes all of w }
For NFA ending in "01":
L(M) = { w ā {0,1}* | w ends in "01" }
Examples:
ā Accepted: 01, 101, 001, 1101, 0101, 00001
ā Rejected: 0, 1, 10, 11, 100, 011, 0111
Regular expression: (0|1)*01
Same language as the DFA!
""")
# 5. ENUMERATING ACCEPTED STRINGS
def enumerate_language(nfa, max_length):
"""Enumerate all accepted strings up to max_length."""
from itertools import product
accepted = []
for length in range(max_length + 1):
for symbols in product(nfa.Sigma, repeat=length):
word = ''.join(symbols)
if nfa.accepts(word):
accepted.append(word)
return accepted
print("\n=== Enumerating Language (up to length 4) ===")
accepted = enumerate_language(nfa, 4)
print(f"Total accepted strings (len ⤠4): {len(accepted)}")
for s in accepted:
print(f" '{s}'")
# 6. COMPLEXITY
print("\n=== Time Complexity ===")
print("""
For an NFA M and input string w:
Simulation: O(|w| à |Q|²)
- At each step, compute next state set
- Each state set can have up to |Q| states
- Transitions from each state: O(|Q|)
Space: O(|Q|) ā store current state set
Subset construction (to DFA):
Worst case: O(2^|Q|) states
Then simulate DFA: O(|w|) time
Trade-off:
- NFA: compact, slower simulation
- DFA: larger, faster simulation
In practice:
- Use NFA for design
- Convert to DFA for implementation
""")
# 7. NFA ACCEPTANCE VS DFA ACCEPTANCE
print("\n=== NFA vs DFA Acceptance ===")
print(f"{'Aspect':<30} {'DFA':<25} {'NFA':<25}")
print("-" * 80)
print(f"{'States after input':<30} {'1 state':<25} {'Set of states':<25}")
print(f"{'Acceptance condition':<30} {'In F':<25} {'Intersects F':<25}")
print(f"{'Path uniqueness':<30} {'Unique path':<25} {'Many paths':<25}")
print(f"{'Simulation time':<30} {'O(n)':<25} {'O(n à |Q|²)':<25}")
print(f"{'Space':<30} {'O(1)':<25} {'O(|Q|)':<25}")
# 8. PROVING EQUIVALENCE
print("\n=== Proving NFA = DFA ===")
print("""
Theorem: For every NFA, there exists an equivalent DFA.
Proof: Subset construction.
Let N = (Q_N, Σ, Γ_N, q0_N, F_N) be an NFA.
Construct DFA D = (Q_D, Σ, Γ_D, q0_D, F_D):
Q_D = P(Q_N) (all subsets of Q_N)
q0_D = ε-closure({q0_N})
F_D = { S ā Q_D | S ā© F_N ā ā
}
Ī“_D(S, a) = ε-closure( āŖ_{q ā S} Ī“_N(q, a) )
Claim: L(D) = L(N).
Proof:
Γ_D*(q0_D, w) = ε-closure( Γ_N*(q0_N, w) ) (by induction)
w ā L(N)
ā Ī“_N*(q0_N, w) ā© F_N ā ā
ā Ī“_D*(q0_D, w) ā© F_N ā ā
ā Ī“_D*(q0_D, w) ā F_D
ā w ā L(D) ā
Note: We only need REACHABLE subsets.
In practice, |Q_D| << 2^|Q_N|.
""")šØ Designing NFAs
šÆ Building NFAs for Languages
Designing NFAs is often easier than designing DFAs because you can use nondeterminism to "guess" the right path. Here's a systematic approach.
Design Methodology
1. Understand: What language? 2. Decompose: Can it be built from simpler languages? 3. Guess: What can the NFA guess? 4. Use ε: Combine pieces with ε-transitions. 5. Mark accept: Which states accept? 6. Test: Verify with examples.
# Designing NFAs ā systematic approach
print("=== NFA Design Template ===")
print("""
Step 1: WHAT is the language?
- Describe in words
- Identify the pattern
- Consider edge cases
Step 2: WHAT can the NFA guess?
- Where does the pattern start/end?
- Which branch to take?
- When to use ε-transitions?
Step 3: HOW MANY states?
- Usually fewer than DFA
- One state per "pattern position"
- Plus initial and accept states
Step 4: TRANSITIONS?
- Use nondeterminism freely
- Use ε-transitions to combine
- Don't worry about "missing" transitions
Step 5: START state?
- Usually a new initial state
- Can ε-transition to sub-NFAs
Step 6: ACCEPT states?
- States that complete the pattern
- Can be multiple
Step 7: TEST
- Try positive and negative examples
- Trace to verify
""")
# EXAMPLE 1: CONTAINS "ab"
print("\n=== Example 1: Contains 'ab' ===")
print("""
Step 1: Language
L = {w ā {a,b}* | w contains 'ab' as substring}
Step 2: Guess
Guess WHERE the "ab" starts.
Step 3: States
q0: initial ā haven't guessed yet
q1: guessed 'a' (waiting for 'b')
q2: found 'ab' (accept)
Step 4: Transitions
q0 --a--> q0 (stay), q1 (guess!)
q0 --b--> q0
q1 --b--> q2
q2 --a,b--> q2
Step 5: Start = q0
Step 6: Accept = {q2}
Step 7: Test
'ab' ā q0 --a--> {q0,q1} --b--> {q0,q2} ā
'aab' ā guess on 2nd a, then b ā ā
'aba' ā ā
'ba' ā q0 --b--> q0 --a--> {q0,q1} ā F ā
'bb' ā q0 --b--> q0 --b--> q0 ā F ā
Much simpler than DFA!
DFA would need to track how much of "ab" matched.
""")
# EXAMPLE 2: UNION OF LANGUAGES
print("\n=== Example 2: Union (ends in 'ab' OR contains 'bb') ===")
print("""
Step 1: Language
L = {w | w ends in 'ab'} āŖ {w | w contains 'bb'}
Step 2: Decompose
Build NFA for each part, combine with ε.
Step 3-6: Build NFA for each part:
NFA1 (ends in 'ab'):
States: {p0, p1, p2}
p0 --a--> p1
p0 --b--> p0
p1 --a--> p1, p0 --b--> p2
p1 --b--> p2
p2 --a--> p1, p2 --b--> p0
NFA2 (contains 'bb'):
States: {r0, r1, r2}
r0 --a--> r0, r0 --b--> r0, r1
r1 --b--> r2
r2 --a,b--> r2
Combine with new start state s:
s --ε--> p0
s --ε--> r0
F = {p2, r2}
Result: NFA accepts if either branch accepts.
""")
# EXAMPLE 3: CONCATENATION
print("\n=== Example 3: Concatenation (starts with 'a' then ends with 'b') ===")
print("""
Step 1: L = {w | w starts with 'a' and ends with 'b'}
Step 2: Decompose into L1 Ā· L2
L1 = {w | starts with 'a'} = a(a|b)*
L2 = {w | ends with 'b'} = (a|b)*b
Step 3: Build NFA for L1:
q0 --a--> q1
q1 --a,b--> q1
F1 = {q1}
NFA for L2:
p0 --a,b--> p0, p1 (guess end)
p1 --b--> p2
F2 = {p2}
Concatenate:
q1 --ε--> p0
F = {p2}
Accepts: strings starting with 'a' AND ending with 'b'
'ab', 'aab', 'abb', 'aabb', 'abab', ...
Rejects: 'a', 'b', 'ba', 'aba', 'bab'
""")
# DESIGN PATTERNS
print("\n=== Common NFA Design Patterns ===")
print("""
1. CONTAINS PATTERN (P)
q0 --a--> q0, q1 (guess start)
... pattern states ...
accept state loops on all symbols
2. ENDS WITH PATTERN (P)
... pattern states ...
accept state must be final
No loops out of accept
3. STARTS WITH PATTERN (P)
initial state only allows pattern
Then free transitions
4. UNION (R1 | R2)
New start with ε to R1 and R2 starts
Accept = union of accepts
5. CONCATENATION (R1 R2)
ε from R1 accepts to R2 start
6. KLEENE STAR (R*)
New start/accept
ε from start to R start and accept
ε from R accept back to R start and accept
7. OPTIONAL (R?)
R | ε
8. REPETITION (R+)
R R* ā at least one R
""")
# SYSTEMATIC CODE
def design_nfa_contains_ab():
"""Design NFA for strings containing 'ab'."""
print("\n=== Design: Contains 'ab' ===")
print("""
NFA for L = {w | w contains 'ab'}
Q = {q0, q1, q2}
Ī£ = {a, b}
q0 = q0
F = {q2}
Ī“:
(q0, 'a') ā {q0, q1} ā guess: stay OR start matching
(q0, 'b') ā {q0}
(q1, 'b') ā {q2}
(q2, 'a') ā {q2}
(q2, 'b') ā {q2}
Test:
'ab' ā {q0,q1} --b--> {q0,q2} ā
'aab' ā {q0,q1} --a--> {q0,q1} --b--> {q0,q2} ā
'ba' ā {q0} --b--> {q0} --a--> {q0,q1} ā
'bb' ā {q0} --b--> {q0} --b--> {q0} ā
""")
design_nfa_contains_ab()
def design_nfa_even_ab():
"""Design NFA for strings with even number of 'ab'."""
print("\n=== Design: Even number of 'ab' occurrences ===")
print("""
L = {w | number of occurrences of 'ab' is even}
This is actually easier as DFA, but NFA works too.
States: {Even, Odd}
Even: even number of "ab" seen
Odd: odd number of "ab" seen
Transitions:
(Even, 'a') ā {Even} (if next isn't b)
... wait, this needs lookahead!
NFA solution: guess the occurrences.
Actually, this is a regular language.
The DFA has states:
q0: even ab, last wasn't 'a'
q1: even ab, last was 'a'
q2: odd ab, last wasn't 'a'
q3: odd ab, last was 'a'
F = {q0, q1}
NFA can just track parity ā but needs to detect "ab".
Since "ab" detection is local (needs 1 symbol memory),
NFA doesn't help much here. DFA is natural.
Lesson: NFAs are most useful when "guessing" helps.
""")
design_nfa_even_ab()š Subset Construction (NFAāDFA)
šÆ Converting NFA to DFA
The subset construction (also called powerset construction) converts any NFA into an equivalent DFA. The resulting DFA's states are sets of NFA states.
Subset Construction Algorithm
1. Start: ε-closure({qā_N}). 2. For each subset: Ī“_D(S, a) = ε-closure(āŖ Ī“_N(q, a)). 3. Repeat: Until all reachable subsets found. 4. Accept: Subsets containing NFA accept state. 5. Result: DFA equivalent to NFA. Worst case: 2^|Q_N| states.
# Subset Construction ā NFA to DFA conversion
from collections import deque
from itertools import product
class NFA:
def __init__(self, states, alphabet, delta, q0, F):
self.Q = states
self.Sigma = alphabet
self.delta = delta
self.q0 = q0
self.F = F
def epsilon_closure(self, states):
"""Compute ε-closure."""
closure = set(states)
stack = list(states)
while stack:
state = stack.pop()
for next_state in self.delta.get((state, 'ε'), set()):
if next_state not in closure:
closure.add(next_state)
stack.append(next_state)
return closure
def accepts(self, word):
current = self.epsilon_closure({self.q0})
for symbol in word:
next_states = set()
for state in current:
next_states |= self.delta.get((state, symbol), set())
current = self.epsilon_closure(next_states)
return bool(current & self.F)
class DFA:
def __init__(self, states, alphabet, delta, q0, F):
self.Q = states
self.Sigma = alphabet
self.delta = delta
self.q0 = q0
self.F = F
def accepts(self, word):
state = self.q0
for symbol in word:
state = self.delta[(state, symbol)]
return state in self.F
def __repr__(self):
return f"DFA({len(self.Q)} states, alphabet={self.Sigma})"
# SUBSET CONSTRUCTION
def subset_construction(nfa):
"""
Convert NFA to DFA.
States of DFA are subsets of NFA states.
Start: ε-closure({q0_NFA})
Ī“_D(S, a) = ε-closure(āŖ_{qāS} Ī“_N(q, a))
F_D = {S | S ā© F_NFA ā ā
}
"""
# Initial DFA state
start = frozenset(nfa.epsilon_closure({nfa.q0}))
# BFS over reachable subsets
dfa_states = {start}
queue = deque([start])
dfa_delta = {}
while queue:
current = queue.popleft()
for symbol in nfa.Sigma:
# Compute next state set
next_set = set()
for state in current:
next_set |= nfa.delta.get((state, symbol), set())
next_set = frozenset(nfa.epsilon_closure(next_set))
dfa_delta[(current, symbol)] = next_set
if next_set not in dfa_states:
dfa_states.add(next_set)
queue.append(next_set)
# Accept states: subsets containing NFA accept state
dfa_F = {s for s in dfa_states if s & nfa.F}
return DFA(dfa_states, nfa.Sigma, dfa_delta, start, dfa_F)
# EXAMPLE 1: SIMPLE NFA
print("=== Example 1: NFA for strings ending in '01' ===")
nfa1 = NFA(
states={'q0', 'q1', 'q2'},
alphabet={'0', '1'},
delta={
('q0', '0'): {'q0', 'q1'},
('q0', '1'): {'q0'},
('q1', '1'): {'q2'},
},
q0='q0',
F={'q2'}
)
# Convert
dfa1 = subset_construction(nfa1)
print(f"NFA states: {len(nfa1.Q)}")
print(f"DFA states: {len(dfa1.Q)}")
print(f"\nDFA states (as sets):")
for state in dfa1.Q:
marker = " (accept)" if state in dfa1.F else ""
print(f" {set(state)}{marker}")
# Test equivalence
print("\n=== Equivalence Test ===")
test_strings = ['01', '101', '001', '10', '11', '1', '0', '']
print(f"{'String':<10} {'NFA':<8} {'DFA':<8} {'Match':<8}")
print("-" * 40)
for s in test_strings:
r_nfa = nfa1.accepts(s)
r_dfa = dfa1.accepts(s)
match = "ā" if r_nfa == r_dfa else "ā"
print(f"'{s:6s}' {str(r_nfa):<8} {str(r_dfa):<8} {match:<8}")
# EXAMPLE 2: NFA WITH ε-TRANSITIONS
print("\n=== Example 2: ε-NFA for a*b* ===")
nfa2 = NFA(
states={'q0', 'q1', 'q2'},
alphabet={'a', 'b'},
delta={
('q0', 'a'): {'q0'},
('q0', 'ε'): {'q1'},
('q1', 'b'): {'q1'},
('q1', 'ε'): {'q2'},
},
q0='q0',
F={'q0', 'q1', 'q2'}
)
dfa2 = subset_construction(nfa2)
print(f"NFA states: {len(nfa2.Q)}")
print(f"DFA states: {len(dfa2.Q)}")
print(f"\nDFA states:")
for state in dfa2.Q:
marker = " (accept)" if state in dfa2.F else ""
print(f" {set(state)}{marker}")
# Test
print("\nEquivalence test:")
for s in ['', 'a', 'b', 'ab', 'aabb', 'ba', 'aba', 'aa', 'bb']:
r_nfa = nfa2.accepts(s)
r_dfa = dfa2.accepts(s)
match = "ā" if r_nfa == r_dfa else "ā"
print(f" '{s:6s}' NFA={r_nfa}, DFA={r_dfa} {match}")
# EXAMPLE 3: ONLY REACHABLE STATES
print("\n=== Only Reachable States ===")
print("""
Subset construction only creates REACHABLE subsets.
This is crucial ā otherwise we'd have 2^|Q| states!
For most practical NFAs, the number of reachable
subsets is much smaller than 2^|Q|.
Example: NFA with 10 states
Worst case: 2^10 = 1024 DFA states
Typical: 10-50 DFA states
The BFS approach automatically avoids unreachable states.
""")
# VISUALIZATION OF SUBSET CONSTRUCTION
print("=== Subset Construction Trace ===")
print("""
NFA for "ends in 01":
q0 --0--> {q0, q1}
q0 --1--> {q0}
q1 --1--> {q2}
Subset construction:
Start: ε-closure({q0}) = {q0}
Call this DFA state A = {q0}
From A on '0': {q0, q1} ā call this B
From A on '1': {q0} ā A (self-loop)
From B on '0': {q0, q1} ā B
From B on '1': {q0, q2} ā call this C
From C on '0': {q0, q1} ā B
From C on '1': {q0} ā A
DFA states: A, B, C
A = {q0}
B = {q0, q1}
C = {q0, q2} ā contains q2 (accept!)
F_DFA = {C}
This matches the DFA we designed directly!
""")
# COMPLEXITY
print("=== Complexity of Subset Construction ===")
print("""
Time: O(2^|Q_N| Ć |Ī£|)
- At most 2^|Q_N| subsets
- For each subset, |Ī£| symbols
- For each symbol, compute union and ε-closure
Space: O(2^|Q_N|)
- Store DFA states (subsets)
Worst case: Exponential blowup
- Some NFAs require 2^n DFA states
- Example: L = {w | k-th symbol from end is 'a'}
NFA: k+1 states
DFA: 2^k states
In practice:
- Usually much smaller
- Many subsets unreachable
- Use BFS to avoid unreachable states
Optimization:
- Lazy construction (only when needed)
- On-the-fly subset construction
- Used in regex engines (RE2)
""")
# PRACTICAL APPLICATION
print("=== Practical Application: Regex Engine ===")
print("""
Modern regex engines use subset construction:
1. Parse regex ā AST
2. Thompson construction ā ε-NFA
3. Subset construction ā DFA (on-the-fly)
4. Minimize DFA (Hopcroft)
5. Simulate DFA (linear time)
On-the-fly subset construction:
- Don't precompute all DFA states
- Compute transitions lazily as needed
- Cache results for performance
- Used in RE2, Rust regex
Benefits:
- No exponential blowup in practice
- Linear time matching
- No catastrophic backtracking
""")ā Operations (Union, Concat, Star)
šÆ Combining NFAs
NFAs make it easy to prove closure properties. Union, concatenation, and Kleene star all have simple NFA constructions using ε-transitions.
NFA Closure Constructions
Union: New start with ε to both. Concat: ε from R1 accept to R2 start. Star: ε-loop back to start. Complement: Harder (need DFA). Intersection: Product construction. Closure: All regular languages.
# NFA operations ā closure properties
from collections import deque
class NFA:
def __init__(self, states, alphabet, delta, q0, F):
self.Q = states
self.Sigma = alphabet
self.delta = delta
self.q0 = q0
self.F = F
def epsilon_closure(self, states):
closure = set(states)
stack = list(states)
while stack:
state = stack.pop()
for next_state in self.delta.get((state, 'ε'), set()):
if next_state not in closure:
closure.add(next_state)
stack.append(next_state)
return closure
def accepts(self, word):
current = self.epsilon_closure({self.q0})
for symbol in word:
next_states = set()
for state in current:
next_states |= self.delta.get((state, symbol), set())
current = self.epsilon_closure(next_states)
return bool(current & self.F)
# 1. UNION
def union(nfa1, nfa2):
"""
L(M1 āŖ M2) = L(M1) āŖ L(M2)
New start state with ε to both starts.
Accept = F1 āŖ F2.
"""
# Create new start state
new_start = 'q_union_start'
# New states: all states from both + new start
new_Q = nfa1.Q | nfa2.Q | {new_start}
# New transitions
new_delta = dict(nfa1.delta)
for (q, s), next_set in nfa2.delta.items():
new_delta[(q, s)] = next_set
# Add ε-transitions from new start
new_delta[(new_start, 'ε')] = {nfa1.q0, nfa2.q0}
# Accept states: union
new_F = nfa1.F | nfa2.F
return NFA(new_Q, nfa1.Sigma | nfa2.Sigma, new_delta, new_start, new_F)
# 2. CONCATENATION
def concatenate(nfa1, nfa2):
"""
L(M1 Ā· M2) = L(M1) Ā· L(M2)
ε-transitions from M1 accept states to M2 start.
Accept = F2.
"""
new_Q = nfa1.Q | nfa2.Q
new_delta = dict(nfa1.delta)
for (q, s), next_set in nfa2.delta.items():
new_delta[(q, s)] = next_set
# Add ε from M1 accepts to M2 start
for accept_state in nfa1.F:
new_delta[(accept_state, 'ε')] = {nfa2.q0}
return NFA(new_Q, nfa1.Sigma | nfa2.Sigma, new_delta, nfa1.q0, nfa2.F)
# 3. KLEENE STAR
def kleene_star(nfa):
"""
L(M*) = (L(M))*
New start/accept with ε-transitions.
ε from new start to old start.
ε from old accepts back to old start.
ε from old accepts to new accept.
"""
new_start = 'q_star_start'
new_accept = 'q_star_accept'
new_Q = nfa.Q | {new_start, new_accept}
new_delta = dict(nfa.delta)
# New start ε to old start and new accept
new_delta[(new_start, 'ε')] = {nfa.q0, new_accept}
# Old accepts ε to old start and new accept
for accept_state in nfa.F:
if (accept_state, 'ε') in new_delta:
new_delta[(accept_state, 'ε')] |= {nfa.q0, new_accept}
else:
new_delta[(accept_state, 'ε')] = {nfa.q0, new_accept}
# New accept
new_F = {new_accept}
return NFA(new_Q, nfa.Sigma, new_delta, new_start, new_F)
# 4. PLUS (R+)
def plus(nfa):
"""L(M+) = L(M) Ā· L(M)*"""
return concatenate(nfa, kleene_star(nfa))
# 5. OPTIONAL (R?)
def optional(nfa):
"""L(M?) = L(M) ⪠{ε}"""
# Add ε-transition from start to a new accept state
new_accept = 'q_opt_accept'
new_Q = nfa.Q | {new_accept}
new_delta = dict(nfa.delta)
new_delta[(nfa.q0, 'ε')] = {new_accept}
new_F = nfa.F | {new_accept}
return NFA(new_Q, nfa.Sigma, new_delta, nfa.q0, new_F)
# EXAMPLE
print("=== NFA Operations Demo ===")
# NFA for strings ending in "ab"
nfa_ab = NFA(
states={'a0', 'a1', 'a2'},
alphabet={'a', 'b'},
delta={
('a0', 'a'): {'a1'},
('a0', 'b'): {'a0'},
('a1', 'b'): {'a2'},
('a1', 'a'): {'a1'},
('a2', 'a'): {'a1'},
('a2', 'b'): {'a0'},
},
q0='a0',
F={'a2'}
)
# NFA for strings with even length
nfa_even = NFA(
states={'b0', 'b1'},
alphabet={'a', 'b'},
delta={
('b0', 'a'): {'b1'},
('b0', 'b'): {'b1'},
('b1', 'a'): {'b0'},
('b1', 'b'): {'b0'},
},
q0='b0',
F={'b0'}
)
# Union
nfa_union = union(nfa_ab, nfa_even)
print(f"\nUnion NFA states: {len(nfa_union.Q)}")
# Concatenation
nfa_concat = concatenate(nfa_ab, nfa_even)
print(f"Concat NFA states: {len(nfa_concat.Q)}")
# Star
nfa_star = kleene_star(nfa_ab)
print(f"Star NFA states: {len(nfa_star.Q)}")
# Test
print("\n=== Testing Union (ends in 'ab' OR even length) ===")
for s in ['ab', 'aabb', 'abab', 'a', 'b', 'aa', 'bb', 'aba', 'ba']:
r1 = nfa_ab.accepts(s)
r2 = nfa_even.accepts(s)
r_union = nfa_union.accepts(s)
expected = r1 or r2
match = "ā" if r_union == expected else "ā"
print(f" '{s:6s}' ab={r1}, even={r2}, union={r_union} {match}")
print("\n=== Testing Concatenation (ends in 'ab' Ā· even length) ===")
for s in ['ab', 'abab', 'abaa', 'abbb', 'aabb', 'abba']:
r_concat = nfa_concat.accepts(s)
print(f" '{s:6s}' ā {r_concat}")
print("\n=== Testing Star ((ends in 'ab')*) ===")
for s in ['', 'ab', 'abab', 'ababab', 'a', 'ababa', 'ba']:
r_star = nfa_star.accepts(s)
print(f" '{s:6s}' ā {r_star}")
# 6. CLOSURE PROPERTIES
print("\n=== Closure Properties of Regular Languages ===")
print("""
Regular languages are closed under:
ā UNION: L1 āŖ L2
Construction: New start with ε to both
ā CONCATENATION: L1 Ā· L2
Construction: ε from L1 accepts to L2 start
ā KLEENE STAR: L*
Construction: ε-loops
ā COMPLEMENT: Ī£* \ L
Construction: Swap F with Q\F (need DFA)
ā INTERSECTION: L1 ā© L2
Construction: Product (need DFA)
ā DIFFERENCE: L1 \ L2
Construction: L1 ā© complement(L2)
ā REVERSAL: L^R
Construction: Reverse all transitions
ā HOMOMORPHISM: h(L)
Construction: Replace symbols
All these preserve regularity!
NFA makes union, concat, star trivial.
""")
# 7. REVERSAL
def reverse(nfa):
"""
L(M^R) = {w^R | w ā L(M)}
1. Reverse all transitions
2. New start state with ε to all old accepts
3. Old start becomes accept
"""
new_start = 'q_rev_start'
# Reverse transitions
new_delta = {}
for (q, s), next_set in nfa.delta.items():
for next_state in next_set:
if (next_state, s) not in new_delta:
new_delta[(next_state, s)] = set()
new_delta[(next_state, s)].add(q)
# New start ε to old accepts
new_delta[(new_start, 'ε')] = set(nfa.F)
# Old start becomes accept
new_F = {nfa.q0}
return NFA(nfa.Q | {new_start}, nfa.Sigma, new_delta, new_start, new_F)
# Test reversal
nfa_rev = reverse(nfa_ab)
print("\n=== Testing Reversal (reverse of 'ends in ab') ===")
print("Original: ends in 'ab' ā 'ab', 'aab', 'bab'")
print("Reversed: starts with 'ba' ā 'ba', 'baa', 'bab'")
for s in ['ba', 'baa', 'bab', 'ab', 'b', 'a', 'bba']:
r_orig = nfa_ab.accepts(s)
r_rev = nfa_rev.accepts(s)
print(f" '{s:6s}' original={r_orig}, reversed={r_rev}")š° Equivalence & Testing
šÆ Are Two NFAs the Same?
Two NFAs are equivalent if they accept the same language. This is decidable ā convert both to DFAs and test DFA equivalence.
Equivalence Testing for NFAs
Approach: NFA1 ā DFA1, NFA2 ā DFA2. Then: Test DFA equivalence. Alternative: Product construction on NFAs. Complexity: O(2^|Q1| Ć 2^|Q2|). Decidable: Yes, always. Counterexample: Shortest distinguishing string.
# NFA equivalence testing
from collections import deque
from itertools import product as cartesian_product
class NFA:
def __init__(self, states, alphabet, delta, q0, F):
self.Q = states
self.Sigma = alphabet
self.delta = delta
self.q0 = q0
self.F = F
def epsilon_closure(self, states):
closure = set(states)
stack = list(states)
while stack:
state = stack.pop()
for next_state in self.delta.get((state, 'ε'), set()):
if next_state not in closure:
closure.add(next_state)
stack.append(next_state)
return closure
def accepts(self, word):
current = self.epsilon_closure({self.q0})
for symbol in word:
next_states = set()
for state in current:
next_states |= self.delta.get((state, symbol), set())
current = self.epsilon_closure(next_states)
return bool(current & self.F)
# 1. NFA ā DFA (subset construction)
def nfa_to_dfa(nfa):
"""Convert NFA to DFA using subset construction."""
start = frozenset(nfa.epsilon_closure({nfa.q0}))
dfa_states = {start}
queue = deque([start])
dfa_delta = {}
while queue:
current = queue.popleft()
for symbol in nfa.Sigma:
next_set = set()
for state in current:
next_set |= nfa.delta.get((state, symbol), set())
next_set = frozenset(nfa.epsilon_closure(next_set))
dfa_delta[(current, symbol)] = next_set
if next_set not in dfa_states:
dfa_states.add(next_set)
queue.append(next_set)
dfa_F = {s for s in dfa_states if s & nfa.F}
class DFA:
def __init__(self, Q, Sigma, delta, q0, F):
self.Q = Q
self.Sigma = Sigma
self.delta = delta
self.q0 = q0
self.F = F
def accepts(self, word):
state = self.q0
for symbol in word:
state = self.delta[(state, symbol)]
return state in self.F
return DFA(dfa_states, nfa.Sigma, dfa_delta, start, dfa_F)
# 2. DFA EQUIVALENCE (product construction)
def check_equivalence(dfa1, dfa2):
"""
Check if two DFAs are equivalent.
Returns (True, None) or (False, counterexample).
"""
start = (dfa1.q0, dfa2.q0)
queue = deque([(start, "")])
visited = {start}
while queue:
(q1, q2), word = queue.popleft()
if (q1 in dfa1.F) != (q2 in dfa2.F):
return False, word
for symbol in dfa1.Sigma:
next_q1 = dfa1.delta[(q1, symbol)]
next_q2 = dfa2.delta[(q2, symbol)]
next_state = (next_q1, next_q2)
if next_state not in visited:
visited.add(next_state)
queue.append((next_state, word + symbol))
return True, None
# 3. NFA EQUIVALENCE
def nfa_equivalent(nfa1, nfa2):
"""Check if two NFAs are equivalent."""
dfa1 = nfa_to_dfa(nfa1)
dfa2 = nfa_to_dfa(nfa2)
return check_equivalence(dfa1, dfa2)
# EXAMPLE
print("=== NFA Equivalence Testing ===")
# NFA 1: ends in "01"
nfa1 = NFA(
states={'q0', 'q1', 'q2'},
alphabet={'0', '1'},
delta={
('q0', '0'): {'q0', 'q1'},
('q0', '1'): {'q0'},
('q1', '1'): {'q2'},
},
q0='q0',
F={'q2'}
)
# NFA 2: equivalent, different design
nfa2 = NFA(
states={'r0', 'r1', 'r2'},
alphabet={'0', '1'},
delta={
('r0', '0'): {'r1'},
('r0', '1'): {'r2'},
('r1', '0'): {'r1'},
('r1', '1'): {'r3'},
('r2', '0'): {'r1'},
('r2', '1'): {'r2'},
('r3', '0'): {'r1'},
('r3', '1'): {'r2'},
},
q0='r0',
F={'r3'}
)
# NFA 3: different language
nfa3 = NFA(
states={'s0', 's1', 's2'},
alphabet={'0', '1'},
delta={
('s0', '0'): {'s0'},
('s0', '1'): {'s0', 's1'},
('s1', '0'): {'s2'},
},
q0='s0',
F={'s2'}
)
# Test pairs
print("\n1. NFA1 vs NFA2 (should be equivalent):")
equiv, counter = nfa_equivalent(nfa1, nfa2)
if equiv:
print(" ā Equivalent")
else:
print(f" ā Differ on '{counter}'")
print("\n2. NFA1 vs NFA3 (should differ):")
equiv, counter = nfa_equivalent(nfa1, nfa3)
if equiv:
print(" ā Equivalent")
else:
print(f" ā Differ on '{counter}'")
print(f" NFA1 accepts: {nfa1.accepts(counter)}")
print(f" NFA3 accepts: {nfa3.accepts(counter)}")
# 4. DECISION PROBLEMS
print("\n=== Decidable Problems for NFAs ===")
print("""
All decidable (via conversion to DFA):
1. MEMBERSHIP
"Is w ā L(N)?"
ā Simulate NFA, O(|w| Ć |Q|²)
2. EMPTINESS
"Is L(N) = ā
?"
ā Check if any accept reachable
ā BFS from start
3. UNIVERSALITY
"Is L(N) = Σ*?"
ā Convert to DFA, check complement empty
4. EQUIVALENCE
"Is L(N1) = L(N2)?"
ā Convert both to DFA, test equivalence
5. INCLUSION
"Is L(N1) ā L(N2)?"
ā Convert to DFA, test L1 \ L2 = ā
6. DISJOINTNESS
"Is L(N1) ā© L(N2) = ā
?"
ā Convert to DFA, product construction
7. FINITENESS
"Is L(N) finite?"
ā Convert to DFA, check cycle
All efficient after DFA conversion.
""")
# 5. EMPTINESS CHECK
def is_empty(nfa):
"""Check if NFA accepts empty language."""
reachable = nfa.epsilon_closure({nfa.q0})
queue = deque(reachable)
visited = set(reachable)
while queue:
state = queue.popleft()
if state in nfa.F:
return False
for symbol in nfa.Sigma:
for next_state in nfa.delta.get((state, symbol), set()):
if next_state not in visited:
visited.add(next_state)
queue.append(next_state)
return True
print(f"\nnfa1 is empty: {is_empty(nfa1)}")
print(f"nfa2 is empty: {is_empty(nfa2)}")
print(f"nfa3 is empty: {is_empty(nfa3)}")
# 6. SUMMARY
print("\n=== Summary: NFA Decision Problems ===")
print(f"{'Problem':<20} {'Method':<30} {'Complexity':<20}")
print("-" * 70)
print(f"{'Membership':<20} {'Simulate NFA':<30} {'O(n à |Q|²)':<20}")
print(f"{'Emptiness':<20} {'Reachability':<30} {'O(|Q| Ć |Ī£|)':<20}")
print(f"{'Equivalence':<20} {'Subset + DFA product':<30} {'O(2^|Q1| Ć 2^|Q2|)':<20}")
print(f"{'Inclusion':<20} {'Subset + DFA product':<30} {'Exponential':<20}")
print(f"{'Finiteness':<20} {'Cycle detection':<30} {'O(|Q|)':<20}")
# 7. PRACTICAL NOTE
print("\n=== Practical Note ===")
print("""
In practice:
- Direct NFA simulation is often faster than subset construction
(especially if NFA is small)
- For repeated queries, convert to DFA once
- For equivalence, always go through DFA
Tools:
- Python: automata-lib
- Java: dk.brics.automaton
- C++: RE2, OpenFST
- Rust: rust-automata
Remember:
- NFA simulation tracks sets of states
- Subset construction may blow up
- But resulting DFA is fast to simulate
""")š NFA Minimization
šÆ Finding the Smallest NFA
Unlike DFAs, minimal NFAs are not unique. Finding the smallest NFA for a language is computationally hard (PSPACE-complete). However, we can still reduce NFA size using various techniques.
NFA Reduction Techniques
1. Remove unreachable: States not reachable from qā. 2. Remove dead: States that can't reach accept. 3. ε-elimination: Remove ε-transitions. 4. Merge equivalent: States with same behavior. 5. Convert to DFA: Then minimize DFA. Not unique: Different minimal NFAs possible.
# NFA minimization and reduction
from collections import deque, defaultdict
class NFA:
def __init__(self, states, alphabet, delta, q0, F):
self.Q = states
self.Sigma = alphabet
self.delta = delta
self.q0 = q0
self.F = F
def epsilon_closure(self, states):
closure = set(states)
stack = list(states)
while stack:
state = stack.pop()
for next_state in self.delta.get((state, 'ε'), set()):
if next_state not in closure:
closure.add(next_state)
stack.append(next_state)
return closure
def accepts(self, word):
current = self.epsilon_closure({self.q0})
for symbol in word:
next_states = set()
for state in current:
next_states |= self.delta.get((state, symbol), set())
current = self.epsilon_closure(next_states)
return bool(current & self.F)
# 1. REMOVE UNREACHABLE STATES
def remove_unreachable(nfa):
"""Remove states not reachable from q0."""
reachable = nfa.epsilon_closure({nfa.q0})
queue = deque(reachable)
while queue:
state = queue.popleft()
for symbol in nfa.Sigma:
for next_state in nfa.delta.get((state, symbol), set()):
if next_state not in reachable:
reachable.add(next_state)
queue.append(next_state)
# Also follow ε-transitions
for next_state in nfa.delta.get((state, 'ε'), set()):
if next_state not in reachable:
reachable.add(next_state)
queue.append(next_state)
new_delta = {
(q, s): {d for d in next_set if d in reachable}
for (q, s), next_set in nfa.delta.items()
if q in reachable }
return NFA(
states=reachable,
alphabet=nfa.Sigma,
delta=new_delta,
q0=nfa.q0,
F=nfa.F & reachable
)
# 2. REMOVE DEAD STATES
def remove_dead(nfa):
"""Remove states from which no accept state is reachable."""
# Reverse transitions (including ε)
reverse = defaultdict(set)
for (q, s), next_set in nfa.delta.items():
for d in next_set:
reverse[d].add(q)
# BFS from accept states backwards
live = set(nfa.F)
queue = deque(nfa.F)
while queue:
state = queue.popleft()
for prev in reverse[state]:
if prev not in live:
live.add(prev)
queue.append(prev)
live.add(nfa.q0) # Keep start state
new_delta = {
(q, s): {d for d in next_set if d in live}
for (q, s), next_set in nfa.delta.items()
if q in live
}
return NFA(
states=live,
alphabet=nfa.Sigma,
delta=new_delta,
q0=nfa.q0,
F=nfa.F & live
)
# 3. ELIMINATE ε-TRANSITIONS
def eliminate_epsilon(nfa):
"""Remove ε-transitions from NFA."""
# Compute ε-closures
closures = {state: nfa.epsilon_closure({state}) for state in nfa.Q}
new_delta = {}
for q in nfa.Q:
for a in nfa.Sigma:
next_states = set()
for p in closures[q]:
next_states |= nfa.delta.get((p, a), set())
next_states = nfa.epsilon_closure(next_states)
if next_states:
new_delta[(q, a)] = next_states
new_F = {q for q in nfa.Q if closures[q] & nfa.F}
return NFA(nfa.Q, nfa.Sigma, new_delta, nfa.q0, new_F)
# 4. MERGE EQUIVALENT STATES (simple heuristic)
def merge_equivalent(nfa):
"""
Merge states with identical transition behavior.
This is a heuristic ā not guaranteed to find minimal NFA.
"""
# Compute signature for each state
signatures = {}
for q in nfa.Q:
sig = []
for a in sorted(nfa.Sigma):
sig.append(tuple(sorted(nfa.delta.get((q, a), set()))))
sig.append(q in nfa.F)
signatures[q] = tuple(sig)
# Group states with same signature
groups = defaultdict(set)
for q, sig in signatures.items():
groups[sig].add(q)
# Create mapping from old states to group representatives
mapping = {}
for sig, group in groups.items():
rep = min(group) # choose representative
for q in group:
mapping[q] = rep
# Build new NFA
new_Q = set(mapping.values())
new_delta = {}
for (q, a), next_set in nfa.delta.items():
new_q = mapping[q]
new_next = {mapping[d] for d in next_set}
if (new_q, a) in new_delta:
new_delta[(new_q, a)] |= new_next
else:
new_delta[(new_q, a)] = new_next
new_F = {mapping[q] for q in nfa.F}
return NFA(new_Q, nfa.Sigma, new_delta, mapping[nfa.q0], new_F)
# 5. FULL REDUCTION PIPELINE
def reduce_nfa(nfa):
"""Apply all reduction techniques."""
nfa = remove_unreachable(nfa)
nfa = remove_dead(nfa)
nfa = eliminate_epsilon(nfa)
nfa = merge_equivalent(nfa)
nfa = remove_unreachable(nfa) # again after merge
return nfa
# EXAMPLE
print("=== NFA Reduction Example ===")
# Create a non-minimal NFA
nfa = NFA(
states={'q0', 'q1', 'q2', 'q3', 'q4', 'q5'},
alphabet={'a', 'b'},
delta={
('q0', 'a'): {'q1', 'q2'},
('q0', 'b'): {'q3'},
('q1', 'a'): {'q1'},
('q1', 'b'): {'q4'},
('q2', 'a'): {'q2'},
('q2', 'b'): {'q4'},
('q3', 'a'): {'q3'},
('q3', 'b'): {'q3'},
('q4', 'a'): {'q4'},
('q4', 'b'): {'q4'},
('q5', 'a'): {'q5'}, # unreachable
('q5', 'b'): {'q5'},
},
q0='q0',
F={'q4', 'q5'}
)
print(f"Original NFA: {len(nfa.Q)} states")
# Apply reductions
nfa_unreach = remove_unreachable(nfa)
print(f"After removing unreachable: {len(nfa_unreach.Q)} states")
nfa_dead = remove_dead(nfa_unreach)
print(f"After removing dead: {len(nfa_dead.Q)} states")
nfa_no_eps = eliminate_epsilon(nfa_dead)
print(f"After eliminating ε: {len(nfa_no_eps.Q)} states")
nfa_merged = merge_equivalent(nfa_no_eps)
print(f"After merging equivalent: {len(nfa_merged.Q)} states")
nfa_final = reduce_nfa(nfa)
print(f"After full reduction: {len(nfa_final.Q)} states")
# Test equivalence
print("\nEquivalence test after reduction:")
for s in ['', 'a', 'b', 'ab', 'ba', 'aab', 'abb', 'abab', 'bb']:
r_orig = nfa.accepts(s)
r_reduced = nfa_final.accepts(s)
match = "ā" if r_orig == r_reduced else "ā"
print(f" '{s:6s}' orig={r_orig}, reduced={r_reduced} {match}")
# 6. WHY NFA MINIMIZATION IS HARD
print("\n=== Why NFA Minimization is Hard ===")
print("""
Complexity:
- Finding minimal NFA is PSPACE-complete
- Finding minimal DFA is polynomial (Hopcroft)
- This is a fundamental difference
Intuition:
- DFA minimal is unique (Myhill-Nerode)
- NFA minimal is NOT unique
- Choosing where to use nondeterminism is combinatorial
Practical approach:
1. Reduce using heuristics
2. Convert to DFA
3. Minimize DFA (optimal for DFA)
4. Optionally try to reduce further
In practice:
- NFA minimization is rarely needed
- NFA is usually a design tool
- Convert to DFA for implementation
- Minimize the DFA
""")
# 7. NFA VS DFA MINIMIZATION
print("\n=== NFA vs DFA Minimization ===")
print(f"{'Aspect':<30} {'DFA':<25} {'NFA':<25}")
print("-" * 80)
print(f"{'Minimal unique?':<30} {'Yes (Myhill-Nerode)':<25} {'No':<25}")
print(f"{'Complexity':<30} {'Polynomial':<25} {'PSPACE-complete':<25}")
print(f"{'Algorithm':<30} {'Hopcroft':<25} {'Heuristics':<25}")
print(f"{'Practical':<30} {'Always optimal':<25} {'Rarely needed':<25}")
print(f"{'Approach':<30} {'Partition refinement':<25} {'Reduce + convert':<25}")
# 8. REDUCTION TECHNIQUES SUMMARY
print("\n=== NFA Reduction Techniques ===")
print("""
1. UNREACHABLE REMOVAL
- BFS from start
- Remove states not visited
- O(|Q| Ć |Ī£|)
2. DEAD STATE REMOVAL
- Backward BFS from accepts
- Remove states that can't reach accept
- O(|Q| Ć |Ī£|)
3. ε-TRANSITION ELIMINATION
- Compute ε-closures
- Build new transitions
- O(|Q|² à |Σ|)
4. EQUIVALENT STATE MERGING
- Compare transition signatures
- Merge identical states
- O(|Q|² Ć |Ī£|) ā heuristic
5. DIRECT DFA CONSTRUCTION
- Skip NFA reduction
- Subset construction
- Minimize resulting DFA
- Often best approach
""")š¤ Regular Expressions & Thompson
šÆ Regex to NFA: Thompson's Construction
Thompson's construction converts any regular expression into an equivalent ε-NFA. This is the foundation of modern regex engines.
Thompson's Construction
Literal: ε ā q0 --a--> q1 ā ε. Union: New start/accept with ε. Concat: ε from R1 accept to R2 start. Star: ε-loops. Result: ε-NFA with one start, one accept. Then: Subset construction ā DFA.
# Thompson's Construction: Regex ā ε-NFA
from dataclasses import dataclass
from typing import Set, Dict, Tuple, FrozenSet, Optional
import re
# 1. AST FOR REGEX
@dataclass
class RegexNode:
"""Abstract syntax tree node for regex."""
type: str # 'literal', 'concat', 'union', 'star', 'plus', 'optional', 'epsilon'
value: Optional[str] = None
left: Optional['RegexNode'] = None
right: Optional['RegexNode'] = None
def parse_regex(pattern: str) -> RegexNode:
"""Simple regex parser (supports |, *, +, ?, concat, parentheses, literals)."""
# This is a simplified parser ā production regex is more complex
pattern = pattern.replace(' ', '')
def parse_union(s):
parts = split_top_level(s, '|')
if len(parts) == 1:
return parse_concat(parts[0])
node = RegexNode('union')
current = node
for i, part in enumerate(parts):
if i == 0:
node.left = parse_concat(part)
else:
new_node = RegexNode('union')
new_node.left = current.right
new_node.right = parse_concat(part)
current.right = new_node
current = new_node
return node
def parse_concat(s):
parts = split_concat(s)
if len(parts) == 1:
return parse_postfix(parts[0])
node = RegexNode('concat')
node.left = parse_postfix(parts[0])
if len(parts) > 1:
node.right = parse_concat(''.join(parts[1:]))
return node
def parse_postfix(s):
if len(s) == 1:
return RegexNode('literal', value=s)
if s.endswith('*'):
return RegexNode('star', left=parse_postfix(s[:-1]))
if s.endswith('+'):
return RegexNode('plus', left=parse_postfix(s[:-1]))
if s.endswith('?'):
return RegexNode('optional', left=parse_postfix(s[:-1]))
# Handle parentheses
if s.startswith('(') and s.endswith(')'):
return parse_union(s[1:-1])
# Handle literals and escapes
if len(s) > 1:
return RegexNode('concat',
left=RegexNode('literal', value=s[0]),
right=parse_postfix(s[1:]))
return RegexNode('literal', value=s)
def split_top_level(s, op):
"""Split by op at top level (not inside parentheses)."""
parts = []
depth = 0
current = ''
for c in s:
if c == '(':
depth += 1
current += c
elif c == ')':
depth -= 1
current += c
elif c == op and depth == 0:
parts.append(current)
current = ''
else:
current += c
parts.append(current)
return parts
def split_concat(s):
"""Split concatenation into parts."""
parts = []
current = ''
depth = 0
for c in s:
if c == '(':
depth += 1
current += c
elif c == ')':
depth -= 1
current += c
elif c in '*+?' and depth == 0:
current += c
else:
if depth == 0 and current and current[-1] not in '*+?':
parts.append(current)
current = c
else:
current += c
if current:
parts.append(current)
return parts
return parse_union(pattern)
# 2. THOMPSON'S CONSTRUCTION
class EpsilonNFA:
"""ε-NFA for Thompson construction."""
_counter = 0
def __init__(self, start, accept, transitions):
self.start = start
self.accept = accept
self.transitions = transitions # (state, symbol) -> set of states
@classmethod
def new_state(cls):
cls._counter += 1
return f"q{cls._counter}"
@classmethod
def from_literal(cls, char):
"""Build NFA for a single literal."""
s0 = cls.new_state()
s1 = cls.new_state()
return cls(s0, s1, {(s0, char): {s1}})
@classmethod
def from_epsilon(cls):
"""Build NFA for ε."""
s0 = cls.new_state()
s1 = cls.new_state()
return cls(s0, s1, {(s0, 'ε'): {s1}})
@classmethod
def union(cls, nfa1, nfa2):
"""Build NFA for R1 | R2."""
s0 = cls.new_state()
s1 = cls.new_state()
transitions = {}
# Copy transitions
for (q, a), next_set in {**nfa1.transitions, **nfa2.transitions}.items():
transitions[(q, a)] = set(next_set)
# New start ε to both starts
transitions[(s0, 'ε')] = {nfa1.start, nfa2.start}
# Both accepts ε to new accept
transitions[(nfa1.accept, 'ε')] = transitions.get((nfa1.accept, 'ε'), set()) | {s1}
transitions[(nfa2.accept, 'ε')] = transitions.get((nfa2.accept, 'ε'), set()) | {s1}
return cls(s0, s1, transitions)
@classmethod
def concat(cls, nfa1, nfa2):
"""Build NFA for R1 R2."""
transitions = {}
for (q, a), next_set in {**nfa1.transitions, **nfa2.transitions}.items():
transitions[(q, a)] = set(next_set)
# nfa1 accept ε to nfa2 start
transitions[(nfa1.accept, 'ε')] = transitions.get((nfa1.accept, 'ε'), set()) | {nfa2.start}
return cls(nfa1.start, nfa2.accept, transitions)
@classmethod
def star(cls, nfa):
"""Build NFA for R*."""
s0 = cls.new_state()
s1 = cls.new_state()
transitions = {}
for (q, a), next_set in nfa.transitions.items():
transitions[(q, a)] = set(next_set)
# New start ε to old start and new accept
transitions[(s0, 'ε')] = {nfa.start, s1}
# Old accept ε back to old start and to new accept
transitions[(nfa.accept, 'ε')] = transitions.get((nfa.accept, 'ε'), set()) | {nfa.start, s1}
return cls(s0, s1, transitions)
@classmethod
def plus(cls, nfa):
"""Build NFA for R+ = R R*."""
return cls.concat(nfa, cls.star(nfa))
@classmethod
def optional(cls, nfa):
"""Build NFA for R? = R | ε."""
return cls.union(nfa, cls.from_epsilon())
def to_nfa_dict(self):
"""Convert to standard NFA format."""
states = set()
for (q, a), next_set in self.transitions.items():
states.add(q)
states.update(next_set)
states.add(self.start)
states.add(self.accept)
return {
'states': states,
'alphabet': {a for (q, a) in self.transitions if a != 'ε'},
'delta': self.transitions,
'start': self.start,
'accept': {self.accept}
}
def thompson_construction(regex_node: RegexNode) -> EpsilonNFA:
"""Build ε-NFA from regex AST using Thompson's construction."""
if regex_node.type == 'literal':
return EpsilonNFA.from_literal(regex_node.value)
elif regex_node.type == 'epsilon':
return EpsilonNFA.from_epsilon()
elif regex_node.type == 'concat':
left = thompson_construction(regex_node.left)
right = thompson_construction(regex_node.right)
return EpsilonNFA.concat(left, right)
elif regex_node.type == 'union':
left = thompson_construction(regex_node.left)
right = thompson_construction(regex_node.right)
return EpsilonNFA.union(left, right)
elif regex_node.type == 'star':
inner = thompson_construction(regex_node.left)
return EpsilonNFA.star(inner)
elif regex_node.type == 'plus':
inner = thompson_construction(regex_node.left)
return EpsilonNFA.plus(inner)
elif regex_node.type == 'optional':
inner = thompson_construction(regex_node.left)
return EpsilonNFA.optional(inner)
else:
raise ValueError(f"Unknown node type: {regex_node.type}")
# 3. DEMONSTRATION
print("=== Thompson's Construction Demo ===")
test_regexes = [
('a', 'literal a'),
('ab', 'concatenation'),
('a|b', 'union'),
('a*', 'Kleene star'),
('a+', 'plus'),
('a?', 'optional'),
('(a|b)*abb', 'complex regex'),
]
for pattern, desc in test_regexes:
try:
ast = parse_regex(pattern)
enfa = thompson_construction(ast)
nfa_dict = enfa.to_nfa_dict()
print(f"\nRegex: {pattern} ({desc})")
print(f" States: {len(nfa_dict['states'])}")
print(f" Start: {nfa_dict['start']}")
print(f" Accept: {nfa_dict['accept']}")
print(f" Transitions: {len(nfa_dict['delta'])}")
except Exception as e:
print(f"\nRegex: {pattern} ({desc})")
print(f" Error: {e}")
# 4. TEST MATCHING
print("\n=== Testing Regex Matching ===")
def test_regex_match(pattern, strings):
"""Test if regex matches strings."""
try:
ast = parse_regex(pattern)
enfa = thompson_construction(ast)
nfa_dict = enfa.to_nfa_dict()
# Create NFA object for simulation
class NFA:
def __init__(self, d):
self.Q = d['states']
self.Sigma = d['alphabet']
self.delta = d['delta']
self.q0 = d['start']
self.F = d['accept']
def epsilon_closure(self, states):
closure = set(states)
stack = list(states)
while stack:
state = stack.pop()
for ns in self.delta.get((state, 'ε'), set()):
if ns not in closure:
closure.add(ns)
stack.append(ns)
return closure
def accepts(self, word):
current = self.epsilon_closure({self.q0})
for symbol in word:
next_states = set()
for state in current:
next_states |= self.delta.get((state, symbol), set())
current = self.epsilon_closure(next_states)
return bool(current & self.F)
nfa = NFA(nfa_dict)
print(f"\nPattern: {pattern}")
for s in strings:
result = "ā" if nfa.accepts(s) else "ā"
print(f" '{s}' ā {result}")
except Exception as e:
print(f"\nPattern: {pattern}")
print(f" Error: {e}")
test_regex_match('(a|b)*abb', ['abb', 'aabb', 'babb', 'abab', 'ab', 'b', ''])
test_regex_match('a*b*', ['', 'a', 'b', 'ab', 'aabb', 'aa', 'bb', 'ba'])
# 5. PRACTICAL REGEX ENGINE PIPELINE
print("\n=== Practical Regex Engine Pipeline ===")
print("""
1. PARSE
Input: regex string like "(a|b)*abb"
Output: AST (Abstract Syntax Tree)
2. THOMPSON CONSTRUCTION
Input: AST
Output: ε-NFA
- Each regex operator has a construction rule
- Result: NFA with single start and accept
3. SUBSET CONSTRUCTION
Input: ε-NFA
Output: DFA
- States are subsets of NFA states
- May be exponential in worst case
4. DFA MINIMIZATION
Input: DFA
Output: Minimal DFA
- Hopcroft's algorithm
- Unique minimal DFA
5. SIMULATION
Input: Minimal DFA + text
Output: Match/no match
- O(n) time
- O(1) space
This is how RE2, Rust regex, and others work.
""")
# 6. COMPARISON WITH BACKTRACKING
print("\n=== Thompson NFA vs Backtracking ===")
print(f"{'Aspect':<25} {'Thompson NFA':<25} {'Backtracking':<25}")
print("-" * 75)
print(f"{'Time (worst)':<25} {'O(n Ć m)':<25} {'O(2^n)':<25}")
print(f"{'Space':<25} {'O(2^m)':<25} {'O(m Ć n)':<25}")
print(f"{'Features':<25} {'Regular only':<25} {'Backrefs, lookahead':<25}")
print(f"{'Implementation':<25} {'Complex':<25} {'Simple':<25}")
print(f"{'Used in':<25} {'RE2, Rust':<25} {'Python, Java, JS':<25}")
print("""
Key insight:
- Thompson NFA guarantees linear time
- But doesn't support backreferences
- Backtracking supports more features
- But can be exponential
For untrusted input: use Thompson NFA
For feature-rich regex: use backtracking (carefully)
""")
# 7. LIMITATIONS
print("\n=== Regex Limitations (Regular Languages) ===")
print("""
Regex (and thus NFA/DFA) CANNOT express:
ā Balanced parentheses: a^n b^n
ā Palindromes: w = w^R
ā Prime length strings
ā Nested structures (JSON, HTML)
ā Backreferences: (a+)b\\1
Why?
These require unbounded memory.
Finite automata have finite states.
For these, need:
- Context-free grammars (CFG)
- Pushdown automata (PDA)
- Turing machines (TM)
Note: Many "regex" implementations add features
(backrefs, lookahead) that go beyond
regular languages.
""")š¼ Applications
šÆ NFAs in the Real World
NFAs are the design tool of choice for pattern matching. They're used in compilers, text editors, network security, and anywhere regex is needed.
Application Domains
Compilers: Lexical analysis. Regex engines: Thompson construction. Text editors: Search and replace. Network: Intrusion detection. Bioinformatics: DNA matching. Verification: Model checking.
# Applications of NFAs
import re
from collections import deque
# 1. LEXICAL ANALYSIS (Compiler Frontend)
print("=== Lexical Analysis ===")
print("""
Compilers use NFAs to build lexers:
Pipeline:
Regex patterns ā NFA (Thompson) ā DFA (subset) ā Scanner
Each token type is a regex:
Keywords: if|else|while|for|return
Identifiers: [a-zA-Z_][a-zA-Z0-9_]*
Numbers: [0-9]+(\\.[0-9]+)?
Operators: \\+|-|\\*|/|==|!=|<=|>=
Delimiters: \\(|\\)|\\{|\\}|;|,
Combined NFA: union of all token NFAs
Then: subset construction ā single DFA
Scanner: runs DFA on input, emits tokens
Tools:
- Lex (original)
- Flex (fast lex)
- ANTLR lexers
- Hand-written (recursive descent)
""")
# Simple NFA-based lexer
class NFALexer:
"""Lexer using NFA simulation (via regex)."""
def __init__(self):
self.token_patterns = [
('NUMBER', re.compile(r'\d+(\.\d+)?')),
('IDENT', re.compile(r'[a-zA-Z_][a-zA-Z0-9_]*')),
('OP', re.compile(r'[+\-*/=<>!]+')),
('DELIM', re.compile(r'[(){};,\.]')),
]
def tokenize(self, text):
tokens = []
pos = 0
while pos < len(text):
if text[pos].isspace():
pos += 1
continue
matched = False
for token_type, pattern in self.token_patterns:
match = pattern.match(text, pos)
if match:
tokens.append((token_type, match.group()))
pos = match.end()
matched = True
break
if not matched:
tokens.append(('UNKNOWN', text[pos]))
pos += 1
return tokens
lexer = NFALexer()
source = "x = 10 + y * 3.14"
print(f"Source: {source}")
print(f"Tokens: {lexer.tokenize(source)}")
# 2. REGEX ENGINES
print("\n=== Regex Engines ===")
print("""
Modern regex engines use Thompson NFA:
Pipeline:
1. Parse regex ā AST
2. Thompson construction ā ε-NFA
3. Subset construction ā DFA (on-the-fly)
4. Simulate DFA
Advantages:
- Linear time: O(n Ć m)
- No catastrophic backtracking
- Predictable performance
Implementations:
- RE2 (Google) ā C++
- Rust regex ā Rust
- Hyperscan (Intel) ā SIMD accelerated
- Go regexp ā Go (uses Thompson NFA)
Comparison:
- Thompson NFA: O(n Ć m), no backrefs
- Backtracking: O(2^n) worst, full features
- Use Thompson for untrusted input
""")
# NFA-based regex engine (simplified)
def nfa_regex_match(pattern, text):
"""Simple NFA-based regex matcher."""
try:
compiled = re.compile(pattern)
return bool(compiled.fullmatch(text))
except:
return False
test_cases = [
(r'(a|b)*abb', 'aabb', True),
(r'(a|b)*abb', 'abab', False),
(r'a*b*', 'aaabbb', True),
(r'a*b*', 'ba', False),
]
print("NFA-based regex matching:")
for pattern, text, expected in test_cases:
result = nfa_regex_match(pattern, text)
match = "ā" if result == expected else "ā"
print(f" '{pattern}' on '{text}': {result} {match}")
# 3. NETWORK INTRUSION DETECTION
print("\n=== Network Intrusion Detection ===")
print("""
NFAs for deep packet inspection:
Approach:
- Compile attack signatures to NFA
- Run NFA on network packets
- Alert on match
Signatures:
- SQL injection patterns
- XSS patterns
- Malware signatures
- Protocol violations
Tools:
- Snort (NFA/DFA based)
- Suricata
- Bro/Zeek
Performance:
- 10+ Gbps throughput
- Multiple signatures in parallel
- Hardware acceleration (FPGA, GPU)
Example: Hyperscan (Intel)
- 100+ GB/s on modern CPUs
- SIMD optimized
- Multiple patterns simultaneously
""")
# 4. TEXT SEARCH AND REPLACE
print("\n=== Text Search and Replace ===")
print("""
grep, sed, and text editors use NFA-based matching:
grep:
- Pattern ā NFA ā DFA
- Linear time matching
- Efficient for large files
sed:
- Pattern ā NFA
- Substitution
- Streaming processing
Editors:
- Find/replace with regex
- Syntax highlighting (NFA for tokens)
- Auto-completion
Example: Syntax highlighting
- Keywords: NFA for keyword set
- Strings: NFA for quoted strings
- Comments: NFA for comment syntax
- Numbers: NFA for number formats
""")
# 5. BIOINFORMATICS
print("\n=== Bioinformatics ===")
print("""
NFAs for DNA/RNA sequence analysis:
Patterns:
- Motifs: short conserved sequences
- Restriction sites: EcoRI = GAATTC
- Promoters: TATA box
- Splice sites
Approach:
- Compile patterns to NFA
- Search sequences
- Handle mutations (IUPAC codes)
Example: IUPAC ambiguity codes
A = A
R = A or G (purine)
Y = C or T (pyrimidine)
N = any base
Tools:
- Aho-Corasick (multiple patterns)
- BLAST (heuristic)
- Pattern matching libraries
Performance:
- Human genome: 3 billion base pairs
- Need linear time matching
- NFAs/DFAs ideal
""")
# 6. COMPILER OPTIMIZATION
print("\n=== Compiler Optimization ===")
print("""
NFA/DFA for peephole optimization:
Patterns in assembly:
mov R1, 0 ā xor R1, R1 (faster)
add R1, 0 ā (remove)
mul R1, 1 ā (remove)
jmp next ā (remove)
Approach:
- Compile patterns to NFA
- Run on instruction stream
- Replace matched patterns
Also:
- Register allocation
- Dead code elimination
- Loop optimization
""")
# 7. WEB SECURITY
print("\n=== Web Security ===")
print("""
Input validation using NFAs:
Email validation:
Regex: ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$
NFA: linear time validation
SQL injection prevention:
NFA for allowed SQL patterns
Block suspicious input
XSS prevention:
NFA for safe HTML/JS
Sanitize input
URL parsing:
NFA for scheme, host, path, query, fragment
Performance:
- O(n) validation
- Constant memory
- No ReDoS vulnerability
""")
# 8. CASE STUDY: SNORT IDS
print("\n=== Case Study: Snort IDS ===")
print("""
Snort ā Network Intrusion Detection System
Architecture:
1. Rules: patterns for attacks
2. Compile rules ā NFA/DFA
3. Run on network packets
4. Alert on match
Rule example:
alert tcp any any -> any 80 (
content: "GET";
content: "/etc/passwd";
msg: "Possible path traversal";
)
Implementation:
- Multi-pattern matching
- Aho-Corasick + NFA
- Hardware acceleration
Performance:
- 10+ Gbps
- Millions of rules
- Low latency
""")
# 9. SUMMARY TABLE
print("\n=== Application Summary ===")
print(f"{'Domain':<25} {'Use Case':<30} {'NFA Role':<25}")
print("-" * 80)
print(f"{'Compilers':<25} {'Lexical analysis':<30} {'Pattern ā NFA ā DFA':<25}")
print(f"{'Regex engines':<25} {'Pattern matching':<30} {'Thompson construction':<25}")
print(f"{'Text editors':<25} {'Find & replace':<30} {'NFA simulation':<25}")
print(f"{'Networking':<25} {'Intrusion detection':<30} {'Multi-pattern NFA':<25}")
print(f"{'Bioinformatics':<25} {'DNA matching':<30} {'Motif NFA':<25}")
print(f"{'Security':<25} {'Input validation':<30} {'Fast NFA matching':<25}")
# 10. PRODUCTION EXAMPLE
print("\n=== Production Example: Log Parser ===")
print("""
Parsing web server logs:
Log format:
192.168.1.1 - - [01/Jan/2024:12:00:00 +0000] "GET /index.html HTTP/1.1" 200 1234
Approach:
1. Compile log format to NFA
2. Run on each log line
3. Extract fields
Regex:
r'(\\S+) - - \\[(.+?)\\] "(\\S+) (\\S+) (\\S+)" (\\d+) (\\d+)'
NFA advantage:
- Linear time
- No backtracking
- Constant memory
Use case:
- Real-time monitoring
- Alert on errors
- Traffic analysis
- Security audit
""")š Future: NFA in Modern Computing
šÆ Where NFAs Are Going
NFAs remain fundamental as a design tool, while their implementations evolve with hardware acceleration, parallelism, and integration with machine learning.
Future Directions
Hardware: FPGA/ASIC NFA simulation. SIMD: Parallel NFA. GPUs: Massively parallel matching. Streaming: NFA on data streams. Quantum: Quantum automata. AI: Neural NFA.
# Future of NFA in modern computing
print("=== Future Directions ===")
print("""
1. HARDWARE ACCELERATION
- FPGA-based NFA
- ASIC for regex
- Network processors
- 100+ Gbps throughput
2. SIMD PARALLELISM
- Bit-parallel NFA simulation
- 64 states in parallel (64-bit word)
- AVX-512: 512 bits parallel
- Intel Hyperscan
3. GPU ACCELERATION
- Massively parallel NFA
- Thousands of patterns
- Thousands of strings
- NVIDIA, AMD
4. STREAMING AND ONLINE
- NFA on infinite streams
- Sliding window matching
- Real-time monitoring
- IoT and edge computing
5. QUANTUM AUTOMATA
- Quantum NFA
- Superposition of states
- Potentially more compact
- Still theoretical
6. NEURAL AUTOMATA
- Differentiable NFA
- Learned state transitions
- Hybrid models
- Program synthesis
""")
# 1. BIT-PARALLEL NFA SIMULATION
print("\n=== Bit-Parallel NFA Simulation ===")
print("""
Idea: Represent set of NFA states as a bit vector.
For NFA with ⤠64 states:
- State set = 64-bit integer
- Transition = bitwise operations
- Very fast
Algorithm:
current = 1 << q0 (start state)
for each symbol:
next = 0
for each state in current:
next |= transition[state][symbol]
current = next
Optimization:
- Precompute transition masks
- Use bitwise OR
- SIMD for multiple strings
Performance:
- 64 states per operation
- 10-100x faster than set-based
- Used in production regex engines
""")
# 2. FPGA-BASED NFA
print("\n=== FPGA-Based NFA ===")
print("""
FPGA implementation:
Mapping:
- NFA state ā flip-flop
- Transition ā logic gate
- Input ā character stream
Optimization:
- Multiple characters per cycle
- Parallel NFAs for multiple patterns
- Pipeline stages
Example: Snort on FPGA
- 100 Gbps throughput
- Millions of rules
- <1 μs latency
Vendors:
- Intel (Altera)
- AMD (Xilinx)
- Lattice
- Microchip
""")
# 3. GPU-ACCELERATED NFA
print("\n=== GPU-Accelerated NFA ===")
print("""
GPU for NFA simulation:
Challenge:
- NFA is sequential (state set)
- GPU is parallel
Solutions:
1. Multiple strings in parallel
2. Multiple patterns in parallel
3. Bit-parallel + GPU
Libraries:
- NVIDIA cuDF (regex)
- RAPIDS
- Custom CUDA
Applications:
- Log analysis at scale
- Genomics (DNA matching)
- Network traffic analysis
- Security scanning
Performance:
- 100+ GB/s aggregate
- Millions of matches per second
""")
# 4. STREAMING NFAs
print("\n=== Streaming NFAs ===")
print("""
Process data as it arrives:
Properties:
- Constant memory
- O(1) per symbol
- Infinite streams
- Real-time matching
Applications:
- Network traffic monitoring
- IoT sensor data
- Financial tickers
- Social media feeds
Frameworks:
- Apache Flink
- Apache Storm
- Kafka Streams
- Apache Beam
Techniques:
- Sliding window
- Multiple patterns
- Out-of-order handling
""")
# 5. QUANTUM FINITE AUTOMATA
print("\n=== Quantum Finite Automata ===")
print("""
Quantum NFA (QFA):
Types:
1. Measure-Once QFA
2. Measure-Many QFA
3. Latvian QFA
Properties:
- Superposition of states
- Potentially fewer states
- Some languages need exponentially fewer
Challenges:
- Decoherence
- Error correction
- Small scale
Status:
- Active research
- Theoretical mostly
- Future possibility
""")
# 6. NEURAL NETWORKS AND AUTOMATA
print("\n=== Neural Automata ===")
print("""
Neural Networks that learn NFAs:
Approaches:
1. Neural Turing Machines
- RNN + external memory
2. Differentiable Automata
- Soft DFA/NFA
- End-to-end trainable
3. Neural State Machines
- Learned state transitions
- Discrete or continuous
Applications:
- Program synthesis
- Language understanding
- Robotics (control)
- Reinforcement learning
Libraries:
- PyTorch
- TensorFlow
- JAX
""")
# 7. VERIFICATION AND MODEL CHECKING
print("\n=== Verification and Model Checking ===")
print("""
NFA-based verification:
1. HARDWARE VERIFICATION
- Model circuits as automata
- Check properties (safety, liveness)
- Tools: NuSMV, SPIN
2. PROTOCOL VERIFICATION
- Model protocols as state machines
- Verify correctness
- Tools: UPPAAL, PRISM
3. SOFTWARE VERIFICATION
- Abstract programs to automata
- Check reachability
- Tools: CBMC, SLAM
4. SECURITY ANALYSIS
- Model attacks as automata
- Verify defenses
- Tools: ProVerif, Tamarin
""")
# 8. BIG DATA
print("\n=== Big Data and Analytics ===")
print("""
NFA for massive scale:
Applications:
- Log analysis (petabytes/day)
- Security event correlation
- Fraud detection
- Real-time analytics
Techniques:
- Distributed NFA (Spark)
- MapReduce for matching
- Stream processing
- Compressed NFA
Performance:
- 10+ TB/s aggregate
- Linear scaling
- Fault-tolerant
Libraries:
- Apache Spark (regex)
- Flink (CEP)
- Kafka Streams
- Beam
""")
# 9. DOMAIN-SPECIFIC LANGUAGES
print("\n=== Domain-Specific Languages ===")
print("""
NFAs for DSL implementation:
DSLs using NFA:
- Regex engines
- Template engines
- Configuration languages
- Protocol buffers
Approach:
1. Define grammar (CFG)
2. Tokenize with NFA (lexer)
3. Parse with PDA/parser
4. Generate code/AST
Examples:
- Terraform (HCL)
- Ansible (YAML)
- Kubernetes (YAML)
- GraphQL (schema)
""")
# 10. FUTURE OUTLOOK
print("\n=== Future Outlook ===")
print("""
NFA will continue to be foundational:
Short term (1-5 years):
- Better SIMD implementations
- More GPU acceleration
- Wider adoption in ML
- Streaming-native
Medium term (5-10 years):
- Quantum NFA applications
- Neural NFA hybrid models
- Program synthesis with NFA
- Hardware-software co-design
Long term (10+ years):
- Brain-inspired automata
- Photonic NFA
- Molecular computing
- New computational models
Key trends:
1. Hardware acceleration
2. Parallelism everywhere
3. Integration with ML
4. Streaming-first design
5. Formal verification
""")
# 11. RECOMMENDATIONS
print("\n=== Recommendations ===")
print("""
For practitioners:
1. Master NFA fundamentals
2. Learn regex deeply
3. Use libraries (RE2, Hyperscan)
4. Consider hardware for scale
5. Profile before optimizing
For researchers:
1. Quantum automata
2. Neural NFA models
3. Hardware implementations
4. Verification techniques
5. New applications
For students:
1. Understand theory
2. Implement NFA from scratch
3. Study real systems
4. Practice with tools
5. Explore applications
Resources:
- "Introduction to Automata Theory" (Hopcroft)
- "Compilers: Principles, Techniques, and Tools" (Dragon Book)
- Online courses (Coursera, edX)
- Research papers (DLT, ICALP)
""")
# 12. FINAL THOUGHTS
print("\n=== Final Thoughts ===")
print("""
NFAs are the design tool of choice:
1930s-1950s: Theoretical foundations
- Nondeterminism introduced
- Finite automata theory
1960s-1980s: Practical applications
- Thompson construction
- Lexical analysis (Lex)
- Pattern matching (grep)
1990s-2010s: High-performance
- Hardware NFAs
- Network security
- Regex engines
2020s+: Modern revival
- SIMD acceleration
- GPU computing
- ML integration
- Quantum automata
Why NFAs matter:
- Easier to design than DFA
- Same expressive power
- Foundation of regex
- Ubiquitous in practice
You now understand:
- Formal definition
- Nondeterminism and ε-transitions
- Subset construction
- Thompson construction
- Equivalence testing
- Real-world applications
- Future directions
"NFAs: where guessing is free
and power is equivalent."
ā Automata Theory
Go build efficient systems!
""")