Introduction
A recursive descent parser is a top-down parser that uses a set of mutually recursive functions to process input according to a grammar. Its time complexity depends on the grammar: O(n) for LL(1) grammars (no backtracking), O(n^k) for grammars with bounded backtracking, and O(2^n) in the worst case for ambiguous grammars with unbounded backtracking. Understanding these complexity classes helps you design grammars that parse efficiently.
How Recursive Descent Parsers Work
Each non-terminal in the grammar becomes a function. The parser starts with the start symbol and recursively calls functions for each production rule:
1# Grammar: expr → term (('+' | '-') term)*
2# term → factor (('*' | '/') factor)*
3# factor → NUMBER | '(' expr ')'
4
5class Parser:
6 def __init__(self, tokens):
7 self.tokens = tokens
8 self.pos = 0
9
10 def peek(self):
11 return self.tokens[self.pos] if self.pos < len(self.tokens) else None
12
13 def consume(self, expected=None):
14 token = self.tokens[self.pos]
15 if expected and token != expected:
16 raise SyntaxError(f"Expected {expected}, got {token}")
17 self.pos += 1
18 return token
19
20 def expr(self):
21 result = self.term()
22 while self.peek() in ('+', '-'):
23 op = self.consume()
24 right = self.term()
25 result = (op, result, right)
26 return result
27
28 def term(self):
29 result = self.factor()
30 while self.peek() in ('*', '/'):
31 op = self.consume()
32 right = self.factor()
33 result = (op, result, right)
34 return result
35
36 def factor(self):
37 token = self.peek()
38 if token == '(':
39 self.consume('(')
40 result = self.expr()
41 self.consume(')')
42 return result
43 return ('num', self.consume())
O(n) — LL(1) Grammars (No Backtracking)
When the parser can decide which production to use by looking at just one token ahead, no backtracking is needed. Each token is examined once:
1Grammar (LL(1)):
2 stmt → if_stmt | while_stmt | assign_stmt
3 if_stmt → 'if' expr 'then' stmt
4 while_stmt → 'while' expr 'do' stmt
5 assign_stmt → ID '=' expr
The parser sees the first token ('if', 'while', or an identifier) and immediately knows which rule to apply. Every token is consumed exactly once, giving O(n) time and O(d) space where d is the maximum nesting depth.
1def stmt(self):
2 if self.peek() == 'if':
3 return self.if_stmt() # No backtracking
4 elif self.peek() == 'while':
5 return self.while_stmt() # Determined by first token
6 else:
7 return self.assign_stmt()
O(n^k) — Bounded Backtracking
When the parser must look ahead k tokens to choose between alternatives, it may backtrack up to k tokens per decision point:
Grammar (LL(k)):
declaration → type ID ';'
function → type ID '(' params ')' block
Both start with type ID, so the parser must look ahead to the third token (; vs () to decide. With k-token lookahead at each of n positions, worst case is O(n * k) ≈ O(n) for fixed k.
1def declaration_or_function(self):
2 saved = self.pos
3 try:
4 return self.function_def() # Try function first
5 except SyntaxError:
6 self.pos = saved # Backtrack
7 return self.declaration() # Try declaration
O(2^n) — Unbounded Backtracking
Ambiguous or highly non-deterministic grammars can cause exponential blowup:
1Grammar (ambiguous):
2 S → A | B
3 A → 'a' A 'b' | 'a' 'b'
4 B → 'a' B 'b' 'b' | 'a' 'b' 'b'
The parser may try rule A, consume many tokens, fail near the end, backtrack to the beginning, and try rule B — repeatedly for nested structures. Each backtrack doubles the work.
Memoization: Packrat Parsing — O(n)
Adding memoization converts any recursive descent parser to O(n) time at the cost of O(n * G) space (where G is the number of grammar rules):
1class PackratParser(Parser):
2 def __init__(self, tokens):
3 super().__init__(tokens)
4 self.memo = {}
5
6 def memoize(self, rule_name, func):
7 key = (rule_name, self.pos)
8 if key in self.memo:
9 result, new_pos = self.memo[key]
10 self.pos = new_pos
11 return result
12
13 result = func()
14 self.memo[key] = (result, self.pos)
15 return result
16
17 def expr(self):
18 return self.memoize('expr', super().expr)
Packrat parsing guarantees linear time by never re-parsing the same rule at the same position.
Complexity Summary
| Grammar Type | Backtracking | Time | Space | Example |
| LL(1) | None | O(n) | O(d) | Most programming languages |
| LL(k) | Bounded | O(n*k) | O(d) | Some declarations |
| PEG (packrat) | Memoized | O(n) | O(n*G) | Any PEG grammar |
| Ambiguous | Unbounded | O(2^n) | O(n) | Natural language grammars |
Where n = input length, d = max nesting depth, G = number of grammar rules.
Eliminating Backtracking
Left Factoring
1Before (requires backtracking):
2 stmt → ID '=' expr ';'
3| ID '(' args ')' ';' After (LL(1)): stmt → ID stmt_tail stmt_tail → '=' expr ';' | '(' args ')' ';' ``` ### Eliminating Left Recursion ``` Before (causes infinite recursion in recursive descent): expr → expr '+' term | term After (right-recursive, works in recursive descent): expr → term expr_rest expr_rest → '+' term expr_rest |
4| --- | --- | --- |
5| ANTLR | LL(\*) with prediction | O(n) typical | Java, Python, C++ |
6| PEG.js / Pest | PEG (packrat) | O(n) guaranteed | JS / Rust |
7| Yacc/Bison | LALR(1) | O(n) | C/C++ |
8| Hand-written | Recursive descent | O(n) if LL(1) | Any |
9
10## Common Pitfalls
11
12* **Left recursion**: Recursive descent parsers cannot handle left-recursive rules (`A → A '+' B`). They cause infinite recursion. Always transform to right recursion or iterative loops.
13* **Backtracking performance**: Naive backtracking can make an O(n) grammar parse in O(n²) or worse. Use predictive parsing (LL(1)) or memoization (packrat) to avoid this.
14* **Stack overflow**: Deeply nested input (e.g., `(((((...))))`) causes deeply nested recursive calls. For very deep nesting, convert to an iterative parser with an explicit stack.
15* **Ambiguous grammars**: If a grammar has multiple valid parse trees for the same input, a recursive descent parser picks one arbitrarily (usually the first alternative). This may not be the intended parse.
16* **Error recovery**: Simple recursive descent parsers give poor error messages on syntax errors. Add synchronization points (skip to next `;` or `}`) for better error reporting.
17
18## Summary
19
20* Recursive descent parsers have O(n) time complexity for LL(1) grammars (one token lookahead, no backtracking)
21* Backtracking increases complexity: bounded backtracking is O(n\*k), unbounded can be O(2^n)
22* Packrat parsing (memoization) guarantees O(n) time for any grammar at the cost of O(n\*G) memory
23* Eliminate left recursion and apply left factoring to make grammars LL(1)-compatible
24* Most practical programming language grammars are LL(1) or LL(k) with small k, yielding linear parse time