C - Recursive Functions
Recursion is a technique where a function solves a problem by calling itself on smaller subproblems, and combining those results. It’s essential for problems with natural self-similarity (trees, divide-and-conquer, backtracking).
Learning Objectives
- Recognize problems that suit recursion (divide-and-conquer, tree/graph traversal, backtracking).
- Design correct recursive functions with clear base cases and progress.
- Trace the call stack; reason about time/space complexity and stack depth.
- Compare recursive vs iterative approaches and understand tail recursion.
Prerequisites
- C - Functions
- C - Looping Statements (for comparison)
- C - Pointers (for tree/linked list examples)
How Recursion Works
- Base case: a minimal input where the answer is known directly.
- Recursive case: reduce the problem to smaller subproblems and call the same function.
- Progress: each call moves toward the base case (e.g., n-1).
// Template
ResultType f(Input x) {
  if (is_base_case(x)) return base_answer;
  smaller = reduce(x);
  partial = f(smaller);
  return combine(partial);
}
Visualizing the Call Stack (factorial 4!)
fact(4)
  ↳ 4 * fact(3)
       ↳ 3 * fact(2)
            ↳ 2 * fact(1)
                 ↳ 1 (base)
Unwind: 2*1=2 → 3*2=6 → 4*6=24
Classic Examples
Factorial (simple)
#include <stdio.h>
int fact(int n) {
  if (n <= 1) return 1;     // base
  return n * fact(n - 1);   // recursive
}
int main(void) { printf("%d\n", fact(5)); }
Factorial (tail-recursive style)
Tail recursion puts the recursive call last; some compilers can optimize it, but tail-call optimization is not guaranteed in C. Don’t rely on it for eliminating stack usage.
int fact_tail(int n, int acc) {
  if (n <= 1) return acc;
  return fact_tail(n - 1, n * acc);
}
int fact_tr(int n) { return fact_tail(n, 1); }
Fibonacci (naive vs improved)
int fib(int n) {                     // O(phi^n): exponential
  if (n < 2) return n;
  return fib(n-1) + fib(n-2);
}
int fib_iter(int n) {                // O(n), iterative
  int a = 0, b = 1;
  for (int i = 0; i < n; i++) { int t = a + b; a = b; b = t; }
  return a;
}
Divide and Conquer
Binary Search (recursive)
#include <stdio.h>
int bsearch_rec(const int *a, int lo, int hi, int key) {
  if (lo > hi) return -1;                 // base: not found
  int mid = lo + (hi - lo) / 2;
  if (a[mid] == key) return mid;          // base: found
  if (key < a[mid]) return bsearch_rec(a, lo, mid - 1, key);
  else               return bsearch_rec(a, mid + 1, hi, key);
}
Quicksort (reference implementation)
#include <stdio.h>
void swap(int *x, int *y) { int t = *x; *x = *y; *y = t; }
int partition(int *a, int lo, int hi) {
  int pivot = a[hi], i = lo;
  for (int j = lo; j < hi; j++) if (a[j] < pivot) swap(&a[i++], &a[j]);
  swap(&a[i], &a[hi]); return i;
}
void quicksort(int *a, int lo, int hi) {
  if (lo >= hi) return; // base
  int p = partition(a, lo, hi);
  quicksort(a, lo, p - 1);
  quicksort(a, p + 1, hi);
}
Recursive Data Structures
Linked List (sum)
struct Node { int val; struct Node *next; };
int sum_list(const struct Node *n) {
  if (!n) return 0;              // base
  return n->val + sum_list(n->next);
}
Binary Tree Traversal
struct T { int v; struct T *l, *r; };
void inorder(const struct T *t) {
  if (!t) return;
  inorder(t->l);
  printf("%d ", t->v);
  inorder(t->r);
}
Backtracking (generate all subsets)
#include <stdio.h>
void subsets(const int *a, int n, int i, int *cur, int k) {
  if (i == n) {
    printf("{"); for (int j = 0; j < k; j++) printf("%s%d", j?", ":"", cur[j]); printf("}\n");
    return;
  }
  // exclude a[i]
  subsets(a, n, i + 1, cur, k);
  // include a[i]
  cur[k] = a[i];
  subsets(a, n, i + 1, cur, k + 1);
}
Complexity and Limits
- Time/space: derive recurrences; consider call depth and extra memory.
- Stack overflow: deep recursion can crash; prefer iteration or manual stacks if depth is large.
- Tail recursion: may reduce stack in some languages/compilers, but not guaranteed in C.
Common Pitfalls
- Missing/incorrect base case → infinite recursion.
- No progress toward base case (e.g., calling with same argument).
- Exponential blowup (e.g., naive Fibonacci). Consider memoization/iteration.
Checks for Understanding
- Identify the base and recursive cases in factandbsearch_rec.
- Why is naive Fibonacci exponential? How to improve it?
- When should you avoid recursion in C?
Show suggested answers
- fact: base n<=1; recursive n*fact(n-1). bsearch: base lo>hi (not found) or a[mid]==key; reduce range each call.
- Overlapping subproblems cause repeated work; use iteration or memoization.
- When depth can exceed stack limits or when an iterative solution is simpler/clearer.
Practice Exercises
- 
    Sum of array recursively: int sum(const int *a, int n).Solutionint sum(const int *a, int n){ return (n==0)?0 : a[n-1]+sum(a,n-1);}
- 
    Reverse a string in place recursively.
    Solutionvoid rev(char *s,int i,int j){ if(i>=j) return; char t=s[i]; s[i]=s[j]; s[j]=t; rev(s,i+1,j-1);}
- 
    Fast power by squaring: double powi(double x, int n).Solutiondouble powi(double x,int n){ if(n==0) return 1; if(n<0) return 1.0/powi(x,-n); double h=powi(x,n/2); return (n%2)? h*h*x : h*h; }
Recurrence relations and the Master theorem (high level)
Many divide-and-conquer algorithms have runtimes that follow a recurrence of the form T(n) = a T(n/b) + f(n):
- a: number of subproblems; b: factor by which the problem size shrinks; f(n): cost to divide/combine.
- Master theorem (intuition):
    - Let n^{log_b a}be the “tree size.” Comparef(n)ton^{log_b a}.
- If f(n)is smaller (polynomially),T(n) = Θ(n^{log_b a}).
- If they match (within polylog factors), T(n) = Θ(n^{log_b a} log n).
- If f(n)is larger (polynomially) and “regular,”T(n) = Θ(f(n)).
 
- Let 
Examples:
- Binary search: T(n) = T(n/2) + Θ(1)→a=1, b=2, n^{log_b a} = n^0 = 1. Sincef(n)=Θ(1)matches, result isΘ(log n).
- Mergesort: T(n) = 2 T(n/2) + Θ(n)→a=2, b=2, n^{log_b a}=n.f(n)=Θ(n)matches →Θ(n log n).
- Quicksort (average): T(n) = T(n/2) + T(n/2) + Θ(n)(≈ mergesort) →Θ(n log n).
Memoization patterns in C
Memoization stores results of subproblems to avoid recomputation. In C, simple cases use arrays (dense domains). For sparse domains, consider hash tables (custom or library-based).
Array-based memo (Fibonacci)
#include <stdio.h>
int fib_memo(int n, int memo[]) {
  if (n < 2) return n;
  if (memo[n] != -1) return memo[n];
  memo[n] = fib_memo(n-1, memo) + fib_memo(n-2, memo);
  return memo[n];
}
int main(void) {
  enum { N = 46 };                 // fits in 32-bit int
  int memo[N+1];
  for (int i = 0; i <= N; i++) memo[i] = -1;  // sentinel
  printf("%d\n", fib_memo(N, memo));
}
Hash-table memo (sketch)
For non-dense keys (e.g., multiple parameters), use a map from a composite key to result. In pure C, you can:
- Use a simple open-addressing table with a struct key (e.g., pair of ints) and sentinel states (EMPTY, USED).
- Or bring a small hash-map implementation and wrap lookups in get_or_compute().
// Pseudocode outline
// struct Key { int x, y; };
// struct Entry { struct Key k; int value; int state; };
// int lookup_or_insert(struct Entry *tab, size_t cap, struct Key k, int (*compute)(int,int)) {
//   size_t i = hash(k) % cap; for (...) { probe... if match return value; if empty, compute & store; }
// }
When to memoize
- Overlapping subproblems (e.g., naive DP like Fibonacci, edit distance).
- Tree/graph traversals where subtrees repeat (with cycle checks).
- Be mindful of memory usage; clear tables if they grow without bound.
Capstone Challenge
Implement a recursive quicksort that handles duplicates well (e.g., 3-way partitioning), or solve a maze using DFS backtracking. Compare recursion depth and performance against iterative variants.