シャトルソートを使用する

シャトルソート (shuttle sort) は、各走査で新しい位置の要素を左方向へ隣接交換で運びながら、先頭側の整列済み区間をひとつずつ広げていく比較ソートである。値が左へ「往復するシャトル」のように見えることから名付けられた。

  1. 整列済み領域: 最初は先頭要素だけを整列済みとみなす。
  2. i 回目の走査: インデックス i の要素をキーと見て、A[i-1] > A[i] なら隣接交換し i を左へ進める。順序が正しくなるまで繰り返す。
  3. 走査完了: i 回目の走査の終わりで [0, i] が昇順に整列済みとなる。
  4. 終了: i = n - 1 まで走査を繰り返す。
procedure shuttle_sort(A)
  n = length(A)
  for i from 1 to n - 1
    j = i
    while j > 0 and A[j - 1] > A[j] then
      swap(A[j - 1], A[j])
      j = j - 1

隣接交換のみで > のときだけ入れ替えるため安定なソートとして扱える。追加配列を使わなければ空間計算量は O(1) である。すでに昇順に近い入力では内側のループが早く終わり最良は O(n)、逆順に近い並びでは O(n²) になる。

走査単位で「どこまで整列済みか」がはっきりするため、挿入ソートの説明に隣接交換版を載せた教材と対応づけやすい。一方、ノームソートのように状態が一本のインデックスだけで表せるわけではない。

ノームソート・シェーカーソートとの違い

ノームソート も隣接交換で左へ戻りながら整列させる点は似ている。シェーカーソート は文献によって shuttle sort と呼ばれることもあるが、ここで扱うシャトルソートとは走査の向きと整列済み区間の広げ方が異なる。

観点 シャトルソート ノームソート シェーカーソート
制御 外側の走査番号 i と内側の位置 j 単一の現在位置 pos だけ 左右端 begin / end と往復する走査
走査の向き 各走査は左方向へのシャトルのみ 前後どちらにも一歩ずつ 右方向と左方向を交互に
不変条件 i 回目の走査完了後、先頭 i + 1 要素が整列済み 走査という区切りはなく、pos == n で初めて全体完了 ラウンドごとに端へ最大・最小が寄り、比較範囲が縮む
戻り方 1 回の走査のうち j を左へ下げるだけ 交換のたびに pos -= 1 し、先頭付近まで戻りうる 逆向き走査で小さな値も左端へ運びやすい
典型の読み方 挿入ソートを隣接交換で写した形 庭のノームが一歩ずつ前後に歩く状態機械 バブルソートに逆向き走査を足した形

ノームソートとの比較では、同じ入力でも走査の区切り方が違うため 比較・交換の回数は一致しない場合がある

シェーカーソートとの比較では、どちらも隣接交換だけで安定だが、シャトルソートは先頭側の整列済み区間を走査ごとに広げ、シェーカーソートは両端から未整列区間を狭めていく。

三つとも最悪・平均 O(n²)、整列済みに近い入力では O(n) に近づく。

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

Size Average time Maximum time Average memory Maximum memory
256 0.000008 0.000039 1678 1684
512 0.000026 0.000070 1682 1688
1024 0.000088 0.000167 1690 1696
2048 0.000342 0.000419 1706 1712
4096 0.001334 0.002223 1738 1744
8192 0.005492 0.020039 1801 1808
16384 0.021904 0.074351 1934 1940
32768 0.071984 0.137185 2194 2200
計測に使用したコードを表示する

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



fn shuttle_sort(a: &mut [usize]) {
    insertion_sort(a);
}


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

    shuttle_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