クラムソートを使用する

クラムソート (crumsort) はフラックスソートと同じく、ピボットで分割して再帰するトップダウン方式のハイブリッド比較ソートである。違いは分割が不安定かつインプレースな点で、補助配列への二重書き込みの代わりにフルクラム(支点)分割を使う。

整列度が高い区間や小区間・不均衡時はクワッドソート(マージソート系)へ切り替える。本番実装は分岐の少ない比較や固定長の小さなスワップ領域(既定で数百要素)を用いるが、本記事の実装は説明用に簡略化している。小区間・フォールバックは挿入ソートマージソートで代用し、ピボットは9点の準中央値のみとする。

  1. アナライザ: 全体が昇順なら何もしない。降順(同値を含む非増加)なら反転して終了する(比較・移動とも O(n))。配列を 4 分割し、各区間の隣接昇順ペアが半数超ならその区間をマージソートで仕上げる(本番ではクワッドソート)。
  2. ピボット選択: 区間をほぼ等間隔に 9 点取り、3 組の三点中央値の中央値(準中央値)をピボットにする。
  3. フルクラム分割: ピボット値を 1 要素ぶんの退避スロットに置き、先頭側(head)と末尾側(tail)から走査する。≤ ピボット は前方へ、> ピボット は後方へ 2 代入で書き分ける(通常の 3 代入スワップより軽い)。同値の相対順序は保たない。
  4. 等値の第二走査: 右側が空(すべて ≤ ピボット)なら、< ピボット だけを前方へ寄せて等値帯を再帰から外す。重複の多い入力向けの対策である。
  5. 不均衡フォールバック: 左右の長さ比が 1:16 を超えて偏ったら、両側をマージソートする(本番ではクワッドソート)。最悪計算量を O(n log n) に抑えるためのガードである。
  6. 小区間: 要素数が閾値未満なら挿入ソートで仕上げる(本番の閾値付近ではクワッドソートの小区間ルーチン)。
procedure crum_fulcrum_partition(A, pivot)
  // A[0] にピボットを置き、値はローカルへ退避(1 要素の swap 領域)
  move pivot into A[0]; pivot_val := A[0]
  head := 0; tail := length(A) - 1
  loop
    while head < tail and A[tail] > pivot_val
      tail := tail - 1
    if head ≥ tail then
      A[head] := pivot_val; return head
    A[head] := A[tail]; head := head + 1
    while head < tail and A[head] ≤ pivot_val
      head := head + 1
    if head ≥ tail then
      A[head] := pivot_val; return head
    A[tail] := A[head]; tail := tail - 1

procedure crum_sort_range(A)
  if length(A) < INSERTION_THRESHOLD then
    insertion_sort(A); return
  pivot := quasimedian_of_9(A)
  mid := crum_fulcrum_partition(A, pivot)
  left := mid; right := length(A) - mid - 1
  if right = 0 then
    gather keys < pivot to front
    crum_sort_range(A[0 .. lt))
    return
  if left < length(A)/16 or right < length(A)/16 then
    merge_sort(A[0 .. left)); merge_sort(A[mid+1 .. end)); return
  crum_sort_range(A[0 .. left))
  crum_sort_range(A[mid+1 .. end))

procedure crumsort(A)
  if A is sorted then return
  if A is reverse-sorted then reverse(A); return
  for each quarter Q of A
    if ordered_pairs(Q) > half then merge_sort(Q)
  if A is sorted then return
  crum_sort_range(A)

最良は整列済み検出により O(n)、平均・最悪は O(n log n) である。分割そのものはインプレースで、本番の補助メモリは小さな固定領域に抑えられる(不安定ソート)。説明用実装ではマージフォールバック用に最大 O(n) の作業領域を使う。

以下のデモでは視認性のため挿入閾値を 4、不均衡判定を 1/4 に緩めている。

類似アルゴリズムとの相違点

フラックスソートは安定な二重書き込み分割と最大 O(n) の補助配列を使う。クラムソートはフルクラム分割で不安定・インプレース寄りにし、ランダム入力では大規模でフラックスソートを追い抜きやすい一方、既存の並びパターンを崩しやすい。

クワッドソートは分割を行わず、ボトムアップのクワッドマージだけで完結する。クラムソートはランダム寄りの入力ではフルクラム分割を主とし、整列度が高い区間・小区間・不均衡時だけクワッドソート系へ寄せる。

ホア分割型クイックソートも両端から詰めるインプレース分割だが、フルクラムはピボットを 1 要素の退避スロットに置き、3 代入のスワップを 2 代入の書き分けに置き換える。

パターン撃退型クイックソートも悪パターン対策のハイブリッドで不安定・低補助メモリである。クラムソートは先頭アナライザと不均衡時のマージ切替、等値の第二走査を組み合わせる点が近い。

計算時間量および空間計算量を計測する

Size Average time Maximum time Average memory Maximum memory
256 0.000008 0.000591 2 2
512 0.000017 0.000188 4 4
1024 0.000035 0.000094 8 8
2048 0.000076 0.000253 16 16
4096 0.000163 0.000412 32 32
8192 0.000348 0.000469 64 64
16384 0.000747 0.002182 128 128
32768 0.001617 0.003149 256 256
65536 0.003465 0.008982 512 512
131072 0.007202 0.009840 1024 1024
262144 0.015271 0.020022 2048 2048
計測に使用したコードを表示する

set -euo pipefail

WORKDIR="$(mktemp -d)"
trap 'rm -rf "$WORKDIR"' EXIT

cat > "$WORKDIR/Dockerfile" <<'EOF'
FROM rust:1.95.0

WORKDIR /app

RUN mkdir -p src

RUN cat > Cargo.toml <<'CARGO'
[package]
name = "rust-benchmark"
version = "0.1.0"
edition = "2021"

[profile.release]
lto = true
codegen-units = 1
panic = "abort"
CARGO

RUN cat > src/main.rs <<'RUST'
use std::{
    alloc::{GlobalAlloc, Layout, System},
    env,
    process::Command,
    sync::atomic::{AtomicUsize, Ordering},
    time::{Duration, Instant},
};

/// Counts live heap bytes and the high-water mark so auxiliary sort buffers
/// (swap Vecs, etc.) are measured as explicit heap growth during the sort.
struct TrackingAllocator;

static LIVE_BYTES: AtomicUsize = AtomicUsize::new(0);
static PEAK_BYTES: AtomicUsize = AtomicUsize::new(0);

fn record_alloc(size: usize) {
    let live = LIVE_BYTES.fetch_add(size, Ordering::Relaxed) + size;
    PEAK_BYTES.fetch_max(live, Ordering::Relaxed);
}

unsafe impl GlobalAlloc for TrackingAllocator {
    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
        let ptr = System.alloc(layout);
        if !ptr.is_null() {
            record_alloc(layout.size());
        }
        ptr
    }

    unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
        LIVE_BYTES.fetch_sub(layout.size(), Ordering::Relaxed);
        System.dealloc(ptr, layout);
    }

    unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
        let ptr = System.alloc_zeroed(layout);
        if !ptr.is_null() {
            record_alloc(layout.size());
        }
        ptr
    }

    unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
        let new_ptr = System.realloc(ptr, layout, new_size);
        if !new_ptr.is_null() {
            LIVE_BYTES.fetch_sub(layout.size(), Ordering::Relaxed);
            record_alloc(new_size);
        }
        new_ptr
    }
}

#[global_allocator]
static GLOBAL: TrackingAllocator = TrackingAllocator;
const MIN_POWER: u32 = 8;
const MAX_POWER: u32 = 18;
const RUNS: usize = 8192;
fn insertion_sort(a: &mut [usize]) {
    for i in 1..a.len() {
        let mut j = i;
        while j > 0 && a[j - 1] > a[j] {
            a.swap(j - 1, j);
            j -= 1;
        }
    }
}



const CRUM_INSERTION_THRESHOLD: usize = 24;

fn crum_is_sorted(a: &[usize]) -> bool {
    a.windows(2).all(|w| w[0] <= w[1])
}

fn crum_is_reverse_sorted(a: &[usize]) -> bool {
    a.windows(2).all(|w| w[0] >= w[1])
}

fn crum_reverse(a: &mut [usize]) {
    let mut lo = 0;
    let mut hi = a.len();
    while lo + 1 < hi {
        hi -= 1;
        a.swap(lo, hi);
        lo += 1;
    }
}

/// Count ascending adjacent pairs (presortedness measure).
fn crum_ordered_pairs(a: &[usize]) -> usize {
    a.windows(2).filter(|w| w[0] <= w[1]).count()
}

fn crum_median3_idx(a: &[usize], i: usize, j: usize, k: usize) -> usize {
    let (x, y, z) = (a[i], a[j], a[k]);
    if x < y {
        if y < z {
            j
        } else if x < z {
            k
        } else {
            i
        }
    } else if x < z {
        i
    } else if y < z {
        k
    } else {
        j
    }
}

/// Quasimedian of 9: median of three medians-of-three sampled across the range.
fn crum_quasimedian9(a: &[usize]) -> usize {
    let n = a.len();
    if n < 9 {
        return a[n / 2];
    }
    let step = n / 8;
    let i0 = 0;
    let i1 = step;
    let i2 = step * 2;
    let i3 = step * 3;
    let i4 = step * 4;
    let i5 = step * 5;
    let i6 = step * 6;
    let i7 = step * 7;
    let i8 = n - 1;
    let m0 = crum_median3_idx(a, i0, i1, i2);
    let m1 = crum_median3_idx(a, i3, i4, i5);
    let m2 = crum_median3_idx(a, i6, i7, i8);
    a[crum_median3_idx(a, m0, m1, m2)]
}

fn crum_merge(a: &mut [usize], swap: &mut [usize]) {
    let n = a.len();
    if n <= 1 {
        return;
    }
    let mid = n / 2;
    crum_merge(&mut a[..mid], swap);
    crum_merge(&mut a[mid..], swap);
    let (left, right) = a.split_at(mid);
    let mut i = 0;
    let mut j = 0;
    let mut k = 0;
    while i < left.len() && j < right.len() {
        if left[i] <= right[j] {
            swap[k] = left[i];
            i += 1;
        } else {
            swap[k] = right[j];
            j += 1;
        }
        k += 1;
    }
    while i < left.len() {
        swap[k] = left[i];
        i += 1;
        k += 1;
    }
    while j < right.len() {
        swap[k] = right[j];
        j += 1;
        k += 1;
    }
    a.copy_from_slice(&swap[..n]);
}

/// Fulcrum partition: hold the pivot value in a one-element swap slot and walk
/// head/tail with two assignments per move (instead of a three-way swap).
/// Places a chosen `pivot` value at the split. Unstable. Returns its index.
fn crum_fulcrum_partition(a: &mut [usize], pivot: usize) -> usize {
    let n = a.len();
    debug_assert!(n >= 2);

    let mut pivot_idx = 0usize;
    for i in 0..n {
        if a[i] == pivot {
            pivot_idx = i;
            break;
        }
    }
    a.swap(0, pivot_idx);

    let pivot_val = a[0];
    let mut head = 0usize;
    let mut tail = n - 1;

    loop {
        while head < tail && a[tail] > pivot_val {
            tail -= 1;
        }
        if head >= tail {
            a[head] = pivot_val;
            return head;
        }
        a[head] = a[tail];
        head += 1;

        while head < tail && a[head] <= pivot_val {
            head += 1;
        }
        if head >= tail {
            a[head] = pivot_val;
            return head;
        }
        a[tail] = a[head];
        tail -= 1;
    }
}

fn crum_partition_sort(a: &mut [usize], swap: &mut [usize]) {
    let n = a.len();
    if n <= 1 {
        return;
    }
    if n < CRUM_INSERTION_THRESHOLD {
        insertion_sort(a);
        return;
    }

    let pivot = crum_quasimedian9(a);
    let mid = crum_fulcrum_partition(a, pivot);
    let left_len = mid;
    let right_len = n - mid - 1;

    // All keys ≤ pivot: filter equals out so recursion makes progress
    // (crumsort’s reverse / second sweep for generic / low-cardinality data).
    if right_len == 0 {
        let mut lt = 0usize;
        for i in 0..n {
            if a[i] < pivot {
                a.swap(lt, i);
                lt += 1;
            }
        }
        if lt > 1 {
            crum_partition_sort(&mut a[..lt], swap);
        }
        return;
    }

    // Worst-case guard: one side < 1/16 of the other → mergesort both sides.
    let unbalanced = (left_len > 0 && left_len < n / 16)
        || (right_len > 0 && right_len < n / 16);

    if unbalanced {
        if left_len > 1 {
            crum_merge(&mut a[..left_len], swap);
        }
        if right_len > 1 {
            crum_merge(&mut a[mid + 1..], swap);
        }
        return;
    }

    if left_len > 1 {
        crum_partition_sort(&mut a[..left_len], swap);
    }
    if right_len > 1 {
        crum_partition_sort(&mut a[mid + 1..], swap);
    }
}

fn crum_analyze(a: &mut [usize], swap: &mut [usize]) -> bool {
    let n = a.len();
    if n <= 1 {
        return true;
    }
    if crum_is_sorted(a) {
        return true;
    }
    if crum_is_reverse_sorted(a) {
        crum_reverse(a);
        return true;
    }

    // Four-segment presortedness: if more than half the adjacent pairs in a
    // segment are ordered, finish that segment with mergesort (stand-in for
    // quadsort). Remaining disorder is handled by partitioning afterward.
    let q = n / 4;
    if q >= 2 {
        let bounds = [0, q, q * 2, q * 3, n];
        for s in 0..4 {
            let lo = bounds[s];
            let hi = bounds[s + 1];
            if hi - lo < 2 {
                continue;
            }
            let pairs = hi - lo - 1;
            if crum_ordered_pairs(&a[lo..hi]) * 2 > pairs {
                crum_merge(&mut a[lo..hi], swap);
            }
        }
        if crum_is_sorted(a) {
            return true;
        }
    }
    false
}

fn crum_sort(a: &mut [usize]) {
    let n = a.len();
    if n <= 1 {
        return;
    }
    // Educational stand-in: merge fallback / analyzer share an O(n) buffer.
    // Production crumsort keeps a small fixed swap (≈512) with quadsort.
    let mut swap = vec![0usize; n];
    if crum_analyze(a, &mut swap) {
        return;
    }
    crum_partition_sort(a, &mut swap);
}


fn benchmark_sort(array: &mut [usize]) {

    crum_sort(array);

}

fn is_non_decreasing(a: &[usize]) -> bool {
    a.windows(2).all(|w| w[0] <= w[1])
}

fn same_multiset(a: &[usize], b: &[usize]) -> bool {
    if a.len() != b.len() {
        return false;
    }

    let mut left = a.to_vec();
    let mut right = b.to_vec();
    left.sort_unstable();
    right.sort_unstable();
    left == right
}

fn check_correctness_case(label: &str, mut input: Vec<usize>) {
    let original = input.clone();

    benchmark_sort(&mut input);

    if !is_non_decreasing(&input) {
        panic!("correctness case {}: output is not sorted", label);
    }

    if !same_multiset(&input, &original) {
        panic!("correctness case {}: elements were lost or added", label);
    }
}

fn few_unique_values(size: usize, unique: usize, seed: u64) -> Vec<usize> {
    let mut state = seed;

    (0..size)
        .map(|_| {
            state ^= state << 13;
            state ^= state >> 7;
            state ^= state << 17;
            (state as usize % unique) + 1
        })
        .collect()
}

fn run_correctness_checks() {
    check_correctness_case("empty", vec![]);
    check_correctness_case("single", vec![42]);
    check_correctness_case("duplicates", vec![3, 1, 3, 2, 1, 2]);
    check_correctness_case("sorted", vec![1, 2, 3, 4, 5]);
    check_correctness_case("reverse", vec![5, 4, 3, 2, 1]);
    check_correctness_case("all_equal", vec![7, 7, 7, 7]);
    check_correctness_case("skewed_range", vec![1_000_000, 2, 1_000_001, 1, 999_999]);
    // Static-buffer Grail skips the in-buffer build when key collection is sparse
    // (ideal_buffer = false). Exercising that path catches regressions in buffer gating.
    check_correctness_case(
        "few_keys_len16",
        vec![2, 2, 2, 2, 2, 2, 2, 2, 4, 3, 1, 2, 3, 4, 1, 4],
    );
    // Seed 0 is a fixed point of the xorshift below, so it would degenerate into
    // yet another all-equal case instead of a 4-value mix. Start at 1.
    for seed in 1..=32 {
        check_correctness_case(
            &format!("few_keys_len32_seed_{seed}"),
            few_unique_values(32, 4, seed),
        );
    }
}


fn shuffled(size: usize, seed: u64) -> Vec<usize> {
    let mut v: Vec<usize> = (1..=size).collect();

    let mut state = seed;

    for i in (1..size).rev() {
        state ^= state << 13;
        state ^= state >> 7;
        state ^= state << 17;

        let j = (state as usize) % (i + 1);

        v.swap(i, j);
    }

    v
}

fn micros(d: Duration) -> u128 {
    d.as_micros()
}

fn input_array(size: usize, seed: u64) -> Vec<usize> {
    shuffled(size, seed)
}

/// Peak heap growth during `benchmark_sort`, in KiB (explicit buffers such as swap).
fn run_once(size: usize, seed: usize) -> (u128, usize) {
    let mut array = input_array(size, seed as u64);

    let base_bytes = LIVE_BYTES.load(Ordering::Relaxed);
    PEAK_BYTES.store(base_bytes, Ordering::Relaxed);

    let start = Instant::now();

    benchmark_sort(&mut array);

    let elapsed = start.elapsed();
    let peak_bytes = PEAK_BYTES.load(Ordering::Relaxed);
    let aux_kb = peak_bytes.saturating_sub(base_bytes) / 1024;

    let expected: Vec<usize> = (1..=size).collect();
    if array != expected {
        panic!(
            "sort failed with seed {} for size {}",
            seed,
            size
        );
    }

    (micros(elapsed), aux_kb)
}

fn run_child(args: &[String]) {
    let size = args[2].parse::<usize>().expect("invalid size");
    let seed = args[3].parse::<usize>().expect("invalid seed");
    let (elapsed_us, mem) = run_once(size, seed);
    println!("{} {}", elapsed_us, mem);
}

fn main() {
    let args: Vec<String> = env::args().collect();
    if args.get(1).is_some_and(|arg| arg == "--run-once") {
        run_child(&args);
        return;
    }

    run_correctness_checks();

    println!(
        "| {:>10} | {:>15} | {:>15} | {:>15} | {:>15} |",
        "Size",
        "Average time",
        "Maximum time",
        "Average memory",
        "Maximum memory"
    );

    println!(
        "|{:-<11}:|{:-<16}:|{:-<16}:|{:-<16}:|{:-<16}:|",
        "",
        "",
        "",
        "",
        ""
    );

    for power in MIN_POWER..=MAX_POWER {
        let size = 1usize << power;

        let mut total_time: u128 = 0;
        let mut max_time: u128 = 0;

        let mut total_mem: usize = 0;
        let mut max_mem: usize = 0;

        for seed in 1..=RUNS {
            let output = Command::new(env::current_exe().expect("failed to find current executable"))
                .arg("--run-once")
                .arg(size.to_string())
                .arg(seed.to_string())
                .output()
                .expect("failed to run benchmark child process");

            if !output.status.success() {
                panic!(
                    "benchmark child process failed: {}",
                    String::from_utf8_lossy(&output.stderr)
                );
            }

            let stdout = String::from_utf8(output.stdout)
                .expect("child process returned non-UTF-8 output");
            let mut fields = stdout.split_whitespace();
            let elapsed_us = fields
                .next()
                .expect("missing elapsed time")
                .parse::<u128>()
                .expect("invalid elapsed time");
            let aux_mem = fields
                .next()
                .expect("missing memory usage")
                .parse::<usize>()
                .expect("invalid memory usage");

            total_time += elapsed_us;

            if elapsed_us > max_time {
                max_time = elapsed_us;
            }

            total_mem += aux_mem;

            if aux_mem > max_mem {
                max_mem = aux_mem;
            }
        }

        let avg_time = total_time / RUNS as u128;
        let avg_mem = total_mem / RUNS;

        println!(
            "| {:>10} | {:>15} | {:>15} | {:>15} | {:>15} |",
            size,
            format!("{}.{:06}", avg_time / 1_000_000, avg_time % 1_000_000),
            format!("{}.{:06}", max_time / 1_000_000, max_time % 1_000_000),
            avg_mem,
            max_mem
        );
    }
}
RUST

RUN cargo build --release

CMD ["./target/release/rust-benchmark"]
EOF

docker build -t rust-benchmark "$WORKDIR"
docker run --rm --init rust-benchmark