トライソートを使用する

トライソート(trie sort、接頭辞木ソート)は、各キーをトライ木に十進桁ごとに挿入し、子を 0..9 の順にたどってキーを取り出す非比較ソートである。文字列を辞書順に整列するトライ木の考え方を、固定桁幅の整数キーへそのまま拡張したものとみなせる。

基数ソート の MSD(最上位桁から)版と本質的に同じ区分付けを行うが、桁ごとに挿入しながら木を組み立て、構築後に 0 から 9 へ枝を順にたどって値を取り出す点が異なる。キーがビット列なら 2 分岐のビットトライ木となり、MSD 基数ソートやバイナリクイックソートと同型の区分付けになる。

  1. 桁幅の決定: 配列の最大値 max から、最上位桁の重み exp(1, 10, 100, …)を求める。
  2. 挿入: 各要素 x について、(x / exp) % 10 から始め exp を 10 で割りながら子ノードへ降り、最終ノードの終端リストへ x を記録する(先頭ゼロ埋め相当)。
  3. 走査: 根から各桁 0..9 の順に子へ再帰し、終端リストの値を左から出力配列へ追加する。
procedure trie_sort(A)
  if length(A) = 0 then return
  maxVal = maximum(A)
  exp = highest_digit_weight(maxVal)
  root = 空のトライ木ノード
  for each x in A
    insert x into root following digits from exp down to 1
  out = empty list
  dfs_collect(root, out)   // children 0..9 in order, then terminal values
  A = out

桁数を w とすると挿入と走査はともに O(n · w) であり、キー同士の大小比較は行わない。

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

基数ソートの MSD 版と桁による区分は同型で、違いは区分をカウンティングと再帰で進めるか、一度トライ木を組み立ててから走査するかにある。ツリーソートはキーの大小比較で二分木へ挿入するため、可変長文字列では桁ごとのトライ木を使う本アルゴリズムの方が素直に実装しやすい。

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

Size Average time Maximum time Average memory Maximum memory
256 0.000027 0.000209 126 132
512 0.000053 0.000419 154 160
1024 0.000108 0.000281 241 248
2048 0.000215 0.000857 426 432
4096 0.000403 0.001405 782 788
8192 0.000801 0.003055 1526 1532
16384 0.001673 0.010321 2860 2900
32768 0.003454 0.011504 5806 5848
65536 0.008061 0.041693 11560 11600
131072 0.019451 0.085285 23088 23128
262144 0.036573 0.064747 46123 46164
計測に使用したコードを表示する

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;


#[derive(Default)]
struct TrieNode {
    children: [Option<Box<TrieNode>>; 10],
    terminal: Vec<usize>,
}

fn max_digit_exp(max: usize) -> usize {
    let mut exp = 1usize;
    while exp.saturating_mul(10) <= max && exp <= usize::MAX / 10 {
        exp *= 10;
    }
    exp
}

fn trie_insert(node: &mut TrieNode, value: usize, exp: usize) {
    if exp == 0 {
        node.terminal.push(value);
        return;
    }
    let digit = (value / exp) % 10;
    let child = node.children[digit].get_or_insert_with(|| Box::new(TrieNode::default()));
    trie_insert(child, value, exp / 10);
}

fn trie_collect(node: &TrieNode, out: &mut Vec<usize>) {
    for child in node.children.iter().flatten() {
        trie_collect(child, out);
    }
    out.extend(node.terminal.iter().copied());
}

fn trie_sort(a: &mut [usize]) {
    if a.is_empty() {
        return;
    }

    let max = *a.iter().max().unwrap();
    let exp = max_digit_exp(max);
    let mut root = TrieNode::default();

    for &x in a.iter() {
        trie_insert(&mut root, x, exp);
    }

    let mut out = Vec::with_capacity(a.len());
    trie_collect(&root, &mut out);
    a.copy_from_slice(&out);
}


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

    trie_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