Divide & Conquer/Divide and Conquer & Mergesort

Lesson 2.13,878 words

Divide and Conquer & Mergesort

Divide and conquer breaks a problem into smaller copies of itself, solves them recursively, and stitches the answers together. We meet the paradigm through mergesort — its merge step, its loop-invariant proof, and the recursion tree that pins its cost at Θ(nlogn)\Theta(n\log n) — then count inversions with the same machinery and distill the whole pattern into the master theorem.

╌╌╌╌

Some problems are easiest to solve by reducing them to smaller versions of themselves. This is the divide-and-conquer paradigm, and it is one of the most productive ideas in all of algorithm design. Every divide-and-conquer algorithm has the same three-part skeleton:

  • Divide the problem into one or more subproblems that are smaller instances of the same problem.
  • Conquer the subproblems by solving them recursively. When a subproblem is small enough (the base case), solve it directly without recursing.
  • Combine the subproblem solutions into a solution for the original problem.

Erickson's advice captures the mindset: assume the recursion already works, so that the recursive calls correctly solve the smaller instances, and focus your energy on the divide and combine steps. This recursion fairy1 stance turns a single hard problem into two manageable questions: how do I split? and how do I merge?

The payoff is always a recurrence. If an instance of size spawns subproblems each of size , and the divide-plus-combine work costs , then the total cost obeys

Almost every algorithm in this module is an exercise in choosing , , and wisely and then reading off . The master theorem (stated at the end of this lesson) turns that reading-off into a mechanical three-case rule; the recursion tree is the picture behind it. Mergesort is the cleanest first example, so we start there.

The sorting problem, revisited

Recall the specification from the previous module:

Insertion sort grew a sorted prefix one element at a time, costing in the worst case. Divide and conquer does much better. Ask: if I already had two sorted halves, could I finish the job cheaply? The answer, yes, by merging, gives us mergesort.2

Mergesort

To sort the subarray , split it at the midpoint , recursively sort the two halves, and merge them back together. A single element () is already sorted, so it is the base case.

Mergesort on . The top half divides (black arrows) down to singletons — the base cases; the bottom half merges (blue arrows) those sorted runs back up, pair by pair, to the final sorted list.
Algorithm 1:Merge-Sort(A,p,r)\textsc{Merge-Sort}(A, p, r) — sort A[p..r]A[p..r] in increasing order
  1. 1
    if p<rp < r then
  2. 2
    q(p+r)/2q \gets \floor{(p + r) / 2}
    split point
  3. 3
    call Merge-Sort(A,p,q)\textsc{Merge-Sort}(A, p, q)
    sort left half
  4. 4
    call Merge-Sort(A,q+1,r)\textsc{Merge-Sort}(A, q + 1, r)
    sort right half
  5. 5
    call Merge(A,p,q,r)\textsc{Merge}(A, p, q, r)
    combine halves

All the real work lives in the combine step. takes two adjacent sorted runs, and , and interleaves them into a single sorted run in place. It copies each half into a scratch array, then repeatedly takes the smaller of the two front elements and writes it back.

Algorithm 2:Merge(A,p,q,r)\textsc{Merge}(A, p, q, r) — merge sorted A[p..q]A[p..q] and A[q+1..r]A[q+1..r]
  1. 1
    n1qp+1n_1 \gets q - p + 1
  2. 2
    n2rqn_2 \gets r - q
  3. 3
    let L[1..n1+1]L[1..n_1 + 1] and R[1..n2+1]R[1..n_2 + 1] be new arrays
  4. 4
    for i1i \gets 1 to n1n_1 do
  5. 5
    L[i]A[p+i1]L[i] \gets A[p + i - 1]
    copy left half
  6. 6
    for j1j \gets 1 to n2n_2 do
  7. 7
    R[j]A[q+j]R[j] \gets A[q + j]
    copy right half
  8. 8
    L[n1+1]L[n_1 + 1] \gets \infty
    sentinel guards the run end
  9. 9
    R[n2+1]R[n_2 + 1] \gets \infty
  10. 10
    i1i \gets 1
  11. 11
    j1j \gets 1
  12. 12
    for kpk \gets p to rr do
  13. 13
    if L[i]R[j]L[i] \le R[j] then
  14. 14
    A[k]L[i]A[k] \gets L[i]
  15. 15
    ii+1i \gets i + 1
  16. 16
    else
  17. 17
    A[k]R[j]A[k] \gets R[j]
  18. 18
    jj+1j \gets j + 1
mergesort.pypython
from typing import Protocol, TypeVar

class Comparable(Protocol):
  """
    Anything that supports `<=`; mergesort needs nothing more.\n
  """

  def __le__(self, other: object) -> bool: ...

Element = TypeVar("Element", bound=Comparable)

def merge(left: list[Element], right: list[Element]) -> list[Element]:
  """
    Interleave two already-sorted runs into one sorted list.\n
    Ties favour `left`, so equal elements keep their original order and the\n
    overall sort stays stable. Runs in O(len(left) + len(right)).\n
  """
  merged: list[Element] = []
  left_cursor: int = 0
  right_cursor: int = 0

  # take the smaller front element until one run is exhausted.
  while left_cursor < len(left) and right_cursor < len(right):
    if left[left_cursor] <= right[right_cursor]:
      merged.append(left[left_cursor])
      left_cursor += 1
    else:
      merged.append(right[right_cursor])
      right_cursor += 1

  # one run is empty; the other is already sorted, so append its tail.
  merged.extend(left[left_cursor:])
  merged.extend(right[right_cursor:])
  return merged

def mergesort(values: list[Element]) -> list[Element]:
  """
    Return a new sorted copy of `values` in increasing order.\n
    A list of zero or one element is its own base case.\n
  """
  if len(values) <= 1:
    return list(values)

  # split at the midpoint and sort each half, then merge the two runs.
  midpoint: int = len(values) // 2
  sorted_left: list[Element] = mergesort(values[:midpoint])
  sorted_right: list[Element] = mergesort(values[midpoint:])
  return merge(sorted_left, sorted_right)

The two sentinel values are a small but useful device: once one half is used up, its front element is forever , so the comparison always picks from the other half. This removes the need to test have we run out? on every iteration.

Picture the merge in flight. Two sorted runs and sit above the output; the cursors and point at their smallest uncopied elements, and marks where the next winner lands in . Each step compares to , writes the smaller, and advances that one cursor.

Merge step with cursors and on sorted runs and writing the smaller value into at . The last cell of each run holds the sentinel .

Here and , so wins: it is written to and advances. The two sentinels guard the right ends so the comparison is always well-defined.

Why merge is correct

Merge runs in time on elements: each of the iterations of the final for loop does work and advances exactly one of , . Correctness rests on a loop invariant:

For example, run the loop to completion on the two halves and . After the copy phase, and . Each row below is one iteration of the for loop: one comparison, one write, one cursor advance.

comparisonwinner after the write
vs
vs (tie: takes left)
vs
vs
vs
vs
vs
vs

Two rows deserve a second look. At the fronts tie at ; the comparison takes from , the left half — the choice that makes the sort stable (more on this below). At the right run is exhausted and its front is the sentinel , so the comparison automatically drains the rest of with no special end-of-run test. Eight iterations, eight writes, each element landing in its sorted slot:

The completed merge of sorted halves and interleaves into one sorted run.

Analyzing the cost

Let be the worst-case running time of mergesort on elements. Splitting costs , the two recursive calls cost , and the merge costs . So

To see why this resolves to , draw the recursion tree. Each node is labeled with the non-recursive work it does, the cost of its own merge. The root merges elements; its two children each merge ; the next level has four nodes each merging ; and so on.

Recursion tree for mergesort with each node showing its merge cost, summing to per level.

Each level sums to the same amount. The root level is ; the next is ; the next is ; in general level has nodes each doing work, for a row total of .

Doubling the node count while halving each node's work keeps every level's total fixed at ; the rows give .

Halving from down to the base case of takes steps, so there are levels. Multiplying the per-level cost by the number of levels:

This is the canonical application of the master theorem (, , , so and we land in the balanced case), but the recursion tree makes the concrete: levels, work apiece.

Stability

This falls out of the in : when we take from , the left (earlier) half, first. We saw it happen in the trace above, at : the two front elements tied at , and the left half's copy was emitted first. Since every element of came from earlier positions in than every element of , and recursion preserves the property inductively, equal elements never swap places.

Stability on : the three equal keys (subscripts mark original order) arrive in the output in the same left-to-right order they started in.

Stability matters when records are sorted on one key but carry others: a stable sort lets you sort by secondary key, then primary key, and trust that ties on the primary preserve the secondary ordering. Sorting employees by department after sorting them by name leaves each department's roster alphabetized — but only if the second sort is stable.

Mergesort versus other sorts

PropertyMergesortInsertion sortHeapsortQuicksort
Worst case
Average case
Extra space
Stableyesyesnono
In placenoyesyesyes

Mergesort's worst-case guarantee and stability make it the sort of choice when predictability matters or when data does not fit in memory. Its sequential, merge-based access pattern is ideal for sorting linked lists and for external sorting of data streamed from disk.3 Its cost is the auxiliary array. Quicksort, the subject of the next lesson, trades that guarantee for better constants and in-place operation.

When the recursion is not worth it

Divide and conquer wins asymptotically, but each recursive call carries real overhead: stack frames, index arithmetic, the scratch-array traffic of . On a subarray of ten elements, insertion sort's tight loop with no allocation beats all of that machinery outright. Two standard adjustments exploit this.

Cut off to insertion sort. Stop recursing once the subarray shrinks below a threshold and finish it with insertion sort. The base cases cost each, for total, while the merging now spans only levels of work apiece:

For constant this is still — the asymptotics are untouched — but the constant factor drops because the bottom levels of the recursion tree, the levels with the most nodes and the most per-call overhead, are replaced by a handful of cheap quadratic sorts. In practice is tuned somewhere between and .

Go bottom-up. The recursion can be removed entirely. Bottom-up mergesort treats the array as sorted runs of width , then makes passes that merge adjacent runs pairwise: after the first pass the runs have width , then , then , doubling until one run remains.

Bottom-up mergesort on : each pass merges adjacent runs pairwise, doubling the run width, with no recursion at all.

Each pass is a plain loop over the array doing merge work, and there are passes, so the cost is the same — the recursion tree read bottom-to-top instead of top-to-bottom. What iteration buys is engineering: no stack, no function-call overhead, and a shape that suits linked lists (splice runs instead of copying) and external sorting, where each pass is one sequential sweep over the data on disk. What it gives up is the cutoff trick's easy hybridization and any chance to exploit runs that are already sorted — refinements that top-down and bottom-up variants alike can bolt back on.

The broader moral: divide and conquer sets the asymptotic ceiling, but at small sizes a simple iterative method with better constants wins, so real implementations are hybrids — recursion (or doubling passes) for the large scales, iteration for the base.

Counting inversions

Here is a problem that has nothing to do with sorting on its surface, yet falls to the very machinery we just built. Given a list , how close to sorted is it? A natural measure counts the pairs that are out of order.

A sorted array has zero inversions; a reverse-sorted one has the maximum, . (Inversion counts also drive collaborative-filtering how similar are two rankings? scores.) The brute-force algorithm loops over all pairs and counts the bad ones, costing exactly comparisons. We can do far better.

Idea 0: divide and conquer, just like mergesort. Split into a left half and a right half . Every inversion is one of three kinds:

  • both endpoints in , counted by recursing on ;
  • both endpoints in , counted by recursing on ;
  • one endpoint in each: a cross inversion, on the left and on the right with .
A cross inversion linking an element in left half to a smaller element in right half .

Counting cross inversions with a double loop costs for the combine step, giving , which the master theorem resolves to , no gain. The combine step is the bottleneck.

Idea 1: count cross inversions during a merge. Suppose the two halves arrive already sorted. Walk them with two cursors exactly as does. When we are about to emit and , the element is smaller than and than everything after it in , so forms an inversion with all remaining elements of at once. Add that count, emit , and move on.

This batching is why the count collapses to linear time: a single comparison reveals inversions, not one. Because is sorted, every element from onward exceeds , so each is inverted with it.

When the merge finds , sortedness of means every remaining also exceeds — so emitting adds cross inversions in one stroke.
Algorithm 3:Count-Cross-Inv(B[1..p],C[1..q])\textsc{Count-Cross-Inv}(B[1..p], C[1..q]) — cross inversions, B,CB, C sorted
  1. 1
    ans0\mathit{ans} \gets 0
  2. 2
    i1i \gets 1
  3. 3
    j1j \gets 1
  4. 4
    while ipi \le p and jqj \le q do
  5. 5
    if B[i]C[j]B[i] \le C[j] then
  6. 6
    ii+1i \gets i + 1
    no inversion
  7. 7
    else
  8. 8
    ansans+(pi+1)\mathit{ans} \gets \mathit{ans} + (p - i + 1)
    C[j]C[j] inverts with B[i..p]B[i..p]
  9. 9
    jj+1j \gets j + 1
  10. 10
    return ans\mathit{ans}

This runs in , the linear merge pattern. But it demands sorted halves, so we must sort them first: sorting and costs an extra per level, and there are levels, giving . Better than quadratic, but the repeated sorting is wasteful.

Idea 2: sort and count in one pass. We are doing almost all of mergesort's work anyway, so let the recursion return both the inversion count and a sorted copy of its slice. Then the cross-counting merge also produces the sorted output the parent needs, for free.

Algorithm 4:Sort-And-Count-Inv(A,lo,hi)\textsc{Sort-And-Count-Inv}(A, \mathit{lo}, \mathit{hi}) — sort A[lo..hi]A[\mathit{lo}..\mathit{hi}], return its inversion count
  1. 1
    if hilo\mathit{hi} \le \mathit{lo} then
  2. 2
    return 00
    single element: no inversions
  3. 3
    t(lo+hi)/2t \gets \floor{(\mathit{lo} + \mathit{hi}) / 2}
  4. 4
    cSort-And-Count-Inv(A,lo,t)c \gets \textsc{Sort-And-Count-Inv}(A, \mathit{lo}, t)
    left inversions + sort left
  5. 5
    cc+Sort-And-Count-Inv(A,t+1,hi)c \gets c + \textsc{Sort-And-Count-Inv}(A, t + 1, \mathit{hi})
    right inversions + sort right
  6. 6
    cc+Count-Cross-Inv-And-Merge(A,lo,t,hi)c \gets c + \textsc{Count-Cross-Inv-And-Merge}(A, \mathit{lo}, t, \mathit{hi})
    cross + merge
  7. 7
    return cc
count_inversions.pypython
from typing import TypeVar

from comparable import Comparable

Element = TypeVar("Element", bound=Comparable)

def _merge_and_count(
  left: list[Element],
  right: list[Element],
) -> tuple[list[Element], int]:
  """
    Merge two sorted runs and count the cross inversions between them.\n
    When `right[right_cursor]` is the smaller front element, every element\n
    still pending in `left` exceeds it, so all of them are added at once.\n
  """
  merged: list[Element] = []
  left_cursor: int = 0
  right_cursor: int = 0
  cross_inversions: int = 0

  # emit the smaller front element; tie favours left to stay stable.
  while left_cursor < len(left) and right_cursor < len(right):
    if not (right[right_cursor] < left[left_cursor]):
      merged.append(left[left_cursor])
      left_cursor += 1
    else:
      # right element is smaller than every left element still pending.
      cross_inversions += len(left) - left_cursor
      merged.append(right[right_cursor])
      right_cursor += 1

  # one run is drained; the other's tail is already sorted.
  merged.extend(left[left_cursor:])
  merged.extend(right[right_cursor:])
  return merged, cross_inversions

def _sort_and_count(values: list[Element]) -> tuple[list[Element], int]:
  """
    Return a sorted copy of `values` together with its inversion count.\n
    Left and right inversions come from the recursive calls; cross\n
    inversions come from the merge that stitches the halves together.\n
  """
  if len(values) <= 1:
    return list(values), 0

  # sort each half; the recursion tallies the inversions within each.
  midpoint: int = len(values) // 2
  sorted_left, left_inversions = _sort_and_count(values[:midpoint])
  sorted_right, right_inversions = _sort_and_count(values[midpoint:])

  # merge the halves, adding the cross inversions to both sides' counts.
  merged, cross_inversions = _merge_and_count(sorted_left, sorted_right)
  return merged, left_inversions + right_inversions + cross_inversions

def count_inversions(values: list[Element]) -> int:
  """
    The number of pairs (i, j) with i < j and values[i] > values[j].\n
    A sorted list has zero; a reverse-sorted one has the maximum n(n-1)/2.\n
  """
  _, inversions = _sort_and_count(values)
  return inversions
comparable.pypython
from typing import Any, Protocol, TypeVar


class Comparable(Protocol):
  """
    Anything orderable with `<` (int, float, str, tuple, date, …).\n
  """

  # `other` is position-only so built-ins (int, str, …), whose dunder
  # operands are position-only, structurally satisfy the protocol.
  def __lt__(self, other: Any, /) -> bool: ...
  def __gt__(self, other: Any, /) -> bool: ...
  def __le__(self, other: Any, /) -> bool: ...
  def __ge__(self, other: Any, /) -> bool: ...

The helper is just with the counting rule from folded in: whenever it takes from the right half because , it adds the number of elements still waiting in the left half. The combine step is now plain linear, so

This is the same recurrence as mergesort, and the same recursion tree explains it: levels, work each. Counting how disordered a list is costs no more, asymptotically, than sorting it.

A worked count

Run the algorithm on , whose inversions are , , and — three in all. The split gives and . Both halves are already sorted, so the recursive calls return and , and everything rides on the counting merge:

stepfrontsactioncount addedrunning total
vs emit from (both of exceed )
vs emit from
vs emit from (only remains in )
vs emit from
emptyemit from

Total: , matching the hand count, and the array leaves the merge sorted as , ready for use by the parent call. Step is the batching in action: one comparison charged two inversions, because sortedness of guarantees every element from its cursor onward exceeds the emitted value.

Beyond sorting: faster multiplication

Sorting is not the only home for divide and conquer. The same paradigm beats the grade-school algorithm for multiplying large integers (Karatsuba, three half-size products instead of four, ) and the cubic schoolbook algorithm for multiplying matrices (Strassen, seven block products instead of eight, ). Both spend cheap additions to buy back an expensive multiplication, and both fall straight out of the master theorem below. We give them a lesson of their own: Fast Multiplication.

The master theorem

Every recurrence in this lesson has the form . The recursion-tree analysis we did by hand each time generalizes to a single rule. Compare the branching exponent , the rate at which leaves proliferate, against the work exponent :

The three cases correspond to the three shapes of recursion tree: when the rows grow toward the leaves (Karatsuba), when they are equal every row costs the same (mergesort), and when the root's work dominates. Reading off our examples:

AlgorithmRecurrence vs
Mergesort, balanced
Counting inversions, balanced
Inversions, naive combine, root-heavy
Karatsuba, leaf-heavy
master_theorem.pypython
import math
from enum import Enum
from typing import NamedTuple

class Regime(Enum):
  """
    Which term of the recursion tree dominates the total cost.\n
  """
  LEAF_HEAVY = "leaf-heavy"
  BALANCED = "balanced"
  ROOT_HEAVY = "root-heavy"

class MasterSolution(NamedTuple):
  """
    The solved recurrence: its regime, the dominating exponent, whether a\n
    log factor appears, and a human-readable Theta(...) description.\n
  """
  regime: Regime
  exponent: float
  has_log_factor: bool
  asymptotic: str

def _format_exponent(exponent: float) -> str:
  """
    Render an exponent compactly: `n`, `1`, or `n^k` with tidy decimals.\n
  """
  # special-case the bare exponents 0 and 1 for a cleaner label.
  if math.isclose(exponent, 0.0, abs_tol=1e-9):
    return "1"
  if math.isclose(exponent, 1.0, abs_tol=1e-9):
    return "n"

  # otherwise trim trailing zeros and render as n^k.
  text: str = f"{round(exponent, 4):g}"
  return f"n^{text}"

def master_theorem(
  subproblems: int,
  shrink_factor: float,
  work_exponent: float,
) -> MasterSolution:
  """
    Solve T(n) = a*T(n/b) + Theta(n^c) for `a` subproblems, shrink factor\n
    `b`, and combine-work exponent `c`. Requires a >= 1 and b > 1.\n
    Returns the regime, the branching/work exponent that wins, and the\n
    Theta(...) bound as a string.\n
  """
  # reject recurrences the theorem can't classify.
  if subproblems < 1:
    raise ValueError("subproblems (a) must be at least 1")
  if shrink_factor <= 1:
    raise ValueError("shrink_factor (b) must be greater than 1")

  # log_b(a) is the rate leaves proliferate; compare it against c.
  branching_exponent: float = math.log(subproblems) / math.log(shrink_factor)

  # leaves win: cost is dominated by the bottom of the recursion tree.
  if branching_exponent > work_exponent + 1e-9:
    regime: Regime = Regime.LEAF_HEAVY
    exponent: float = branching_exponent
    has_log_factor: bool = False

  # balanced: every level costs the same, so a log factor appears.
  elif math.isclose(branching_exponent, work_exponent, abs_tol=1e-9):
    regime = Regime.BALANCED
    exponent = work_exponent
    has_log_factor = True

  # root wins: cost is dominated by the top-level combine work.
  else:
    regime = Regime.ROOT_HEAVY
    exponent = work_exponent
    has_log_factor = False

  # assemble the Theta(...) bound, folding in the log factor if balanced.
  body: str = _format_exponent(exponent)
  asymptotic: str = (
    f"Theta({body} log n)" if has_log_factor else f"Theta({body})"
  )
  return MasterSolution(regime, exponent, has_log_factor, asymptotic)

One last sanity check: it makes no difference whether the combine cost is written or bounded above by . The recurrences and have the same solution. The master theorem depends only on , , and the exponent .

The sort real programs call

Mergesort's clean structure and stability make it the base for the sort that most real programs actually call.

Timsort: exploit the runs already there. The default sort in Python's list and Java's Arrays.sort for objects is Timsort (Tim Peters, 2002), an adaptive, stable mergesort. Real data is rarely random: it arrives with long stretches already ascending or descending — a log file appended over time, a list re-sorted after a few edits. Timsort scans for these natural runs first, reversing descending ones in place, and only merges the runs it finds, so an already-sorted array costs a single pass instead of . It extends short runs with an insertion sort up to a minimum length, and it merges runs under a stack invariant that keeps run lengths balanced (the invariant had a famous bug, found in 2015 by researchers formally verifying the merge policy, that could overflow the merge stack — since fixed). The through-line is the bottom-up idea from earlier, made adaptive: instead of blindly doubling from width , start from the runs already present in the input.

Merging in parallel. The recursion tree's independent subproblems make mergesort a natural fit for multiple cores: the two recursive sorts run on separate threads, and the join waits for both. But a naive parallel mergesort is bottlenecked by its sequential merge at the root. The fix is a parallel merge: to merge two sorted halves, binary-search the median of one into the other to split both into balanced pieces that merge independently, recursively. This drops the span (critical-path length) to while keeping the work , the design behind the parallel sorts in libraries like Intel TBB and the C++17 parallel std::sort. Mergesort's sequential, predictable access pattern — the same property that suits linked lists and disk — also lets it carve cleanly across cores.3

Takeaways

  • Divide and conquer = divide into smaller copies, conquer recursively, combine. Trust the recursion; focus on the split and the merge. The cost is always a recurrence .
  • divides at the midpoint and combines with a linear-time whose correctness is a clean loop-invariant argument.
  • The recurrence unfolds into a recursion tree with levels of work each, giving .
  • Mergesort is stable and worst-case optimal among comparison sorts, at the cost of extra space, ideal for linked lists and external sorting.
  • At small sizes the recursion's overhead loses to plain iteration: real implementations cut off to insertion sort below a threshold () or run bottom-up, merging width- runs with no recursion at all.
  • Counting inversions reuses the merge: fold a cross-inversion count into so each step adds , sorting and counting together in instead of the brute-force .
  • The same machinery beats grade-school arithmetic — see Fast Multiplication for Karatsuba () and Strassen ().
  • The master theorem turns the tree into a rule: compare to for leaf-heavy, balanced, or root-heavy behavior.4

Footnotes

  1. Erickson, Algorithms, Ch. 1 — Recursion: the recursion fairy stance of assuming recursive calls already work and focusing on divide and combine.
  2. CLRS, Ch. 2 (§2.3) — Designing algorithms: mergesort as the canonical divide-and-conquer sort built on a linear-time merge.
  3. Skiena, The Algorithm Design Manual, §4 — Sorting and Searching: mergesort's stability and suitability for linked lists and external sorting. 2
  4. CLRS, Ch. 4 — Divide-and-Conquer: the master theorem comparing against the work exponent to classify leaf-heavy, balanced, and root-heavy recurrences.
Practice

╌╌ END ╌╌