Lecture 12: Recursion¶
Recursion is a function calling itself to solve a smaller version of the same problem. It can feel like a magic trick the first time you see it work — but underneath, it's just the call stack from Lecture 11, doing exactly what it always does. Recursion is the natural way to think about trees (Unit 5) and graphs (Unit 6), which is why it earns its own dedicated lecture right before those units begin.
In This Lecture¶
- The basic concept: base case and recursive case
- How recursion actually uses the call stack, traced and diagrammed frame by frame
- Recursion versus iteration, with a real timed comparison on Fibonacci
- Why deep recursion can exhaust the stack, demonstrated safely
- Types of recursion
- The advantages and limitations of thinking recursively
Basic Concept: Base Case and Recursive Case¶
Every correct recursive function needs exactly two parts:
- Base case — the simplest possible version of the problem, answered directly with no further recursive call. Without one, the function calls itself forever.
- Recursive case — the function calls itself on a smaller version of the problem, and combines that result to solve the original.
#include <iostream>
using namespace std;
// factorial(n) = n * (n-1) * (n-2) * ... * 1, and factorial(0) = 1
unsigned long factorial(int n) {
if (n == 0) { // base case: the simplest version, no recursion needed
return 1;
}
return n * factorial(n - 1); // recursive case: solve a smaller problem, then combine
}
int main() {
for (int n : {0, 1, 5, 10}) {
cout << n << "! = " << factorial(n) << endl;
}
return 0;
}
The Call Stack¶
Every function call — recursive or not — pushes a new stack frame onto the call stack: its parameters, local variables, and where to resume once it returns. A recursive call is no different; it's just a function pushing another copy of itself.
flowchart TD
A["factorial(3) called"] --> B["factorial(2) called"]
B --> C["factorial(1) called"]
C --> D["factorial(0) called<br/>BASE CASE: returns 1"]
D --> E["factorial(1) returns 1 * 1 = 1"]
E --> F["factorial(2) returns 2 * 1 = 2"]
F --> G["factorial(3) returns 3 * 2 = 6"]
Each call waits, paused on the call stack, for the call below it to return before it can finish its own multiplication — the stack unwinds from the base case back up to the original call, exactly like popping a stack one frame at a time.
#include <iostream>
using namespace std;
int factorialTraced(int n, int depth = 0) {
string indent(depth * 2, ' ');
cout << indent << "-> factorialTraced(" << n << ") called" << endl;
if (n == 0) {
cout << indent << "<- base case, returning 1" << endl;
return 1;
}
int result = n * factorialTraced(n - 1, depth + 1);
cout << indent << "<- factorialTraced(" << n << ") returning " << result << endl;
return result;
}
int main() {
int result = factorialTraced(4); // let the trace finish before printing the result
cout << "Result: " << result << endl;
return 0;
}
$ g++ -std=c++17 -o recursion_trace recursion_trace.cpp
$ ./recursion_trace
-> factorialTraced(4) called
-> factorialTraced(3) called
-> factorialTraced(2) called
-> factorialTraced(1) called
-> factorialTraced(0) called
<- base case, returning 1
<- factorialTraced(1) returning 1
<- factorialTraced(2) returning 2
<- factorialTraced(3) returning 6
<- factorialTraced(4) returning 24
Result: 24
Notice the shape: calls go down (indenting deeper) until the base case, then results come back up in reverse order — the last call made is the first one to return. That's the call stack's LIFO behavior from Lecture 10, made visible.
The trace above shows the order of calls and returns, but not what's physically sitting
on the call stack at the deepest point. The diagram below makes that literal: each box is
one stack frame — one paused, waiting call to factorialTraced — stacked exactly like
Lecture 10's LinkedStack, with the most recently pushed frame drawn on top.
flowchart TD
subgraph Growing["Growing: factorialTraced(4) calling down to the base case"]
direction TD
F4["frame: factorialTraced(4)<br/>n = 4, waiting on: 4 * factorialTraced(3)"]
F3["frame: factorialTraced(3)<br/>n = 3, waiting on: 3 * factorialTraced(2)"]
F2["frame: factorialTraced(2)<br/>n = 2, waiting on: 2 * factorialTraced(1)"]
F1["frame: factorialTraced(1)<br/>n = 1, waiting on: 1 * factorialTraced(0)"]
F0["frame: factorialTraced(0)<br/>n = 0, BASE CASE -- returns 1 immediately"]
F4 --> F3 --> F2 --> F1 --> F0
end
flowchart BT
subgraph Unwinding["Unwinding: each frame resumes, multiplies, and pops"]
direction BT
U0["factorialTraced(0) returns 1<br/>(frame popped)"]
U1["factorialTraced(1) resumes:<br/>1 * 1 = 1, returns 1<br/>(frame popped)"]
U2["factorialTraced(2) resumes:<br/>2 * 1 = 2, returns 2<br/>(frame popped)"]
U3["factorialTraced(3) resumes:<br/>3 * 2 = 6, returns 6<br/>(frame popped)"]
U4["factorialTraced(4) resumes:<br/>4 * 6 = 24, returns 24<br/>(frame popped -- stack empty)"]
U0 --> U1 --> U2 --> U3 --> U4
end
Every frame in the "growing" diagram is genuinely still on the stack, paused mid-statement
(n * factorialTraced(n - 1) can't finish computing the multiplication until the recursive
call returns) — exactly n + 1 frames deep for factorialTraced(n), which is why deep
recursion costs stack memory proportional to the recursion depth, not a constant amount.
Forgetting the base case causes a stack overflow
If factorial never checked n == 0, it would keep calling itself — factorial(-1),
factorial(-2), forever — pushing a new stack frame every time, until the program
runs out of stack memory and crashes with a stack overflow. This is the single most
common bug in recursive code; always verify the base case is actually reachable.
Recursion versus Iteration¶
Anything recursion can do, a loop (iteration) can also do — the two are computationally equivalent. The choice is about which one expresses the problem more naturally.
#include <iostream>
using namespace std;
unsigned long factorialRecursive(int n) {
if (n == 0) return 1;
return n * factorialRecursive(n - 1);
}
unsigned long factorialIterative(int n) {
unsigned long result = 1;
for (int i = 1; i <= n; i++) {
result *= i;
}
return result;
}
int main() {
int n = 8;
cout << "Recursive: " << n << "! = " << factorialRecursive(n) << endl;
cout << "Iterative: " << n << "! = " << factorialIterative(n) << endl;
return 0;
}
$ g++ -std=c++17 -o recursion_vs_iteration recursion_vs_iteration.cpp
$ ./recursion_vs_iteration
Recursive: 8! = 40320
Iterative: 8! = 40320
| Recursion | Iteration | |
|---|---|---|
| Memory | Uses O(n) call stack space for n nested calls |
O(1) extra space, just loop variables |
| Readability | Often much closer to the mathematical definition (trees, Unit 5, are naturally recursive) | Can be less obvious for inherently recursive problems |
| Risk | Stack overflow if the base case is unreachable or the input is very large | Infinite loop if the loop condition is wrong — no stack risk |
factorial doesn't make the performance difference between the two obvious, because it
only ever makes one recursive call per level — the recursive and iterative versions both
do exactly n multiplications. Fibonacci is a far more dramatic example, because the
naive recursive version makes two recursive calls per level, and those two calls'
subtrees overlap heavily — fibRecursive(5) and fibRecursive(4) both end up separately
recomputing fibRecursive(3) from scratch, with no memory of having done it already.
flowchart TD
F5["fib(5)"] --> F4a["fib(4)"]
F5 --> F3a["fib(3)"]
F4a --> F3b["fib(3)"]
F4a --> F2a["fib(2)"]
F3a --> F2b["fib(2)"]
F3a --> F1a["fib(1)"]
F3b --> F2c["fib(2)"]
F3b --> F1b["fib(1)"]
Notice fib(3) appears twice and fib(2) appears three times in just this small
slice down to fib(5) — each identical call re-does all the work of its entire subtree
below it, from scratch, every single time. The number of calls roughly doubles with every
increase in n, giving O(2ⁿ) total calls — genuinely exponential, unlike factorial's O(n).
#include <iostream>
#include <chrono>
using namespace std;
using namespace std::chrono;
long fibRecursive(int n) {
if (n <= 1) return n;
return fibRecursive(n - 1) + fibRecursive(n - 2);
}
long fibIterative(int n) {
if (n <= 1) return n;
long prev = 0, curr = 1;
for (int i = 2; i <= n; i++) {
long next = prev + curr;
prev = curr;
curr = next;
}
return curr;
}
int main() {
int n = 32;
auto start1 = high_resolution_clock::now();
long r1 = fibRecursive(n);
auto end1 = high_resolution_clock::now();
double recursiveMs = duration_cast<duration<double, milli>>(end1 - start1).count();
auto start2 = high_resolution_clock::now();
long r2 = fibIterative(n);
auto end2 = high_resolution_clock::now();
double iterativeMs = duration_cast<duration<double, milli>>(end2 - start2).count();
cout << "fibRecursive(" << n << ") = " << r1 << endl;
cout << "fibIterative(" << n << ") = " << r2 << endl;
double ratio = recursiveMs / (iterativeMs > 0.0001 ? iterativeMs : 0.0001);
cout << "Recursive vs iterative: ";
if (ratio > 100.0) cout << "recursion was at least 100x slower" << endl;
else if (ratio > 10.0) cout << "recursion was at least 10x slower" << endl;
else cout << "recursion was slower, but by less than 10x" << endl;
return 0;
}
$ g++ -std=c++17 -o fib_recursive_vs_iterative fib_recursive_vs_iterative.cpp
$ ./fib_recursive_vs_iterative
fibRecursive(32) = 2178309
fibIterative(32) = 2178309
Recursive vs iterative: recursion was at least 100x slower
Same answer, wildly different cost — and a verdict instead of a raw number
Both functions compute the identical value, 2178309 — correctness isn't the issue,
cost is. fibIterative does exactly n - 1 additions no matter what; fibRecursive
makes roughly two million calls to compute fib(32) alone, because of all the repeated
subtree work shown in the diagram above. As with Lecture 9's timing comparison, the
program prints a bucketed verdict ("at least 100x slower") rather than a raw
millisecond count — the exact ratio depends on the machine, but the fact that
exponential beats linear by orders of magnitude does not, which is exactly what makes
it safe to assert as a fixed, reproducible verdict here. Unit 5 and Unit 6 (trees and
graphs) both fix exactly this kind of repeated-subproblem waste with memoization —
caching each subproblem's answer the first time it's computed.
Why Deep Recursion Can Exhaust the Stack¶
The call-stack diagrams earlier in this lecture showed factorialTraced(4) using 5 stack
frames. That's nothing — but the pattern scales directly: factorialTraced(n) always
uses n + 1 frames, and every frame consumes a small but nonzero amount of stack memory
(parameters, local variables, and bookkeeping for where to resume). A typical program's
call stack is only a few megabytes, set by the operating system when the program starts —
nothing like the gigabytes of heap memory new can draw from. Recurse deep enough, and
that stack space runs out: a stack overflow, which typically crashes the entire
program immediately, with no exception to catch (unlike ArrayStack::push in Lecture 10,
which throws a clean, catchable overflow_error instead).
#include <iostream>
using namespace std;
// Recurses `depth` levels deep, doing negligible work per frame, then
// unwinds back and returns the deepest level actually reached.
int recurseTo(int target, int current = 1) {
if (current >= target) return current;
return recurseTo(target, current + 1);
}
int main() {
for (int target : {1000, 5000, 20000}) {
int reached = recurseTo(target);
cout << "Requested depth " << target << " -> reached " << reached
<< " (returned successfully, stack intact)" << endl;
}
return 0;
}
$ g++ -std=c++17 -o deep_recursion deep_recursion.cpp
$ ./deep_recursion
Requested depth 1000 -> reached 1000 (returned successfully, stack intact)
Requested depth 5000 -> reached 5000 (returned successfully, stack intact)
Requested depth 20000 -> reached 20000 (returned successfully, stack intact)
All three depths here complete safely — each frame of recurseTo is tiny (two int
parameters, nothing else), so even 20,000 nested frames comfortably fits inside a typical
few-megabyte stack. This lecture deliberately stops at a depth that succeeds, rather than
pushing further until it crashes: a genuine stack overflow terminates the whole process
immediately (no recoverable exception, no clean output), which is the wrong thing to
trigger in code you intend to keep running — including this book's own verification tooling.
This same function crashes well before 100,000 on a typical default stack
Pushing recurseTo's target past roughly 30,000–40,000 on a typical MinGW/Windows
default stack (about 1 MB, well under the 8 MB some Linux defaults reserve) is enough
to exhaust it and crash with a stack overflow — try it locally outside of any grading
or verification pipeline, at your own risk, and watch the program terminate abruptly
with no output for that line at all. The fix is never "recurse more carefully" — it's
either converting to an equivalent iterative loop (as fibIterative did above, O(1)
extra space instead of O(n)) or, for problems that genuinely need it, explicitly
managing your own stack data structure on the heap instead of relying on the call
stack's fixed-size memory.
Types of Recursion¶
- Direct recursion — a function calls itself directly, as
factorialdoes above. - Indirect recursion — function
Acalls functionB, which callsAagain. - Tail recursion — the recursive call is the very last thing the function does,
with no pending work afterward (some compilers can optimize this into a loop
automatically).
factorialabove is not tail-recursive, because it still has to multiply bynafter the recursive call returns.
Why tail recursion connects back to the stack-overflow discussion
A tail-recursive function's stack frame has nothing left to do once the recursive
call returns — it can just hand back whatever the recursive call produced. A sufficiently
smart compiler can therefore reuse the same stack frame for every "call" instead of
pushing a new one, turning the recursion into a loop under the hood at zero extra stack
cost. factorial(int n) as written is not tail-recursive (the pending n * means the
frame is still needed after the call returns), but rewriting it with an accumulator
parameter — factorialTail(int n, unsigned long acc = 1), returning
factorialTail(n - 1, n * acc) — would be. Don't rely on this optimization happening,
though: C++ doesn't guarantee it (unlike some functional languages), so treating it as
"recursion with no stack cost" is a bug waiting to happen if you ever change compilers
or optimization settings.
Advantages and Limitations of Recursion¶
Advantages
- Naturally expresses problems that are themselves defined recursively — tree traversal (Lecture 19), graph traversal (Lecture 26), and divide-and-conquer sorting (Lecture 31) all read far more clearly as recursion than as hand-rolled loops with manual bookkeeping.
- Often shorter and closer to a mathematical proof than the equivalent iterative code.
Limitations
- Each call consumes stack memory — deep recursion on very large inputs can overflow the stack, a risk plain loops don't share.
- Can be slower than iteration due to function-call overhead, unless the compiler optimizes it away.
Try It Yourself¶
- Write a recursive function
int sumDigits(int n)that returns the sum of the digits of a non-negative integern(e.g.,sumDigits(1234)should return10). Identify the base case and the recursive case before writing any code. - Compile and run
recursion_trace.cppwithfactorialTraced(6)instead of4, and count how many lines of indentation deep the trace goes before hitting the base case. Confirm it matches your expectation based on the call-stack diagram above. - Add a
fibTraced(int n, int depth = 0)function, modeled onfactorialTraced, that prints an indented "called" line on entry and a "returning" line on exit. Run it forfibTraced(4)and count how many timesfib(1)andfib(0)get printed as "called" — does the count match what the recursion-tree diagram earlier in this lecture predicts? - Modify
fib_recursive_vs_iterative.cppto also print the number of callsfibRecursivemakes (add astatic long callCountor pass a counter by reference, incremented once per call) forn = 20,n = 25, andn = 30. Does the call count roughly double each timenincreases by 1, matching the O(2ⁿ) claim in this lecture? - Write
factorialTail(int n, unsigned long acc = 1), the tail-recursive version of factorial described in the tip above. Confirm it produces the same results asfactorialRecursivefor several values ofn, then explain in a sentence or two why its stack-frame shape, if you were to draw it as the growing/unwinding diagrams from earlier, would look different fromfactorialTraced's. - Using
deep_recursion.cppas a starting point, write a separate program (don't run it inside any grading or verification pipeline) that increases the target depth in a loop — 10,000, 20,000, 40,000, 80,000 — until it crashes on your machine. Record the approximate depth where it stopped succeeding. Is it close to the range this lecture's warning mentions?
Key Takeaways¶
- Every correct recursive function needs a base case (stops the recursion) and a recursive case (calls itself on a smaller problem).
- Recursive calls use the call stack exactly like any other function call — LIFO, with each paused call waiting for the one below it to return; the growing/unwinding diagrams in this lecture make that literal, one stack frame per pending call.
- Recursion and iteration are computationally equivalent — the choice is about which one more naturally expresses the problem, not raw capability. But equivalent doesn't mean equally fast: naive recursive Fibonacci is O(2ⁿ) because of massively overlapping repeated work, while the iterative version is O(n) — a gap real enough to report as a reproducible verdict, not just a theoretical curiosity.
- A missing or unreachable base case causes a stack overflow, the most common bug in recursive code — always verify it before trusting a recursive function. Even a correct base case doesn't save you if the recursion is simply too deep for the available stack space, which is finite (typically a few megabytes) unlike heap memory.
- Tail recursion — where the recursive call is the last thing a function does — can in principle be optimized into a loop with zero extra stack cost, but C++ never guarantees this happens, so it shouldn't be relied on as a substitute for an explicit iterative rewrite when stack depth is a genuine concern.
- Recursion is the natural mental model for trees and graphs — the next two units will lean on it constantly.