コーディング · アルゴリズム
ヒープソートの仕組み・コード・計算量をわかりやすく解説
結論
ヒープソートは比較ベースのソートで、まず配列を最大ヒープに並べ替えます。最大ヒープとは、配列に格納された二分木で、すべての親が子以上の大きさを持つものです。次に、最大の要素(根)を配列の末尾に交換して移し、ヒープを1つ縮め、新しい根を下へシフトダウンしてヒープの性質を回復する、という操作を繰り返します。どのケースでも O(n log n) 時間で動作し、追加の空間 O(1) でインプレースにソートできますが、安定ではなく、実際には通常クイックソートより遅くなります。
行の並べ替えを開く考え方を平易に説明
ヒープソートは、最大値をより速く見つける方法を使った選択ソートです。選択ソートは毎回、未ソート部分全体を O(n) で走査します。ヒープソートは未ソート部分をヒープとして保つため、最大値は常にインデックス 0 にあり、それを取り除いた後の順序の回復は O(log n) で済みます。
二分ヒープは、ポインタを使わず、普通の配列に格納されます。インデックス i の要素については次のようになります。
- 左の子は
2*i + 1、右の子は2*i + 2にあります。 - 親は
(i - 1) // 2にあります。 - 最大ヒープでは、すべての親が子以上なので、最大の要素はインデックス 0 にあります。
- 最後のものから 0 番に向かって、葉でないすべてのノードをシフトダウンして、配列から最大ヒープを作ります。
- 根(最大値)をヒープの最後の要素と交換します。その要素は最終位置に収まります。
- ヒープを1つ縮め、ヒープの性質が再び成り立つまで、新しい根をシフトダウンします。
- ヒープの要素が1つになるまで、手順2と3を繰り返します。
具体例をステップごとに見る
[4, 10, 3, 5, 1] をソートします。バーの後ろの要素はソート済みで確定です。
| ステップ | 操作 | 配列 |
|---|---|---|
| 開始 | [4, 10, 3, 5, 1] | |
| 構築 1 | インデックス 1(10)をシフトダウン:子の 5 と 1 より大きいので変更なし | [4, 10, 3, 5, 1] |
| 構築 2 | インデックス 0(4)をシフトダウン:10 と交換し、次に 5 と交換 | [10, 5, 3, 4, 1] 最大ヒープ |
| 取り出し 1 | 10 を末尾へ交換し、1 をシフトダウン:5 と交換し、次に 4 と交換 | [5, 4, 3, 1 | 10] |
| 取り出し 2 | 5 を末尾へ交換し、1 をシフトダウン:4 と交換 | [4, 1, 3 | 5, 10] |
| 取り出し 3 | 4 を末尾へ交換し、3 をシフトダウン:子に大きいものはなし | [3, 1 | 4, 5, 10] |
| 取り出し 4 | 3 を末尾へ交換 | [1 | 3, 4, 5, 10] ソート完了 |
Pythonでの実装
def sift_down(a, root, end):
"""Restore the max-heap property for a[root..end-1]."""
while True:
child = 2 * root + 1
if child >= end:
return
if child + 1 < end and a[child + 1] > a[child]:
child += 1 # pick the larger child
if a[root] >= a[child]:
return
a[root], a[child] = a[child], a[root]
root = child
def heapsort(a):
"""Sort list a in place, ascending."""
n = len(a)
for i in range(n // 2 - 1, -1, -1): # build the max-heap
sift_down(a, i, n)
for end in range(n - 1, 0, -1):
a[0], a[end] = a[end], a[0] # move the max to the end
sift_down(a, 0, end)
return a
print(heapsort([4, 10, 3, 5, 1])) # [1, 3, 4, 5, 10]
print(heapsort([2, 2, 1])) # [1, 2, 2]Pythonの heapq モジュールは、普通のリスト上の最小ヒープを提供します。すべての要素を順に取り出せばソート済みリストが得られ、これは(インプレースではない)短いヒープソートになります。
import heapq
def heapsort_heapq(items):
h = list(items)
heapq.heapify(h) # O(n)
return [heapq.heappop(h) for _ in range(len(h))]
print(heapsort_heapq([4, 10, 3, 5, 1])) # [1, 3, 4, 5, 10]
print(heapq.nlargest(2, [4, 10, 3, 5, 1])) # [10, 5]時間計算量と空間計算量
| ケース | 時間 | 理由 |
|---|---|---|
| ヒープの構築 | O(n) | ほとんどのノードは下のほうにあり、シフトダウンする距離が短い |
| 最良 | O(n log n) | n − 1 回の取り出しがあり、それぞれのシフトダウンは最大 log₂ n 段階(キーがすべて異なる場合) |
| 平均 | O(n log n) | 順序に関係なく作業量は同じ |
| 最悪 | O(n log n) | 木の高さは常に約 log₂ n |
| 追加の空間 | O(1) | ヒープは入力配列の中にあり、シフトダウンはループで行う |
ボトムアップのヒープ構築は O(n log n) ではなく O(n) です。完全二分木のノードの高さの総和が O(n) になるためです。全体の時間を支配するのは、取り出しの段階です。
ヒープソートと他のソートの比較
| アルゴリズム | 最良 | 平均 | 最悪 | 追加の空間 | 安定 |
|---|---|---|---|---|---|
| ヒープソート | O(n log n) | O(n log n) | O(n log n) | O(1) | いいえ |
| クイックソート | 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(n) | はい |
| 選択ソート | O(n²) | O(n²) | O(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) | はい |
ヒープソートは安定ではありません。根を末尾に交換する際に、同じキーを持つ他の要素を飛び越えて要素が運ばれてしまうことがあるためです。主な強みは、最悪ケースの O(n log n) の保証と追加の空間 O(1) を両立している点で、これはクイックソートにもマージソートにもありません。弱点はメモリアクセスです。インデックス i と 2*i + 1 の間を飛び回るため CPU キャッシュをうまく使えず、うまく実装されたクイックソートよりも通常は遅くなります。イントロソートは、クイックソートの再帰が深くなりすぎたときのフォールバックとして、ヒープソートを使います。
派生と、よくある間違い
- 部分ソート:大きいほうから k 個だけが欲しい場合は、ヒープを作って k 回取り出します。計算量は O(n + k log n) です。Pythonでは
heapq.nlargestとheapq.nsmallestがこれを行います。 - ストリームからの上位 k 件:サイズ k の最小ヒープを保ち、新しい要素ごとに push または置き換えます。時間は O(n log k)、メモリは O(k) です。
- ボトムアップヒープソートは、穴をまず葉までシフトダウンしてから上に戻すことで、比較回数を減らします。
- スムースソートは、ダイクストラによるヒープベースの派生で、すでにソート済みの入力では O(n) で動作しますが、複雑なためほとんど使われません。
ヒープソートを使う場面
追加のメモリなしで、最悪ケースの O(n log n) が保証されている必要がある場合に使います。たとえば組み込みシステムやリアルタイムシステム、あるいは他のソートの内部の安全なフォールバックとしてです。日常的なPythonコードでは、安定で、一般的なデータでより高速な sorted() を使ってください。ソートよりも、ヒープそのもののほうが役に立ちます。優先度付きキュー、ダイクストラ法による最短経路、スケジューラ、k 個のソート済みリストのマージ(heapq.merge)、上位 k 個の値の探索は、すべてヒープを土台にしています。面接では、ヒープのインデックスの公式、ヒープの構築が O(n) になる理由、上位 k 件の問題がよく問われます。
よくある質問
ヒープソートは安定ですか?
いいえ。根を末尾に移してから要素をシフトダウンする過程で、等しいキーの相対的な順序が変わることがあります。
ヒープソートの時間計算量は?
最良・平均・最悪のどのケースでも O(n log n) です。ヒープの構築は O(n) で、n − 1 回の取り出しがそれぞれ O(log n) かかります。
ヒープソートはインプレースですか?
はい。ヒープはソート対象の配列の中に格納され、ソート済みの部分は末尾から伸びていくため、追加の空間は O(1) で済みます。
ヒープソートが実際にはクイックソートより遅いのはなぜですか?
どちらも平均で O(n log n) ですが、ヒープソートは離れた配列の位置の間を飛び回るのでキャッシュミスが増え、要素あたりの比較回数もクイックソートより多くなります。
Pythonで heapq を使ってソートするには?
heapq.heapify(list) を呼んでから heapq.heappop を繰り返すか、小さいほうから k 個なら heapq.nsmallest(k, items) を使います。完全なソートなら、sorted() のほうが簡単で高速です。