AmouAI Hub/Courses/Data Structures & Algorithms/Chapter 12
Divide and Conquer: Merge Sort and Quicksort
Sorting a thousand cards alone is miserable. Sorting five cards and handing the rest to two friends who do the same is O(n log n) — and that is the whole trick.
By the end of this chapter you can
- State the three steps of divide and conquer and identify them in any such algorithm
- Explain where the
log ninO(n log n)comes from, using the recursion tree - Implement merge sort and quicksort, and say exactly what each trades away
- Describe the input that makes quicksort quadratic, and the standard defences
- Prove that no comparison sort can beat
O(n log n), and name the sorts that escape the bound
1Split, solve, combine
Three steps that show up everywhere from sorting to matrix multiplication to fast Fourier transforms.
You have a thousand cards to sort. Doing it alone by insertion is a long afternoon.
Instead: split the pile in two, give one half to each of two friends, and ask them to sort it — by exactly this method. When they hand back two sorted piles, you merge them by repeatedly taking the smaller of the two top cards.
You did almost no work. Your friends did almost no work, because they split again. The combining is the only real effort, and it is linear.
Divide and conquer is three steps:
- Divide the problem into smaller instances of the same problem.
- Conquer each one recursively (base case: it is small enough to solve outright).
- Combine the sub-answers into the answer.
The cost of the whole thing depends on where the work lives:
| Algorithm | Divide | Combine | Total |
|---|---|---|---|
| Merge sort | Free (split in half) | O(n) merge | O(n log n) |
| Quicksort | O(n) partition | Free (already in place) | O(n log n) average |
| Binary search | Free | Free | O(log n) |
| Towers of Hanoi | Free | Free (one move) | O(2ⁿ) |
Every time you halve the problem, the depth of the recursion is log₂ n
— because that is how many times you can halve n before reaching 1.
At each level of the tree the algorithm does O(n) total work (every element is
touched exactly once per level). So the total is
levels × work per level = log n × n.
That sentence is the entire derivation, and it applies to both sorts in this chapter.
2Merge sort
The reliable one. O(n log n) on every input without exception, stable, and it pays for that with memory.
Splitting is trivial — take the midpoint. All the work is in the merge, and the merge is the routine you already wrote in Chapter 5: walk two sorted sequences, repeatedly taking whichever front value is smaller.
def merge_sort(xs):
if len(xs) <= 1: # base case: already sorted
return xs
mid = len(xs) // 2 # 1. DIVIDE
left = merge_sort(xs[:mid]) # 2. CONQUER
right = merge_sort(xs[mid:])
return merge(left, right) # 3. COMBINE
def merge(a, b):
out = []
i = j = 0
while i < len(a) and j < len(b):
if a[i] <= b[j]: # <= not <, and that is what keeps it STABLE
out.append(a[i]); i += 1
else:
out.append(b[j]); j += 1
out.extend(a[i:]) # one side is exhausted; the other is
out.extend(b[j:]) # already sorted, so append it wholesale
return outThe uniformity is the selling point. Merge sort does not care about the input: sorted, reversed,
random, adversarial — always n log n. That makes it the right choice when you need
a guarantee rather than an average.
The price is O(n) extra memory for the merge buffers. In-place merge sort exists but
is complicated and slower in practice, so almost nobody uses it.
Merge sort only ever reads its inputs sequentially, front to back. That makes it the only practical choice when the data does not fit in memory: split a 500 GB file into chunks that do fit, sort each chunk, write them out, then merge the sorted files by streaming.
Quicksort's random access would thrash the disk. This is why sort on the command
line, and every database's ORDER BY on a large table, is a merge sort.
3Quicksort
The fast one. Same asymptotics on average, better constants, sorts in place — and it has a genuine failure mode.
Quicksort moves the work to the other end. Instead of splitting blindly and merging carefully, it partitions carefully and then has nothing to combine at all.
Pick a pivot. Rearrange the array so everything smaller is left of it and everything larger is right of it. The pivot is now in its final position — permanently — and the two sides are independent subproblems.
def quicksort(xs, lo=0, hi=None):
if hi is None:
hi = len(xs) - 1
if lo >= hi:
return xs
p = partition(xs, lo, hi)
quicksort(xs, lo, p - 1) # everything left of the pivot
quicksort(xs, p + 1, hi) # everything right of it
return xs # no combine step — it is already sorted
def partition(xs, lo, hi):
"""Lomuto partition. Returns the pivot's final index."""
pivot = xs[hi]
i = lo # boundary of the B region
for j in range(lo, hi):
if xs[j] < pivot:
xs[i], xs[j] = xs[j], xs[i]
i += 1
xs[i], xs[hi] = xs[hi], xs[i] # put the pivot between the two regions
return iWhy it beats merge sort in practice
- No allocation. Partitioning happens in the array itself.
- Cache locality. Partitioning is a linear scan over contiguous memory, which is what CPUs are fastest at.
- Small constant. A swap is cheaper than an append plus a copy.
If the pivot is always the smallest or largest remaining element, each partition peels off one
element instead of halving. The recursion depth becomes n, and the cost becomes
O(n²).
With pivot = xs[hi], that happens on already-sorted input —
which is extremely common. The naive quicksort's worst case is the most likely real-world input,
which is a spectacularly bad property.
The three standard defences
import random
# 1. Random pivot — an adversary cannot predict it
def partition_random(xs, lo, hi):
r = random.randint(lo, hi)
xs[r], xs[hi] = xs[hi], xs[r]
return partition(xs, lo, hi)
# 2. Median of three — cheap, and immune to sorted input specifically
def median_of_three(xs, lo, hi):
mid = (lo + hi) // 2
trio = sorted([(xs[lo], lo), (xs[mid], mid), (xs[hi], hi)])
return trio[1][1] # index of the median value
# 3. Introsort — track the recursion depth, and if it exceeds
# 2*log(n), abandon quicksort and finish with heapsort.
# This is what C++ A s speed with
# merge sort's guarantee.4Merge sort versus quicksort
Both O(n log n) on average. They differ in every other respect, and the differences decide real choices.
| Merge sort | Quicksort | |
|---|---|---|
| Worst case | O(n log n) — guaranteed | O(n²) on bad pivots |
| Extra space | O(n) | O(log n) for the call stack |
| Stable | Yes | No |
| Cache behaviour | Good | Excellent |
| Typical constant | Larger | Smaller |
| Data larger than RAM | The only option | Unusable |
| Linked lists | Natural — O(1) space, no random access needed | Awkward |
| Real-world users | Python (Timsort), Java objects, external sorts | C++ std::sort, Java primitives |
Notice the Java row. Arrays.sort uses quicksort for int[] and merge sort
for Object[]. That is not inconsistency — it is the stability requirement.
Primitives are indistinguishable when equal, so stability is meaningless and speed wins. Objects can
be equal by key but distinct, so stability matters and merge sort wins.
What real libraries actually ship
- Timsort (Python, Java objects, Rust, Android): merge sort that finds existing
sorted runs and merges them, with insertion sort for short runs.
O(n)on already-sorted data, which is common. Stable. - Introsort (C++
std::sort): quicksort, switching to heapsort if the recursion gets too deep, and to insertion sort for small ranges. Not stable, butO(n log n)guaranteed. - Pattern-defeating quicksort (Rust unstable sort, modern C++): detects adversarial patterns and adapts.
All three are hybrids: a good asymptotic algorithm for the bulk of the work, insertion sort for small pieces, and a fallback for the pathological case.
That is the general lesson. Real systems rarely use one textbook algorithm — they use the right one for each regime and switch between them.
5The n log n barrier
No comparison sort can do better than O(n log n). Here is the proof, and here is how to cheat.
The argument is short and worth understanding, because it is a rare case where we can say something is impossible rather than merely difficult.
- A list of n distinct items has n! possible orderings. Exactly one is sorted.
- Each comparison has two outcomes, so it can at best halve the set of orderings still consistent with what you know.
- To get from n! possibilities down to 1 by halving, you need at least log₂(n!) comparisons.
- By Stirling's approximation, log₂(n!) is about n log₂ n.
So O(n log n) is not a limit of our cleverness. It is a limit of comparison
as a way of learning about data.
The bound only applies to sorts that learn about the data solely by comparing pairs of elements. If you know something else — that the values are small integers, or fixed-length strings — you can use that knowledge directly and beat the bound.
Counting sort
If the values are integers in a known small range, do not compare them at all. Count them.
def counting_sort(xs, max_value):
counts = [0] * (max_value + 1)
for x in xs:
counts[x] += 1 # tally — no comparison anywhere
out = []
for value, n in enumerate(counts):
out.extend([value] * n) # read the tallies back in order
return out
# O(n + k) where k is the value range. When k is small this is LINEAR,
# which is impossible for a comparison sort.
#
# When k is large it is a disaster: sorting [1, 1000000000] allocates
# a billion counters. The trade is explicit and unforgiving.| Sort | Time | Requires | Use when |
|---|---|---|---|
| Counting sort | O(n + k) | Small integer range k | Ages, scores, byte values, grades |
| Radix sort | O(d · (n + b)) | Fixed-width keys | Sorting integers or fixed-length strings in bulk |
| Bucket sort | O(n) average | Uniformly distributed values | Floats spread evenly over a range |
Non-comparison sorts look attractive on paper and are narrower than they appear. Counting sort on 32-bit integers needs four billion counters. Radix sort needs stable passes and careful bookkeeping.
Use sorted(). Reach for a non-comparison sort only when you have measured that
sorting is your bottleneck and your keys have the special structure these need.
What to carry forward
- Divide and conquer is divide, conquer, combine. The cost sits in whichever of divide or combine is not free.
- The
log nis the depth of the halving; thenis the work per level. Multiply them. - Merge sort: O(n log n) guaranteed, stable, sequential access — but O(n) extra space. The only option for data larger than memory.
- Quicksort: in place, cache-friendly, small constant — but O(n²) on bad pivots, and the naive pivot makes sorted input the worst case.
- Defences: random pivot, median of three, or introsort (bail out to heapsort when the recursion gets too deep).
- No comparison sort can beat O(n log n) — there are n! orderings and each comparison halves the possibilities. Counting and radix sorts escape by not comparing.
>_Playground
Race the sorts, then feed quicksort its worst case and watch it fall over.
✓Exercises
Checked automatically the moment you submit. Work top to bottom — each one assumes the last. Your answers are saved in this browser.
Chapter 13 — Binary Search and Searching the Answer
Twenty questions gets you to any number under a million. The technique is easy; the boundary conditions are where careers are made.