コーディング · アルゴリズム
クイックソートの仕組み・コード・計算量をわかりやすく解説
結論
クイックソートは、分割統治法に基づくソートアルゴリズムです。1つの要素をピボットとして選び、ピボットより小さい要素が前、大きい要素が後ろに来るように配列を並べ替え(パーティション)、その後、両側を再帰的にソートします。平均で O(n log n) 時間で動作し、O(log n) のスタック空間でインプレースにソートしますが、ピボットが常に悪いと O(n²) に悪化します。安定ではありません。ランダムなピボットや三点中央値のピボットを使えば、実用上は最速クラスの汎用ソートのひとつです。
行の並べ替えを開く考え方を平易に説明
- ピボットとなる要素を選びます。たとえば最後の要素やランダムな要素です。
- パーティション:ピボットより小さい要素をすべてその左に、大きい要素をすべてその右に移動します。これでピボットは、ソート後の最終位置に収まります。
- ピボットの左側の部分と右側の部分を、それぞれ再帰的にソートします。その後に結合する作業は必要ありません。
マージソートは分割が簡単でマージが難しいアルゴリズムです。クイックソートはその逆で、パーティションが仕事をし、2つの半分は同じ配列内ですでに正しい位置にあるため、結合は不要です。
具体例:ロムートのパーティション1回分
[10, 80, 30, 90, 40, 50, 70] を、最後の要素 70 をピボットとしてパーティションします。インデックス i はピボットより小さい要素の領域の終端を示し、j は左から右へ走査します。
| j | items[j] | 70 より小さい? | 操作 | 操作後の配列 |
|---|---|---|---|---|
| 0 | 10 | はい | i = 0、0↔0 を交換 | [10, 80, 30, 90, 40, 50, 70] |
| 1 | 80 | いいえ | なし | [10, 80, 30, 90, 40, 50, 70] |
| 2 | 30 | はい | i = 1、1↔2 を交換 | [10, 30, 80, 90, 40, 50, 70] |
| 3 | 90 | いいえ | なし | [10, 30, 80, 90, 40, 50, 70] |
| 4 | 40 | はい | i = 2、2↔4 を交換 | [10, 30, 40, 90, 80, 50, 70] |
| 5 | 50 | はい | i = 3、3↔5 を交換 | [10, 30, 40, 50, 80, 90, 70] |
| 終了 | ピボットを i + 1 = 4 に置く | [10, 30, 40, 50, 70, 90, 80] |
70 はインデックス 4、つまり最終位置に収まりました。クイックソートは次に、[10, 30, 40, 50] と [90, 80] を別々にソートします。
Pythonでの実装
import random
def partition(a, lo, hi):
"""Lomuto partition of a[lo..hi] around a random pivot."""
r = random.randint(lo, hi)
a[r], a[hi] = a[hi], a[r] # move the pivot to the end
pivot = a[hi]
i = lo - 1
for j in range(lo, hi):
if a[j] < pivot:
i += 1
a[i], a[j] = a[j], a[i]
a[i + 1], a[hi] = a[hi], a[i + 1]
return i + 1
def quicksort(a, lo=0, hi=None):
"""Sort list a in place."""
if hi is None:
hi = len(a) - 1
while lo < hi:
p = partition(a, lo, hi)
if p - lo < hi - p: # recurse on the smaller side
quicksort(a, lo, p - 1)
lo = p + 1
else:
quicksort(a, p + 1, hi)
hi = p - 1
return a
print(quicksort([10, 80, 30, 90, 40, 50, 70])) # [10, 30, 40, 50, 70, 80, 90]
print(quicksort([3, 3, 3, 1])) # [1, 3, 3, 3]小さいほうの側に再帰し、大きいほうの側はループで処理すると、最悪ケースでもスタックの深さを O(log n) に抑えられます。アイデアの説明には、インプレースではない短い版がよく使われますが、これは各段階で新しいリストを確保します。
def quicksort_simple(items):
if len(items) <= 1:
return items
pivot = items[len(items) // 2]
less = [x for x in items if x < pivot]
equal = [x for x in items if x == pivot]
greater = [x for x in items if x > pivot]
return quicksort_simple(less) + equal + quicksort_simple(greater)
print(quicksort_simple([5, 2, 9, 2, 7])) # [2, 2, 5, 7, 9]時間計算量と空間計算量
| ケース | 時間 | 理由 |
|---|---|---|
| 最良 | O(n log n) | どのピボットも範囲を半分に分ける |
| 平均 | O(n log n) | ランダムなピボットなら十分に均等な分割が得られ、比較は約 1.39 n log₂ n 回 |
| 最悪 | O(n²) | どのピボットも最小または最大の要素になる |
| 追加の空間 | O(log n) | 小さいほうの側に再帰する場合の再帰スタック |
典型的な最悪ケースは、すでにソート済みまたは逆順にソート済みの入力に対して、先頭または末尾の要素を固定でピボットにする場合です。各パーティションがピボットしか取り除かないので、O(n) の作業が 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(n) | はい |
| ヒープソート | O(n log n) | O(n log n) | O(n log n) | O(1) | いいえ |
| 挿入ソート | O(n) | O(n²) | O(n²) | O(1) | はい |
| バブルソート | 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) | はい |
クイックソートは安定ではありません。パーティション中の交換で、要素が等しい要素を飛び越えることがあるためです。メモリ上の配列では、内側のループが単純で、インプレースで動作し、メモリにほぼ順番にアクセスするため、通常はマージソートやヒープソートに勝ります。C++ の標準ライブラリの多くの std::sort 実装で使われているイントロソートは、クイックソートで始めて、再帰が深くなりすぎるとヒープソートに切り替えることで、O(n log n) を保証します。
ピボットの選び方、派生、よくある間違い
- ピボットの選び方:先頭または末尾の要素(簡単ですが、ソート済みの入力で O(n²) になります)、ランダムな要素、三点中央値(先頭・中央・末尾の中央値)。
- ホーアのパーティション:2つのインデックスが互いに向かって動き、逆側にあるペアを交換します。平均では、ロムートより交換が少なくなります。
- 三分割パーティション(オランダ国旗問題):小さい・等しい・大きいの3つに分けます。キーの重複が多い配列でも高速になります。単純なロムート方式は、すべてのキーが等しいと O(n²) に悪化します。
- デュアルピボットクイックソートは、2つのピボットと3つの部分を使います。Javaは、プリミティブ型の配列のソートにこれを使っています。
- クイックセレクトは、パーティションを1回行って片側にだけ再帰し、k 番目に小さい要素を平均 O(n) 時間で見つけます。
クイックソートを使う場面
安定性が不要で、メモリ上の配列をソートする場合、クイックソートは良いデフォルトの選択です。ただしPythonでは、代わりに sorted() か list.sort() を使ってください。これらは Timsort を使っており、安定で、部分的にソート済みのデータでも高速です。クイックソートの実装は面接で非常によく出る課題で、最悪ケース、ピボットの選び方、安定性、クイックセレクトによる k 番目に大きい要素の探索などの質問が続くことがよくあります。
よくある質問
クイックソートは安定ですか?
いいえ。パーティションは離れた位置にある要素同士を交換するため、等しい要素の相対的な順序が変わることがあります。安定版も存在しますが、追加のメモリが必要です。
クイックソートの最悪ケースは?
O(n²) で、すべてのピボットがその範囲の最小値または最大値になる場合です。たとえば、すでにソート済みの入力に対して最後の要素をピボットにするときです。ランダムなピボットや三点中央値のピボットなら、実際にはこれを避けられます。
クイックソートが実際に速いのはなぜですか?
インプレースでソートし、内側のループが単純な比較と交換で、メモリを順番に走査するので CPU キャッシュをうまく使えるからです。平均の定数係数はヒープソートより小さく、マージソートのようなバッファも不要です。
クイックソートとマージソートの違いは何ですか?
クイックソートはまずパーティションを行い、マージは不要です。マージソートはまず分割し、マージで仕事をします。クイックソートはインプレースですが、最悪ケースが O(n²) で、安定ではありません。マージソートは安定で、常に O(n log n) ですが、O(n) の追加空間が必要です。
クイックソートはどれくらいのメモリを使いますか?
インプレースでソートします。再帰スタックは平均で O(log n) で、常に小さいほうの側に先に再帰すれば、最悪ケースでも O(log n) です。