Purpose
Divide and conquer reduces a problem to multiple sub-problems, solves each recursively, and merges the solutions. In plain induction you typically shrink the problem by one element. Divide and conquer instead shrinks it to a constant fraction of the original size, and that difference is where the speedup comes from. Merge sort, binary search, and Strassen’s algorithm all follow this shape. This note works through two examples in full: bisection for root finding and the closest pair of points.
Why Balanced Partitioning?
Say the merge step costs and the brute force algorithm costs . Peeling off one element at a time gives
which buys nothing over brute force. Now split into two halves and solve each half by brute force:
One level of splitting cut the work roughly in half. A second level makes it roughly 4 times faster, a third almost 8, and so on. Recursing all the way down gives
The recursion tree makes the bound visible. Subproblems shrink geometrically, but their count doubles at each level, so every level does the same total merge work:
flowchart TD subgraph L0["level 0 · 1 × n = n work"] A["n"] end subgraph L1["level 1 · 2 × n/2 = n work"] B1["n/2"] B2["n/2"] end subgraph L2["level 2 · 4 × n/4 = n work"] C1["n/4"] C2["n/4"] C3["n/4"] C4["n/4"] end A --> B1 A --> B2 B1 --> C1 B1 --> C2 B2 --> C3 B2 --> C4 C1 --> D1["⋮"] C2 --> D2["⋮"] C3 --> D3["⋮"] C4 --> D4["⋮"]
Halving continues for levels before subproblems hit constant size, and each level sums to , giving the total.
In practice the best approach is often to recurse down to a small problem size and finish with the iterative brute force algorithm, which avoids recursion overhead on tiny inputs. Quick sort with random splitters is implemented this way.
Finding the Root of a Function
Given a continuous function and two points such that and , find an approximate root: a point such that some with satisfies . Such an exists by the intermediate value theorem.
Naive Approach
Divide into intervals and check each one for a sign change. This runs in .
Bisection
Check the midpoint and recurse into the half that still has a sign change.
def bisection(f, a, b, eps):
if (b - a) < eps:
return a
m = (a + b) / 2
if f(m) < 0:
return bisection(f, m, b, eps)
else:
return bisection(f, a, m, eps)Let . Each step halves the interval, so
Correctness
: for all with , , and , bisection returns a value such that with and .
Base case : by the intermediate value theorem, with . We output , and .
Inductive hypothesis: assume .
Inductive step : given arbitrary with , , and , let .
Case 1: . Then satisfy the premises of , since , , and .
Case 2: . Then satisfy the premises of by the same reasoning.
Either way the recursive call returns a valid answer by the inductive hypothesis.
Closest Pair of Points
Given points in the plane, find the pair with the smallest Euclidean distance between them. Checking every pair costs . The geometry lets us skip almost all of those comparisons.
1 Dimensional Version
Given points on the real line, sort them and compare each consecutive pair. The closest pair must be consecutive in sorted order.
2 Dimensional Version
- Divide: draw a vertical line with points on each side.
- Conquer: find the closest pair on each side recursively.
Let be the smaller of the two one-side minimum distances. The only remaining candidates are pairs that straddle , and both endpoints of such a pair must lie within of .
Partition each side of the strip into squares. Each square holds at most one point: two points in the same square would be at distance at most on the same side of , contradicting the minimality of on that side.
Now sort the points in the strip by -coordinate to get .
Claim: , if , then .
Proof: The strip is wide, so each row of squares in the strip contains 4 squares, each holding at most one point. Any point more than two rows away from has vertical distance greater than from . Within two rows of there are at most 3 other points in its own row and 8 in the two rows above (or below), so any point more than positions away in -sorted order is more than two rows away, and thus at distance greater than .
So the merge step only compares each strip point to its 11 neighbors in -sorted order, which keeps the merge linear after sorting and gives , which is . Presorting by tightens this to .
The constant 11 is not tunable downward
The neighbor bound comes from the packing argument, not from profiling. Shrinking the window below 11 can miss the closest straddling pair on adversarial inputs, while enlarging it only wastes comparisons. The implementation below checks indices through , matching the claim exactly. It also switches to brute force at , the small-input cutoff discussed under balanced partitioning.
Implementation
def bounding_indices(P, low, high, key=lambda x: x[0]):
n = len(P)
l, r = 0, n - 1
while l <= r:
mid = (l + r) // 2
if low <= key(P[mid]):
r = mid - 1
else:
l = mid + 1
smallest_index = l
l, r = 0, n - 1
while l <= r:
mid = (l + r) // 2
if high >= key(P[mid]):
l = mid + 1
else:
r = mid - 1
highest_index = r
return smallest_index, highest_index
def d(p1, p2):
if p1 is None or p2 is None:
return float('inf')
return ((p1[0] - p2[0]) ** 2 + (p1[1] - p2[1]) ** 2) ** .5
def cp_brute_force(P):
n = len(P)
ans = P[:2]
min_d = d(P[0], P[1])
for i in range(n):
for j in range(i):
curr_d = d(P[i], P[j])
if curr_d < min_d:
min_d = curr_d
ans = [P[i], P[j]]
return ans
def cp_recursive(P):
n = len(P)
if n < 2:
return None, None
if n <= 10:
return cp_brute_force(P)
l1, l2 = cp_recursive(P[:n//2])
r1, r2 = cp_recursive(P[n//2:])
m1, m2 = (l1, l2) if d(l1, l2) < d(r1, r2) else (r1, r2)
delta = d(m1, m2)
L = (P[n//2 - 1][0] + P[n//2][0]) / 2
l, h = bounding_indices(P, L - delta, L + delta)
middle = sorted(P[l:h + 1], key=lambda x: x[1])
k = len(middle)
for i in range(k):
for j in range(max(0, i - 11), min(k, i + 12)):
if i == j:
continue
curr_dist = d(middle[i], middle[j])
if curr_dist < delta:
delta = curr_dist
m1, m2 = middle[i], middle[j]
return m1, m2
def closest_points(P):
return cp_recursive(sorted(P, key=lambda x: x[0]))