Recursion and Divide-and-Conquer
Recursion is one of the most powerful techniques in algorithm design: a function solving a problem by solving smaller instances of itself. 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
A function is recursive if it 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
When a function calls itself, the runtime maintains a call stack — a stack of frames, each recording the local variables and return address for one invocation. 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 . This overhead can be a concern for very deep recursions, but for many problems the clarity and elegance of the recursive solution outweigh the cost.
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
There is a deep connection between recursion and mathematical induction. 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 correct. 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 every recursive call on a strictly smaller input returns the correct answer, then the current call also returns the correct answer.
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. As we saw in Chapter 2, the Master Theorem often gives us the solution directly.
Binary search
Our first divide-and-conquer algorithm is one of the most important: 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 the remaining elements.
Here is our iterative implementation (an iterative approach avoids the overhead of recursive calls and is standard for 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;
}
Although this implementation is iterative, it mirrors the recursive divide-and-conquer structure exactly: the variables low and high define the current subproblem, and each iteration halves the range.
Tracing through an example
Let and .
| Step | low | high | mid | arr[mid] | Action |
|---|---|---|---|---|---|
| 1 | 0 | 6 | 3 | 7 | : search right half |
| 2 | 4 | 6 | 5 | 11 | : search left half |
| 3 | 4 | 4 | 4 | 9 | : found, return 4 |
After only 3 comparisons, we have found the element in a 7-element array. A linear scan might have taken up to 7 comparisons.
Correctness
We prove correctness using a loop invariant.
Invariant: If is in , then is in .
- Initialization: Before the loop, and , so the invariant holds trivially.
- Maintenance: If , then cannot be in (since is sorted), so setting preserves the invariant. The case is symmetric.
- Termination: The loop terminates either when is found or when , meaning the search range is empty. In the latter case, is not in , and returning is correct.
Complexity analysis
Each iteration halves the search range. Starting from elements, after iterations we have at most elements. The loop terminates when , i.e., after iterations.
- Time complexity: .
- Space complexity: (the iterative version uses only a few variables).
Using the Master Theorem on the recursive form: . 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 a single element but half 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 .
Here is the iterative implementation:
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: .
The recurrence for the recursive view is , the same as binary search, giving by the Master Theorem.
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 larger number downward:
export function gcdSlow(x: number, y: number): number {
const max = Math.max(x, y);
for (let i = max; 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:
This holds because any common divisor of and also divides (since ), and conversely. 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 {
let r = x % y;
while (r > 0) {
x = y;
y = r;
r = x % y;
}
return y;
}
Tracing through an example
Let us compute :
| Step | |||
|---|---|---|---|
| 1 | 210 | 2618 | 210 |
| 2 | 2618 | 210 | 98 |
| 3 | 210 | 98 | 14 |
| 4 | 98 | 14 | 0 |
Result: .
The naive approach would have tested candidates from 2618 down to 14 — over 2600 iterations. The Euclidean algorithm needed only 4.
Correctness
We prove correctness by induction on the number of iterations.
Base case. If , then divides , so . The algorithm returns . Correct.
Inductive step. Assume the algorithm correctly computes where . Since , the result is correct.
Complexity analysis
The key insight is that after two consecutive iterations, the value of is reduced by at least half. Formally: if , then , and one can show that whenever . By the Fibonacci-like worst case analysis (due to Gabriel Lamé, 1844):
- Time complexity: .
- Space complexity: .
This is an exponential improvement over the naive approach.
The closest pair of points
Our most substantial example brings together all the divide-and-conquer ideas. Given a set of points in the plane, we want to find two points 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:
function bruteForce(pts: readonly Point[]): ClosestPairResult {
let best: ClosestPairResult = {
p1: pts[0]!,
p2: pts[1]!,
distance: distance(pts[0]!, pts[1]!),
};
for (let i = 0; i < pts.length; i++) {
for (let j = i + 1; j < pts.length; j++) {
const d = distance(pts[i]!, pts[j]!);
if (d < best.distance) {
best = { p1: pts[i]!, p2: pts[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 be within distance of ? Since all such points lie in a rectangle, and any two points in the same half (left or right) are at least apart, a packing argument shows that at most 7 other points in the strip need to be checked.
This means the combine step checks each strip point against at most 7 neighbors — a constant number — so it takes time (after sorting the strip by ).
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');
}
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(pts: readonly Point[]): ClosestPairResult {
if (pts.length <= 3) {
return bruteForce(pts);
}
const mid = Math.floor(pts.length / 2);
const midPoint = pts[mid]!;
const left = pts.slice(0, mid);
const right = pts.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 pts) {
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 geometric packing argument: any split pair closer than must lie in the strip and must appear within 7 positions of each other when sorted by .
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. Since the inner loop breaks when the -distance exceeds , and any valid split pair must appear within 7 -neighbors, no valid candidate is missed.
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: (each point is compared with at most 7 neighbors).
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.
Looking ahead
In this chapter we developed recursion and the divide-and-conquer paradigm:
- 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.
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. Write a recursive version of binary search. What is its space complexity? Compare it with the iterative version presented in this chapter.
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?