置換選択ソートを使用する

置換選択ソート (replacement selection sort) は、限られた大きさの最小ヒープで入力を流し、平均でヒープ容量の約 2 倍の長さの整列済みランを生成し、それらをマージして全体を昇順にする整列である。

外部整列ではメモリに載らないファイルを扱うとき、単純にメモリ分だけ読んでクイックソートすると初期ラン長はメモリ容量 M に留まる。 置換選択では、出力した直前のキー以上の入力だけをヒープへ戻す(置換する)ことで、ランダム入力でも期待ラン長がおよそ 2M になる。 昇順に近い入力ではさらに長くなり、最悪(厳密な降順)では M まで縮む。

本記事のデモとベンチマークでは、主記憶上の配列を入力ストリームとみなし、容量 M の最小ヒープでランを作ったあと、ラン同士をマージして配列へ書き戻す。

  1. 充填: 入力から最大 M 個を読み、最小ヒープを構築する。
  2. 抽出と置換: ヒープの最小を現在ランへ出力する。次の入力が直前の出力以上なら根へ入れて沈降(現在ランに残す)。小さければ次ラン用の待避領域へ置き、ヒープは縮む。
  3. ラン区切り: ヒープが空になったら現在ランを確定し、待避していた要素でヒープを組み直して次ランを始める。入力が尽きるまで繰り返す。
  4. マージ: できたランをマージし、1 本の昇順列にする。
procedure sift_down(H, i)
  // 最小ヒープ条件を満たすよう H[i] を沈降

procedure generate_runs(input, M)
  H = first min(M, length(input)) elements; heapify_min(H)
  frozen = empty; run = empty; i = |H|
  while true
    if H is empty then
      if run nonempty then emit run; run = empty
      if frozen empty and i >= length(input) then break
      H = frozen; frozen = empty
      while i < length(input) and |H| < M
        append input[i] to H; i = i + 1
      heapify_min(H)
      continue
    out = extract_min(H)
    append out to run
    if i < length(input) then
      next = input[i]; i = i + 1
      if next >= out then insert next into H
      else append next to frozen
  return all emitted runs

procedure replacement_selection_sort(A)
  runs = generate_runs(A, M)
  A = merge_all(runs)

ラン生成は各要素がヒープへ高々定数回出入りするため \(O(n \log M)\)、マージはラン数を R とすると概ね \(O(n \log R)\) で、合計は \(O(n \log n)\) 程度になる。 ヒープと待避・ラン用に \(O(M + n)\) の追加領域が要り、不安定である。デモでは M = 4、ベンチマークでは M = 32 とする。

外部整列の初期ラン生成として置換選択を使い、できたランをポリフェーズマージなどでマージするのが古典的な組み合わせである。メモリ全体をヒープに使えるならランは 1 本になり、振る舞いは最小ヒープからの連続抽出に近づく。

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

ヒープソートは配列全体をヒープ化しインプレースで縮める。置換選択は容量 M の窓だけをヒープに保ち、ストリームからランを伸ばす。

トーナメントソートや敗者木ソートは「次の最小」を木で更新する構造が近く、外部マージの選択木としても使われる。置換選択はラン長を伸ばす生成法としての側面が強い。

ストランドソートも単調列を切り取ってマージするが、ヒープによる置換は行わず、1 回の走査で拾える非減少部分列に限る。

ポリフェーズマージソートは固定長チャンクを初期ランとする実装が多い。置換選択で長い初期ランを渡せば、マージパス数をさらに抑えられる。

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

Size Average time (s) Maximum time (s) Average memory (KiB) Maximum memory (KiB)
256 0.000022 0.001439 4 5
512 0.000045 0.001127 10 11
1024 0.000087 0.001155 20 23
2048 0.000185 0.003634 40 44
4096 0.000350 0.003149 81 86
8192 0.000734 0.004720 163 171
16384 0.002018 0.056631 326 336
32768 0.004071 0.037409 652 668
65536 0.007650 0.050894 1305 1332
131072 0.015410 0.096450 2609 2654
262144 0.032595 0.099702 5216 5291
計測に使用したコードを表示する

#!/usr/bin/env swift
import Foundation

// This standalone Swift driver creates the same temporary Docker build
// context as the former shell wrapper.  The benchmark program itself remains
// embedded below so readers can copy one complete, reproducible file.
struct BenchmarkError: Error, CustomStringConvertible {
    let message: String

    var description: String { message }

    init(_ message: String) {
        self.message = message
    }
}

func runCommand(_ executable: String, _ arguments: [String]) throws {
    let process = Process()
    process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
    process.arguments = [executable] + arguments
    process.standardInput = FileHandle.standardInput
    process.standardOutput = FileHandle.standardOutput
    process.standardError = FileHandle.standardError

    do {
        try process.run()
    } catch {
        throw BenchmarkError("Could not start \(executable): \(error)")
    }
    process.waitUntilExit()
    guard process.terminationStatus == 0 else {
        throw BenchmarkError(
            "Command failed (\(process.terminationStatus)): " +
            "\(executable) \(arguments.joined(separator: " "))"
        )
    }
}

do {
    // The UUID avoids collisions when two benchmark copies are run at once.
    let workdir = FileManager.default.temporaryDirectory
        .appendingPathComponent("swift-sort-benchmark-\(UUID().uuidString)")
    try FileManager.default.createDirectory(at: workdir, withIntermediateDirectories: true)
    defer { try? FileManager.default.removeItem(at: workdir) }

    // A raw Swift string is used so the nested main.swift keeps its own
    // interpolation expressions such as \(seed) until Docker compiles it.
    let dockerfile = #"""
FROM swift:6.0

WORKDIR /app

RUN cat > alloc_track.c <<'ALLOC'
#define _GNU_SOURCE
#include <dlfcn.h>
#include <malloc.h>
#include <stdatomic.h>
#include <stddef.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>

static atomic_size_t live_bytes = 0;
static atomic_size_t peak_bytes = 0;

static void *(*real_malloc)(size_t) = NULL;
static void *(*real_calloc)(size_t, size_t) = NULL;
static void *(*real_realloc)(void *, size_t) = NULL;
static void (*real_free)(void *) = NULL;

static void init_reals(void) {
    if (real_malloc) {
        return;
    }
    real_malloc = (void *(*)(size_t))dlsym(RTLD_NEXT, "malloc");
    real_calloc = (void *(*)(size_t, size_t))dlsym(RTLD_NEXT, "calloc");
    real_realloc = (void *(*)(void *, size_t))dlsym(RTLD_NEXT, "realloc");
    real_free = (void (*)(void *))dlsym(RTLD_NEXT, "free");
}

static void record_alloc(size_t size) {
    size_t live = atomic_fetch_add(&live_bytes, size) + size;
    size_t peak = atomic_load(&peak_bytes);
    while (live > peak) {
        if (atomic_compare_exchange_weak(&peak_bytes, &peak, live)) {
            break;
        }
    }
}

void alloc_track_reset_peak(void) {
    atomic_store(&peak_bytes, atomic_load(&live_bytes));
}

size_t alloc_track_live(void) { return atomic_load(&live_bytes); }
size_t alloc_track_peak(void) { return atomic_load(&peak_bytes); }

void *malloc(size_t size) {
    init_reals();
    void *p = real_malloc(size);
    if (p) {
        record_alloc(malloc_usable_size(p));
    }
    return p;
}

void *calloc(size_t nmemb, size_t size) {
    init_reals();
    void *p = real_calloc(nmemb, size);
    if (p) {
        record_alloc(malloc_usable_size(p));
    }
    return p;
}

void *realloc(void *ptr, size_t size) {
    init_reals();
    size_t old_size = 0;
    if (ptr) {
        old_size = malloc_usable_size(ptr);
    }
    void *p = real_realloc(ptr, size);
    if (p) {
        atomic_fetch_sub(&live_bytes, old_size);
        record_alloc(malloc_usable_size(p));
    } else if (size == 0) {
        atomic_fetch_sub(&live_bytes, old_size);
    }
    return p;
}

void free(void *ptr) {
    init_reals();
    if (ptr) {
        atomic_fetch_sub(&live_bytes, malloc_usable_size(ptr));
        real_free(ptr);
    }
}

ALLOC

RUN cat > main.swift <<'SWIFT'
import Foundation
#if canImport(Glibc)
import Glibc
#elseif canImport(Darwin)
import Darwin
#endif

@_silgen_name("alloc_track_live") func alloc_track_live() -> Int
@_silgen_name("alloc_track_peak") func alloc_track_peak() -> Int
@_silgen_name("alloc_track_reset_peak") func alloc_track_reset_peak()

extension UnsafeMutableBufferPointer where Element == Int {
    func swapAt(_ i: Int, _ j: Int) {
        let t = self[i]; self[i] = self[j]; self[j] = t
    }
}

let MIN_POWER: Int = 8
let MAX_POWER: Int = 18
let RUNS: Int = 8192
func merge_values(_ left: UnsafeBufferPointer<Int>, _ right: UnsafeBufferPointer<Int>) -> [Int] {
    var out = [Int]()
    out.reserveCapacity(left.count + right.count)
    var l = 0
    var r = 0
    while l < left.count && r < right.count {
        if left[l] <= right[r] {
            out.append(left[l])
            l += 1
        } else {
            out.append(right[r])
            r += 1
        }
    }
    while l < left.count {
        out.append(left[l])
        l += 1
    }
    while r < right.count {
        out.append(right[r])
        r += 1
    }
    return out
}

func merge_values(_ left: [Int], _ right: [Int]) -> [Int] {
    left.withUnsafeBufferPointer { leftBuf in
        right.withUnsafeBufferPointer { rightBuf in
            merge_values(leftBuf, rightBuf)
        }
    }
}



let REPLACEMENT_SELECTION_HEAP_SIZE = 32

func replacement_selection_sift_down(_ heap: inout [Int], _ i: Int) {
    var i = i
    let n = heap.count
    while true {
        let left = 2 * i + 1
        let right = left + 1
        var smallest = i
        if left < n && heap[left] < heap[smallest] {
            smallest = left
        }
        if right < n && heap[right] < heap[smallest] {
            smallest = right
        }
        if smallest == i {
            break
        }
        heap.swapAt(i, smallest)
        i = smallest
    }
}

func replacement_selection_sift_up(_ heap: inout [Int], _ i: Int) {
    var i = i
    while i > 0 {
        let parent = (i - 1) / 2
        if heap[i] >= heap[parent] {
            break
        }
        heap.swapAt(i, parent)
        i = parent
    }
}

func replacement_selection_heapify_min(_ heap: inout [Int]) {
    if heap.count <= 1 {
        return
    }
    for i in (0..<(heap.count / 2)).reversed() {
        replacement_selection_sift_down(&heap, i)
    }
}

func replacement_selection_heap_push(_ heap: inout [Int], _ value: Int) {
    heap.append(value)
    let i = heap.count - 1
    replacement_selection_sift_up(&heap, i)
}

func replacement_selection_heap_pop_min(_ heap: inout [Int]) -> Int {
    let n = heap.count
    precondition(n > 0)
    let min = heap[0]
    let last = heap.removeLast()
    if !heap.isEmpty {
        heap[0] = last
        replacement_selection_sift_down(&heap, 0)
    }
    return min
}

func replacement_selection_generate_runs(_ input: UnsafeBufferPointer<Int>, _ mem: Int) -> [[Int]] {
    let n = input.count
    var runs = [[Int]]()
    if n == 0 {
        return runs
    }

    let m = max(min(mem, n), 1)
    var i = 0
    var heap = [Int]()
    heap.reserveCapacity(m)
    while i < n && heap.count < m {
        heap.append(input[i])
        i += 1
    }
    replacement_selection_heapify_min(&heap)

    var frozen = [Int]()
    frozen.reserveCapacity(m)
    var run = [Int]()

    while true {
        if heap.isEmpty {
            if !run.isEmpty {
                runs.append(run)
                run = []
            }
            if frozen.isEmpty && i >= n {
                break
            }
            heap = frozen
            frozen = []
            while i < n && heap.count < m {
                heap.append(input[i])
                i += 1
            }
            if heap.isEmpty {
                break
            }
            replacement_selection_heapify_min(&heap)
            continue
        }

        let out = replacement_selection_heap_pop_min(&heap)
        run.append(out)

        if i < n {
            let next = input[i]
            i += 1
            if next >= out {
                replacement_selection_heap_push(&heap, next)
            } else {
                frozen.append(next)
            }
        }
    }

    return runs
}

func replacement_selection_merge_all_runs(_ runs: [[Int]]) -> [Int] {
    if runs.isEmpty {
        return []
    }
    var queue = runs
    while queue.count > 1 {
        var next = [[Int]]()
        next.reserveCapacity((queue.count + 1) / 2)
        var idx = 0
        while idx + 1 < queue.count {
            next.append(merge_values(queue[idx], queue[idx + 1]))
            idx += 2
        }
        if idx < queue.count {
            next.append(queue[idx])
        }
        queue = next
    }
    return queue.popLast() ?? []
}

func replacement_selection_sort(_ a: inout [Int]) {
    a.withUnsafeMutableBufferPointer { replacement_selection_sort($0) }
}

func replacement_selection_sort(_ a: UnsafeMutableBufferPointer<Int>) {
    let n = a.count
    if n <= 1 {
        return
    }
    let runs = replacement_selection_generate_runs(
        UnsafeBufferPointer(a),
        REPLACEMENT_SELECTION_HEAP_SIZE
    )
    let sorted = replacement_selection_merge_all_runs(runs)
    for i in 0..<n {
        a[i] = sorted[i]
    }
}


func benchmark_sort(_ array: inout [Int]) {

    replacement_selection_sort(&array)

}

func is_non_decreasing(_ a: [Int]) -> Bool {
    guard a.count >= 2 else { return true }
    for i in 1..<a.count {
        if a[i - 1] > a[i] { return false }
    }
    return true
}

func same_multiset(_ a: [Int], _ b: [Int]) -> Bool {
    if a.count != b.count {
        return false
    }

    var left = a
    var right = b
    left.sort()
    right.sort()
    return left == right
}

func check_correctness_case(_ label: String, _ input: [Int]) {
    var input = input
    let original = input

    benchmark_sort(&input)

    if !is_non_decreasing(input) {
        fatalError("correctness case \(label): output is not sorted")
    }

    if !same_multiset(input, original) {
        fatalError("correctness case \(label): elements were lost or added")
    }
}

// Skip cases larger than the algorithm's measured size cap (MAX_POWER). That
// cap exists because larger inputs are impractically slow; forcing them here
// would stall the published measurement script before any table rows print.
func check_correctness_case_within_limit(_ label: String, _ input: [Int]) {
    if input.count > (1 << MAX_POWER) {
        return
    }
    check_correctness_case(label, input)
}

func few_unique_values(_ size: Int, _ unique: Int, _ seed: UInt64) -> [Int] {
    var state = seed
    var result = [Int]()
    result.reserveCapacity(size)
    for _ in 0..<size {
        state ^= state << 13
        state ^= state >> 7
        state ^= state << 17
        result.append(Int(state % UInt64(unique)) + 1)
    }
    return result
}

func run_correctness_checks() {
    check_correctness_case("empty", [])
    check_correctness_case("single", [42])
    check_correctness_case("duplicates", [3, 1, 3, 2, 1, 2])
    check_correctness_case("sorted", [1, 2, 3, 4, 5])
    check_correctness_case("reverse", [5, 4, 3, 2, 1])
    check_correctness_case("all_equal", [7, 7, 7, 7])
    check_correctness_case("skewed_range", [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",
        [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(
            "few_keys_len32_seed_\(seed)",
            few_unique_values(32, 4, UInt64(seed))
        )
    }
    // Small-input cutoffs (insertion sort below 32 elements, etc.) hide duplicate-key
    // bugs in the recursive path, so repeat the duplicate cases at the smallest
    // benchmark size, which every algorithm must handle within reasonable time.
    check_correctness_case("all_equal_len256", [Int](repeating: 7, count: 256))
    for seed in 1...4 {
        check_correctness_case(
            "few_keys_len256_seed_\(seed)",
            few_unique_values(256, 4, UInt64(seed))
        )
    }
    // Blit's equal-key second sweep used to copy the whole range into a fixed
    // 512-element swap; lengths above that must still sort without panicking.
    // Respect MAX_POWER so algorithms with a low measured-size cap (slow,
    // sleep) do not hang here for minutes or months.
    check_correctness_case_within_limit("all_equal_len600", [Int](repeating: 7, count: 600))
    for seed in 1...4 {
        check_correctness_case_within_limit(
            "few_keys_len2048_seed_\(seed)",
            few_unique_values(2048, 4, UInt64(seed))
        )
    }
}


func shuffled(_ size: Int, seed: UInt64) -> [Int] {
    guard size > 0 else { return [] }

    var v = Array(1...size)
    var state = seed

    if size > 1 {
        for i in stride(from: size - 1, through: 1, by: -1) {
            state ^= state << 13
            state ^= state >> 7
            state ^= state << 17

            let j = Int(state % UInt64(i + 1))
            v.swapAt(i, j)
        }
    }

    return v
}

func micros(_ d: Duration) -> UInt64 {
    let c = d.components
    let fromSeconds = UInt64(c.seconds) * 1_000_000
    let fromAttos = UInt64(max(0, c.attoseconds / 1_000_000_000_000))
    return fromSeconds + fromAttos
}

func padLeft(_ value: String, _ width: Int) -> String {
    if value.count >= width {
        return value
    }
    return String(repeating: " ", count: width - value.count) + value
}

func formatSeconds(_ micros: UInt64) -> String {
    let whole = micros / 1_000_000
    let frac = micros % 1_000_000
    let fracStr = padLeft(String(frac), 6).replacingOccurrences(of: " ", with: "0")
    return "\(whole).\(fracStr)"
}

func input_array(_ size: Int, seed: UInt64) -> [Int] {
    shuffled(size, seed: seed)
}

/// Peak heap growth during `benchmark_sort`, in bytes (explicit buffers such as swap).
/// Kept in bytes so the parent can average before rounding; converting to KiB here
/// would truncate sub-KiB buffers to 0 in every run and hide them from the average.
func run_once(size: Int, seed: Int) -> (UInt64, Int) {
    var array = input_array(size, seed: UInt64(seed))

    let baseBytes = alloc_track_live()
    alloc_track_reset_peak()

    let start = ContinuousClock.now

    benchmark_sort(&array)

    let elapsed = ContinuousClock.now - start
    let peakBytes = alloc_track_peak()
    let auxBytes = max(0, peakBytes - baseBytes)

    let expected: [Int] = size > 0 ? Array(1...size) : []
    if array != expected {
        fatalError("sort failed with seed \(seed) for size \(size)")
    }

    return (micros(elapsed), auxBytes)
}

func run_child(_ args: [String]) {
    let size = Int(args[2])!
    let seed = Int(args[3])!
    let (elapsedUs, mem) = run_once(size: size, seed: seed)
    print("\(elapsedUs) \(mem)")
}

let args = CommandLine.arguments
if args.count > 1 && args[1] == "--run-once" {
    run_child(args)
} else {
    run_correctness_checks()

    let tableHeader =
        "| \(padLeft("Size", 10)) | " +
        "\(padLeft("Average time (s)", 16)) | " +
        "\(padLeft("Maximum time (s)", 16)) | " +
        "\(padLeft("Average memory (KiB)", 20)) | " +
        "\(padLeft("Maximum memory (KiB)", 20)) |"
    print(tableHeader)
    print("|-----------:|-----------------:|-----------------:|---------------------:|---------------------:|")

    for power in MIN_POWER...MAX_POWER {
        let size = 1 << power

        var totalTime: UInt64 = 0
        var maxTime: UInt64 = 0

        var totalMem = 0
        var maxMem = 0

        for seed in 1...RUNS {
            let process = Process()
            process.executableURL = URL(fileURLWithPath: args[0])
            process.arguments = ["--run-once", "\(size)", "\(seed)"]
            let stdout = Pipe()
            let stderr = Pipe()
            process.standardOutput = stdout
            process.standardError = stderr

            do {
                try process.run()
            } catch {
                fatalError("failed to run benchmark child process: \(error)")
            }
            process.waitUntilExit()

            if process.terminationStatus != 0 {
                let err = String(data: stderr.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
                fatalError("benchmark child process failed: \(err)")
            }

            let data = stdout.fileHandleForReading.readDataToEndOfFile()
            let stdoutText = String(data: data, encoding: .utf8) ?? ""
            let fields = stdoutText.split(whereSeparator: \.isWhitespace)
            guard fields.count >= 2,
                  let elapsedUs = UInt64(fields[0]),
                  let auxMem = Int(fields[1]) else {
                fatalError("invalid child process output: \(stdoutText)")
            }

            totalTime += elapsedUs
            if elapsedUs > maxTime {
                maxTime = elapsedUs
            }

            totalMem += auxMem
            if auxMem > maxMem {
                maxMem = auxMem
            }
        }

        let avgTime = totalTime / UInt64(RUNS)
        // Memory is summed in bytes and converted to KiB once, after averaging.
        let avgMemKb = totalMem / RUNS / 1024
        let maxMemKb = maxMem / 1024

        let tableRow =
            "| \(padLeft(String(size), 10)) | " +
            "\(padLeft(formatSeconds(avgTime), 16)) | " +
            "\(padLeft(formatSeconds(maxTime), 16)) | " +
            "\(padLeft(String(avgMemKb), 20)) | " +
            "\(padLeft(String(maxMemKb), 20)) |"
        print(tableRow)
    }
}
SWIFT

RUN clang -O2 -fPIC -shared alloc_track.c -o liballoc_track.so -ldl

RUN swiftc -Ounchecked -whole-module-optimization \
    main.swift \
    -o swift-benchmark \
    -L. -lalloc_track \
    -Xlinker -rpath -Xlinker /app

ENV LD_PRELOAD=/app/liballoc_track.so
CMD ["./swift-benchmark"]
"""#
    try dockerfile.write(
        to: workdir.appendingPathComponent("Dockerfile"),
        atomically: true,
        encoding: .utf8
    )

    // Keeping build and run as separate child processes preserves Docker's
    // normal output and the original image tag used by the benchmark skill.
    try runCommand("docker", ["build", "-t", "swift-benchmark", workdir.path])
    try runCommand("docker", ["run", "--rm", "--init", "swift-benchmark"])
} catch {
    fputs("\(error)\n", stderr)
    exit(1)
}