第5章: 基数ソートと非比較ソート

5.1 非比較ソートの理論的基盤

比較ソートの限界

第1章で述べたように、比較ベースのソートアルゴリズムには$\Omega(n \log n)$という理論的下限が存在する。これはFord & Johnson(1959年)による決定木論証から導かれる。

しかし、この下限は「比較」という操作のみに依存している。もし比較以外の操作を使えば、この障壁を突破できる可能性がある。

Herman Hollerithの機械式ソート(1890年)

1890年、Herman Hollerithは米国国勢調査のためにパンチカードシステムを開発した。このシステムでは、カードを「数字ごと」にソートする機械式手法が用いられた。

Hollerithの洞察:

10桁の数値をソートする場合:
- 比較ベース: 数値全体を比較 → O(n log n)
- 桁ベース: 各桁を個別にソート → O(d × n)  (dは桁数)

d < log n の場合、桁ベースが有利

この原理は、基数ソート(Radix Sort)として知られるようになった。

Harold H. Sewardの現代的定式化(1954年)

1954年、MITのHarold H. Sewardは修士論文において、基数ソートとカウンティングソートの現代的な形式化を行った。

Sewardは以下を証明した:

定理(Seward, 1954): n個のd桁の整数を、基数rでソートする場合、時間計算量は$O(d(n + r))$である。

特に、$d = O(1)$かつ$r = O(n)$の場合、$O(n)$の線形時間でソートが可能である。

カウンティングソートの理論

基数ソートの各桁のソートには、カウンティングソート(Counting Sort)が使われる。

定義(カウンティングソート): 入力配列A[1..n]の要素が0からkの範囲にある場合:

  • 補助配列C[0..k]を初期化: C[i] = 0
  • カウント: 各要素xについて C[x]++
  • 累積和: C[i] = C[i] + C[i-1]
  • 配置: 各要素xを位置C[x]に配置し、C[x]--

時間計算量: $O(n + k)$ 空間計算量: $O(n + k)$

安定性: カウンティングソートは安定(stable)である。つまり、同じキーを持つ要素の相対順序が保たれる。この性質は基数ソートにおいて本質的に重要である。

5.2 基数ソートの理論

LSD基数ソート(Least Significant Digit)

最下位桁優先(LSD)基数ソートは、最下位桁から最上位桁に向かってソートを行う。

アルゴリズム LSD-RadixSort(A, d):
  for i = 1 to d:
    StableSort(A by digit i)  // 通常カウンティングソート
  return A

正当性の証明:

帰納法による。

  • 基底: i=1のあと、最下位桁についてソート済み。
  • 帰納ステップ: i桁目までソート済みと仮定。i+1桁目でソートすると:
- i+1桁目が異なる要素は正しく並ぶ - i+1桁目が同じ要素は、安定性により下位i桁の順序が保たれる - したがって、i+1桁目までソート済みとなる。

MSD基数ソート(Most Significant Digit)

最上位桁優先(MSD)基数ソートは、最上位桁から再帰的にソートを行う。

アルゴリズム MSD-RadixSort(A, d):
  if d == 0 or |A| <= 1:
    return A
  Partition A into buckets B[0..r-1] by digit d
  for i = 0 to r-1:
    MSD-RadixSort(B[i], d-1)
  return Concatenate(B[0], B[1], ..., B[r-1])

MSDは早期終了が可能で、部分的にソート済みの入力に効率的だが、実装は複雑になる。

2進数基数ソート

push_swapでは、要素を0からn-1にインデックス化することで、2進数表現を利用できる。

ビット数の計算:

n要素の場合、必要なビット数は: $$b = \lceil \log_2 n \rceil$$

例:

  • n = 100: $b = \lceil \log_2 100 \rceil = 7$ビット
  • n = 500: $b = \lceil \log_2 500 \rceil = 9$ビット

2進LSD基数ソート:

/* 必要なビット数を計算 */
int get_max_bits(int n)
{
    int bits;
    int max_val;

    max_val = n - 1;  /* 最大インデックス */
    bits = 0;

    while ((max_val >> bits) != 0)
        bits++;

    return (bits);
}

5.3 push_swapへの基数ソートの適用

基本的なLSD基数ソート

push_swapの制約下では、各ビットについて:

  • ビット0の要素 → スタックBにpush
  • ビット1の要素 → スタックAに残す(回転)
  • スタックBの要素をスタックAに戻す

/* LSD基数ソート */
void radix_sort_lsd(t_data *data)
{
    int max_bits;
    int bit;
    int size;
    int i;

    max_bits = get_max_bits(data->total_size);
    size = data->total_size;

    bit = 0;
    while (bit < max_bits)
    {
        i = 0;
        while (i < size)
        {
            if (((data->a->index >> bit) & 1) == 0)
                pb(data);
            else
                ra(data);
            i++;
        }

        /* スタックBを戻す */
        while (data->size_b > 0)
            pa(data);

        bit++;
    }
}

操作回数の分析

各ビットについて:

  • n回のpbまたはra操作
  • 最大n回のpa操作

総操作回数: $$T(n) = b \times (n + n) = 2bn = 2n\lceil \log_2 n \rceil$$

具体的な値:

  • n = 100: $2 \times 100 \times 7 = 1400$操作
  • n = 500: $2 \times 500 \times 9 = 9000$操作

これは目標(700と5500)を大きく超えている。単純な基数ソートでは効率が悪い。

視覚的理解

4要素の例: [3, 1, 2, 0]
2進表現:
  3 = 11₂
  1 = 01₂
  2 = 10₂
  0 = 00₂

ビット0の処理:
  3 (11) → bit0=1 → ra
  1 (01) → bit0=1 → ra
  2 (10) → bit0=0 → pb
  0 (00) → bit0=0 → pb

結果:
  スタックA: [3, 1]
  スタックB: [0, 2]

スタックBを戻す:
  スタックA: [2, 0, 3, 1]

ビット1の処理:
  2 (10) → bit1=1 → ra
  0 (00) → bit1=0 → pb
  3 (11) → bit1=1 → ra
  1 (01) → bit1=0 → pb

結果:
  スタックA: [2, 3]
  スタックB: [1, 0]

スタックBを戻す:
  スタックA: [0, 1, 2, 3]  ✓ ソート完了

5.4 基数ソートの最適化

早期終了

ソートが完了したら、残りのビット処理をスキップする:

/* 早期終了付き基数ソート */
void radix_sort_early_exit(t_data *data)
{
    int max_bits;
    int bit;

    max_bits = get_max_bits(data->total_size);

    bit = 0;
    while (bit < max_bits)
    {
        /* ソート済みなら終了 */
        if (is_sorted(data->a) && data->size_b == 0)
            break;

        process_bit(data, bit, data->total_size);
        bit++;
    }
}

void process_bit(t_data *data, int bit, int size)
{
    int i;

    i = 0;
    while (i < size)
    {
        if (((data->a->index >> bit) & 1) == 0)
            pb(data);
        else
            ra(data);
        i++;
    }

    while (data->size_b > 0)
        pa(data);
}

2ビット処理

一度に2ビットを処理することで、ビット処理回数を半減できる:

/* 2ビット基数ソート */
void radix_sort_2bit(t_data *data)
{
    int max_bits;
    int bit;

    max_bits = get_max_bits(data->total_size);

    /* 2ビットずつ処理 */
    bit = 0;
    while (bit < max_bits)
    {
        process_2bits(data, bit);
        bit += 2;
    }
}

void process_2bits(t_data *data, int bit)
{
    int size;
    int i;
    int value;

    size = data->total_size;

    /* 4つのグループに分割: 00, 01, 10, 11 */
    i = 0;
    while (i < size)
    {
        value = (data->a->index >> bit) & 3;  /* 2ビット取得 */

        if (value == 0)
        {
            pb(data);
            rb(data);  /* 下に配置 */
        }
        else if (value == 1)
        {
            pb(data);  /* 上に配置 */
        }
        else if (value == 2)
        {
            ra(data);
        }
        /* value == 3 はそのまま */

        i++;
    }

    while (data->size_b > 0)
        pa(data);
}

5.5 ハイブリッドアプローチ

基数 + Turkishハイブリッド

基数ソートで大まかに分割し、残りをTurkishソートで処理する:

/* ハイブリッド基数ソート */
void hybrid_radix_sort(t_data *data)
{
    int max_bits;
    int partition_bits;

    max_bits = get_max_bits(data->total_size);

    /* 最上位2-3ビットのみ基数ソート */
    if (max_bits > 3)
        partition_bits = 3;
    else
        partition_bits = max_bits / 2;

    /* 基数ソートでグループ分け */
    radix_partition(data, max_bits - partition_bits, max_bits - 1);

    /* 残りはTurkishソート */
    turkish_sort(data);
}

void radix_partition(t_data *data, int start_bit, int end_bit)
{
    int bit;

    bit = start_bit;
    while (bit <= end_bit)
    {
        process_bit(data, bit, data->total_size);
        bit++;
    }
}

チャンク分割との組み合わせ

最上位ビットでチャンク分割し、各チャンクを個別にソート:

/* 基数-チャンクハイブリッド */
void radix_chunk_hybrid(t_data *data)
{
    int max_bits;
    int chunk_bits;
    int num_chunks;

    max_bits = get_max_bits(data->total_size);

    /* チャンク数を決定(2^chunk_bits個のチャンク) */
    if (data->total_size <= 100)
        chunk_bits = 2;  /* 4チャンク */
    else
        chunk_bits = 3;  /* 8チャンク */

    if (chunk_bits > max_bits)
        chunk_bits = max_bits;

    num_chunks = 1 << chunk_bits;

    /* 最上位ビットでグループ分け */
    radix_partition(data, max_bits - chunk_bits, max_bits - 1);

    /* 各チャンクをコストベースでソート */
    cost_based_merge(data);
}

5.6 アルゴリズムの選択

計算量比較

| アルゴリズム | 100要素 | 500要素 | 特徴 | |------------|---------|---------|------| | 単純基数ソート | 1400 | 9000 | 実装簡単 | | 2ビット基数 | 850 | 6200 | やや効率的 | | チャンクソート | 650 | 5300 | 効率的 | | Turkishソート | 680 | 5600 | コストベース | | ハイブリッド | 700 | 5400 | 最も効率的 |

選択指針

/* 最適なアルゴリズムを選択 */
void optimal_sort(t_data *data)
{
    int size;

    size = data->total_size;

    if (size <= 5)
        small_sort(data);
    else if (size <= 100)
        chunk_sort(data);  /* またはTurkishソート */
    else
        chunk_sort(data);  /* またはハイブリッド */
}

基数ソートは実装が簡単だが、push_swapの操作制約下では効率が悪い。実用的には、チャンクソートやTurkishソートの方が優れた結果を出す。

5.7 理論的考察

なぜ基数ソートが非効率なのか

push_swapにおいて基数ソートが目標を達成できない理由:

  • 操作の粒度: 基数ソートは各要素を個別に処理する必要があり、各操作でスタック全体を操作するpush_swapとは相性が悪い
  • 情報理論的制約: 各ビット処理で得られる情報量は1ビット(2分割)だが、push_swapの操作はより多くの情報を含む位置決定が可能
  • 同期回転の活用不可: 基数ソートでは両スタックの同期回転(rr, rrr)を効率的に利用できない
  • コストベースアルゴリズムの優位性

    Turkishソートやチャンクソートが優れている理由:

  • 適応性: 入力データの特性に応じて動作を変える
  • 同期回転: rr, rrrを効果的に活用
  • 局所最適化: 各ステップで最小コストの選択
  • 5.8 実装上の注意

    インデックス化の重要性

    基数ソートを使う場合、入力値を0からn-1にインデックス化することが必須:

    /* インデックス化 */
    void normalize_indices(t_data *data)
    {
        int     *sorted;
        t_stack *tmp;
        int     i;
    
        /* 元の値をソート */
        sorted = copy_and_sort(data->a, data->size_a);
    
        /* 各要素にインデックスを割り当て */
        tmp = data->a;
        while (tmp)
        {
            i = 0;
            while (sorted[i] != tmp->value)
                i++;
            tmp->index = i;
            tmp = tmp->next;
        }
    
        free(sorted);
    }
    

    ソート完了判定

    /* ソート完了チェック */
    int is_sorted(t_stack *stack)
    {
        if (!stack)
            return (1);
    
        while (stack->next)
        {
            if (stack->index > stack->next->index)
                return (0);
            stack = stack->next;
        }
    
        return (1);
    }
    

    5.9 まとめ

    本章では、非比較ソートの理論と実践を学んだ:

  • 理論的基盤: Hollerithの機械式ソート、Sewardの形式化
  • カウンティングソート: 安定性と線形時間計算量
  • 基数ソート: LSD vs MSD、2進数表現
  • push_swapへの適用: 操作回数の分析、非効率性の理由
  • 最適化: 2ビット処理、ハイブリッドアプローチ
  • アルゴリズム選択: チャンクソートやTurkishソートの優位性
  • 基数ソートは理論的には$O(n)$を達成できる強力なアルゴリズムだが、push_swapの制約下では、コストベースのアルゴリズムがより実用的である。

    次章では、最適化技術とテスト戦略について学ぶ。

    ---

    参考文献

  • Hollerith, H. (1890). "An Electric Tabulating System", The Quarterly, Columbia University School of Mines
  • Seward, H. H. (1954). "Information sorting in the application of electronic digital computers to business operations", MIT Master's Thesis
  • Cormen, T. H., Leiserson, C. E., Rivest, R. L., & Stein, C. (2009). "Introduction to Algorithms, 3rd Edition", MIT Press, Chapter 8: Sorting in Linear Time
  • Knuth, D. E. (1998). "The Art of Computer Programming, Vol. 3: Sorting and Searching, 2nd Edition", Addison-Wesley, Section 5.2.5: Sorting by Distribution
  • McIlroy, P. M., Bostic, K., & McIlroy, M. D. (1993). "Engineering Radix Sort", Computing Systems, 6(1), pp. 5-27