ペイシェンスソートを使用する

ペイシェンスソート (patience sort) は、ソリティアでトランプを並べていくときの規則に似せたアルゴリズムである。左から並ぶ複数の山(パイル)に入力を順に載せ、その後それらの一番上の値のみを読みながら昇順への取り出しを続けることで並び替えを完結させる構想になる。

  1. 積載: 入力配列を先頭から左へ見ていき、現在の値 x について、一番上の値が x より大きいなかで先頭にあるパイルを探し、その山の上へ x を載せる。

    該当する山がひとつもなければ、一番右に新しい山を増やしてそこに x だけ載せた状態から始める。

  2. 取出: すべての山が空になるまで、一番上の値のうち現在の最小値を持っている山ひとつを選び、その値を取り除いて結果列の末尾へ付ける。

    一番上の値だけを対象とするのでマージソートと同種のマージ処理の特殊形だと見なせる。

procedure patience_deal(elements)
  piles = sequence of piles, initially empty lists
  for each x in elements then
    i = smallest index where piles[i] is non-empty AND top(piles[i]) > x
           (otherwise i = undefined)
    if i is undefined then
      piles.append([x])
    else then
      push x onto piles[i]

procedure patience_collect(piles)
  result = []
  until all piles empty then
    p = pile index minimizing top(piles[p]), breaking ties arbitrarily
    y = pop top from piles[p]
    append y to result
  return result

山の総数によらず、一番上の値の参照にはヒープなどを使える。また積載のルールだけを切り離してみると、山の個数が増加部分列についてのモデルとも対応することが知られている。

同じ値が並んでいても、「一番上の値が載せたい値より厳密に大きい」という条件だけで載せる山が決まり、並べ替え前の順序まで保証しないため、安定ソートではない。また 入力とは別に山と出力領域 を要する。

アルゴリズムの名前は単純比較ベースとは別の発想になりやすいが、並び変え問題として見れば 入力をいくつかの単調並びに分解し、その後統合して完成させる という広い視点での仲間となる。

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

Size Average time Maximum time Average memory Maximum memory
256 0.000021 0.000376 1670 1676
512 0.000050 0.000661 1678 1688
1024 0.000111 0.000467 1694 1704
2048 0.000264 0.001003 1726 1736
4096 0.000628 0.001742 1783 1800
8192 0.001561 0.001793 1907 1924
16384 0.003949 0.007335 2137 2164
32768 0.010260 0.040460 2481 2616
65536 0.027393 0.044625 3327 3328
131072 0.076682 0.154541 5134 5248
262144 0.222812 0.341926 8702 8832
計測に使用したコードを表示する

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 patience_sort(a: &mut [usize]) {
    let mut piles: Vec<Vec<usize>> = Vec::new();
    for &value in a.iter() {
        let mut lo = 0;
        let mut hi = piles.len();
        while lo < hi {
            let mid = (lo + hi) / 2;
            if *piles[mid].last().unwrap() >= value {
                hi = mid;
            } else {
                lo = mid + 1;
            }
        }
        if lo == piles.len() {
            piles.push(vec![value]);
        } else {
            piles[lo].push(value);
        }
    }
    for slot in a.iter_mut() {
        let mut min = 0;
        for i in 1..piles.len() {
            if piles[i].last().is_some()
                && (piles[min].last().is_none()
                    || piles[i].last().unwrap() < piles[min].last().unwrap())
            {
                min = i;
            }
        }
        *slot = piles[min].pop().unwrap();
    }
}

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

    patience_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