アメリカ国旗ソートを使用する

アメリカ国旗ソート (American flag sort) は、キーを固定幅の記号列(典型例は 1 バイト単位)として見なし、現在位置の記号値ごとにカウンティングしてバケット境界を決め、インプレースの交換で各要素を対応バケットへ集める処理を再帰的に実行する。

名称は Dijkstra の「オランダ国旗問題」に由来する 3 区分のインプレース分割と、星条旗の複数色帯を連想させる MSD の多区分分割の対応から来ている。基数ソートや文字列向け MSD 整列の実装で、補助配列を増やさずに済ませたい場面に用いられる。

  1. 記号位置の選択: 最上位バイト(または桁)から処理する。部分配列が十分小さければ挿入ソートなどで終える。
  2. 出現回数の集計: 現在位置の記号 0..σ-1(バイトなら σ = 256)について各出現数を数える。
  3. バケット境界の確定: 累積和から各記号の区間 [start, end) を求める。
  4. インプレース配置: 記号 r の区間を左から走査し、属する記号が r でなければ先頭未確定要素と交換して前進させ、すべて r に揃えたら次の記号へ進む。
  5. 再帰: 要素が 2 個以上残った各区間について、次の記号位置で手順 1〜4 を繰り返す。
procedure american_flag_sort(A, byte)
  if length(A) <= THRESHOLD then
    insertion_sort(A)
    return
  if byte >= key_width then
    return
  count[0..σ-1] = 0
  for each x in A
    count[digit(x, byte)]++
  offset[0] = 0
  for r from 1 to σ - 1
    offset[r] = offset[r - 1] + count[r - 1]
  begin[r] = offset[r]
  end[r] = offset[r] + count[r]
  for r from 0 to σ - 1
    if count[r] = 0 then continue
    while begin[r] < end[r]
      b = digit(A[begin[r]], byte)
      if b ≠ r then
        end[b] = end[b] - 1
        swap(A[begin[r]], A[end[b]])
      else
        begin[r] = begin[r] + 1
    american_flag_sort(A[offset[r]..offset[r] + count[r]), byte + 1)

整数キーを usize として整列するときは、最上位バイトから下位バイトへと digit(x, byte) を取り、記号集合サイズ σ = 256 として上記を適用するのが典型である(下の計測コードもこの方式)。

記号幅 w なら O(w · (n + σ)) となり、カウンティング配列 O(σ) だけのインプレース志向の実装が多い。一般に不安定である。

以下のデモでは視認性のため十進の各桁(σ = 10)を上位桁から同じ手順で示す。バイト列版と違いは記号の取り方だけで、バケット形成と再帰の流れは同じである。

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

基数ソートの MSD 版に近いが、各桁(記号)ごとにインプレースでバケット境界へ集める。カウンティング配列 O(σ) だけで済ませやすい。

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

Size Average time Maximum time Average memory Maximum memory
256 0.000012 0.000061 1739 1744
512 0.000017 0.000051 1743 1748
1024 0.000026 0.000079 1751 1756
2048 0.000043 0.000116 1767 1772
4096 0.000078 0.000152 1799 1804
8192 0.000150 0.000373 1863 1868
16384 0.000296 0.000522 1995 2000
32768 0.000599 0.001146 2255 2260
65536 0.001247 0.003208 2767 2772
131072 0.003073 0.009491 3791 3796
262144 0.006327 0.010087 5839 5844
計測に使用したコードを表示する

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 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 american_flag_sort_bytes(a: &mut [usize], byte: usize) {
    const W: usize = 256;
    const THRESHOLD: usize = 16;

    if a.len() <= THRESHOLD {
        insertion_sort(a);
        return;
    }
    if byte >= std::mem::size_of::<usize>() {
        return;
    }

    let shift = (std::mem::size_of::<usize>() - 1 - byte) * 8;

    let mut count = [0usize; W];
    for &value in a.iter() {
        count[((value >> shift) & 0xFF) as usize] += 1;
    }

    let mut begin = [0usize; W];
    let mut sum = 0usize;
    for i in 0..W {
        begin[i] = sum;
        sum += count[i];
    }

    let initial = begin;
    let mut end = [0usize; W];
    for i in 0..W {
        end[i] = begin[i] + count[i];
    }

    for bucket in 0..W {
        if count[bucket] == 0 {
            continue;
        }

        while begin[bucket] < end[bucket] {
            let digit = ((a[begin[bucket]] >> shift) & 0xFF) as usize;
            if digit != bucket {
                end[digit] -= 1;
                a.swap(begin[bucket], end[digit]);
            } else {
                begin[bucket] += 1;
            }
        }

        let start = initial[bucket];
        if count[bucket] > 1 {
            american_flag_sort_bytes(&mut a[start..start + count[bucket]], byte + 1);
        }
    }
}

fn american_flag_sort(a: &mut [usize]) {
    if !a.is_empty() {
        american_flag_sort_bytes(a, 0);
    }
}


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

    american_flag_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