Recursive Descent Parser
Parser Complexity
Syntax Analysis
Compiler Design
Computational Linguistics

On complexity of recursive descent parsers

Interview Questions practice on Codemia

Over 8,000 real interview questions from top companies, searchable by company and role.

Browse interview questions

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:

python
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.

python
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.

python
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  SA | 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):

python
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 TypeBacktrackingTimeSpaceExample
LL(1)NoneO(n)O(d)Most programming languages
LL(k)BoundedO(n*k)O(d)Some declarations
PEG (packrat)MemoizedO(n)O(n*G)Any PEG grammar
AmbiguousUnboundedO(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() 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

Free course
Beginner
7 lessons
2 hours
Tackling System Design Interview Problems

A short course that equips you with the skills to approach system design interviews methodically.

Start the free course
Track what you have practised

A free account saves your progress, solutions and study plan across every problem on Codemia.

Interview Questions practice on Codemia

Over 8,000 real interview questions from top companies, searchable by company and role.

Browse interview questions

All Rights Reserved.