サークルソートを使用する

サークルソート (circle sort) は、区間の両端から内側へ向かう「直径」上の要素同士を比較・交換し、そのあと区間を半分に分けて同じ処理を再帰する整列である。配列上に同心円を描き、同じ円上で向かい合う位置を見る、という比喩で説明されることが多い。

1 回の再帰パスだけでは必ずしも昇順にならないため、パス中に一度でも交換が起きた場合は配列全体に対して同じ処理を繰り返す。

  1. 直径比較(halver): 部分配列 A[lo .. hi] について、A[lo]A[hi]A[lo+1]A[hi-1]、… のように向かい合う要素を比較し、逆順なら交換する。
  2. 奇数長の中央: 要素数が奇数のとき、ポインタが中央で出会ったあと、中央要素とその右隣を比較して必要なら入れ替える。
  3. 再帰分割: 区間を前後半に分け、それぞれに同じパスを再帰する。区間長が 1 以下なら何もしない。
  4. 反復: パス全体で交換が起きなくなるまで、手順 1〜3 を配列全体に繰り返す。
procedure circle_pass(A, lo, hi) -> swapped
  if lo >= hi then
    return false
  swapped = false
  left = lo
  right = hi
  while left < right
    if A[left] > A[right] then
      swap(A[left], A[right])
      swapped = true
    left = left + 1
    right = right - 1
  if left == right and right + 1 <= hi and A[left] > A[right + 1] then
    swap(A[left], A[right + 1])
    swapped = true
  mid = lo + floor((hi - lo) / 2)
  left_swapped = circle_pass(A, lo, mid)
  right_swapped = circle_pass(A, mid + 1, hi)
  return swapped or left_swapped or right_swapped

procedure circle_sort(A)
  n = length(A)
  if n < 2 then
    return
  while circle_pass(A, 0, n - 1)
    // 交換がなくなるまで繰り返す

1 パスあたりの比較回数はおおよそ O(n log n) で、パス回数は典型的に O(log n) 程度とされるため、全体は O(n log² n) 前後になる。補助配列は使わず、再帰の深さは O(log n) である。一般に不安定である。

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

クイックソートはピボットで区間を分割するが、サークルソートの直径比較はピボット値を選ばず、両端から内側へ向かう固定パターンで入れ替えるだけである。

バイトニックソートも距離を意識した比較ネットワークだが、昇順・降順のバイトニック列を組み立ててからマージするのに対し、本アルゴリズムはパスを繰り返して収束させる。

コムソートはギャップを縮小しながら遠方の要素を入れ替える点が近いが、再帰的な同心円状の分割は行わない。

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

Size Average time Maximum time Average memory Maximum memory
256 0.000017 0.000114 74 80
512 0.000038 0.000129 61 68
1024 0.000086 0.000274 61 68
2048 0.000219 0.001918 58 64
4096 0.000469 0.004720 62 68
8192 0.001043 0.008992 70 76
16384 0.002217 0.015200 62 68
32768 0.004864 0.030524 62 68
65536 0.010428 0.028383 62 68
131072 0.025252 0.069694 58 64
262144 0.054851 0.121746 81 88
計測に使用したコードを表示する

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 circle_pass(a: &mut [usize], low: usize, high: usize) -> bool {
    if low >= high {
        return false;
    }

    let mut swapped = false;
    let mut left = low;
    let mut right = high;

    while left < right {
        if a[left] > a[right] {
            a.swap(left, right);
            swapped = true;
        }
        left += 1;
        right -= 1;
    }

    // Odd-length range: compare the middle element with its right neighbor.
    if left == right && right + 1 <= high && a[left] > a[right + 1] {
        a.swap(left, right + 1);
        swapped = true;
    }

    let mid = low + (high - low) / 2;
    let left_swapped = circle_pass(a, low, mid);
    let right_swapped = circle_pass(a, mid + 1, high);
    swapped || left_swapped || right_swapped
}

fn circle_sort(a: &mut [usize]) {
    let n = a.len();
    if n < 2 {
        return;
    }
    while circle_pass(a, 0, n - 1) {}
}


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

    circle_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