コーディング · アルゴリズム
計数ソートの仕組み・コード・計算量をわかりやすく解説
結論
計数ソートは、サイズ k の既知の小さな範囲にある整数(または整数に対応づけられるキー)のための、比較を使わないソートアルゴリズムです。各値の出現回数を数え、累積和によって回数を開始位置に変換し、各要素をその位置に直接配置します。時間は O(n + k)、追加の空間も O(n + k) です。累積和を使う版は安定で、そのため基数ソートの構成要素になっています。
行の並べ替えを開く計数ソートが解決する問題
要素のペアを比較するだけのソートは、最悪ケースで Ω(n log n) 回の比較が必要です。計数ソートは、比較をまったく行わず、キーそのものを配列のインデックスとして使うことで、この限界を回避します。0〜100 の試験の点数、年齢、0〜255 のバイト値、星の評価のように、キーが小さな整数なら、どんな比較ソートよりも高速です。
代償は、キーの範囲に比例するメモリです。範囲全体に散らばった少数の64ビット整数をソートしようとすると、現実的でないほど大きなカウント用の配列が必要になります。
考え方と具体例
値が 0〜8 の範囲(k = 9)にある [4, 2, 2, 8, 3, 3, 1] をソートします。
- 各値を数える:
count[v]は v が現れる回数です。 - 累積和:各カウントを、その値以下の要素の個数に置き換えます。すると
count[v]は、v が入る最後の位置の1つ先を指します。 - 要素を配置する:入力を右から左へたどり、各値 v について
count[v]を1減らして、その位置に v を書き込みます。右から左へたどることで、等しい値は元の順序を保ちます。
| 値 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|---|
| カウント | 0 | 1 | 2 | 2 | 1 | 0 | 0 | 0 | 1 |
| 累積和 | 0 | 1 | 3 | 5 | 6 | 6 | 6 | 6 | 7 |
その結果、1 はインデックス 0 に、2 が2つはインデックス 1 と 2 に、3 が2つは 3 と 4 に、4 はインデックス 5 に、8 はインデックス 6 に入ります。結果は [1, 2, 2, 3, 3, 4, 8] です。
Pythonでの実装
この安定版は、0 から k - 1 までの整数キーで、任意のオブジェクトをソートできます。
def counting_sort(items, k, key=lambda x: x):
"""Stable sort of items by integer key in range(k)."""
count = [0] * k
for x in items:
count[key(x)] += 1
for v in range(1, k): # prefix sums
count[v] += count[v - 1]
out = [None] * len(items)
for x in reversed(items): # right to left: stable
count[key(x)] -= 1
out[count[key(x)]] = x
return out
print(counting_sort([4, 2, 2, 8, 3, 3, 1], k=9))
# [1, 2, 2, 3, 3, 4, 8]
people = [("Ana", 3), ("Bo", 1), ("Cy", 3), ("Di", 2)]
print(counting_sort(people, k=4, key=lambda p: p[1]))
# [('Bo', 1), ('Di', 2), ('Ana', 3), ('Cy', 3)]普通の整数だけをソートし、安定性が不要なら、カウントから出力を再構築するもっと単純な版が使えます。この版は、最小値の分だけずらすことで負の数も扱えます。
def counting_sort_ints(nums):
if not nums:
return []
lo, hi = min(nums), max(nums)
count = [0] * (hi - lo + 1)
for x in nums:
count[x - lo] += 1
out = []
for offset, c in enumerate(count):
out.extend([offset + lo] * c)
return out
print(counting_sort_ints([3, -1, 2, -1, 0])) # [-1, -1, 0, 2, 3]時間計算量と空間計算量
| 指標 | 値 | 理由 |
|---|---|---|
| 時間(すべてのケース) | O(n + k) | 入力の走査1回、カウント配列の走査1回、配置1回 |
| 追加の空間 | O(n + k) | サイズ k のカウント配列と、サイズ n の出力配列 |
| 比較回数 | 0 | キーは比較されず、インデックスとして使われる |
k が O(n) のとき、計数ソートは線形時間です。k が n よりずっと大きい場合、たとえば 0〜10⁹ の範囲に1,000個の値がある場合は、カウント配列の処理が支配的になり、比較ソートか基数ソートのほうが適しています。
計数ソートと他のソートの比較
| アルゴリズム | 最良 | 平均 | 最悪 | 追加の空間 | 安定 |
|---|---|---|---|---|---|
| 計数ソート | O(n + k) | O(n + k) | O(n + k) | O(n + k) | はい |
| 基数ソート(LSD) | O(d · (n + b)) | O(d · (n + b)) | O(d · (n + b)) | O(n + b) | はい |
| マージソート | O(n log n) | O(n log n) | O(n log n) | O(n) | はい |
| クイックソート | O(n log n) | O(n log n) | O(n²) | O(log n) | いいえ |
| ヒープソート | O(n log n) | O(n log n) | O(n log n) | O(1) | いいえ |
| 挿入ソート | O(n) | O(n²) | O(n²) | O(1) | はい |
Timsort(Pythonの sorted) | O(n) | O(n log n) | O(n log n) | O(n) | はい |
ここで k はキーの範囲のサイズ、d は桁数、b は基数ソートで使う基数です。累積和を使って右から左へ要素を配置する場合、計数ソートは安定です。カウントから再構築する単純な版は、普通の整数なら同じ結果になりますが、キーに付随する他のデータを運ぶことはできません。
派生と、よくある間違い
- 基数ソートは、桁ごとに安定な計数ソートを1回ずつ実行する方法で、この考え方を大きな整数や固定長の文字列に拡張したものです。
- バケットソートは、値を範囲ごとにバケットに振り分けて各バケットをソートします。値が一様に分布している場合に高速です。
- ソートしない数え上げ:ヒストグラムを作る、最頻値を求めるなど、必要なのがカウントだけの場面もよくあります。
collections.Counterが直接それを行います。
計数ソートを使う場面
キーが、要素数に比べて小さな範囲の整数である場合に使います。成績、年齢、月の日付、バイト値、0〜255 の色チャンネルの値、小さな列挙型の値などです。基数ソートや接尾辞配列の構築アルゴリズムの内部ステップでもあります。面接では、色のソート(値は 0、1、2)、頻度によるソート、H-index といった問題や、比較ソートの O(n log n) の限界をどう超えるかという定番の質問で登場します。
よくある質問
計数ソートは安定ですか?
はい。累積和を使って右から左に要素を配置する標準的な版は安定です。この性質があるからこそ、基数ソートは各桁にこれを使えます。
計数ソートの時間計算量は?
O(n + k) です。n は要素数、k はキーの範囲のサイズです。最良・平均・最悪のどのケースでも同じです。
計数ソートが O(n log n) より速いのはなぜですか?
Ω(n log n) という下限が当てはまるのは、比較ソートだけです。計数ソートは要素を一切比較せず、各キーを配列のインデックスとして使うため、その限界の対象になりません。
計数ソートは負の数を扱えますか?
はい。すべてのキーから最小値を引いて、最小のキーがインデックス 0 に対応するようにし、出力を書き込むときに足し戻します。
計数ソートを使うべきでないのはどんなときですか?
キーの範囲 k が n よりずっと大きい場合、キーが浮動小数点数や任意の文字列の場合、メモリが厳しい場合です。そのときは sorted() のような比較ソートのほうが適しています。