Divide and Conquer
Break a big problem into smaller versions of itself, solve those, then combine the results. Always relies on recursion and a clear base case.
Have you ever had a huge pile of dishes to wash, and instead of doing them one by one, you split them into smaller batches and got through them faster? That is basically what divide and conquer is all about. It is one of the most powerful ideas in programming, and once you get it, a whole bunch of algorithms (sorting, searching, and more) suddenly make a lot more sense.
Here is the entire idea in one sentence:
If a problem is big, break it into smaller pieces, solve those pieces, then put the answers back together. If the problem is already small, just solve it directly.
That is it. No magic, just smart problem-splitting.
There is no fixed rule here. It depends on the problem. Sometimes:
You decide what counts as “small enough to just solve it” based on the specific problem you are working on.
Every algorithm that uses this approach follows the same three-step recipe:
Divide -> Break the big problem into smaller piecesConquer -> Solve each smaller piece (often by repeating the same process)Combine -> Put the smaller answers together into the final answerDivide and conquer always relies on recursion, a function that calls itself with a smaller version of the same problem.
Why? Because dividing a problem usually means doing the exact same thing again, just on a smaller chunk. That repeating pattern is exactly what recursion is built for.
Here is what the general shape of a divide and conquer function looks like:
solve(arr, p, q):
if the problem is small enough: return the direct answer
else: mid = (p + q) // 2
left_result = solve(arr, p, mid) right_result = solve(arr, mid + 1, q)
return combine(left_result, right_result)| Part | What it means |
|---|---|
arr | The input you are working with |
p | Where the current chunk starts |
q | Where the current chunk ends |
mid | The middle point, used to split the chunk in half |
The base case is what tells your recursion when to stop. Without it, your function would keep splitting forever and crash.
For example, if you are looking for the biggest number in a list:
These tiny, easy-to-solve situations are your base cases. Everything else gets split further.
Picture an array of 8 items getting divided again and again until each piece has just 1 item:
8 / \ 4 4 / \ / \ 2 2 2 2 / \ / \ / \ / \ 1 1 1 1 1 1 1 1A few things to notice:
log base 2 of NTo figure out how fast a divide and conquer algorithm is, we write something called a recurrence relation. It looks intimidating, but it is just a formula describing “how much work happens” in terms of smaller versions of the same problem.
The general shape:
T(N) = a * T(N/b) + f(N)| Symbol | Meaning |
|---|---|
a | How many recursive calls you make |
N/b | How big each smaller piece is |
f(N) | Extra work done outside the recursive calls (the divide + combine steps) |
There are three common ways people solve these formulas:
You don’t need to memorize how to derive these by hand right now. Just know they exist, and that they help predict how fast or slow an algorithm will run as the input grows.
| Algorithm | Recurrence |
|---|---|
| Binary Search | T(N) = T(N/2) + C |
| Merge Sort | T(N) = 2T(N/2) + N |
| Quick Sort (average case) | T(N) = 2T(N/2) + N |
| Finding Max and Min | T(N) = 2T(N/2) + C |
Notice how similar they all look. That is the beauty of divide and conquer: once you understand the pattern, you can recognize it everywhere.
Let’s use a simple, friendly example to really get comfortable with this idea: finding the biggest and smallest number in a list.
Array = [70, 50, 45, 10, 12, 15, 75, 29, 37, 57]
Maximum = 75Minimum = 10The simplest way to do this: walk through the list once, and keep track of the biggest and smallest number you have seen so far.
max = negative infinitymin = positive infinity
for each number in the list: if number > max: max = number if number < min: min = numberThis takes O(N) time, which is already great. So why bother with divide and conquer here?
The base cases (our “small enough to solve directly” situations):
The big case (when there is more than 2 elements):
Here is what that looks like written out:
findMinMax(arr, i, j):
if i == j: return (arr[i], arr[i])
if i == j - 1: if arr[i] < arr[j]: return (arr[j], arr[i]) else: return (arr[i], arr[j])
mid = i + (j - i) // 2
(maxLeft, minLeft) = findMinMax(arr, i, mid) (maxRight, minRight) = findMinMax(arr, mid + 1, j)
finalMax = max(maxLeft, maxRight) finalMin = min(minLeft, minRight)
return (finalMax, finalMin)Every time this function calls itself, your program needs to remember “where it was” so it can come back later. It does this using the call stack, the same LIFO (Last In, First Out) structure we talked about with regular stacks.
The deepest the stack ever gets is roughly log base 2 of N calls deep, which is really shallow even for huge lists. That is one of the nice side effects of splitting a problem in half each time.
T(N) = 2T(N/2) + CSolving this gives us:
T(N) = O(N)Same speed as the brute force loop. The real value here isn’t speed, it is the practice. This pattern (divide, recurse, combine) shows up again and again in much harder problems.
| Approach | Time | Space |
|---|---|---|
| Simple loop | O(N) | O(1) |
| Divide and conquer | O(N) | O(log N) |
Before we get to sorting, let’s cover a technique that often pairs beautifully with divide and conquer style thinking: the two pointer approach.
The idea is simple: instead of using one variable to walk through a list, you use two variables (pointers) moving through it at the same time. This often turns a slow, nested-loop solution into something much faster.
A lot of beginner solutions look like this:
for i in range(n): for j in range(i + 1, n): check(arr[i], arr[j])This checks every possible pair, which takes O(N²) time. That gets painfully slow once your list has thousands of items.
With two pointers, you move smartly instead of brute-forcing every pair, often bringing this down to O(N).
This trick works great when:
If a problem mentions any of these phrases, your brain should immediately think “two pointers”:
This style works great with sorted arrays. One pointer starts at the beginning, one starts at the end, and they move toward each other.
left -> <- rightExample: Does a pair add up to a target sum?
Since the array is sorted, you can be smart about which pointer to move:
i = 0j = n - 1
while i < j: sum = arr[i] + arr[j]
if sum == target: return True elif sum < target: i += 1 else: j -= 1
return FalseSometimes both pointers move forward, just at different speeds. This pattern is often called “fast and slow pointers.”
slow ->fast ->This is handy for things like removing duplicates from a sorted array in place: one pointer scans ahead (fast), and the other keeps track of where the next “clean” value should go (slow).
A sliding window is when your two pointers define a “window” over part of the data, and that window grows or shrinks as needed.
Example: Find the longest stretch of numbers whose sum is at most k
left = 0sum = 0
for right in range(n): sum += arr[right]
while sum > k: sum -= arr[left] left += 1
update the answerThe window expands by moving right forward, and shrinks by moving left forward whenever the sum gets too big.
| Problem | Technique |
|---|---|
| Two Sum (sorted array) | Pointers moving toward each other |
| Container With Most Water | Pointers moving toward each other |
| Valid Palindrome | Pointers moving toward each other |
| Remove Duplicates | Fast and slow pointers |
| Longest Substring problems | Sliding window |
while i <= j when you actually meant while i < j (easy way to get stuck in a loop)Now let’s look at a real, practical algorithm built entirely on divide and conquer: Merge Sort.
Instead of trying to sort everything at once, Merge Sort does this:
Let’s take this array:
[50, 70, 45, 5, 79, 47, 29, 52]Merge Sort keeps splitting it in half until every piece has just one item:
[50 70 45 5 79 47 29 52] / \ [50 70 45 5] [79 47 29 52] / \ / \ [50 70] [45 5] [79 47] [29 52] / \ / \ / \ / \ [50] [70] [45] [5] [79] [47] [29] [52]A list with just one item is automatically “sorted,” since there is nothing to compare it against. That is our base case.
Once everything is split apart, the real work happens while putting it back together. This is called the merge step, and it is the heart of this algorithm.
Here’s how merging two already-sorted lists works:
Left: [10, 20, 30, 40]Right: [11, 21, 31, 41]You compare the front of each list, take the smaller one, and repeat:
Result: [10, 11, 20, 21, 30, 31, 40, 41]Here is the logic written out:
merge(left, right): result = empty list i = 0 j = 0
while i < length of left and j < length of right: if left[i] <= right[j]: add left[i] to result i += 1 else: add right[j] to result j += 1
add any leftover items from left add any leftover items from right
return resultmergeSort(arr, left, right): if left == right: return # single item, already sorted
mid = left + (right - left) // 2
mergeSort(arr, left, mid) mergeSort(arr, mid + 1, right) merge(arr, left, mid, right)And here it is in real Python code:
def merge(arr, left, mid, right): temp = [] i = left j = mid + 1
while i <= mid and j <= right: if arr[i] <= arr[j]: temp.append(arr[i]) i += 1 else: temp.append(arr[j]) j += 1
while i <= mid: temp.append(arr[i]) i += 1
while j <= right: temp.append(arr[j]) j += 1
for k in range(len(temp)): arr[left + k] = temp[k]
def merge_sort(arr, left, right): if left >= right: return
mid = left + (right - left) // 2 merge_sort(arr, left, mid) merge_sort(arr, mid + 1, right) merge(arr, left, mid, right)arr = [50, 70, 45, 5, 79, 47, 29, 52]merge_sort(arr, 0, len(arr) - 1)print(arr)# [5, 29, 45, 47, 50, 52, 70, 79]Time: Merging two halves together takes about N steps. Since this happens at every level of splitting, the total time works out to:
T(N) = 2T(N/2) + O(N) = O(N log N)This holds true no matter what your input looks like, best case, worst case, it does not matter.
| Case | Time |
|---|---|
| Best | O(N log N) |
| Average | O(N log N) |
| Worst | O(N log N) |
Space: Merge Sort needs a temporary extra list during merging, so it uses O(N) extra space, plus a bit more (O(log N)) for tracking the recursive calls.
Use it when:
Skip it when:
Now let’s meet Merge Sort’s faster, scrappier cousin: Quick Sort.
Merge Sort splits the array by its middle index, no matter what values are inside. Quick Sort works differently: it splits the array based on values, using something called a pivot.
The basic plan:
The cool part: Quick Sort does not need a separate merge step. It rearranges everything in place, which is why it tends to be faster in real-world use.
We will walk through a common method called the Lomuto partition scheme, where the pivot is simply the first element of the chunk you’re working on.
Here are the moving pieces:
p - where this chunk startsq - where this chunk endspivot - the value we are organizing everything around (arr[p])i - marks the boundary of “things smaller than or equal to the pivot”j - the pointer scanning through the rest of the arrayAt any moment during the scan, here’s what is true:
Everything from p+1 to i is <= pivotEverything from i+1 to j-1 is > pivotEverything from j to q is still unknown, not checked yetLet’s partition this array, using 70 as our pivot (the first element):
Index: 0 1 2 3 4 5 6 7 8Array: [70, 50, 27, 99, 85, 47, 123, 34, 147]j points to | Value | What happens |
|---|---|---|
| 1 | 50 | smaller, so move it into the “smaller” zone |
| 2 | 27 | smaller, so move it into the “smaller” zone |
| 3 | 99 | bigger, just skip past it |
| 4 | 85 | bigger, just skip past it |
| 5 | 47 | smaller, so move it into the “smaller” zone |
| 6 | 123 | bigger, just skip past it |
| 7 | 34 | smaller, so move it into the “smaller” zone |
| 8 | 147 | bigger, just skip past it |
After scanning everything, we do one final swap to put the pivot in its rightful spot:
[34, 50, 27, 47, 70, 99, 123, 85, 147] ^ pivotLook at that: 70 landed exactly where it belongs, with smaller numbers to its left and bigger numbers to its right.
partition(arr, p, q): pivot = arr[p] i = p
for j from p+1 to q: if arr[j] <= pivot: i = i + 1 swap(arr[i], arr[j])
swap(arr[p], arr[i]) return iquickSort(arr, p, q): if p < q: m = partition(arr, p, q) quickSort(arr, p, m - 1) quickSort(arr, m + 1, q)And here it is in Python:
def partition(arr, p, q): pivot = arr[p] i = p
for j in range(p + 1, q + 1): if arr[j] <= pivot: i += 1 arr[i], arr[j] = arr[j], arr[i]
arr[p], arr[i] = arr[i], arr[p] return i
def quicksort(arr, p, q): if p < q: m = partition(arr, p, q) quicksort(arr, p, m - 1) quicksort(arr, m + 1, q)This really depends on how lucky (or unlucky) your pivot choices are.
Best and average case: if the pivot roughly splits the array in half each time:
T(N) = 2T(N/2) + O(N) = O(N log N)Worst case: if the pivot always happens to be the smallest or largest value (which happens often with already-sorted arrays, if you always pick the first item as your pivot):
T(N) = T(N - 1) + O(N) = O(N²)| Case | Time |
|---|---|
| Best | O(N log N) |
| Average | O(N log N) |
| Worst | O(N²) |
| Property | Answer |
|---|---|
| Sorts in place? | Yes |
| Keeps equal items in order (stable)? | No |
| Extra memory needed? | O(log N) for the recursive calls |
| Cache friendly? | Yes |
Use it when:
Avoid it when:
Here’s the problem with regular Quick Sort: if you always pick the first (or last) item as your pivot, an already-sorted array turns into the worst-case scenario, every single time. That is a predictable, exploitable weakness.
The fix is delightfully simple: pick the pivot randomly instead.
If the pivot is chosen randomly, the order of the input simply cannot manipulate which item becomes the pivot. A sorted array, a reverse-sorted array, or a messy array all become equally “safe” to sort.
The expected time stays at a reliable:
O(N log N)even though, in theory, an extremely unlucky run could still hit O(N²). In practice, that almost never happens.
It is almost identical to regular Quick Sort, just with one extra step: picking a random index before partitioning.
randomizedQuickSort(arr, start, end):
if start >= end: return
pick a random pivot partition around it
sort the left side sort the right sideLet’s break down working code for this, piece by piece.
The partition function, which assumes the pivot is sitting at the very start of the segment:
def find_pivot_index(arr, start, end): if start > end: return
i = start j = i + 1
while j <= end: if arr[j] < arr[start]: i += 1 arr[i], arr[j] = arr[j], arr[i] j += 1
arr[i], arr[start] = arr[start], arr[i] return iHere, i tracks the boundary of the “smaller than pivot” zone, and j scans through everything else, just like we saw earlier with the Lomuto scheme.
The random pivot picker, which grabs a random index, swaps it to the front, then calls the partition function above:
import random
def rand_pivot(arr, start, end): rand_index = random.randrange(start, end + 1) arr[rand_index], arr[start] = arr[start], arr[rand_index] return find_pivot_index(arr, start, end)We swap the randomly chosen item to the front because our partition function expects the pivot to already be sitting there.
The main sort function, tying it all together:
def sort(arr, start=0, end=None): if end is None: end = len(arr) - 1
if len(arr) < 1 or start >= end: return
pivot_index = rand_pivot(arr, start, end)
sort(arr, start, pivot_index - 1) sort(arr, pivot_index + 1, end)| Case | Time |
|---|---|
| Best | O(N log N) |
| Average | O(N log N) |
| Worst (extremely rare) | O(N²) |
| Expected, basically always | O(N log N) |
Space: the recursive call stack typically stays around O(log N), though in the rare unlucky case it could grow to O(N).
| Feature | Regular Quick Sort | Randomized Quick Sort |
|---|---|---|
| How the pivot is picked | Fixed (first or last item) | Random |
| Worst case common? | Yes, especially on sorted data | Very rare |
| Predictable performance? | Not really | Yes |
| Generally preferred? | No | Yes |
Randomized Quick Sort takes a problem that depends on “what the input looks like” and turns it into a problem that depends on “luck,” and then makes sure the bad luck is so rare it basically never matters. That shift, from input-dependent to probability-dependent performance, is the entire reason this trick is so widely used.
Divide and Conquer
Break a big problem into smaller versions of itself, solve those, then combine the results. Always relies on recursion and a clear base case.
Two Pointers
Use two variables moving through data at once instead of nested loops. Great for sorted arrays, pairs, and sliding window problems.
Merge Sort
Split repeatedly down to single items, then merge sorted pieces back together. Always O(N log N), stable, but needs extra memory.
Quick Sort
Pick a pivot, partition around it so smaller items go left and bigger items go right, then recurse. Fast and in-place, but not stable.
Randomized Quick Sort
Same as Quick Sort, but the pivot is chosen randomly. This makes the painful worst case extremely unlikely, no matter the input.
Recurrence Relations
A formula like T(N) = aT(N/b) + f(N) that describes how much work a recursive algorithm does, used to predict its speed.