Recursion and Divide-and-Conquer
Recursion is one of the most powerful techniques in algorithm design: solving a problem by breaking it into smaller instances of the same problem. In this chapter we study recursion from the ground up, connect it to mathematical induction, and then develop the divide-and-conquer strategy — splitting a problem into independent subproblems, solving each recursively, and combining the results. We illustrate these ideas with four algorithms: binary search, fast exponentiation, the Euclidean algorithm for greatest common divisors, and the closest pair of points.
Recursion
Some problems have a natural recursive structure: the solution depends on solving one or more smaller instances of the same problem. When we translate this structure into code, we get a recursive function — a function that calls itself. This is not mere circularity: each call works on a smaller instance of the problem, and eventually the instances become small enough to solve directly. Every recursive function has two essential ingredients:
- Base case. One or more input sizes for which the answer is immediate, without further recursion.
- Recursive case. For larger inputs, the function reduces the problem to one or more smaller instances and combines the results.
Consider a simple example: computing the factorial .
function factorial(n: number): number {
if (n <= 1) return 1; // base case
return n * factorial(n - 1); // recursive case
}
The base case is , where we return 1. The recursive case multiplies by the factorial of . Each recursive call reduces the argument by 1, so the chain of calls eventually reaches the base case:
The call stack
To understand how recursion works at runtime, we need to understand how computers handle function calls in general. Whenever any function is called — recursive or not — the runtime needs to remember where to return after the call finishes, and what values the local variables had. It stores this information in a frame: a block of memory holding the function's arguments, local variables, and the return address (the point in the calling code to resume after the call completes).
These frames are organized in a call stack — a stack data structure where each new call pushes a frame on top, and each return pops one off. For ordinary (non-recursive) code, the stack is typically shallow — for example, if main calls f, which calls g, the stack is only three frames deep. But with recursion, the same function can appear many times on the stack simultaneously, each frame representing a different invocation with different argument values.
For factorial(4), the stack grows to depth 4 before the base case is reached:
factorial(4) — waiting for factorial(3)
factorial(3) — waiting for factorial(2)
factorial(2) — waiting for factorial(1)
factorial(1) — returns 1
factorial(2) — returns 2 × 1 = 2
factorial(3) — returns 3 × 2 = 6
factorial(4) — returns 4 × 6 = 24
Each frame occupies memory, so a recursion of depth uses stack space. For factorial(n), the depth is , so the space complexity is .
Stack overflow
The call stack has a fixed maximum size, set by the operating system or the language runtime. When a recursion goes too deep, the stack runs out of space, and the program crashes with a stack overflow error. This is not an abstract concern — it happens easily in practice. For example, our factorial function works fine for small inputs, but calling factorial(100000) will likely crash:
factorial(100000); // RangeError: Maximum call stack size exceeded
In JavaScript and TypeScript, the default stack size typically allows around 10,000–15,000 frames (the exact limit depends on the runtime and the size of each frame). Other languages have similar limits.
This is an important practical consideration when choosing between a recursive and an iterative solution. Any recursion can be rewritten as a loop with an explicit stack, trading the elegance of recursion for safety against overflow. But for some problems the clarity and elegance of the recursive solution outweigh the cost. For algorithms where the recursion depth is logarithmic (like binary search, with depth ), stack overflow is never a concern — even for , the depth is only about 60. But for algorithms where the depth is linear in the input (like our factorial), an iterative version may be preferable for large inputs.
Common pitfalls
Two mistakes arise frequently when writing recursive functions:
-
Missing base case. Without a base case, the recursion never terminates:
function infiniteRecursion(n: number): number { return n * infiniteRecursion(n - 1); // no base case! }This is not an algorithm in the sense of Definition 1.1 — it does not terminate.
-
Subproblems that do not shrink. Even with a base case, the recursion must make progress:
function noProgress(n: number): number { if (n <= 1) return 1; return n * noProgress(n); // n does not decrease! }This function never reaches the base case for .
Recursion and mathematical induction
Mathematical induction is a proof technique for showing that a statement is true for every element of a well-ordered sequence — most commonly the natural numbers, but the idea applies whenever instances can be ranked by size (array lengths, tree depths, and so on). It works in two steps. First, you show the statement is true for the smallest case (usually or ) — this is the base case. Second, you show that if the statement is true for some number , then it must also be true for — this is the inductive step. Together, these two steps create a chain of reasoning: the base case establishes the first domino, and the inductive step guarantees that each domino knocks over the next, so the statement holds for all natural numbers.
There is a deep connection between this technique and recursion. Induction proves that a property holds for all natural numbers; recursion computes a value for all valid inputs. The structures are parallel:
| Induction | Recursion |
|---|---|
| Base case: prove (or ) | Base case: return a value directly |
| Inductive step: assuming , prove | Recursive case: assuming the recursive call returns the correct result, compute the current result |
This parallel is not a coincidence — it is the foundation for proving recursive algorithms correctness. To prove that a recursive function computes the right answer, we use strong induction (also called complete induction): assume the function works correctly for all inputs smaller than , and show it works correctly for input .
Definition 3.1 - Correctness of a recursive algorithm
A recursive algorithm is correct if:
- It produces the correct answer on all base cases.
- If the algorithm produces the correct answer on every strictly smaller subproblem, then it also produces the correct answer on the current problem.
When we implement a recursive algorithm as a function in code, these two conditions translate directly: the base case corresponds to the if branch that returns a value without recursing, and the recursive case corresponds to the branch that calls the function on a smaller input and combines the result. Condition 2 becomes: if every recursive call on a strictly smaller input returns the correct answer, then the current call also returns the correct answer.
Not every function is an algorithm — a function might not terminate, or might not solve a well-defined problem — but when a recursive function does implement an algorithm, proving it correct means verifying exactly these two conditions.
Example 3.1: Correctness of factorial.
Base case. When , the function returns 1, and indeed .
Inductive step. Assume factorial(k) returns for all . Then factorial(n) returns .
Divide and conquer
Divide and conquer is a specific recursion pattern that solves a problem by:
- Divide: split the input into two or more smaller subproblems of the same type.
- Conquer: solve each subproblem recursively (or directly if it is small enough).
- Combine: merge the subproblem solutions into a solution for the original problem.
Not every recursive algorithm is divide-and-conquer. The factorial function above reduces the problem by a constant amount (from to ), which is sometimes called decrease and conquer. True divide-and-conquer algorithms typically split the input by a constant fraction (usually in half), leading to logarithmic recursion depth and often dramatically better performance.
The running time of a divide-and-conquer algorithm is typically expressed as a recurrence of the form
where is the number of subproblems, is their size, and is the cost of dividing and combining.
Note that and need not be equal: describes how the input is partitioned (a structural choice), while describes how many of those parts the algorithm actually recurses on (an algorithmic choice). For example, binary search splits the array in two () but recurses on only one half (); merge sort also splits in two () but must recurse on both halves (). An algorithm can even have : Karatsuba multiplication splits each number into two halves () but produces three recursive subproblems () from clever algebraic rearrangement. The relationship between and is what ultimately determines the algorithm's growth rate. As we saw in Chapter 2, the Master Theorem often gives us the asymptotic estimate for directly.
Binary search
Our first divide-and-conquer algorithm is one of the most important ones: binary search. It finds the position of a target value in a sorted array by repeatedly halving the search space.
The problem
Input: A sorted array of numbers and a target value .
Output: An index such that , or if is not in .
The algorithm
The idea is simple: compare with the middle element of the array.
- If they match, return the index.
- If is smaller, recurse on the left half.
- If is larger, recurse on the right half.
Each step eliminates half of the remaining elements.
Recursive implementation
Since binary search is a divide-and-conquer algorithm, it is most natural to express it recursively. The function takes an array, a target, and the current search range (low and high). The two base cases are: (1) the range is empty — the element is not present; (2) the middle element matches — we return its index. The recursive cases narrow the range to one half:
export function binarySearchRecursive(
arr: number[],
element: number,
low: number = 0,
high: number = arr.length - 1,
): number {
if (low > high) return -1; // base case: empty range
const mid = Math.floor((low + high) / 2);
const midVal = arr[mid]!;
if (midVal === element) {
return mid; // base case: found
} else if (midVal < element) {
return binarySearchRecursive(arr, element, mid + 1, high); // search right half
} else {
return binarySearchRecursive(arr, element, low, mid - 1); // search left half
}
}
This implementation directly mirrors the divide-and-conquer description: each call either solves the problem immediately (base case) or delegates to a single subproblem of half the size.
Tracing through an example
Let and .
| Call | low | high | mid | arr[mid] | Action |
|---|---|---|---|---|---|
| 1 | 0 | 6 | 3 | 7 | : recurse on right half |
| 2 | 4 | 6 | 5 | 11 | : recurse on left half |
| 3 | 4 | 4 | 4 | 9 | : found, return 4 |
After only 3 comparisons (and 3 recursive calls), we have found the element in a 7-element array. A linear scan might have taken up to 7 comparisons.
Correctness
We prove correctness by induction on the size of the search range .
Base case. When (empty range, ), the element cannot be present, and the function correctly returns .
Inductive step. Assume the function returns the correct result for all ranges smaller than . For a range of size , the function computes and compares with :
- If , we return . Correct.
- If , then since is sorted, cannot be in . The recursive call searches , a strictly smaller range, so by the inductive hypothesis it returns the correct answer.
- The case is symmetric.
From recursion to iteration
Notice that the recursive binary search is tail-recursive: the recursive call is the very last operation in each branch — the function returns whatever the recursive call returns, without doing any further computation. Tail-recursive functions can always be transformed into a simple loop: we replace the recursive calls with assignments to the parameters and repeat.
Of course, even without this manual transformation, the computer already executes the recursion iteratively at the hardware level — using the call stack we discussed earlier in this chapter. Each recursive call pushes a new frame, and each return pops one off. But that mechanical translation is wasteful: it allocates a stack frame for every call, even though the caller does nothing with the result except pass it through. Transforming a tail-recursive function into a loop eliminates the stack entirely — the parameters are simply updated in place and control jumps back to the top of the function. The reason we can eliminate the stack is precisely that the function is tail-recursive: since nothing remains to be done after the recursive call returns, the caller's frame holds no state that is still needed, so there is nothing to save and no need for a stack frame at all. This is a general property — any tail-recursive function can be mechanically rewritten as a loop without a stack.
Let us apply this transformation to our recursive binary search:
export function binarySearch(arr: number[], element: number): number {
let low = 0;
let high = arr.length - 1;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const midVal = arr[mid]!;
if (midVal === element) {
return mid;
} else if (midVal < element) {
low = mid + 1;
} else {
high = mid - 1;
}
}
return -1;
}
The variables low and high play exactly the same role as the parameters of the recursive version — they define the current subproblem. Each iteration halves the range, just as each recursive call did. The iterative version has the advantage of using space instead of , because it avoids the overhead of the call stack. In practice, both versions are fine for binary search (the recursion depth is at most about 60 even for ), but the iterative form is conventional and marginally faster.
Complexity analysis
Each step halves the search range. Starting from elements, after steps we have at most elements. When there is still one element left to compare, so the search has not yet terminated. The range becomes empty — and the process terminates — when , i.e., after at most steps.
- Time complexity: (both versions).
- Space complexity: for the recursive version (call stack depth); for the iterative version.
Using the Master Theorem on the recurrence: . Here , , . Since , Case 2 gives .
Comparison with linear search
For comparison, here is the linear search algorithm:
export function linearSearch<T>(arr: T[], element: T): number {
let position = -1;
let currentIndex = 0;
while (position < 0 && currentIndex < arr.length) {
if (arr[currentIndex] === element) {
position = currentIndex;
} else {
currentIndex++;
}
}
return position;
}
Linear search works on any array (not just sorted ones) but takes time. Binary search requires a sorted array but is exponentially faster:
| Elements | Linear search | Binary search |
|---|---|---|
| 1,000 | 1,000 comparisons | 10 comparisons |
| 1,000,000 | 1,000,000 comparisons | 20 comparisons |
| comparisons | 30 comparisons |
This dramatic improvement — from linear to logarithmic — is the hallmark of the divide-and-conquer approach. The key insight is that each comparison does not eliminate just a single element but half of the remaining elements.
Fast exponentiation (exponentiation by squaring)
Our second example addresses the problem of computing efficiently.
The problem
Input: A number (the base) and a non-negative integer (the exponent).
Output: The value .
Naive approach
The straightforward approach multiplies by itself times:
export function powSlow(base: number, power: number): number {
let result = 1;
for (let i = 0; i < power; i++) {
result = result * base;
}
return result;
}
This performs multiplications, so it runs in time.
Exponentiation by squaring
We can do much better by observing a simple mathematical identity:
When is even, we compute once and square the result — a single multiplication instead of multiplications. When is odd, we reduce to an even exponent by extracting one factor of .
The recurrence above translates directly into a recursive function — just as with binary search. Writing that recursive version is straightforward (see Exercise 3.3). Here, we skip the intermediate steps we took for binary search and jump straight to the optimized iterative version. As before, the recursive version is tail-recursive, so the same transformation applies: we replace recursive calls with assignments to the parameters and loop:
export function pow(base: number, power: number): number {
let result = 1;
while (power > 0) {
if (power % 2 === 0) {
base = base * base;
power = power / 2;
} else {
result = result * base;
power = power - 1;
}
}
return result;
}
Tracing through an example
Let us compute :
| Step | base | power | result | Action |
|---|---|---|---|---|
| 1 | 2 | 10 | 1 | Even: base ← , power ← 5 |
| 2 | 4 | 5 | 1 | Odd: result ← , power ← 4 |
| 3 | 4 | 4 | 4 | Even: base ← , power ← 2 |
| 4 | 16 | 2 | 4 | Even: base ← , power ← 1 |
| 5 | 256 | 1 | 4 | Odd: result ← , power ← 0 |
Result: . The naive approach would have used 10 multiplications; fast exponentiation used 5.
Correctness
Invariant: At the start of each iteration, equals the original .
- Initialization. , , . The invariant holds.
- Maintenance.
- If power is even: we replace base with and power with . Then . Invariant preserved.
- If power is odd: we replace result with and power with . Then . Invariant preserved.
- Termination. When power , the invariant gives .
Complexity analysis
At each "odd" step, the exponent decreases by 1 (making it even). At each "even" step, the exponent halves. After at most two consecutive steps (one odd, one even), the exponent has been at least halved. Therefore the total number of steps is .
- Time complexity: .
- Space complexity: .
Alternatively, we can obtain a more precise asymptotic estimate directly from the Master Theorem. The recurrence for the recursive view is , the same as binary search, giving .
The Euclidean algorithm for GCD
The greatest common divisor (GCD) of two positive integers and is the largest integer that divides both. It is one of the oldest algorithms known, recorded by Euclid around 300 BC.
The problem
Input: Two positive integers and .
Output: , the largest positive integer dividing both and .
Naive approach
The brute-force approach tries every candidate from the smaller number downward. The GCD of and cannot exceed , because no number larger than can divide (and likewise for ). So there is no point starting the search above the smaller of the two inputs:
export function gcdSlow(x: number, y: number): number {
const min = Math.min(x, y);
for (let i = min; i >= 2; i--) {
if (x % i === 0 && y % i === 0) {
return i;
}
}
return 1;
}
This checks up to candidates, so its time complexity is .
The Euclidean algorithm
The Euclidean algorithm is based on a key observation:
The modulo operation. The expression (pronounced "x mod y") denotes the remainder when is divided by : for instance, because , and because divides evenly. In TypeScript this is written
x % y— we already used it in the naive GCD function above (x % i === 0). Formally, , and the result always satisfies .
This identity holds because the pairs and have exactly the same set of common divisors — any integer that divides both and also divides (a linear combination of and ), and conversely. Since the two pairs share the same divisors, they share the same greatest common divisor. Since , the arguments strictly decrease, and the process terminates when the remainder is 0:
Here is the implementation:
export function gcd(x: number, y: number): number {
while (y > 0) {
const r = x % y;
x = y;
y = r;
}
return x;
}
Tracing through an example
Let us compute . Each row shows and at the start of the iteration. After computing , the algorithm sets and , producing the values shown in the next row:
| Step | |||
|---|---|---|---|
| 1 | 210 | 2618 | 210 |
| 2 | 2618 | 210 | 98 |
| 3 | 210 | 98 | 14 |
| 4 | 98 | 14 | 0 |
| 5 | 14 | 0 | loop exits, return |
Result: .
The naive approach would have tested candidates from 2618 down to 14 — over 2600 iterations. The Euclidean algorithm needed only 5.
Correctness
We prove correctness by induction on the number of iterations.
Base case. When , the loop does not execute, and the algorithm returns . This is correct because .
Inductive step. Assume the algorithm correctly computes where . Since , the result is correct.
Complexity analysis
The key insight is that each iteration at least halves : whenever , we have . Since the algorithm swaps and at each step, after every two consecutive iterations the values have shrunk by at least a factor of 2. Starting from , we can halve at most times before reaching 0, so the total number of iterations is .
- Time complexity: .
- Space complexity: .
This is an exponential improvement over the naive approach.
Why . There are two cases. Case 1: . The remainder is always strictly less than the divisor, so . Case 2: . Since but , dividing by gives a quotient of exactly 1, so . In both cases, .
The closest pair of points
Our final example is the most challenging one, because it requires all three stages of the divide-and-conquer recipe — a nontrivial divide step, two recursive subproblems, and a combine step whose efficiency depends on a subtle geometric argument. The problem itself is easy to state: given a set of points in the plane, find the two that are closest to each other.
The problem
Input: A set of points in the plane, where each point is a pair of coordinates.
Output: A pair of points that minimize the Euclidean distance .
Brute-force approach
The obvious approach checks all pairs — the number of ways to choose 2 points from , written in the binomial coefficient notation :
function bruteForce(points: readonly Point[]): ClosestPairResult {
let best: ClosestPairResult = {
p1: points[0]!,
p2: points[1]!,
distance: distance(points[0]!, points[1]!),
};
for (let i = 0; i < points.length; i++) {
for (let j = i + 1; j < points.length; j++) {
const d = distance(points[i]!, points[j]!);
if (d < best.distance) {
best = { p1: points[i]!, p2: points[j]!, distance: d };
}
}
}
return best;
}
This runs in time. Can we do better?
The divide-and-conquer idea
The strategy is:
-
Divide. Sort the points by -coordinate and split them into a left half and a right half at the median -value.
-
Conquer. Recursively find the closest pair in and in . Let and be these distances, and let .
-
Combine. The overall closest pair is either entirely in , entirely in , or split — with one point in and one in . We have already found the first two cases. For the split case, we need to check if any split pair has distance less than .
The crux of the algorithm is the combine step: can we check split pairs efficiently?
The strip optimization
Consider the vertical strip of width centered on the dividing line (at the median -coordinate). Any split pair with distance less than must have both points in this strip, because otherwise the horizontal distance alone exceeds .
Now comes the key geometric insight. Sort the points in the strip by -coordinate. For any point in the strip, how many other strip points can lie within distance of ? Geometrically, such points could be anywhere in a box centered on (width from the strip, height because points could be above or below). But we can cut this box in half with a simple algorithmic trick: we will design the inner loop so that it walks through the strip points in ascending -order, and for each point it only checks points that come after in that order — that is, points above . There is no need to look below , because any pair where is below will already have been examined when was the current point and was above it. This way, every pair in the strip is considered exactly once.
Because we only look upward from , the relevant region is not the full box but only its upper half: a rectangle extending from to (see the figure). We can divide each half of this rectangle into four cells. Each cell has diagonal , so no two points from the same half can share a cell (they are at least apart). That gives at most 4 points per half, 8 total in the rectangle, so at most 7 other points besides .
This bound directly controls the cost of the nested loop in the code. Because at most 7 points above can lie within -distance , the inner loop executes at most 8 times per outer iteration: at most 7 points within range, plus 1 check that triggers the break. Summing over all strip points, the total number of inner-loop iterations is at most . The nested loop is linear, not quadratic, despite its two-loop appearance.
Implementation
We define the Point and ClosestPairResult types:
export interface Point {
x: number;
y: number;
}
export interface ClosestPairResult {
p1: Point;
p2: Point;
distance: number;
}
The distance function:
export function distance(a: Point, b: Point): number {
const dx = a.x - b.x;
const dy = a.y - b.y;
return Math.sqrt(dx * dx + dy * dy);
}
The main function sorts by -coordinate and delegates to the recursive helper:
export function closestPair(points: readonly Point[]): ClosestPairResult {
if (points.length < 2) {
throw new Error('At least 2 points are required');
}
// Tie-break on y for a deterministic order among points with equal x
const sortedByX = [...points].sort(
(a, b) => a.x - b.x || a.y - b.y,
);
return closestPairRec(sortedByX);
}
The recursive function implements the three steps:
function closestPairRec(points: readonly Point[]): ClosestPairResult {
if (points.length <= 3) {
return bruteForce(points);
}
const mid = Math.floor(points.length / 2);
const midPoint = points[mid]!;
const left = points.slice(0, mid);
const right = points.slice(mid);
const leftResult = closestPairRec(left);
const rightResult = closestPairRec(right);
let best =
leftResult.distance <= rightResult.distance
? leftResult
: rightResult;
const delta = best.distance;
// Build the strip
const strip: Point[] = [];
for (const p of points) {
if (Math.abs(p.x - midPoint.x) < delta) {
strip.push(p);
}
}
// Sort strip by y-coordinate
strip.sort((a, b) => a.y - b.y);
// Check each point against at most 7 subsequent points
for (let i = 0; i < strip.length; i++) {
for (let j = i + 1; j < strip.length; j++) {
const dy = strip[j]!.y - strip[i]!.y;
if (dy >= best.distance) {
break;
}
const d = distance(strip[i]!, strip[j]!);
if (d < best.distance) {
best = { p1: strip[i]!, p2: strip[j]!, distance: d };
}
}
}
return best;
}
Tracing through an example
Consider 6 points:
Step 1: Sort by x. .
Step 2: Divide. Left: . Right: . Dividing line at .
Step 3: Conquer (left). With 3 points, brute force checks all 3 pairs:
Closest in left: with .
Step 3: Conquer (right). Brute force on :
Closest in right: with .
Step 4: Combine. . The strip contains all points within of — which includes none of the left points (they are at , all more than away from 12) and only and on the right. The strip pair distance is 20, which does not improve on .
Result: The closest pair is with distance .
Correctness
The algorithm correctly finds the closest pair because it considers all three possible cases — closest pair entirely in the left, entirely in the right, or split across the dividing line. The correctness of the strip check follows from the observation that any split pair closer than must lie in the strip (both points are within of the dividing line), and the inner loop's break condition (dy >= delta) guarantees that every such pair is examined.
Base case. For 2 or 3 points, brute force checks all pairs. Correct.
Inductive step. Assume the recursive calls return the correct closest pairs in and . Then is the correct minimum distance within each half. The strip check examines all candidates for a closer split pair: it iterates over all strip points sorted by , and for each point checks subsequent points until the -distance reaches . Any split pair with distance less than must have -distance less than as well, so the break condition cannot skip a valid candidate.
Complexity analysis
Let be the running time. The algorithm:
- Divides the points in half: (the array is already sorted by ).
- Recursively solves two subproblems: .
- Builds and sorts the strip: in the worst case (the strip could contain all points).
- Checks strip pairs: . The nested loop looks quadratic, but the inner loop is tightly bounded by the geometry. Because the strip is sorted by -coordinate, the inner loop visits only points above the current point (earlier points were already handled). The packing argument guarantees that at most 7 such points can have -distance less than (they all lie in a rectangle above the current point, which fits at most 8 points total). Once the -distance reaches , the
breakfires. So the inner loop runs at most 8 times per outer iteration ( valid neighbors + 1 break), giving at most inner-loop iterations in total — including alldycomparisons and all distance computations.
The combine step is dominated by the strip sort at . The recurrence is:
This does not fall neatly into Case 2 of the Master Theorem (where ). Solving by the recursion tree method or the Akra-Bazzi theorem gives .
However, the initial sort by -coordinate costs and is done once. With a more careful implementation (maintaining a pre-sorted-by- list using a merge step instead of re-sorting the strip), the combine step can be reduced to , giving the optimal recurrence:
Our implementation uses the simpler approach, which is already a substantial improvement over the brute force. In practice, the strip is typically much smaller than , so the extra logarithmic factor is rarely felt.
- Time complexity: as implemented; with the merge-based optimization.
- Space complexity: for the sorted arrays and strip.
Summary of closest pair
| Approach | Time | Space |
|---|---|---|
| Brute force | ||
| Divide-and-conquer (simple) | ||
| Divide-and-conquer (optimal) |
The closest pair problem beautifully illustrates the power of divide and conquer. The brute-force approach must check all pairs. By splitting the problem, solving each half, and cleverly bounding the combine step, we achieve near-linear time.
The divide-and-conquer recipe
Looking back at our four algorithms, we can identify a common recipe:
-
Identify a way to shrink the problem. Binary search halves the array, exponentiation by squaring halves the exponent, the Euclidean algorithm replaces a number with a remainder, and closest pair splits the point set.
-
Solve the smaller instance(s). Sometimes there is one subproblem (binary search, exponentiation, GCD); sometimes there are two (closest pair).
-
Combine. Binary search and GCD need no combining — the subproblem answer is the final answer. Exponentiation squares the subresult. Closest pair must check the strip.
-
Analyze with recurrences. The running time follows from the recurrence and the Master Theorem (or recursion tree method when the Master Theorem does not apply directly).
This recipe is a powerful tool for designing new algorithms. When you face a problem, ask: can I split it into smaller instances of the same problem? If so, the divide-and-conquer approach may yield an efficient solution.
A note on memoization
There is one more important idea connected to recursion that we should mention here: memoization.
Many recursive algorithms solve the same subproblems repeatedly. Consider computing the Fibonacci numbers recursively: . A direct recursive implementation calls itself twice at each level, and the subproblems overlap heavily — is computed many times when computing , is computed many times when computing , and so on. The resulting recursion tree grows exponentially, even though there are only distinct subproblems.
Memoization is a technique that eliminates this redundancy: when a recursive function is about to compute a subproblem, it first checks whether the result has already been computed and stored (in a cache, hash map, or array). If so, it returns the cached value immediately; if not, it computes the result, stores it, and then returns it. The name comes from "memo" — a note to oneself — and the effect can be dramatic: for Fibonacci, memoization reduces the time from exponential to linear , because each of the subproblems is solved at most once.
Memoization is valuable whenever a recursive decomposition produces overlapping subproblems — the same smaller instance appears in multiple branches of the recursion. The divide-and-conquer algorithms in this chapter (binary search, fast exponentiation, GCD, closest pair) do not have this property: each recursive call works on a distinct portion of the input, so no subproblem is ever solved twice. But many important recursive algorithms do have overlapping subproblems, and for those, memoization is the difference between a practical algorithm and an unusably slow one.
This idea is at the heart of dynamic programming, a powerful algorithm design paradigm that we study in detail in Chapter 16. There we will see memoization in action on problems such as Fibonacci numbers, coin change, longest common subsequence, and the knapsack problem, and we will also explore tabulation — a bottom-up alternative that avoids recursion entirely.
Summary
In this chapter we studied recursion and the divide-and-conquer strategy:
- Recursion solves a problem by reducing it to smaller instances, terminating at base cases. Its correctness is proven by induction.
- Divide-and-conquer is a specific recursion pattern: divide into subproblems, conquer recursively, combine the results.
- Binary search halves the search space at each step, achieving time.
- Exponentiation by squaring computes in multiplications instead of .
- The Euclidean algorithm computes GCD in time, an ancient and elegant application of the divide-and-conquer idea.
- The closest pair of points demonstrates a nontrivial combine step, achieving (or in the simpler variant) versus brute force.
- Memoization caches the results of recursive calls to avoid redundant computation when subproblems overlap — an idea we will develop fully in Chapter 16 on dynamic programming.
In the next chapter, we turn to the sorting problem. We begin with three elementary sorting algorithms — bubble sort, selection sort, and insertion sort — all of which run in time. In Chapter 5, we study efficient sorting algorithms — merge sort, quicksort, and heapsort — that use divide-and-conquer to achieve time.
Exercises
Exercise 3.1. In this chapter we presented both a recursive and an iterative version of binary search. Explain why the recursive version is tail-recursive and how that property enables the transformation to the iterative version. Can every recursive function be transformed this way? Give an example of a recursive function that is not tail-recursive and explain what makes the transformation harder.
Exercise 3.2. The Tower of Hanoi puzzle has disks of decreasing size stacked on one of three pegs. The goal is to move all disks to another peg, moving one disk at a time, never placing a larger disk on a smaller one. Write a recursive function hanoi(n: number, from: string, to: string, via: string): void that prints the moves. What is the time complexity? Prove that moves are both necessary and sufficient.
Exercise 3.3. Implement a recursive version of the pow function (exponentiation by squaring). Analyze its space complexity and compare it with the iterative version.
Exercise 3.4. The maximum subarray problem asks for a contiguous subarray of an array of numbers with the largest sum. Design an divide-and-conquer algorithm for this problem. (Hint: split the array in half; the maximum subarray is entirely in the left half, entirely in the right half, or crossing the midpoint.)
Exercise 3.5. Karatsuba's algorithm multiplies two -digit numbers using the recurrence . Use the Master Theorem to determine its time complexity. How does this compare with the naive multiplication algorithm?