第7回:ソート(2)マージソート・クイックソート¶
前回のバブル・選択ソートは「全ペアを見比べる」ため、比較回数は \(n(n-1)/2 \approx O(n^2)\) だった。全ペアを見なくても並べ替えはできるのか? 鍵は第5回の分割統治。ただし、その前に手を動かして1つ確かめる。
演習7-1¶
表向きのカードが2つの山に分かれていて、どちらもすでに小さい順に並んでいる。
- 山A:
[1, 4, 7] - 山B:
[2, 3, 8]
これを1つの山に、小さい順でまとめたい。ルール:
- 各山は一番上(先頭)の1枚しか見られない
- 取り出すと次がめくれる
- 何回比較したか数えること
今日の鍵¶
各山の先頭だけを比べて、小さい方を取り出す。これを繰り返すだけでよい。山の中を探し回る必要はなく、いつも見るのは「先頭の2枚」。
比較は5回。要素の合計 n とほぼ同じ → O(n)。
今日の鍵
すでに並んだ2つの山を1つにまとめる(これをマージという)のは速い。これが今回のソートの土台になる。
マージソート¶
発想¶
- さっきの体験:並んだ2つの山さえあれば、統合は速い(O(n))
- では「並んだ2つの山」をどう手に入れる? → 同じ手をもう一度。山を半分に割り、それぞれを並べてから、マージする
- 半分に割った山も、また半分に割って…… 1枚になれば「もう並んでいる」
これがそのまま分割統治:
- Divide: 半分に切る
- Conquer: 左右それぞれを整列(再帰)
- Combine: マージ(さっき体験したやつ)
流れを追う:[5, 3, 8, 1]¶
分割:
統合:
Pythonコード¶
def merge_sort(A):
if len(A) <= 1: # 基底ケース:1枚は整列済み
return A
mid = len(A) // 2
left = merge_sort(A[:mid]) # 左半分を整列(再帰)
right = merge_sort(A[mid:]) # 右半分を整列(再帰)
return merge(left, right) # 整列済み2つを統合
def merge(L, R):
result = []
i = j = 0
while i < len(L) and j < len(R):
if L[i] <= R[j]: # 等しいときは左を優先 → 安定になる
result.append(L[i]); i += 1
else:
result.append(R[j]); j += 1
result += L[i:] # 余った分をそのまま後ろへ
result += R[j:]
return result
再帰の3ステップとの対応
- Divide:
mid = len(A) // 2で半分に切る - Conquer:
merge_sort(A[:mid])とmerge_sort(A[mid:])で左右を再帰 - Combine:
merge(left, right)で2つの整列済み列を統合
演習7-2¶
[8, 3, 5, 1, 7, 2, 6, 4] にマージソートを適用する。分割の全段と、統合の全段を、順番に書き出すこと。
マージソートの計算量¶
- 1つの段で、全
n要素を1回マージする → 1段あたり O(n) - 段の数:半分にし続けるので \(\log_2 n\) 段
- 合計:O(n log n)
n = 1,000,000 のとき:
- O(n²): 約 5,000億回 → 数時間
- O(n log n): 約 2,000万回 → 0.2秒
桁が変わる。これが「半分にする」の威力。
クイックソート¶
もう一つの発想:分割で頑張る¶
マージソートは「分割は簡単・統合(マージ)で頑張る」型だった。逆に「分割で頑張る・統合は簡単」型もある。
やり方:
- 1枚を基準(ピボット)に選ぶ
- 残りを「基準より小さい山」「基準以上の山」に振り分ける
- 振り分けた瞬間、基準は定位置に座る
- あとは各山を、同じ手順で並べる(再帰)
統合は「小さい山+基準+大きい山」をつなぐだけ。マージは要らない。
流れを追う:[5, 3, 8, 1, 9, 2, 7, 4]¶
ピボット=先頭の 5:
各山に同じことを再帰:
つなぐと: [1, 2, 3, 4, 5, 7, 8, 9]。
Pythonコード¶
def quick_sort(A):
if len(A) <= 1:
return A
pivot = A[0] # 先頭をピボットに
less = [x for x in A[1:] if x < pivot] # 基準より小さい山
more = [x for x in A[1:] if x >= pivot] # 基準以上の山
return quick_sort(less) + [pivot] + quick_sort(more)
- 統合は
+でつなぐだけ。マージのループがない(そこが簡単) - そのかわり「振り分け(partition)」に毎回 O(n) かかる
演習7-3¶
[4, 8, 2, 6, 1, 5] にクイックソートを最後まで適用する(ピボットは各段の先頭)。各段の「小さい山/基準/大きい山」を書き出すこと。
うまく半分に割れれば速い、が……¶
ピボットでだいたい半々に割れれば:
- \(\log n\) 段 × 1段 O(n) = O(n log n)(平均)
でも、ピボットの選び方しだいで偏る。偏るとどうなる? 手を動かして確かめる。
演習7-4¶
すでに昇順の [1, 2, 3, 4, 5] に、先頭をピボットでクイックソートを適用する。各段で何回比較するか数え、合計を出せ。
クイックソートの最悪ケース
すでに整列済み(または逆順)でかつ「先頭をピボット」にすると、毎回「ピボット1枚 vs 残り n-1 枚」の偏った分割になる。比較回数は 4+3+2+1 = 10 = \(n(n-1)/2\) で、これはバブル・選択とまったく同じ回数。つまりピボットが最悪だと O(n²) に落ちる。
対策:ピボットをランダムに選ぶ/3つ見て真ん中を選ぶ(詳細は深入りしない)。
対決:マージソート vs クイックソート¶
| マージソート | クイックソート | |
|---|---|---|
| 分割 | 簡単(半分に切る) | 大変(ピボットで振り分け) |
| 統合 | 大変(マージ) | 簡単(つなぐだけ) |
| 平均計算量 | O(n log n) | O(n log n) |
| 最悪計算量 | O(n log n) | O(n²)(ピボットが悪いとき) |
| 安定性 | 安定 | 不安定(基本形) |
| 追加メモリ | O(n) 必要 | O(log n)(ほぼその場で) |
「分割で頑張るか、統合で頑張るか」が両者の正体。計算量(平均)は同じ。違うのは最悪ケース・安定性・メモリ(← 前回の「アルゴリズムは速さだけで選ぶものではない」と同じ視点)。
演習7-5¶
配列 [4, 2, 6, 1, 5, 3](6枚)を、バブルソートとマージソートの両方で昇順に並べる。それぞれ比較を何回したか数える(どこを比べたか意識する)。
演習7-6¶
手で数えるのは6枚が限界。可視化ツール ④ソート図鑑 を使う。
バブルソートとマージソートを、要素数 8 / 10 / 16 で「再生」が終わったときの比較回数と交換・移動回数を記録する。
気づきのヒント
- 比較回数は
nが増えるほど差が開く(バブルは急、マージは緩やか) - 交換・移動も同じ傾向。バブルは隣どうしを何度も入れ替えるので多い
- もっと大きい
nは ⑤計算量比較 の表で → 手では届かない桁の差=オーダー(O)の差
今日の問いへの答え¶
- 問い: O(n²) より速くできる?
- 答え: できる。分割統治で O(n log n) まで落ちる
- ただし、比較だけで並べ替えるなら O(n log n) が壁(理論上の下界)
- 「半分にする」発想は、第5回の探索(二分探索)に続いて、並べ替えでも効いた
まとめ¶
- マージソート: 分割は簡単・統合(マージ)で頑張る。O(n log n)、安定、メモリ O(n)
- クイックソート: 分割(振り分け)で頑張る・統合は簡単。平均 O(n log n)、最悪 O(n²)、不安定
- 「半分にする=分割統治」で、ソートは O(n²) → O(n log n) に
- 平均は同じでも、最悪ケース・安定性・メモリで選び分ける
次回予告¶
今日まで:データを並べておけば、二分探索で速く引ける。でも、1単語追加するたびに並べ直すのは O(n log n)。それを毎回やる?
「追加しながら、常に並んだ状態を保てる」入れ物があれば……。
次回:ツリー(1)二分探索木。辞書検索(追加しながら高速に引く)はどう作る?