GUIDE · Recursion

Recursion patterns

a function defined in terms of smaller instances of itself; base case stops it.

lesson12 sectionsruns in browser35 asserts137 lines

At a glance

WHAT
a function defined in terms of smaller instances of itself; base case stops it.
TELL
"nested", "tree / list shaped", "defined in terms of itself", "divide and conquer", "all combinations", "flatten", "deep".
SAY
base caserecursive casecall stack / stack framerecursion depthmemoizationtail calldivide and conquer"trust the recursion" (assume the sub-call is correct)mutual recursionexplicit stack (iterative conversion)
BIG-O
time = number of calls x work per call; space = max depth of the call stack.

The idea#

Picture a set of Russian dolls. You open the outer doll and there is a smaller doll inside, the same shape, the same job. You open that one too. Eventually you reach the tiny solid doll that does not open. That solid doll is the base case. Every larger doll is the recursive case: make the problem smaller, then trust that the doll inside already knows how to finish the job.

In code a function is defined in terms of a smaller instance of itself. The call stack is the line of open dolls sitting on the table. Each call is a frame. The depth is how many dolls are open at once. You do not re-solve the inner doll while you are holding it. You wait for it to come back with an answer, then you do your one piece: add a digit, append a character, move one disk.

The rule is two lines. The base case returns directly. The recursive case makes the input smaller and trusts the sub-result. If the input never shrinks, you never reach the solid doll, and the table fills until it tips over.

Why it matters#

Anything nested has this shape: a tree, a list of lists, a value defined by smaller values of the same kind, a divide-and-conquer split, a flatten, a deep copy. "All combinations" often starts here too, though the search version of that lives with backtracking. You reach for recursion when the problem is already a smaller copy of itself.

Time is the number of calls times the work inside each call. Space is the deepest the call stack gets, which is the tallest stack of open dolls, even when the total number of dolls you ever open is larger. A naive Fibonacci opens an exponential number of dolls, on the order of φ to the n, because it reopens the same inner dolls over and over. A memo map writes each answer down once and brings that back to linear time and linear space.

A small example#

JS
function openDolls(doll) {  if (doll.inside === null) return 1;  return 1 + openDolls(doll.inside);} const set = { inside: { inside: { inside: null } } };const count = openDolls(set);

The solid center returns 1. Each outer doll adds itself to whatever the inner doll returned. count is 3. The function never inspects a doll two levels down. It trusts the recursive call.

What the code below is doing#

The sheet starts with that anatomy: base case, then a smaller input, then trust. Fast exponentiation is divide and conquer on the exponent. You square x^(n/2) and multiply by x one extra time when n is odd, so the depth is logarithmic. Sum of digits is the last digit plus the sum of the rest. Reverse is the reverse of the rest, then the first character. As a demo that is fine; string concatenation makes the time quadratic even though the depth is linear.

Flatten walks a nested array and pushes a value when it is not an array. Deep clone maps arrays recursively, rebuilds objects from their entries, and returns primitives as they are. The sheet notes that a built-in structured clone covers more kinds of values, including dates and cycles, and still skips functions. Tower of Hanoi is the three-step doll: move n - 1 to the spare peg, move one disk to the target, move n - 1 from the spare onto the target. That is O(2^n) moves.

Fibonacci is shown twice, naive and memoized, so you can see the same dolls recomputed and then computed once. The next section turns the call stack into an explicit stack you push and pop yourself. Same cost, and you can see the frames. Tail calls are in the language spec. V8 still grows the stack on a tail-recursive loop, so a tail call is a doll you still have to hold. The depth probe on a default Node stack lands roughly ten to twelve thousand frames, then a RangeError. A linked list of a hundred thousand nodes will blow up if you reverse it recursively. Mutual recursion is two functions calling each other, isEven and isOdd, each with its own base case.

When you talk about it, say this: name the base case, show that each call gets smaller, and trust the inner doll.

Everything below is the demonstration. Same idea, written as code you can run.

JSalgorithms/recursion.js · setup
'use strict';const assert = require('node:assert/strict');
01

Anatomy#

(a) base case returns directly; (b) recursive case makes the input smaller and TRUSTS the sub-result

JS
function factorial(n) {  if (n <= 1) return 1; // base case  return n * factorial(n - 1); // recursive case: assume factorial(n-1) is right}
Verified by 2 asserts
JS
assert.equal(factorial(5), 120);assert.equal(factorial(0), 1);
02

Fast exponentiation (exponentiation by squaring)#

x^n = (x^(n/2))^2 * (x if n odd)

O(log n) / O(log n) stack
JS
function power(x, n) {  if (n === 0) return 1;  if (n < 0) return 1 / power(x, -n);  const half = power(x, Math.floor(n / 2));  return n % 2 === 0 ? half * half : half * half * x;}
Verified by 3 asserts
JS
assert.equal(power(2, 10), 1024);assert.equal(power(2, -2), 0.25);assert.equal(power(7, 0), 1);
03

Sum of digits#

last digit + sum of the rest

O(digits)
JS
const sumDigits = (n) => (n < 10 ? n : (n % 10) + sumDigits(Math.floor(n / 10)));
Verified by 3 asserts
JS
assert.equal(sumDigits(1234), 10);assert.equal(sumDigits(0), 0);assert.equal(sumDigits(9), 9);
04

Reverse string recursively#

reverse(rest) + first

O(n) / O(n) stack (O(n^2) with string concat; fine as a demo)
JS
const reverseStr = (s) => (s.length <= 1 ? s : reverseStr(s.slice(1)) + s[0]);
Verified by 3 asserts
JS
assert.equal(reverseStr('hello'), 'olleh');assert.equal(reverseStr(''), '');assert.equal(reverseStr('a'), 'a');
05

Flatten nested arrays#

for each item: array ? recurse : push

O(total items) / O(depth)
JS
function flatten(arr, out = []) {  for (const x of arr) Array.isArray(x) ? flatten(x, out) : out.push(x);  return out;}
Verified by 4 asserts
JS
assert.deepEqual(flatten([1, [2, [3, [4]], 5]]), [1, 2, 3, 4, 5]);assert.deepEqual(flatten([]), []);assert.deepEqual(flatten([[[]]]), []);assert.deepEqual([1, [2, [3]]].flat(Infinity), [1, 2, 3]); // built-in equivalent
06

Deep clone (plain data)#

arrays map recursively; objects rebuild from entries; primitives returned as is

O(n)
JS
function deepClone(v) {  if (Array.isArray(v)) return v.map(deepClone);  if (v !== null && typeof v === 'object') return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, deepClone(x)]));  return v;}const orig = { a: [1, { b: 2 }], c: null };const copy = deepClone(orig);// NOTE: structuredClone(v) exists in Node 17+; handles Map/Set/Date/cycles but not functions.
Verified by 4 asserts
JS
assert.deepEqual(copy, orig);assert.notEqual(copy.a, orig.a);assert.notEqual(copy.a[1], orig.a[1]);assert.equal(deepClone(5), 5);
07

Tower of Hanoi#

move n-1 to aux, move 1 to target, move n-1 from aux to target

O(2^n) moves
JS
function hanoi(n, from = 'A', to = 'C', via = 'B', moves = []) {  if (n === 0) return moves;  hanoi(n - 1, from, via, to, moves);  moves.push([from, to]);  hanoi(n - 1, via, to, from, moves);  return moves;}
Verified by 3 asserts
JS
assert.equal(hanoi(3).length, 7); // 2^n - 1assert.deepEqual(hanoi(1), [['A', 'C']]);assert.deepEqual(hanoi(0), []);
08

Fibonacci: naive vs memoized#

naive O(phi^n) recomputes; memo Map makes each n computed once

O(n) / O(n)
JS
function fibNaive(n) { return n < 2 ? n : fibNaive(n - 1) + fibNaive(n - 2); }function fibMemo(n, memo = new Map()) {  if (n < 2) return n;  if (memo.has(n)) return memo.get(n);  const v = fibMemo(n - 1, memo) + fibMemo(n - 2, memo);  memo.set(n, v);  return v;}
Verified by 4 asserts
JS
assert.equal(fibNaive(10), 55);assert.equal(fibMemo(10), 55);assert.equal(fibMemo(90), 2880067194370816000); // naive would take forever; exact up to fib(78) in doubles, close enough hereassert.equal(fibMemo(0), 0);
09

Recursion -> iteration with an explicit stack#

push the initial frame; loop: pop, do work, push sub-frames

same O()
JS
//        Worked example: sum of a nested array. Recursive version first, then the stack version.const sumNestedRec = (arr) => arr.reduce((s, x) => s + (Array.isArray(x) ? sumNestedRec(x) : x), 0);function sumNestedIter(arr) {  const stack = [arr]; // frames = things still to process  let sum = 0;  while (stack.length) {    const cur = stack.pop();    if (Array.isArray(cur)) stack.push(...cur); // "recurse" = push children    else sum += cur;  }  return sum;}const nested = [1, [2, [3, [4, [5]]]], 6];// For in-order tree traversal the iterative form needs a "visited" marker or the classic// `while (cur || stack.length) { while (cur) { stack.push(cur); cur = cur.left; } cur = stack.pop(); visit; cur = cur.right; }`.
Verified by 3 asserts
JS
assert.equal(sumNestedRec(nested), 21);assert.equal(sumNestedIter(nested), 21);assert.equal(sumNestedIter([]), 0);
10

Tail-call note#

JS spec (ES2015) has proper tail calls but V8/Node do NOT implement TCO. A tail-recursive loop still

JS
//        grows the stack. If depth may exceed ~10^4, convert to a loop or explicit stack.function sumToTail(n, acc = 0) { return n === 0 ? acc : sumToTail(n - 1, acc + n); } // tail position, still O(n) stack in V8
Verified by 1 assert
JS
assert.equal(sumToTail(1000), 500500);
11

Max recursion depth in Node#

roughly 10k-12k frames by default (depends on frame size). Deeper => RangeError.

JS
//        Avoid: iterate, use an explicit stack, memoize bottom-up (DP tabulation), or increase --stack-size (not available in assessments).function depthProbe(n) { try { return depthProbe(n + 1); } catch { return n; } }const maxDepth = depthProbe(0);// A linked-list of 10^5 nodes reversed recursively WILL blow the stack; iterate instead.
Verified by 2 asserts
JS
assert.ok(maxDepth > 5000 && maxDepth < 100000, `observed depth ${maxDepth}`);assert.throws(() => { const f = (n) => f(n + 1) + 1; f(0); }, RangeError); // "Maximum call stack size exceeded"
12

Mutual recursion#

two functions call each other; base case in each

O(n)
JS
const isEven = (n) => (n === 0 ? true : isOdd(n - 1));const isOdd = (n) => (n === 0 ? false : isEven(n - 1));
Verified by 3 asserts
JS
assert.equal(isEven(10), true);assert.equal(isOdd(7), true);assert.equal(isEven(1), false);

See also