図書館ソートを使用する

図書館ソート (library sort空隙付き挿入ソートとも呼ばれる) は、整列済みデータを 隙間(空きスロット)を挟みながら 棚に並べるイメージのソートである。新しい本(要素)を挿入するとき、適切な隙間に直接置ければ周りを大きく動かさずに済む。隙間が足りなければ、近くの空きへ本をずらしてから挿入する。

挿入位置の探索に二分探索を使えるため、ランダムな入力に対しては 比較回数が O(n log n) になりやすい(詳細は隙間の取り方や再配置の戦略に依存する)。古典的な挿入ソートと同じく 安定ソート にできる。

  1. 棚(作業配列): 要素数より長いバッファを用意し、値が入っていないマスを 空き とみなす。
  2. まだ挿していない値 を1つずつ取り出す(入力順はデモではシャッフル後の並び)。
  3. 探索: 棚上の値だけを対象に、挿入すべき 順序上の位置 を二分探索で求める。
  4. 挿入: その区間に空きがあればそこへ値を書き込む。なければ 近い空きまで値を隣接交換でずらし、空いたマスへ書き込む。
  5. 終了: すべての値を置き終えたとき、左から右へ読めば昇順になっている(値同士の間に空きが残ってもよい)。

以下は手順を抽象的に示した疑似コードである。

procedure library_sort_insert(keys, capacity)
  buf[0 .. capacity-1] = empty
  for each key in keys
    find sorted rank r of key among filled cells of buf
    choose a free cell at or near rank r (open with shifts if needed)
    buf[chosen] = key

デモでは 30マスの棚15個の値 を順に挿入する。空きマスは灰色の短いバーで示す。実装の論文レベルの工夫(一定割合の隙間の維持や全体の再配置など)は省略し、二分探索で順序位置を決め、必要なら右または左の空きへ向かって隣接スワップでずらす ところまでを追えるようにしている。

概念的には「棚に空きを残しておく」ことで挿入ソートで毎回長いシフトが続く状況を緩和しようとする発想である。実装や定数の取り方次第では再配置のコストが支配的になる場合もあるため、用途に合わせてマージソートやヒープソートなどとも比較したい。

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

Size Average time Maximum time Average memory Maximum memory
256 0.000007 0.000324 1665 1672
512 0.000016 0.000670 1670 1676
1024 0.000046 0.000663 1682 1688
2048 0.000148 0.000573 1706 1712
4096 0.000529 0.000913 1754 1760
8192 0.001992 0.009550 1849 1856
16384 0.007454 0.010787 1918 1924
32768 0.033039 0.055288 2180 2184
65536 0.152553 0.231454 2944 2944
131072 0.659235 0.972273 4479 4480
262144 3.067918 6.330758 7555 7664
計測に使用したコードを表示する

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 library_sort(a: &mut [usize]) {
    let mut shelf: Vec<usize> = Vec::with_capacity(a.len());
    for &value in a.iter() {
        let pos = shelf.binary_search(&value).unwrap_or_else(|pos| pos);
        shelf.insert(pos, value);
    }
    a.copy_from_slice(&shelf);
}

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

    library_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 {
    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 run_once(size: usize, seed: usize) -> (u128, usize) {
    let expected: Vec<usize> = (1..=size).collect();
    let mut array = shuffled(size, seed as u64);

    let start = Instant::now();

    benchmark_sort(&mut array);

    let elapsed = start.elapsed();

    if array != expected {
        panic!(
            "sort failed with seed {} for size {}",
            seed,
            size
        );
    }

    (micros(elapsed), memory_usage_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;
    }

    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 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 += mem;

            if mem > max_mem {
                max_mem = 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