Overview
Recursion defines a function that calls itself during execution to solve problems by breaking them into smaller instances of the same problem. Each recursive call operates on a reduced version of the original input until reaching a terminating condition. The technique applies across algorithm design, data structure traversal, and mathematical computation.
A recursive function requires two components: a base case that returns without further recursion, and a recursive case that reduces the problem and calls the function again. The base case prevents infinite recursion by providing an exit condition. The recursive case transforms the problem into a simpler form, ensuring eventual termination.
def factorial(n)
return 1 if n <= 1 # base case
n * factorial(n - 1) # recursive case
end
factorial(5)
# => 120
Tail recursion represents a specific form where the recursive call appears as the final operation before returning. This position allows certain optimizations that eliminate stack growth. A tail recursive function performs all computation before the recursive call, passing accumulated results as arguments rather than computing after the call returns.
def factorial_tail(n, accumulator = 1)
return accumulator if n <= 1 # base case
factorial_tail(n - 1, n * accumulator) # tail call
end
factorial_tail(5)
# => 120
The distinction matters because each recursive call consumes stack space to store the return address and local variables. Standard recursion builds a stack of pending operations. Tail recursion enables optimization that reuses the current stack frame, converting recursion into iteration at the machine level.
Key Principles
Recursion Mechanics
Every recursive call creates a new stack frame containing the function's local variables, parameters, and return address. The runtime pushes this frame onto the call stack. When the function returns, the runtime pops the frame and resumes execution at the stored return address. This mechanism enables the function to maintain separate state for each invocation level.
The call stack has finite size, typically measured in megabytes. Deep recursion exhausts available stack space, triggering a stack overflow error. The maximum recursion depth depends on stack size, frame size, and available memory. Most systems support thousands of recursive calls for simple functions but fewer for functions with large local variables.
Base Case Requirements
The base case must guarantee termination for all valid inputs. Missing or incorrect base cases cause infinite recursion. Multiple base cases may exist for complex problems. Each base case returns a result directly without recursion.
def fibonacci(n)
return n if n <= 1 # handles both 0 and 1
fibonacci(n - 1) + fibonacci(n - 2)
end
Recursive Case Design
The recursive case must progress toward the base case with each call. This progression requirement ensures termination. The function typically reduces input size, decrements a counter, or moves toward a boundary condition. Failure to progress causes infinite recursion even with valid base cases.
def sum_array(arr)
return 0 if arr.empty?
arr.first + sum_array(arr.drop(1)) # reduces array size
end
Tail Call Position
A call occupies tail position when it represents the final operation before return. The calling function performs no computation with the result. This position enables tail call optimization (TCO) because the caller needs no information preserved after the call returns.
# Tail position - recursive call is last operation
def countdown(n)
return if n <= 0
puts n
countdown(n - 1) # nothing happens after this call
end
# Not tail position - multiplication happens after recursive call
def factorial_regular(n)
return 1 if n <= 1
n * factorial_regular(n - 1) # multiplication after call returns
end
Tail Call Optimization
TCO replaces the current stack frame with the frame for the tail call instead of creating a new frame. This replacement works because the current function requires no information after the tail call returns. The optimization converts recursion into iteration, eliminating stack growth and enabling unlimited recursion depth.
Languages implementing TCO can execute tail recursive functions with constant stack space regardless of recursion depth. The optimization applies automatically when the compiler or interpreter detects tail position calls. Not all languages support TCO. Some require explicit configuration or specific syntax.
Accumulator Pattern
Converting standard recursion to tail recursion typically requires an accumulator parameter that carries intermediate results. The recursive case updates the accumulator and passes it forward. The base case returns the accumulator value. This pattern moves computation before the recursive call instead of after it returns.
# Standard recursion - computes after return
def sum_standard(n)
return 0 if n <= 0
n + sum_standard(n - 1)
end
# Tail recursion - computes before call
def sum_tail(n, acc = 0)
return acc if n <= 0
sum_tail(n - 1, acc + n)
end
Ruby Implementation
Ruby supports recursion with standard call stack mechanics. Each method invocation creates a new stack frame. The default stack size varies by Ruby implementation and platform but typically allows several thousand recursive calls for simple methods.
Stack Size Limitations
Ruby raises SystemStackError when recursion depth exceeds available stack space. The exact limit depends on Ruby version, platform, and method complexity.
def infinite_recursion(n)
infinite_recursion(n + 1)
end
infinite_recursion(0)
# SystemStackError: stack level too deep
Tail Call Optimization Support
Ruby introduced TCO as an experimental feature in MRI 2.0. The optimization remains disabled by default due to compatibility concerns with debugging tools and stack traces. Enabling TCO requires setting RubyVM::InstructionSequence compile options.
RubyVM::InstructionSequence.compile_option = {
tailcall_optimization: true,
trace_instruction: false
}
# Define method after enabling TCO
eval <<-CODE
def factorial_tco(n, acc = 1)
return acc if n <= 1
factorial_tco(n - 1, n * acc)
end
CODE
factorial_tco(10000) # succeeds with TCO enabled
The trace_instruction option must be disabled because instruction tracing conflicts with TCO. This requirement prevents using certain debugging tools with TCO-enabled code. Alternative Ruby implementations like TruffleRuby provide different TCO support.
Converting Standard to Tail Recursion
Standard recursive methods require conversion to tail recursive form for TCO benefits. The conversion introduces accumulator parameters and restructures computation.
# Standard recursion
def reverse_array(arr)
return [] if arr.empty?
reverse_array(arr.drop(1)) + [arr.first]
end
# Tail recursive version
def reverse_array_tail(arr, acc = [])
return acc if arr.empty?
reverse_array_tail(arr.drop(1), [arr.first] + acc)
end
Method Definition Context
TCO applies only to methods defined after enabling the optimization. Methods defined before enabling TCO or loaded from required files do not receive optimization. This behavior requires careful management of when and how methods are defined.
# TCO not applied - defined before enabling
def method_without_tco(n, acc = 1)
return acc if n <= 1
method_without_tco(n - 1, n * acc)
end
RubyVM::InstructionSequence.compile_option = {
tailcall_optimization: true,
trace_instruction: false
}
# TCO applied - defined after enabling
eval <<-CODE
def method_with_tco(n, acc = 1)
return acc if n <= 1
method_with_tco(n - 1, n * acc)
end
CODE
Recursion with Blocks and Procs
Ruby blocks and Procs support recursion through explicit naming or the call method. Anonymous recursion requires passing the Proc as an argument to itself.
# Named Proc recursion
factorial_proc = ->(n, acc = 1) {
n <= 1 ? acc : factorial_proc.call(n - 1, n * acc)
}
factorial_proc.call(5)
# => 120
# Y combinator for anonymous recursion
y = ->(f) { ->(x) { f.call(x.call(x)) }.call(->(x) { f.call(->(v) { x.call(x).call(v) }) }) }
factorial_y = y.call(->(f) { ->(n, acc = 1) { n <= 1 ? acc : f.call(n - 1, n * acc) } })
factorial_y.call(5)
# => 120
Mutual Recursion
Multiple methods can call each other recursively. Mutual recursion requires forward declaration or careful ordering in Ruby.
def even?(n)
return true if n == 0
odd?(n - 1)
end
def odd?(n)
return false if n == 0
even?(n - 1)
end
even?(10)
# => true
Practical Examples
Tree Traversal
Recursion naturally expresses tree traversal algorithms. Each node processes itself and recursively processes children.
class TreeNode
attr_accessor :value, :left, :right
def initialize(value, left = nil, right = nil)
@value = value
@left = left
@right = right
end
end
def inorder_traversal(node, result = [])
return result if node.nil?
inorder_traversal(node.left, result)
result << node.value
inorder_traversal(node.right, result)
result
end
# Build tree: 4
# / \
# 2 6
# / \ / \
# 1 3 5 7
tree = TreeNode.new(4,
TreeNode.new(2, TreeNode.new(1), TreeNode.new(3)),
TreeNode.new(6, TreeNode.new(5), TreeNode.new(7))
)
inorder_traversal(tree)
# => [1, 2, 3, 4, 5, 6, 7]
Directory Traversal
File system navigation demonstrates practical recursion for hierarchical structures.
require 'find'
def find_files(path, pattern, results = [])
return results unless File.directory?(path)
Dir.foreach(path) do |entry|
next if entry == '.' || entry == '..'
full_path = File.join(path, entry)
if File.directory?(full_path)
find_files(full_path, pattern, results)
elsif entry.match?(pattern)
results << full_path
end
end
results
end
find_files('/usr/local', /\.rb$/)
# => ["/usr/local/lib/ruby/script.rb", ...]
Merge Sort Implementation
Divide-and-conquer algorithms use recursion to split problems and combine results.
def merge_sort(arr)
return arr if arr.length <= 1
mid = arr.length / 2
left = merge_sort(arr[0...mid])
right = merge_sort(arr[mid..-1])
merge(left, right)
end
def merge(left, right)
result = []
until left.empty? || right.empty?
result << (left.first <= right.first ? left.shift : right.shift)
end
result + left + right
end
merge_sort([64, 34, 25, 12, 22, 11, 90])
# => [11, 12, 22, 25, 34, 64, 90]
Permutation Generation
Recursion generates all permutations by fixing elements and permuting remainders.
def permutations(arr)
return [arr] if arr.length <= 1
result = []
arr.each_with_index do |element, index|
remaining = arr[0...index] + arr[(index + 1)..-1]
permutations(remaining).each do |perm|
result << [element] + perm
end
end
result
end
permutations([1, 2, 3])
# => [[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1]]
Nested Data Structure Flattening
Recursion handles arbitrarily nested structures by processing each level.
def deep_flatten(arr)
result = []
arr.each do |element|
if element.is_a?(Array)
result.concat(deep_flatten(element))
else
result << element
end
end
result
end
deep_flatten([1, [2, [3, [4]], 5], 6, [7, 8]])
# => [1, 2, 3, 4, 5, 6, 7, 8]
Common Patterns
Linear Recursion
Linear recursion makes one recursive call per invocation. The pattern applies to sequential processing where each step depends on the previous result.
def sum_list(list)
return 0 if list.empty?
list.first + sum_list(list.drop(1))
end
def string_reverse(str)
return str if str.length <= 1
string_reverse(str[1..-1]) + str[0]
end
Binary Recursion
Binary recursion makes two recursive calls per invocation. Common in divide-and-conquer algorithms and binary tree operations.
def fibonacci_binary(n)
return n if n <= 1
fibonacci_binary(n - 1) + fibonacci_binary(n - 2)
end
def binary_search(arr, target, low = 0, high = arr.length - 1)
return -1 if low > high
mid = (low + high) / 2
return mid if arr[mid] == target
return binary_search(arr, target, low, mid - 1) if arr[mid] > target
binary_search(arr, target, mid + 1, high)
end
Multiple Recursion
Functions making more than two recursive calls handle problems with multiple subproblems.
def tower_of_hanoi(n, source, target, auxiliary)
return if n <= 0
tower_of_hanoi(n - 1, source, auxiliary, target)
puts "Move disk #{n} from #{source} to #{target}"
tower_of_hanoi(n - 1, auxiliary, target, source)
end
tower_of_hanoi(3, 'A', 'C', 'B')
# Move disk 1 from A to C
# Move disk 2 from A to B
# Move disk 1 from C to B
# Move disk 3 from A to C
# Move disk 1 from B to A
# Move disk 2 from B to C
# Move disk 1 from A to C
Accumulator Pattern
Accumulator parameters carry intermediate results through recursive calls, enabling tail recursion.
def product_list(list, acc = 1)
return acc if list.empty?
product_list(list.drop(1), acc * list.first)
end
def filter_tail(list, predicate, acc = [])
return acc if list.empty?
new_acc = predicate.call(list.first) ? acc + [list.first] : acc
filter_tail(list.drop(1), predicate, new_acc)
end
filter_tail([1, 2, 3, 4, 5], ->(x) { x.even? })
# => [2, 4]
Continuation Pattern
Continuation-passing style passes a function representing remaining computation instead of returning values directly.
def factorial_cps(n, cont = ->(x) { x })
return cont.call(1) if n <= 1
factorial_cps(n - 1, ->(result) { cont.call(n * result) })
end
factorial_cps(5)
# => 120
Memoization Pattern
Caching recursive call results eliminates redundant computation in problems with overlapping subproblems.
def fibonacci_memo(n, memo = {})
return n if n <= 1
return memo[n] if memo.key?(n)
memo[n] = fibonacci_memo(n - 1, memo) + fibonacci_memo(n - 2, memo)
end
fibonacci_memo(50)
# => 12586269025 (computed efficiently)
Performance Considerations
Stack Space Complexity
Each recursive call consumes stack space proportional to the size of the stack frame. Frame size includes parameters, local variables, and return addresses. Deep recursion with large frames exhausts stack space faster than shallow recursion with small frames.
Linear recursion has O(n) stack space complexity where n represents recursion depth. Binary recursion typically has O(log n) space for balanced problems like binary search but O(n) for unbalanced cases. The call stack depth determines memory requirements independent of heap allocations.
Time Complexity Analysis
Recursion time complexity depends on the number of recursive calls and work per call. Linear recursion performs O(n) calls with O(1) work each, yielding O(n) total time. Binary recursion can perform exponential calls as seen in naive Fibonacci implementation.
# O(2^n) time complexity - exponential
def fibonacci_slow(n)
return n if n <= 1
fibonacci_slow(n - 1) + fibonacci_slow(n - 2)
end
# O(n) time complexity with memoization
def fibonacci_fast(n, memo = {})
return n if n <= 1
return memo[n] if memo.key?(n)
memo[n] = fibonacci_fast(n - 1, memo) + fibonacci_fast(n - 2, memo)
end
Recursion vs Iteration Performance
Iteration generally outperforms recursion in languages without TCO due to lower overhead. Each recursive call requires stack frame creation, parameter copying, and return address storage. Iteration maintains state in local variables without function call overhead.
require 'benchmark'
def factorial_recursive(n)
return 1 if n <= 1
n * factorial_recursive(n - 1)
end
def factorial_iterative(n)
result = 1
(2..n).each { |i| result *= i }
result
end
Benchmark.bm do |x|
x.report("recursive:") { 10000.times { factorial_recursive(20) } }
x.report("iterative:") { 10000.times { factorial_iterative(20) } }
end
# recursive: typically 2-3x slower without TCO
Tail Recursion Performance
Tail recursion with TCO performs comparably to iteration by eliminating stack frame creation. The compiler or interpreter converts tail calls into jumps, reusing the current stack frame. This optimization reduces both time and space overhead to iteration levels.
Without TCO, tail recursive functions still incur call overhead despite the tail position. Ruby's disabled-by-default TCO means tail recursion offers no performance benefit in standard configurations.
Memory Allocation Patterns
Recursive functions often create intermediate objects for partial results. These allocations accumulate across recursive calls, increasing garbage collection pressure.
# Creates many intermediate arrays
def recursive_map(arr, &block)
return [] if arr.empty?
[block.call(arr.first)] + recursive_map(arr.drop(1), &block)
end
# More efficient - fewer allocations
def recursive_map_optimized(arr, block, result = [])
return result if arr.empty?
result << block.call(arr.first)
recursive_map_optimized(arr.drop(1), block, result)
end
Cache Performance
Deep recursion creates poor cache locality as the call stack grows beyond cache sizes. Each recursive call accesses different memory locations for stack frames. Iteration maintains better cache performance through sequential memory access patterns and smaller working sets.
Common Pitfalls
Missing Base Case
Omitting the base case or incorrectly defining termination conditions causes infinite recursion and stack overflow.
# Missing base case
def broken_countdown(n)
puts n
broken_countdown(n - 1) # never terminates
end
# Incorrect base case
def broken_factorial(n)
return 1 if n == 1 # fails for n <= 0
n * broken_factorial(n - 1)
end
# Correct implementation
def correct_factorial(n)
return 1 if n <= 1 # handles all base cases
n * correct_factorial(n - 1)
end
Non-Progressing Recursion
Recursive calls that fail to reduce the problem size prevent termination.
# Does not progress toward base case
def broken_sum(n)
return 0 if n <= 0
n + broken_sum(n) # n never decreases
end
# Correct progression
def correct_sum(n)
return 0 if n <= 0
n + correct_sum(n - 1) # n decreases each call
end
Unhandled Edge Cases
Negative inputs, empty collections, or nil values can break recursion assumptions.
# Fails with negative input
def unsafe_factorial(n)
return 1 if n == 0
n * unsafe_factorial(n - 1) # infinite recursion for n < 0
end
# Handles edge cases
def safe_factorial(n)
return nil if n < 0 # reject invalid input
return 1 if n <= 1
n * safe_factorial(n - 1)
end
Excessive Memory Consumption
Creating new objects in each recursive call accumulates memory rapidly.
# Creates many intermediate strings
def inefficient_join(arr, separator = "")
return "" if arr.empty?
return arr.first.to_s if arr.length == 1
arr.first.to_s + separator + inefficient_join(arr.drop(1), separator)
end
# More efficient with accumulator
def efficient_join(arr, separator = "", result = "")
return result if arr.empty?
new_result = result.empty? ? arr.first.to_s : result + separator + arr.first.to_s
efficient_join(arr.drop(1), separator, new_result)
end
Naive Fibonacci Implementation
The standard recursive Fibonacci implementation recalculates values exponentially.
# Exponential time complexity
def fibonacci_naive(n)
return n if n <= 1
fibonacci_naive(n - 1) + fibonacci_naive(n - 2)
end
fibonacci_naive(40) # takes several seconds
# Linear time with memoization
def fibonacci_optimized(n, memo = {})
return n if n <= 1
return memo[n] if memo.key?(n)
memo[n] = fibonacci_optimized(n - 1, memo) + fibonacci_optimized(n - 2, memo)
end
fibonacci_optimized(40) # returns immediately
Incorrect Tail Call Form
Operations after the recursive call prevent tail call optimization.
# Not tail recursive - multiplication after call
def factorial_not_tail(n)
return 1 if n <= 1
n * factorial_not_tail(n - 1) # must save n for multiplication
end
# Tail recursive - no operations after call
def factorial_tail_correct(n, acc = 1)
return acc if n <= 1
factorial_tail_correct(n - 1, n * acc) # all work done before call
end
Modifying Shared State
Recursion with mutable shared state creates difficult-to-debug issues.
# Dangerous shared state
@count = 0
def dangerous_recursion(n)
return if n <= 0
@count += 1 # modifies shared state
dangerous_recursion(n - 1)
end
# Safe approach with parameters
def safe_recursion(n, count = 0)
return count if n <= 0
safe_recursion(n - 1, count + 1)
end
Stack Overflow from Deep Recursion
Large inputs cause stack overflow in languages without TCO or when TCO is disabled.
def sum_range(n)
return 0 if n <= 0
n + sum_range(n - 1)
end
sum_range(10000)
# SystemStackError: stack level too deep
# Iterative alternative
def sum_range_safe(n)
(1..n).reduce(0, :+)
end
sum_range_safe(10000)
# => 50005000
Reference
Recursion Characteristics Comparison
| Aspect | Standard Recursion | Tail Recursion | Iteration |
|---|---|---|---|
| Stack Growth | O(n) frames | O(1) with TCO, O(n) without | O(1) |
| Performance | Slower due to call overhead | Equal to iteration with TCO | Fastest |
| Memory Usage | High for deep recursion | Low with TCO enabled | Lowest |
| Code Clarity | Often more readable | Requires accumulators | More verbose |
| TCO Required | No | Yes for benefits | No |
Ruby TCO Configuration
| Setting | Value | Purpose |
|---|---|---|
| tailcall_optimization | true | Enable tail call optimization |
| trace_instruction | false | Required - tracing conflicts with TCO |
| Application Scope | Eval context only | Methods must be defined after enabling |
| Default State | Disabled | Must explicitly enable |
Common Recursive Patterns
| Pattern | Recursive Calls | Use Case | Example |
|---|---|---|---|
| Linear | 1 per call | Sequential processing | List traversal, counting |
| Binary | 2 per call | Divide and conquer | Binary search, merge sort |
| Multiple | 3+ per call | Complex subproblems | Tower of Hanoi, tree traversal |
| Tail | 1 in tail position | Stack-safe recursion | Accumulator-based computation |
| Mutual | Functions call each other | State machines | Even/odd determination |
Base Case Design
| Requirement | Description | Example |
|---|---|---|
| Termination | Must stop recursion | n <= 0, array.empty? |
| Completeness | Handle all stopping conditions | Multiple base cases for edge cases |
| Correctness | Return valid result | Return identity value for operation |
| Reachability | Recursive case must reach it | Problem size decreases each call |
Performance Characteristics
| Metric | Typical Value | Notes |
|---|---|---|
| Stack Size | 1-8 MB default | Platform and Ruby version dependent |
| Max Depth | 5000-15000 calls | Varies with frame size |
| Call Overhead | 2-5x iteration | Without TCO |
| TCO Overhead | Same as iteration | When properly enabled |
Conversion Checklist
| Step | Action | Purpose |
|---|---|---|
| 1 | Identify recursive operations | Determine what needs accumulation |
| 2 | Add accumulator parameters | Carry intermediate results |
| 3 | Move computation before call | Ensure tail position |
| 4 | Update base case | Return accumulator value |
| 5 | Initialize accumulator | Provide identity value in default parameter |
| 6 | Verify tail position | Ensure no operations after recursive call |
Stack Overflow Prevention
| Technique | Description | Trade-offs |
|---|---|---|
| Enable TCO | Use RubyVM compile options | Breaks debugging tools |
| Convert to iteration | Replace recursion with loops | Less elegant for some problems |
| Add memoization | Cache results | Increases memory usage |
| Limit input size | Validate maximum depth | May not solve problem |
| Use trampolining | Return continuations | Complex implementation |
Time Complexity by Pattern
| Pattern | Without Optimization | With Memoization | Iterative Equivalent |
|---|---|---|---|
| Linear recursion | O(n) | O(n) | O(n) |
| Binary recursion | O(2^n) worst case | O(n) | O(log n) for balanced |
| Tail recursion | O(n) | O(n) | O(n) |
| Tree traversal | O(n) nodes | O(n) nodes | O(n) nodes |