DSA DSA In Depth
0%
build build-labtimsortsorting

Build Lab: Hybrid Adaptive Sort (Run Detection + Insertion + Galloping Merge)

7 min read Module 2.2: Sorting Mechanics, Hybrid Sorts & Linear-Time Order Statistics Updated Oct 23, 2026
📄 Cheat Sheet
TimSort Explained and Implemented — Adaptive Runs, Insertion Sort & Galloping Merge
Geekific TimSort Explained and Implemented — Adaptive Runs, Insertion Sort & Galloping Merge Watch on YouTube ↗

Illustrates how TimSort detects natural monotonic runs, extends short runs via binary insertion sort, and merges run stacks to beat pure MergeSort.

0:00 / --:--

Lab Architecture & Design Specification

Textbook sorting algorithms optimize for a single regime: Insertion Sort wins for $N \le 32$, Merge Sort guarantees stability and $\Theta(N \log N)$ worst-case comparisons, and Exponential Galloping wins when merging clustered runs. In this lab (corresponding to the interactive workbench build-hybrid-introsort-timsort), we engineer a self-contained C++20 HybridAdaptiveSort engine that unifies all four stages:

  1. Stage 1 — Dynamic compute_minrun(n): Shifts $n$ right until $n \in [32, 64)$, setting the lowest bit to $1$ if any shifted-out bit was $1$, so $\lceil N / \text{minrun} \rceil$ is close to or slightly below a power of two.
  2. Stage 2 — Stability-Safe Run Scanner (count_run_and_make_ascending): Scans from lo: if $A[lo+1] < A[lo]$, continues scanning while $A[i] < A[i-1]$ (strictly descending) and reverses the slice in-place; otherwise scans while $A[i] \ge A[i-1]$ (non-decreasing).
  3. Stage 3 — Binary Insertion Extension: If the natural run length is $< \text{minrun}$, uses Binary Insertion Sort starting at the end of the already-sorted prefix lo + run_len to extend the run to $\min(n - lo, \text{minrun})$.
  4. Stage 4 — Smaller-Half Galloping Merge & 4-Condition Stack Collapse: Maintains the corrected 4-condition TimSort invariant (De Gouw et al. fix) and merges adjacent runs by copying only the smaller run into a reusable scratch buffer.
The Corrected 4-Condition Run Stack Invariant

Let the run stack hold $s$ runs with lengths $L[0], L[1], \dots, L[s-1]$ from bottom to top. After pushing a new run, we repeatedly merge until all three of the following conditions hold (preventing the 2015 TimSort bug):

  1. If $s \ge 3$: $L[s-3] > L[s-2] + L[s-1]$
  2. If $s \ge 4$: $L[s-4] > L[s-3] + L[s-2]$ (the De Gouw fix checking one level deeper!)
  3. If $s \ge 2$: $L[s-2] > L[s-1]$

Whenever any condition fails, we merge run $s-2$ with whichever of $s-3$ or $s-1$ is shorter.

TimSort Natural Run Stack (A > B+C), Galloping & pdqsort BlockQuicksort
O(N) Adaptive · O(N log N) Worst

Explore TimSort natural run detection, Fibonacci-bounded run stack invariants (|Z| > |Y|+|X| ∧ |Y| > |X|), exponential galloping merge, and Rust pdqsort branchless offsets.

Step 1 / 7
Custom Input:
Timeline

Invariant & State

TimSort Stack Invariant: For top 3 runs Z, Y, X: (1) |Z| > |Y| + |X| and (2) |Y| > |X|. This forces run lengths to grow at least as fast as Fibonacci numbers (stack height ≤ 85 for 2⁶⁴).
🔗 Live Pointer & State Transition Graph — TimSort Natural Run Stack (A > B+C), Galloping & pdqsort BlockQuicksort Active Pointer Jump/Transition Matched/Optimal
|Z|=64 Run Z… [0] |Y|=32 Run Y… [1] |Y| ≤ | Run X… [2] Fibonac Merge… [3]
[0]Run Z: len=64|Z|=64
[1]Run Y: len=32|Y|=32
[2]Run X: len=40 (Violates |Y| > |X|!)|Y| ≤ |X| Violation!
[3]Merge(Y, Z) or Merge(Y, X) ⇒ Balanced!Fibonacci Stack Bound
MinRun Length
32..64 (chosen so N/minrun ≈ 2^k)
Gallop Threshold
MIN_GALLOP = 7 consecutive wins
Adaptive Best Case
O(N) comparisons
[Step 1] Detected |Y|=32 ≤ |X|=40 violation on run stack; merged Y with smaller neighbor to restore Fibonacci invariant.
Input Distribution Comparisons Element Moves Scratch Memory Used
Already Ascending ($I(A) = 0$) $N - 1$ $0$ $0$ bytes
Strictly Descending $N - 1$ $N$ (single in-place reversal) $0$ bytes
$k$ Pre-Sorted Runs Appended $O(N \log k)$ (gallop trimmed) $O(N \log k)$ $\le N / 2$ elements
Uniform Random Permutation $\approx N \log_2 N$ $\approx 1.5 N \log_2 N$ $\le N / 2$ elements
Boundary-Trimmed Smaller-Half Merge: Trim Already-In-Place Ends Before Copying A_prefix ≤ B[0] Active Run A' (Copy to tmp) Active Run B' (Merge in-place) B_suffix ≥ A[last] 1. Trim Prefix: gallop_right(B[0], A) skips all leading elements of A already ≤ B[0] (0 moves!) 2. Trim Suffix: gallop_left(A'[last], B) skips all trailing elements of B already ≥ A'[last] (0 moves!) 3. Copy ONLY min(|A'|, |B'|) into scratch buffer and merge into the remaining window!
flowchart LR A["Scan Natural Run Reverse if strictly <"] --> B["Extend to minrun via Binary Insertion"] B --> C["Push Run [base, len] to Run Stack"] C --> D{"4-Condition Stack Invariant Holds?"} D -- "No" --> E["Trim Ends via Gallop Merge Smaller Run via tmp"] E --> D D -- "Yes" --> F["Advance to Next Run"]
Hardware Reality: Why Binary Insertion Sort Beats Linear Insertion Sort for minrun Extension

When extending a natural run of length $r$ up to $\text{minrun} \in [32, 64]$, the first $r$ elements are already sorted. Using binary search to locate the insertion point for element $i$ takes at most $\lceil \log_2 64 \rceil = 6$ comparisons (instead of up to 64 in linear Insertion Sort)—a crucial win when comparing composite structs, strings, or user-defined comparator closures—while moving the suffix via std::move_backward compiles to a single vectorized memmove over L1-resident cache lines.

Complete From-Scratch C++20 Implementation: HybridAdaptiveSort

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

template <std::totally_ordered T>
class HybridAdaptiveSort {
public:
    static void sort(std::vector<T>& a) {
        const std::size_t n = a.size();
        if (n <= 1) return;
        if (n < 32) {
            binary_insertion_sort(a.data(), 0, n, 1);
            return;
        }

        const std::size_t minrun = compute_minrun(n);
        std::vector<Run> stack;
        stack.reserve(48);
        std::vector<T> tmp;

        std::size_t lo = 0;
        while (lo < n) {
            std::size_t run_len = count_run_and_make_ascending(a.data(), lo, n);
            if (run_len < minrun) {
                const std::size_t force = std::min(minrun, n - lo);
                binary_insertion_sort(a.data(), lo, lo + force, lo + run_len);
                run_len = force;
            }
            stack.push_back({lo, run_len});
            lo += run_len;
            merge_collapse(a.data(), stack, tmp);
        }
        while (stack.size() > 1) {
            std::size_t k = stack.size() - 2;
            if (k > 0 && stack[k - 1].len < stack[k + 1].len) --k;
            merge_at(a.data(), stack, k, tmp);
        }
    }

private:
    struct Run { std::size_t base, len; };

    static constexpr std::size_t compute_minrun(std::size_t n) noexcept {
        std::size_t r = 0;
        while (n >= 64) {
            r |= (n & 1U);
            n >>= 1U;
        }
        return n + r;
    }

    static std::size_t count_run_and_make_ascending(T* a, std::size_t lo, std::size_t hi) noexcept {
        if (lo + 1 == hi) return 1;
        std::size_t run_hi = lo + 2;
        if (a[lo + 1] < a[lo]) { // Strictly descending: safe to reverse without breaking stability
            while (run_hi < hi && a[run_hi] < a[run_hi - 1]) ++run_hi;
            std::reverse(a + lo, a + run_hi);
        } else { // Non-decreasing: !(a[i] < a[i-1])
            while (run_hi < hi && !(a[run_hi] < a[run_hi - 1])) ++run_hi;
        }
        return run_hi - lo;
    }

    static void binary_insertion_sort(T* a, std::size_t lo, std::size_t hi, std::size_t start) noexcept {
        if (start == lo) ++start;
        for (std::size_t i = start; i < hi; ++i) {
            T pivot = std::move(a[i]);
            std::size_t left = lo, right = i;
            while (left < right) {
                const std::size_t mid = left + ((right - left) >> 1);
                if (pivot < a[mid]) right = mid;
                else                left = mid + 1;
            }
            std::move_backward(a + left, a + i, a + i + 1);
            a[left] = std::move(pivot);
        }
    }

    // Corrected 4-condition TimSort invariant (De Gouw et al., 2015)
    static void merge_collapse(T* a, std::vector<Run>& s, std::vector<T>& tmp) {
        while (s.size() > 1) {
            std::size_t n = s.size() - 2;
            if ((n > 0 && s[n - 1].len <= s[n].len + s[n + 1].len) ||
                (n > 1 && s[n - 2].len <= s[n - 1].len + s[n].len)) {
                if (s[n - 1].len < s[n + 1].len) --n;
                merge_at(a, s, n, tmp);
            } else if (s[n].len <= s[n + 1].len) {
                merge_at(a, s, n, tmp);
            } else {
                break;
            }
        }
    }

    static void merge_at(T* a, std::vector<Run>& s, std::size_t k, std::vector<T>& tmp) {
        std::size_t base1 = s[k].base, len1 = s[k].len;
        std::size_t base2 = s[k + 1].base, len2 = s[k + 1].len;
        s[k].len = len1 + len2;
        s.erase(s.begin() + static_cast<std::ptrdiff_t>(k + 1));

        // Trim prefix of Run 1 already <= a[base2]
        const T* p1 = a + base1;
        const T* p1_end = std::upper_bound(p1, p1 + len1, a[base2]);
        const std::size_t skip_left = static_cast<std::size_t>(p1_end - p1);
        base1 += skip_left;
        len1 -= skip_left;
        if (len1 == 0) return;

        // Trim suffix of Run 2 already >= a[base1 + len1 - 1]
        const T* p2 = a + base2;
        const T* p2_end = std::lower_bound(p2, p2 + len2, a[base1 + len1 - 1]);
        len2 = static_cast<std::size_t>(p2_end - p2);
        if (len2 == 0) return;

        // Smaller-half copy merge
        if (len1 <= len2) {
            tmp.assign(std::make_move_iterator(a + base1), std::make_move_iterator(a + base1 + len1));
            std::size_t i = 0, j = base2, dst = base1;
            while (i < len1 && j < base2 + len2) {
                if (a[j] < tmp[i]) a[dst++] = std::move(a[j++]);
                else               a[dst++] = std::move(tmp[i++]);
            }
            while (i < len1) a[dst++] = std::move(tmp[i++]);
        } else {
            tmp.assign(std::make_move_iterator(a + base2), std::make_move_iterator(a + base2 + len2));
            std::ptrdiff_t i = static_cast<std::ptrdiff_t>(base1 + len1) - 1;
            std::ptrdiff_t j = static_cast<std::ptrdiff_t>(len2) - 1;
            std::ptrdiff_t dst = static_cast<std::ptrdiff_t>(base2 + len2) - 1;
            while (i >= static_cast<std::ptrdiff_t>(base1) && j >= 0) {
                if (tmp[j] < a[i]) a[dst--] = std::move(a[i--]);
                else               a[dst--] = std::move(tmp[j--]);
            }
            while (j >= 0) a[dst--] = std::move(tmp[j--]);
        }
    }
};
Right-to-Left Merge Tie-Breaking Must Remain Stable!

Look closely at the right-to-left merge branch when len2 < len1: we compare if (tmp[j] < a[i]) and move a[i--] only when a[i] is strictly greater than tmp[j]! If a[i] == tmp[j], the element from Run 2 (tmp[j]) originated to the right of Run 1, so when filling from the back (dst--), tmp[j] must be placed at the rightmost slot first to preserve relative stability!

Check yourself Question 1 of 4
Concept Check Boundary Trimming Before Allocating the Temporary Merge Buffer recall

In `HybridAdaptiveSort::merge_at`, Run 1 is `A[base1 .. base1 + len1 - 1]` and Run 2 is `A[base2 .. base2 + len2 - 1]`. Before copying anything to `tmp`, we find `skip_left = upper_bound(Run1, A[base2])` and `len2 = lower_bound(Run2, A[base1 + len1 - 1])`.

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

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

What is the tightest upper bound on the capacity of `tmp` across the entire sort, and when is `tmp` never allocated at all ($0$ bytes)?

Key Takeaways

compute_minrun(n) picks a minimum run size in [32, 64] such that ceil(n / minrun) is a power of two (or slightly less), preventing unbalanced final merges.
Binary Insertion Sort extends natural runs shorter than minrun using at most 6 binary-search comparisons per element plus a contiguous move_backward shift.
Trimming already-ordered prefixes and suffixes of adjacent runs before allocating scratch memory reduces sorted-input overhead to zero copies.
When merging right-to-left (len2 < len1), equal elements must take from the right run (tmp[j]) first so their original left-to-right relative order is preserved.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue