二分木ソートで配列を並び替える
二分木ソートを使用する
二分木ソート (tree sort) は、要素を順に 二分探索木 (binary search tree) へ挿入し、その後中順走査 (in-order traversal; 左部分木 → 根 → 右部分木) ですべてのキーを読み出して昇順を得る比較ソートである。
通常の二分探索木は挿入順によって木の高さが偏りやすく、特定の入力では高さが O(n) になり、結果として時間計算量が O(n²) に悪化する。
そこで 平衡二分探索木 (self-balancing binary search tree)を使うと、木の高さを O(log n) に抑えられる。
平衡木への各挿入は高さ O(log n) の探索と再平衡化を伴うため、挿入全体で O(n log n) となる。一方、中順走査は左部分木、根、右部分木の順に各ノードを一度ずつ処理するだけなので全体でO(n) である。
したがって、合計は O(n log n) + O(n) = O(n log n) となる。別途 木全体のために O(n) のノード記憶域が必要になり、入力配列とは別構造となる点はインプレースではない。
また、キー(ここでは棒の高さ=値)だけで比較する素のツリーソートでは、等しいキー同士の相対順序は木の実装や等値を左子・右子のどちらへ入れるかなどの規約に依存し、入力順は保証されない。したがって一般には 安定ソートではない。安定性まで要るなら、比較へ到着順などの副キーを含める必要がある(これはアルゴリズムの拡張であり、「通常の」キー比較だけの性質とは別物である)。
次に掲げるデモでは、同じ値の棒が画面上で入れ替わらないよう、二分木挿入の比較を 値が異なれば値、等しければ元の位置 id の辞書式順にしている。これは 本ページの可視化のための工夫 であり、直前の段落で述べた「素のツリーソートは一般に不安定」という話を打ち消すものではない。
- 挿入: 入力値を順に平衡二分探索木へ挿入する(規則に従って回転や再着色などでバランスを復元する)。
- 取出し: 中順走査でキーを昇順に列挙し、ひとつの配列へ書き込むか、そのまま消費する。
procedure balanced_tree_sort(elements)
T = empty balanced binary search tree
for x in elements
insert_balanced(T, x)
return inorder_traversal(T)
単純な二分木ソートは、実装が短くとも特定の入力で性能が崩れやすく、実務や教材でいう標準手法とはなりにくい。一方、「二分木を経由して整列する」という発想そのものは、アルゴリズムの説明や他の資料構造との対比(たとえば優先度付きキューを使うヒープ整列)において有用である。
クイックソートのように入力配列だけをインプレースで整える手法とは異なり、ツリーソートでは 木の側の処理とは別に、配列上の並びは最後まで固定である と一言で説明しにくい(このページのデモでも同様である)。視覚化のため、ここでは中順の順になるよう配列を スワップで寄せている。実装では通常、走査結果を別バッファに書き込むだけで済ませればよい。バブルソートより改善されやすい計算量である一方で、キャッシュ効率や定数係数、アロケータへの負荷などの観点では配列だけを直接いじる整列アルゴリズムに軍配が上がることもある。
計算時間量および空間計算量を計測する
| Size | Average time | Maximum time | Average memory | Maximum memory |
|---|---|---|---|---|
| 256 | 0.000016 | 0.000651 | 1678 | 1684 |
| 512 | 0.000031 | 0.000548 | 1694 | 1700 |
| 1024 | 0.000067 | 0.000480 | 1729 | 1736 |
| 2048 | 0.000140 | 0.000657 | 1802 | 1808 |
| 4096 | 0.000299 | 0.000527 | 1945 | 1952 |
| 8192 | 0.000658 | 0.000905 | 2234 | 2240 |
| 16384 | 0.001485 | 0.002282 | 2685 | 2692 |
| 32768 | 0.003461 | 0.005545 | 3716 | 3720 |
| 65536 | 0.007998 | 0.028173 | 6016 | 6016 |
| 131072 | 0.018450 | 0.049344 | 10624 | 10624 |
| 262144 | 0.043443 | 0.081286 | 19843 | 19952 |
計測に使用したコードを表示する
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 Node {
value: usize,
count: usize,
left: Option<Box<Node>>,
right: Option<Box<Node>>,
}
fn insert_node(root: &mut Option<Box<Node>>, value: usize) {
match root {
Some(node) if value < node.value => insert_node(&mut node.left, value),
Some(node) if value > node.value => insert_node(&mut node.right, value),
Some(node) => node.count += 1,
None => {
*root = Some(Box::new(Node {
value,
count: 1,
left: None,
right: None,
}));
}
}
}
fn drain_node(root: &Option<Box<Node>>, out: &mut Vec<usize>) {
if let Some(node) = root {
drain_node(&node.left, out);
out.extend(std::iter::repeat(node.value).take(node.count));
drain_node(&node.right, out);
}
}
fn tree_sort(a: &mut [usize]) {
let mut root = None;
for &value in a.iter() {
insert_node(&mut root, value);
}
let mut out = Vec::with_capacity(a.len());
drain_node(&root, &mut out);
a.copy_from_slice(&out);
}
fn benchmark_sort(array: &mut [usize]) {
tree_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