トライソートを使用する

トライソート(trie sort、接頭辞木ソート)は、各キーをトライ木に十進桁ごとに挿入し、子を 0..9 の順に深さ優先走査してキーを取り出す。

文字列の辞書順整列にトライ木を使う発想を、固定桁幅の整数キーへ拡張したものとみなせる。

基数ソート の MSD(最上位桁から)版と本質的に同じ区分付けを行うが、トライ木を明示的に構築してから走査する点が異なる。ビット列をキーとする場合は 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) となり、キーの大小比較を使わない非比較ソートである。

ツリーソート が二分探索木へ挿入して中順走査するのに対し、トライソートは桁ごとの r 分岐木を使う。値域が広く桁数が多いとノード数が増えやすいが、桁ごとの区分が MSD 基数ソートと同型であるため、分布が適度に散らばる整数列では線形に近い性能が期待できる。

基数ソートとの関係

MSD 基数ソートは、各桁パスでカウンティングと配置を行い、部分配列を再帰的に処理する。トライソートは同じ桁区分を木構造として保持し、構築後に一度走査する。

観点 トライソート MSD 基数ソート
データ構造 明示的なトライ木 カウンティング配列+再帰
走査 構築後に DFS 1 回 桁ごとに配置と再帰
追加メモリ ノードポインタ分 カウンティング用 O(r)
典型用途 文字列・可変長キー 固定桁整数・文字列

Burstsort などは文字列整列でトライ木とバケットを組み合わせ、大規模データ向けに最適化している。

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

Size Average time Maximum time Average memory Maximum memory
256 0.000017 0.000058 1726 1732
512 0.000033 0.000072 1774 1780
1024 0.000070 0.000175 1874 1880
2048 0.000143 0.000837 2070 2076
4096 0.000275 0.000761 2461 2468
8192 0.000555 0.001414 3246 3252
16384 0.001140 0.004275 4699 4708
32768 0.002328 0.007006 7876 7908
65536 0.005700 0.016380 14148 14180
131072 0.016631 0.051791 26692 26724
262144 0.044030 0.134816 51779 51812
計測に使用したコードを表示する

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 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