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:
- 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. - Stage 2 — Stability-Safe Run Scanner (
count_run_and_make_ascending): Scans fromlo: 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). - 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_lento extend the run to $\min(n - lo, \text{minrun})$. - 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.
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):
- If $s \ge 3$: $L[s-3] > L[s-2] + L[s-1]$
- If $s \ge 4$: $L[s-4] > L[s-3] + L[s-2]$ (the De Gouw fix checking one level deeper!)
- 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.
Explore TimSort natural run detection, Fibonacci-bounded run stack invariants (|Z| > |Y|+|X| ∧ |Y| > |X|), exponential galloping merge, and Rust pdqsort branchless offsets.
Invariant & State
| 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 |
minrun ExtensionWhen 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--]);
}
}
};
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!