Search Tech Journey

Find topics, journeys and posts

back to blog
algorithmsintermediate 60m read

L21 · Recursion & the Call Stack

Base case discipline, hand-tracing a call stack, and knowing when recursion is the wrong tool — the substrate under trees, backtracking and dynamic programming.

🧩DSAPhase 1 · Core patterns· Session 021 of 130 60 min

🎯 Stop treating recursion as spooky: see it as a function that calls itself on a smaller problem, trust the recursive leap, write bulletproof base cases, hand-trace the call stack, and know exactly when recursion is the wrong tool.

Series: LeetCode — From Basics to Interview-Ready · Session 21 / 65 · Phase 1 · Core patterns

Prerequisites

What this pattern IS

Recursion is a function that solves a problem by calling itself on a smaller version of the same problem, until the problem is small enough to answer directly. That's it. Two parts, always: the base case (the smallest input, answered without recursing) and the recursive case (reduce the problem, call yourself, combine). Miss the base case and you recurse forever; the machine runs out of stack and crashes — a stack overflow, named after the very structure recursion runs on.

The hard part isn't writing the recursive call — it's trusting it. The recursive leap of faith is the discipline of assuming your function already works correctly on the smaller input, using that result, and not mentally unrolling the whole thing. If factorial(n) should return n * factorial(n-1), you assume factorial(n-1) is correct and move on. Trying to trace every level in your head is what makes recursion feel impossible; refusing to is what makes it easy. You prove correctness the way you'd prove induction: base case holds, and if the smaller case is right, the combination is right.

Under the hood, each call gets a stack frame holding its local variables and where to return. Calls push frames as they go deeper; returns pop them as they come back. That's why recursion depth costs real memory (O(depth)) and why Python caps recursion at ~1000 frames by default — a limit that turns some naturally-recursive problems into "convert to iteration" problems.

Russian nesting dolls
🌍 Real world
To count the dolls, you open one, and to count what's inside you do the exact same thing — open and count — until you reach the tiny solid doll that doesn't open (the base case). You don't need to see all the dolls at once; you trust that 'count the inside' works, and add one.
💻 Code world
def count(doll): return 0 if doll.solid else 1 + count(doll.inside)

Deriving correct recursion

From first principles
Start with the question
What guarantees a recursive function terminates and returns the right answer?
  1. 1
    Every recursive call must move strictly toward the base case.
    forced by · if the input doesn't shrink, the recursion never bottoms out and the stack overflows.
  2. 2
    The base case must handle the smallest input directly and correctly.
    forced by · it's the anchor the whole induction rests on; a wrong base case corrupts every level above it.
  3. 3
    Assuming the recursive call is correct on the smaller input, the combine step must produce the correct answer for the current input.
    forced by · this is the inductive step — correctness propagates upward only if the combination is right.
  4. 4
    Termination + correct base + correct combine => correct for all inputs, by induction on input size.
    forced by · base case is the induction base; the combine step is the induction step.
  5. 5
    The call stack depth equals the longest chain of un-returned calls.
    forced by · each pending call holds a frame until its recursive call returns, so depth = memory cost.
⇒ Therefore
Recipe: (1) name the base case and answer it directly; (2) shrink the input; (3) assume the recursive call works and combine. Correctness is induction; the depth is your space bill.

The templates — annotated Python

# ---- Template A: the universal recursion shape --------------
def solve(problem):
    if is_base_case(problem):          # 1. the anchor
        return base_answer(problem)
    smaller = reduce(problem)          # 2. move toward the base
    sub = solve(smaller)               # 3. LEAP OF FAITH: trust it
    return combine(problem, sub)       # 4. build the answer
 
 
# ---- Template B: concrete example — factorial ---------------
def factorial(n: int) -> int:
    if n <= 1:                          # base: 0! = 1! = 1
        return 1
    return n * factorial(n - 1)         # trust factorial(n-1)
 
 
# ---- Template C: two recursive calls (branching) ------------
def fib(n: int) -> int:
    if n < 2:                          # base: fib(0)=0, fib(1)=1
        return n
    return fib(n - 1) + fib(n - 2)      # two leaps; combine by +
# WARNING: this is O(2^n) — it recomputes the same subproblems.
# Memoise (L33) to make it O(n): cache results by n.
 
 
# ---- Template D: memoised (top-down DP preview) -------------
from functools import lru_cache
 
@lru_cache(maxsize=None)               # cache every (n) -> result
def fib_fast(n: int) -> int:
    if n < 2:
        return n
    return fib_fast(n - 1) + fib_fast(n - 2)   # each n computed once => O(n)
 
 
# ---- Template E: recursion -> iteration with an explicit stack
def factorial_iter(n: int) -> int:
    result = 1
    stack = list(range(2, n + 1))      # the frames we'd have pushed
    while stack:
        result *= stack.pop()          # unwind by hand
    return result
# Any recursion can be rewritten with an explicit stack — do this
# when depth might exceed Python's ~1000-frame limit.

The single most common bug is a base case that's missing or wrong for an edge input — an empty list, n == 0, a leaf node. Write the base case first, before the recursive case, every time. The second most common is a recursive call that doesn't actually shrink the input (passing the same list, forgetting to slice), which loops until overflow. And the branching template (C) is a trap: naive fib is exponential because it recomputes subproblems — that observation is the entire seed of dynamic programming, which we'll grow in L33.

Mental modelThe stack of unfinished promises
Each call is a promise 'I'll return once my sub-call returns'. Promises pile up (the stack grows) as you go deeper, then get fulfilled bottom-up (the stack unwinds) as the base case resolves and answers bubble back up. The deepest pile is your memory cost.
  • Write the base case FIRST — it's the anchor.
  • Every call must strictly shrink the input toward the base.
  • Take the leap: assume the recursive call is correct, then combine.
  • Depth = memory (O(depth)); Python overflows near 1000 frames.
🔔 Fires when you see
'Same problem on a smaller part' — trees, nested structure, 'try all choices', divide-and-conquer, or a definition that refers to itself.

Memory / mnemonic

The recall peg is "BRC"Base case, Reduce, Combine — recited in that order because that is the order you should write the three parts. Base case first (so you never forget the anchor), then how you shrink, then how you glue the sub-answer back. If you can say "B-R-C" you have the skeleton of every recursive function.

For the mindset, memorise the phrase "trust the leap." When you're mid-write and the recursion starts to feel dizzying, that dizziness is you trying to unroll it — stop, and trust that the smaller call already works. And keep the induction slogan handy: "base holds, small⇒big holds." Those two clauses are a full correctness proof, and reciting them stops you from hand-tracing five levels deep and losing the thread.

Common misconception
✗ What most people think
Recursion is elegant and therefore the right choice whenever a problem can be phrased recursively.
✓ What is actually true
Recursion costs O(depth) stack memory and a per-call overhead, and Python overflows around 1000 frames. For deep-but-linear problems (a long linked list, a huge n) an iterative loop is safer and faster. Recursion shines on branching structure (trees), not linear depth.
Why the myth is so sticky
Textbooks show recursion on toy inputs where depth is tiny, so the memory cost is invisible. On a linked list of length 100000, recursive reversal blows the stack while the iterative version doesn't even notice.
Prove it to yourself
Recursively sum a list of 100000 elements: def s(a,i): return 0 if i==len(a) else a[i]+s(a,i+1). It raises RecursionError. The iterative sum doesn't. That crash is the O(depth) cost made visible.

What interviewers actually ask

Pure recursion is rarely the whole question — it's the substrate the interviewer builds on. But they do probe base-case discipline, the leap of faith, complexity of a recursion tree, and whether you know when to convert to iteration.

  • Fibonacci Number (509) · Easy · Amazon · probes: naive O(2^n) vs memoised O(n) vs iterative O(1) space · follow-up: the seed of dynamic programming (L33).
  • Pow(x, n) (50) · Medium · Amazon, Meta, Google · probes: fast exponentiation by halving — recursion that shrinks the problem by 2x each step · follow-up: handle negative n and overflow.
  • Merge Two Sorted Lists (21) · Easy · Amazon, Microsoft · probes: the recursive formulation vs iterative (from L19) · follow-up: merge k lists via divide-and-conquer recursion.
  • Reverse Linked List (206) · Easy · Amazon, Microsoft, Meta · probes: the recursive reversal and its base case · follow-up: why iterative is preferred for long lists (stack depth).
  • Maximum Depth of Binary Tree (104) · Easy · Amazon, LinkedIn · probes: the canonical "recurse on children, combine with 1 +" shape · follow-up: do it iteratively with a stack (the L22 bridge).
  • Sort List (148) · Medium · Amazon, Meta · probes: merge sort as divide-and-conquer recursion on a linked list · follow-up: O(1) space bottom-up merge sort.

Worked solutions

1. Pow(x, n) (50)

Plain words: compute x to the power n efficiently. Brute force multiplies x by itself n times — O(n). Insight: x^n = (x^(n/2))^2, so each step halves the exponent — O(log n) via recursion that shrinks the problem by 2x.

def myPow(x: float, n: int) -> float:
    if n < 0:                          # x^-n = (1/x)^n
        x, n = 1 / x, -n
    def go(x: float, n: int) -> float:
        if n == 0:                     # base: anything^0 = 1
            return 1.0
        half = go(x, n // 2)           # trust it: x^(n//2)
        if n % 2 == 0:
            return half * half         # even: square it
        return half * half * x         # odd: square and one more x
    return go(x, n)

Complexity: O(log n) time (exponent halves each level), O(log n) space (recursion depth). The base case n == 0 is the anchor; computing half once and squaring — not calling go twice — is what keeps it logarithmic rather than linear. Follow-up: negative n is handled up front by inverting.

2. Maximum Depth of Binary Tree (104)

Plain words: the depth is the longest root-to-leaf path length. This is the archetypal recursive shape — the depth of a tree is 1 plus the deeper of its two subtrees' depths.

def maxDepth(root) -> int:
    if not root:                       # base: empty tree has depth 0
        return 0
    left = maxDepth(root.left)         # trust: depth of left subtree
    right = maxDepth(root.right)       # trust: depth of right subtree
    return 1 + max(left, right)        # combine: this node + deeper side

Complexity: O(n) time (visit each node once), O(h) space where h is the tree height (the recursion depth) — O(n) for a degenerate list-like tree, O(log n) for a balanced one. This is the BRC template in its purest form and the doorway to all tree DFS in L22.

Try itCompute the tree's diameter (longest path between any two nodes)

Extend maxDepth so it also computes the diameter — the number of edges on the longest path between any two nodes, which need not pass through the root. The trick is the return-vs-update split: each call returns its own depth but updates a shared max with left+right. This split is the single most important idea for L22–L26 tree problems.

💡 Hint · At each node, the longest path THROUGH it is left-depth + right-depth; track a global max while returning the depth.

3. Reverse Linked List — recursively (206)

Plain words: reverse a list using the leap of faith. Assume the rest of the list is already reversed; then make the current node the new tail of that reversed rest.

def reverseList(head):
    if not head or not head.next:      # base: empty or single node
        return head
    new_head = reverseList(head.next)  # trust: rest is reversed
    head.next.next = head              # the next node now points back to us
    head.next = None                   # sever our old forward link
    return new_head                    # the deepest node is the new head

Complexity: O(n) time, O(n) space — and that space is the catch. Each node adds a stack frame, so a list of 100000 nodes overflows Python's recursion limit. This is exactly why L19 taught the iterative version first: on a long linear structure, iteration is the safe default and recursion is the elegant-but-dangerous alternative.

When NOT to use recursion

The tradeoff
Recursion versus iteration versus recursion-with-an-explicit-stack.
Plain recursion
+ you gain Clean, matches recursive structure (trees, divide-and-conquer); minimal code.
− you pay O(depth) call-stack memory; Python overflows near 1000 frames; per-call overhead.
pick when Branching/recursive structure with shallow depth — trees, backtracking, divide-and-conquer.
Iteration (a loop)
+ you gain O(1) frame overhead, no depth limit, usually faster; no overflow risk.
− you pay Awkward for genuinely branching structure; you may hand-roll a stack anyway.
pick when Linear problems with large depth — long lists, big n, streaming.
Recursion + explicit stack
+ you gain Keeps the recursive logic but moves frames to the heap, dodging the recursion limit.
− you pay More verbose; you manage the stack manually.
pick when Deep tree/graph traversal where recursion is natural but depth might exceed 1000.
What a senior engineer actually does
Recurse when the structure branches and depth is bounded (trees). Loop when the problem is linear and deep (lists, large n). When you need recursion's logic but fear the depth limit, convert to an explicit heap-allocated stack — same algorithm, no overflow.

Where this shows up in production

The call stack is not an abstraction — it's the mechanism every language uses to run functions, which is why a stack trace exists and why infinite recursion produces a RecursionError / stack overflow (the site Stack Overflow is named after it). Recursive descent parsers (used in real compilers and JSON/config readers) are recursion mirroring grammar structure. Quicksort and mergesort are divide-and-conquer recursion. File-system traversal (os.walk), rendering nested UI component trees, and evaluating nested expressions are all recursion on tree structure. The honest framing: recursion is natural wherever data is nested, and dangerous wherever data is deep-and-linear — the same trade this session drills.

You can now…
  • Write any recursion as base-case-first, reduce, combine (BRC).
  • Apply the leap of faith instead of hand-tracing every level.
  • Prove a recursion correct by induction (base holds, small⇒big holds).
  • Explain why naive fib is O(2^n) and how memoisation makes it O(n).
  • Predict the space cost as O(depth) and spot Python's ~1000-frame limit.
  • Use fast exponentiation (halving) for O(log n) recursion.
  • Convert a recursion to an explicit-stack iteration when depth is dangerous.
Recall · L21 · click to reveal
★ = stretch question

Practice queue

Key points