Skip to content

Recursion & Divide-and-Conquer

Recursion is a thinking tool, not just a language trick. Its essence is a single sentence: “solve a smaller version of the same problem,” then assemble the answer back up. Once you can see that a problem contains itself, writing the code becomes a matter of describing that relationship directly.

Picture Russian nesting dolls (matryoshka). To know what’s inside the biggest doll, you open it and ask the exact same question about the doll inside — smaller, but structurally identical. You keep asking until you reach the smallest doll, which has nothing inside it: that’s your stopping point. Then, going back outward, each doll reports what it found to the one that opened it. That inward-then-outward motion — descend until trivial, then report back up — is exactly what a recursive function does on the call stack.

Every recursive function has two parts, always:

  • Base case — the smallest case whose answer we know immediately, with no further self-call. This is the “stop button.”
  • Recursive case — break the problem into something smaller, then call the function on that smaller problem, always moving closer to the base case.

Stripped to its skeleton, every recursive function follows the same template:

def recurse(problem):
if is_base_case(problem): # 1. stop condition — smallest case, answer known
return base_answer(problem)
smaller_problem = shrink(problem) # 2. make progress toward the base case
sub_answer = recurse(smaller_problem) # 3. recursive call — trust it (leap of faith)
return combine(problem, sub_answer) # 4. combine into the final answer

The leap of faith: when you write the recursive case, simply trust that calling the function on a smaller problem returns the correct answer. You don’t need to trace every level in your head. Just guarantee that (1) the base case is correct and (2) each call genuinely moves toward it. With those two, the logic is complete.

The classic example is factorial. By definition, n! = n × (n-1)! and 0! = 1:

def factorial(n):
if n == 0: # base case
return 1
return n * factorial(n - 1) # recursive case

Notice the code is almost a direct copy of the mathematical definition. That is the charm of recursion: when a problem defines itself, the code reads like that definition.

Each time a function is called, the system creates a “frame” and pushes it onto the call stack, holding its variables and the point to return to. Let’s trace factorial(4) step by step.

The push phase (going down — calling deeper until we hit the base case):

┌─────────────────────────────┐
push 5 → │ factorial(0) return 1 │ ← base case! start unwinding
├─────────────────────────────┤
push 4 → │ factorial(1) 1 * factorial(0)│
├─────────────────────────────┤
push 3 → │ factorial(2) 2 * factorial(1)│
├─────────────────────────────┤
push 2 → │ factorial(3) 3 * factorial(2)│
├─────────────────────────────┤
push 1 → │ factorial(4) 4 * factorial(3)│ ← first frame
└─────────────────────────────┘

The pop phase (coming back up — each frame gets its answer from the frame above and returns):

factorial(0) = 1 pop → send 1 up
factorial(1) = 1 * 1 = 1 pop → send 1 up
factorial(2) = 2 * 1 = 2 pop → send 2 up
factorial(3) = 3 * 2 = 6 pop → send 6 up
factorial(4) = 4 * 6 = 24 pop → final answer = 24

The downward trip is “breaking the problem apart”; the upward trip is “assembling the answer.” The real result is computed as the stack unwinds, not on the way down.

The same push/pop shape applies to any recursive function, even simpler ones. Trace sum_list([3, 1, 4]):

Call Body evaluates to Returns (once its inner call returns)
sum_list([3, 1, 4]) 3 + sum_list([1, 4]) 3 + 5 = 8
sum_list([1, 4]) 1 + sum_list([4]) 1 + 4 = 5
sum_list([4]) 4 + sum_list([]) 4 + 0 = 4
sum_list([]) base case, no further call 0

Read the table top-to-bottom for the push order, and bottom-to-top for the pop order — the answer only becomes concrete once the bottom row (the base case) resolves.

Tree recursion: when one call becomes many

Section titled “Tree recursion: when one call becomes many”

Every recursive call in factorial produces exactly one further recursive call — the call chain is a straight line, n frames deep. This is linear recursion: draw the call graph and you get a single path, so the total number of calls is proportional to the depth, O(n).

Fibonacci is different. fib(n) = fib(n-1) + fib(n-2) makes two recursive calls, each of which makes two more. Draw the calls and you get a branching tree, not a line — this is tree recursion.

def fib(n):
if n < 2: # base case
return n
return fib(n - 1) + fib(n - 2) # two recursive calls — the tree branches here

The call tree for fib(4):

fib(4)
/ \
fib(3) fib(2)
/ \ / \
fib(2) fib(1) fib(1) fib(0)
/ \
fib(1) fib(0)

Look closely: fib(2) is computed twice, and fib(1) three times. Zoom out to fib(30) and the same small subproblems get recomputed millions of times over. These are overlapping subproblems — the exact same input recurs at many different points in the tree, and naive recursion recomputes it from scratch every single time, throwing the answer away the moment it returns.

Drag the slider in the widget above and watch the tree’s node count explode — the tree roughly doubles in size for every +1 to n, because every node spawns two children. That doubling is the signature of exponential growth.

Linear recursion (one call per level, like factorial) costs O(n) calls total. Tree recursion with branching factor b and depth n costs on the order of O(bⁿ) calls — for naive fib, that’s O(2ⁿ) (more precisely O(φⁿ) where φ ≈ 1.618, but “exponential” is the takeaway).

Why naive recursion explodes — and the fix

Section titled “Why naive recursion explodes — and the fix”

Run the numbers: fib(30) makes over 2.7 million calls to produce a single integer that fits in a few bytes. fib(50) would make over 40 billion calls — a wait of minutes for an answer a calculator gives instantly. The problem isn’t recursion itself; it’s that the same subproblem gets solved over and over, and each solution is thrown away the instant it’s used.

The fix is almost embarrassingly simple: remember what you’ve already computed.

def fib_memo(n, cache={}):
if n in cache:
return cache[n]
if n < 2:
return n
cache[n] = fib_memo(n - 1, cache) + fib_memo(n - 2, cache)
return cache[n]

Now every value of n gets computed exactly once — fib(30) drops from roughly 2.7 million calls to 30. This technique — caching the result of each unique subproblem so you never redo the work — is called memoization. It turns an exponential tree of repeated work into a linear chain of unique work.

Memoization is the bridge between recursion and dynamic programming: whenever you spot a recursive tree with overlapping subproblems, ask “have I already solved this exact subproblem?” If yes, that’s your cue to cache. The next lesson builds this idea into a full technique — top-down memoization and bottom-up tables.

Recursion and iteration (while/for) can compute the same result — the choice is about which shape fits the problem and what you’re willing to pay for it.

Recursion Iteration
Space O(d) call-stack frames for depth d O(1) extra space (usually)
Reads best when the problem is naturally self-similar (trees, nested structures, divide-and-conquer) the problem is a straight linear scan
Risk stack overflow if d is large none from depth — loops don’t consume stack
Python specifics no tail-call optimization; ceiling around 1000 calls by default no ceiling beyond available memory
Code shape often shorter, mirrors the mathematical definition sometimes more verbose, but every step is visible

Factorial rewritten iteratively needs no extra stack at all:

def factorial_iterative(n):
result = 1
for i in range(2, n + 1):
result *= i
return result

Same answer, O(1) space instead of O(n). Whenever a recursive function is linear (one call per level) and its depth can be large (proportional to input size), prefer the iterative version. Reach for recursion when the depth is naturally shallow (O(log n), as in binary search) or when the data itself is a tree or graph — an iterative version there would have to manage its own explicit stack anyway, so recursion just uses the call stack Python already gives you for free.

Recursion is a form of decomposition — instead of solving one big chunk at once, we split it into identical sub-problems, solve each, and combine the results. The divide-and-conquer strategy applies this principle deliberately: divide the problem into parts, conquer each part with a recursive call, then combine the answers.

  • Merge sort — split the list into two halves, sort each half (recursively), then merge the two sorted halves together. Runs in O(n log n).
  • Binary search — discard half of the search range on every comparison against the middle element, leaving a problem half the size. Finds the target in O(log n).

Notice the log n that appears — it is the signature of “cutting the problem in half at every step,” the heart of divide and conquer. Contrast this with tree recursion above: divide-and-conquer also branches, but each branch works on a disjoint slice of the input, so there’s no overlap to recompute — that’s what keeps O(n log n) and O(log n) fast instead of exponential.

Elegance has a price. Every call that hasn’t returned yet occupies one frame on the call stack, so recursion that goes d levels deep always uses O(d) space, even if the work per level is tiny.

  • Go too deep and the stack fills up, causing a stack overflow (in Python this is a RecursionError).
  • Python sets a ceiling to guard against runaway loops. Check it with sys.getrecursionlimit() (usually ~1000) and raise it with sys.setrecursionlimit(n) — but pushing it too high can crash the interpreter for real.
  • Python has no tail-call optimization, so you cannot rely on deep recursion the way some other languages allow.
import sys
print(sys.getrecursionlimit()) # e.g. 1000

When recursion depth is proportional to the data size (e.g. one level per element over a million-item list), iteration with while/for is usually safer: it uses O(1) space and won’t hit the stack ceiling. Reach for recursion when the depth is shallow (e.g. O(log n)) or when the structure is naturally a tree.

“Tree-shaped” structures are recursion’s playground — for example, walking a directory tree, where a folder can contain sub-folders nested without limit.

import os
def total_size(path):
"""Return the total size (bytes) of every file under path, recursively."""
if os.path.isfile(path): # base case: it's a file, return its size
return os.path.getsize(path)
total = 0
for name in os.listdir(path): # recursive case: it's a folder
child = os.path.join(path, name)
total += total_size(child) # add each child's size (recurse)
return total
print(total_size("."))

No matter how deeply folders nest, the code stays the same length, because at each level we only state that “a folder’s size = the sum of its children’s sizes” and let recursion handle the deeper levels. This is the leap of faith in practice.

Try each one yourself before opening the solution.

1. Sum of a list — write sum_list(xs) that returns the sum of the numbers in the list, using recursion.

Answer
def sum_list(xs):
if not xs: # base case: empty list
return 0
return xs[0] + sum_list(xs[1:]) # head + sum of the tail

2. Reverse a string — write reverse(s) that returns the reversed string.

Answer
def reverse(s):
if len(s) <= 1: # base case: empty or single character
return s
return reverse(s[1:]) + s[0] # reverse the tail, then append the head

3. Fibonacci — write fib(n) where fib(0)=0, fib(1)=1, fib(n)=fib(n-1)+fib(n-2).

Answer
def fib(n):
if n < 2: # base case: fib(0)=0, fib(1)=1
return n
return fib(n - 1) + fib(n - 2)

This version is straightforward but very slow — as the tree recursion section above shows, it recomputes the same values over and over, giving O(2ⁿ) time. fib(40) calls itself hundreds of millions of times. The fix is memoization, reducing it to O(n) — the starting point of dynamic programming, which you’ll study next.

4. Power — write power(base, exp) returning base raised to exp (a non-negative integer).

Answer
def power(base, exp):
if exp == 0: # base case: anything to the power 0 = 1
return 1
return base * power(base, exp - 1)

You can speed this up to O(log exp) with halving: power(b, e) = power(b, e//2)² (multiply by an extra b if e is odd) — that’s divide and conquer again, and it’s linear recursion (one call per level), not tree recursion, since each call only shrinks toward one smaller problem.

5. Count tree nodes — given a tree as the dict {"value": x, "children": [...]}, write count_nodes(tree) that counts all nodes.

Answer
def count_nodes(tree):
count = 1 # count itself
for child in tree["children"]:
count += count_nodes(child) # add each child's node count
return count
# A leaf node (empty children) is the implicit base case: the loop runs zero
# times and we return 1.

6. Euclidean GCD — what’s the Big O? Here is a recursive implementation of the greatest common divisor. What is its time complexity, in terms of a and b?

def gcd(a, b):
if b == 0: # base case
return a
return gcd(b, a % b) # recursive case
Answer

O(log(min(a, b))). Each call replaces (a, b) with (b, a % b); a classical result (tied to Fibonacci numbers via Lamé’s theorem) shows a % b shrinks by roughly half within every two steps, so the number of calls grows logarithmically, not linearly, with the input’s size. This is why Euclid’s algorithm handles hundred-digit inputs instantly — a sharp contrast with the exponential blowup of naive fib above, even though both are recursive.

7. Predict the output — without running it, what does this print, and in what order?

def mystery(n):
if n == 0:
return
print("enter", n)
mystery(n - 1)
print("exit", n)
mystery(3)
Answer
enter 3
enter 2
enter 1
exit 1
exit 2
exit 3

Every print("enter", ...) happens on the way down (the push phase), before the recursive call. Every print("exit", ...) happens on the way back up (the pop phase), after the recursive call returns. This mirrors the factorial push/pop trace earlier: code written before the recursive call runs top-down; code written after it runs bottom-up.

8. Spot the bug — this function is supposed to print n, n-1, ..., 1. It has a base case, but crashes with RecursionError. Why?

def count_down(n):
if n == 0:
return
print(n)
count_down(n)
Answer

The recursive call passes n unchanged instead of n - 1. There is a base case (n == 0), but no call ever gets closer to it once n starts above 0 — the function calls itself with the exact same argument forever. A base case is necessary but not sufficient: every recursive call must also make measurable progress toward it. Fix: count_down(n - 1).

9. Improve this code — an interviewer asks you to speed up this function without changing what it returns. What do you do, and how much faster is it?

def fib(n):
if n < 2:
return n
return fib(n - 1) + fib(n - 2)
Answer

Add memoization — cache each n the first time it’s computed, and look it up instead of recomputing it on every later call:

def fib(n, cache=None):
if cache is None:
cache = {}
if n in cache:
return cache[n]
if n < 2:
return n
cache[n] = fib(n - 1, cache) + fib(n - 2, cache)
return cache[n]

This takes the naive version from O(2ⁿ) calls down to O(n) — for fib(30), roughly 2.7 million calls collapse into 30. Same output, same recursive structure, radically different cost, purely because overlapping subproblems are no longer recomputed. This caching move is the core idea of dynamic programming.

An AI assistant was asked to write a function that counts down from n, and replied with the following, insisting it “definitely works.” Do you agree?

def countdown(n):
print(n)
countdown(n - 1) # call itself, decreasing by 1
countdown(5)

Think before scrolling to the solution: when does this function ever stop?

Solution

Wrong — there is no base case. This function calls itself forever; n keeps decreasing past 0 into the negatives without end, until the stack fills and you get RecursionError: maximum recursion depth exceeded. This is infinite recursion, a mistake AI loves to make when it forgets the stopping condition.

Fix it by adding a base case that terminates the recursion:

def countdown(n):
if n < 0: # base case: stop once we pass zero
return
print(n)
countdown(n - 1)

Lesson: whenever you receive recursive code from anyone (AI included), check two things immediately — (1) is there a base case, and (2) does every call genuinely move toward it? Miss either one and the code loops forever. Exercise 8 above shows the second failure mode in isolation: a base case that exists but is never reached.

A paint-bucket tool, a fog-of-war reveal, and a match-3 blob detector are all the same algorithm wearing different costumes: flood fill. Seed it at one tile and it recursively spreads outward to every neighboring tile that shares the seed’s property (same color, same “unexplored” state, same gem type), stopping wherever that property breaks. It’s the exact shrink-trust-combine shape from total_size above — the “children” of a tile are just its four grid neighbors instead of files in a folder.

Naive recursive flood fill, 4-connected, with three base cases: off the map, already visited, wrong tile.

def flood_fill(grid, x, y, target, replacement, visited=None):
"""Recursively repaint every tile 4-connected to (x, y) that matches
target — a paint-bucket tool, a match-3 blob check, and a fog-of-war
reveal are all this same algorithm wearing a different hat."""
if visited is None:
visited = set()
height, width = len(grid), len(grid[0])
if not (0 <= x < width and 0 <= y < height): # base case: off the map
return
if (x, y) in visited: # base case: already handled
return
if grid[y][x] != target: # base case: wrong tile / wall
return
visited.add((x, y))
grid[y][x] = replacement # do the work at this tile
flood_fill(grid, x + 1, y, target, replacement, visited) # right
flood_fill(grid, x - 1, y, target, replacement, visited) # left
flood_fill(grid, x, y + 1, target, replacement, visited) # down
flood_fill(grid, x, y - 1, target, replacement, visited) # up

Same three ingredients as every recursive function in this lesson: a base case that stops the recursion, a recursive case that makes progress (marking a tile visited before recursing is the progress here), and a leap of faith that each neighbor’s flood fill correctly handles everything past it.

4 3 2 3 4 3 x 1 2 3 2 1 0 1 2 3 2 1 x 3 4 3 2 3 4

Figure: flood fill spreading outward in rings from the seed tile (0) — each number is the recursion depth from the seed, and the two × tiles are walls that stop the spread cold.

The iterative fix. Run flood_fill on a tiny test room and it works perfectly. Run it on a wide-open 500×500 fog-of-war map and it can crash with RecursionError, even though nothing about the algorithm is wrong. The depth-first walk can tunnel in one direction for tens of thousands of tiles before it ever backtracks — recursion depth tracks the length of that tunnel, not the tile count — and Python’s call stack has no room for a 100,000-frame tunnel. This is the “cost of recursion” section again, wearing a level-design hat: any region shaped so the DFS can go deep before branching is a stack overflow waiting to happen.

The fix is the one that section already gave you: trade the implicit call stack for an explicit one.

def flood_fill_iterative(grid, x, y, target, replacement):
height, width = len(grid), len(grid[0])
stack = [(x, y)]
while stack:
cx, cy = stack.pop()
if not (0 <= cx < width and 0 <= cy < height):
continue
if grid[cy][cx] != target:
continue
grid[cy][cx] = replacement
stack.extend([(cx + 1, cy), (cx - 1, cy), (cx, cy + 1), (cx, cy - 1)])

Same tiles get filled, same amount of work, but the “stack” is now a Python list on the heap, not call frames on the C stack — it can grow to a million entries without complaint. Swap the list.pop() (LIFO) for a collections.deque and popleft() (FIFO) and the very same fill happens in breadth-first, ring-by-ring order — the classic expanding-circle look of a fog-of-war reveal.

This grid widget technically solves a different problem — shortest path, not flood fill — but click a few tiles into walls and watch how they carve up the traversal. Same intuition: a wall is a base case that stops the spread cold, and the shape of the walls decides the shape (and depth) of the search.

Scene-tree traversal. Recursion’s other job inside a game engine is walking the scene tree itself — the parent-child hierarchy of nodes, from a UI panel’s widgets down to an enemy’s hitboxes. Exercise 5 from the main lesson, count_nodes, is a scene-tree traversal in game terms: swap “tree” for “scene root” and “children” for “child nodes,” and it’s the exact function an engine uses to count every object under a prefab, or to recursively call destroy() on a node and everything beneath it before freeing memory.

A note on minimax. Tree recursion shows up again in game AI. Minimax explores a game’s tree of future moves exactly like fib explores its tree of subproblems — branching at every ply — except each level alternates between taking the max of its children’s scores (your move) and the min (the opponent’s move). It inherits the exact same exponential blowup as naive fib, for the exact same reason: the tree has O(bᵈ) nodes for branching factor b and search depth d. Game AI’s answer isn’t memoization (positions rarely repeat exactly) but a cousin idea — alpha-beta pruning cuts off branches that provably cannot change the result, and a hard depth limit trades a perfect answer for one that returns before the frame budget runs out.

Drag the widget above and picture each level alternating max/min instead of addition — the branching, exploding shape is identical; only what happens at each node differs.

Exercises

1. Trace the fill. Given this room (. = floor, # = wall) and flood_fill(grid, 0, 0, '.', '*') recursing in the order right → left → down → up, list the tiles in the order they get repainted.

. . #
. # .
. . .
Answer

(0,0) → (1,0) → (0,1) → (0,2) → (1,2) → (2,2) → (2,1) — all seven floor tiles, in that order. From (0,0) the walk goes right to (1,0), which is a dead end (right is a wall, left is visited, down is a wall, up is off the map), so it backtracks and goes down from (0,0) instead, then tunnels down and right around the wall at (1,1) until it reaches (2,1) from above.

2. Spot the three base cases. Using the same room and the flood_fill code above, give one concrete (x, y) call that triggers each of the three base cases.

Answer

Off the map: e.g. (3, 0) or (-1, 1). Wrong tile: (2, 0), which is #. Already visited: calling flood_fill(grid, 0, 0, ...) again after step 1 has already marked (0,0) visited.

3. Count the calls. flood_fill always fires all four neighbor calls after visiting a tile, regardless of whether those neighbors turn out valid. If it starts on a completely open, wall-free w × h room, how many total calls to flood_fill happen — including ones that immediately hit a base case — in terms of w and h? Work it out for a 3×4 room.

Answer

Every one of the w·h tiles gets visited exactly once, and each visit unconditionally issues 4 further calls, so total calls = 1 + 4·w·h (the +1 is the initial call). For a 3×4 room: 1 + 4·12 = 49 calls, of which only 12 actually repaint a tile — the other 37 hit a base case immediately.

4. Why does it crash? A tester reports RecursionError on a huge, completely open fog-of-war room, but not on a small maze-like room with lots of walls — even though the maze has more total tiles reachable overall in some cases. What actually determines recursion depth here, if not the number of tiles?

Answer

Depth tracks the length of the longest unbroken chain of calls before a backtrack — essentially how far the depth-first walk can tunnel in one direction before it runs out of new neighbors. A big open room lets the walk snake across the whole area in one long chain (depth can approach the tile count), while a maze full of walls forces frequent backtracking, keeping any single chain short even if the total reachable area is similar. This is why “avoid deep recursion on large regions” is really “avoid recursion whose depth scales with region size,” not “avoid recursion on regions with many tiles.”

Challenge problems

A. Diagonal connectivity. Rewrite flood fill so tiles connect 8-directionally (including diagonals) instead of 4-directionally — useful for match-3 games where a diagonal-adjacent cluster of gems should count as one blob.

Approach

Replace the four hardcoded recursive calls with a loop over eight offsets:

DIRS_8 = [(1, 0), (-1, 0), (0, 1), (0, -1),
(1, 1), (1, -1), (-1, 1), (-1, -1)]
def flood_fill_8(grid, x, y, target, replacement, visited=None):
if visited is None:
visited = set()
height, width = len(grid), len(grid[0])
if not (0 <= x < width and 0 <= y < height):
return
if (x, y) in visited or grid[y][x] != target:
return
visited.add((x, y))
grid[y][x] = replacement
for dx, dy in DIRS_8:
flood_fill_8(grid, x + dx, y + dy, target, replacement, visited)

The connectivity choice is a design decision, not just a technical one: two tiles diagonally adjacent across a wall’s corner are “connected” under 8-connectivity but not under 4-connectivity, which matters for whether light, fog reveal, or a match-3 blob is allowed to “leak” diagonally past a corner.

B. Convert to iteration, ring by ring. A fog-of-war reveal should crash on nothing and should visually expand outward like a radius growing over time, not tunnel unevenly in one direction the way depth-first recursion does. Convert flood_fill to an iterative version that both avoids stack overflow and visits tiles in expanding-ring order.

Approach

Swap the stack (LIFO) for a queue (FIFO) — a breadth-first walk visits every tile at distance d from the seed before any tile at distance d + 1, which is exactly the “ring” order a reveal animation wants:

from collections import deque
def flood_fill_bfs(grid, x, y, target, replacement):
height, width = len(grid), len(grid[0])
queue = deque([(x, y)])
seen = {(x, y)}
while queue:
cx, cy = queue.popleft()
if grid[cy][cx] != target:
continue
grid[cy][cx] = replacement
for nx, ny in ((cx + 1, cy), (cx - 1, cy), (cx, cy + 1), (cx, cy - 1)):
if 0 <= nx < width and 0 <= ny < height and (nx, ny) not in seen:
seen.add((nx, ny))
queue.append((nx, ny))

Stack depth is now always O(1) regardless of the room’s shape, and because deque grows on the heap, a million-tile map is no more dangerous than a ten-tile one. To animate the reveal, drain the queue one ring at a time (process everything currently in the queue before pulling in what got appended this round) and render a frame between rings.

  • MIT 6.0001 — Introduction to Computer Science and Programming in Python (OCW) — the lectures on recursion and dictionaries build up base/recursive cases gradually.
  • MIT 6.006 — Introduction to Algorithms (OCW) — a deeper dive into divide and conquer, merge sort, and complexity analysis.
  • Harvard CS50x — the week on recursion, with approachable visual examples of the call stack.
  • Stanford CS106B — Programming Abstractions — a thorough unit on recursion and recursive backtracking.
  • CLRS — Introduction to Algorithms, Chapter 4 — Divide-and-Conquer and solving recurrence relations with the master theorem, the formal tool for analyzing the branching recursions in this lesson.
  • Sedgewick & Wayne — Algorithms, 4th ed. — clean implementations of mergesort and quicksort that make the divide-and-conquer recursion pattern concrete in real code.
  • Bhargava — Grokking Algorithms, 2nd ed. — the recursion chapter is one of the best beginner-friendly, illustrated explanations of the call stack and the “leap of faith” anywhere.
  • Roughgarden — Algorithms Illuminated, Part 1 — rigorous but approachable treatment of recurrences and the master method, pairing well with CLRS chapter 4.