Dev Study
← アルゴリズムの手法 — CLRS を読むための地図
レッスン 4 / 13·約 9 分·第29期 アルゴリズムの手法 — CLRS を読むための地図

キーの形を知っていれば床をくぐれる — 比較しない整列

アプリと DB の下に何が積まれているか(左が下、右が上)
  1. 物理機リージョン/AZ
  2. ハイパーバイザVM exit で捕まえる
  3. VMゲストOS
  4. コンテナnamespace / cgroup
  5. プロセスアプリ / DB今ここ

第11期の『比較で並べ替える限界』は、比較しない整列の計算量に踏み込まずに止めました。今回はその続きで、比較の代わりに何を前提にしているかを読みます。

まず結論

比較ソートの(平均の)下限は「2つを比べる」以外の情報を使わない、という前提の上に立っています。キーが「狭い範囲の整数」や「桁に分けられる値」だと分かっていれば、比べずに置き場所を直接計算でき、床の外に出られます。

使っている構造は「キーの形についての知識」です。代わりに新しい前提が1つ増えます。桁ごとに並べる基数ソートは、2回目以降の各回が安定(同じキーの元の順序を保つ)でないと結果が壊れます。

? 読む前に予測

0〜150 の年齢200人分を、各値の個数を数えて並べ直す方法(計数ソート)で整列します。要素どうしの比較は何回起きるでしょうか。

当てることが目的ではありません。外した予測ほど記憶に残るので、直感で選んでください。

抽象原理

床は「比べることしかしない」という前提の上に立っている

第11期の『比較で並べ替える限界』で、比較ソートには(全順列にわたる平均で)n log n 程度の下限があると見ました。あの下限が数えているのは比較の回数で、「2つを比べて大小を知る」以外の手段を使わない並べ方だけが対象です。

では、比べずに済むとしたらどうでしょう。年齢のようにキーが 0〜150 の整数だと最初から分かっているなら、値そのものを添字にできます。

  1. 数える — 値ごとの個数を数える
  2. 累積する — 個数を前から足し、各値の置き場所の先頭にする
  3. 置く — 入力を前から順に見て、その値の置き場所へ置き、先頭を1つ進める

どこにも「2つを比べる」が出てきません。置き場所が計算で決まるからです。

計数ソート — 比べずに置き場所を計算する(キーが 0〜k−1 の整数のとき)
入力キーは 0〜150 の整数
1. 値を添字にして個数を数える
個数の表151 個の箱
2. 前から足して置き場所の先頭にする
累積の表値 v の先頭 = v 未満の個数
3. 入力を前から見て、先頭に置き1つ進める
出力比較 0 回
  • ・使っている構造は「キーが狭い範囲の整数だ」という知識
  • ・前から順に置くので、同じキーの元の順序が保たれる(安定)
具体実物

数えてみると 0 回 — そして 1954 年の報告にある同じ手順

手順をそのまま書くとこうなります。

def counting_sort(items, k, key):
    count = [0] * (k + 1)
    for it in items:                 # 1. 数える
        count[key(it) + 1] += 1
    for v in range(k):               # 2. 累積して置き場所の先頭にする
        count[v + 1] += count[v]
    out = [None] * len(items)
    for it in items:                 # 3. 前から順に置く
        out[count[key(it)]] = it
        count[key(it)] += 1
    return out

比較演算子が呼ばれるたびに数える int のサブクラスを作り、0〜150 の乱数200個で測りました(Docker python:3.14-slim、2026-10-07。測ったのは個数を数えて値を並べ直す簡略版)。

比較回数の実測(Python 3.14.7 / aarch64 Linux)コマンド出力
python 3.14.7 aarch64 Linux
counting sort n=200 (ages 0..150): comparisons = 0 correct = True
builtin sorted() same input: comparisons = 1275

計数ソートは 0 回、組み込みの sorted() は 1,275 回。

この「数える → 累積 → 置く」は、MIT Digital Computer Laboratory の Report R-232(Harold H. Seward、1954年)の §2.4.6 "Internal Sorting by Floating Digital Sort" に書かれています。

the items are counted before each pass to obtain the number of storage locations required for each value of the digit on the next pass.

続く Phase I で個数を数え、Phase II で累積し、Phase III で2回目の走査をして置きます。ただし本文に "counting sort" という語はありません。 前書きは "this thesis report" と書きますが学位の種別は確かめられておらず、第2章冒頭は桁ごとの分配を "the same type of sorting as that used with punched-card sorters" と書いています。誰が最初かは、この報告からは言えません。

✓ ここまでの確認

計数ソートが比較を1回もせずに並べられるのは、何を前提にしているからですか。

抽象原理

桁に分けられるなら、下の桁から安定に並べればよい

範囲が広いキーはどうするか。IPv4 アドレスは32ビットの整数で、そのまま計数ソートにすると箱が 2^32 個要ります。そこでキーを桁に分けます。8ビットずつなら1桁は 0〜255、箱は256個です。

LSD 基数ソートは最下位の桁から1桁ずつ並べ替えを4回繰り返します。1回目の後は最下位の桁で並び、2回目は2桁目で並べ替えます。このとき2桁目が同じものどうしは、1回目で作った「最下位の桁の順」のまま残っていなければなりません。

ここで要るのが安定(stable)、つまり並べ替えのキーが同じ要素どうしの元の順序を保つ性質です。各回が安定なら、i 回目の後には下位 i 桁で正しく並んでいます。前から順に置く計数ソートは安定なので、1回分の部品にそのまま使えます。

手間は、回数を d、1桁の取りうる値(箱の数)を k とすると、1回で n 個を配り k 個の箱を見るので d × (n + k) 程度です。IPv4 を8ビットずつなら d = 4、k = 256。前提は「キーが決まった桁数に分けられる」ことで、桁数が入力によって伸びるなら d も伸びます。

具体実物

1回を不安定にすると壊れる — そして NumPy の実物

同じ桁の箱の中を逆順に戻す「安定でない」1回を、4回のうち1つだけ混ぜました。1,000本の乱数アドレスと2本の最小例です(上と同じ環境)。

LSD 基数ソート、1回だけ不安定にした結果(pass 0 = 1回目 = 最下位バイト)コマンド出力
LSD radix, all 4 passes stable: correct = True
pass 0 (bits 0-7) unstable: correct = True
pass 1 (bits 8-15) unstable: correct = True
pass 2 (bits 16-23) unstable: correct = False
pass 3 (bits 24-31) unstable: correct = False
minimal example input: ['10.0.0.1', '10.0.0.2']
all stable -> ['10.0.0.1', '10.0.0.2'] correct = True
last pass unstable -> ['10.0.0.2', '10.0.0.1'] correct = False

3回目・4回目を崩すと壊れ、2本だけの例でも最後の回を崩すと逆転します。 10.0.0.1 と 10.0.0.2 は最上位バイトが同じなので、最後の回で箱の中を逆にすると、それまでの順が失われます。

1回目は崩しても壊れません。 1回目より前の並びは入力の順で、守るべき情報がまだ無いからです。2回目が壊れなかったのは、この1,000本でたまたまそうだっただけで、保証ではありません。10.0.1.1 と 10.0.1.2 の2本なら、3回目を崩しても2回目を崩しても逆転します(2回目は手元の Python 3.14.6 / macOS arm64 で確認)。

実際のライブラリにもあります。NumPy v2.5 の numpy.sort 公式文書(2026-10-07 取得)の Notes です。

'mergesort' and 'stable' are mapped to radix sort for integer data types. Radix sort is an O(n) sort instead of O(n log n).

どのアルゴリズムが選ばれたかを実行時に返す API は無いので、ここはソースを読むしかありません。

✓ ここまでの確認

IPv4 アドレスを最下位の8ビットから4回に分けて並べる LSD 基数ソートで、どの回なら「同じ桁の中の順序を崩す(安定でない)並べ方」にしても結果が必ず正しいですか。

⇄ 2つを見比べる
具体例 A

計数ソート — 年齢 0〜150

キーそのものを添字にし、数える・累積する・置くの3段で終わります。200個で比較は 0 回。前提は「キーが狭い既知の範囲の整数」で、箱は範囲の大きさだけ要ります。

具体例 B

LSD 基数ソート — IPv4 を8ビットずつ4回

32ビットを4桁に分け、下の桁から計数ソートを4回掛けます。箱は256個で済みます。前提は「決まった桁数に分けられる」ことと「2回目以降の各回が安定」で、上位の回を崩すと結果が壊れます。

範囲が狭い整数をそのまま数える計数ソートと、桁に分けて4回並べる基数ソート。床をくぐるために、それぞれ入力に何を要求しているでしょうか。

「箱はいくつ要るか」と「前提が崩れたときに何が起きるか」を並べてみてください。

この章のまとめ

  • 比較ソートの下限は「比べることしかしない」並べ方の話。 キーの形を知っていれば、比べずに置き場所を計算して床の外に出られる。
  • 計数ソートは「数える → 累積 → 置く」。 0〜150 の200個で比較 0 回、同じ入力の sorted() は 1,275 回(Python 3.14.7 / aarch64 Linux)。
  • この手順は Seward 1954 の MIT 報告 R-232 §2.4.6 に書かれている。 ただし "counting sort" の語は無く、初出かどうかは言えない。
  • 基数ソートは桁に分けて下から並べる。 手間は d × (n + k) 程度。2回目以降の各回が安定でないと壊れ、最上位の回を崩すと 10.0.0.1 と 10.0.0.2 が逆転する。最下位の1回目だけは崩しても壊れない。
  • NumPy の公式文書は「整数型は基数ソート」と書くが、ソースでは bool と 8・16 ビット整数の5型だけ、1.17.0 のリリースノートは 16 bits or less と書く。
  • 既存クイズでは、「アルゴリズムとデータ構造」の algorithm-11・algorithm-6、「II(応用編)」の algorithm-2-15 がこの章の結論の一行版です。
  • CLRS(『アルゴリズムイントロダクション』)の目次や索引で counting sort / radix sort(計数ソート・基数ソート) を探すと、この回の内容に対応する章や節が見つかります。章の題と番号は版で変わるので、手元の目次で確かめてください。

自分の現場で確かめる

読んだだけでは身につきません。手元の環境で実際に見てみてください。

環境:

理解度チェック

読んだ直後は「分かった」と感じやすいものです。実際に解いて確かめてください。

問題 1 / 3正解: 0

NumPy の numpy.sort 公式文書は「'mergesort' and 'stable' are mapped to radix sort for integer data types」と書いています。ソースとリリースノートを合わせて読むと、基数ソートが使われる型について何が言えますか。

用語集(3語)
計数ソート
キーが 0〜k−1 の整数だと分かっているとき、値ごとの個数を数え、累積して置き場所の先頭を決め、前から順に置く整列。要素どうしを比較しない。
LSD 基数ソート
キーを桁に分け、最下位の桁から1桁ずつ並べ替えを繰り返す整列。LSD は least significant digit の略。2回目以降の各回の並べ替えが安定であることが前提(最下位の1回目は守るべき順序がまだ無い)。
安定(stable)
並べ替えのキーが同じ要素どうしの元の順序を、並べ替えた後も保つ性質。基数ソートでは、前の回で作った下位の桁の順序を守るために必要になる。