Recursion

CS205 Data Structures

"To understand recursion, you must first understand recursion." ┌──────────────────────┐ │ recursion (n): │ │ if n == 0: │ │ return "got it" │ │ else: │ │ return │ │ recursion(n-1) │ <── calls itself! └──────────────────────┘

Use arrow keys or buttons to navigate · Press S to reveal steps

1 / 18

What Is Recursion?

A recursive function is a function that calls itself to solve smaller instances of the same problem.

Analogy: Russian Nesting Dolls

Each doll contains a smaller doll inside it. You keep opening dolls until you reach the smallest one (the base case) -- then you stop.

┌──────────────────────┐ │ ┌────────────────┐ │ │ │ ┌──────────┐ │ │ │ │ │ ┌────┐ │ │ │ │ │ │ │ ** │ │ │ │ <-- base case │ │ │ └────┘ │ │ │ (smallest) │ │ └──────────┘ │ │ │ └────────────────┘ │ └──────────────────────┘

Real-World Examples

  • A folder that contains subfolders (which contain subfolders...)
  • A mirror reflecting another mirror
  • A story within a story within a story
  • Looking up a word in the dictionary, and the definition uses another word you need to look up

Key Idea

Recursion works because each call solves a smaller version of the same problem, until the problem is so small we know the answer directly.

2 / 18

The Two Essential Parts

Every correct recursive function needs exactly two things:

1. Base Case (Termination)

The condition under which the function does NOT call itself. This is the "smallest doll" -- the answer we know directly.

2. Recursive Case (Progress)

The function calls itself with a smaller or simpler input, making progress toward the base case.

Warning

If you forget the base case, or if the recursive case doesn't make progress, you get infinite recursion and a StackOverflowError.

Flowchart

┌──────────────┐ │ function(n) │ └──────┬───────┘ │ ┌──────▼───────┐ │ Is it the │ │ base case? │ └──┬────────┬──┘ YES │ │ NO │ │ ┌───────▼──┐ ┌──▼──────────────┐ │ Return │ │ Do some work │ │ known │ │ Call function( │ │ answer │ │ smaller input) │ └──────────┘ └──┬──────────────┘ │ │ (repeats until │ base case hit) ▼
3 / 18

Example: Factorial

n! = n * (n-1) * (n-2) * ... * 1   and   0! = 1

Recursive Definition

factorial(n): if n == 0: // base case return 1 else: // recursive case return n * factorial(n - 1)

Key Idea

factorial(4) can't finish until factorial(3) finishes, which can't finish until factorial(2) finishes, and so on down to factorial(0).

Call Stack: factorial(4)

EXPANDING (calls going down): factorial(4) = 4 * factorial(3) = 3 * factorial(2) = 2 * factorial(1) = 1 * factorial(0) = 1 <-- base case! COLLAPSING (returns coming back up): factorial(0) = 1 factorial(1) = 1 * 1 = 1 factorial(2) = 2 * 1 = 2 factorial(3) = 3 * 2 = 6 factorial(4) = 4 * 6 = 24 <-- answer!
4 / 18

The Call Stack

The call stack is how the computer keeps track of which function called which, what each function's local variables are, and where to return to.

How It Works

  • Each function call creates a stack frame
  • A frame holds: parameters, local variables, return address
  • Frames are pushed on call, popped on return
  • The stack grows downward in memory (conceptually upward in diagrams)

Stack Overflow

The stack has a finite size (typically a few MB). Too many recursive calls without returning will exhaust it, causing a StackOverflowError.

Stack during factorial(4)

At deepest point of recursion: ┌───────────────────────────┐ <-- TOP │ Frame: factorial(0) │ │ n = 0 │ │ return 1 │ ├───────────────────────────┤ │ Frame: factorial(1) │ │ n = 1 │ │ waiting for factorial(0)│ ├───────────────────────────┤ │ Frame: factorial(2) │ │ n = 2 │ │ waiting for factorial(1)│ ├───────────────────────────┤ │ Frame: factorial(3) │ │ n = 3 │ │ waiting for factorial(2)│ ├───────────────────────────┤ │ Frame: factorial(4) │ │ n = 4 │ │ waiting for factorial(3)│ ├───────────────────────────┤ │ Frame: main() │ │ called factorial(4) │ └───────────────────────────┘ <-- BOTTOM
5 / 18

Tracing Recursion Step by Step

Let's trace factorial(4) showing the stack at every step.

Press S to reveal each step.

Step 1: main calls factorial(4) Step 2: factorial(4) calls factorial(3) ┌──────────────────┐ ┌──────────────────┐ │ factorial(4) │ │ factorial(3) │ <-- new frame │ n=4 │ ├──────────────────┤ ├──────────────────┤ │ factorial(4) │ │ main() │ │ n=4, waiting │ └──────────────────┘ ├──────────────────┤ │ main() │ └──────────────────┘ Step 3: factorial(3) calls factorial(2) Step 4: factorial(2) calls factorial(1) ┌──────────────────┐ ┌──────────────────┐ │ factorial(2) │ │ factorial(1) │ ├──────────────────┤ ├──────────────────┤ │ factorial(3) │ │ factorial(2) │ │ n=3, waiting │ │ n=2, waiting │ ├──────────────────┤ ├──────────────────┤ │ factorial(4) │ │ factorial(3) │ │ n=4, waiting │ │ n=3, waiting │ ├──────────────────┤ ├──────────────────┤ │ main() │ │ factorial(4) │ └──────────────────┘ │ n=4, waiting │ ├──────────────────┤ │ main() │ └──────────────────┘ Step 5: factorial(1) calls factorial(0) Step 6: BASE CASE! factorial(0) returns 1 ┌──────────────────┐ ┌──────────────────┐ │ factorial(0) │ │ factorial(0) = 1 │ ---> POP! ├──────────────────┤ ├──────────────────┤ │ factorial(1) │ │ factorial(1) │ │ n=1, waiting │ │ gets 1 back │ ├──────────────────┤ ├──────────────────┤ │ factorial(2) │ │ ... │ │ n=2, waiting │ └──────────────────┘ ├──────────────────┤ │ factorial(3) │ Then returns unwind: │ n=3, waiting │ f(1)=1*1=1, f(2)=2*1=2 ├──────────────────┤ f(3)=3*2=6, f(4)=4*6=24 │ factorial(4) │ │ n=4, waiting │ ├──────────────────┤ │ main() │ <-- 6 frames! └──────────────────┘
6 / 18

Example: Fibonacci

fib(0)=0, fib(1)=1, fib(n) = fib(n-1) + fib(n-2) for n ≥ 2

Code

fib(n): if n == 0: return 0 // base if n == 1: return 1 // base return fib(n-1) + fib(n-2)

Sequence

n01234567
fib(n)011235813

Key Idea

Unlike factorial (one recursive call), Fibonacci makes two recursive calls per invocation. This creates a binary tree of calls.

Recursion Tree for fib(5)

fib(5) / \ fib(4) fib(3) / \ / \ fib(3) fib(2) fib(2) fib(1) / \ / \ / \ | fib(2) fib(1) f(1) f(0) f(1) f(0) 1 / \ | | | | | f(1) f(0) 1 1 0 1 0 | | 1 0 Total calls: 15 (for just fib(5)!)
7 / 18

Why Naive Fibonacci Is Slow

The recursion tree has O(2n) nodes because we recompute the same values over and over.

Repeated Subproblems

In fib(5), look how many times each value is computed: fib(5): 1 time fib(4): 1 time fib(3): 2 times <-- waste! fib(2): 3 times <-- waste! fib(1): 5 times <-- waste! fib(0): 3 times <-- waste! For fib(40): ~331 million calls! But only 41 unique subproblems.

Exponential Growth

fib(50) would take minutes. fib(100) would take longer than the age of the universe.

The Fix: Memoization

fib_memo(n, memo={}): if n in memo: return memo[n] // cached! if n == 0: return 0 if n == 1: return 1 memo[n] = fib_memo(n-1, memo) + fib_memo(n-2, memo) return memo[n]

Key Idea

Store results of subproblems so each is computed only once. This drops the time from O(2n) to O(n). This technique is the foundation of dynamic programming.

ApproachTimeSpace
Naive recursionO(2n)O(n) stack
MemoizedO(n)O(n)
IterativeO(n)O(1)
8 / 18

Example: Sum of an Array

Compute the sum of an array recursively: peel off one element at a time.

Code

sum(arr, n): // n = number of elements if n == 0: // base case return 0 return arr[n-1] + sum(arr, n-1)

Analogy

Imagine a stack of bills. To count the total: take the top bill, add its value to the total of the remaining stack. If the stack is empty, the total is $0.

Trace: sum([3, 7, 2, 5], 4)

sum([3,7,2,5], 4) = 5 + sum([3,7,2,5], 3) = 2 + sum([3,7,2,5], 2) = 7 + sum([3,7,2,5], 1) = 3 + sum([3,7,2,5], 0) = 0 <-- base case Unwinding: sum(arr, 0) = 0 sum(arr, 1) = 3 + 0 = 3 sum(arr, 2) = 7 + 3 = 10 sum(arr, 3) = 2 + 10 = 12 sum(arr, 4) = 5 + 12 = 17 <-- answer! Call stack at deepest point: ┌────────────────────┐ │ sum(arr, 0) n=0 │ ├────────────────────┤ │ sum(arr, 1) n=1 │ ├────────────────────┤ │ sum(arr, 2) n=2 │ ├────────────────────┤ │ sum(arr, 3) n=3 │ ├────────────────────┤ │ sum(arr, 4) n=4 │ └────────────────────┘
9 / 18

Example: Binary Search (Recursive)

Search a sorted array by repeatedly halving the search space. Classic divide and conquer.

Code

binarySearch(arr, target, lo, hi): if lo > hi: // base: not found return -1 mid = (lo + hi) / 2 if arr[mid] == target: // base: found! return mid if target < arr[mid]: return binarySearch(arr, target, lo, mid-1) else: return binarySearch(arr, target, mid+1, hi)

Key Idea

Each recursive call eliminates half the remaining elements. This gives us O(log n) time -- far better than linear search's O(n).

Trace: search for 7

arr = [1, 3, 5, 7, 9, 11, 13] index: 0 1 2 3 4 5 6 Call 1: lo=0, hi=6, mid=3 [1, 3, 5, 7, 9, 11, 13] ^ arr[3]=7 == target? YES! Found! Another example: search for 11 Call 1: lo=0, hi=6, mid=3 [ 1 3 5 7 9 11 13] ~~~~~~~~~~^~~~~~~~~~~ 11 > 7, so search right half Call 2: lo=4, hi=6, mid=5 [ 9 11 13] ~~~~^~~~~ arr[5]=11 == target? YES! Found! Search space halving: ┌─────────────────────────┐ n elements └────────────┐ │ ▼ │ ┌───────────┐ │ n/2 └─────┐ │ ▼ │ n/4 ┌─────┐ │ └──┐ │ n/8 ... until 1 ▼ O(log n) calls total
10 / 18

Example: Power Function

Compute xn recursively. Two approaches with very different performance.

Naive: O(n)

power(x, n): if n == 0: return 1 return x * power(x, n-1)
power(2, 8) = 2 * power(2, 7) = 2 * power(2, 6) = 2 * power(2, 5) ... 8 recursive calls total

Each call reduces n by 1, so we make n calls.

Fast: O(log n)

fastPower(x, n): if n == 0: return 1 half = fastPower(x, n / 2) if n is even: return half * half else: return x * half * half
fastPower(2, 8) half = fastPower(2, 4) half = fastPower(2, 2) half = fastPower(2, 1) half = fastPower(2, 0) = 1 return 2 * 1 * 1 = 2 return 2 * 2 = 4 return 4 * 4 = 16 return 16 * 16 = 256 Only 4 calls (log2(8) + 1)!

Key Idea

By squaring the half-result, we halve the exponent each time instead of subtracting 1. This is exponentiation by squaring.

11 / 18

Linear vs Binary/Multiple Recursion

Recursive functions are classified by how many recursive calls they make.

Linear Recursion (1 call)

Shape: a chain f(n) ──▶ f(n-1) ──▶ f(n-2) ──▶ ... ──▶ f(0) Examples: - factorial(n) - sum(arr, n) - binary search Depth: O(n) or O(log n) Total calls: O(n) or O(log n)

Key Idea

Linear recursion creates a chain. Stack depth equals the number of calls. Easy to convert to iteration.

Binary/Multiple Recursion (2+ calls)

Shape: a tree f(n) / \ f(n-1) f(n-2) / \ / \ ... ... ... ... Examples: - fibonacci(n) - merge sort - Towers of Hanoi Depth: O(n) or O(log n) Total calls: up to O(2^n) !

Warning

Binary recursion can be exponential in total calls even though the stack depth is only O(n). The tree can have far more nodes than its height.

12 / 18

Tail Recursion

A recursive call is a tail call if it is the very last operation the function performs -- nothing happens after the call returns.

NOT Tail Recursive

factorial(n): if n == 0: return 1 return n * factorial(n-1) ^ | Multiplication happens AFTER the recursive call returns. Must keep frame alive to do the multiply.

Tail Recursive Version

factorial(n, acc=1): if n == 0: return acc return factorial(n-1, n*acc) ^ | Nothing happens after this call. The recursive call IS the return. The frame can be reused!

Trace Comparison

NON-TAIL: factorial(4) Stack grows to depth 5: 4 * (3 * (2 * (1 * 1))) Must unwind all frames. TAIL: factorial(4, 1) factorial(4, 1) factorial(3, 4) // acc = 4*1 factorial(2, 12) // acc = 3*4 factorial(1, 24) // acc = 2*12 factorial(0, 24) // acc = 1*24 return 24 With tail-call optimization (TCO): Only 1 stack frame reused!

Key Idea

Tail recursion carries the result in an accumulator parameter. The compiler can optimize this into a simple loop -- same speed, O(1) stack space.

Note

Java and Python do not optimize tail calls. Functional languages (Haskell, Scheme, Scala) typically do.

13 / 18

Recursion vs Iteration

Any recursive algorithm can be converted to iteration (and vice versa). When should you use which?

Side by Side: Factorial

RECURSIVE: ITERATIVE: factorial(n): factorial(n): if n == 0: result = 1 return 1 for i in 1..n: return n * result *= i factorial(n-1) return result
AspectRecursionIteration
ClarityOften cleaner for trees, divide & conquerCleaner for simple loops
StackO(n) framesO(1)
SpeedFunction call overheadSlightly faster
RiskStack overflowInfinite loop

When to Use Recursion

  • Problem has a recursive structure (trees, graphs, nested data)
  • Divide and conquer algorithms (merge sort, quicksort)
  • Backtracking problems (N-queens, mazes)
  • The recursive solution is much simpler than iterative

When to Use Iteration

  • Simple linear computations (sum, factorial)
  • Performance-critical code
  • Very deep recursion possible (n > 10000)
  • Language lacks tail-call optimization

Analogy

Recursion is like giving instructions to a chain of helpers -- each passes a smaller task down. Iteration is like doing it yourself in a loop. Both finish the job; pick the one that's clearest.

14 / 18

Common Recursion Patterns

Most recursive algorithms fall into one of these three families.

Divide and Conquer

Split the problem into independent subproblems, solve each recursively, then combine results.

problem / \ sub1 sub2 | | solve solve \ / combine

Examples: merge sort, quicksort, binary search, fast power

Decrease and Conquer

Reduce the problem by a constant amount (usually 1) each step. Linear chain of calls.

f(n) | f(n-1) | f(n-2) | ... | f(0) base

Examples: factorial, sum of array, insertion sort, selection sort

Backtracking

Try a choice, recurse. If it fails, undo the choice and try the next option.

try A / \ ok? fail | | try B undo A / \ try C ok? fail ...

Examples: N-queens, maze solving, sudoku, generating permutations

15 / 18

Towers of Hanoi

Move n disks from peg A to peg C using peg B as auxiliary. Rules: move one disk at a time; never place a larger disk on a smaller one.

Press S to step through the solution for 3 disks.

Recursive Solution

hanoi(n, from, to, aux): if n == 1: move disk 1 from 'from' to 'to' return hanoi(n-1, from, aux, to) // top n-1 to aux move disk n from 'from' to 'to' hanoi(n-1, aux, to, from) // n-1 from aux to to

Key Idea

To move n disks: (1) move the top n-1 disks out of the way, (2) move the big disk, (3) move the n-1 disks on top. It takes 2n - 1 moves.

For 3 disks: 23 - 1 = 7 moves

Initial State

A B C | | | [1] | | [ 2 ] | | [ 3 ] | | ======== ======== ========

Move 1: disk 1 A->C

A B C | | | | | | [ 2 ] | | [ 3 ] | [1] ======== ======== ========

Move 2: disk 2 A->B

A B C | | | | | | | | | [ 3 ] [ 2 ] [1] ======== ======== ========

Move 3: disk 1 C->B

A B C | | | | | | | [1] | [ 3 ] [ 2 ] | ======== ======== ========

Move 4: disk 3 A->C

A B C | | | | | | | [1] | | [ 2 ] [ 3 ] ======== ======== ========

Move 5: disk 1 B->A

A B C | | | | | | | | | [1] [ 2 ] [ 3 ] ======== ======== ========

Move 6: disk 2 B->C

A B C | | | | | | | | [ 2 ] [1] | [ 3 ] ======== ======== ========

Move 7: disk 1 A->C -- DONE!

A B C | | | | | [1] | | [ 2 ] | | [ 3 ] ======== ======== ========
16 / 18

Common Mistakes

Debugging recursion can be tricky. Watch out for these pitfalls.

1. Missing Base Case

// BROKEN -- no base case! factorial(n): return n * factorial(n-1) factorial(3) -> factorial(2) -> factorial(1) -> factorial(0) -> factorial(-1) -> factorial(-2) -> ... STACK OVERFLOW!

Fix: Always define when to stop.

2. Not Making Progress

// BROKEN -- n never changes! countdown(n): if n == 0: return print(n) countdown(n) // should be n-1 Infinite loop: countdown(3) -> countdown(3) -> countdown(3) -> ...

Fix: Each call must move closer to the base case.

3. Wrong Base Case

// BROKEN -- base case never reached! factorial(n): if n == 1: return 1 // what about 0? return n * factorial(n-1) factorial(0) -> 0 * factorial(-1) -> -1 * factorial(-2) ... Should handle n == 0 too!

Fix: Consider all possible inputs, including edge cases.

4. Redundant Computation

// WORKS but extremely slow: fib(n): if n <= 1: return n return fib(n-1) + fib(n-2) fib(5) computes fib(2) three times, fib(1) five times... O(2^n) when O(n) is possible!

Fix: Use memoization or convert to iteration.

Debugging Tip

Add print statements at the start of the function showing the parameters, and at the end showing the return value. This makes the call tree visible.

17 / 18

Summary & Cheat Sheet

The Recursion Recipe

  1. Identify the base case(s) -- the simplest input(s) with a known answer
  2. Identify the recursive case -- how to break the problem into smaller pieces
  3. Ensure each call makes progress toward a base case
  4. Trust the recursion -- assume the recursive call returns the correct answer for the smaller problem
  5. Combine the result of the recursive call with the current step
Pattern Time Example ───────────────────────────────────────── Decrease by 1 O(n) factorial Decrease by half O(log n) bin search Two calls (dep.) O(2^n)* fibonacci* Two calls (indep.) O(n log n) merge sort Backtracking varies N-queens * without memoization

Key Concepts to Remember

ConceptWhat It Means
Base CaseWhen to stop recursing
Recursive CaseSelf-call with smaller input
Call StackFrames tracking each call
Stack OverflowToo-deep recursion
Tail RecursionRecursive call is last op
MemoizationCache repeated subproblems
Divide & ConquerSplit, solve halves, combine
BacktrackingTry, recurse, undo if fail

Final Analogy

Recursion is the art of solving a problem by saying: "If I could magically solve a slightly smaller version of this problem, how would I use that to solve the original?" Then you just need to handle the tiniest case directly.

CS205 Data Structures · Recursion

18 / 18