Learning Objectives
By the end of this section, you will be able to:
- Define tail recursion and explain how it differs from regular recursion
- Identify tail position in a recursive function and determine if a call is a tail call
- Understand tail call optimization (TCO) and which languages support it
- Convert regular recursive functions to tail-recursive form using the accumulator pattern
- Implement the trampoline pattern for tail recursion in languages without TCO
- Recognize when tail recursion is not possible and choose appropriate alternatives
Why This Matters: Understanding tail recursion unlocks the ability to write elegant recursive solutions that run with the efficiency of loops. In languages with tail call optimization, a tail-recursive function uses O(1) stack space—the same as iteration—combining the clarity of recursion with the performance of loops.
The Problem with Regular Recursion
In the previous section, we explored how the call stack manages recursive function calls. Each recursive call creates a new stack frame, consuming memory. For deeply recursive computations, this can lead to stack overflow.
Consider the standard recursive factorial:
1def factorial(n):
2 if n <= 1:
3 return 1
4 return n * factorial(n - 1) # Must wait for result, then multiplyWhen we call factorial(5), the call stack builds up like this:
1factorial(5) ← Waiting for factorial(4), then multiply by 5
2 factorial(4) ← Waiting for factorial(3), then multiply by 4
3 factorial(3) ← Waiting for factorial(2), then multiply by 3
4 factorial(2) ← Waiting for factorial(1), then multiply by 2
5 factorial(1) ← Base case, returns 1
6 ← returns 2 (2 * 1)
7 ← returns 6 (3 * 2)
8 ← returns 24 (4 * 6)
9← returns 120 (5 * 24)The problem is clear: each frame has pending work after the recursive call returns. The multiplication cannot happen until the recursive call completes, so each frame must stay on the stack.
The Space Complexity Issue
For factorial(1,000,000), we would need a million stack frames. Most systems default to stack sizes between 1-8 MB, so this would cause a stack overflow long before completion.
Can We Do Better?
What Is Tail Recursion?
A tail-recursive function is one where the recursive call is the last operation performed before returning. There is no pending computation after the recursive call returns.
Compare these two versions of factorial:
| Regular Recursion | Tail Recursion |
|---|---|
| return n * factorial(n - 1) | return factorial(n - 1, n * acc) |
| Multiplies AFTER call returns | Multiplies BEFORE making call |
| Pending work: n × ... | No pending work |
| Must keep frame for n | Can discard frame immediately |
Here's the tail-recursive factorial with an accumulator:
With tail recursion, the call sequence looks different:
1factorial(5, 1) → multiply 5*1=5, call factorial(4, 5)
2factorial(4, 5) → multiply 4*5=20, call factorial(3, 20)
3factorial(3, 20) → multiply 3*20=60, call factorial(2, 60)
4factorial(2, 60) → multiply 2*60=120, call factorial(1, 120)
5factorial(1, 120) → base case, return 120
6
7Result: 120The key insight: each call is completely self-contained. The previous call doesn't need to do anything after this call returns—it just passes along the result.
Understanding Tail Position
A function call is in tail position if it is the last action before the function returns. More formally:
A call is a tail call if the result of the call is immediately returned by the calling function, with no further processing.
Examples of Tail Position
1# TAIL POSITION - These are tail calls:
2
3def f(x):
4 return g(x) # ✓ Result of g is returned directly
5
6def f(x):
7 if x > 0:
8 return g(x) # ✓ Tail call in the if branch
9 else:
10 return h(x) # ✓ Tail call in the else branch
11
12def f(x):
13 y = compute(x)
14 return g(y) # ✓ Still tail position (y computation is before)1# NOT TAIL POSITION - These are NOT tail calls:
2
3def f(x):
4 return 1 + g(x) # ✗ Addition happens AFTER g returns
5
6def f(x):
7 return g(x) * 2 # ✗ Multiplication happens AFTER g returns
8
9def f(x):
10 result = g(x)
11 return result # ✗ Assignment, then return (though many compilers optimize this)
12
13def f(x):
14 return g(x) + h(x) # ✗ Both calls, then additionThe Key Test
- If yes: Not in tail position. The frame must be kept.
- If no: Tail position. The frame can be discarded.
Quick Check
Which of the following recursive calls is in tail position?
Interactive: Stack Comparison
Watch how the call stack behaves differently for regular vs. tail recursion. Notice that regular recursion builds up a stack of waiting frames, while tail recursion can reuse the same frame.
Tail Recursion Stack Comparison
def factorial(n):
if n <= 1:
return 1
return n * factorial(n - 1) # Pending: n * ...def factorial(n, acc=1):
if n <= 1:
return acc
return factorial(n - 1, n * acc) # Tail callCall Stack
Execution Log
Regular Recursion Overhead
Each call must wait for its recursive call to return before it can complete its pending work. This requires O(n) stack space.
Key observations from the visualization:
- Regular recursion: Stack grows to depth n, then unwinds computing results
- Tail recursion: Stack stays at depth 1—each call replaces the previous
- Space complexity: O(n) vs O(1) when TCO is applied
- Result computation: Regular: during unwinding; Tail: during winding (in accumulator)
Tail Call Optimization (TCO)
Tail Call Optimization (also called Tail Call Elimination) is a compiler/interpreter optimization that transforms tail-recursive calls into jumps, eliminating the need to create new stack frames.
How TCO Works
When the compiler detects a tail call, it can:
- Skip frame creation: Since there's no pending work, the current frame can be discarded
- Reuse the frame: Update the parameters in place and jump back to the function start
- Replace CALL with JMP: At the machine level, use a jump instruction instead of a call
Effectively, the tail-recursive function becomes a loop:
1# Tail-recursive version:
2def factorial(n, acc=1):
3 if n <= 1:
4 return acc
5 return factorial(n - 1, n * acc)
6
7# With TCO, becomes equivalent to:
8def factorial_loop(n, acc=1):
9 while n > 1:
10 acc = n * acc
11 n = n - 1
12 return accBoth versions use O(1) stack space, but the recursive version is often more readable for complex algorithms.
The Guarantee That Matters
| Aspect | Without TCO | With TCO |
|---|---|---|
| Stack space | O(n) - grows with recursion depth | O(1) - constant |
| Large inputs | Stack overflow possible | Works like a loop |
| Performance | Function call overhead each time | Just a jump instruction |
| Debugging | Full stack trace available | Stack trace may be lost |
Interactive: TCO Across Languages
Not all languages support TCO. Click on different languages to see their TCO status and implementation notes.
Tail Call Optimization Across Languages
The Key Transformation
When the compiler detects a tail call, it can replace the CALL instruction with a JMP instruction. Since there's no work to do after the recursive call returns, we don't need to save the current frame's state—we can simply reuse it for the next call. This transforms O(n) stack space into O(1).
JavaScript's Broken Promise
Python's Deliberate Choice
The Accumulator Pattern
The accumulator pattern is the key technique for converting regular recursion to tail recursion. The idea is to carry the partial result as an extra parameter, doing computation before the recursive call rather than after.
The General Pattern
The transformation follows a consistent recipe:
- Add an accumulator parameter initialized to the identity element for your operation
- Move the "pending work" into the accumulator update
- Return the accumulator at the base case (it now contains the complete answer)
| Operation | Identity Element | Example |
|---|---|---|
| Addition (+) | 0 | sum(n, acc=0) |
| Multiplication (×) | 1 | factorial(n, acc=1) |
| List concatenation | [] | reverse(lst, acc=[]) |
| Boolean AND | True | all_true(lst, acc=True) |
| Boolean OR | False | any_true(lst, acc=False) |
| String concatenation | "" | join(lst, acc="") |
Example: Sum of List
1# Regular recursion
2def sum_list(lst):
3 if not lst:
4 return 0
5 return lst[0] + sum_list(lst[1:]) # Pending addition
6
7# Tail-recursive version
8def sum_list(lst, acc=0):
9 if not lst:
10 return acc
11 return sum_list(lst[1:], acc + lst[0]) # Tail callInteractive: Converting to Tail Recursion
Practice the conversion process step by step. Select an algorithm and follow the guided transformation.
Converting to Tail Recursion
Original (Regular Recursion)
O(n) stack spacedef factorial(n):
if n <= 1:
return 1
return n * factorial(n - 1)
# ^-- pending multiplicationTail Recursive Version
O(1) stack space with TCOConversion Steps
1. Identify the pending work
After the recursive call returns, we multiply by n. This multiplication is the 'pending work' that prevents tail optimization.
return n * factorial(n - 1)The Accumulator Pattern
The key to converting regular recursion to tail recursion is the accumulator pattern:
- Add an extra parameter (the accumulator) to carry partial results
- Initialize it to the identity element (0 for addition, 1 for multiplication, [] for lists)
- Do the work BEFORE the recursive call, updating the accumulator
- Return the accumulator at the base case (it now contains the complete answer)
This transforms "work after recursion" into "work before recursion", allowing TCO.
When Tail Recursion Is Not Possible
Not all recursive algorithms can be converted to tail-recursive form. Understanding when this is the case helps you choose the right approach.
Multiple Recursive Calls
When a function makes multiple recursive calls that must be combined, simple tail recursion isn't possible. However, techniques like continuation-passing style (CPS) can sometimes help.
1# Binary tree traversal - cannot be trivially tail-recursive
2def tree_sum(node):
3 if node is None:
4 return 0
5 # Must combine results from BOTH left AND right
6 return node.value + tree_sum(node.left) + tree_sum(node.right)
7
8# Merge sort - needs results from both halves
9def merge_sort(arr):
10 if len(arr) <= 1:
11 return arr
12 mid = len(arr) // 2
13 left = merge_sort(arr[:mid]) # Need this result...
14 right = merge_sort(arr[mid:]) # ...and this result...
15 return merge(left, right) # ...to combine themSolutions for Non-Tail Recursion
| Approach | When to Use | Trade-off |
|---|---|---|
| Convert to iteration | When the algorithm allows it | May lose clarity |
| Use explicit stack | For tree/graph traversals | Manual stack management |
| Continuation-Passing Style | Functional languages | Complex transformation |
| Trampoline pattern | Any language | Slight overhead |
| Just accept O(n) space | When depth is bounded | Simplest approach |
Bounded Recursion
The Trampoline Pattern
The trampoline pattern is a technique to achieve the benefits of tail recursion in languages that don't support TCO. Instead of making a recursive call, we return a "thunk" (suspended computation) that the trampoline loop will execute.
The Concept
A thunk is a function wrapper that defers computation. Instead of this:
1# Direct recursion (causes stack growth)
2def factorial(n, acc=1):
3 if n <= 1:
4 return acc
5 return factorial(n - 1, n * acc) # Recursive callWe return a thunk that describes the next call without making it:
1# Trampoline-compatible version
2class Thunk:
3 def __init__(self, fn, *args):
4 self.fn = fn
5 self.args = args
6
7def factorial(n, acc=1):
8 if n <= 1:
9 return acc # Return actual value
10 return Thunk(factorial, n - 1, n * acc) # Return thunk, not call
11
12def trampoline(thunk):
13 """Keep bouncing until we get a value"""
14 while isinstance(thunk, Thunk):
15 thunk = thunk.fn(*thunk.args)
16 return thunk
17
18# Usage
19result = trampoline(Thunk(factorial, 10000)) # Works with huge n!Why It Works
The key insight is that each "recursive call" actually returns to the trampoline before the next iteration. The stack never grows beyond a constant depth:
- Trampoline calls the thunk
- Thunk executes, returns either a value or a new thunk
- Control returns to trampoline (stack unwinds!)
- Trampoline calls the new thunk (if any)
- Repeat until we get a final value
Interactive: Trampoline in Action
Watch the trampoline pattern execute. Notice how each "bounce" returns to the trampoline, keeping the stack at constant depth.
The Trampoline Pattern
Trampoline Execution
Implementation
class Thunk:
"""Represents a suspended computation"""
def __init__(self, fn, *args):
self.fn = fn
self.args = args
def trampoline(thunk):
"""Keep bouncing until we get a value"""
while isinstance(thunk, Thunk):
thunk = thunk.fn(*thunk.args)
return thunk
def factorial_thunk(n, acc=1):
if n <= 1:
return acc # Return value, not thunk
# Return a thunk instead of recursing
return Thunk(factorial_thunk, n-1, n*acc)
# Usage:
result = trampoline(Thunk(factorial_thunk, 5))
print(result) # 120function trampoline(fn) {
return function(...args) {
let result = fn(...args);
while (typeof result === 'function') {
result = result();
}
return result;
};
}
const factorial = trampoline((n, acc = 1) => {
if (n <= 1) return acc;
return () => factorial(n - 1, n * acc);
});
console.log(factorial(10000)); // Works!How the Trampoline Works
Instead of making a recursive call (which would grow the stack), the function returns a thunk—a suspended computation wrapped in a closure. The trampoline loop keeps "bouncing" these thunks until one returns an actual value. Since each thunk returns to the trampoline before being called, the stack never grows beyond O(1).
Continuation-Passing Style
Continuation-Passing Style (CPS) is a more powerful transformation that can make any function tail-recursive, even those with multiple recursive calls. The idea is to pass a "continuation"—a function representing "what to do next"—as a parameter.
CPS Complexity
Practical Considerations
When to Use Tail Recursion
| Scenario | Recommendation |
|---|---|
| Language has guaranteed TCO (Scheme, Haskell) | Use tail recursion freely |
| Language has optional TCO (C/C++ with optimization) | Use tail recursion, verify with compiler flags |
| Language lacks TCO (Python, JavaScript) | Prefer iteration for deep recursion; use trampoline if needed |
| Recursion depth is O(log n) | Regular recursion is usually fine |
| Algorithm has multiple recursive calls | Consider CPS or accept the stack usage |
Debugging Trade-offs
One downside of TCO is that it can make debugging harder:
- Stack traces are shortened: You see only the current frame, not the history of calls
- Variables "disappear": Since frames are reused, you can't inspect earlier states
- Profiling changes: Function call counts may not reflect actual iterations
Development Strategy
Scala's @tailrec Annotation
Scala provides a clever solution: the @tailrec annotation. It tells the compiler to verify that a function is tail-recursive and will give a compile error if it's not:
1import scala.annotation.tailrec
2
3@tailrec
4def factorial(n: Int, acc: Int = 1): Int = {
5 if (n <= 1) acc
6 else factorial(n - 1, n * acc) // Compiler verifies this is a tail call
7}
8
9// This would cause a compile error:
10// @tailrec
11// def badFactorial(n: Int): Int = {
12// if (n <= 1) 1
13// else n * badFactorial(n - 1) // ERROR: not in tail position
14// }Summary
Tail recursion is a powerful technique for writing efficient recursive algorithms:
Key Concepts
| Concept | Definition |
|---|---|
| Tail Position | A call is the last action before returning; no pending work after |
| Tail Recursion | A recursive function where all recursive calls are in tail position |
| Tail Call Optimization | Compiler transforms tail calls to jumps, reusing the stack frame |
| Accumulator Pattern | Carry partial results as parameters to enable tail recursion |
| Trampoline | Return thunks instead of recursing; loop calls them sequentially |
The Transformation Recipe
- Identify the pending work after recursive calls
- Add an accumulator parameter for the partial result
- Move the pending work into the accumulator update (before the call)
- Return the accumulator at the base case
Space Complexity Comparison
Exercises
Conceptual Questions
- Explain why
return n + f(n-1)is not a tail call, butreturn f(n-1, acc+n)is. - Why did Python's creator choose not to implement TCO? What are the trade-offs?
- If a language has TCO, does that mean all recursive functions automatically become O(1) space? Explain.
- What is the relationship between tail recursion and iteration? When is each preferred?
Conversion Exercises
Convert each of the following to tail-recursive form using the accumulator pattern:
- 🐍python
1def length(lst): 2 if not lst: 3 return 0 4 return 1 + length(lst[1:]) - 🐍python
1def power(base, exp): 2 if exp == 0: 3 return 1 4 return base * power(base, exp - 1) - 🐍python
1def gcd(a, b): 2 if b == 0: 3 return a 4 return gcd(b, a % b) # Is this already tail-recursive? - 🐍python
1def count_digits(n): 2 if n < 10: 3 return 1 4 return 1 + count_digits(n // 10)
Implementation Challenges
- Implement a trampoline in JavaScript and use it to compute
factorial(50000)without stack overflow. - Write both regular and tail-recursive versions of a function that sums the digits of a number. Compare their behavior for very large numbers.
- Challenge: The Ackermann function is famously not primitive recursive:Can this be made tail-recursive using CPS? Implement it.🐍python
1def ackermann(m, n): 2 if m == 0: 3 return n + 1 4 elif n == 0: 5 return ackermann(m - 1, 1) 6 else: 7 return ackermann(m - 1, ackermann(m, n - 1))
Analysis Questions
- Consider a language that has TCO. A developer writes:Is this tail-recursive? Will TCO apply? Explain.🐍python
1def mystery(n, result): 2 if n <= 0: 3 return result 4 print(n) 5 return mystery(n - 1, result + n) - In Scala, what would happen if you applied
@tailrecto a function that usestry/catcharound a recursive call? - Research: Some languages implement a feature called "Proper Tail Calls" vs "Tail Call Optimization." What's the difference?
In the next section, we'll compare Recursion vs. Iteration—understanding when each approach is more appropriate and how to convert between them.