1.2.38 · D4Introduction to Programming (Python)

Exercises — Classic recursion — factorial, Fibonacci, binary search

2,913 words13 min readBack to topic

Before we start, one reminder of the two pieces every recursion has (from the parent note):


Level 1 — Recognition

"Can you spot the pieces?"

L1.1 — Identify the base case

Recall Solution

Base case: if n == 0:return. Why: it is the only branch that returns without calling countdown again. It is reached when the input has shrunk all the way down to . Without it, countdown(n-1) would keep going to forever → RecursionError.

L1.2 — Read the recurrence off the code

Recall Solution

Why ? The total up to is " itself" glued onto "the total up to " — that is the self-similar structure. Why ? Summing no numbers gives (the empty sum), and it stops the chain.

L1.3 — Count the base cases

Recall Solution

Two base cases: and . Why two? The recurrence reaches back two steps. If you only anchored , then computing would ask for — below the floor — and never stop. You need as many fixed starting values as the number of steps the recurrence looks back.


Level 2 — Application

"Can you run the templates?"

L2.1 — Trace factorial

Recall Solution
factorial(4) = 4 * factorial(3)
             = 4 * (3 * factorial(2))
             = 4 * (3 * (2 * factorial(1)))
             = 4 * (3 * (2 * (1 * factorial(0))))
             = 4 * (3 * (2 * (1 * 1)))     # base case 0! = 1
             = 24

Why this order? Every multiplication waits on the call stack until the base case factorial(0)=1 returns, then the products resolve bottom-up: . Answer: .

Look at the picture — each row is one call, and the shaded band is the part of the list still "alive".

Figure — Classic recursion — factorial, Fibonacci, binary search
Recall Solution

Indices: 2→0, 4→1, 6→2, 8→3, 10→4, 11→5, 14→6. Start lo=0, hi=6.

call lo hi mid arr[mid] decision
1 0 6 3 8 → go right, lo=4
2 4 6 5 11 found → return 5

mid = (0+6)//2 = 3; since the whole left half (indices 0–3) is thrown away. Next mid = (4+6)//2 = 5, and arr[5]==11. Answer: returns index .

L2.3 — Write power(base, n)

Recall Solution

Recurrence: (one factor of peeled off), base: (empty product).

def power(b, n):
    if n == 0:            # base case: b^0 = 1
        return 1
    return b * power(b, n - 1)   # recursive case

Trace: . Answer: .


Level 3 — Analysis

"Can you reason about cost and behaviour?"

L3.1 — How deep does the stack go?

Recall Solution

Frames exist for factorial(100), factorial(99), …, factorial(0) — that's every integer from down to inclusive: frames. General: factorial(n) reaches depth . Answer for 100: frames. This is why very large can trigger RecursionError — Python's default recursion limit is around frames.

L3.2 — Count the calls in naive Fibonacci

Study the call tree — every circle is one call, and you simply count circles.

Figure — Classic recursion — factorial, Fibonacci, binary search
Recall Solution

Recurrence for the counter: one call for fib(n) itself, plus all calls its two children make: Building up: Answer: calls. (Notice is far more than the distinct values — the extra are redundant recomputations. See Big-O Notation.)

L3.3 — How many comparisons does binary search make?

Recall Solution

Each comparison halves the remaining range, so the worst case is the number of halvings needed to shrink down to : Check: , so you can halve at most times. Answer: comparisons. Compare to linear search's worst case of — that gap is the payoff of .


Level 4 — Synthesis

"Can you build new recursions?"

L4.1 — Recursive sum of a list

Recall Solution

Idea: the sum of a list is "its first element" plus "the sum of the rest".

def sum_list(a):
    if len(a) == 0:        # base case: empty list sums to 0
        return 0
    return a[0] + sum_list(a[1:])   # first + sum of the rest

Recurrence: , base . Trace: . Answer: .

L4.2 — Reverse a string recursively

Recall Solution

Idea: move the first character to the end, after reversing the rest.

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

Trace:

reverse("cat") = reverse("at") + "c"
               = (reverse("t") + "a") + "c"
               = ("t" + "a") + "c"       # base: "t" reverses to "t"
               = "tac"

Answer: "tac".

L4.3 — Recursive gcd (Euclid's algorithm)

Recall Solution
def gcd(a, b):
    if b == 0:          # base case: gcd(a,0)=a
        return a
    return gcd(b, a % b)   # smaller problem: remainder shrinks fast

Trace:

gcd(48,18) = gcd(18, 48%18) = gcd(18, 12)
           = gcd(12, 18%12) = gcd(12, 6)
           = gcd(6,  12%6)  = gcd(6, 0)
           = 6              # base case

Why does it stop? The remainder is always strictly smaller than , so b decreases every call and must hit . Answer: .


Level 5 — Mastery

"Subtle traps and full re-derivations."

L5.1 — The mutable default argument bug

Recall Solution

In Python, a default argument is created once, when the function is definednot on each call. So the same memo dict is shared across every call that doesn't pass its own.

  • Danger: the cache from fib(50) persists into fib(30). Here that's harmless (the cached Fibonacci values are still correct and it even speeds things up), but the same pattern with a list default (e.g. def add(x, box=[])) causes results to leak between calls — a classic bug.
  • Safe fix: use memo=None and set if memo is None: memo = {} inside, giving each top-level call a fresh cache while still sharing it down the recursion. Key insight: correctness of memoized fib here survives the shared dict only because Fibonacci values never change; don't rely on that luck in general.

L5.2 — Fast exponentiation (divide & conquer)

Recall Solution

Idea (a Divide and Conquer move): if is even, — compute the half-power once, then square it. If is odd, .

def fast_power(b, n):
    if n == 0:
        return 1
    half = fast_power(b, n // 2)
    if n % 2 == 0:
        return half * half
    return b * half * half

Trace :

3^10 = (3^5)^2
3^5  = 3 * (3^2)^2
3^2  = (3^1)^2
3^1  = 3 * (3^0)^2 = 3*1*1 = 3

So , , . Recurrence: — same shape as binary search. Answer: .

L5.3 — Why sortedness is non-negotiable

Recall Solution

Start lo=0, hi=4. mid = (0+4)//2 = 2, arr[2] = 5. Since , the code assumes everything left of index 2 is smaller and throws it away, searching right with lo=3.

call 1: lo=0 hi=4 mid=2 arr[mid]=5  -> 5<7 -> go right lo=3
call 2: lo=3 hi=4 mid=3 arr[mid]=3  -> 3<7 -> go right lo=4
call 3: lo=4 hi=4 mid=4 arr[mid]=7  -> found, return 4

Here it accidentally found it — but that's luck. If target = 1 (at index 1), the first step would send it left to indices 0–1, where arr is [9,1], and depending on comparisons it may return the wrong index or -1. Why it breaks: the halving logic depends on "left of mid ≤ mid ≤ right of mid", which is exactly the sorted property. Remove that guarantee and eliminating a half is unjustified. Fix: sort first (, see Sorting Algorithms) or use linear search.


Recall One-line answer key (self-check)

L2.1 24 · L2.2 5 · L2.3 32 · L3.1 101 · L3.2 15 · L3.3 10 · L4.1 14 · L4.2 "tac" · L4.3 6 · L5.2 59049


Connections