Divide and Conquer: Breaking Problems Down
Divide and conquer solves problems by breaking them into smaller subproblems, solving each recursively, and combining results.
The Three Steps
Divide → Split problem into smaller subproblems
Conquer → Solve subproblems recursively (base case: trivial to solve)
Combine → Merge subproblem solutions into final solutionThe Master Theorem
Analyzes recurrence relations of the form:
T(n) = aT(n/b) + f(n)Where:
- a = number of subproblems
- b = factor by which input size is reduced
- f(n) = cost of dividing and combining
Three Cases
Case 1: If f(n) = O(n^c) where c < log_b(a) → T(n) = Θ(n^log_b(a))
Case 2: If f(n) = Θ(n^c) where c = log_b(a) → T(n) = Θ(n^c log n)
Case 3: If f(n) = Ω(n^c) where c > log_b(a) → T(n) = Θ(f(n))Examples
| Algorithm | Recurrence | Complexity |
|---|---|---|
| Binary search | T(n) = T(n/2) + O(1) | O(log n) |
| Merge sort | T(n) = 2T(n/2) + O(n) | O(n log n) |
| Quick sort (avg) | T(n) = 2T(n/2) + O(n) | O(n log n) |
| Quick sort (worst) | T(n) = T(n-1) + O(n) | O(n²) |
| Karatsuba (multiplication) | T(n) = 3T(n/2) + O(n) | O(n^1.58) |
| Strassen (matrix multiply) | T(n) = 7T(n/2) + O(n²) | O(n^2.81) |
Classic Divide and Conquer Algorithms
Merge Sort
def mergesort(arr):
if len(arr) <= 1:
return arr
mid = len(arr) // 2
left = mergesort(arr[:mid])
right = mergesort(arr[mid:])
return merge(left, right)
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
return result + left[i:] + right[j:]Divide: Split array in half. O(1) Conquer: Sort halves recursively. 2T(n/2) Combine: Merge sorted halves. O(n)
Quick Sort
def quicksort(arr, low, high):
if low < high:
pivot = partition(arr, low, high)
quicksort(arr, low, pivot - 1)
quicksort(arr, pivot + 1, high)
def partition(arr, low, high):
pivot = arr[high]
i = low - 1
for j in range(low, high):
if arr[j] <= pivot:
i += 1
arr[i], arr[j] = arr[j], arr[i]
arr[i + 1], arr[high] = arr[high], arr[i + 1]
return i + 1Divide: Partition around pivot. O(n) Conquer: Sort partitions recursively. Combine: Trivial (in-place).
Binary Search
def binary_search(arr, target, low, high):
if low > high:
return -1
mid = (low + high) // 2
if arr[mid] == target:
return mid
elif arr[mid] < target:
return binary_search(arr, target, mid + 1, high)
else:
return binary_search(arr, target, low, mid - 1)Divide: Compare with middle element. O(1) Conquer: Search one half. T(n/2) Combine: Trivial.
Closest Pair of Points
Find the minimum distance between any two points in a plane.
def closest_pair(points):
if len(points) <= 3:
return brute_force(points)
mid = len(points) // 2
mid_x = points[mid][0]
left = closest_pair(points[:mid])
right = closest_pair(points[mid:])
d = min(left, right)
strip = [p for p in points if abs(p[0] - mid_x) < d]
strip.sort(key=lambda p: p[1])
for i in range(len(strip)):
for j in range(i + 1, min(i + 7, len(strip))):
d = min(d, distance(strip[i], strip[j]))
return dDivide: Split points by x-coordinate. O(1) Conquer: Find closest in each half. 2T(n/2) Combine: Check points near dividing line. O(n)
Time: O(n log n)
Divide and Conquer vs. Dynamic Programming
| Aspect | Divide and Conquer | Dynamic Programming | |
Optimizing Divide and Conquer
Reducing Subproblems
Karatsuba multiplication: 3 multiplications instead of 4 → O(n^1.58) instead of O(n²):
def karatsuba(x, y):
if x < 10 or y < 10:
return x * y
n = max(len(str(x)), len(str(y)))
m = n // 2
a, b = divmod(x, 10**m)
c, d = divmod(y, 10**m)
ac = karatsuba(a, c)
bd = karatsuba(b, d)
ad_bc = karatsuba(a + b, c + d) - ac - bd
return ac * 10**(2*m) + ad_bc * 10**m + bdReducing Combine Cost
Quick sort combines in O(1) (in-place), while merge sort combines in O(n). But merge sort gives guaranteed O(n log n).
Common Mistakes
- Not handling base cases: Must reach a trivially solvable case
- Incorrect combination: Combining is harder than it looks (closest pair strip check)
- Assuming non-overlapping: If subproblems overlap, DP is better
- Stack overflow: Recursive depth can exceed stack for large n (use iteration)
Divide and conquer is a fundamental algorithm design paradigm. Master it by understanding how to split problems and combine solutions.
Core Divide-and-Conquer Techniques
Recurrence Relations and Analysis
Divide-and-conquer algorithms are analyzed using recurrence relations. For an algorithm that divides a problem of size n into a subproblems of size n/b and requires f(n) work to divide and combine, the recurrence is T(n) = a × T(n/b) + f(n). The Master Theorem provides closed-form solutions for common recurrences. Merge sort with T(n) = 2T(n/2) + O(n) solves to O(n log n), while binary search with T(n) = T(n/2) + O(1) solves to O(log n). The median-of-medians algorithm for finding the kth smallest element in worst-case O(n) uses a clever divide step that partitions the input into groups of 5. Understanding recurrence structure helps identify whether a problem is amenable to divide-and-conquer decomposition.
Classic Divide-and-Conquer Examples
Merge sort divides an array into two halves, recursively sorts each half, and merges the sorted halves. The merging step is where the real work happens — comparing elements from both halves and placing the smaller element into the output. Quicksort selects a pivot, partitions the array around it, and recursively sorts the subarrays on each side. Unlike merge sort, quicksort’s divide step does the work and the conquer step is trivial. Strassen’s matrix multiplication reduces the standard 8 recursive multiplications to 7 by cleverly combining submatrix products, achieving O(n^2.807) complexity compared to the naive O(n³). The fast Fourier transform (FFT) recursively divides a polynomial into even and odd coefficients, achieving O(n log n) time for convolution operations.
Applying Divide and Conquer
Identifying opportunities for divide-and-conquer requires recognizing when a problem can be broken into independent subproblems. The subproblems must be smaller instances of the same problem, and the solution must be composable from subproblem solutions. Common indicators include pairs problems (count inversions, closest pair), tree-based problems (where the data structure itself suggests recursive decomposition), and range queries (segment trees and Fenwick trees). In practice, divide-and-conquer algorithms are often combined with other strategies — dynamic programming uses divide-and-conquer at each state transition, and greedy algorithms sometimes employ divide-and-conquer for the subproblem structure.
Parallel Divide-and-Conquer
Divide-and-conquer algorithms are naturally parallelizable because subproblems are independent. Parallel merge sort distributes subarrays across processors, merges local results, and then performs a hierarchical merge. MapReduce frameworks apply divide-and-conquer at massive scale — the map phase divides input data into independent chunks for parallel processing, and the reduce phase combines results. The parallel prefix sum (scan) algorithm uses a divide-and-conquer tree to compute prefix sums in O(log n) parallel time. Fork-join parallelism models divide-and-conquer directly — a fork creates parallel tasks for subproblems, and join combines results. This model is supported by frameworks like Java’s ForkJoinPool and C++’s TBB. The key performance consideration is granularity: splitting into too many tiny subproblems creates overhead from task creation and scheduling.
Divide-and-Conquer in Computational Geometry
The closest pair of points problem demonstrates elegant divide-and-conquer geometry. The line dividing points into left and right halves is solved recursively, then the minimal distance d from both halves is found. A strip of width d around the dividing line is examined — points within the strip are sorted by y-coordinate, and each point is compared with at most the next 7 points in the sorted order, yielding O(n log n) total time. The convex hull problem also admits a divide-and-conquer solution — recursively compute the convex hull of left and right halves, then merge by finding upper and lower supporting lines in linear time. The Delaunay triangulation can be constructed in O(n log n) time using divide-and-conquer by recursively triangulating halves and merging along a dividing line, removing triangles that violate the empty circumcircle property.
Frequently Asked Questions
When should I use divide and conquer over other strategies? Use divide and conquer when the problem naturally decomposes into independent subproblems whose solutions can be combined efficiently. If subproblems overlap significantly, dynamic programming is more appropriate. If a greedy choice at each step leads to optimal solution, use greedy algorithms. Divide and conquer excels for sorting, searching, matrix operations, and computational geometry.
What is the difference between divide and conquer and dynamic programming? Divide and conquer solves independent subproblems that do not overlap. Each subproblem is solved once. Dynamic programming solves overlapping subproblems — the same subproblem appears multiple times, and DP stores the result to avoid recomputation. Both use recursion, but DP adds memoization for efficiency.
How do I analyze the time complexity of divide-and-conquer algorithms? Use the Master Theorem for standard recurrences. Write the recurrence as T(n) = aT(n/b) + O(n^d). Compare log_b(a) to d: if log_b(a) > d, complexity is O(n^{log_b(a)}); if equal, O(n^d log n); if less, O(n^d). For non-standard recurrences, use recursion tree analysis or the Akra-Bazzi method.
Sorting Algorithms Guide — Searching Algorithms Guide — Dynamic Programming Guide