カスケードマージソートを使用する

カスケードマージソート (cascade merge sort) は、配列の一部を作業領域として使いながら交換ベースのマージを繰り返すインプレース・マージソートである。

整列済み部分列と作業領域の内容を swap で入れ替えながらマージし、作業領域の幅を 1/2 → 1/4 → 1/8 … と段階的に狭めていく(カスケードする)点が名前の由来である。

通常のマージソートが O(n) の補助配列を要するのに対し、追加配列を使わず配列内の未整列区間を作業領域として再利用する。最後の数要素は挿入ソートで仕上げる。

  1. 初回分割: 区間 [l, u) を半分に分け、前半 [l, m) を再帰整列して結果を後半側の作業領域 [w, u)w = l + u - m)へ wsort で書き出す。
  2. カスケード併合: 作業領域 [l, w) に残った未整列部分をさらに半分ずつ整列し、整列済みの右半 [w, u)wmerge で併合する。w を中央付近へ更新し、作業領域を狭めながら繰り返す。
  3. 交換マージ: 2 つの昇順部分列 [i, m)[j, n) について、先頭同士を比較し、小さい方と作業位置 w の要素を交換して確定させる。
  4. 挿入ソート仕上げ: 作業領域が 2 要素以下になったら、残りの未整列 prefix を右方向へ交換しながら昇順 suffix へ挿入する。
procedure wmerge(A, i, m, j, n, w)
  while i < m and j < n
    if A[i] <= A[j] then
      swap(A[w], A[i]); w = w + 1; i = i + 1
    else
      swap(A[w], A[j]); w = w + 1; j = j + 1
  copy rest of [i, m) or [j, n) into A[w ..) via swap

procedure wsort(A, l, u, w)
  if u - l > 1 then
    m = floor((l + u) / 2)
    imsort(A, l, m)
    imsort(A, m, u)
    wmerge(A, l, m, m, u, w)
  else
    move A[l] into working slot A[w] by swap

procedure imsort(A, l, u)
  if u - l <= 1 then return
  m = floor((l + u) / 2)
  w = l + u - m
  wsort(A, l, m, w)
  while w - l > 2
    n = w
    w = l + floor((n - l + 1) / 2)
    wsort(A, w, n, l)
    wmerge(A, l, l + n - w, n, u, w)
  for n from w down to l + 1
    bubble A[n - 1] right into sorted suffix [n, u)

O(n) 補助配列なしのインプレース・マージだが、交換コストの定数因子が大きくなりやすい。

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

マージソートO(n) の補助配列でマージする。カスケードは配列内の未整列区間を作業領域に使い、交換で併合するインプレース版である。外部記憶向けの多段テープ併合とは目的が異なる。

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

Size Average time Maximum time Average memory Maximum memory
256 0.000011 0.000198 66 72
512 0.000025 0.000219 58 64
1024 0.000057 0.000121 58 64
2048 0.000128 0.000613 62 68
4096 0.000305 0.000769 70 76
8192 0.000589 0.002248 65 72
16384 0.001110 0.002040 58 64
32768 0.002476 0.009237 78 84
65536 0.005538 0.013132 62 68
131072 0.011435 0.018767 62 68
262144 0.024987 0.042936 62 68
計測に使用したコードを表示する

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::{
    env,
    process::Command,
    time::{Duration, Instant},
};
const MIN_POWER: u32 = 8;
const MAX_POWER: u32 = 18;
const RUNS: usize = 8192;


fn wmerge(a: &mut [usize], i: usize, m: usize, j: usize, n: usize, mut w: usize) {
    let mut i = i;
    let mut j = j;
    while i < m && j < n {
        if a[i] <= a[j] {
            a.swap(w, i);
            w += 1;
            i += 1;
        } else {
            a.swap(w, j);
            w += 1;
            j += 1;
        }
    }
    while i < m {
        a.swap(w, i);
        w += 1;
        i += 1;
    }
    while j < n {
        a.swap(w, j);
        w += 1;
        j += 1;
    }
}

fn wsort(a: &mut [usize], l: usize, u: usize, w: usize) {
    if u - l > 1 {
        let m = l + (u - l) / 2;
        imsort_range(a, l, m);
        imsort_range(a, m, u);
        wmerge(a, l, m, m, u, w);
    } else {
        let mut l = l;
        let mut w = w;
        while l < u {
            a.swap(l, w);
            l += 1;
            w += 1;
        }
    }
}

fn imsort_range(a: &mut [usize], l: usize, u: usize) {
    if u - l <= 1 {
        return;
    }
    let m = l + (u - l) / 2;
    let mut w = l + u - m;
    wsort(a, l, m, w);
    while w - l > 2 {
        let n = w;
        w = l + (n - l + 1) / 2;
        wsort(a, w, n, l);
        wmerge(a, l, l + n - w, n, u, w);
    }
    let mut n = w;
    while n > l {
        let mut m_idx = n;
        while m_idx < u && a[m_idx] < a[m_idx - 1] {
            a.swap(m_idx, m_idx - 1);
            m_idx += 1;
        }
        n -= 1;
    }
}

fn cascade_merge_sort(a: &mut [usize]) {
    if a.len() <= 1 {
        return;
    }
    imsort_range(a, 0, a.len());
}


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

    cascade_merge_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],
    );
    for seed in 0..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 memory_usage_kb() -> usize {
    // VmHWM (peak RSS, KiB). Reported memory subtracts a per-size baseline that only
    // holds the input array, so the table reflects auxiliary space during sorting.
    let contents = std::fs::read_to_string("/proc/self/status")
        .unwrap_or_default();

    for line in contents.lines() {
        if let Some(rest) = line.strip_prefix("VmHWM:") {
            let kb = rest
                .split_whitespace()
                .next()
                .unwrap_or("0")
                .parse::<usize>()
                .unwrap_or(0);

            return kb;
        }
    }

    0
}

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

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

fn run_baseline(size: usize) -> usize {
    let _hold = input_array(size, 1);
    memory_usage_kb()
}

fn run_once(size: usize, seed: usize) -> (u128, usize) {
    let mut array = input_array(size, seed as u64);

    let start = Instant::now();

    benchmark_sort(&mut array);

    let elapsed = start.elapsed();
    let mem = memory_usage_kb();

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

    (micros(elapsed), mem)
}

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

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 == "--baseline-once") {
        run_baseline_child(&args);
        return;
    }
    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 baseline_output = Command::new(env::current_exe().expect("failed to find current executable"))
            .arg("--baseline-once")
            .arg(size.to_string())
            .output()
            .expect("failed to run benchmark baseline process");

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

        let baseline_stdout = String::from_utf8(baseline_output.stdout)
            .expect("baseline process returned non-UTF-8 output");
        let baseline_mem = baseline_stdout
            .split_whitespace()
            .next()
            .expect("missing baseline memory usage")
            .parse::<usize>()
            .expect("invalid baseline memory usage");

        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 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;
            }

            let aux_mem = mem.saturating_sub(baseline_mem);

            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