Break the problem in half, solve each half, combine the results. It's recursion with a strategy โ and it's behind some of the most important algorithms in computer science.
IntermediateDivide and conquer (D&C) is a three-step algorithm design strategy: (1) divide the problem into smaller subproblems, (2) conquer each subproblem recursively, and (3) combine the results into a solution for the original problem. It's one of the fundamental algorithm design paradigms, alongside greedy algorithms and dynamic programming.
The idea predates computers. Military strategists have used "divide and conquer" for millennia โ break an enemy into smaller groups and defeat them individually. In algorithms, the "enemy" is a problem too big to solve directly. Split it into pieces small enough to handle, solve each piece, and reassemble the answer.
Think of sorting a messy pile of 1,000 papers alphabetically. Trying to sort the whole pile at once is overwhelming. But split it into two piles of 500, sort each pile separately, then merge the two sorted piles together โ that's far more manageable. And each pile of 500 can be split further. This is exactly merge sort.
The power of D&C comes from the math. Problems that seem O(nยฒ) can often be solved in O(n log n) by dividing them. The "log n" factor comes from the recursive splitting โ each level halves the problem, so you only go logโ(n) levels deep before hitting the base case. If you do O(n) work at each level (like merge sort does), the total is O(n log n).
John von Neumann invented merge sort in 1945, making it one of the earliest D&C algorithms. Tony Hoare developed quicksort in 1960. Anatolii Karatsuba discovered his fast multiplication algorithm in 1960 (disproving Kolmogorov's conjecture that multiplication required O(nยฒ) operations). Volker Strassen's matrix multiplication followed in 1969. Each of these breakthroughs came from the same core insight: splitting the problem differently lets you do less total work.
Look for these signals:
If subproblems overlap (same inputs computed repeatedly), D&C wastes work by solving the same thing multiple times. In that case, switch to dynamic programming (D&C + memoization).
Input: [38, 27, 43, 3, 9, 82, 10]
Divide phase (top-down splitting):
Combine phase (bottom-up merging):
The merge step is where the magic happens. Take two sorted arrays and weave them together: compare the front elements, pick the smaller one, advance that pointer. Repeat until both arrays are exhausted. This runs in O(n) for each level (n total elements across all merges at that level), and there are logโ(n) levels, giving O(n log n) total.
Quick sort picks a pivot element and rearranges the array so everything smaller than the pivot is on the left and everything larger is on the right. The pivot ends up in its final sorted position. Then recursively sort the left and right parts.
The Lomuto partition scheme (simpler to understand): pick the last element as pivot. Maintain a pointer i that tracks where the next "small" element should go. Walk through the array with pointer j. Whenever arr[j] < pivot, swap arr[i] and arr[j], and increment i. At the end, swap the pivot into position i.
The Hoare partition scheme (faster in practice): use two pointers starting from both ends, moving inward. Swap elements that are on the wrong side. More cache-friendly and does fewer swaps on average.
Pivot selection matters. If you always pick the first or last element, already-sorted input causes O(nยฒ) behavior (the partition is maximally unbalanced). Solutions: pick a random pivot, use the median-of-three (first, middle, last), or use the median-of-medians algorithm for guaranteed O(n log n). In practice, random pivot works fine.
Most D&C algorithms produce a recurrence relation โ an equation expressing the runtime in terms of smaller inputs. The Master Theorem gives a direct formula for many common recurrences.
For T(n) = aT(n/b) + O(nd), where:
Three cases:
Examples:
Both break problems into subproblems. The key difference:
Rule of thumb: if you draw the recursion tree and see the same subproblem appearing multiple times, you need memoization (DP), not pure D&C.
Sometimes confused: decrease and conquer reduces the problem size by a constant or constant factor and solves only one subproblem. Binary search is technically decrease-and-conquer (you only recurse on one half). True D&C solves multiple subproblems and combines them. The distinction matters for understanding recurrences โ decrease-and-conquer gives T(n) = T(n/b) + O(f(n)), which is usually O(f(n)) or O(f(n) log n).
len(arr) <= 1, not len(arr) == 1 โ you need to handle empty arrays too. Always test with empty input and single-element input.
mid = lo + (hi - lo) // 2, not (lo + hi) // 2. The second form overflows in C++/Java when lo + hi exceeds the integer max. In Python it doesn't overflow, but using the first form is a good habit. Also make sure one half is [lo, mid] and the other is [mid+1, hi] (or [lo, mid) and [mid, hi) for slice-based splits) โ overlapping halves cause infinite recursion.
arr[:mid] and arr[mid:] in Python) makes merge sort use O(n log n) total space from copies. For interviews this is usually fine, but in production, use an in-place merge sort with an auxiliary buffer, or pass indices instead of slicing. Quick sort is naturally in-place โ that's one of its advantages.
| Algorithm | Best | Average | Worst | Space |
|---|---|---|---|---|
| Merge Sort | O(n log n) | O(n log n) | O(n log n) | O(n) |
| Quick Sort | O(n log n) | O(n log n) | O(nยฒ) | O(log n) |
| Binary Search | O(1) | O(log n) | O(log n) | O(1) |
| Closest Pair | O(n log n) | O(n log n) | O(n log n) | O(n) |
| Karatsuba Multiply | O(n1.585) | O(n) | ||
| Strassen Matrix Mult. | O(n2.807) | O(nยฒ) | ||
| FFT | O(n log n) | O(n) | ||
def merge_sort(arr):
"""Classic merge sort. Stable. Always O(n log n).
Returns a new sorted array (doesn't modify the original)."""
# Base case: single element or empty โ already sorted
if len(arr) <= 1:
return arr
mid = len(arr) // 2
# DIVIDE: split into two halves
left = merge_sort(arr[:mid])
right = merge_sort(arr[mid:])
# COMBINE: merge sorted halves
return merge(left, right)
def merge(left, right):
"""Merge two sorted arrays into one sorted array.
Time: O(n) where n = len(left) + len(right).
This is the core of merge sort โ where all the work happens."""
result = []
i = j = 0
# Compare front elements, pick the smaller one.
# Using <= (not <) makes this a STABLE sort:
# equal elements from the left array come first.
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
# One array is exhausted. Append the rest of the other.
# Only one of these extends actually adds anything.
result.extend(left[i:])
result.extend(right[j:])
return result
def merge_sort_inplace(arr):
"""In-place merge sort using an auxiliary buffer.
Avoids creating new arrays at every recursive call.
Still O(n) extra space for the buffer, but no per-call allocations."""
aux = [0] * len(arr) # single auxiliary buffer
_sort(arr, aux, 0, len(arr) - 1)
def _sort(arr, aux, lo, hi):
if lo >= hi:
return
mid = lo + (hi - lo) // 2 # avoid overflow in typed languages
_sort(arr, aux, lo, mid)
_sort(arr, aux, mid + 1, hi)
# Optimization: skip merge if already sorted
if arr[mid] <= arr[mid + 1]:
return
_merge(arr, aux, lo, mid, hi)
def _merge(arr, aux, lo, mid, hi):
# Copy to auxiliary buffer
aux[lo:hi+1] = arr[lo:hi+1]
i, j = lo, mid + 1
for k in range(lo, hi + 1):
if i > mid:
arr[k] = aux[j]; j += 1
elif j > hi:
arr[k] = aux[i]; i += 1
elif aux[i] <= aux[j]:
arr[k] = aux[i]; i += 1
else:
arr[k] = aux[j]; j += 1
import random
def quicksort(arr, lo=0, hi=None):
"""In-place quicksort with random pivot.
Average O(n log n), worst O(nยฒ) but random pivot makes
worst case astronomically unlikely (~1/n! probability)."""
if hi is None:
hi = len(arr) - 1
if lo >= hi:
return
# Random pivot prevents O(nยฒ) on sorted/nearly-sorted input
pivot_idx = random.randint(lo, hi)
arr[pivot_idx], arr[hi] = arr[hi], arr[pivot_idx]
p = partition(arr, lo, hi)
quicksort(arr, lo, p - 1)
quicksort(arr, p + 1, hi)
def partition(arr, lo, hi):
"""Lomuto partition scheme.
Pivot is arr[hi]. Everything < pivot goes to the left.
Returns the final position of the pivot."""
pivot = arr[hi]
i = lo # i marks where the next "small" element should go
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] # put pivot in place
return i
function mergeSort(arr) {
if (arr.length <= 1) return arr;
const mid = Math.floor(arr.length / 2);
const left = mergeSort(arr.slice(0, mid));
const right = mergeSort(arr.slice(mid));
return merge(left, right);
}
function merge(left, right) {
const result = [];
let i = 0, j = 0;
while (i < left.length && j < right.length) {
// <= for stability: equal elements from left come first
if (left[i] <= right[j]) result.push(left[i++]);
else result.push(right[j++]);
}
return result.concat(left.slice(i), right.slice(j));
}
def count_inversions(arr):
"""An inversion is a pair (i, j) where i < j but arr[i] > arr[j].
Measures "how far from sorted" an array is.
Brute force: O(nยฒ). This D&C approach: O(n log n).
Key insight: during the merge step, when we pick an element
from the right half, EVERY remaining element in the left half
forms an inversion with it (they're all larger and have smaller indices)."""
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, split_inv = merge_count(left, right)
return merged, left_inv + right_inv + split_inv
def merge_count(left, right):
result, inversions = [], 0
i = j = 0
while i < len(left) and j < len(right):
if left[i] <= right[j]:
result.append(left[i]); i += 1
else:
# Everything remaining in left[] is > right[j].
# Each of those elements forms an inversion with right[j].
inversions += len(left) - i
result.append(right[j]); j += 1
result.extend(left[i:])
result.extend(right[j:])
return result, inversions
# count_inversions([2, 4, 1, 3, 5]) โ ([1,2,3,4,5], 3)
# Inversions: (2,1), (4,1), (4,3)
def max_subarray_dc(arr, lo=0, hi=None):
"""Find maximum subarray sum using divide and conquer.
Three cases: max subarray is entirely in left half,
entirely in right half, or crosses the midpoint.
Time: O(n log n). (Kadane's is O(n), but this illustrates D&C.)"""
if hi is None:
hi = len(arr) - 1
if lo == hi:
return arr[lo] # base case: single element
mid = lo + (hi - lo) // 2
# Case 1 & 2: max is entirely in one half
left_max = max_subarray_dc(arr, lo, mid)
right_max = max_subarray_dc(arr, mid + 1, hi)
# Case 3: max crosses the midpoint
# Expand left from mid, then expand right from mid+1
cross_left = float('-inf')
running = 0
for i in range(mid, lo - 1, -1):
running += arr[i]
cross_left = max(cross_left, running)
cross_right = float('-inf')
running = 0
for i in range(mid + 1, hi + 1):
running += arr[i]
cross_right = max(cross_right, running)
return max(left_max, right_max, cross_left + cross_right)
Divide and conquer maps perfectly to parallel computing. MapReduce โ the framework behind Google's original search indexing and Hadoop โ is essentially D&C at scale. Divide the data across machines (map phase), process each chunk independently (conquer), and combine the results (reduce phase).
Here's a practical example: counting word frequencies across a massive text corpus using a D&C approach that naturally parallelizes.
from collections import Counter
from concurrent.futures import ProcessPoolExecutor
def count_words(text_chunk):
"""Map phase: count words in a single chunk."""
return Counter(text_chunk.lower().split())
def merge_counts(counter1, counter2):
"""Reduce phase: combine two word-frequency maps."""
combined = Counter(counter1)
combined.update(counter2)
return combined
def parallel_word_count(text, num_workers=4):
"""Divide and conquer word counting.
Divide: split text into chunks.
Conquer: count words in each chunk (in parallel).
Combine: merge the frequency maps."""
# DIVIDE: split text into roughly equal chunks
chunk_size = len(text) // num_workers
chunks = []
for i in range(num_workers):
start = i * chunk_size
end = (i + 1) * chunk_size if i < num_workers - 1 else len(text)
# Extend to the next space to avoid splitting words
while end < len(text) and text[end] != ' ':
end += 1
chunks.append(text[start:end])
# CONQUER: process each chunk in parallel
with ProcessPoolExecutor(max_workers=num_workers) as pool:
partial_counts = list(pool.map(count_words, chunks))
# COMBINE: merge all partial counts
from functools import reduce
return reduce(merge_counts, partial_counts)
# This is exactly how MapReduce works at Google scale,
# just with distributed machines instead of local processes.
The same pattern applies to: parallel merge sort (split the array, sort halves on different cores, merge), distributed sum/max/min operations, and even rendering (split the image into tiles, render each tile independently, stitch together).
is_feasible(x) that's monotonic (all True then all False, or vice versa), you can binary search for the boundary.
statistics.median() and C++'s std::nth_element.
| # | Problem | Difficulty | Key Concept |
|---|---|---|---|
| 912 | Sort an Array | Medium | Merge sort implementation |
| 23 | Merge K Sorted Lists | Hard | D&C merge or heap |
| 493 | Reverse Pairs | Hard | Modified merge sort |
| 315 | Count of Smaller Numbers After Self | Hard | Merge sort + counting |
| 53 | Maximum Subarray | Medium | D&C approach (not just Kadane's) |
| 4 | Median of Two Sorted Arrays | Hard | Binary search / D&C |
| 241 | Different Ways to Add Parentheses | Medium | Recursive D&C on operators |
| 215 | Kth Largest Element in an Array | Medium | Quickselect |
| 327 | Count of Range Sum | Hard | Merge sort on prefix sums |