デカルト木ソートを使用する

デカルト木ソート (cartesian tree sort) は、配列からデカルト木を一度だけ構築し、根(部分木の最小値)を先頭に取り出しつつ左右部分木の結果をマージして昇順の並びを得る。

デカルト木は次の2条件を同時に満たす二分木である。

  1. 中順が元の配列順: 左部分木 → 根 → 右部分木の順に走査すると、元の配列の左から右への並び(添字の昇順)になる。
  2. ヒープ性: 最小デカルト木では、各親の値は両方の子以下である(最大デカルト木では逆)。

入力配列が決まれば、値がすべて異なるときデカルト木の形は一意に定まる。整列では最小デカルト木を想定する。

素朴に「根を選んで再帰的に部分木を作る」と O(n²) になりうるが、単調スタック (monotonic stack) を使えば各添字はスタックに高々1回入り1回出るため、全体で O(n) 時間に木を構築できる。

添字 i を左から処理するときの典型形は次のとおりである。

  1. スタック上端より大きい値の添字を、値が A[i] 以下になるまで取り出す(最後に取り出した添字を last とする)。
  2. スタックが空でなければ、A[i] はスタック上端の右の子になる。
  3. last があれば、その添字は i の左の子になる。
  4. i をスタックに追加する。
procedure build_cartesian_tree(A)
  stack = empty stack of indices
  for i from 0 to length(A) - 1
    last = null
    while stack not empty and A[stack.top] > A[i]
      last = stack.pop()
    if stack not empty
      right[stack.top] = i
    if last not null
      left[i] = last
    stack.push(i)
  return tree encoded by left[], right[], and stack[0] as root

構築後、各ノードについて根のキーを出力し、左右部分木から得たすでに昇順の列をマージする再帰的取出しで整列する。左右部分木は元配列の連続部分区間に対応するため、再帰の各段階で部分列は昇順に保たれる。

procedure merge(L, R)
  // 2 つの昇順列を先頭から比較しながら連結する

procedure extract_sorted(node)
  if node is null
    return empty list
  left = extract_sorted(left[node])
  right = extract_sorted(right[node])
  return [A[node]] followed by merge(left, right)

スタックで O(n) 構築でき、素朴な取出しは O(n log n) となる。添字情報も使うため、比較ソートの下界とは前提が異なる。

クイックソートの分割木や、二分木ソートの「挿入順で形が変わる木」とも対比しやすい。範囲最小クエリ (RMQ) や最長増加部分列 (LIS) など、同じスタック構造が別問題でも登場する。

以下のデモでは、同値の棒が入れ替わらないよう、ヒープ比較を値が異なれば値、等しければ元の位置 id の辞書式順にしている(可視化上の安定化であり、一般のデカルト木ソートの性質を変えるものではない)。

  1. 構築: 左から添字を処理し、スタックとの比較(オレンジ)とリンク付け(紫)を示す。構築済みの最小デカルト木もデモ下部に表示する。
  2. 取出し: 根優先の再帰取出し+マージで得た昇順に、配列上の棒をスワップ(緑)で並べ替えて視覚化する(実装では取出し結果を別バッファへ書くだけでよい)。

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

ツリーソートは挿入順で木の形が変わる。デカルト木は値と添字から一意に定まり、O(n) で構築できる。

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

Size Average time Maximum time Average memory Maximum memory
256 0.000017 0.000205 14 14
512 0.000031 0.000088 28 28
1024 0.000063 0.000110 56 56
2048 0.000130 0.000292 112 112
4096 0.000272 0.000516 224 224
8192 0.000562 0.001025 448 448
16384 0.001187 0.002193 896 896
32768 0.002518 0.003924 1792 1792
65536 0.005354 0.017135 3584 3584
131072 0.010990 0.018423 7168 7168
262144 0.022316 0.037251 14336 14336
計測に使用したコードを表示する

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 merge_values(left: &[usize], right: &[usize]) -> Vec<usize> {
    let mut out = Vec::with_capacity(left.len() + right.len());
    let (mut l, mut r) = (0, 0);
    while l < left.len() && r < right.len() {
        if left[l] <= right[r] {
            out.push(left[l]);
            l += 1;
        } else {
            out.push(right[r]);
            r += 1;
        }
    }
    out.extend_from_slice(&left[l..]);
    out.extend_from_slice(&right[r..]);
    out
}



fn cartesian_tree_sort(a: &mut [usize]) {
    let n = a.len();
    if n <= 1 {
        return;
    }
    let mut left = vec![None; n];
    let mut right = vec![None; n];
    let mut stack = Vec::new();
    for i in 0..n {
        let mut last = None;
        while stack.last().is_some_and(|&top| a[top] > a[i]) {
            last = stack.pop();
        }
        if let Some(&top) = stack.last() {
            right[top] = Some(i);
        }
        if let Some(last_idx) = last {
            left[i] = Some(last_idx);
        }
        stack.push(i);
    }
    fn extract(
        node: Option<usize>,
        a: &[usize],
        left: &[Option<usize>],
        right: &[Option<usize>],
    ) -> Vec<usize> {
        if let Some(i) = node {
            let l = extract(left[i], a, left, right);
            let r = extract(right[i], a, left, right);
            let merged = merge_values(&l, &r);
            let mut out = Vec::with_capacity(merged.len() + 1);
            out.push(a[i]);
            out.extend(merged);
            out
        } else {
            Vec::new()
        }
    }
    let root = stack.first().copied();
    let out = extract(root, a, &left, &right);
    a.copy_from_slice(&out);
}


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

    cartesian_tree_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