Recursion & Recursive Data
Recursion is not just a programming trick — it is the natural language of self-referential structure, and mastering it means learning to see problems as they really are.
Overview
Recursion sits at the philosophical heart of computer science. Before it is a technique, it is an idea: that the most powerful way to describe something is often in terms of itself. A list is either empty or a head attached to a list. A tree is either a leaf or a node with subtree children. An algorithm over such structures mirrors that same self-referential structure — and this is not coincidence. It is design.
In this module we move carefully from the intuition to the machinery. We distinguish the procedure you write (the syntactic, textual definition) from the process it generates at runtime (the actual sequence of computation, the stack frames, the substitutions). This distinction — introduced with crystal clarity in SICP — is one of the deepest and most practically useful ideas in all of CS. Two procedures that compute the same answer can generate radically different processes: one that grows a tower of deferred work, and one that stays flat and constant-space.
We then extend to tree recursion, where the call graph is no longer a chain but a branching tree, and show both its expressive power (elegant solutions to problems that seem hard) and its danger (exponential blowup from repeated subproblems — the bridge to dynamic programming). We build recursive data structures — linked lists, binary trees, expression trees — and see that recursive procedures over them fall out almost automatically from the structure of the data. Finally, we interrogate the recursion-versus-iteration question honestly: they are equivalent in expressive power, but tail-call optimization (TCO) is the key that makes recursive style practical even for large inputs, and understanding it illuminates how function calls really work on hardware.
By the end, recursion will feel less like a confusing trick and more like putting on the right glasses: the world snaps into focus.
Recursion is not an exotic feature — it is everywhere in the foundations of computing. Compilers parse recursive grammars. Operating systems traverse recursive directory trees. Databases execute recursive queries (CTEs). Functional languages like Haskell and Lisp are built almost entirely on recursive definitions. Even imperative loop constructs are ultimately just restricted forms of recursion in disguise. Understanding recursion deeply means understanding induction (its mathematical twin), divide-and-conquer algorithms, the structure of data, and the mechanics of the call stack.
In real software engineering, the ability to recognize a recursive structure in a problem — and then decide whether to solve it with explicit recursion, memoized recursion, iteration, or a stack-based simulation — is a mark of genuine CS literacy. Interview problems, competitive programming, system design, and language implementation all demand it. More broadly, recursion trains you to think compositionally: to trust that the "rest of the problem" will be handled correctly if you handle only the current step correctly. That trust, grounded in precise reasoning by induction, is the foundation of writing correct programs at all.
Recursion Tree for fib(5)
The call tree for fib(5) has 15 nodes. fib(3) is computed twice, fib(2) three times — overlapping subproblems cause exponential blowup. Memoization prunes all duplicate subtrees, reducing calls to O(n).
The concepts
01Recursive Procedures vs Recursive Processes
The most important conceptual distinction in this module is between the procedure (the code you write) and the process (the computation that unfolds at runtime).
A recursive procedure is one that calls itself syntactically. But a recursive procedure can generate either a recursive process (one that builds up a chain of deferred operations, requiring stack space proportional to the depth) or an iterative process (one that stays flat, using only constant auxiliary space beyond the accumulator).
Consider computing factorial:
# Recursive process: builds deferred multiplications
def fact_rec(n):
if n == 0:
return 1
return n * fact_rec(n - 1) # must remember 'n *' for each call
The call fact_rec(4) expands like:
fact_rec(4)
4 * fact_rec(3)
4 * (3 * fact_rec(2))
4 * (3 * (2 * fact_rec(1)))
4 * (3 * (2 * (1 * fact_rec(0))))
4 * (3 * (2 * (1 * 1)))
...
24
Notice the growing triangle of pending work. Now consider:
# Iterative process (via tail-recursive procedure)
def fact_iter(n, acc=1):
if n == 0:
return acc
return fact_iter(n - 1, acc * n) # no deferred work: acc carries state
The call fact_iter(4) stays flat:
fact_iter(4, 1)
fact_iter(3, 4)
fact_iter(2, 12)
fact_iter(1, 24)
fact_iter(0, 24)
24
Both are recursive procedures, but they generate fundamentally different processes. SICP calls this one of the key lessons: "syntactic sugar" does not change the underlying process shape. An iterative process can be described by a recursive procedure — the structure of the process is determined by when the recursive call returns value versus when it defers work.
def fact_rec(n):
if n == 0:
return 1
return n * fact_rec(n - 1) # deferred: 'n *' must be remembered
def fact_iter(n, acc=1):
if n == 0:
return acc
return fact_iter(n - 1, acc * n) # no deferred work
02The Substitution Model and the Call Stack
To understand why a recursive process uses O(n) stack space, you need a mental model of how function calls work. The substitution model (from SICP) is the simplest: to evaluate f(x), substitute x into the body of f and evaluate the result. Each unevaluated expression is 'deferred' until its operands are ready.
At the hardware level, each function call pushes an activation record (stack frame) onto the call stack. The frame contains the local variables, the return address, and the arguments. When a function returns, its frame is popped. For a deeply recursive call like fact_rec(10000), you push 10000 frames before you pop any — this can overflow the stack (Python's default limit is ~1000 frames; C's stack is typically 1–8 MB).
An iterative process, by contrast, only needs one frame at a time (assuming the language implements tail-call optimization — more on this below). The current frame is reused rather than stacked.
The key insight: deferred operations are debt. Each n * fact_rec(n-1) is a note saying "I still owe a multiplication after the recursive call finishes." That note must be stored somewhere — and the call stack is where the hardware stores it. Eliminating all deferred operations eliminates the debt, and the call stack stays flat.
This is also why stack overflows happen on recursive algorithms over large inputs, and why understanding this model lets you reason about memory usage precisely.
# Trace the stack depth for fact_rec(4):
# Frame 4: n=4, waiting for fact_rec(3)
# Frame 3: n=3, waiting for fact_rec(2)
# Frame 2: n=2, waiting for fact_rec(1)
# Frame 1: n=1, waiting for fact_rec(0)
# Frame 0: n=0, returns 1 <-- deepest
# Frame 1 resumes: returns 1*1 = 1
# Frame 2 resumes: returns 2*1 = 2
# Frame 3 resumes: returns 3*2 = 6
# Frame 4 resumes: returns 4*6 = 24
03Tree Recursion
Linear recursion makes at most one recursive call per activation. Tree recursion makes two or more, causing the call graph to branch into a tree.
The canonical example is the Fibonacci sequence:
def fib(n):
if n <= 1:
return n
return fib(n-1) + fib(n-2)
The call tree for fib(5) has 15 calls (and fib(n) requires O(φ^n) calls where φ = 1.618...). This is exponential, despite the code looking innocent.
Why? Because fib(3) is computed twice, fib(2) three times, fib(1) five times. The tree recursion is doing redundant work on an exponential scale.
Yet tree recursion is not inherently bad. For problems where subproblems truly do not overlap, it is the right approach:
- Generating all permutations of a list: each branch explores one choice for the first element, then recurses on the rest.
- Traversing a binary tree: naturally calls left and right subtrees.
- Counting the number of ways to make change for a given amount.
The cure for overlapping subproblems is memoization: cache the result of each distinct call. This converts fib(n) from O(2^n) to O(n) — the bridge to dynamic programming.
from functools import lru_cache
@lru_cache(maxsize=None)
def fib_memo(n):
if n <= 1:
return n
return fib_memo(n-1) + fib_memo(n-2) # O(n) time, O(n) space
The fundamental lesson: tree recursion is structurally natural for tree-shaped problems, but you must recognize when subproblems overlap and add memoization.
# Naive: O(phi^n) time
def fib(n):
if n <= 1:
return n
return fib(n-1) + fib(n-2)
# Memoized: O(n) time
from functools import lru_cache
@lru_cache(maxsize=None)
def fib_memo(n):
if n <= 1:
return n
return fib_memo(n-1) + fib_memo(n-2)
04Recursive Data Structures
The deepest reason recursion matters in CS is that data itself is naturally recursive. A linked list is either:
- Empty (nil), or
- A node holding a value and a pointer to another linked list.
A binary tree is either:
- A leaf (empty), or
- A node holding a value, a left binary tree, and a right binary tree.
This recursive definition is not just mathematical notation — it directly prescribes how to write functions over these structures. The base case of the recursion corresponds to the base case of the data definition; the recursive case corresponds to the recursive case of the data definition. This principle is called structural recursion or structural induction.
class Node:
def __init__(self, val, next=None):
self.val = val
self.next = next
def length(node): # mirrors the data definition exactly
if node is None: # base: empty list
return 0
return 1 + length(node.next) # recursive: rest is a list
def sum_tree(node):
if node is None: # base: empty tree
return 0
return node.val + sum_tree(node.left) + sum_tree(node.right)
In functional languages like Haskell, this is made completely explicit with algebraic data types:
data List a = Nil | Cons a (List a)
length :: List a -> Int
length Nil = 0
length (Cons _ xs) = 1 + length xs
The code and the type definition are structurally isomorphic — the recursion in the code mirrors the recursion in the type. Once you see this, writing correct recursive functions becomes almost mechanical: decompose the data, handle each case, trust the recursion on sub-data.
# Binary tree in Python
class TreeNode:
def __init__(self, val, left=None, right=None):
self.val = val
self.left = left
self.right = right
def height(node):
if node is None:
return 0
return 1 + max(height(node.left), height(node.right))
def inorder(node):
if node is None:
return []
return inorder(node.left) + [node.val] + inorder(node.right)
05Mutual Recursion
Two (or more) functions can be mutually recursive: A calls B, and B calls A. This is not a curiosity — it arises naturally in parsing and in some elegant state-machine encodings.
A clean example: testing whether a non-negative integer is even or odd without the % operator.
def is_even(n):
if n == 0:
return True
return is_odd(n - 1)
def is_odd(n):
if n == 0:
return False
return is_even(n - 1)
This is simultaneously silly (there is a better way) and profound: it shows that mutual recursion is equivalent to a finite state machine. is_even is the "in-even-state" function; is_odd is the "in-odd-state" function. They ping-pong between states, decrementing n at each step.
In compiler design, mutually recursive functions arise in recursive-descent parsers. A grammar rule like expression ::= term ('+' term)* leads to mutually recursive parse_expression and parse_term functions.
Mathematically, mutual recursion is covered by fixed-point theorems: the pair (is_even, is_odd) is the unique pair of functions satisfying both definitions simultaneously.
def is_even(n):
if n == 0: return True
return is_odd(n - 1)
def is_odd(n):
if n == 0: return False
return is_even(n - 1)
# Each function encodes one state of a two-state FSM
06Tail Calls and Tail-Call Optimization (TCO)
A tail call is a function call that is the very last action of a function — the caller has nothing to do after the callee returns except return that same value. In fact_iter, the last action is return fact_iter(n-1, acc*n) — a tail call.
Why does this matter? Because when a call is in tail position, the caller's stack frame is no longer needed. The callee can reuse the caller's frame rather than allocating a new one. This is tail-call optimization (TCO), and when a language implements it, tail-recursive functions run in O(1) stack space — they are equivalent to loops.
Scheme and Haskell guarantee TCO by specification. JavaScript (ES6+) mandates it in strict mode. Python famously does NOT implement TCO (Guido van Rossum deliberately omitted it); C/C++ compilers often apply it as an optimization but do not guarantee it.
How to convert a recursive process to a tail-recursive one: introduce an accumulator argument that carries the "state" that would otherwise be deferred on the stack.
; Scheme: guaranteed TCO
(define (fact-iter n acc)
(if (= n 0)
acc
(fact-iter (- n 1) (* n acc)))) ; tail call
(fact-iter 1000000 1) ; runs in O(1) space in Scheme
The key test: is the recursive call in tail position? If after the call you still need to do work (like multiply by n), it is NOT in tail position. If the call result is returned directly, it IS in tail position.
Even without TCO in the language, understanding tail calls is valuable: it tells you exactly when you can safely replace recursion with a loop, and it underpins continuation-passing style (CPS), trampolining, and other advanced techniques.
; Scheme (TCO guaranteed by standard)
(define (sum-iter lst acc)
(if (null? lst)
acc
(sum-iter (cdr lst) (+ acc (car lst))))) ; tail call
; Equivalent C with a loop (same process shape)
int sum_iter(int* arr, int n, int acc) {
if (n == 0) return acc;
return sum_iter(arr + 1, n - 1, acc + arr[0]); // tail call
// gcc -O2 will optimize this to a loop
}
07Recursion vs Iteration: Equivalence and Trade-offs
A fundamental theorem of computation: recursion and iteration are equally expressive. Any function computable iteratively is computable recursively, and vice versa. This is a consequence of Church's thesis — both are Turing-complete.
Yet in practice, the choice matters:
When recursion is clearer:
- The problem is naturally tree-structured (tree traversal, parsing, divide-and-conquer).
- The algorithm mirrors the data definition (structural recursion).
- The base cases and recursive cases correspond directly to the problem's mathematical definition.
When iteration is clearer (or necessary):
- The problem is inherently sequential (reading a file line by line).
- The language lacks TCO and the depth is large (Python over large lists).
- Performance is critical and you want to avoid function-call overhead.
Converting recursion to iteration mechanically: any recursive function can be made iterative by maintaining an explicit stack (mimicking the call stack in user space). This is exactly what iterative DFS does vs. recursive DFS:
# Recursive DFS
def dfs_rec(node, visited):
visited.add(node)
for neighbor in node.neighbors:
if neighbor not in visited:
dfs_rec(neighbor, visited)
# Iterative DFS (explicit stack)
def dfs_iter(start):
visited, stack = set(), [start]
while stack:
node = stack.pop()
if node not in visited:
visited.add(node)
stack.extend(node.neighbors)
The iterative version uses an explicit stack; the recursive version uses the call stack. They are doing the same thing — the call stack is just hidden.
The deep lesson: iteration is recursion with a fixed call depth of 1 (you only ever call the current frame again, not a new frame). Tail recursion makes this precise.
# Recursive DFS (natural, mirrors graph structure)
def dfs_rec(node, visited=None):
if visited is None: visited = set()
visited.add(node)
for nb in node.neighbors:
if nb not in visited:
dfs_rec(nb, visited)
return visited
# Iterative DFS (explicit stack = manual call stack)
def dfs_iter(start):
visited, stack = set(), [start]
while stack:
node = stack.pop()
if node not in visited:
visited.add(node)
stack.extend(node.neighbors)
return visited
08Induction as the Proof Technique for Recursive Code
Recursion and mathematical induction are two faces of the same coin. Proving a recursive function correct follows exactly the same structure as a proof by induction:
- Base case: Verify the function is correct on the base case(s) (e.g.,
n=0). - Inductive step: Assume the function is correct for smaller inputs (the inductive hypothesis). Prove it is correct for the current input given that assumption.
This is not a metaphor — it is the formal verification method. Example: prove length(lst) returns the number of nodes.
- Base case:
lstisNone.length(None) = 0. A null list has 0 nodes. Correct. - Inductive step: Assume
length(lst.next)correctly returns the number of nodes inlst.next. Thenlength(lst) = 1 + length(lst.next) = 1 + (number of nodes in lst.next) = number of nodes in lst. Correct.
The trust you place in the recursive call is exactly the inductive hypothesis. When you write return 1 + length(node.next), you are saying "I trust, by induction, that length(node.next) is correct." Your only job is to make the current step correct given that trust.
This is why structural recursion over well-founded data structures always terminates: each recursive call is on strictly smaller data (shorter list, smaller tree), so by well-founded induction, the recursion must bottom out at the base case.
Learning to reason this way — trusting the recursive call as an inductive hypothesis, proving only the current step — is the core skill of writing correct recursive code.
# Correctness proof sketch for sum of a list:
def sum_list(lst):
if not lst: # base case: empty list
return 0 # sum of empty list is 0. Correct.
# IH: sum_list(lst[1:]) == sum of lst[1:]
return lst[0] + sum_list(lst[1:])
# Therefore: lst[0] + sum(lst[1:]) == sum(lst). Correct.
09Counting Change and Combinatorial Tree Recursion
A beautiful non-trivial example of tree recursion is the counting change problem from SICP: given n cents and a list of coin denominations, how many distinct ways can you make change?
The key insight is a purely recursive decomposition:
- Ways to make change for n using coins [c, ...] = (ways using at least one c) + (ways using no c at all).
- The first term recurses on
n - cwith the same coin list (we used one c). - The second term recurses on
nwith the rest of the coins (we skip c forever).
def count_change(n, coins=(50, 25, 10, 5, 1)):
if n == 0:
return 1 # exactly one way: use no more coins
if n < 0 or not coins:
return 0 # impossible
return (count_change(n - coins[0], coins) + # use first coin at least once
count_change(n, coins[1:])) # never use first coin
This is elegant but exponential. The recursive call tree branches at every step. For count_change(100), there are overlapping subproblems: count_change(50, (10,5,1)) is computed many times.
Adding memoization (or converting to bottom-up DP) makes it polynomial. The counting-change problem is one of the most instructive examples because:
- The tree recursion falls out naturally from a purely logical decomposition.
- The exponential blowup is obvious once you draw the tree.
- Memoization is the surgical fix — the logical structure remains intact.
This is the template for recognizing DP problems: if a tree-recursive solution has overlapping subproblems, memoize it.
from functools import lru_cache
@lru_cache(maxsize=None)
def count_change(n, coins=(50, 25, 10, 5, 1)):
if n == 0: return 1
if n < 0 or not coins: return 0
return (count_change(n - coins[0], coins) +
count_change(n, coins[1:]))
print(count_change(100)) # 292
10Expression Trees and Recursive Evaluation
One of the most important applications of recursive data structures is the abstract syntax tree (AST) — the data structure that compilers and interpreters use to represent programs.
An arithmetic expression like (3 + 4) * (2 - 1) is naturally a tree:
*
/ \
+ -
/ \ / \
3 4 2 1
Evaluating it follows the recursive structure exactly:
class Num:
def __init__(self, val): self.val = val
class BinOp:
def __init__(self, op, left, right):
self.op, self.left, self.right = op, left, right
def evaluate(expr):
if isinstance(expr, Num):
return expr.val # base case
left = evaluate(expr.left) # recurse
right = evaluate(expr.right) # recurse
if expr.op == '+': return left + right
if expr.op == '*': return left * right
if expr.op == '-': return left - right
This pattern — interpreter / evaluator over a recursive data type — is the skeleton of every programming language implementation. Scheme interpreters are often under 100 lines because the language's structure is so cleanly recursive. Pattern matching in functional languages (Haskell, OCaml, Rust) makes this correspondence even more explicit: each case of the evaluate function corresponds directly to one constructor of the AST type.
Building and evaluating expression trees is the prototype for understanding how computers run programs.
class Num:
def __init__(self, val): self.val = val
class BinOp:
def __init__(self, op, left, right):
self.op, self.left, self.right = op, left, right
def evaluate(expr):
if isinstance(expr, Num):
return expr.val
l = evaluate(expr.left)
r = evaluate(expr.right)
ops = {'+': l+r, '-': l-r, '*': l*r, '/': l/r}
return ops[expr.op]
# (3 + 4) * (2 - 1)
tree = BinOp('*', BinOp('+', Num(3), Num(4)), BinOp('-', Num(2), Num(1)))
print(evaluate(tree)) # 7
Key insights
- ✦A recursive *procedure* can generate either a recursive *process* (deferred work, O(n) stack) or an iterative *process* (no deferred work, O(1) stack) — the distinction is about the shape of the runtime computation, not the syntax.
- ✦The accumulator pattern is the universal translation from recursive process to iterative process: move the 'deferred operation' into an argument that accumulates state across calls.
- ✦Structural recursion and structural induction are the same thing viewed from two angles: the proof technique for recursive code is exactly mathematical induction, with the inductive hypothesis being 'the recursive call is correct'.
- ✦Tree recursion is exponential when subproblems overlap; memoization (top-down DP) is the surgical fix that preserves the recursive structure while achieving polynomial time.
- ✦The call stack IS the data structure of recursion: a recursive process 'manually' builds a stack of deferred operations; TCO collapses this stack when the call is in tail position.
- ✦Recursive data structures (lists, trees, grammars) naturally prescribe the recursion in their functions: base case of the code = base case of the data, recursive case of the code = recursive case of the data.
- ✦Iteration and recursion are equivalent in expressive power; any recursive function can be made iterative by maintaining an explicit stack, which is exactly what the hardware's call stack was doing invisibly.
Common pitfalls
- ⚠Confusing a recursive procedure with a recursive process: a tail-recursive procedure generates an iterative process, but only if the language implements TCO. In Python, tail-recursive procedures still blow the stack.
- ⚠Forgetting the base case or writing an incorrect base case — the most common cause of infinite recursion. Always ask: what is the smallest input, and what should the function return for it?
- ⚠Off-by-one in the base case: `fib(1)` vs `fib(0)` as the base case, or `n == 0` vs `n == 1` for factorial. Trace carefully on small inputs.
- ⚠Not trusting the inductive hypothesis — the anti-pattern of 'unrolling' the recursion in your head and getting confused. You only need to verify: (a) base case is correct, (b) current step is correct assuming the recursive call is correct.
- ⚠Tree recursion without memoization on overlapping subproblems: `fib(50)` without memoization takes minutes; with `@lru_cache` it takes microseconds. Recognize the overlap.
- ⚠Using recursion in Python for large inputs (depth > ~900) without awareness of the default recursion limit; failing to raise `sys.setrecursionlimit` or convert to iterative when needed.
- ⚠Mutating shared state inside a recursive function in unexpected ways — especially mutable default arguments in Python (e.g., using a mutable list as a default accumulator).
Practice
- 01easy
Implement `flatten(nested_list)` that takes an arbitrarily nested Python list (e.g., `[1, [2, [3, 4], 5]]`) and returns a flat list of all integers. Use structural recursion — your base case should handle a non-list element, and your recursive case should handle a list.
- 02easy
Prove by structural induction that your `length(lst)` function (which returns the number of nodes in a linked list) is correct. Write the proof in English: state the base case, state the inductive hypothesis, and carry out the inductive step.
- 03medium
Rewrite the naive `fib(n)` as a tail-recursive function in Scheme (or Python with an explicit accumulator). What are the two accumulator values you need? Verify it runs correctly on fib(30) and, in Scheme, on fib(1000000).
- 04medium
Implement a recursive `merge_sort(lst)` in Python. Analyze its time complexity using a recurrence relation T(n) = 2T(n/2) + O(n) and solve it using the Master Theorem. What is the space complexity, and how does the call stack depth factor in?
- 05hard
Write a recursive descent parser for simple arithmetic expressions of the form `expr := num | expr '+' num`. Represent the result as an expression tree (using classes like `Num` and `BinOp`). Then write `evaluate(tree)` and `to_string(tree)`. Test on '3+4+5'.
- 06hard
Implement `count_change(n, coins)` with memoization. Then convert it to a bottom-up iterative dynamic programming solution using a 2D table `dp[i][j]` = ways to make change for j cents using the first i coin types. Verify both give the same answer for count_change(100, [1,5,10,25,50]) = 292.
Resources
Chapter 1 is the definitive treatment of recursive vs iterative processes, accumulator pattern, and tree recursion. Required reading.
MIT's introductory CS course. Lectures and problem sets covering recursion, tree recursion, and algorithmic thinking.
Berkeley's version of SICP-style CS1. Excellent labs and projects on recursion, tree recursion, and data structures. Lecture slides and videos freely available.
Chapters on divide-and-conquer and recurrence relations (Master Theorem) provide the rigorous complexity analysis of recursive algorithms.
The definitive textbook on structural recursion — teaches the 'design recipe' of deriving recursive functions directly from data definitions. Highly recommended for building correct intuition.
Clear explanation of tail calls, TCO, and which languages guarantee it. Good reference for the TCO section.
Chapter 2 and 3 show how algebraic data types and structural recursion are first-class citizens in a production language, making the recursion-data duality completely explicit.
Step-through visualizations of recursion trees for factorial, Fibonacci, and other examples. Useful for building intuition about the shape of the process.
Checkpoints — you can now…
- ✓You can explain the difference between a recursive procedure and a recursive process, and give an example of each generated by a tail-recursive vs non-tail-recursive function.
- ✓You can trace the call stack for a small recursive function (e.g., fib(4)) and count exactly how many frames are on the stack at the deepest point.
- ✓You can identify whether a recursive call is in tail position, and convert a non-tail-recursive function to a tail-recursive one using the accumulator pattern.
- ✓You can write a correct recursive function over a linked list or binary tree by following the structure of the data definition, and prove its correctness by structural induction.
- ✓You can explain why naive tree-recursive Fibonacci is exponential, identify the overlapping subproblems, and add memoization to achieve O(n) time.
- ✓You can implement both recursive and iterative DFS on a graph, and explain that the iterative version uses an explicit stack that exactly mimics the implicit call stack of the recursive version.
- ✓You can build a simple expression tree and write a recursive evaluator over it, connecting this to how interpreters and compilers work.