Command Palette

Search for a command to run...

MTH 4300

Lecture 8

The Recursive Pattern

A recursive function is one that calls itself. Every correct recursive function has two parts:

  1. Base case — a condition under which the function returns directly without calling itself.
  2. Recursive case — a call to itself with a strictly simpler input, moving toward the base case.

Without a base case, the function calls itself forever until the program crashes with a stack overflow.

Anatomy of a recursive function
int factorial(int n) {
    if (n == 0) return 1;        // base case
    return n * factorial(n - 1); // recursive case — n decreases each call
}

The call stack

Each function call gets its own stack frame — its own copy of local variables and the point to return to. Recursive calls pile up frames until the base case is hit, then unwind:

Call stack for factorial(4)
factorial(4) called
  factorial(3) called
    factorial(2) called
      factorial(1) called
        factorial(0) called
          returns 1          ← base case
        returns 1 × 1 = 1
      returns 2 × 1 = 2
    returns 3 × 2 = 6
  returns 4 × 6 = 24

Each return propagates back up through the chain of pending calls.

Stack overflow

Every active call occupies stack memory. Recursing too deeply — either from a missing base case or a very large input — exhausts the stack and crashes the program. The default stack depth is typically a few thousand frames.

Linear Recursion

A function that makes exactly one recursive call per invocation is linearly recursive. Each call reduces the problem by one step.

Factorial

Factorial
#include <iostream>

int factorial(int n) {
    if (n == 0) return 1;
    return n * factorial(n - 1);
}

int main() {
    std::cout << factorial(5) << "\n"; // 120
    return 0;
}

Power

A naive power function makes n recursive calls:

Naive power — O(n)
int power(int base, int exp) {
    if (exp == 0) return 1;
    return base * power(base, exp - 1);
}

A smarter version halves the problem each time — O(log n):

Fast exponentiation — O(log n)
int power(int base, int exp) {
    if (exp == 0) return 1;
    if (exp % 2 == 0) {
        int half = power(base, exp / 2);
        return half * half;              // reuse the result, don't call twice
    }
    return base * power(base, exp - 1);
}

power(2, 8) now makes calls for exponents 8 → 4 → 2 → 1 → 0 rather than 8 → 7 → ... → 0.

Multiple Recursion

A function that makes more than one recursive call per invocation is multiply recursive. The call tree branches, and the total number of calls grows exponentially.

Fibonacci

Fibonacci
int fib(int n) {
    if (n == 0) return 0;
    if (n == 1) return 1;
    return fib(n - 1) + fib(n - 2);
}

The first several values: 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, ...

This is correct but inefficient. fib(5) recomputes fib(3) twice, fib(2) three times, and so on. The number of calls to compute fib(n) grows roughly as 2ⁿ.

Storing already-computed results — called memoization — reduces the Fibonacci recursion to O(n) time. This is the foundation of dynamic programming, which you'll encounter in later courses.

Tail Recursion

A recursive call is tail-recursive if it is the very last operation in the function — nothing happens to its return value before it is returned to the caller.

Compare the two factorial implementations:

Not tail-recursive
int factorial(int n) {
    if (n == 0) return 1;
    return n * factorial(n - 1); // must multiply after the call returns
}
Tail-recursive — accumulator pattern
int factorial_tail(int n, int acc = 1) {
    if (n == 0) return acc;
    return factorial_tail(n - 1, n * acc); // result passed forward; nothing left to do after
}

The second form carries a running product (acc) forward into each call instead of doing work on the way back up. Tail-recursive calls can be optimized by the compiler into a loop — no new stack frame needed — making them as efficient as iteration.

Using the tail-recursive version
#include <iostream>

int factorial_tail(int n, int acc = 1) {
    if (n == 0) return acc;
    return factorial_tail(n - 1, n * acc);
}

int main() {
    std::cout << factorial_tail(5) << "\n"; // 120
    return 0;
}

C++ does not guarantee tail-call optimization, but most compilers apply it under optimization flags (-O2, -O3). In practice, the accumulator pattern is still worth knowing — it directly expresses the iterative structure of the computation.

Mutual Recursion

Two functions are mutually recursive when each calls the other. Both must have base cases that break the cycle.

Mutual recursion — is_even and is_odd
#include <iostream>

bool is_even(int n);
bool is_odd(int n);

bool is_even(int n) {
    if (n == 0) return true;
    return is_odd(n - 1);
}

bool is_odd(int n) {
    if (n == 0) return false;
    return is_even(n - 1);
}

int main() {
    std::cout << is_even(4) << "\n"; // 1
    std::cout << is_odd(7) << "\n";  // 1
    return 0;
}

Forward declarations are required when two functions reference each other — the compiler must know is_odd exists before it sees the body of is_even.

Recursion on Sequences

Recursion applies naturally to sequences: peel off one element, recurse on the rest.

Recursive sum

Recursive sum of a vector
#include <vector>

int sum(const std::vector<int>& v, int i = 0) {
    if (i == static_cast<int>(v.size())) return 0; // base case: past the end
    return v[i] + sum(v, i + 1);                   // add current element, recurse on the rest
}

Binary search is naturally recursive: compare the middle element, then recurse on whichever half could contain the target. The vector must be sorted.

Recursive binary search
#include <vector>

int binary_search(const std::vector<int>& v, int target, int lo, int hi) {
    if (lo > hi) return -1;                        // base case: target not found

    int mid = lo + (hi - lo) / 2;
    if (v[mid] == target) return mid;              // base case: found

    if (v[mid] < target)
        return binary_search(v, target, mid + 1, hi);  // search right half
    return binary_search(v, target, lo, mid - 1);      // search left half
}

Each call halves the search range, giving O(log n) time.

mid = lo + (hi - lo) / 2 instead of (lo + hi) / 2 avoids integer overflow when lo and hi are large.

Exercises

Q1.

Without running the code, predict what this program prints. Trace the call stack step by step.

#include <iostream>

int mystery(int n) {
    if (n <= 1) return n;
    return mystery(n - 1) + mystery(n - 2);
}

int main() {
    for (int i = 0; i <= 6; i++) {
        std::cout << mystery(i) << " ";
    }
    std::cout << "\n";

    return 0;
}

Q2.

Without running the code, predict what this program prints.

#include <iostream>

bool is_even(int n);
bool is_odd(int n);

bool is_even(int n) {
    if (n == 0) return true;
    return is_odd(n - 1);
}

bool is_odd(int n) {
    if (n == 0) return false;
    return is_even(n - 1);
}

int main() {
    std::cout << is_even(3) << "\n"; // line A
    std::cout << is_odd(3) << "\n";  // line B
    std::cout << is_even(0) << "\n"; // line C
    std::cout << is_odd(1) << "\n";  // line D

    return 0;
}

Q3.

Trace through this program step by step. For each call to power, write the arguments and return value.

#include <iostream>

int power(int base, int exp) {
    if (exp == 0) return 1;
    if (exp % 2 == 0) {
        int half = power(base, exp / 2);
        return half * half;
    }
    return base * power(base, exp - 1);
}

int main() {
    std::cout << power(3, 6) << "\n";
    return 0;
}

Q4.

Each snippet has a bug related to recursion. Identify what's wrong and what happens at runtime.

// Snippet A
int count_down(int n) {
    std::cout << n << "\n";
    return count_down(n - 1);
}

// Snippet B
int factorial(int n) {
    if (n == 1) return 1;
    return n * factorial(n - 1);
}

// Snippet C
int sum_to(int n) {
    if (n == 0) return 0;
    return n + sum_to(n + 1); // supposed to sum 1..n
}

// Snippet D
int fib(int n) {
    if (n == 0) return 0;
    if (n == 1) return 1;
    return fib(n - 1) + fib(n - 1); // typo
}

Q5.

Write a recursive function count_if_rec(const std::vector<int>& v, int target, int i) that returns how many times target appears in v, starting from index i. Then wrap it with a convenience overload that starts from index 0.

#include <iostream>
#include <vector>

int count_if_rec(const std::vector<int>& v, int target, int i) {
    // TODO
}

int count_target(const std::vector<int>& v, int target) {
    // TODO: call count_if_rec starting from index 0
}

int main() {
    std::vector<int> v = {1, 2, 3, 2, 4, 2};
    std::cout << count_target(v, 2) << "\n"; // 3
    std::cout << count_target(v, 5) << "\n"; // 0

    return 0;
}

Q6.

Write a recursive function is_palindrome(const std::string& s, int lo, int hi) that returns true if the characters of s between indices lo and hi (inclusive) form a palindrome. Wrap it with a convenience function is_palindrome(const std::string& s) that checks the whole string.

#include <iostream>
#include <string>

bool is_palindrome(const std::string& s, int lo, int hi) {
    // TODO
}

bool is_palindrome(const std::string& s) {
    // TODO
}

int main() {
    std::cout << is_palindrome("racecar") << "\n"; // 1
    std::cout << is_palindrome("hello") << "\n";   // 0
    std::cout << is_palindrome("a") << "\n";       // 1
    std::cout << is_palindrome("") << "\n";        // 1

    return 0;
}

Q7.

Rewrite power(int base, int exp) using the accumulator (tail-recursive) pattern. The function signature should be power_tail(int base, int exp, int acc = 1). Use simple O(n) recursion (not fast exponentiation) so the accumulator logic stays clear.

#include <iostream>

int power_tail(int base, int exp, int acc = 1) {
    // TODO
}

int main() {
    std::cout << power_tail(2, 10) << "\n"; // 1024
    std::cout << power_tail(3, 4) << "\n";  // 81
    std::cout << power_tail(5, 0) << "\n";  // 1

    return 0;
}

Practice Problems