コンテンツにスキップ

第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枚」。

1 vs 2 → 1 を取る
4 vs 2 → 2 を取る
4 vs 3 → 3 を取る
4 vs 8 → 4 を取る
7 vs 8 → 7 を取る
残り 8

比較は5回。要素の合計 n とほぼ同じ → O(n)

今日の鍵

すでに並んだ2つの山を1つにまとめる(これをマージという)のは速い。これが今回のソートの土台になる。


マージソート

発想

  • さっきの体験:並んだ2つの山さえあれば、統合は速い(O(n))
  • では「並んだ2つの山」をどう手に入れる? → 同じ手をもう一度。山を半分に割り、それぞれを並べてから、マージする
  • 半分に割った山も、また半分に割って…… 1枚になれば「もう並んでいる」

これがそのまま分割統治:

  • Divide: 半分に切る
  • Conquer: 左右それぞれを整列(再帰)
  • Combine: マージ(さっき体験したやつ)

流れを追う:[5, 3, 8, 1]

分割:

[5, 3, 8, 1] → [5, 3] / [8, 1]
             → [5][3] / [8][1]   (1枚ずつ=もう整列済み)

統合:

[5][3] → [3, 5]、[8][1] → [1, 8]
[3, 5] と [1, 8] をマージ → [1, 3, 5, 8]

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:

小さい山: [3, 1, 2, 4] / 大きい山: [8, 9, 7]
→ [3, 1, 2, 4] + [5] + [8, 9, 7]

各山に同じことを再帰:

[3, 1, 2, 4](ピボット3)→ [1, 2] + [3] + [4]
[8, 9, 7]   (ピボット8)→ [7]    + [8] + [9]

つなぐと: [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)二分探索木。辞書検索(追加しながら高速に引く)はどう作る?