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

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

整列済み部分列と作業領域の内容を 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.000008 0.000445 1662 1668
512 0.000019 0.000666 1666 1672
1024 0.000044 0.000494 1674 1680
2048 0.000095 0.000438 1690 1696
4096 0.000212 0.000707 1721 1728
8192 0.000473 0.000777 1785 1792
16384 0.001023 0.001249 1918 1924
32768 0.002249 0.004620 2177 2184
65536 0.005162 0.016857 2689 2696
131072 0.010093 0.014215 3714 3720
262144 0.021324 0.152617 5762 5768
計測に使用したコードを表示する

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

    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