Divide and Conquer: Mental Model with Merge Sort and Quick Sort
Learn the divide-and-conquer pattern through merge sort and quick sort, recurrence relations, the Master theorem, and how to recognize the pattern.
What you'll learn
- ✓The three-step divide-and-conquer template
- ✓How merge sort splits, sorts, and merges in O(n log n)
- ✓How quick sort partitions in place and why its average case beats its worst
- ✓How to write and solve recurrence relations like T(n) = 2T(n/2) + O(n)
- ✓A practical statement of the Master theorem
Prerequisites
- •Recursion: [Recursion Fundamentals](/blog/recursion-fundamentals)
- •Arrays: [Arrays Introduction](/blog/arrays-introduction)
- •Big-O: [Big-O Notation Explained](/blog/big-o-notation-explained)
Divide and conquer is the algorithmic strategy of breaking a problem into smaller subproblems of the same shape, solving them recursively, and combining the results. The pattern shows up in sorting (merge sort, quick sort), searching (binary search), numerical algorithms (Karatsuba, FFT), and computational geometry (closest pair of points). Once you see the template, you can derive runtimes mechanically with the Master theorem.
The template
Every divide-and-conquer algorithm has three steps:
- Divide the input into smaller pieces, usually of roughly equal size.
- Conquer each piece by recursing.
- Combine the partial results into a solution for the whole input.
def solve(problem):
if is_small(problem):
return solve_directly(problem)
parts = divide(problem)
answers = [solve(p) for p in parts]
return combine(answers)
The art is choosing a split that makes the combine step cheap, or vice
versa. Merge sort does the work in combine; quick sort does the work in
divide. Both end up at O(n log n) average time, but for different
reasons.
Merge sort
Split the array in half, sort each half recursively, then merge two sorted halves into one.
def merge_sort(arr):
if len(arr) <= 1:
return arr
mid = len(arr) // 2
left = merge_sort(arr[:mid])
right = merge_sort(arr[mid:])
return merge(left, right)
def merge(a, b):
out = []
i = j = 0
while i < len(a) and j < len(b):
if a[i] <= b[j]:
out.append(a[i]); i += 1
else:
out.append(b[j]); j += 1
out.extend(a[i:])
out.extend(b[j:])
return out
Properties worth memorizing:
- Time: O(n log n) in every case, best, average, and worst.
- Space: O(n) auxiliary because merging needs a buffer.
- Stable: equal elements keep their original order, which matters when sorting tuples by a secondary key.
Merge sort is the canonical example because the recurrence is simple:
splitting is O(1) if you pass indices, sorting halves is 2T(n/2), and
merging is O(n).
T(n) = 2T(n/2) + O(n)
Quick sort
Pick a pivot, partition the array so elements less than the pivot are left and greater are right, then recursively sort the two sides.
def quick_sort(arr, lo=0, hi=None):
if hi is None:
hi = len(arr) - 1
if lo >= hi:
return
p = partition(arr, lo, hi)
quick_sort(arr, lo, p - 1)
quick_sort(arr, p + 1, hi)
def partition(arr, lo, hi):
pivot = arr[hi]
i = lo
for j in range(lo, hi):
if arr[j] <= pivot:
arr[i], arr[j] = arr[j], arr[i]
i += 1
arr[i], arr[hi] = arr[hi], arr[i]
return i
Properties worth memorizing:
- Time: O(n log n) average, O(n^2) worst case (pathological pivots).
- Space: O(log n) recursion stack on average, O(n) in the worst case.
- In place: no auxiliary buffer needed.
- Not stable in this form.
The worst case happens when the pivot is consistently the minimum or maximum, for example on an already-sorted array with naive pivot choice. Production implementations defend against this with randomized pivots or median-of-three.
Recurrence relations
A recurrence describes the runtime in terms of subproblem runtimes. For divide and conquer, the form is almost always:
T(n) = a * T(n / b) + f(n)
ais the number of subproblems.n / bis the size of each subproblem.f(n)is the cost of the divide and combine steps at the current level.
Examples:
- Merge sort:
T(n) = 2T(n/2) + O(n). - Binary search:
T(n) = T(n/2) + O(1). - Karatsuba multiplication:
T(n) = 3T(n/2) + O(n). - Strassen matrix multiplication:
T(n) = 7T(n/2) + O(n^2).
The Master theorem (informally)
Compare f(n) to n^(log_b a):
- If
f(n) = O(n^(log_b a - eps))for someeps > 0, thenT(n) = O(n^(log_b a)). The subproblems dominate. - If
f(n) = O(n^(log_b a)), thenT(n) = O(n^(log_b a) * log n). Subproblems and combine are balanced. - If
f(n) = O(n^(log_b a + eps))and regularity holds, thenT(n) = O(f(n)). The combine step dominates.
For merge sort, log_2 2 = 1 and f(n) = O(n) = O(n^1), so we are in
case two and get O(n log n). For binary search, log_2 1 = 0 and
f(n) = O(1) = O(n^0), again case two, giving O(log n). The theorem
fails for non-polynomial f(n) like n log n; in those cases use the
Akra-Bazzi method or analyze the recursion tree directly.
Problem 1: Maximum subarray (divide and conquer)
Given an integer array, return the maximum sum of any contiguous subarray.
The classic linear answer is Kadane’s algorithm, which belongs to Dynamic Programming Introduction. The divide-and-conquer version is also instructive: split the array, recurse on each half, and combine by computing the best subarray that crosses the midpoint.
def max_subarray(arr):
def helper(lo, hi):
if lo == hi:
return arr[lo]
mid = (lo + hi) // 2
left = helper(lo, mid)
right = helper(mid + 1, hi)
# best subarray crossing the midpoint
s, best_left = 0, float('-inf')
for i in range(mid, lo - 1, -1):
s += arr[i]
best_left = max(best_left, s)
s, best_right = 0, float('-inf')
for j in range(mid + 1, hi + 1):
s += arr[j]
best_right = max(best_right, s)
return max(left, right, best_left + best_right)
return helper(0, len(arr) - 1)
Recurrence: T(n) = 2T(n/2) + O(n), so the total is O(n log n). Kadane
beats this at O(n), but the divide-and-conquer version generalizes to
2D and to problems where Kadane does not apply.
Problem 2: Quickselect
Find the kth smallest element of an array in expected
O(n)time.
Use the same partition routine as quick sort, but only recurse into the
side that contains index k.
import random
def quickselect(arr, k):
lo, hi = 0, len(arr) - 1
while True:
if lo == hi:
return arr[lo]
pivot_idx = random.randint(lo, hi)
arr[pivot_idx], arr[hi] = arr[hi], arr[pivot_idx]
p = partition(arr, lo, hi)
if p == k:
return arr[p]
elif p < k:
lo = p + 1
else:
hi = p - 1
Each step shrinks the search range by a constant factor in expectation,
giving T(n) = T(n/2) + O(n) and a total of O(n). The deterministic
median-of-medians algorithm achieves O(n) worst case but with much
larger constants.
When divide and conquer is the right tool
- The problem has natural structure where solutions on halves combine cleanly.
- Subproblems are independent (no overlap). If they overlap, switch to DP.
- The combine step is cheap relative to the size of the subproblems.
Searching a sorted array? Binary search. Sorting? Merge or quick. Counting inversions? Modified merge sort. Closest pair in 2D? Sort and recurse. The pattern keeps paying for itself.
Wrap up
Divide and conquer turns hard problems into “the same problem, but smaller” plus a combine step. Internalize the template, the merge sort recurrence, and the Master theorem rule of thumb, and you can analyze new algorithms in minutes. Pair it with Recursion Fundamentals for the implementation discipline and you will reach for it instinctively on the right problems.