🧠 PDA Fundamentals
🎯 What is a Pushdown Automaton?
A Pushdown Automaton (PDA) is a finite automaton equipped with a stack — an unbounded last-in-first-out (LIFO) memory. This extra memory allows PDAs to recognize context-free languages, which are strictly more powerful than regular languages.
Key insight: The stack gives a PDA one "counter" — enough to match nested structures like balanced parentheses or anbn, but not enough for anbncn (which needs two counters).
Why PDAs Matter
Power: Beyond regular languages. Stack: Unbounded LIFO memory. CFG equivalence: Same power as CFGs. Parsing: Foundation of parsers. Nondeterminism: Essential for full power. Compilers: Syntax analysis.
# Pushdown Automaton — the big picture
print("""
=== What Makes a PDA Special? ===
DFA/NFA: Finite states only
PDA: Finite states + STACK
The stack is:
- Unbounded (can grow arbitrarily)
- LIFO (last-in, first-out)
- Only top is accessible
This gives PDAs the ability to:
- Count (match a's with b's)
- Remember nested structure
- Parse context-free languages
=== Real-World Analogy: Plate Stack ===
Imagine a stack of plates:
- You can only add/remove from the TOP
- Can't access middle plates directly
- Can grow arbitrarily high
A PDA uses its stack like this:
- Push: add a plate
- Pop: remove top plate
- Peek: look at top plate
=== The Key Limitation ===
A single stack gives ONE counter.
a^n b^n: ✓ PDA can do (one counter)
a^n b^n c^n: ✗ PDA can't (two counters needed)
ww: ✗ PDA can't (needs to copy)
ww^R: ✓ PDA can do (palindrome — stack!)
=== PDA vs Other Models ===
DFA: Finite memory → regular languages
PDA: Stack memory → context-free languages
LBA: Linear memory → context-sensitive
TM: Infinite memory → recursively enumerable
""")
# ANALOGY
print("=== Analogy: Well-Balanced Parentheses ===")
print("""
How does a PDA check if parentheses are balanced?
Input: ( ( ) ( ) )
Process:
Read '(' → push '(' onto stack
Read '(' → push '(' onto stack
Read ')' → pop '(' from stack ✓
Read '(' → push '(' onto stack
Read ')' → pop '(' from stack ✓
Read ')' → pop '(' from stack ✓
End of input: stack is empty → ACCEPT
If stack is non-empty or we try to pop from empty:
→ REJECT
The stack remembers how many unmatched '(' we have.
This is impossible for a DFA (finite memory)!
=== Why a^n b^n c^n is Hard ===
To match a^n b^n c^n, we need to:
- Count a's (push)
- Match b's (pop)
- Match c's with... what? The stack is empty!
One stack can only do ONE count.
After matching a's with b's, there's nothing left
to match c's with.
So a^n b^n c^n is NOT context-free.
""")
# PDA IN CODE
print("=== Python: Simple PDA Simulation ===")
from collections import deque
class PDA:
"""A simple nondeterministic pushdown automaton."""
def __init__(self, states, input_alphabet, stack_alphabet,
transitions, start_state, start_stack, accept_states):
self.Q = states
self.Sigma = input_alphabet
self.Gamma = stack_alphabet
self.delta = transitions # (state, input, stack_top) -> [(next_state, push)]
self.q0 = start_state
self.Z0 = start_stack
self.F = accept_states
def accepts(self, input_string, max_steps=10000):
"""Simulate PDA (nondeterministic, BFS)."""
# Configuration: (state, remaining_input_index, stack_tuple)
initial = (self.q0, 0, (self.Z0,))
queue = deque([(initial, [])])
visited = set()
steps = 0
while queue and steps < max_steps:
(state, idx, stack), path = queue.popleft()
steps += 1
key = (state, idx, stack)
if key in visited:
continue
visited.add(key)
# Acceptance: input consumed and (in accept state OR empty stack)
if idx == len(input_string):
if state in self.F or len(stack) == 0:
return True, path
# Try epsilon transitions
if stack:
top = stack[0]
for (ns, push) in self.delta.get((state, 'ε', top), []):
new_stack = tuple(push) + stack[1:]
queue.append(((ns, idx, new_stack), path + [(state, 'ε', top, ns, push)]))
# Try consuming input
if idx < len(input_string) and stack:
symbol = input_string[idx]
top = stack[0]
for (ns, push) in self.delta.get((state, symbol, top), []):
new_stack = tuple(push) + stack[1:]
queue.append(((ns, idx + 1, new_stack),
path + [(state, symbol, top, ns, push)]))
return False, None
# Example: PDA for a^n b^n
pda_anbn = PDA(
states={'q0', 'q1', 'q2'},
input_alphabet={'a', 'b'},
stack_alphabet={'Z', 'A'},
transitions={
('q0', 'a', 'Z'): [('q0', ('A', 'Z'))],
('q0', 'a', 'A'): [('q0', ('A', 'A'))],
('q0', 'b', 'A'): [('q1', ())],
('q1', 'b', 'A'): [('q1', ())],
('q1', 'ε', 'Z'): [('q2', ('Z',))],
},
start_state='q0',
start_stack='Z',
accept_states={'q2'}
)
print("PDA for a^n b^n")
print("Testing:")
for s in ['', 'ab', 'aabb', 'aaabbb', 'aab', 'abb', 'ba', 'aabbb']:
accepted, path = pda_anbn.accepts(s)
result = "✓ ACCEPT" if accepted else "✗ REJECT"
print(f" '{s}' → {result}")
# Trace one example
print("\nTrace for 'aabb':")
accepted, path = pda_anbn.accepts('aabb')
if path:
for i, (state, symbol, top, ns, push) in enumerate(path):
print(f" Step {i+1}: ({state}, {symbol}, {top}) → ({ns}, push {push})")
# LANGUAGE OF A PDA
print("""
=== Language of a PDA ===
A PDA M accepts a string w if there exists
a sequence of transitions from the start
configuration to an accepting configuration.
Two acceptance modes:
1. Accept by final state: end in F
2. Accept by empty stack: stack is empty
L(M) = { w | M accepts w }
=== Key Facts ===
1. Every CFG can be converted to a PDA.
2. Every PDA can be converted to a CFG.
3. Therefore: PDA ⟺ CFG (same power).
4. Nondeterminism is essential for full power.
5. Deterministic PDAs are weaker (DCFL).
=== Comparison ===
Model Memory Languages
DFA None Regular
PDA Stack Context-Free
LBA Linear tape Context-Sensitive
TM Infinite tape Recursively Enumerable
""")📐 Formal Definition
🎯 The 7-Tuple
A PDA is formally defined as a 7-tuple M = (Q, Σ, Γ, δ, q₀, Z₀, F), where each component plays a specific role in the machine's operation.
The Components
Q: Finite set of states. Σ: Input alphabet. Γ: Stack alphabet. δ: Transition function Q × (Σ∪{ε}) × Γ → P(Q × Γ*). q₀: Start state. Z₀: Initial stack symbol. F: Accept states.
# Formal Definition of PDA
from dataclasses import dataclass
from typing import Set, Dict, Tuple, FrozenSet, List
@dataclass
class PDA:
"""
Formal PDA as 7-tuple M = (Q, Σ, Γ, δ, q₀, Z₀, F)
Q: finite set of states
Σ: input alphabet
Γ: stack alphabet
δ: Q × (Σ ∪ {ε}) × Γ → P(Q × Γ*) transition function
q₀: start state
Z₀: initial stack symbol
F: set of accept states
"""
Q: FrozenSet[str] # States
Sigma: FrozenSet[str] # Input alphabet
Gamma: FrozenSet[str] # Stack alphabet
delta: Dict[Tuple[str, str, str], List[Tuple[str, Tuple[str, ...]]]]
q0: str # Start state
Z0: str # Initial stack symbol
F: FrozenSet[str] # Accept states
def __post_init__(self):
# Validate
assert self.q0 in self.Q, "Start state must be in Q"
assert self.Z0 in self.Gamma, "Z0 must be in Γ"
assert self.F.issubset(self.Q), "F must be subset of Q"
assert self.Sigma & self.Gamma == frozenset() or True, \
"Σ and Γ may overlap (usually don't)"
for (q, a, Z), results in self.delta.items():
assert q in self.Q, f"State {q} not in Q"
assert a in self.Sigma or a == 'ε', f"Symbol {a} not valid"
assert Z in self.Gamma, f"Stack symbol {Z} not in Γ"
for (next_q, push) in results:
assert next_q in self.Q, f"Next state {next_q} not in Q"
for s in push:
assert s in self.Gamma, f"Push symbol {s} not in Γ"
def __repr__(self):
lines = [f"PDA M = (Q, Σ, Γ, δ, q₀, Z₀, F)"]
lines.append(f" Q = {sorted(self.Q)}")
lines.append(f" Σ = {sorted(self.Sigma)}")
lines.append(f" Γ = {sorted(self.Gamma)}")
lines.append(f" q₀ = {self.q0}")
lines.append(f" Z₀ = {self.Z0}")
lines.append(f" F = {sorted(self.F)}")
lines.append(f" δ:")
for (q, a, Z), results in sorted(self.delta.items()):
for (nq, push) in results:
push_str = ''.join(push) if push else 'ε'
lines.append(f" δ({q}, {a}, {Z}) ∋ ({nq}, {push_str})")
return '\n'.join(lines)
# Example: PDA for a^n b^n
pda_anbn = PDA(
Q=frozenset({'q0', 'q1', 'q2'}),
Sigma=frozenset({'a', 'b'}),
Gamma=frozenset({'Z', 'A'}),
delta={
('q0', 'a', 'Z'): [('q0', ('A', 'Z'))],
('q0', 'a', 'A'): [('q0', ('A', 'A'))],
('q0', 'b', 'A'): [('q1', ())],
('q1', 'b', 'A'): [('q1', ())],
('q1', 'ε', 'Z'): [('q2', ('Z',))],
},
q0='q0',
Z0='Z',
F=frozenset({'q2'})
)
print(pda_anbn)
# CONFIGURATION
print("""
=== Configurations ===
A configuration (or instantaneous description)
of a PDA is a triple:
(q, w, γ)
where:
q ∈ Q current state
w ∈ Σ* remaining input
γ ∈ Γ* current stack contents
=== Initial Configuration ===
(q₀, w, Z₀)
=== Accepting Configuration ===
Accept by final state: (q, ε, γ) with q ∈ F
Accept by empty stack: (q, ε, ε) for any q
=== Transition Relation ⊢ ===
We write:
(q, aw, Zγ) ⊢ (q', w, αγ)
if δ(q, a, Z) contains (q', α).
This means:
- In state q
- Reading 'a' (or ε)
- With Z on top of stack
- Move to state q'
- Replace Z with α
⊢* is the reflexive-transitive closure.
""")
# CONFIGURATION TRACE
print("=== Configuration Trace: 'aabb' ===")
print("""
PDA for a^n b^n:
δ(q0, a, Z) = (q0, AZ)
δ(q0, a, A) = (q0, AA)
δ(q0, b, A) = (q1, ε)
δ(q1, b, A) = (q1, ε)
δ(q1, ε, Z) = (q2, Z)
Input: aabb
Start: (q0, aabb, Z)
Read 'a': (q0, aabb, Z) ⊢ (q0, abb, AZ)
Read 'a': (q0, abb, AZ) ⊢ (q0, bb, AAZ)
Read 'b': (q0, bb, AAZ) ⊢ (q1, b, AZ)
Read 'b': (q1, b, AZ) ⊢ (q1, ε, Z)
ε-move: (q1, ε, Z) ⊢ (q2, ε, Z)
Accept: q2 ∈ F and input consumed ✓
""")
# FORMAL ACCEPTANCE
print("""
=== Formal Acceptance Definitions ===
ACCEPT BY FINAL STATE:
L(M) = { w | (q₀, w, Z₀) ⊢* (q, ε, γ) for some q ∈ F, γ ∈ Γ* }
ACCEPT BY EMPTY STACK:
N(M) = { w | (q₀, w, Z₀) ⊢* (q, ε, ε) for some q ∈ Q }
=== Equivalence ===
For any PDA M1 (final state), there exists M2 (empty stack)
such that L(M1) = N(M2), and vice versa.
Therefore, the two modes are equivalent.
=== Standard Convention ===
We usually specify:
- Start state q0
- Initial stack symbol Z0
- Accept states F
And use "accept by final state" as the default.
""")
# VALIDATION
print("=== Validation ===")
def validate_pda(pda):
"""Validate a PDA's structure."""
errors = []
if pda.q0 not in pda.Q:
errors.append(f"Start state {pda.q0} not in Q")
if pda.Z0 not in pda.Gamma:
errors.append(f"Z0 {pda.Z0} not in Γ")
if not pda.F.issubset(pda.Q):
errors.append(f"F not subset of Q")
for (q, a, Z), results in pda.delta.items():
if q not in pda.Q:
errors.append(f"State {q} not in Q")
if a != 'ε' and a not in pda.Sigma:
errors.append(f"Symbol {a} not in Σ ∪ {{ε}}")
if Z not in pda.Gamma:
errors.append(f"Stack symbol {Z} not in Γ")
for (nq, push) in results:
if nq not in pda.Q:
errors.append(f"Next state {nq} not in Q")
for s in push:
if s not in pda.Gamma:
errors.append(f"Push symbol {s} not in Γ")
if errors:
print("❌ Validation errors:")
for e in errors:
print(f" - {e}")
else:
print("✓ PDA is valid")
return len(errors) == 0
validate_pda(pda_anbn)
# MORE EXAMPLES
print("\n=== More PDA Examples ===")
print("""
1. PALINDROMES over {a, b}:
Q = {q0, q1, q2}
Γ = {Z, a, b}
Push all symbols, guess middle, pop and match.
2. BALANCED PARENTHESES:
Q = {q0, q1}
Γ = {Z, (}
Push '(', pop on ')'.
3. a^i b^j with i > j:
Push a's, pop on b's, accept if stack still has a's.
4. EQUAL a's and b's (any order):
Push when seeing a, pop when seeing b, or vice versa.
Nondeterministic choice.
""")📚 The Stack
🎯 The PDA's Memory
The stack is a LIFO (last-in, first-out) data structure. It's the key difference between a PDA and a finite automaton, giving the PDA its extra computational power.
Stack Operations
Push: Add symbol to top. Pop: Remove top symbol. Peek: Read top symbol. Replace: Pop then push. Empty: No symbols. LIFO: Last in, first out.
# The Stack in detail
print("=" * 60)
print("THE STACK")
print("=" * 60)
print("""
=== Stack Basics ===
A stack is a LIFO (Last-In, First-Out) data structure.
Operations:
push(x): Add x to the top
pop(): Remove and return the top
peek(): Return the top without removing
isEmpty(): Check if empty
=== Stack in a PDA ===
The PDA's stack:
- Starts with symbol Z₀ (bottom marker)
- Can grow arbitrarily high
- Only top is accessible
- Operations:
* Push symbols (δ returns a string to push)
* Pop symbol (δ returns empty string)
* Replace (push a string in place of top)
=== Visualizing the Stack ===
Empty: [ ]
After push A: [ A ]
After push B: [ B A ]
After pop: [ A ]
After push C: [ C A ]
Note: The top is on the LEFT in our notation.
""")
# STACK IMPLEMENTATION
print("=== Python: Stack Implementation ===")
class Stack:
"""Simple stack for PDA simulation."""
def __init__(self, initial=None):
# We'll use a list with the top at the END for efficiency
# But for PDA display, top is often shown first
self.items = list(initial) if initial else []
def push(self, symbols):
"""Push a sequence of symbols."""
# If pushing string "ABC", A goes on top
# So we push in reverse order
for s in reversed(symbols):
self.items.append(s)
def pop(self):
"""Pop the top symbol."""
if self.items:
return self.items.pop()
return None
def peek(self):
"""Return the top symbol."""
return self.items[-1] if self.items else None
def is_empty(self):
return len(self.items) == 0
def __repr__(self):
# Display with top on the left
return '[' + ' '.join(reversed(self.items)) + ']'
# Demonstrate stack operations
print("Stack operations:")
s = Stack()
print(f" Initial: {s}")
s.push("Z")
print(f" push Z: {s}")
s.push("A")
print(f" push A: {s}")
s.push("B")
print(f" push B: {s}")
top = s.pop()
print(f" pop → {top}: {s}")
s.push("C")
print(f" push C: {s}")
print(f" peek: {s.peek()}")
print(f" is_empty: {s.is_empty()}")
# STACK IN PDA TRANSITIONS
print("""
=== Stack Operations in PDA Transitions ===
Each transition δ(q, a, Z) = (q', γ) does:
1. Pop Z from the stack top
2. Push γ (a string of stack symbols)
3. Move to state q'
Examples:
δ(q, a, Z) = (q', ε)
→ Pop Z, push nothing (net: pop Z)
δ(q, a, Z) = (q', A)
→ Pop Z, push A (net: replace Z with A)
δ(q, a, Z) = (q', AB)
→ Pop Z, push AB (A on top of B)
→ Net: replace Z with A, then B below A
δ(q, a, Z) = (q', Z)
→ Pop Z, push Z back (net: no change, just read input)
""")
# STACK TRACE VISUALIZATION
print("=== Stack Trace for a^n b^n on 'aabb' ===")
print("""
Input: a a b b
Start: Stack: [Z]
Read 'a': Push A Stack: [A Z]
Read 'a': Push A Stack: [A A Z]
Read 'b': Pop A Stack: [A Z]
Read 'b': Pop A Stack: [Z]
ε-move: No change Stack: [Z]
Accept: stack has Z, in accept state q2 ✓
Visual:
Start Read a Read a Read b Read b Accept
[Z] → [A Z] → [A A Z] → [A Z] → [Z] → [Z]
push A push A pop A pop A
""")
# STACK FOR DIFFERENT LANGUAGES
print("=== Stack Usage for Different Languages ===")
print("""
1. a^n b^n (n ≥ 0)
Push A for each a, pop A for each b.
Accept if stack returns to Z.
2. BALANCED PARENTHESES
Push '(' for each '('.
Pop '(' for each ')'.
Accept if stack returns to Z.
3. PALINDROMES (w = w^R)
Push all symbols until middle (guess).
Pop and match for second half.
Accept if all match.
4. a^i b^j (i > j)
Push A for each a.
Pop A for each b.
Accept if stack still has A's left.
5. a^i b^j (i < j)
Push A for each a.
Pop A for each b.
Accept if we run out of A's before input.
(Need to check we don't have extra A's.)
6. EQUAL a's and b's (any order)
Nondeterministically push A for a, pop A for b,
OR push B for b, pop B for a.
""")
# STACK SIMULATION IN CODE
print("=== Full Stack Simulation ===")
class PDASimulator:
"""PDA simulator with stack visualization."""
def __init__(self, states, input_alphabet, stack_alphabet,
transitions, start_state, start_stack, accept_states):
self.Q = states
self.Sigma = input_alphabet
self.Gamma = stack_alphabet
self.delta = transitions
self.q0 = start_state
self.Z0 = start_stack
self.F = accept_states
def simulate(self, input_string, verbose=False):
"""Simulate PDA (BFS, returns acceptance and trace)."""
from collections import deque
initial = (self.q0, 0, (self.Z0,))
queue = deque([(initial, [])])
visited = set()
while queue:
(state, idx, stack), path = queue.popleft()
if (state, idx, stack) in visited:
continue
visited.add((state, idx, stack))
# Accept?
if idx == len(input_string) and (state in self.F or len(stack) == 0):
return True, path
if stack:
top = stack[0]
# Epsilon transitions
for (ns, push) in self.delta.get((state, 'ε', top), []):
new_stack = tuple(push) + stack[1:]
queue.append(((ns, idx, new_stack),
path + [(state, 'ε', top, ns, push)]))
# Input transitions
if idx < len(input_string):
symbol = input_string[idx]
for (ns, push) in self.delta.get((state, symbol, top), []):
new_stack = tuple(push) + stack[1:]
queue.append(((ns, idx + 1, new_stack),
path + [(state, symbol, top, ns, push)]))
return False, []
def print_trace(self, input_string):
"""Print detailed trace."""
accepted, path = self.simulate(input_string)
print(f"\nInput: '{input_string}'")
print(f"Result: {'ACCEPT ✓' if accepted else 'REJECT ✗'}")
if path:
# Reconstruct stack states
stack = [self.Z0]
state = self.q0
print(f"\n{'Step':<6} {'State':<8} {'Read':<6} {'Top':<6} {'Next':<8} {'Push':<10} {'Stack':<20}")
print("-" * 70)
print(f"{'0':<6} {state:<8} {'—':<6} {'—':<6} {'—':<8} {'—':<10} {str(stack):<20}")
for i, (q, sym, top, ns, push) in enumerate(path):
# Update stack
stack = stack[1:] # pop
stack = list(push) + stack # push
state = ns
push_str = ''.join(push) if push else 'ε'
read_str = sym if sym != 'ε' else 'ε'
stack_str = '[' + ' '.join(stack) + ']' if stack else '[ ]'
print(f"{i+1:<6} {ns:<8} {read_str:<6} {top:<6} {'—':<8} {push_str:<10} {stack_str:<20}")
else:
print("No accepting path found.")
# Example: a^n b^n
pda = PDASimulator(
states={'q0', 'q1', 'q2'},
input_alphabet={'a', 'b'},
stack_alphabet={'Z', 'A'},
transitions={
('q0', 'a', 'Z'): [('q0', ('A', 'Z'))],
('q0', 'a', 'A'): [('q0', ('A', 'A'))],
('q0', 'b', 'A'): [('q1', ())],
('q1', 'b', 'A'): [('q1', ())],
('q1', 'ε', 'Z'): [('q2', ('Z',))],
},
start_state='q0',
start_stack='Z',
accept_states={'q2'}
)
pda.print_trace('aabb')
pda.print_trace('aaabbb')
pda.print_trace('aab')
# STACK LIMITATIONS
print("""
=== Stack Limitations ===
The stack gives ONE counter. This means:
✓ CAN DO:
- Match a's with b's
- Check balanced parentheses
- Palindromes
- One comparison
✗ CANNOT DO:
- a^n b^n c^n (two counters)
- ww (copying)
- a^n b^n c^n d^n
- Multiple independent counts
=== Why Two Counters Fail ===
To recognize a^n b^n c^n, we need to:
1. Count a's (push)
2. Match b's (pop)
3. Match c's with... nothing!
After step 2, the stack is empty.
There's no memory left for c's.
You'd need TWO stacks (or more) to do this.
A two-stack PDA is equivalent to a Turing machine!
=== Interesting Fact ===
1-stack PDA = CFG (context-free)
2-stack PDA = TM (Turing machine)
""")🔄 Transition Function
🎯 The Heart of the PDA
The transition function δ: Q × (Σ ∪ {ε}) × Γ → P(Q × Γ*) defines how the PDA moves. It reads the current state, input symbol (or ε), and stack top, and produces a set of possible next moves.
Transition Types
Read input: Consumes a symbol. ε-transition: No input consumed. Push: Add to stack. Pop: Remove from stack. Replace: Pop then push. Nondeterminism: Multiple choices.
# Transition Function in detail
print("=" * 60)
print("TRANSITION FUNCTION δ")
print("=" * 60)
print("""
=== Formal Definition ===
δ: Q × (Σ ∪ {ε}) × Γ → P(Q × Γ*)
Inputs:
q ∈ Q current state
a ∈ Σ ∪ {ε} input symbol (or ε)
Z ∈ Γ top of stack
Output:
A SET of pairs (q', γ) where:
q' ∈ Q next state
γ ∈ Γ* string to replace Z with
=== Reading a Transition ===
δ(q, a, Z) = (q', γ)
Means:
"In state q, reading 'a', with Z on top:
go to state q', pop Z, push γ"
=== Special Cases ===
1. δ(q, a, Z) = (q', ε)
Pop Z, push nothing. Stack shrinks.
2. δ(q, a, Z) = (q', Z)
Pop Z, push Z back. No stack change.
3. δ(q, a, Z) = (q', AB)
Pop Z, push A then B.
A is on top of B in new stack.
4. δ(q, ε, Z) = (q', γ)
ε-transition: no input consumed.
""")
# TRANSITION TYPES WITH EXAMPLES
print("=== Transition Types with Examples ===")
examples = [
("Push", "δ(q0, 'a', Z) = (q0, AZ)",
"Read 'a', push A on top of Z"),
("Pop", "δ(q0, 'b', A) = (q1, ε)",
"Read 'b', pop A"),
("Replace", "δ(q0, 'a', Z) = (q1, A)",
"Read 'a', replace Z with A"),
("No change", "δ(q0, 'a', Z) = (q0, Z)",
"Read 'a', stack unchanged"),
("ε-move", "δ(q0, ε, Z) = (q1, Z)",
"No input, change state only"),
("Push two", "δ(q0, 'a', Z) = (q0, ABZ)",
"Read 'a', push A and B"),
("Push three", "δ(q0, 'a', Z) = (q0, ABCZ)",
"Read 'a', push A, B, C (A on top)"),
("Nondeterministic", "δ(q0, 'a', Z) = {(q1, AZ), (q2, ε)}",
"Two choices: push or pop"),
]
print(f"{'Type':<15} {'Transition':<40} {'Meaning':<30}")
print("-" * 85)
for name, trans, meaning in examples:
print(f"{name:<15} {trans:<40} {meaning:<30}")
# TRANSITION FUNCTION IN CODE
print("\n=== Transition Function in Code ===")
class PDA:
def __init__(self, states, input_alphabet, stack_alphabet,
transitions, start_state, start_stack, accept_states):
self.Q = states
self.Sigma = input_alphabet
self.Gamma = stack_alphabet
self.delta = transitions
self.q0 = start_state
self.Z0 = start_stack
self.F = accept_states
def get_transitions(self, state, symbol, stack_top):
"""Get all transitions for a configuration."""
return self.delta.get((state, symbol, stack_top), [])
def step(self, state, remaining, stack):
"""Compute all next configurations."""
results = []
if not stack:
return results
top = stack[0]
# Epsilon transitions
for (ns, push) in self.get_transitions(state, 'ε', top):
new_stack = tuple(push) + stack[1:]
results.append((ns, remaining, new_stack))
# Input transitions
if remaining:
symbol = remaining[0]
for (ns, push) in self.get_transitions(state, symbol, top):
new_stack = tuple(push) + stack[1:]
results.append((ns, remaining[1:], new_stack))
return results
# Example: a^n b^n
pda = PDA(
states={'q0', 'q1', 'q2'},
input_alphabet={'a', 'b'},
stack_alphabet={'Z', 'A'},
transitions={
('q0', 'a', 'Z'): [('q0', ('A', 'Z'))],
('q0', 'a', 'A'): [('q0', ('A', 'A'))],
('q0', 'b', 'A'): [('q1', ())],
('q1', 'b', 'A'): [('q1', ())],
('q1', 'ε', 'Z'): [('q2', ('Z',))],
},
start_state='q0',
start_stack='Z',
accept_states={'q2'}
)
print("Transition table for a^n b^n:")
print(f"{'State':<8} {'Input':<8} {'Stack':<8} {'→ Next':<10} {'Push':<10}")
print("-" * 50)
for (q, a, Z), results in sorted(pda.delta.items()):
for (nq, push) in results:
push_str = ''.join(push) if push else 'ε'
print(f"{q:<8} {a:<8} {Z:<8} {nq:<10} {push_str:<10}")
# NONDETERMINISM
print("""
=== Nondeterminism in PDAs ===
A PDA is NONDETERMINISTIC if:
|δ(q, a, Z)| > 1 for some (q, a, Z)
Or if it has ε-transitions.
=== Why Nondeterminism Matters ===
Nondeterministic PDAs accept all CFLs.
Deterministic PDAs accept only DCFLs (a strict subset).
Example: PALINDROMES
L = { w | w = w^R }
Nondeterministic PDA:
- Push symbols
- Nondeterministically guess the middle
- Pop and match
Deterministic PDA: IMPOSSIBLE
- Can't know when middle is reached
- Needs to "guess"
=== Example: Nondeterministic Transition ===
δ(q0, 'a', Z) = {(q0, AZ), (q1, Z)}
Choice 1: push A (continue building)
Choice 2: go to q1 (start matching)
The PDA explores BOTH possibilities.
Accepts if ANY path accepts.
""")
# EPSILON TRANSITIONS
print("""
=== ε-Transitions ===
ε-transitions consume no input:
δ(q, ε, Z) = (q', γ)
Uses:
1. State change without consuming input
2. Stack manipulation without reading
3. Model nondeterministic choices
4. Convert CFG to PDA
Example: CFG to PDA uses ε-transitions:
For rule A → α:
δ(q, ε, A) = (q, α^R)
This expands A without consuming input!
=== Important ===
ε-transitions can cause infinite loops:
δ(q, ε, Z) = (q, Z) ← loops forever!
In practice:
- BFS simulation avoids infinite loops
- Track visited configurations
- Or design PDAs without such loops
""")
# TRANSITION DESIGN PATTERNS
print("=== Common Transition Patterns ===")
print("""
1. PUSH FOR MATCHING
δ(q, 'a', Z) = (q, AZ)
δ(q, 'a', A) = (q, AA)
Push A for each 'a'.
2. POP FOR MATCHING
δ(q, 'b', A) = (q, ε)
Pop A for each 'b'.
3. SWITCH STATE
δ(q0, 'b', A) = (q1, ε)
Change state when transitioning.
4. GUESS MIDDLE
δ(q, ε, A) = (q', A)
ε-transition to nondeterministically guess.
5. ACCEPT CONDITION
δ(q, ε, Z) = (qf, Z)
Move to accept state at end.
6. PUSH BOTTOM MARKER
δ(q0, ε, Z) = (q0, Z0Z)
Use special bottom marker.
7. MULTIPLE PUSH
δ(q, 'a', Z) = (q, ABZ)
Push multiple symbols at once.
""")
# TRANSITION COMPOSITION
print("""
=== Composing Transitions ===
Complex PDAs are built from simple transitions.
For a^n b^n:
1. δ(q0, a, Z) = (q0, AZ) — first a, push A
2. δ(q0, a, A) = (q0, AA) — more a's, push A
3. δ(q0, b, A) = (q1, ε) — first b, pop A, switch
4. δ(q1, b, A) = (q1, ε) — more b's, pop A
5. δ(q1, ε, Z) = (q2, Z) — end, accept
The sequence of transitions implements the algorithm:
Push a's → Pop b's → Accept
This is how all PDAs are designed:
- Break the language into phases
- Design transitions for each phase
- Connect phases with state changes
""")✅ Acceptance Modes
🎯 Two Ways to Accept
PDAs can accept strings in two ways: by final state (ending in an accept state) or by empty stack (stack completely emptied). The two modes are equivalent in power.
Acceptance Modes
Final state: (q₀,w,Z₀) ⊢* (q,ε,γ), q ∈ F. Empty stack: (q₀,w,Z₀) ⊢* (q,ε,ε). Equivalence: Both accept exactly CFLs. Conversion: Can convert between modes. Standard: Final state is default. Practical: Empty stack is simpler.
# Acceptance Modes
print("=" * 60)
print("ACCEPTANCE MODES")
print("=" * 60)
print("""
=== Two Acceptance Modes ===
1. ACCEPT BY FINAL STATE
L(M) = { w | (q₀, w, Z₀) ⊢* (q, ε, γ) for some q ∈ F }
- Input must be consumed
- End in an accept state
- Stack contents irrelevant
2. ACCEPT BY EMPTY STACK
N(M) = { w | (q₀, w, Z₀) ⊢* (q, ε, ε) for some q ∈ Q }
- Input must be consumed
- Stack must be completely empty
- State irrelevant
=== Theorem ===
For every PDA M1 (final state), there exists a PDA M2
(empty stack) such that L(M1) = N(M2), and vice versa.
Therefore, the two modes are EQUIVALENT.
""")
# FINAL STATE PDA
print("=== Example: Final State PDA for a^n b^n ===")
print("""
States: {q0, q1, q2}
Input: {a, b}
Stack: {Z, A}
Start: q0, Z
Accept: {q2}
Transitions:
δ(q0, a, Z) = (q0, AZ) push A
δ(q0, a, A) = (q0, AA) push A
δ(q0, b, A) = (q1, ε) pop A, switch to q1
δ(q1, b, A) = (q1, ε) pop A
δ(q1, ε, Z) = (q2, Z) accept move
Acceptance: end in q2 with input consumed.
Stack still has Z at the end.
Trace for "ab":
(q0, ab, Z) ⊢ (q0, b, AZ)
⊢ (q1, ε, Z)
⊢ (q2, ε, Z)
q2 ∈ F ✓ ACCEPT
""")
# EMPTY STACK PDA
print("=== Example: Empty Stack PDA for a^n b^n ===")
print("""
States: {q0, q1}
Input: {a, b}
Stack: {Z, A}
Start: q0, Z
Accept: (empty stack — no F needed)
Transitions:
δ(q0, a, Z) = (q0, AZ) push A
δ(q0, a, A) = (q0, AA) push A
δ(q0, b, A) = (q1, ε) pop A
δ(q1, b, A) = (q1, ε) pop A
δ(q1, ε, Z) = (q1, ε) pop Z (empty stack!)
Acceptance: stack is empty.
No need for accept states.
Trace for "ab":
(q0, ab, Z) ⊢ (q0, b, AZ)
⊢ (q1, ε, Z)
⊢ (q1, ε, ε)
Stack empty ✓ ACCEPT
""")
# CONVERSION BETWEEN MODES
print("=== Converting Between Acceptance Modes ===")
print("""
FINAL STATE → EMPTY STACK:
Given M1 = (Q, Σ, Γ, δ, q0, Z0, F), construct M2:
M2 = (Q ∪ {q0', qe}, Σ, Γ ∪ {X0}, δ', q0', X0, ∅)
where:
- q0' is a new start state
- qe is a new "erase" state
- X0 is a new bottom marker
Transitions:
1. δ'(q0', ε, X0) = (q0, Z0 X0) [initialize]
2. δ'(q, a, Z) = δ(q, a, Z) [copy M1]
3. For each q ∈ F: δ'(q, ε, Z) = (qe, Z) [enter erase]
4. For each Z ∈ Γ: δ'(qe, ε, Z) = (qe, ε) [erase stack]
5. δ'(qe, ε, X0) = (qe, ε) [erase bottom]
Now M2 accepts by empty stack.
""")
# CONVERSION IN CODE
print("=== Converting Final State to Empty Stack ===")
def convert_to_empty_stack(pda):
"""Convert a final-state PDA to an empty-stack PDA."""
new_q0 = 'q0_init'
new_qe = 'q_erase'
new_X0 = 'X0'
new_Q = pda.Q | {new_q0, new_qe}
new_Gamma = pda.Gamma | {new_X0}
new_delta = {}
# Copy original transitions
for key, results in pda.delta.items():
new_delta[key] = list(results)
# 1. Initialize
new_delta[(new_q0, 'ε', new_X0)] = [(pda.q0, (pda.Z0, new_X0))]
# 3. From accept states, go to erase state
for q in pda.F:
for Z in pda.Gamma:
key = (q, 'ε', Z)
if key not in new_delta:
new_delta[key] = []
new_delta[key].append((new_qe, (Z,)))
# 4. Erase stack
for Z in new_Gamma:
key = (new_qe, 'ε', Z)
new_delta[key] = [(new_qe, ())]
return PDA(
states=new_Q,
input_alphabet=pda.Sigma,
stack_alphabet=new_Gamma,
transitions=new_delta,
start_state=new_q0,
start_stack=new_X0,
accept_states=frozenset() # empty stack acceptance
)
# Convert example
original = PDA(
states={'q0', 'q1', 'q2'},
input_alphabet={'a', 'b'},
stack_alphabet={'Z', 'A'},
transitions={
('q0', 'a', 'Z'): [('q0', ('A', 'Z'))],
('q0', 'a', 'A'): [('q0', ('A', 'A'))],
('q0', 'b', 'A'): [('q1', ())],
('q1', 'b', 'A'): [('q1', ())],
('q1', 'ε', 'Z'): [('q2', ('Z',))],
},
start_state='q0',
start_stack='Z',
accept_states={'q2'}
)
converted = convert_to_empty_stack(original)
print("Original PDA (final state):")
print(f" States: {original.Q}")
print(f" Accept: {original.F}")
print("\nConverted PDA (empty stack):")
print(f" States: {converted.Q}")
print(f" Start: {converted.q0}")
print(f" Start stack: {converted.Z0}")
print(f" Accept: (empty stack)")
print("\nNew transitions:")
for (q, a, Z), results in sorted(converted.delta.items()):
for (nq, push) in results:
push_str = ''.join(push) if push else 'ε'
print(f" δ({q}, {a}, {Z}) = ({nq}, {push_str})")
# EMPTY STACK → FINAL STATE
print("""
=== Converting Empty Stack to Final State ===
Given M1 = (Q, Σ, Γ, δ, q0, Z0, ∅), construct M2:
M2 = (Q ∪ {q0', qf}, Σ, Γ ∪ {X0}, δ', q0', X0, {qf})
Transitions:
1. δ'(q0', ε, X0) = (q0, Z0 X0)
2. δ'(q, a, Z) = δ(q, a, Z) for all original
3. For each q: δ'(q, ε, X0) = (qf, X0)
Now M2 accepts by final state.
The idea:
- Use new bottom marker X0
- When stack is about to be empty, go to qf
- qf is the only accept state
""")
# ACCEPTANCE COMPARISON
print("=== Comparison of Acceptance Modes ===")
print(f"{'Aspect':<30} {'Final State':<25} {'Empty Stack':<25}")
print("-" * 80)
print(f"{'Accept condition':<30} {'End in F':<25} {'Stack empty':<25}")
print(f"{'Accept states':<30} {'Required':<25} {'Not needed':<25}")
print(f"{'Stack at end':<30} {'Any':<25} {'Empty':<25}")
print(f"{'Design simplicity':<30} {'More states':<25} {'Simpler':<25}")
print(f"{'Conversion':<30} {'Possible':<25} {'Possible':<25}")
print(f"{'Standard?':<30} {'Yes':<25} {'Sometimes':<25}")
print(f"{'Used in':<30} {'Textbooks':<25} {'CFG→PDA':<25}")
# PRACTICAL CHOICE
print("""
=== Which Mode to Use? ===
FINAL STATE:
✓ More intuitive
✓ Standard definition
✓ Easier to specify accept states
✓ Closer to DFA/NFA
✓ Used in most textbooks
EMPTY STACK:
✓ Simpler construction (CFG → PDA)
✓ No need for accept states
✓ Natural for some proofs
✓ Easier to convert to CFG
In practice:
- Use final state for design
- Use empty stack for constructions
- Convert as needed
""")
# ACCEPTANCE EXAMPLES
print("=== Acceptance Examples ===")
print("""
Test string "aabb" on both PDAs:
FINAL STATE PDA:
(q0, aabb, Z) ⊢ (q0, abb, AZ)
⊢ (q0, bb, AAZ)
⊢ (q1, b, AZ)
⊢ (q1, ε, Z)
⊢ (q2, ε, Z)
q2 ∈ F → ACCEPT ✓
EMPTY STACK PDA:
(q0, aabb, Z) ⊢ (q0, abb, AZ)
⊢ (q0, bb, AAZ)
⊢ (q0, b, AZ) [wait, need q1]
Actually:
(q0, aabb, Z) ⊢ (q0, abb, AZ)
⊢ (q0, bb, AAZ)
⊢ (q1, b, AZ)
⊢ (q1, ε, Z)
⊢ (q1, ε, ε)
Stack empty → ACCEPT ✓
""")⚙️ Deterministic PDAs
🎯 When There's No Choice
A Deterministic Pushdown Automaton (DPDA) has at most one transition for each configuration. DPDAs are weaker than nondeterministic PDAs — they accept the deterministic context-free languages (DCFLs).
DPDA Properties
At most one move: |δ(q,a,Z)| ≤ 1. No ε-conflicts: Can't have both ε and input move. Weaker: Strict subset of CFLs. DCFL: Deterministic CFLs. Parsing: LR(k) grammars. Practical: Most programming languages.
# Deterministic PDAs
print("=" * 60)
print("DETERMINISTIC PDAs")
print("=" * 60)
print("""
=== Definition ===
A PDA is DETERMINISTIC if:
1. For every (q, a, Z) with a ∈ Σ:
|δ(q, a, Z)| ≤ 1
2. For every (q, ε, Z):
|δ(q, ε, Z)| ≤ 1
AND if δ(q, ε, Z) ≠ ∅, then δ(q, a, Z) = ∅ for all a ∈ Σ.
In words: at most one transition per configuration,
and no conflict between ε and input transitions.
=== Why "Deterministic"? ===
Given the current configuration (q, w, γ),
there's at most ONE possible next configuration.
No guessing, no backtracking — just follow the path.
=== DCFL ===
Languages accepted by DPDAs are called
DETERMINISTIC CONTEXT-FREE LANGUAGES (DCFLs).
DCFL ⊊ CFL (strict subset)
There are CFLs that no DPDA can recognize!
""")
# DPDA EXAMPLES
print("=== Example: DPDA for a^n b^n ===")
print("""
States: {q0, q1, q2}
Input: {a, b}
Stack: {Z, A}
Start: q0, Z
Accept: {q2}
Transitions:
δ(q0, a, Z) = (q0, AZ) [only one choice]
δ(q0, a, A) = (q0, AA) [only one choice]
δ(q0, b, A) = (q1, ε) [only one choice]
δ(q1, b, A) = (q1, ε) [only one choice]
δ(q1, ε, Z) = (q2, Z) [only one choice]
This is deterministic!
No configuration has multiple choices.
""")
# NON-DETERMINISTIC EXAMPLE
print("=== Example: NPDA for Palindromes ===")
print("""
L = { w | w = w^R } over {a, b}
A DPDA CANNOT recognize this!
Why? To recognize palindromes, the PDA must
"guess" the middle of the string.
Nondeterministic PDA:
δ(q0, 'a', Z) = {(q0, AZ), (q1, Z)}
Choice 1: push A (not at middle yet)
Choice 2: go to q1 (middle found)
δ(q0, 'b', Z) = {(q0, BZ), (q1, Z)}
Similar
The PDA guesses when the middle is reached.
Deterministic PDA can't guess!
=== The Key Insight ===
Nondeterminism adds POWER to PDAs.
PDA (nondeterministic) = all CFLs
DPDA (deterministic) = DCFLs ⊊ CFLs
This is different from finite automata!
DFA = NFA (same power)
DPDA ⊊ PDA (different power)
""")
# DPDA IN CODE
print("=== DPDA Simulator ===")
class DPDA:
"""Deterministic Pushdown Automaton."""
def __init__(self, states, input_alphabet, stack_alphabet,
transitions, start_state, start_stack, accept_states):
self.Q = states
self.Sigma = input_alphabet
self.Gamma = stack_alphabet
self.delta = transitions # (q, a, Z) -> (q', push) or None
self.q0 = start_state
self.Z0 = start_stack
self.F = accept_states
def is_deterministic(self):
"""Check if this PDA is deterministic."""
# Check no multiple transitions
for key, val in self.delta.items():
if isinstance(val, list) and len(val) > 1:
return False
# Check no ε/input conflict
for (q, a, Z) in self.delta:
if a == 'ε':
for symbol in self.Sigma:
if (q, symbol, Z) in self.delta:
return False
return True
def accepts(self, input_string, max_steps=10000):
"""Simulate DPDA (deterministic)."""
state = self.q0
idx = 0
stack = (self.Z0,)
trace = [(state, idx, stack)]
for _ in range(max_steps):
# Accept?
if idx == len(input_string):
if state in self.F or len(stack) == 0:
return True, trace
if not stack:
# Empty stack — accept if input consumed
if idx == len(input_string):
return True, trace
return False, trace
top = stack[0]
# Try ε-transition first (if deterministic, only one type)
if (state, 'ε', top) in self.delta:
next_state, push = self.delta[(state, 'ε', top)]
stack = tuple(push) + stack[1:]
state = next_state
trace.append((state, idx, stack))
continue
# Try input transition
if idx < len(input_string):
symbol = input_string[idx]
if (state, symbol, top) in self.delta:
next_state, push = self.delta[(state, symbol, top)]
stack = tuple(push) + stack[1:]
state = next_state
idx += 1
trace.append((state, idx, stack))
continue
# No transition — stuck
return False, trace
return False, trace
# Example DPDA
dpda = DPDA(
states={'q0', 'q1', 'q2'},
input_alphabet={'a', 'b'},
stack_alphabet={'Z', 'A'},
transitions={
('q0', 'a', 'Z'): ('q0', ('A', 'Z')),
('q0', 'a', 'A'): ('q0', ('A', 'A')),
('q0', 'b', 'A'): ('q1', ()),
('q1', 'b', 'A'): ('q1', ()),
('q1', 'ε', 'Z'): ('q2', ('Z',)),
},
start_state='q0',
start_stack='Z',
accept_states={'q2'}
)
print(f"Is deterministic: {dpda.is_deterministic()}")
print("\nTesting a^n b^n:")
for s in ['', 'ab', 'aabb', 'aaabbb', 'aab', 'abb']:
accepted, trace = dpda.accepts(s)
print(f" '{s}' → {'✓' if accepted else '✗'}")
# DCFL VS CFL
print("""
=== DCFL vs CFL ===
DCFL ⊊ CFL
Examples of DCFLs:
✓ a^n b^n
✓ Balanced parentheses
✓ Arithmetic expressions
✓ Most programming languages
Examples of CFLs that are NOT DCFLs:
✗ Palindromes: w = w^R
✗ ww^R
✗ { a^i b^j c^k | i = j or j = k }
✗ Any language requiring "guessing"
=== Closure Properties ===
DCFLs are:
✓ Closed under complement
✗ NOT closed under union
✗ NOT closed under intersection
✓ Closed under ∩ regular
CFLs are:
✗ NOT closed under complement
✓ Closed under union
✗ NOT closed under intersection
✓ Closed under ∩ regular
""")
# PARSING CONNECTION
print("""
=== Connection to Parsing ===
DCFLs are exactly the languages parsed by LR(k) parsers!
DPDA ⟺ LR(1) grammar
DPDA ⟺ DCFG (deterministic CFG)
Programming languages:
- Designed to be DCFLs
- Parsed by LR parsers (Yacc, Bison)
- Or LL parsers (ANTLR, recursive descent)
This is why compilers work:
- Programming languages are deterministic
- Can be parsed in O(n) time
- No backtracking needed
=== LR Parsing ===
LR(k) parser = DPDA with:
- States from item sets
- Stack holds states + grammar symbols
- Actions: shift, reduce, accept, error
This is the practical realization of a DPDA.
""")
# SUMMARY
print("=== Summary ===")
print(f"{'Aspect':<30} {'DPDA':<25} {'NPDA':<25}")
print("-" * 80)
print(f"{'Transitions':<30} {'≤ 1 per config':<25} {'Any number':<25}")
print(f"{'ε-conflicts':<30} {'Not allowed':<25} {'Allowed':<25}")
print(f"{'Power':<30} {'DCFL':<25} {'CFL':<25}")
print(f"{'Example':<30} {'a^n b^n':<25} {'Palindromes':<25}")
print(f"{'Parsing':<30} {'LR(k)':<25} {'CYK, Earley':<25}")
print(f"{'Practical':<30} {'Compilers':<25} {'NLP':<25}")
print(f"{'Closure ∪':<30} {'No':<25} {'Yes':<25}")
print(f"{'Closure complement':<30} {'Yes':<25} {'No':<25}")🎨 Designing PDAs
🎯 Building PDAs for Languages
Designing PDAs is a skill. You need to identify what the stack should remember and design transitions that implement the recognition algorithm.
Design Methodology
1. Understand: What language? 2. Stack strategy: What to push/pop? 3. Phases: Break into stages. 4. States: One per phase. 5. Transitions: Push/pop/switch. 6. Accept: When to stop. 7. Test: Verify with examples.
# Designing PDAs
print("=" * 60)
print("DESIGNING PDAs")
print("=" * 60)
print("""
=== Design Template ===
Step 1: WHAT is the language?
- Describe in words
- Identify the pattern
- Consider edge cases
Step 2: WHAT should the stack remember?
- What needs to be counted?
- What needs to be matched?
- What's the "state of knowledge"?
Step 3: HOW to break into phases?
- Phase 1: Push / build up
- Phase 2: Pop / match
- Phase 3: Accept / clean up
Step 4: HOW MANY states?
- Usually one per phase
- Plus accept state(s)
Step 5: TRANSITIONS?
- For each (state, input, stack) → next
- Push on one symbol, pop on another
Step 6: ACCEPT?
- Final state or empty stack
Step 7: TEST
- Positive and negative examples
""")
# EXAMPLE 1: a^n b^n
print("=== Example 1: a^n b^n ===")
print("""
Step 1: L = { a^n b^n | n ≥ 0 }
Step 2: Stack strategy
- Push A for each 'a'
- Pop A for each 'b'
- Match ensures equal counts
Step 3: Phases
- Phase 1: Read a's, push A
- Phase 2: Read b's, pop A
- Phase 3: Accept
Step 4: States
- q0: reading a's
- q1: reading b's
- q2: accept
Step 5: Transitions
δ(q0, a, Z) = (q0, AZ) push A
δ(q0, a, A) = (q0, AA) push A
δ(q0, b, A) = (q1, ε) pop A, switch
δ(q1, b, A) = (q1, ε) pop A
δ(q1, ε, Z) = (q2, Z) accept
Step 6: Accept = {q2}
Step 7: Test
'aabb' → push, push, pop, pop → accept ✓
'aab' → push, push, pop → stack has A → reject ✓
'abb' → push, pop, pop from empty → reject ✓
""")
# EXAMPLE 2: BALANCED PARENTHESES
print("=== Example 2: Balanced Parentheses ===")
print("""
Step 1: L = { w | w has balanced ( and ) }
Step 2: Stack strategy
- Push '(' for each '('
- Pop '(' for each ')'
- Stack empty at end = balanced
Step 3: Phases
- Single phase (always matching)
Step 4: States
- q0: reading input
- q1: accept
Step 5: Transitions
δ(q0, '(', Z) = (q0, (Z) push (
δ(q0, '(', () = (q0, (() push (
δ(q0, ')', () = (q0, ε) pop (
δ(q0, ε, Z) = (q1, Z) accept
Step 6: Accept = {q1}
Step 7: Test
'()' → push, pop → accept ✓
'(())' → push, push, pop, pop → accept ✓
'(()' → push, push, pop → stack has ( → reject ✓
')(' → pop from empty → reject ✓
""")
# EXAMPLE 3: PALINDROMES
print("=== Example 3: Palindromes (w = w^R) ===")
print("""
Step 1: L = { w | w = w^R } over {a, b}
Step 2: Stack strategy
- Push first half
- Guess middle
- Pop and match second half
Step 3: Phases
- Phase 1: Push (first half)
- Phase 2: Guess middle (nondeterministic!)
- Phase 3: Pop and match (second half)
Step 4: States
- q0: pushing
- q1: matching (after middle)
- q2: accept
Step 5: Transitions
δ(q0, 'a', Z) = (q0, AZ) push a
δ(q0, 'a', A) = (q0, AA) push a
δ(q0, 'b', Z) = (q0, BZ) push b
δ(q0, 'b', B) = (q0, BB) push b
Guess middle:
δ(q0, ε, Z) = (q1, Z) middle (even length)
δ(q0, ε, A) = (q1, A) middle (odd, skip middle)
δ(q0, ε, B) = (q1, B) middle (odd, skip middle)
Matching:
δ(q1, 'a', A) = (q1, ε) match a
δ(q1, 'b', B) = (q1, ε) match b
Accept:
δ(q1, ε, Z) = (q2, Z)
Step 6: Accept = {q2}
Step 7: Test
'aba' → push a, push b, guess odd, pop b, pop a → accept ✓
'abba' → push a, push b, guess even, pop b, pop a → accept ✓
'ab' → push a, push b, guess even, pop b (matches? no, need 'a')
→ reject ✓
""")
# EXAMPLE 4: EQUAL A'S AND B'S
print("=== Example 4: Equal a's and b's (any order) ===")
print("""
Step 1: L = { w | #a(w) = #b(w) }
Step 2: Stack strategy
- Nondeterministic choice:
* Push A for 'a', pop A for 'b'
* OR push B for 'b', pop B for 'a'
- Accept if stack empty
Step 3: Phases
- Single phase, but nondeterministic
Step 4: States
- q0: reading
- q1: accept
Step 5: Transitions
# Option 1: push a, pop b
δ(q0, 'a', Z) = {(q0, AZ), (q0, BZ)} # choose!
δ(q0, 'a', A) = {(q0, AA), (q0, ε)} # push or pop
δ(q0, 'a', B) = {(q0, AB), (q0, ε)} # push or pop
δ(q0, 'b', Z) = {(q0, AZ), (q0, BZ)}
δ(q0, 'b', A) = {(q0, BA), (q0, ε)}
δ(q0, 'b', B) = {(q0, BB), (q0, ε)}
δ(q0, ε, Z) = (q1, Z) # accept empty stack
Step 6: Accept = {q1}
Step 7: Test
'ab' → push A, pop A → accept ✓
'ba' → push B, pop B → accept ✓
'aabb' → push A, push A, pop A, pop A → accept ✓
'aab' → push A, push A, pop A → stack has A → reject ✓
""")
# EXAMPLE 5: a^i b^j, i > j
print("=== Example 5: a^i b^j with i > j ===")
print("""
Step 1: L = { a^i b^j | i > j ≥ 0 }
Step 2: Stack strategy
- Push A for each 'a'
- Pop A for each 'b'
- Accept if stack still has A's (i > j)
Step 3: Phases
- Phase 1: Read a's, push
- Phase 2: Read b's, pop
- Phase 3: Accept if stack has A
Step 4: States
- q0: reading a's
- q1: reading b's
- q2: accept
Step 5: Transitions
δ(q0, a, Z) = (q0, AZ)
δ(q0, a, A) = (q0, AA)
δ(q0, b, A) = (q1, ε) switch to b's
δ(q1, b, A) = (q1, ε) pop more
δ(q1, ε, A) = (q2, A) accept if A's left
Step 6: Accept = {q2}
Step 7: Test
'aab' → push A, push A, pop A, stack has A → accept ✓
'abb' → push A, pop A, then pop from empty → reject ✓
'a' → push A, stack has A → accept ✓
""")
# DESIGN PATTERNS
print("=== Common Design Patterns ===")
print("""
1. MATCHING PAIRS
Push for opening, pop for closing.
Example: a^n b^n, parentheses.
2. COUNTING COMPARISON
Push for one symbol, pop for another.
Accept based on stack state.
Example: i > j, i < j, i = j.
3. GUESSING MIDDLE
Use nondeterministic ε-transition.
Example: palindromes, ww^R.
4. MULTIPLE CHOICES
Nondeterministically push A or B.
Example: equal a's and b's (any order).
5. PHASE TRANSITIONS
State change marks phase boundary.
Example: a^n b^n, a^i b^j.
6. STACK MARKERS
Use special symbols for tracking.
Example: bottom marker Z, phase markers.
""")
# SYSTEMATIC CODE
print("=== Systematic Design in Code ===")
def design_pda_matching(symbols_open, symbols_close):
"""
Design PDA for matching pairs.
Example: a^n b^n uses open='a', close='b'
"""
return f"""
PDA for matching {symbols_open} with {symbols_close}:
States: q0 (reading {symbols_open}), q1 (reading {symbols_close}), q2 (accept)
Stack: Z, A
Transitions:
δ(q0, {symbols_open}, Z) = (q0, AZ)
δ(q0, {symbols_open}, A) = (q0, AA)
δ(q0, {symbols_close}, A) = (q1, ε)
δ(q1, {symbols_close}, A) = (q1, ε)
δ(q1, ε, Z) = (q2, Z)
Accept: {{q2}}
"""
print(design_pda_matching('a', 'b'))
print(design_pda_matching('(', ')'))📗 CFG ↔ PDA
🎯 The Fundamental Equivalence
Context-free grammars and pushdown automata are equivalent: every CFG has a corresponding PDA and vice versa. This is the CFG analog of the DFA/regex equivalence.
Equivalence Theorem
CFG → PDA: Nondeterministic PDA (empty stack). PDA → CFG: Construct grammar from PDA. Both directions: Constructive proofs. Same power: Both recognize CFLs. Key insight: Stack simulates derivation.
# CFG ↔ PDA Equivalence
print("=" * 60)
print("CFG ↔ PDA EQUIVALENCE")
print("=" * 60)
print("""
=== Theorem ===
L is context-free
⟺
L is accepted by some PDA
=== Proof Strategy ===
(⟹) CFG → PDA:
Construct a PDA that simulates leftmost derivations.
(⟸) PDA → CFG:
Construct a grammar where nonterminals represent
"from state p with stack A, go to state q".
Both constructions are constructive.
""")
# CFG TO PDA
print("=== CFG → PDA Construction ===")
print("""
Given CFG G = (V, Σ, R, S), construct PDA M:
M = ({q0, q1}, Σ, V ∪ Σ ∪ {Z}, δ, q0, Z, {q1})
Transitions:
1. START: δ(q0, ε, Z) = (q1, S Z)
Push start symbol S.
2. EXPAND: For each rule A → α in R:
δ(q1, ε, A) = (q1, α^R)
Push RHS in reverse (so leftmost is on top).
3. MATCH: For each terminal a ∈ Σ:
δ(q1, a, a) = (q1, ε)
Match input with top of stack.
4. ACCEPT: δ(q1, ε, Z) = (q1, ε)
Empty the stack to accept.
=== Key Idea ===
The PDA simulates a LEFTMOST derivation:
- Stack holds the current sentential form
- Top of stack = leftmost symbol
- Nonterminal → expand (nondeterministic)
- Terminal → match with input
=== Example ===
Grammar: S → aSb | ε
PDA transitions:
δ(q0, ε, Z) = (q1, SZ)
δ(q1, ε, S) = (q1, bSa) [S → aSb, reversed]
δ(q1, ε, S) = (q1, ε) [S → ε]
δ(q1, a, a) = (q1, ε)
δ(q1, b, b) = (q1, ε)
δ(q1, ε, Z) = (q1, ε)
""")
# CFG TO PDA IN CODE
print("=== CFG to PDA in Code ===")
class CFG:
def __init__(self, start, rules):
self.start = start
self.rules = rules
def __repr__(self):
lines = []
for lhs in sorted(self.rules.keys()):
for rhs in self.rules[lhs]:
rhs_str = ' '.join(rhs) if rhs else 'ε'
lines.append(f" {lhs} → {rhs_str}")
return '\n'.join(lines)
class PDA:
def __init__(self, states, input_alphabet, stack_alphabet,
transitions, start_state, start_stack, accept_states):
self.Q = states
self.Sigma = input_alphabet
self.Gamma = stack_alphabet
self.delta = transitions
self.q0 = start_state
self.Z0 = start_stack
self.F = accept_states
def __repr__(self):
lines = [f"PDA:"]
lines.append(f" States: {sorted(self.Q)}")
lines.append(f" Input: {sorted(self.Sigma)}")
lines.append(f" Stack: {sorted(self.Gamma)}")
lines.append(f" Start: {self.q0}, {self.Z0}")
lines.append(f" Accept: {sorted(self.F)}")
lines.append(f" Transitions:")
for (q, a, Z), results in sorted(self.delta.items()):
for (nq, push) in results:
push_str = ''.join(push) if push else 'ε'
lines.append(f" δ({q}, {a}, {Z}) = ({nq}, {push_str})")
return '\n'.join(lines)
def cfg_to_pda(cfg):
"""Convert CFG to PDA (accept by empty stack)."""
q0, q1 = 'q0', 'q1'
Z = 'Z'
states = {q0, q1}
stack_alphabet = set(cfg.rules.keys()) | {Z}
for prods in cfg.rules.values():
for rhs in prods:
stack_alphabet |= set(rhs)
transitions = {}
# Start: push S
transitions[(q0, 'ε', Z)] = [(q1, (cfg.start, Z))]
# Expand nonterminals
for lhs, prods in cfg.rules.items():
for rhs in prods:
key = (q1, 'ε', lhs)
if key not in transitions:
transitions[key] = []
# Push reversed RHS
transitions[key].append((q1, tuple(reversed(rhs))))
# Match terminals
terminals = set()
for prods in cfg.rules.values():
for rhs in prods:
for s in rhs:
if s not in cfg.rules:
terminals.add(s)
for a in terminals:
transitions[(q1, a, a)] = [(q1, ())]
# Accept: empty stack
transitions[(q1, 'ε', Z)] = [(q1, ())]
return PDA(states, terminals, stack_alphabet, transitions, q0, Z, {q1})
# Example
anbn_cfg = CFG('S', {
'S': [('a', 'S', 'b'), ()],
})
print("CFG:")
print(anbn_cfg)
pda = cfg_to_pda(anbn_cfg)
print("\nConverted PDA:")
print(pda)
# PDA TO CFG
print("""
=== PDA → CFG Construction ===
Given PDA M = (Q, Σ, Γ, δ, q0, Z0, F), construct CFG G:
Nonterminals: [p, A, q] for all p, q ∈ Q, A ∈ Γ
Meaning: "from state p with A on top of stack,
go to state q with A popped"
Start symbol: [q0, Z0, qf] for some qf ∈ F
(Or for all qf ∈ F, use a new start symbol)
Rules:
1. For each state q: [q, A, q] → ε
(Pop A and return to same state — no net change)
2. For transition δ(p, a, A) ∋ (r, B1 B2 ... Bk):
For all states q1, q2, ..., qk:
[p, A, qk] → a [r, B1, q1] [q1, B2, q2] ... [qk-1, Bk, qk]
3. Start: S → [q0, Z0, qf] for each qf ∈ F
=== Intuition ===
[p, A, q] generates strings that:
- Start from state p
- End in state q
- Net effect: pop A from stack
The rules simulate the PDA's computation.
""")
# EQUIVALENCE SUMMARY
print("=== Equivalence Summary ===")
print(f"{'Aspect':<30} {'CFG':<25} {'PDA':<25}")
print("-" * 80)
print(f"{'Type':<30} {'Grammar':<25} {'Machine':<25}")
print(f"{'Memory':<30} {'Rules':<25} {'Stack':<25}")
print(f"{'Nondeterminism':<30} {'In rules':<25} {'In transitions':<25}")
print(f"{'Derivation':<30} {'S ⇒* w':<25} {'(q0,w,Z0) ⊢* (q,ε,γ)':<25}")
print(f"{'Acceptance':<30} {'w ∈ L(G)':<25} {'Final state or empty stack':<25}")
print(f"{'Normal forms':<30} {'CNF, GNF':<25} {'DPDA (restricted)':<25}")
print(f"{'Membership':<30} {'CYK O(n³)':<25} {'O(n³) simulation':<25}")
print(f"{'Equivalence':<30} {'Undecidable':<25} {'Undecidable':<25}")
print(f"{'Emptiness':<30} {'Decidable':<25} {'Decidable':<25}")
# WHY EQUIVALENCE MATTERS
print("""
=== Why This Equivalence Matters ===
1. TWO PERSPECTIVES
- Grammar: generative (how to build strings)
- PDA: recognitive (how to accept strings)
Both describe the same class.
2. PROOF TECHNIQUES
- Prove properties of CFGs using PDAs
- Prove properties of PDAs using CFGs
- Choose whichever is easier
3. PARSING
- CFG specifies syntax
- PDA (parser) recognizes it
- Compilers use both views
4. THEORETICAL FOUNDATION
- Establishes CFG = PDA
- Type 2 in Chomsky hierarchy
- Central result in automata theory
5. PRACTICAL TOOLS
- Parser generators (Yacc, ANTLR)
- Take CFG, produce PDA-like parser
- This equivalence is why they work
""")🎯 Classic Examples
🎯 PDAs for Common Languages
These examples show PDAs for classic context-free languages. Each demonstrates a different stack technique.
Example Categories
Matching: anbn, parentheses. Comparing: i > j, i < j. Guessing: Palindromes. Nondeterministic: Equal counts. Multiple: wwR. Complex: Balanced with multiple types.
# Classic PDA Examples
print("=" * 60)
print("CLASSIC PDA EXAMPLES")
print("=" * 60)
# 1. a^n b^n
print("""
=== 1. L = { aⁿbⁿ | n ≥ 0 } ===
Stack technique: Push for a, pop for b.
States: q0 (reading a), q1 (reading b), q2 (accept)
Stack: Z, A
Transitions:
δ(q0, a, Z) = (q0, AZ)
δ(q0, a, A) = (q0, AA)
δ(q0, b, A) = (q1, ε)
δ(q1, b, A) = (q1, ε)
δ(q1, ε, Z) = (q2, Z)
Accept: {q2}
""")
# 2. Balanced Parentheses
print("""
=== 2. L = Balanced Parentheses ===
Stack technique: Push '(', pop on ')'.
States: q0 (reading), q1 (accept)
Stack: Z, (
Transitions:
δ(q0, '(', Z) = (q0, (Z)
δ(q0, '(', () = (q0, (()
δ(q0, ')', () = (q0, ε)
δ(q0, ε, Z) = (q1, Z)
Accept: {q1}
""")
# 3. Palindromes
print("""
=== 3. L = { w | w = wᴿ } ===
Stack technique: Push first half, guess middle, pop second half.
States: q0 (pushing), q1 (matching), q2 (accept)
Stack: Z, A, B
Transitions:
# Pushing
δ(q0, a, Z) = (q0, AZ)
δ(q0, a, A) = (q0, AA)
δ(q0, a, B) = (q0, AB)
δ(q0, b, Z) = (q0, BZ)
δ(q0, b, A) = (q0, BA)
δ(q0, b, B) = (q0, BB)
# Guess middle (nondeterministic!)
δ(q0, ε, Z) = (q1, Z) # even length
δ(q0, ε, A) = (q1, A) # odd, skip middle
δ(q0, ε, B) = (q1, B) # odd, skip middle
# Matching
δ(q1, a, A) = (q1, ε)
δ(q1, b, B) = (q1, ε)
δ(q1, ε, Z) = (q2, Z)
Accept: {q2}
""")
# 4. ww^R
print("""
=== 4. L = { wwᴿ | w ∈ {a,b}* } ===
Same as palindromes! (w w^R is a palindrome)
States: q0, q1, q2
Stack: Z, A, B
Transitions:
δ(q0, a, Z) = (q0, AZ)
δ(q0, a, A) = (q0, AA)
δ(q0, a, B) = (q0, AB)
δ(q0, b, Z) = (q0, BZ)
δ(q0, b, A) = (q0, BA)
δ(q0, b, B) = (q0, BB)
δ(q0, ε, Z) = (q1, Z)
δ(q0, ε, A) = (q1, A)
δ(q0, ε, B) = (q1, B)
δ(q1, a, A) = (q1, ε)
δ(q1, b, B) = (q1, ε)
δ(q1, ε, Z) = (q2, Z)
""")
# 5. a^i b^j, i > j
print("""
=== 5. L = { aⁱbʲ | i > j ≥ 0 } ===
Stack technique: Push a's, pop b's, accept if A's remain.
States: q0, q1, q2
Stack: Z, A
Transitions:
δ(q0, a, Z) = (q0, AZ)
δ(q0, a, A) = (q0, AA)
δ(q0, b, A) = (q1, ε)
δ(q1, b, A) = (q1, ε)
δ(q1, ε, A) = (q2, A) # A's remain → i > j
Accept: {q2}
""")
# 6. a^i b^j, i < j
print("""
=== 6. L = { aⁱbʲ | i < j } ===
Stack technique: Push a's, pop b's, accept if we run out of A's
and still have b's.
States: q0, q1, q2
Stack: Z, A
Transitions:
δ(q0, a, Z) = (q0, AZ)
δ(q0, a, A) = (q0, AA)
δ(q0, b, A) = (q1, ε)
δ(q1, b, A) = (q1, ε)
δ(q1, b, Z) = (q2, Z) # ran out of A's, more b's
Accept: {q2}
""")
# 7. Equal a's and b's (any order)
print("""
=== 7. L = { w | #a(w) = #b(w) } ===
Stack technique: Nondeterministic — push A for a, pop A for b,
OR push B for b, pop B for a.
States: q0, q1
Stack: Z, A, B
Transitions:
# Choose to push A or B on first symbol
δ(q0, a, Z) = {(q0, AZ), (q0, BZ)}
δ(q0, b, Z) = {(q0, AZ), (q0, BZ)}
# Push or pop depending on top
δ(q0, a, A) = {(q0, AA), (q0, ε)}
δ(q0, a, B) = {(q0, AB), (q0, ε)}
δ(q0, b, A) = {(q0, BA), (q0, ε)}
δ(q0, b, B) = {(q0, BB), (q0, ε)}
# Accept empty stack
δ(q0, ε, Z) = (q1, ε)
Accept: {q1} (or empty stack)
""")
# 8. WW
print("""
=== 8. L = { ww | w ∈ {a,b}* } ===
This is NOT context-free!
No PDA can recognize it.
Why? Need to remember the first half and compare
with the second half. But after reading the first half,
the stack has it in REVERSE order. When reading the
second half, we'd need it in FORWARD order.
The stack is LIFO — can't reverse!
=== Attempt ===
Push first half: stack = reverse(w)
Read second half: need to match w, but stack has reverse(w)
✗ Doesn't work!
This is why ww is not CFL, but ww^R is.
""")
# 9. a^n b^n c^n
print("""
=== 9. L = { aⁿbⁿcⁿ | n ≥ 0 } ===
This is NOT context-free!
No PDA can recognize it.
Why? Need TWO counters:
- Count a's and match with b's
- Count b's and match with c's
One stack = one counter.
After matching a's with b's, stack is empty.
Nothing left for c's.
=== Proof ===
Use the CFL pumping lemma.
Choose w = aᵖbᵖcᵖ.
Any pumping breaks the balance.
Contradiction. ∎
""")
# SUMMARY TABLE
print("=== Summary Table ===")
print(f"{'Language':<30} {'CFL?':<10} {'Stack Technique':<30}")
print("-" * 75)
print(f"{'aⁿbⁿ':<30} {'Yes':<10} {'Push a, pop b':<30}")
print(f"{'Balanced parens':<30} {'Yes':<10} {'Push (, pop )':<30}")
print(f"{'Palindromes':<30} {'Yes':<10} {'Push, guess, pop':<30}")
print(f"{'wwᴿ':<30} {'Yes':<10} {'Push, guess, pop':<30}")
print(f"{'aⁱbʲ, i>j':<30} {'Yes':<10} {'Push a, pop b, check':<30}")
print(f"{'aⁱbʲ, i🔒 Closure & Properties
🎯 What Operations Preserve CFLs?
Context-free languages (accepted by PDAs) have specific closure properties. They're closed under union, concatenation, and star, but not under intersection or complement.
Closure Properties
Closed under: Union, concat, star, reversal, homomorphism. NOT closed under: Intersection, complement. Closed under: ∩ regular. Proof: Constructive with PDAs/CFGs. Key example: anbncn. DCFL: Different closure properties.
# Closure Properties of CFLs
print("=" * 60)
print("CLOSURE PROPERTIES OF CFLs")
print("=" * 60)
print("""
=== Operations that PRESERVE Context-Freeness ===
✓ UNION: L1 ∪ L2
Construction: New start state with ε to both.
PDA: Nondeterministically choose which PDA to simulate.
✓ CONCATENATION: L1 · L2
Construction: Connect M1's accept to M2's start.
PDA: Simulate M1, then M2.
✓ KLEENE STAR: L*
Construction: New start/accept with ε-loops.
PDA: Loop back after accepting.
✓ REVERSAL: L^R
Construction: Reverse all transitions.
PDA: Reverse input and transitions.
✓ HOMOMORPHISM: h(L)
Construction: Apply h to terminals in rules.
PDA: Apply h to input symbols.
✓ INVERSE HOMOMORPHISM: h⁻¹(L)
More complex, but still CFL.
✓ INTERSECTION WITH REGULAR: L ∩ R
Construction: Product of PDA and DFA.
PDA: Run both machines in parallel.
=== Operations that DO NOT Preserve ===
✗ INTERSECTION: L1 ∩ L2
Counterexample:
L1 = { aⁿbⁿcᵐ | n,m ≥ 0 } (CFL)
L2 = { aⁿbᵐcᵐ | n,m ≥ 0 } (CFL)
L1 ∩ L2 = { aⁿbⁿcⁿ } (NOT CFL)
✗ COMPLEMENT: Σ* \ L
If CFLs were closed under complement,
then since they're closed under union,
they'd be closed under intersection
(by De Morgan). But they're not.
✗ DIFFERENCE: L1 \ L2
= L1 ∩ complement(L2) — both fail.
""")
# CONSTRUCTION: UNION
print("=== PDA Construction: Union ===")
print("""
Given PDAs M1, M2 for L1, L2:
Construct M for L1 ∪ L2:
- New start state q0
- ε-transition to M1's start
- ε-transition to M2's start
- Accept states = F1 ∪ F2
M nondeterministically chooses which PDA to run.
δ(q0, ε, Z) = {(q0_1, Z0_1 Z), (q0_2, Z0_2 Z)}
where q0_1, Z0_1 are M1's start, and similarly for M2.
M accepts if either M1 or M2 accepts.
""")
# CONSTRUCTION: CONCATENATION
print("=== PDA Construction: Concatenation ===")
print("""
Given PDAs M1, M2 for L1, L2:
Construct M for L1 · L2:
- Run M1
- When M1 accepts, ε-transition to M2's start
- Run M2
- Accept if M2 accepts
Key issue: M1's stack must be cleared before M2 starts,
or we need to handle two stacks.
Solution: Use accept-by-empty-stack.
- M1 accepts by empty stack
- Then M2 starts with empty stack
""")
# CONSTRUCTION: STAR
print("=== PDA Construction: Kleene Star ===")
print("""
Given PDA M for L:
Construct M* for L*:
- New start/accept state
- ε from new start to M's start
- ε from M's accept back to M's start
- ε from M's accept to new accept
This allows:
- Zero repetitions (ε-transition to accept)
- One or more repetitions (loop)
""")
# INTERSECTION WITH REGULAR
print("=== Intersection with Regular ===")
print("""
KEY THEOREM: CFL ∩ Regular = CFL
If L is context-free and R is regular,
then L ∩ R is context-free.
Proof (product construction):
- PDA M1 for L
- DFA M2 for R
- Product PDA:
* States: Q1 × Q2
* Transitions: simulate both
* Accept: both accept
Used for proving non-CFL:
If L ∩ R is not CFL (R regular),
then L is not CFL.
Example:
L = { aⁱbʲcᵏ | i < j < k }
R = a*b*c* (regular)
L ∩ R = { aⁱbʲcᵏ | i < j < k } = not CFL
Therefore L is not CFL.
""")
# DECISION PROBLEMS
print("=== Decision Problems for CFLs ===")
print(f"{'Problem':<30} {'Decidable?':<15} {'Complexity':<20}")
print("-" * 65)
print(f"{'Membership':<30} {'Yes':<15} {'O(n³) CYK':<20}")
print(f"{'Emptiness':<30} {'Yes':<15} {'O(|G|)':<20}")
print(f"{'Finiteness':<30} {'Yes':<15} {'O(|G|)':<20}")
print(f"{'Equivalence':<30} {'NO':<15} {'Undecidable':<20}")
print(f"{'Ambiguity':<30} {'NO':<15} {'Undecidable':<20}")
print(f"{'Universality':<30} {'NO':<15} {'Undecidable':<20}")
print(f"{'Intersection':<30} {'NO':<15} {'Not always CFL':<20}")
print(f"{'Complement':<30} {'NO':<15} {'Not always CFL':<20}")
print("""
=== Why Some Problems are Undecidable ===
Equivalence of CFLs:
If we could decide L1 = L2 for CFLs,
we could decide ambiguity.
But ambiguity is undecidable.
So equivalence is undecidable.
Ambiguity:
No algorithm can decide if a CFG is ambiguous.
This is a fundamental limitation.
Universality:
Given CFG G, is L(G) = Σ*?
Undecidable (related to equivalence).
For REGULAR languages:
All these problems are DECIDABLE!
""")
# DCFL CLOSURE
print("=== DCFL Closure Properties ===")
print(f"{'Operation':<30} {'CFL':<15} {'DCFL':<15}")
print("-" * 60)
print(f"{'Union':<30} {'✓':<15} {'✗':<15}")
print(f"{'Intersection':<30} {'✗':<15} {'✗':<15}")
print(f"{'Complement':<30} {'✗':<15} {'✓':<15}")
print(f"{'Concatenation':<30} {'✓':<15} {'✗':<15}")
print(f"{'Kleene Star':<30} {'✓':<15} {'✗':<15}")
print(f"{'Reversal':<30} {'✓':<15} {'✓':<15}")
print(f"{'∩ Regular':<30} {'✓':<15} {'✓':<15}")
print(f"{'Homomorphism':<30} {'✓':<15} {'✗':<15}")
print("""
Interesting: DCFLs are closed under complement
but NOT under union. This is opposite to CFLs!
This is because:
- DCFLs have deterministic parsers
- Complement just swaps accept/reject
- But union requires nondeterministic choice
""")
# SUMMARY
print("=== Summary ===")
print("""
CFL Closure:
✓ Union, concat, star, reversal, homomorphism
✓ Intersection with regular
✗ Intersection (of two CFLs)
✗ Complement
✗ Difference
DCFL Closure:
✓ Complement
✓ Reversal
✓ Intersection with regular
✗ Union
✗ Intersection
✗ Concat
✗ Star
Decision Problems:
✓ Membership (O(n³))
✓ Emptiness
✓ Finiteness
✗ Equivalence
✗ Ambiguity
✗ Universality
Key insight:
CFLs are "almost" closed under everything,
except intersection and complement.
This is why aⁿbⁿcⁿ is not CFL.
""")💼 Applications
🎯 PDAs in the Real World
PDAs are the theoretical foundation for parsing, which is used in every compiler, interpreter, and data processing tool. The stack is the key to handling nested structures.
Application Domains
Compilers: Syntax analysis. Parsers: LR, LL, recursive descent. Data formats: JSON, XML, YAML. Protocols: Message parsing. NLP: Sentence structure. Web: HTML parsing.
# Applications of PDAs
print("=" * 60)
print("APPLICATIONS OF PDAs")
print("=" * 60)
# 1. COMPILERS
print("""
=== 1. Compilers ===
The most important application.
Pipeline:
Source → Lexer → Tokens → Parser → AST → Codegen
Parser uses PDA:
- Stack holds partial parse tree
- Shift: push token
- Reduce: pop and build tree
- Accept: full parse
Example: LR parser for expressions
Stack: [E, +, T]
Input: id * id
Actions:
Shift id → [E, +, T, id]
Reduce F → id → [E, +, T, F]
Shift * → [E, +, T, F, *]
...
Tools:
- Yacc/Bison (LR)
- ANTLR (LL)
- JavaCC
""")
# 2. PARSING ALGORITHMS
print("""
=== 2. Parsing Algorithms ===
PDAs are the machine model for parsers:
TOP-DOWN (LL):
- Recursive descent
- Predictive parsing
- Stack holds expected symbols
BOTTOM-UP (LR):
- Shift-reduce parsing
- Stack holds processed symbols
- Actions: shift, reduce, accept, error
GENERAL:
- CYK (O(n³), CNF)
- Earley (O(n³), any CFG)
- GLR (generalized LR)
Each is a form of PDA simulation.
""")
# 3. DATA FORMATS
print("""
=== 3. Data Formats ===
JSON parsing:
- Nested objects and arrays
- Stack tracks nesting depth
- Push on { or [
- Pop on } or ]
XML parsing:
- Nested tags
- Stack tracks open tags
- Push on
- Pop on
- Accept if stack empty
YAML:
- Indentation-based nesting
- Stack tracks indentation levels
All require a stack (PDA) to parse correctly!
""")
# 4. NETWORK PROTOCOLS
print("""
=== 4. Network Protocols ===
HTTP message parsing:
- Headers are key-value pairs
- Body can be nested (JSON, XML)
- Stack helps with nested structures
Email (MIME):
- Multipart messages
- Nested parts
- Stack tracks nesting
Protocol buffers:
- Nested messages
- Stack for parsing
SIP, FTP, SMTP:
- Line-based but with nesting
""")
# 5. NATURAL LANGUAGE
print("""
=== 5. Natural Language Processing ===
Sentence parsing:
S → NP VP
NP → Det N | Det Adj N
VP → V NP | V PP
PP → P NP
Parse tree:
S
├── NP
│ ├── Det: The
│ └── N: cat
└── VP
├── V: sat
└── PP
├── P: on
└── NP
├── Det: the
└── N: mat
The stack tracks the current parse state.
""")
# 6. EXPRESSION EVALUATION
print("""
=== 6. Expression Evaluation ===
Calculator using stack:
Input: 3 + 4 * 2
Process:
Read 3 → push 3
Read + → push +
Read 4 → push 4
Read * → push *
Read 2 → push 2
End → pop 2, pop *, pop 4 → 4*2=8
→ pop 8, pop +, pop 3 → 3+8=11
Result: 11
This is exactly how PDAs work!
The stack holds operands and operators.
""")
# 7. BALANCED SYMBOLS
print("""
=== 7. Balanced Symbols ===
Checking balanced brackets in code:
if (x > 0) { if (y > 0) { ... } }
Stack tracks:
( → push (
) → pop (
{ → push {
} → pop {
If stack is empty at end → balanced.
This is a classic PDA application.
""")
# 8. TOOLS
print("""
=== 8. Tools ===
PARSER GENERATORS:
- Yacc/Bison (C, LR)
- ANTLR (Java, Python, ...)
- JavaCC (Java)
- Happy (Haskell)
- PLY (Python)
PARSING LIBRARIES:
- Lark (Python)
- pyparsing (Python)
- Parsec (Haskell)
- Nom (Rust)
All generate PDA-like parsers from grammars.
""")
# 9. SUMMARY TABLE
print("=== Application Summary ===")
print(f"{'Domain':<25} {'Use Case':<30} {'PDA Role':<25}")
print("-" * 80)
print(f"{'Compilers':<25} {'Syntax analysis':<30} {'Stack-based parsing':<25}")
print(f"{'Parsers':<25} {'LL, LR, CYK':<30} {'Stack simulation':<25}")
print(f"{'Data formats':<25} {'JSON, XML':<30} {'Nesting tracking':<25}")
print(f"{'Protocols':<25} {'Message parsing':<30} {'Nested structure':<25}")
print(f"{'NLP':<25} {'Sentence parsing':<30} {'Syntactic stack':<25}")
print(f"{'Calculators':<25} {'Expression eval':<30} {'Operand stack':<25}")
print(f"{'Code analysis':<25} {'Bracket matching':<30} {'Balance checking':<25}")
# 10. CASE STUDY: PYTHON PARSER
print("""
=== Case Study: Python's Parser ===
Python uses a PEG parser (since 3.9).
Pipeline:
1. Tokenizer (lexer) → tokens
2. PEG parser → AST
3. Compiler → bytecode
The PEG parser uses a stack internally
to handle recursion and backtracking.
Key insight:
Even though Python uses PEG (not pure PDA),
the underlying mechanism is stack-based,
just like a PDA.
""")🚀 Advanced Topics
🎯 Beyond Basic PDAs
PDAs are the foundation for many advanced topics: deterministic parsing, tree automata, visibly pushdown automata, and higher-order pushdown automata.
Advanced Directions
DPDA: Deterministic parsing. VPA: Visibly pushdown. HOPDA: Higher-order. Tree PDA: Tree languages. Quantum PDA: Quantum. Weighted PDA: Probabilistic.
# Advanced Topics
print("=" * 60)
print("ADVANCED TOPICS")
print("=" * 60)
# 1. VISIBLY PUSHDOWN AUTOMATA
print("""
=== 1. Visibly Pushdown Automata (VPA) ===
A VPA is a PDA where the stack operation
is determined by the input symbol.
Input alphabet is partitioned:
Σ = Σ_call ∪ Σ_return ∪ Σ_internal
- Call symbols: push
- Return symbols: pop
- Internal symbols: no stack change
Properties:
- Closed under union, intersection, complement
- Determinizable
- Good for structured data (XML, JSON)
Used in:
- XML validation
- Program analysis
- Streaming data
""")
# 2. HIGHER-ORDER PUSHDOwn
print("""
=== 2. Higher-Order Pushdown Automata ===
A k-order PDA has k nested stacks:
- 1-PDA: regular stack
- 2-PDA: stack of stacks
- k-PDA: k levels
Power:
1-PDA = CFL
2-PDA ⊋ CFL
k-PDA grows with k
∞-PDA = TM
Used in:
- Program analysis (recursion)
- Model checking
- Higher-order programs
""")
# 3. TREE PUSHDOWN AUTOMATA
print("""
=== 3. Tree Pushdown Automata ===
PDAs that process trees instead of strings.
Applications:
- XML schema validation
- Compiler ASTs
- Term rewriting
- Program analysis
Related to:
- Tree automata
- Tree grammars
- Regular tree languages
""")
# 4. QUANTUM PDAs
print("""
=== 4. Quantum PDAs ===
Quantum version of PDAs:
- States are quantum superpositions
- Transitions are unitary
- Stack is quantum
Research area:
- Quantum computing theory
- Potential speedups
- Still experimental
Challenges:
- Decoherence
- Measurement
- Error correction
""")
# 5. WEIGHTED PDAs
print("""
=== 5. Weighted PDAs ===
Each transition has a weight (cost, probability).
Used in:
- Probabilistic parsing (PCFGs)
- Speech recognition
- Machine translation
- NLP
Algorithms:
- Inside-outside algorithm
- Viterbi parsing
- Weighted CYK
""")
# 6. ALTERNATING PDAs
print("""
=== 6. Alternating PDAs ===
Alternating automata:
- States are existential or universal
- Existential: accept if some path accepts
- Universal: accept if all paths accept
Power:
Alternating PDA = TM
This shows how adding alternation
can dramatically increase power.
Used in:
- Formal verification
- Model checking
- Game theory
""")
# 7. PUSHDOWN SYSTEMS
print("""
=== 7. Pushdown Systems ===
A pushdown system (PDS) is a PDA without input.
PDS = (Q, Γ, δ)
δ ⊆ Q × Γ × Q × Γ*
Configurations: Q × Γ*
Used in:
- Program analysis (recursion)
- Model checking
- Interprocedural analysis
Key algorithms:
- Pre* and Post* (reachability)
- Saturation
- Automata-based
These algorithms are used in:
- SLAM project (Microsoft)
- Blast
- Software verification
""")
# 8. MODERN PARSING
print("""
=== 8. Modern Parsing ===
NEURAL PARSING:
- Deep learning for parsing
- No explicit grammar
- Still uses stack-like mechanisms
TRANSFORMER PARSING:
- Attention-based
- Parallel parsing
- Fast, accurate
HYBRID:
- PDA + neural
- Best of both worlds
INCREMENTAL:
- Online parsing
- Interactive systems
- Left-to-right
PROBABILISTIC:
- PCFGs
- Weighted PDAs
- Statistical parsing
""")
# 9. OPEN PROBLEMS
print("""
=== 9. Open Problems ===
1. DETERMINIZABILITY
Given a PDA, is there an equivalent DPDA?
This is decidable but complex.
2. UNIVERSALITY
Is L(M) = Σ* for a PDA M?
Undecidable.
3. EQUIVALENCE
Is L(M1) = L(M2)?
Undecidable.
4. AMBIGUITY
Is a PDA ambiguous?
Undecidable.
5. EFFICIENT PARSING
Can we parse any CFG in O(n²)?
Valiant: O(n^ω), ω < 2.373.
Lower bound unknown.
6. QUANTUM PARSING
Can quantum PDAs parse faster?
Research area.
""")
# 10. FINAL THOUGHTS
print("""
=== Final Thoughts ===
PDAs are:
✓ FOUNDATIONAL
Model for context-free languages.
✓ EQUIVALENT TO CFGs
Same power, different perspective.
✓ PRACTICAL
Used in every compiler.
✓ EXTENSIBLE
VPA, HOPDA, tree PDA, quantum PDA.
✓ THEORETICAL
Rich theory, interesting properties.
✓ EVOLVING
Modern parsing, verification, NLP.
Key takeaway:
The stack is the key to context-free power.
One stack = context-free.
Two stacks = Turing machine.
"Pushdown automata: the sweet spot
between finite automata and Turing machines."
— Anonymous
Go build a parser!
""")
# QUICK REFERENCE
print("""
=== Quick Reference ===
PDA: M = (Q, Σ, Γ, δ, q₀, Z₀, F)
Q: states
Σ: input alphabet
Γ: stack alphabet
δ: Q × (Σ∪{ε}) × Γ → P(Q × Γ*)
q₀: start state
Z₀: initial stack symbol
F: accept states
Acceptance:
Final state: end in F
Empty stack: stack empty
Equivalence:
CFG ⟺ PDA
DPDA:
At most one transition per configuration.
Accepts DCFLs (strict subset of CFLs).
Closure:
✓ Union, concat, star
✗ Intersection, complement
Decision:
Membership: Decidable O(n³)
Emptiness: Decidable
Equivalence: Undecidable
Ambiguity: Undecidable
""")