Divide & Conquer: The Strategy Behind the Fastest Algorithms
One Strategy, Many Algorithms
Merge sort. Binary search. Fast exponentiation. Closest pair of points. Strassen's matrix multiplication. Karatsuba multiplication. The Fast Fourier Transform.
These look like unrelated algorithms. They are all the same algorithm. The structure is identical in every case:
Divide the problem into two (or more) smaller subproblems of the same type
Conquer each subproblem recursively
Combine the results into a solution to the original problem
The differences are in the details: how you divide, how many subproblems you create, and how expensive the combine step is. The Master Theorem — covered in this post — lets you derive the time complexity of any divide-and-conquer algorithm from these three parameters without solving the recurrence by hand.
The Master Theorem
Every divide-and-conquer algorithm produces a recurrence of the form:
T(n) = a · T(n/b) + f(n)Where:
a= number of subproblems at each level (≥ 1)b= factor by which the problem size shrinks (> 1)f(n)= cost of the divide + combine step (excluding recursive calls)
The Master Theorem gives the solution in three cases, determined by comparing f(n) to n^(log_b a) — the "critical exponent":
Let c* = log_b(a) (the critical exponent)
Case 1: f(n) = O(n^(c* - ε)) for some ε > 0
→ Work dominated by leaves
→ T(n) = Θ(n^c*)
Case 2: f(n) = Θ(n^c* · log^k n) for some k ≥ 0
→ Work balanced across levels
→ T(n) = Θ(n^c* · log^(k+1) n)
Special case k=0: T(n) = Θ(n^c* · log n)
Case 3: f(n) = Ω(n^(c* + ε)) for some ε > 0, and regularity holds
→ Work dominated by root
→ T(n) = Θ(f(n))Applied to the algorithms you already know:
Algorithm | Recurrence | a | b | c* | f(n) | Case | Result |
|---|---|---|---|---|---|---|---|
Binary Search | T(n) = T(n/2) + O(1) | 1 | 2 | 0 | O(1) = O(n⁰) | 2 | O(log n) |
Merge Sort | T(n) = 2T(n/2) + O(n) | 2 | 2 | 1 | O(n) = O(n¹) | 2 | O(n log n) |
Quick Sort (avg) | T(n) = 2T(n/2) + O(n) | 2 | 2 | 1 | O(n) | 2 | O(n log n) |
Strassen | T(n) = 7T(n/2) + O(n²) | 7 | 2 | 2.81 | O(n²) | 1 | O(n^2.81) |
Fast Power | T(n) = T(n/2) + O(1) | 1 | 2 | 0 | O(1) | 2 | O(log n) |
Worked example — Merge Sort:
a=2, b=2, c* = log₂(2) = 1
f(n) = O(n) = O(n¹) = O(n^c*) → Case 2 with k=0
Result: T(n) = Θ(n¹ · log n) = Θ(n log n) ✓
Worked example — Binary Search:
a=1, b=2, c* = log₂(1) = 0
f(n) = O(1) = O(n⁰) = O(n^c*) → Case 2 with k=0
Result: T(n) = Θ(n⁰ · log n) = Θ(log n) ✓
When you cannot apply the Master Theorem (non-polynomial difference between f(n) and n^c*), use the recursion tree method: draw the tree, compute work at each level, sum across levels.
The Template
Every divide-and-conquer solution follows this skeleton:
def divide_and_conquer(problem, lo, hi):
# Base case: problem is small enough to solve directly
if hi - lo <= THRESHOLD:
return solve_directly(problem, lo, hi)
# Divide
mid = (lo + hi) // 2
# Conquer (recursively)
left_result = divide_and_conquer(problem, lo, mid)
right_result = divide_and_conquer(problem, mid, hi)
# Combine
return combine(left_result, right_result, problem, lo, mid, hi)The art is in defining solve_directly and combine. The recursion structure is always the same.
The Algorithms
Merge Sort — Combine Does the Heavy Lifting
Merge sort divides trivially (split in half) and does all its work in the combine step (merge two sorted halves). This gives guaranteed O(n log n) regardless of input.
def merge_sort(arr):
if len(arr) <= 1:
return arr
mid = len(arr) // 2
left = merge_sort(arr[:mid]) # conquer left
right = merge_sort(arr[mid:]) # conquer right
return merge(left, right) # combine
def merge(left, right):
result = []
i = j = 0
while i < len(left) and j < len(right):
if left[i] <= right[j]:
result.append(left[i]); i += 1
else:
result.append(right[j]); j += 1
result.extend(left[i:])
result.extend(right[j:])
return resultWhy merge sort for linked lists? Linked lists do not support random access — you cannot jump to index mid in O(1). Merge sort only needs to find the middle (via slow/fast pointers) and merge — both work naturally on linked lists.
def sort_list(head):
"""Sort a linked list in O(n log n) using merge sort."""
if not head or not head.next:
return head
# Find middle via slow/fast pointers
slow, fast = head, head.next
while fast and fast.next:
slow = slow.next
fast = fast.next.next
mid = slow.next
slow.next = None # split list into two halves
left = sort_list(head)
right = sort_list(mid)
return merge_lists(left, right)
def merge_lists(l1, l2):
dummy = curr = ListNode(0)
while l1 and l2:
if l1.val <= l2.val:
curr.next = l1; l1 = l1.next
else:
curr.next = l2; l2 = l2.next
curr = curr.next
curr.next = l1 or l2
return dummy.nextQuick Select — O(n) Average to Find the Kth Smallest
Quick select is to order statistics what binary search is to sorted search. Instead of sorting fully, it uses the partition step of quick sort to identify which half contains the kth element — then recurses into only that half.
import random
def find_kth_smallest(nums, k):
"""Find kth smallest element in O(n) average, O(n²) worst case."""
def partition(lo, hi):
pivot_idx = random.randint(lo, hi)
nums[pivot_idx], nums[hi] = nums[hi], nums[pivot_idx]
pivot = nums[hi]
store = lo
for i in range(lo, hi):
if nums[i] <= pivot:
nums[store], nums[i] = nums[i], nums[store]
store += 1
nums[store], nums[hi] = nums[hi], nums[store]
return store
def select(lo, hi, k):
if lo == hi:
return nums[lo]
pivot_idx = partition(lo, hi)
rank = pivot_idx - lo + 1 # rank of pivot within [lo, hi]
if rank == k:
return nums[pivot_idx]
elif k < rank:
return select(lo, pivot_idx - 1, k)
else:
return select(pivot_idx + 1, hi, k - rank)
return select(0, len(nums) - 1, k)
# LeetCode version: Kth Largest Element in an Array
def find_kth_largest(nums, k):
return find_kth_smallest(nums, len(nums) - k + 1)Why O(n) average? At each level, partition runs in O(current size). With a random pivot, the expected split is roughly half-half, giving T(n) = T(n/2) + O(n) → O(n) by Master Theorem Case 3. With a consistently bad pivot (always smallest or largest), it degrades to O(n²) — randomization makes this astronomically unlikely.
Fast Power — Exponentiation by Squaring
Computing x^n naively takes O(n) multiplications. Divide and conquer reduces it to O(log n):
def fast_pow(x, n):
"""Compute x^n in O(log n) using divide and conquer."""
if n == 0: return 1
if n < 0: return 1 / fast_pow(x, -n)
if n % 2 == 0:
half = fast_pow(x, n // 2)
return half * half # x^n = (x^(n/2))²
else:
return x * fast_pow(x, n - 1) # x^n = x · x^(n-1)
# Iterative version (avoids call stack overhead):
def fast_pow_iter(x, n):
if n < 0:
x, n = 1/x, -n
result = 1
while n:
if n % 2 == 1:
result *= x # current bit is 1: multiply in x^(current power)
x *= x # square x for next bit
n //= 2
return resultThe recurrence: T(n) = T(n/2) + O(1) → O(log n). The same pattern applies to modular exponentiation (critical in cryptography and competitive programming): replace * with * mod M.
def mod_pow(base, exp, mod):
result = 1
base %= mod
while exp:
if exp % 2 == 1:
result = result * base % mod
base = base * base % mod
exp //= 2
return resultCounting Inversions — A Merge Sort Byproduct
An inversion is a pair (i, j) where i < j but arr[i] > arr[j]. Counting inversions naively is O(n²). Merge sort counts them in O(n log n) as a byproduct of the merge step.
def count_inversions(arr):
"""Count inversions in O(n log n). Returns (sorted_arr, inversion_count)."""
if len(arr) <= 1:
return arr, 0
mid = len(arr) // 2
left, left_inv = count_inversions(arr[:mid])
right, right_inv = count_inversions(arr[mid:])
merged = []
inversions = left_inv + right_inv
i = j = 0
while i < len(left) and j < len(right):
if left[i] <= right[j]:
merged.append(left[i]); i += 1
else:
# left[i] > right[j]: left[i..end] all invert with right[j]
inversions += len(left) - i
merged.append(right[j]); j += 1
merged.extend(left[i:])
merged.extend(right[j:])
return merged, inversionsThe insight: During merge, whenever we take an element from the right half before the left half is exhausted, every remaining element in the left half is an inversion with that right element — they are all larger and appear earlier. The count is len(left) - i, added in O(1) per right-half element taken.
Closest Pair of Points — O(n log n)
The classic computational geometry problem. Naive is O(n²) (check all pairs). Divide and conquer achieves O(n log n):
import math
def closest_pair(points):
"""Find the minimum distance between any two points in O(n log n)."""
def dist(p1, p2):
return math.sqrt((p1[0]-p2[0])**2 + (p1[1]-p2[1])**2)
def closest_rec(pts_sorted_x):
n = len(pts_sorted_x)
if n <= 3:
# Base case: brute force for small sets
min_d = float('inf')
for i in range(n):
for j in range(i+1, n):
min_d = min(min_d, dist(pts_sorted_x[i], pts_sorted_x[j]))
return min_d
mid = n // 2
mid_x = pts_sorted_x[mid][0]
d_left = closest_rec(pts_sorted_x[:mid])
d_right = closest_rec(pts_sorted_x[mid:])
d = min(d_left, d_right)
# Check strip: points within distance d of the dividing line
strip = [p for p in pts_sorted_x if abs(p[0] - mid_x) < d]
strip.sort(key=lambda p: p[1]) # sort strip by y
# For each point in strip, only check next 7 points (proven bound)
for i in range(len(strip)):
for j in range(i+1, min(i+8, len(strip))):
if strip[j][1] - strip[i][1] >= d:
break
d = min(d, dist(strip[i], strip[j]))
return d
points.sort() # sort by x coordinate
return closest_rec(points)Why only 7 neighbors in the strip? Within a d×2d rectangle (d wide, 2d tall), at most 8 points can fit with pairwise distance ≥ d. This is the geometric argument that makes the strip check O(n) per level despite looking like O(n²).
Binary Search as Divide and Conquer
Binary search is divide and conquer with a combine step of O(1) — it simply discards one half:
T(n) = T(n/2) + O(1)
→ O(log n) by Master Theorem Case 2This framing makes binary search variants feel natural rather than mysterious. "Search in rotated array" is still divide and conquer — you just need an extra step to determine which half to recurse into:
def search_rotated(nums, target):
lo, hi = 0, len(nums) - 1
while lo <= hi:
mid = (lo + hi) // 2
if nums[mid] == target: return mid
# Determine which half is sorted (the divide step)
if nums[lo] <= nums[mid]: # left half sorted
if nums[lo] <= target < nums[mid]:
hi = mid - 1
else:
lo = mid + 1
else: # right half sorted
if nums[mid] < target <= nums[hi]:
lo = mid + 1
else:
hi = mid - 1
return -1The Decision Framework
DOES THE PROBLEM HAVE THIS STRUCTURE?
"Solve a problem of size n by solving
smaller versions of the same problem"
│
▼
YES → Divide and Conquer
HOW MANY SUBPROBLEMS? WHAT IS THE COMBINE COST?
a=1, combine O(1) → O(log n) [binary search, fast power]
a=2, combine O(n) → O(n log n) [merge sort, inversions]
a=2, combine O(1) → O(n) [quick select average]
a>2, combine O(n²) → Master Theorem Case 1 [Strassen]
WHAT DOES THE COMBINE STEP DO?
Nothing (just recurse into one half) → Binary search family
Merge two sorted halves → Merge sort family
Count cross-boundary contributions → Inversion count, closest pair
Discard impossible half → Quick select
IS THE PROBLEM ON A LINKED LIST?
Yes → Merge sort (quick sort needs random access)
No → Either, but quick sort has better cache behaviorThe 8 Must-Know Problems
# | Problem | Technique | Difficulty |
|---|---|---|---|
1 | Merge Sort | Medium | |
2 | Merge Sort on linked list | Medium | |
3 | Quick Select | Medium | |
4 | Fast Power | Medium | |
5 | Binary Search (D&C) | Medium | |
6 | Merge Sort + counting | Hard | |
7 | D&C on expression | Medium | |
8 | Binary Search (D&C) | Hard |
Work through 1–4 first — they are the canonical implementations of the four main patterns. Problem 5 is the most common D&C binary search variant in interviews. Problem 6 (Reverse Pairs) is the hardest merge sort extension — it requires modifying the merge step non-trivially. Problem 8 (Median of Two Sorted Arrays) is the classic Hard that trips up even strong candidates; the key insight is binary searching on partition boundaries, not on values.
Bottom Line
Divide and conquer is not a collection of algorithms to memorize. It is a recursive decomposition strategy — and once you internalize it, you stop seeing merge sort and binary search and fast power as separate things. They are all the same move: split the problem in half, solve recursively, combine the results.
The Master Theorem removes the guesswork from complexity analysis. When you see a recurrence, identify a, b, and f(n), compute the critical exponent, compare — you have the answer in ten seconds.
The combine step is where the real work happens. The divide step is almost always trivial. If you are struggling to design a divide-and-conquer solution, ask: "What information from the left half do I need when combining with the right half?" The answer to that question defines your algorithm.
🧭 Chapter 3 Complete
You have finished the Algorithms chapter of Algorithm Interview 2026:
✅ Post 10: Sorting & Binary Search
✅ Post 11: Recursion & Backtracking
✅ Post 12: Dynamic Programming
✅ Post 13: Greedy Algorithms
✅ Post 14: Divide & Conquer
Related
Greedy Algorithms: When Being Selfish Gives the Optimal Answer
Greedy algorithms are easy to implement but hard to justify. Learn the exchange argument, the 5 greedy patterns, and when greedy fails so you know to reach for DP instead.
Dynamic Programming: From Scary to Systematic in One Post
DP is not about memorizing problems. Recognize two signals — optimal substructure and overlapping subproblems — then apply 6 patterns that make DP finally approachable.
Recursion & Backtracking: Think in Trees, Code in Functions
Every recursive solution is secretly a tree traversal. Once you see that, pruning becomes obvious. Master the backtracking template and 4 problem types found in every interview.
Comments