Recursion patterns and the recursion tree
Linear, binary and multi-branch recursion — and how to read their cost off the tree.
Draw the calls as a tree and the complexity reads straight off it: cost = number of nodes × work per node, and the depth is the space.
Linear recursion — one call per level
const sum = (a, i = 0) => (i === a.length ? 0 : a[i] + sum(a, i + 1));
n nodes in a chain: O(n) time, O(n) stack. A loop does this in O(1) space.
Binary recursion — two calls per level
const fib = (n) => (n < 2 ? n : fib(n - 1) + fib(n - 2));
The tree branches twice per level to depth n: roughly 2ⁿ nodes. fib(40) is over a billion calls, and most of them recompute values already computed. That redundancy is exactly what memoisation removes, and it is the doorway to dynamic programming.
Divide and conquer — two calls on halves
const mergeSortCost = 'T(n) = 2T(n/2) + O(n)'; // → O(n log n)
Also two branches, but each works on half the input, so there are log n levels with n work each. The difference between this and fib is what the recursive call receives, not how many calls there are.
Multi-branch recursion — k calls per level
Permutations branch n ways, then n−1, then n−2: O(n!) leaves. Subsets branch twice per element: O(2ⁿ). These are backtracking's territory, and they are only viable for small n.
The Master Theorem, briefly
For T(n) = a·T(n/b) + O(nᵈ):
| Condition | Result | Example |
|---|---|---|
| d > log_b a | O(nᵈ) | T(n) = 2T(n/2) + O(n²) → O(n²) |
| d = log_b a | O(nᵈ log n) | Merge sort → O(n log n) |
| d < log_b a | O(n^log_b a) | Karatsuba → O(n^1.585) |
Turning a recursion into a memoised one
const fibFast = (n, memo = new Map()) => {
if (n < 2) return n;
if (memo.has(n)) return memo.get(n);
const value = fibFast(n - 1, memo) + fibFast(n - 2, memo);
memo.set(n, value);
return value;
};
Two lines turn O(2ⁿ) into O(n): each distinct argument is computed once. If your recursion tree repeats subproblems, this is always the first thing to try.