Skip to the chapter
←DSAGGroundworkΣDSA
DSA/Beginner/B9
9 / 48 written chapters
/

Levels and plan
  1. B1Complexity analysis
  2. B2JavaScript toolkit
  3. B3Arrays & strings
  4. B4Prefix sums
  5. B5Hashing
  6. B6Two pointers
  7. B7Sliding window
  8. B8Binary search
  9. B9Basic recursion
  10. B10Sorting algorithms
  11. B11Stacks & queues
  12. B12Linked lists
  1. I1Trees
  2. I2BST operations
  3. I3Tree problems in depth
  4. I4Heaps & priority queues
  5. I5Graphs: representation
  6. I6BFS on grids
  7. I7Graph problems
  8. I8Topological patterns
  9. I9Backtracking
  10. I10DP: 1D
  11. I11DP: 2D
  12. I12DP state machines
  13. I13Greedy algorithms
  14. I14Intervals
  15. I15Bit manipulation
  16. I16Matrix problems
  17. I17Tries
  18. I18Monotonic stack & queue
  19. I19Math for interviews
  20. I20Design & simulation
  21. I21Divide and conquer
  22. I22Randomization
  1. A1Advanced DP
  2. A2Union-Find
  3. A3Advanced graph algorithms
  4. A4Minimum Spanning Tree
  5. A5Advanced graphs
  6. A6Segment & Fenwick trees
  7. A7Sparse tables
  8. A8String algorithms
  9. A9Design problems
  10. A10Advanced backtracking
  11. A11Games & probability DP
  12. A12Ordered structures
  13. A13Sweep line & geometry
  14. A14Interview strategy

ΣB9 · Part 1 · Beginner

Basic recursion

Trees, backtracking, DP and divide-and-conquer are all recursion wearing a costume.

30 min read2 diagrams18 sections13 exercises
9 of 48

In this chapter

  • Every recursive function is two things
  • The call stack is a real stack — draw it
  • The leap of faith: write one step, trust the call
  • Recursion on arrays — shrink by one end
  • Slicing hides a cost: count what each call copies

and 13 more

about 30 min
18On this page
  1. Every recursive function is two things
  2. The call stack is a real stack — draw it
  3. The leap of faith: write one step, trust the call
  4. Recursion on arrays — shrink by one end
  5. Slicing hides a cost: count what each call copies
  6. Return it up, or carry it down
  7. Recursion on trees — the shape you'll use constantly later
  8. Nested data: when the input is the recursion
  9. Multiple recursive calls — the branching factor matters
  10. Memoized Fibonacci: each distinct call runs once
  11. Halving the problem: fast exponentiation
  12. Choose or don't choose: generating subsets
  13. Two classics: gcd and the Tower of Hanoi
  14. Reading the cost off the recursion tree
  15. Converting recursion to iteration (when asked)
  16. How deep is too deep? Measure it, then own the stack
  17. The mistakes that cost points
  18. Recognizing it in an unseen problem

Every recursive function is two things

A base case (the answer you can state directly, no further calls needed) and a recursive case (the problem restated in terms of a smaller version of itself). Skip the base case and you get infinite recursion; get the smaller-version part wrong and it either never terminates or terminates with the wrong answer.

function factorial(n) {
  if (n <= 1) return 1;           // base case — the floor
  return n * factorial(n - 1);    // recursive case — smaller problem
}

Dry run: factorial(4)

Callnn <= 1?Returns
factorial(4)4nowaits on 4 × factorial(3)
factorial(3)3nowaits on 3 × factorial(2)
factorial(2)2nowaits on 2 × factorial(1)
factorial(1)1yes1 (base case — no further call)
factorial(2) resumes——2 × 1 = 2
factorial(3) resumes——3 × 2 = 6
factorial(4) resumes——4 × 6 = 24

Nothing multiplies until factorial(1) hits the base case — every call above it is suspended mid-expression, waiting on its own recursive call to resolve. That's exactly the four simultaneously-alive stack frames the space warning below is about: the actual arithmetic only happens on the way back up, never on the way down.

The call stack is a real stack — draw it

factorial(4) → factorial(3) factorial(3) → factorial(2) factorial(2) → factorial(1) factorial(1) → returns 1 ↑ builds up going DOWN into calls ↓ unwinds going UP, multiplying as it returns 1 → 2×1=2 → 3×2=6 → 4×6=24
Nothing multiplies until the base case is hit — then the answers flow back up.
⚠ Recursion isn't free space Every call sits on the stack until it returns — n nested calls is O(n) space, not O(1), even though you never wrote new Array() anywhere. Deep enough recursion (around ten thousand levels in node, measured below) throws a real RangeError: Maximum call stack size exceeded. This is a legitimate interview follow-up: "can you do this iteratively instead?"

The leap of faith: write one step, trust the call

The dry run above shows what recursion does. It is a poor way to write it: if you try to hold five suspended calls in your head while you decide the next line, you will lose the thread by the fourth. Write it the way a proof by induction is written. Assume the function already returns the right answer for every smaller input, and write only the step that turns that answer into yours. Three questions, in this order, and the function mostly falls out:

  • What is the smallest input I can answer without a recursive call? That is the base case. Check that it covers every smallest input, not just the one you were thinking of.
  • If someone handed me the correct answer for a smaller input, what would I do with it? That is the recursive case. It is one step, not a plan for the whole recursion.
  • Is the input I hand down strictly smaller, so the base case is eventually reached? That is the progress check, and it is where most infinite recursions are born.
function sumDigits(n) {
  if (n < 10) return n;                            // base: one digit, the answer is the number itself
  return (n % 10) + sumDigits(Math.floor(n / 10));  // last digit + (trust: the rest is already right)
}

function isPalindrome(s, lo = 0, hi = s.length - 1) {
  if (lo >= hi) return true;                 // base: zero or one character left
  if (s[lo] !== s[hi]) return false;          // the outer pair can settle it alone
  return isPalindrome(s, lo + 1, hi - 1);     // otherwise trust the inside
}

Notice what the bodies do not contain: no loop over the digits, no bookkeeping about how many calls are pending. sumDigits(4072) is 2 + sumDigits(407), and you are allowed to believe sumDigits(407) without opening it up. That permission is the leap of faith, and it is justified: the base case is correct, and if every call on a smaller input is correct then so is the step built on it, so by induction every size is correct.

Dry run: isPalindrome("abca") and isPalindrome("abba")

Calllohis[lo] vs s[hi]Returns
isPalindrome("abca")03a = atrusts the call on (1, 2)
isPalindrome("abca")12b ≠ cfalse, with no further call
isPalindrome("abba")03a = atrusts the call on (1, 2)
isPalindrome("abba")12b = btrusts the call on (2, 1)
isPalindrome("abba")21—lo >= hi: true (nothing left)

The last row is the reason the base case says lo >= hi and not lo === hi. A string of even length shrinks from both ends and the two indices cross without ever being equal. With === the call on "abba" carries on to (3, 0), then to indices off both ends of the string where undefined === undefined keeps the comparison happy, and finally throws a RangeError. When you write a base case, list every input size that can arrive there, not just the one you drew.

⚠ The three ways the leap of faith breaks No progress: the recursive call receives the same input (or one that moves away from the base case), so the leap trusts a call that never returns. A base case that is too narrow: it matches the input you imagined and skips its neighbour, as lo === hi does above. A step that does more than one step: if the body re-solves part of the smaller problem itself, you are no longer trusting the call, and the code becomes hard to reason about and often slow. Fix the step so it uses the returned answer and nothing deeper.

Recursion on arrays — shrink by one end

function sum(arr, i = 0) {
  if (i === arr.length) return 0;      // base case: ran off the end
  return arr[i] + sum(arr, i + 1);      // smaller problem: one fewer element left
}

function reverseString(s) {
  if (s.length <= 1) return s;
  return reverseString(s.slice(1)) + s[0]; // reverse the rest, then tack on the first char
}

Slicing hides a cost: count what each call copies

reverseString above is correct, but it is not the O(n) that its single recursive call suggests. Each call runs s.slice(1), which builds a new string of every character except the first, and then + s[0] builds another. The calls make n − 1, n − 2, … 1 characters' worth of copies, and that adds up to n(n − 1) / 2. Treat that as the cost model: one call per character, each call doing O(n) copying work, so O(n²) time overall. (An engine is free to share storage between a string and its slice, but you cannot rely on it and an interviewer will not grant it, so say O(n²).) The same trap appears in arrays whenever the recursion is written as f(arr.slice(1)) or f([...rest]).

String length nCharacters copied by slice (n(n − 1) / 2)
1045
1004,950
1,000499,500
5,00012,497,500

Those counts were measured by wrapping String.prototype.slice in node and adding up the lengths it returned (499,500 for a 1,000-character string), so they match the formula exactly. The fix is to stop making smaller copies and instead pass indices that describe the smaller problem. The recursion shape is identical and each call does O(1) work:

function reverseInPlace(chars, lo = 0, hi = chars.length - 1) {
  if (lo >= hi) return chars;                         // base: zero or one element left
  [chars[lo], chars[hi]] = [chars[hi], chars[lo]];     // swap the outer pair
  return reverseInPlace(chars, lo + 1, hi - 1);        // trust the inside
}
// reverseInPlace(["a","b","c","d"]) → ["d","c","b","a"]

This does about n / 2 calls, each O(1), so O(n) time, and the call stack gets about n / 2 deep, so O(n) space for the stack. It reverses the array it is given; strings are immutable in JavaScript, so you reverse [...s] and join the result at the end. The general rule worth saying out loud: pass the boundaries of the subproblem, not a copy of it. Binary search, merge sort on index ranges and every backtracking routine later in the course follow it.

Return it up, or carry it down

A recursive call can communicate in two directions. So far the answers have flowed up: each call returns a value, and the caller combines it (sum, treeHeight). The other option is to flow information down: the caller hands the call what has happened so far as an extra parameter, and the call either reports it at the base case or records it somewhere shared. The same sum, both ways:

function sumReturn(arr, i = 0) {              // answer flows UP
  if (i === arr.length) return 0;
  return arr[i] + sumReturn(arr, i + 1);      // the addition waits until the call returns
}

function sumCarry(arr, i = 0, total = 0) {    // answer flows DOWN, in the parameter
  if (i === arr.length) return total;         // the finished total is simply passed back up
  return sumCarry(arr, i + 1, total + arr[i]);
}

sumCarry has nothing left to do after the recursive call, which is called a tail call. In some languages that lets the compiler reuse the frame. Do not count on it in JavaScript: calling sumCarry on an array of 100,000 elements throws a RangeError in node, exactly like sumReturn would. Carrying the total down changes the shape of the code, not the stack depth. If depth is the problem, use a loop or the explicit stack from the conversion section below.

Carrying state down earns its keep when the answer depends on where you are rather than on the children: the depth of a node, the sum of the path so far, the list of choices made so far. Those need a parameter, and the extra parameters are an implementation detail the caller should not see. The standard shape is a clean public function wrapping an inner helper that carries the state:

function deepestLeafDepth(root) {
  let best = 0;                                // shared by every call of walk
  function walk(node, depth) {                 // depth is carried DOWN
    if (node === null) return;
    best = Math.max(best, depth);
    walk(node.left, depth + 1);
    walk(node.right, depth + 1);
  }
  walk(root, 1);
  return best;
}

This returns the same number as treeHeight from the tree section below, computed in the other direction. Return up when the answer is built from the answers to the smaller pieces (height, size, count). Carry down when it depends on the route taken to reach a node (depth, running total, the path itself). Plenty of tree problems do both at once, such as the maximum path sum, which the tree chapters cover.

⚠ Shared state is where carried recursion goes wrong Declare best inside the public function, as above. If it lives outside, at the top of the file, it keeps its value between calls and the second call starts from the first call's answer. Also remember that arrays and objects passed down are shared, not copied: if you collect a path array and push it into your results, you must push a copy ([...path]), or every result will end up pointing at the same array, which the subsets section below shows in action. Finally, a default parameter such as i = 0 becomes part of the public signature: nothing stops a caller passing sumCarry(arr, 5). A wrapper with an inner helper keeps the contract clean.

Recursion on trees — the shape you'll use constantly later

function treeHeight(node) {
  if (node === null) return 0;               // base case: empty subtree
  return 1 + Math.max(
    treeHeight(node.left),
    treeHeight(node.right)
  );                                          // combine two smaller answers
}

Notice this recursion branches into two calls, not one — that's the exact shape the Trees chapter builds on, and it's why tree recursion complexity is usually expressed in terms of the number of nodes visited, not a simple "n halves each time" story.

Nested data: when the input is the recursion

Sometimes you never choose to recurse, because the data is already recursive: an array that can contain arrays, a JSON object whose values can be objects. The tree pattern applies unchanged, with one twist worth stating plainly. The base case is not "empty", it is "this is not a container": a number, a string, null. Everything else is a node with children to visit.

function flatten(value, out = []) {
  if (!Array.isArray(value)) {               // base: a leaf, not a container
    out.push(value);
    return out;
  }
  for (const item of value) flatten(item, out);  // out is carried down and filled in place
  return out;
}

function nestingDepth(value) {
  if (!Array.isArray(value)) return 0;       // a leaf adds no nesting
  let deepest = 0;
  for (const item of value) deepest = Math.max(deepest, nestingDepth(item));
  return 1 + deepest;                        // this array itself is one level
}

Dry run: nestingDepth([1, [2, [3]], 4])

CallIs it an array?Children's depthsReturns
nestingDepth(1)no—0
nestingDepth(2)no—0
nestingDepth(3)no—0
nestingDepth([3])yes01 + 0 = 1
nestingDepth([2, [3]])yes0, 11 + 1 = 2
nestingDepth(4)no—0
nestingDepth([1, [2, [3]], 4])yes0, 2, 01 + 2 = 3

flatten was checked against the built-in arr.flat(Infinity) on random nested arrays. Time is O(total number of values and arrays), because every element is visited once, and the stack depth equals the nesting depth. A tempting shorter version, out.push(...flatten(item)), spreads each inner result into function arguments, and in node spreading an array of 150,000 elements into a call already throws a RangeError; carrying out down avoids both that and the repeated copying.

The same shape handles objects, with three details that cost people offers:

function sumNumbers(value, seen = new Set()) {
  if (typeof value === "number") return value;
  if (value === null || typeof value !== "object") return 0;  // null is typeof "object"!
  if (seen.has(value)) return 0;                              // already visited: breaks cycles
  seen.add(value);
  let total = 0;
  for (const child of Object.values(value)) total += sumNumbers(child, seen);
  return total;
}
  • typeof null === "object". Without the explicit null check, Object.values(null) throws a TypeError the first time a null value appears.
  • Cycles. If a.self = a, plain recursion never reaches a leaf and ends in a RangeError. The seen set records the containers already entered. (JSON has no cycles, which is why JSON.stringify throws on one.)
  • Shared references. The same seen set also skips an object that is merely reachable twice. For [shared, shared] where shared = { x: 1 }, this version returns 1 and a version without seen returns 2. Decide which answer the question wants, and say so.

Multiple recursive calls — the branching factor matters

Naive Fibonacci recomputes the same subproblems over and over — fib(5) calls fib(3) twice, fib(2) three times, and so on. That's O(2ⁿ) work for what's conceptually an O(n) amount of distinct information — memoization (its own chapter, under Dynamic Programming) is exactly the fix: cache each distinct call so it only ever computes once.

// O(2ⁿ) — recomputes the same subproblems repeatedly
function fibSlow(n) {
  if (n <= 1) return n;
  return fibSlow(n - 1) + fibSlow(n - 2);
}
fib(4) fib(3) fib(2) fib(2) fib(1) fib(1) fib(0) fib(1) fib(0) Red = recomputed more than once — fib(2) is computed twice, fib(1) three times Every level doubles the calls below it → O(2ⁿ) nodes in the tree total
This is what "each call branches into two more calls" actually looks like — and why it explodes.
Say it like this → "The base case is the smallest input I can answer directly without recursing, and the recursive case restates the problem on a strictly smaller input — as long as it's strictly smaller every time, the recursion is guaranteed to terminate."

Memoized Fibonacci: each distinct call runs once

The tree above is wasteful because fibSlow(2) means the same thing wherever it appears. A call is determined by its argument, so the first time you compute it, write the answer down; every later request is a lookup. That is all memoization is: a cache keyed by the arguments.

function fibMemo(n, memo = new Map()) {
  if (n <= 1) return n;
  if (memo.has(n)) return memo.get(n);                       // seen this argument: reuse the answer
  const result = fibMemo(n - 1, memo) + fibMemo(n - 2, memo);
  memo.set(n, result);                                        // store before returning
  return result;
}

Dry run: fibMemo(5), in the order things happen

StepCallWhat happensmemo afterwards
1fibMemo(5), (4), (3), (2)nothing cached yet, so each one recurses to fibMemo(n − 1) firstempty
2fibMemo(1), fibMemo(0)base cases, return 1 and 0empty
3fibMemo(2) resumes1 + 0 = 1, stored{2: 1}
4fibMemo(1)the second call of fibMemo(3): base case, 1{2: 1}
5fibMemo(3) resumes1 + 1 = 2, stored{2: 1, 3: 2}
6fibMemo(2)fibMemo(4)'s second call: cache hit, returns 1 without recursing{2: 1, 3: 2}
7fibMemo(4) resumes2 + 1 = 3, stored{2: 1, 3: 2, 4: 3}
8fibMemo(3)fibMemo(5)'s second call: cache hit, returns 2{2: 1, 3: 2, 4: 3}
9fibMemo(5) resumes3 + 2 = 5, stored{2: 1, 3: 2, 4: 3, 5: 5}

That is nine calls in total for fibMemo(5), against fifteen for fibSlow(5). The gap widens fast: counting every call, fibSlow(30) makes 2,692,537 calls and fibMemo(30) makes 59. In general memoized Fibonacci makes 2n − 1 calls: each of the n − 1 arguments from 2 to n is computed once and makes two calls, plus the first call.

The cost rule you will use from here on: time = (number of distinct states) × (work per state). Here there are n states and each does O(1) work besides its calls, so O(n). Space is the n memo entries plus the call stack, also O(n). When the loop version of the same recurrence is allowed, it drops the stack entirely:

function fibLoop(n) {
  let prev = 0, curr = 1;                          // fib(0), fib(1)
  for (let i = 0; i < n; i++) [prev, curr] = [curr, prev + curr];
  return prev;
}
⚠ Four ways a memo quietly fails The cache is recreated on every call. Writing const memo = new Map() at the top of fibMemo, instead of as a parameter or outside, gives each call its own empty cache. The answers stay right and the time stays exponential: fib(20) still makes 21,891 calls. Truthiness checks. if (memo[n]) return memo[n] treats a stored 0 as "not cached"; use has. Depth is not reduced. The memo cuts the time, not the nesting: in node here fibMemo(10000) still throws a RangeError, because the chain of calls from n down to 1 is n deep. Number size. JavaScript numbers hold every integer exactly only up to 253 − 1. fib(78) = 8,944,394,323,791,464 is the last Fibonacci number that fits; fib(79) is 14,472,334,024,676,221 in exact arithmetic, but the number version returns 14472334024676220. Use BigInt when the problem asks for large values.

Halving the problem: fast exponentiation

Computing xn as n − 1 multiplications is O(n), and for a large n that is too slow. Notice what squaring does: x10 = (x5)2, and x5 = x · (x2)2. In general, if you already have the answer for ⌊n / 2⌋, one squaring gets you the answer for n, with one extra factor of x when n is odd. The problem shrinks by half per call, so there are about log₂ n calls instead of n.

function power(x, n) {
  if (n === 0) return 1;                        // base: x⁰ = 1, even for x = 0 by convention
  if (n < 0) return 1 / power(x, -n);          // negative exponent: reciprocal of the positive one
  const half = power(x, Math.floor(n / 2));     // ONE call, result reused
  return n % 2 === 0 ? half * half : half * half * x;
}

Dry run: power(2, 10)

Callnhalf = power(x, ⌊n / 2⌋)Returns
power(2, 10)10needs power(2, 5)(waiting)
power(2, 5)5needs power(2, 2)(waiting)
power(2, 2)2needs power(2, 1)(waiting)
power(2, 1)1needs power(2, 0)(waiting)
power(2, 0)0base case1
power(2, 1) resumes1 (odd)11 · 1 · 2 = 2
power(2, 2) resumes2 (even)22 · 2 = 4
power(2, 5) resumes5 (odd)44 · 4 · 2 = 32
power(2, 10) resumes10 (even)3232 · 32 = 1024

Five calls for n = 10. The count is ⌊log₂ n⌋ + 2, which counting calls in node confirms for n = 10, 1,024 and 1,000,000 (5, 12 and 21 calls). Time is O(log n), and the call stack is the same depth, so O(log n) space. A million-fold larger exponent adds only about twenty calls.

⚠ The one-character mistake that undoes the whole idea Writing power(x, n / 2) * power(x, n / 2) makes two calls per level, each on half the exponent. The tree now doubles as it halves, so it has about n leaves and the running time is O(n), the same as the plain loop. For n = 1,024 the two-call version makes 4,095 calls and the one-call version makes 12. Always store the half in a variable. Related slips: use Math.floor(n / 2), not n / 2 (a fraction is not a valid exponent for this recursion), and note that n >> 1 would wrap an exponent of 231 or more into the wrong value because bitwise operators work on 32-bit integers.

Two practical notes. Floating point: the squaring order multiplies in a different sequence than the loop, so the last digits differ. power(1.0001, 10000) gave 2.718145926824389 while Math.pow(1.0001, 10000) gave 2.7181459268249255. Both are right to about twelve digits, so compare floating results with a tolerance. Modular exponentiation (find xn mod m, ubiquitous in number theory and hashing questions) uses the same halving, but the products grow past 253 in plain numbers: for m = 1,000,000,007 the square of m − 1 is about 10¹⁸, which a number cannot hold exactly. Use BigInt, and the loop form of the halving, which walks the bits of the exponent:

function powMod(base, exp, mod) {                 // all three are BigInt
  let result = 1n;
  base %= mod;
  while (exp > 0n) {
    if (exp & 1n) result = (result * base) % mod;   // this bit of the exponent is set
    base = (base * base) % mod;                    // base, base², base⁴, base⁸, ...
    exp >>= 1n;
  }
  return result;
}
// powMod(2n, 1000000n, 1000000007n) → 235042059n

Choose or don't choose: generating subsets

"All subsets" looks like it needs clever bookkeeping until you ask what a subset is: for each element, you either took it or you did not. That is a recursion with two calls per element, one for each choice, and a base case when every element has been decided. The "answer so far" is the array of chosen elements, carried down.

function subsets(nums) {
  const all = [];
  const current = [];
  function decide(i) {
    if (i === nums.length) {
      all.push([...current]);        // COPY: current keeps changing after this
      return;
    }
    decide(i + 1);                   // choice 1: leave nums[i] out
    current.push(nums[i]);           // choice 2: take it ...
    decide(i + 1);
    current.pop();                   // ... then undo, so the caller sees current unchanged
  }
  decide(0);
  return all;
}
// subsets([1, 2, 3]) → [[], [3], [2], [2, 3], [1], [1, 3], [1, 2], [1, 2, 3]]

Dry run: the eight leaves of subsets([1, 2, 3])

LeafDecision for 1Decision for 2Decision for 3Pushed to all
1outoutout[]
2outoutin[3]
3outinout[2]
4outinin[2, 3]
5inoutout[1]
6inoutin[1, 3]
7ininout[1, 2]
8ininin[1, 2, 3]

The recursion tree has 2n leaves, one per subset, and 2n+1 − 1 calls in all (15 for n = 3). Each leaf copies up to n elements, so time is O(n · 2n); the stack is n + 1 deep and current holds at most n elements, so the working space is O(n) not counting the output. This is the template that the backtracking chapter develops into permutations, combinations and pruning.

⚠ Two lines carry the entire correctness The copy. Change [...current] to current and subsets([1, 2, 3]) returns eight empty arrays: all eight entries are the same array, and by the time the recursion ends every pop() has emptied it. The undo. Without current.pop() the "in" branch leaves its element behind, and the "out" branch of the next level starts with a polluted array. Whenever a recursion carries a mutable array down, ask both questions: did I copy it when saving, and did I restore it when backing out?

The same tree answers a counting question without building any list, which is the "return up" direction again. How many subsets sum to target? Subtract the chosen element from the target and ask whether the remainder reaches exactly 0 at the end:

function countSubsets(nums, target, i = 0) {
  if (i === nums.length) return target === 0 ? 1 : 0;   // all decided: did we land exactly?
  return countSubsets(nums, target, i + 1)                // leave nums[i] out
       + countSubsets(nums, target - nums[i], i + 1);     // take it
}
// countSubsets([1, 2, 3], 3) → 2   ({3} and {1, 2})

It runs in O(2n) time and, because it keeps no list, O(n) stack space. Each call is determined by the pair (i, target), so when many different paths reach the same pair, this is the repeated-subproblem shape from the Fibonacci section and a memo (or a table, in the dynamic programming chapters) collapses the exponential into a polynomial. Zeros and negative numbers are handled correctly because the empty remainder is only tested at the end; nums = [0, 0] with target 0 counts all four subsets.

Two classics: gcd and the Tower of Hanoi

Euclid's gcd is the shortest serious recursion there is, and its correctness is the leap-of-faith argument in miniature. Any number that divides both a and b also divides a − q·b for any whole q, in particular the remainder a % b, and the reverse is true too, so gcd(a, b) = gcd(b, a % b). The second argument strictly shrinks (a % b < b), so it reaches 0, where the answer is the first argument.

function gcd(a, b) {
  if (b === 0) return a;           // nothing left to divide by: a is the answer
  return gcd(b, a % b);
}
Callaba % bNext
gcd(84, 36)843612gcd(36, 12)
gcd(36, 12)36120gcd(12, 0)
gcd(12, 0)120—b is 0, returns 12

How many calls? If b ≤ a / 2 then a % b < b ≤ a / 2, and if b > a / 2 then a % b = a − b < a / 2. Either way the remainder is less than half of a, so every two calls the larger argument at least halves, which gives O(log(min(a, b))) calls. The slowest inputs are consecutive Fibonacci numbers: gcd(1346269, 832040), which are fib(31) and fib(30), takes 30 calls. Edge cases: gcd(a, 0) is a, gcd(0, 0) returns 0 (undefined mathematically; decide what you want), and a negative input can leak out as a negative answer because % keeps the sign of its left operand: gcd(4, -6) returns -2. Apply Math.abs to both arguments at the entry if negatives are possible. For the least common multiple use a / gcd(a, b) * b, dividing first so the intermediate product stays small.

The Tower of Hanoi is the classic case where thinking recursively is much easier than thinking about moves. Move n disks from peg A to peg C using B as a spare, one disk at a time, never putting a larger disk on a smaller one. Leap of faith: suppose you can already move n − 1 disks between any two pegs. Then move the top n − 1 to the spare peg, move the biggest disk once, and move the n − 1 on top of it. Nothing else is needed.

function hanoi(n, from, to, via, moves = []) {
  if (n === 0) return moves;                    // no disks: nothing to move
  hanoi(n - 1, from, via, to, moves);           // trust: top n-1 get out of the way, onto via
  moves.push([from, to]);                       // the largest disk moves exactly once
  hanoi(n - 1, via, to, from, moves);           // trust: the n-1 go back on top, onto to
  return moves;
}
// hanoi(3, "A", "C", "B") → A→C, A→B, C→B, A→C, B→A, B→C, A→C

For n = 3 that is seven moves. A simulation that replays every move confirmed that each is legal and that the disks end up stacked on C for every n up to 10. The move count satisfies M(n) = 2 · M(n − 1) + 1 with M(0) = 0, so M(n) = 2n − 1. No shorter solution exists: to move the largest disk, the other n − 1 must be sitting on the third peg, which takes at least M(n − 1) moves, and after the largest disk moves, they must travel again, which takes at least M(n − 1) more. So the minimum is at least 2M(n − 1) + 1, the same recurrence. The recursion is only n + 1 deep but the work is exponential: 20 disks need 1,048,575 moves, and 64 disks need about 1.8 × 10¹⁹. If you only need the count, return 2 ** n - 1 instead of storing the moves. The rotating roles of from, to and via in the two recursive calls are the usual place to slip.

Reading the cost off the recursion tree

Every function in this chapter can be costed the same way. Draw the recursion tree, then:

  • Time = (number of calls in the tree) × (work each call does itself, not counting its recursive calls). When the work per call varies, add it up level by level.
  • Stack space = (depth of the tree) × (what each frame holds). Add whatever you build on the side, such as a memo or an output list.
FunctionCallsWork per callTimeStack depth
sum(arr, i)n + 1O(1)O(n)n + 1
reverseString with slicenO(n) copyingO(n²)n
reverseInPlaceabout n / 2O(1)O(n)about n / 2
fibSlow(n)2 · fib(n + 1) − 1O(1)O(2ⁿ) bound (grows about 1.6× per step)n
fibMemo(n)2n − 1O(1)O(n)n
power(x, n), one call⌊log₂ n⌋ + 2O(1)O(log n)⌊log₂ n⌋ + 2
power(x, n), two callsO(n): 4,095 for n = 1,024O(1)O(n)⌊log₂ n⌋ + 2
subsets(nums)2ⁿ⁺¹ − 1O(1), plus an O(n) copy at each of 2ⁿ leavesO(n · 2ⁿ)n + 1
hanoi(n, …)2ⁿ⁺¹ − 1O(1)O(2ⁿ)n + 1
gcd(a, b)at most about 2 · log₂(min(a, b)) + 3O(1)O(log min(a, b))same as calls

The call counts for the Fibonacci, power and subsets rows were measured by counting calls in node, and the Fibonacci one matches the formula 2 · fib(n + 1) − 1 for n = 5, 10, 20 and 30 (15, 177, 21,891 and 2,692,537). Two reading habits pay off. First, count distinct states when a call is determined by its arguments: that is what memoization turns the call count into. Second, check whether "work per call" is really constant. slice and spread look like one operation and are not, which is the entire gap between the two reversals above. The same tree-reading carries over to merge sort in the next chapter, where the work per call is the size of the merge and the levels add up to n log n.

Converting recursion to iteration (when asked)

Any recursion can be rewritten iteratively using an explicit stack that mimics what the call stack was doing — this is worth being able to do live, since "avoid the call stack" is a common follow-up.

// factorial, iteratively — no call stack growth
function factorialIter(n) {
  let result = 1;
  for (let i = 2; i <= n; i++) result *= i;
  return result;
}

How deep is too deep? Measure it, then own the stack

"Maximum call stack size exceeded" is not a number of calls, it is a number of bytes of stack, so a function with more parameters and local variables overflows sooner. You can measure your own engine in two lines:

let depth = 0;
function dive() { depth++; dive(); }
try { dive(); } catch (e) { console.log(depth, e.constructor.name); }

On Node 24 this printed a little over 10,000 for the bare function above, and a little over 4,000 for a function with six parameters and four locals, each followed by RangeError. Browsers and other Node versions differ, so never write code that depends on a specific number. The practical rule: a recursion whose depth is proportional to the input (a linked list or skewed tree of 100,000 nodes, a path in a big grid) is a risk, while one whose depth is logarithmic (power, balanced trees) is not.

When depth is the risk, move the call stack into your own array. The recipe is mechanical. A stack frame is just the arguments plus "where I was in the function", so push the arguments instead of calling, and loop until the stack is empty. If the function does work after a recursive call returns, that pending work becomes a frame of its own, pushed before the calls it waits on, because the last item pushed is the first one run. First the easy case, where the state is carried down, so a frame is just [node, depth]:

function treeHeightIterative(root) {
  if (root === null) return 0;
  let best = 0;
  const stack = [[root, 1]];                   // each frame: a node and its depth
  while (stack.length) {
    const [node, depth] = stack.pop();
    best = Math.max(best, depth);
    if (node.left) stack.push([node.left, depth + 1]);
    if (node.right) stack.push([node.right, depth + 1]);
  }
  return best;
}

It returns the same height as treeHeight on random trees, and on a 200,000-node chain it returns 200,000 where the recursive version throws a RangeError. The stack now lives on the heap and is limited by memory, not by the call-stack size. The harder case is a function with work between its calls, such as hanoi, where the move must happen after the first recursive call and before the second. Give the stack two kinds of frame, "solve this subproblem" and "perform this move", and push them in the reverse of the order they should run:

function hanoiIterative(n, from, to, via) {
  const moves = [];
  const stack = [{ n, from, to, via }];                              // a subproblem to solve
  while (stack.length) {
    const f = stack.pop();
    if (f.move) { moves.push([f.from, f.to]); continue; }           // a pending move: perform it
    if (f.n === 0) continue;
    stack.push({ n: f.n - 1, from: f.via, to: f.to, via: f.from });  // runs LAST (pushed first)
    stack.push({ move: true, from: f.from, to: f.to });              // runs in the middle
    stack.push({ n: f.n - 1, from: f.from, to: f.via, via: f.to });  // runs FIRST (pushed last)
  }
  return moves;
}

For every n from 0 to 12 this produces exactly the same list of moves, in the same order, as the recursive hanoi. The reversal is the part to rehearse: reading the three pushes top to bottom gives the opposite of the execution order, because a stack is last in, first out. Same time, O(n) space. A plain loop is simpler than an explicit stack when the recursion is a single chain, as in factorialIter and fibLoop. Reach for the explicit stack when the recursion branches.

The mistakes that cost points

MistakeWhat you seeFix
Missing or too-narrow base case (lo === hi instead of lo >= hi)RangeError on some inputs only, often the empty or even-length oneList every smallest input; test sizes 0, 1, 2
Recursive call does not shrink the inputRangeError on every inputState in a sentence what gets smaller each call
Slicing or spreading a copy per callCorrect but O(n²) time and memoryPass indices or boundaries
Two recursive calls where one would do (power)Correct but exponential or linear when it should be logarithmicStore the shared result in a variable
Saving a shared array without copying, or forgetting to undo a pushResults all identical, or pollutedPush [...current]; pair every push with a pop
Memo recreated per call, or checked by truthinessStill exponential, or cached zeros recomputedPass the memo down or declare it outside; use has
Treating null as a container (typeof null is "object")TypeError on Object.values(null)Check value === null before looking inside
Module-level accumulator reused across callsSecond call returns a stale or inflated answerDeclare the accumulator inside the wrapper function

Recognizing it in an unseen problem

  • The structure itself is recursive — trees, nested lists, nested objects
  • The problem can be restated as "solve it for a smaller version, then combine"
  • Words like "all combinations," "all paths," "every way to" — usually backtracking, built on this same base/recursive-case shape
  • If the same sub-inputs repeat across branches, that's your cue to add memoization rather than leaving it as plain recursion
  • "For each item, take it or leave it" is a two-call recursion over the items, and the number of calls is the first thing to estimate (2ⁿ here)
  • The input is cut in half each call and only one half is used (exponent, search range): expect O(log n) calls, and check you are making one call, not two
  • The answer depends on the route taken to reach a node (depth, running total, path): carry that state down as a parameter; if it is built from the children's answers, return it up
  • The prompt gives a depth that would overflow the stack, or asks you to "do it without recursion": use a loop for a chain, an explicit stack for a branching recursion
Before you move on
  • State the base case and recursive case for a new problem before writing any code, and explain why the recursive case must shrink the input every call.
  • Trace factorial(4) (or a similar small call) by hand, showing which calls are still "waiting" when the base case is hit.
  • Explain why n nested recursive calls cost O(n) space, even with no array ever allocated.
  • Explain why fibSlow(n) is O(2ⁿ) by naming a subproblem it computes more than once.
  • Given a new problem, recognize when it's "restate on a smaller input, then combine" (recursion) versus needing memoization because subproblems repeat.
  • Write a base case by listing every smallest input (sizes 0, 1 and 2), and explain why lo >= hi is safer than lo === hi.
  • Say what reverseString with slice costs and rewrite it with indices; explain why power(x, n / 2) * power(x, n / 2) loses the speedup.
  • Write subsets from memory and name the two lines (the copy and the undo) that its correctness hangs on.
  • Read the time and stack cost of a recursion off its tree: number of calls times work per call, and depth times frame size.
  • Convert a branching recursion to an explicit stack, pushing pending work in the reverse of its execution order.

13Practice this chapter

Opens in the editor: write it, run it, and check it against real tests.

  • 8 testsFibonacci with Memoisationbeginner
  • 8 testsPow(x, n) — Fast Exponentiationintermediate
  • 8 testsGenerate All Subsetsbeginner
  • 8 testsFlatten a Deeply Nested Arraybeginner
  • 7 testsPalindrome Check With Recursionbeginner
  • 7 testsReverse an Array In Place, Recursivelybeginner
  • 7 testsNested List Depth Sumbeginner
  • 7 testsGreatest Common Divisor (Euclid)beginner
  • 6 testsTower of Hanoi: List the Movesbeginner
  • 7 testsCount Subsets With a Given Sumbeginner
  • 9 testsSum Every Number in a Nested Structureintermediate
  • 8 testsTree Height Without Recursionintermediate
  • 7 testsModular Exponentiation With BigIntintermediate
All 217 for this level →

✓Check yourself

5 questions, pass at 4. The check runs in your browser and the result stays on this device, so it is a self-check and not a certificate.

B9

Finished reading?

0 of 48 chapters read in this series

Mark as read

→Next stop

  1. B8Previous · B8Binary search
  2. B9you are here
  3. Next · B10Sorting algorithmsYou'll rarely hand-write one, but you'll constantly need to reason about them.36 min read

01On this page

18 sections · 0%

  1. 01Every recursive function is two things
  2. 02The call stack is a real stack — draw it
  3. 03The leap of faith: write one step, trust the call
  4. 04Recursion on arrays — shrink by one end
  5. 05Slicing hides a cost: count what each call copies
  6. 06Return it up, or carry it down
  7. 07Recursion on trees — the shape you'll use constantly later
  8. 08Nested data: when the input is the recursion
  9. 09Multiple recursive calls — the branching factor matters
  10. 10Memoized Fibonacci: each distinct call runs once
  11. 11Halving the problem: fast exponentiation
  12. 12Choose or don't choose: generating subsets
  13. 13Two classics: gcd and the Tower of Hanoi
  14. 14Reading the cost off the recursion tree
  15. 15Converting recursion to iteration (when asked)
  16. 16How deep is too deep? Measure it, then own the stack
  17. 17The mistakes that cost points
  18. 18Recognizing it in an unseen problem
Mark as read

[] chapters · t top