Recursion (HL)
What Is Recursion?
Recursion is a programming technique where a function calls itself to solve smaller instances of the same problem.
It is particularly useful for problems that can be broken into smaller, similar sub-problems.
How Recursion Works
- Define a Base Case: The simplest instance (e.g. factorial(0) = 1) where no further recursion is needed.
- Define the Recursive Case: Express the problem in terms of a smaller instance (e.g. factorial(n) = n × factorial(n–1)).
- Break Down: Each call reduces the problem size until the base case is reached.
- Unwind: Once the base case is reached, return values propagate back up the call chain to build the final result.
Analogies:
- Russian nesting dolls: To open the largest doll, you open each one inside it until you reach the smallest, then reverse the process.
- (Fractal) tree drawing: Draw a branch, then recursively draw smaller branches at its end, and so on.
Advantages and Limitations of Recursion
| Advantages | Limitations |
|---|---|
| Simplifies code for divide-and-conquer problems. | Consumes more memory due to call stack/memory overhead. |
| Ideal for tree and graph traversals. | Risk of stack overflow if base case is missing or too deep. |
| More intuitive for factorials, and Fibonacci. | Generally slower than iterative versions because of function calls. |
Example: Recursive Quicksort
Algorithm Steps (Quicksort)
- Choose a pivot element.
- Partition remaining elements into left (≤ pivot) and right (> pivot) lists.
- Recursively sort left and right sublists.
- Concatenate sorted left, pivot(s), and sorted right.
def quicksort(arr):
if len(arr) <= 1:
return arr
pivot = arr[len(arr)//2]
left = [x for x in arr if x < pivot]
middle = [x for x in arr if x == pivot]
right = [x for x in arr if x > pivot]
return quicksort(left) + middle + quicksort(right)
numbers = [10, 3, 8, 6, 7, 5]
print("Sorted Array:", quicksort(numbers))
import java.util.Arrays;
public class QuickSort {
public static void quickSort(int[] arr, int low, int high) {
if (low < high) {
int pi = partition(arr, low, high);
quickSort(arr, low, pi - 1);
quickSort(arr, pi + 1, high);
}
}
private static int partition(int[] arr, int low, int high) {
int pivot = arr[high];
int i = low - 1;
for (int j = low; j < high; j++) {
if (arr[j] < pivot) {
i++;
int temp = arr[i]; arr[i] = arr[j]; arr[j] = temp;
}
}
int temp = arr[i + 1]; arr[i + 1] = arr[high]; arr[high] = temp;
return i + 1;
}
public static void main(String[] args) {
int[] numbers = {10, 3, 8, 6, 7, 5};
quickSort(numbers, 0, numbers.length - 1);
System.out.println("Sorted Array: " + Arrays.toString(numbers));
}
}
Example: Recursive and Iterative Factorial
Algorithm Steps (Factorial)
- Recursive:
- If n == 0 → return 1 (base case).
- Else → return n × factorial(n–1).
- Iterative:
- Initialise result = 1.
- Loop i from 1 to n, multiply result by i each time.
- Return result.
Visualisation
def factorial(n):
if n == 0:
return 1
return n * factorial(n - 1)
print("Factorial:", factorial(5))
def factorial_iterative(n):
result = 1
for i in range(1, n + 1):
result *= i
return result
print("Factorial:", factorial_iterative(5))
public class RecursionExample {
public static int factorial(int n) {
if (n == 0) return 1;
return n * factorial(n - 1);
}
public static void main(String[] args) {
System.out.println("Factorial: " + factorial(5));
}
}
public class IterativeFactorial {
public static int factorial(int n) {
int result = 1;
for (int i = 1; i <= n; i++) {
result *= i;
}
return result;
}
public static void main(String[] args) {
System.out.println("Factorial: " + factorial(5));
}
}
EXTENSION: Memoised vs Iterative DP (Fibonacci)
The naive recursive Fibonacci (calling fib(n-1) and fib(n-2) each time) repeats work, so time grows
exponentially (about O(2^n)) and the call stack can get deep (O(n)). Memoised recursion keeps the same
top-down shape but caches results so each n is solved once - time O(n), still uses recursion so stack O(n).
Iterative DP (Dynamic Programming) builds values in a simple loop from F(0) and F(1) - also O(n) time, but with only
constant extra space (O(1)) and no recursion overhead.
- Memoised (top-down): recursion + cache → time O(n), stack O(n).
- Iterative DP (bottom-up): loop only → time O(n), stack O(1).
# Python - Memoised recursion (top-down DP)
from functools import lru_cache
@lru_cache(maxsize=None)
def fib(n: int) -> int:
if n < 2:
return n
return fib(n-1) + fib(n-2)
# Python - Iterative DP (bottom-up)
def fib_it(n: int) -> int:
a, b = 0, 1
for _ in range(n):
a, b = b, a + b
return a
// Java - Memoised recursion (top-down DP)
static int fibMemo(int n, int[] memo) {
if (n < 2) return n;
if (memo[n] != -1) return memo[n];
return memo[n] = fibMemo(n - 1, memo) + fibMemo(n - 2, memo);
}
// Java - Iterative DP (bottom-up)
static int fibIt(int n) {
int a = 0, b = 1;
for (int i = 0; i < n; i++) {
int next = a + b;
a = b;
b = next;
}
return a;
}
Applications of Recursion
- Sorting: Quicksort, mergesort.
- Tree Traversal: In-order, pre-order, post-order of binary trees.
- Graph Traversal: Depth-first search (DFS).
- Fractal Generation: Drawing self-similar patterns.
- Mathematical: Fibonacci, factorials, exponentiation.
Key Takeaways
- Recursion breaks problems into smaller, simpler versions of the problem via self-calls.
- Elegant for divide-and-conquer (Quick Sort, Merge Sort), tree/graph traversals.
- Watch for stack depth and memory usage.
- Base cases must be correct.