DSA DSA In Depth
0%
lesson mergesortinversionsstability

Top-Down vs Bottom-Up Merge Sort & Counting Inversions in O(N log N)

7 min read Module 2.2: Sorting Mechanics, Hybrid Sorts & Linear-Time Order Statistics Updated Oct 23, 2026
📄 Cheat Sheet
Merge Sort & Counting Inversions in O(N log N) Divide-and-Conquer
take U forward Merge Sort & Counting Inversions in O(N log N) Divide-and-Conquer Watch on YouTube ↗

Demonstrates the two-finger linear merge invariant, derives the T(n) = 2T(n/2) + Θ(n) recursion tree, and explains memory allocation trade-offs.

0:00 / --:--

The Two-Finger Merge & Recursion Tree Analysis

John von Neumann (1945) designed Merge Sort around a simple linear-time primitive: merging two already-sorted halves $A[lo \dots mid]$ and $A[mid+1 \dots hi]$ into a single sorted range of length $n = hi - lo + 1$. Each step compares the front elements of both halves and advances the pointer of the smaller element, taking at most $n - 1$ comparisons and exactly $n$ element moves.

The recurrence $T(n) = 2T(n/2) + \Theta(n)$ expands into a binary recursion tree of depth $\lceil \log_2 n \rceil$. At level $d \in [0, \log_2 n]$, there are $2^d$ subproblems each of size $n / 2^d$, so the total merge work at every level is $2^d \cdot c(n/2^d) = cn$. Summing across all $\log_2 n$ levels yields worst-case, average-case, and best-case $\Theta(n \log n)$ time, with at most $n \lceil \log_2 n \rceil - 2^{\lceil \log_2 n \rceil} + 1$ key comparisons.

Split-Inversion Counting Invariant

Every inversion $(p, q)$ with $p < q$ and $A[p] > A[q]$ falls into exactly one of three mutually exclusive cases relative to $mid = \lfloor(lo + hi)/2\rfloor$:

  1. Left Inversion ($p < q \le mid$): Counted recursively when sorting $A[lo \dots mid]$.
  2. Right Inversion ($mid < p < q$): Counted recursively when sorting $A[mid+1 \dots hi]$.
  3. Split Inversion ($p \le mid < q$): Because $L = A[lo \dots mid]$ and $R = A[mid+1 \dots hi]$ are both sorted prior to merging, if $L[i] \le R[j]$, then $L[i]$ is $\le$ all remaining elements in $R$ (contributing $0$ split inversions). Conversely, if $R[j] < L[i]$, then by monotonicity of $L$: $$R[j] < L[i] \le L[i+1] \le \dots \le L[mid]$$ Hence $R[j]$ forms a split inversion with all $(mid - i + 1)$ remaining elements in $L$! Adding $(mid - i + 1)$ to the running accumulator whenever $R[j]$ is chosen counts all $\binom{n}{2}$ possible inversions in $\Theta(n \log n)$ time.
Split-Inversion Jump: R[j] = 14 is strictly less than L[i] = 25, so it is less than ALL remaining L[i..mid] Sorted Left L[lo..mid]: 8 25 (i) 39 44 mid - i + 1 = 3 elements > 14 Sorted Right R[mid+1..hi]: 10 14 (j) 31 52 R[j]=14 < L[i]=25 ⇒ inversions += (mid - i + 1) = +3 for pairs (25,14), (39,14), (44,14)

Top-Down vs. Bottom-Up & Ping-Pong Buffer Optimization

Naive Merge Sort implementations allocate a fresh std::vector<T> temp inside every call to merge(), turning a fast memory-bound sort into a malloc/free allocator bottleneck. Production implementations combine three structural optimizations:

  1. Single Pre-Allocated Scratch Buffer: Allocate one auxiliary buffer aux of size $n$ (or $n/2$) at the top level and pass pointers down.
  2. Ping-Pong Role Reversal (Zero Copy-Back): Instead of copying merged elements into aux and immediately copying them back into a on every level (which doubles memory bandwidth to $2n \log_2 n$ writes), alternate the source and destination roles between a and aux at each recursion depth, cutting total element moves in half to $n \log_2 n$.
  3. Already-Sorted Skip Check: Before merging $A[lo \dots mid]$ and $A[mid+1 \dots hi]$, check if $A[mid] \le A[mid+1]$. If true, the two halves are already globally ordered and the merge step can be skipped in $O(1)$ time—making Merge Sort run in $O(n)$ time on sorted inputs!
  4. Bottom-Up Iterative Merge Sort: Eliminates recursion stack frames entirely by sweeping width $w = 1, 2, 4, 8, \dots$ across the array. It is the canonical choice for sorting linked lists in $O(1)$ auxiliary space.
Sorting Arena: 3-Way QuickSort, MergeSort & LSD RadixSort
O(N log N) vs O(d·N)

Step through Bentley-McIlroy 3-way partition QuickSort, Bottom-Up MergeSort inversion counting, and non-comparison LSD RadixSort with live comparison and swap counters.

Step 1 / 7
Custom Input:
Timeline

Invariant & State

3-Way QuickSort (Dijkstra Dutch National Flag): partitions subarray into < pivot, == pivot, and > pivot.
🌳 Live 2D Hierarchical Tree Topology — Sorting Arena: 3-Way QuickSort, MergeSort & LSD RadixSort Visited/Active Pivot/Root Resolved
38 [0] 27 [1] 43 [2] 3 [3] 9 [4] 82 [5] 10 [6] 27 [7]
[0]38
[1]27
[2]43
[3]3
[4]9
[5]82
[6]10
[7]27
Comparisons
0
Swaps
0
Locked Keys
0 / 8
Array Size
8
[Step 1] Starting 3-Way QuickSort on [38, 27, 43, 3, 9, 82, 10, 27].
Merge Sort Variant Time (Worst / Avg) Best Time (Sorted) Element Writes Auxiliary Space Stable?
Naive Top-Down (alloc per merge) $\Theta(n \log n)$ $\Theta(n \log n)$ $2n \log_2 n$ $\Theta(n)$ + $O(n)$ alloc calls Yes
Top-Down + Pre-alloc + Skip Check $\Theta(n \log n)$ $\Theta(n)$ $2n \log_2 n$ $n$ (1 allocation) Yes
Ping-Pong Bottom-Up Merge Sort $\Theta(n \log n)$ $\Theta(n \log n)$ $n \log_2 n$ (no copy-back) $n$ (0 stack frames) Yes
Bottom-Up Linked-List Merge Sort $\Theta(n \log n)$ $\Theta(n \log n)$ $O(n \log n)$ pointer relinks $O(1)$ (iterative widths) Yes
flowchart TD A["Pass w = 1: Merge pairs of size 1 → sorted runs of 2 (Read from A, Write to AUX)"] --> B["Pass w = 2: Merge pairs of size 2 → sorted runs of 4 (Read from AUX, Write to A)"] B --> C["Pass w = 4: Merge pairs of size 4 → sorted runs of 8 (Read from A, Write to AUX)"] C --> D["Final Pass: If log2(N) is odd, single std::move from AUX to A"]
Hardware Reality: Sequential Stream Prefetching vs. Write-Allocate Cache Pollution

Why does Merge Sort have excellent cache locality despite using $O(n)$ auxiliary memory? During a merge, the CPU reads two strictly sequential forward streams (L[i++] and R[j++]) and writes one strictly sequential output stream (dst[k++]). The L1/L2 hardware stream prefetcher locks onto all three linear strides within 2 cache misses and pre-loads cache lines from DRAM well ahead of the execution core. However, writing to a cold auxiliary buffer triggers RFO (Read-For-Ownership / Write-Allocate) traffic: the CPU must first fetch the old cache line of dst from RAM before overwriting it, consuming $3n$ memory bus bandwidth per pass ($2$ reads + $1$ RFO read + $1$ writeback).

Production C++20 Implementation: Ping-Pong Bottom-Up Merge Sort & Inversion Counter

#include <algorithm>
#include <concepts>
#include <cstddef>
#include <cstdint>
#include <vector>

// 1. Exact Inversion Counter in O(N log N) time using a single pre-allocated buffer
template <std::totally_ordered T>
std::uint64_t count_inversions_rec(T* a, T* aux, std::size_t lo, std::size_t hi) noexcept {
    if (hi - lo <= 1) return 0; // Half-open interval [lo, hi)
    const std::size_t mid = lo + (hi - lo) / 2;

    std::uint64_t inv = 0;
    inv += count_inversions_rec(a, aux, lo, mid);
    inv += count_inversions_rec(a, aux, mid, hi);

    // Optimization: if already ordered across boundary, 0 split inversions!
    if (!(a[mid] < a[mid - 1])) return inv;

    std::size_t i = lo, j = mid, k = lo;
    while (i < mid && j < hi) {
        if (!(a[j] < a[i])) { // a[i] <= a[j]: preserves stability!
            aux[k++] = std::move(a[i++]);
        } else {
            aux[k++] = std::move(a[j++]);
            inv += (mid - i); // All remaining elements in [i, mid) are > a[j]
        }
    }
    while (i < mid) aux[k++] = std::move(a[i++]);
    while (j < hi)  aux[k++] = std::move(a[j++]);
    std::move(aux + lo, aux + hi, a + lo);
    return inv;
}

template <std::totally_ordered T>
std::uint64_t count_inversions(std::vector<T> a) {
    if (a.size() <= 1) return 0;
    std::vector<T> aux(a.size());
    return count_inversions_rec(a.data(), aux.data(), 0, a.size());
}

// 2. Ping-Pong Bottom-Up Merge Sort (Zero Copy-Back Overhead)
template <std::totally_ordered T>
void mergesort_bottom_up_pingpong(std::vector<T>& a) {
    const std::size_t n = a.size();
    if (n <= 1) return;

    std::vector<T> aux(n);
    T* src = a.data();
    T* dst = aux.data();

    for (std::size_t width = 1; width < n; width <<= 1) {
        for (std::size_t lo = 0; lo < n; lo += (width << 1)) {
            const std::size_t mid = std::min(lo + width, n);
            const std::size_t hi  = std::min(lo + (width << 1), n);
            std::size_t i = lo, j = mid, k = lo;
            while (i < mid && j < hi) {
                if (!(src[j] < src[i])) dst[k++] = std::move(src[i++]);
                else                    dst[k++] = std::move(src[j++]);
            }
            while (i < mid) dst[k++] = std::move(src[i++]);
            while (j < hi)  dst[k++] = std::move(src[j++]);
        }
        std::swap(src, dst); // Ping-pong buffers for next width pass!
    }
    if (src != a.data()) {
        std::move(src, src + n, a.data());
    }
}
64-Bit Integer Overflow in Inversion Counting & Equal-Key Ties

For $n = 100{,}000$, the maximum number of inversions is $\binom{10^5}{2} \approx 5 \times 10^9$, which overflows a signed or unsigned 32-bit integer ($2^{31}-1 \approx 2.14 \times 10^9$). Always accumulate inversion counts in uint64_t or int64_t. Furthermore, when $L[i] == R[j]$, you must advance $L[i]$ first (!(R[j] < L[i])): advancing $R[j]$ on equality falsely counts equal pairs as strict inversions!

Check yourself Question 1 of 4
Concept Check Counting Split Inversions During the Merge Step recall

While merging sorted left subarray $L[lo \dots mid-1]$ and sorted right subarray $R[mid \dots hi-1]$, the merge pointers are at $i \in [lo, mid)$ and $j \in [mid, hi)$ and you observe $R[j] < L[i]$.

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

Analyze the invariant, asymptotic bound, and hardware behavior before selecting.

How does Ping-Pong Merge Sort eliminate the `std::copy` pass, and what subtlety arises in Top-Down vs Bottom-Up implementations when $\lceil \log_2 n \rceil$ is odd?

Key Takeaways

Merge Sort guarantees worst-case Θ(N log N) comparisons and preserves stability by taking from the left subarray whenever L[i] == R[j].
During a merge of sorted halves L and R, choosing R[j] < L[i] reveals (mid - i) split inversions simultaneously, enabling exact inversion counting in Θ(N log N) time.
Pre-allocating a single scratch buffer and alternating source/destination pointers (ping-pong merging) eliminates the 2x copy-back memory traffic per pass.
Checking A[mid] <= A[mid + 1] before merging makes Top-Down Merge Sort run in O(N) time on already-sorted inputs.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue