Ch 12 / 30 Divide and Conquer: Merge Sort and Quicksort 0/0 exercises Exercises ↓

AmouAI Hub/Courses/Data Structures & Algorithms/Chapter 12

Part 3 · Recursion and Sorting · 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.

Reading
CLRS Ch. 2.3, 7
Focus
Split, solve, combine
Cost
O(n log n)
Sorts
merge · quick

By the end of this chapter you can

  1. State the three steps of divide and conquer and identify them in any such algorithm
  2. Explain where the log n in O(n log n) comes from, using the recursion tree
  3. Implement merge sort and quicksort, and say exactly what each trades away
  4. Describe the input that makes quicksort quadratic, and the standard defences
  5. 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.

Sorting with friends

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:

  1. Divide the problem into smaller instances of the same problem.
  2. Conquer each one recursively (base case: it is small enough to solve outright).
  3. Combine the sub-answers into the answer.

The cost of the whole thing depends on where the work lives:

AlgorithmDivideCombineTotal
Merge sortFree (split in half)O(n) mergeO(n log n)
QuicksortO(n) partitionFree (already in place)O(n log n) average
Binary searchFreeFreeO(log n)
Towers of HanoiFreeFree (one move)O(2ⁿ)
Where the log n comes from

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.

merge_sort.py
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 out
Best case
O(n log n)
Average
O(n log n)
Worst case
O(n log n)
Space
O(n)
Stable?
Yes
In place?
No

The 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.

Why merge sort owns external sorting

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.

quicksort.py
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 i
Best case
O(n log n)
Average
O(n log n)
Worst case
O(n²)
Space
O(log n)
Stable?
No
In place?
Yes

Why 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.
The adversarial input

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

pivot_choice.py
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++As 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 sortQuicksort
Worst caseO(n log n) — guaranteedO(n²) on bad pivots
Extra spaceO(n)O(log n) for the call stack
StableYesNo
Cache behaviourGoodExcellent
Typical constantLargerSmaller
Data larger than RAMThe only optionUnusable
Linked listsNatural — O(1) space, no random access neededAwkward
Real-world usersPython (Timsort), Java objects, external sortsC++ 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, but O(n log n) guaranteed.
  • Pattern-defeating quicksort (Rust unstable sort, modern C++): detects adversarial patterns and adapts.
The shape of every industrial sort

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.

  1. A list of n distinct items has n! possible orderings. Exactly one is sorted.
  2. Each comparison has two outcomes, so it can at best halve the set of orderings still consistent with what you know.
  3. To get from n! possibilities down to 1 by halving, you need at least log₂(n!) comparisons.
  4. 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.

Which means: stop comparing

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.

counting_sort.py
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.
SortTimeRequiresUse when
Counting sortO(n + k)Small integer range kAges, scores, byte values, grades
Radix sortO(d · (n + b))Fixed-width keysSorting integers or fixed-length strings in bulk
Bucket sortO(n) averageUniformly distributed valuesFloats spread evenly over a range
Do not reach for these first

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 n is the depth of the halving; the n is 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.

scratch.pypython not loaded
Real Python, running in your browser. Nothing is installed or uploaded.
Output appears here. The first run takes a few seconds while Python loads.

Exercises

Checked automatically the moment you submit. Work top to bottom — each one assumes the last. Your answers are saved in this browser.

All Warm-up Core Challenge Reset chapter

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.

Continue →