ポストマンソートを使用する

ポストマンソート (postman's sort) は、キーを階層的な属性(文字・桁・地域コードなど)として扱い、最上位の属性から順にバケットへ配り、各バケットを次の属性で再帰的に仕分ける分布型の整列である。郵便区分機が郵便番号の上位桁/集配局/町域/配達順と段階的に手紙を振り分ける様子に例えられる。

トップダウン(最上位桁優先)の基数ソートの工学的変種とされ、要素同士を比較せずバケットへ配るため、キー幅とバケット数に依存する係数 c に対し時間は O(c · n) と説明される。

  1. 属性(桁)の選択: 最上位の記号から処理する。部分列が十分小さければ挿入ソートなどで終える。
  2. バケットへの配布: 現在位置の記号 0..σ-1 ごとに補助リストへ要素を追加する(デモでは十進桁で σ = 10)。
  3. 再帰: 要素が 2 個以上ある各バケットについて、次の記号位置で手順 1〜2 を繰り返す。
  4. 連結: 記号 0, 1, … の順にバケットを並べれば、全体が昇順になる。
procedure postman_sort(A, exp)
  if length(A) <= THRESHOLD then
    insertion_sort(A)
    return
  if exp = 0 then
    return
  buckets = empty list of σ arrays
  for each x in A
    append x to buckets[digit(x, exp)]
  out = empty list
  for d from 0 to σ - 1
    if length(buckets[d]) > 1 then
      postman_sort(buckets[d], next_exp(exp))
    append buckets[d] to out
  A = out

整数キーを usize として整列するときは、最上位バイトから下位バイトへと記号を取り、σ = 256 として上記を適用するのが典型である(下の計測コードもこの方式)。デモは視認性のため十進の各桁を上位から同じ手順で示す。

記号幅を w、記号集合サイズを σ とすると時間はおおよそ O(w · (n + σ))、補助空間はバケット分 O(n + σ) である。配布を入力順に行い同記号の相対順を保てば安定ソートになる。

郵便の住所のように属性が階層になっているデータや、可変長キーを上位から切りたい場面向きである。固定幅整数なら アメリカ国旗ソート のようなインプレース MSD も選択肢になる。

以下のデモでは 2 桁の整数を十進の十の位・一の位の順に下のバケットへ配り、連結してから下位桁で再帰する。「シャッフル」で別の並びに差し替えられます。

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

バケットソートは値域を一度だけ等分してから各バケットを別ソートする。

バーストソートは閾値を超えたときだけ下位桁へトライを伸ばす。

アメリカ国旗ソートは同じ MSD の多区分だが、補助リストではなくインプレースでバケット境界へ集める。

基数ソートの記事は LSD 中心で、ポストマンはトップダウン(MSD)の配布である。

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

Size Average time Maximum time Average memory Maximum memory
256 0.000028 0.003066 22 22
512 0.000038 0.002309 36 36
1024 0.000054 0.000117 64 64
2048 0.000115 0.000258 120 120
4096 0.000207 0.000337 232 232
8192 0.000405 0.001489 456 456
16384 0.000761 0.002211 904 904
32768 0.001540 0.007308 1800 1800
65536 0.003467 0.013471 3592 3592
131072 0.006973 0.020928 6664 6664
262144 0.014685 0.068146 12808 12808
計測に使用したコードを表示する

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::{
    alloc::{GlobalAlloc, Layout, System},
    env,
    process::Command,
    sync::atomic::{AtomicUsize, Ordering},
    time::{Duration, Instant},
};

/// Counts live heap bytes and the high-water mark so auxiliary sort buffers
/// (swap Vecs, etc.) are measured as explicit heap growth during the sort.
struct TrackingAllocator;

static LIVE_BYTES: AtomicUsize = AtomicUsize::new(0);
static PEAK_BYTES: AtomicUsize = AtomicUsize::new(0);

fn record_alloc(size: usize) {
    let live = LIVE_BYTES.fetch_add(size, Ordering::Relaxed) + size;
    PEAK_BYTES.fetch_max(live, Ordering::Relaxed);
}

unsafe impl GlobalAlloc for TrackingAllocator {
    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
        let ptr = System.alloc(layout);
        if !ptr.is_null() {
            record_alloc(layout.size());
        }
        ptr
    }

    unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
        LIVE_BYTES.fetch_sub(layout.size(), Ordering::Relaxed);
        System.dealloc(ptr, layout);
    }

    unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
        let ptr = System.alloc_zeroed(layout);
        if !ptr.is_null() {
            record_alloc(layout.size());
        }
        ptr
    }

    unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
        let new_ptr = System.realloc(ptr, layout, new_size);
        if !new_ptr.is_null() {
            LIVE_BYTES.fetch_sub(layout.size(), Ordering::Relaxed);
            record_alloc(new_size);
        }
        new_ptr
    }
}

#[global_allocator]
static GLOBAL: TrackingAllocator = TrackingAllocator;
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 postman_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 buckets: [Vec<usize>; W] = std::array::from_fn(|_| Vec::new());

    for &value in a.iter() {
        let digit = ((value >> shift) & 0xFF) as usize;
        buckets[digit].push(value);
    }

    let mut offset = 0usize;
    for bucket in buckets.iter_mut() {
        if bucket.len() > 1 {
            postman_sort_bytes(bucket, byte + 1);
        }
        let len = bucket.len();
        if len > 0 {
            a[offset..offset + len].copy_from_slice(bucket);
            offset += len;
        }
    }
}

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


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

    postman_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],
    );
    // Seed 0 is a fixed point of the xorshift below, so it would degenerate into
    // yet another all-equal case instead of a 4-value mix. Start at 1.
    for seed in 1..=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 micros(d: Duration) -> u128 {
    d.as_micros()
}

fn input_array(size: usize, seed: u64) -> Vec<usize> {
    shuffled(size, seed)
}

/// Peak heap growth during `benchmark_sort`, in KiB (explicit buffers such as swap).
fn run_once(size: usize, seed: usize) -> (u128, usize) {
    let mut array = input_array(size, seed as u64);

    let base_bytes = LIVE_BYTES.load(Ordering::Relaxed);
    PEAK_BYTES.store(base_bytes, Ordering::Relaxed);

    let start = Instant::now();

    benchmark_sort(&mut array);

    let elapsed = start.elapsed();
    let peak_bytes = PEAK_BYTES.load(Ordering::Relaxed);
    let aux_kb = peak_bytes.saturating_sub(base_bytes) / 1024;

    let expected: Vec<usize> = (1..=size).collect();
    if array != expected {
        panic!(
            "sort failed with seed {} for size {}",
            seed,
            size
        );
    }

    (micros(elapsed), aux_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;
    }

    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 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 aux_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 += 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