【初心者向け】主要ソートアルゴリズムを図解で紹介

はい、承知いたしました。「【初心者向け】主要ソートアルゴリズムを図解で紹介」というタイトルの記事を、約5000語の詳細な説明を含めて記述します。図解は直接表示できないため、言葉を使って状態変化や操作手順を詳細に記述し、読者が頭の中で図をイメージできるよう工夫します。


【初心者向け】主要ソートアルゴリズムを図解で紹介

はじめに:なぜソートが必要なのか?

想像してみてください。あなたの部屋が散らかっていて、探している本がどこにあるか全く分からない状態です。一方、図書館では本が五十音順や分類ごとにきれいに並べられていて、目的の本をすぐに見つけることができます。この「きれいに並べる」という行為、これが「ソート(整列)」です。

コンピュータの世界でも、たくさんのデータを扱います。顧客リストを名前順に並べ替えたり、商品の売上データを価格順に並べたり、検索結果を関連度順に表示したり…。これらの処理の多くで、データが「ソートされていること」が前提となります。データがソートされていると、情報を探すのが非常に速くなるなど、様々なメリットがあります。例えば、電話帳で名前を知っていれば、五十音順に並んでいるおかげで目的の番号をすぐに探せますよね。これがもしランダムに並んでいたら、端から順に見ていくしかなく、大変な時間がかかってしまいます。

ソートは、プログラミングやデータ処理における最も基本的で重要な操作の一つです。様々な「ソートアルゴリズム」が存在し、それぞれに得意な状況や特徴があります。この記事では、プログラミング初心者の方でも理解できるよう、代表的なソートアルゴリズムの仕組みを、具体的な例と「図解(図の状態変化を言葉で詳細に追うことでイメージをつかむ)」を交えながら徹底的に解説していきます。

この記事を読むことで、あなたは以下のことが理解できるようになります。

  • ソートアルゴリズムの基本的な考え方
  • 代表的なソートアルゴリズム(バブルソート、選択ソート、挿入ソート、マージソート、クイックソート、ヒープソート、計数ソート)の仕組み
  • それぞれのアルゴリズムがどのように動くのか(図解イメージ)
  • 各アルゴリズムの得意な点、苦手な点
  • アルゴリズムの効率性を測る「計算量」という考え方の基本

さあ、データの並べ替えの魔法とも言えるソートアルゴリズムの世界へ踏み出しましょう!

ソートアルゴリズムを理解するための基礎知識

ソートアルゴリズムを学ぶ前に、いくつか基本的な用語や考え方を知っておくとスムーズです。

データを格納する「配列」や「リスト」

コンピュータでデータを扱うとき、それらをまとめて管理するためによく使われるのが「配列(はいれつ)」や「リスト」と呼ばれるデータ構造です。これらは、複数のデータを順番に並べて格納しておく箱のようなものだと考えてください。それぞれのデータには「添え字(そえじ)」や「インデックス」と呼ばれる番号(たいてい0から始まります)がついていて、その番号を使って特定のデータを取り出したり、書き換えたりできます。

例えば、[5, 2, 8, 1, 9] という数値の並びを考えます。これは長さ5の配列として表現できます。
* インデックス0の要素は 5
* インデックス1の要素は 2
* インデックス2の要素は 8
* インデックス3の要素は 1
* インデックス4の要素は 9

ソートアルゴリズムは、この配列やリストに格納されたデータを、大小などの基準に従って並べ替える処理を行います。

「比較」と「交換」の基本操作

多くのソートアルゴリズムは、「比較(ひかく)」と「交換(こうかん)」という2つの基本的な操作を繰り返すことでデータを並べ替えます。

  • 比較: 2つのデータの大小を比べる操作です。「AはBより大きいか?」「AとBは等しいか?」などを調べます。
  • 交換: 2つのデータの位置を入れ替える操作です。例えば、配列のインデックスiにあるデータとインデックスjにあるデータを入れ替えることで、並び順を変えます。

ソートアルゴリズムは、これらの操作を、すべてのデータが正しく並ぶまで、定められた手順に従って繰り返します。

アルゴリズムの効率性:計算量(O記法)の超基本

様々なソートアルゴリズムがあるということは、それぞれ「どれくらい速く」ソートできるかが異なるということです。この「速さ」や「効率性」を測るための指標として「計算量(けいさんりょう)」という考え方があります。特に、入力データのサイズ(要素数N)が大きくなったときに、処理にかかる時間がどのように増えていくかを示すのが一般的です。

計算量を表す際によく使われるのが「O記法(オーきほう)」です。これは、処理時間や使用メモリ量が、入力サイズNに対しておおよそどのくらいの割合で増えるかを示すものです。

例えば、要素数Nの配列をソートするアルゴリズムがあったとして:

  • O(N): 処理時間がNに比例して増える。要素数が2倍になれば、時間も約2倍になるイメージです。
  • O(N^2): 処理時間がNの2乗に比例して増える。要素数が2倍になれば、時間は約4倍、3倍になれば約9倍になるイメージです。要素数が大きくなるほど、急激に時間がかかります。
  • O(N log N): 処理時間がN * log(N)に比例して増える。これはO(N^2)よりはずっと効率的で、Nが大きくなっても時間の増加が緩やかです。多くの高速なソートアルゴリズムがこの計算量になります。
  • O(1): 処理時間が入力サイズに関係なく一定。

ソートアルゴリズムの計算量では、特に「時間計算量(処理にかかる時間)」と「空間計算量(使用するメモリ量)」が重要視されます。時間計算量については、多くの場合「最悪計算量(最も時間がかかる場合の計算量)」、「平均計算量(平均的な場合の計算量)」、「最善計算量(最も時間がかからない場合の計算量)」などが考慮されます。この記事では、初心者向けに分かりやすく、主に平均的な場合の計算量や、特徴的な最悪計算量に触れます。

安定ソートとは?

ソートアルゴリズムの中には、「安定(Stable)」なものとそうでないものがあります。安定ソートとは、同じ値を持つ複数の要素があった場合、それらのソート前の相対的な順序が、ソート後も維持されるアルゴリズムのことです。

例えば、データが [(りんご, 赤), (バナナ, 黄色), (りんご, 青)] のように、値(果物の種類)と別の情報(色)を持つとします。これを果物の種類でソートする場合、2つの「りんご」があります。もしソート前の並びが (りんご, 赤), (バナナ, 黄色), (りんご, 青) であったとします。

  • 安定ソート: ソート後の「りんご」の並びは、元の順序と同じ (りんご, 赤), (りんご, 青) となります。
  • 不安定ソート: ソート後の「りんご」の並びが (りんご, 青), (りんご, 赤) となる可能性もあります。

安定性は、複数の条件で段階的にソートを行う場合などに重要になります。

それでは、いよいよ具体的なソートアルゴリズムを見ていきましょう!


主要ソートアルゴリズムの詳細解説

ここでは、代表的なソートアルゴリズムを一つずつ、その仕組みを図解イメージで追いながら詳しく解説します。

1. バブルソート (Bubble Sort)

概要:
バブルソートは、最もシンプルで直感的に理解しやすいソートアルゴリズムの一つです。隣り合う要素を繰り返し比較し、順番が逆であれば交換するという操作を繰り返すことで、大きな値がまるで泡(バブル)のように配列の端に浮き上がっていく様子からこの名前がついています。

動作原理(図解イメージ):
配列の先頭から順に隣り合う2つの要素を比較します。もし左側の要素が右側の要素より大きければ、その2つを交換します。この操作を配列の最後まで繰り返すと、一番大きな要素が必ず一番右端に移動します。次に、一番右端を除いた残りの要素に対して、同じ操作を繰り返します。これを、ソートが完了するまで(つまり、一度も交換が行われなくなるまで)、あるいは配列の要素数が1になるまで繰り返します。

例: [5, 2, 8, 1, 9] を昇順(小さい順)にソートする場合

初期状態: [5, 2, 8, 1, 9]

1回目のパス(一番右に最大値を移動させる):

  • 図1-1: [5, 2, 8, 1, 9] – インデックス0と1を比較: 5 > 2 なので交換
    -> [2, 5, 8, 1, 9]
  • 図1-2: [2, 5, 8, 1, 9] – インデックス1と2を比較: 5 < 8 なので交換なし
    -> [2, 5, 8, 1, 9]
  • 図1-3: [2, 5, 8, 1, 9] – インデックス2と3を比較: 8 > 1 なので交換
    -> [2, 5, 1, 8, 9]
  • 図1-4: [2, 5, 1, 8, 9] – インデックス3と4を比較: 8 < 9 なので交換なし
    -> [2, 5, 1, 8, **9**]

    • ポイント: 1回目のパス終了後、最大の要素 9 が末尾(インデックス4)に移動し、その位置は確定します。次はインデックス0から3までの範囲をソート対象とします。

2回目のパス(右から2番目に次に大きい値を移動させる):
対象範囲: [2, 5, 1, 8]

  • 図2-1: [2, 5, 1, 8] – インデックス0と1を比較: 2 < 5 なので交換なし
    -> [2, 5, 1, 8]
  • 図2-2: [2, 5, 1, 8] – インデックス1と2を比較: 5 > 1 なので交換
    -> [2, 1, 5, 8]
  • 図2-3: [2, 1, 5, 8] – インデックス2と3を比較: 5 < 8 なので交換なし
    -> [2, 1, 5, **8**, 9] (元の配列の確定部分 9 を含めて表示)

    • ポイント: 2回目のパス終了後、次に大きい要素 8 が右から2番目(インデックス3)に移動・確定します。次はインデックス0から2までの範囲をソート対象とします。

3回目のパス:
対象範囲: [2, 1, 5]

  • 図3-1: [2, 1, 5] – インデックス0と1を比較: 2 > 1 なので交換
    -> [1, 2, 5]
  • 図3-2: [1, 2, 5] – インデックス1と2を比較: 2 < 5 なので交換なし
    -> [1, 2, **5**, 8, 9]

    • ポイント: 5 が右から3番目(インデックス2)に移動・確定。次はインデックス0から1まで。

4回目のパス:
対象範囲: [1, 2]

  • 図4-1: [1, 2] – インデックス0と1を比較: 1 < 2 なので交換なし
    -> [1, 2, 5, 8, 9]

    • ポイント: 交換が発生しませんでした。このパスで交換が一度も行われなかったということは、残りの要素は全てソート済みであることを意味します。

最終状態: [1, 2, 5, 8, 9] (ソート完了)

コード例(Python):

“`python
def bubble_sort(arr):
n = len(arr)
# 配列の要素数-1回パスを繰り返す(最後の要素は自然とソートされるため)
for i in range(n – 1):
# このパスで一度も交換が行われなかったら、ソート完了とみなすフラグ
swapped = False
# 配列の先頭から、未ソート部分の末尾まで比較と交換を繰り返す
# (n-1-i)なのは、後ろからi個の要素は既に確定しているため
for j in range(n – 1 – i):
# 隣り合う要素を比較
if arr[j] > arr[j + 1]:
# 順序が逆なら交換
arr[j], arr[j + 1] = arr[j + 1], arr[j]
swapped = True # 交換が発生したことを記録

    # もしこのパスで一度も交換が行われなかったら、配列は既にソート済み
    if not swapped:
        break
return arr

my_list = [5, 2, 8, 1, 9]
sorted_list = bubble_sort(my_list)
print(sorted_list) # 出力: [1, 2, 5, 8, 9]
“`

特徴:

  • 計算量:
    • 最悪計算量: O(N^2) (完全に逆順に並んでいる場合など)
    • 平均計算量: O(N^2)
    • 最善計算量: O(N) (既にソート済みの配列で、最適化(交換フラグ)がある場合)
  • 空間計算量: O(1) (データを格納する配列以外に、追加で必要なメモリはごくわずか)
  • 安定性: 安定なソートアルゴリズムです。(同じ値の要素は相対順序が保たれるように交換を設計できるため)
  • 利点: アルゴリズムが非常にシンプルで理解しやすい。実装が容易。
  • 欠点: 要素数が増えると極端に効率が悪くなる(計算量がN^2)。実用的なソートとしてはほとんど使われない。
  • 適している場合: 要素数がごく少ない場合、あるいはアルゴリズムの学習目的。

2. 選択ソート (Selection Sort)

概要:
選択ソートは、未ソートの要素の中から最小値(または最大値)を見つけ出し、それを未ソート部分の先頭要素と交換することを繰り返すアルゴリズムです。

動作原理(図解イメージ):
配列を「ソート済みの部分」と「未ソートの部分」に分けます。最初は配列全体が未ソート部分です。
1. 未ソート部分の中から最も小さい要素を探します。
2. 見つかった最小要素と、未ソート部分の先頭の要素を交換します。
3. これにより、未ソート部分の先頭要素が確定し、ソート済み部分に移動します。
4. この手順を、未ソート部分がなくなるまで繰り返します。

例: [5, 2, 8, 1, 9] を昇順にソートする場合

初期状態: [5, 2, 8, 1, 9] (全体が未ソート)

1回目のパス:

  • 図1-1: [5, 2, 8, 1, 9] – 未ソート部分 [5, 2, 8, 1, 9] から最小値を探す。
    • 5 (現在の最小) と 2 を比較 → 2 が最小
    • 28 を比較 → 2 が最小
    • 21 を比較 → 1 が最小
    • 19 を比較 → 1 が最小
      → 見つかった最小値は 1。現在の未ソート部分の先頭(インデックス0)の要素は 5
  • 図1-2: [5, 2, 8, 1, 9] – 最小値 1 と先頭要素 5 を交換。
    -> [**1**, 2, 8, 5, 9]

    • ポイント: インデックス0の要素 1 が確定し、ソート済み部分となります。次はインデックス1以降が未ソート部分。

2回目のパス:
未ソート部分: [2, 8, 5, 9] (インデックス1から4)

  • 図2-1: [1, **2, 8, 5, 9**] – 未ソート部分 [2, 8, 5, 9] から最小値を探す。
    • 2 (現在の最小) と 8 を比較 → 2 が最小
    • 25 を比較 → 2 が最小
    • 29 を比較 → 2 が最小
      → 見つかった最小値は 2。現在の未ソート部分の先頭(インデックス1)の要素は 2
  • 図2-2: [1, **2**, 8, 5, 9] – 最小値 2 と先頭要素 2 を交換。(位置は変わらない)
    -> [1, **2**, 8, 5, 9]

    • ポイント: インデックス1の要素 2 が確定。次はインデックス2以降が未ソート部分。

3回目のパス:
未ソート部分: [8, 5, 9] (インデックス2から4)

  • 図3-1: [1, 2, **8, 5, 9**] – 未ソート部分 [8, 5, 9] から最小値を探す。
    • 8 (現在の最小) と 5 を比較 → 5 が最小
    • 59 を比較 → 5 が最小
      → 見つかった最小値は 5。現在の未ソート部分の先頭(インデックス2)の要素は 8
  • 図3-2: [1, 2, **8**, 5, 9] – 最小値 5 と先頭要素 8 を交換。
    -> [1, 2, **5**, 8, 9]

    • ポイント: インデックス2の要素 5 が確定。次はインデックス3以降が未ソート部分。

4回目のパス:
未ソート部分: [8, 9] (インデックス3から4)

  • 図4-1: [1, 2, 5, **8, 9**] – 未ソート部分 [8, 9] から最小値を探す。
    • 8 (現在の最小) と 9 を比較 → 8 が最小
      → 見つかった最小値は 8。現在の未ソート部分の先頭(インデックス3)の要素は 8
  • 図4-2: [1, 2, 5, **8**, 9] – 最小値 8 と先頭要素 8 を交換。(位置は変わらない)
    -> [1, 2, 5, **8**, 9]

    • ポイント: インデックス3の要素 8 が確定。未ソート部分はインデックス4の [9] だけになりました。要素数1の未ソート部分はそれ自体がソート済みとみなせるため、これで完了です。

最終状態: [1, 2, 5, 8, 9] (ソート完了)

コード例(Python):

“`python
def selection_sort(arr):
n = len(arr)
# 配列全体を対象にループ(最後の要素は自然とソートされるのでn-1まで)
for i in range(n – 1):
# 未ソート部分の先頭要素のインデックスを最小値のインデックスの仮定値とする
min_idx = i
# 未ソート部分(i+1から最後まで)を探索し、最小値のインデックスを見つける
for j in range(i + 1, n):
if arr[j] < arr[min_idx]:
min_idx = j

    # 見つかった最小値のインデックスが現在の先頭インデックスと異なれば交換
    if min_idx != i:
        arr[i], arr[min_idx] = arr[min_idx], arr[i]
return arr

my_list = [5, 2, 8, 1, 9]
sorted_list = selection_sort(my_list)
print(sorted_list) # 出力: [1, 2, 5, 8, 9]
“`

特徴:

  • 計算量:
    • 最悪計算量: O(N^2)
    • 平均計算量: O(N^2)
    • 最善計算量: O(N^2) (データの並び方に関わらず、常に最小値を探すための比較はN^2回近く行われるため)
  • 空間計算量: O(1)
  • 安定性: 不安定なソートアルゴリズムです。(未ソート部分の先頭要素と、それより後ろにある最小値を交換する際に、同じ値を持つ要素の相対順序が崩れる可能性があるため)
  • 利点: アルゴリズムが比較的シンプル。バブルソートに比べて交換回数が少ない(最大でN-1回)。
  • 欠点: バブルソートと同様に要素数が増えると効率が悪い。比較回数はデータの並び方に影響されず常に多い。
  • 適している場合: 要素数がごく少ない場合、あるいは交換回数を最小限に抑えたい場合。

3. 挿入ソート (Insertion Sort)

概要:
挿入ソートは、配列を「ソート済みの部分」と「未ソートの部分」に分け、未ソート部分の先頭要素を一つ取り出し、それをソート済み部分の適切な位置に「挿入」していくアルゴリズムです。まるでトランプのカードを手札に加えて、既に並んでいるカードの正しい位置に挿し入れるように見えます。

動作原理(図解イメージ):
配列の最初の要素だけをソート済みとみなします(要素が1つならそれはソート済みだからです)。
次に、未ソート部分の最初の要素(つまり配列の2番目の要素)を取り出します。この要素を、ソート済み部分の要素と後ろから順に比較し、自分より小さい要素が見つかるか、あるいはソート済み部分の先頭に達するまで、比較対象の要素を一つずつ後ろにずらしていきます。そして、適切な位置にその要素を挿入します。
この操作を、未ソート部分がなくなるまで、つまり配列のすべての要素がソート済み部分に移動するまで繰り返します。

例: [5, 2, 8, 1, 9] を昇順にソートする場合

初期状態: [5, 2, 8, 1, 9]

1回目のパス(インデックス1の要素を挿入):
ソート済み部分: [5] (インデックス0)
未ソート部分: [2, 8, 1, 9] (インデックス1以降)

  • 図1-1: 未ソート部分の先頭要素 2 を取り出す(比較対象として保持)。ソート済み部分の末尾(インデックス0)の要素 5 と比較。
    [5 | 2, 8, 1, 9]| はソート済み/未ソートの区切り)
  • 図1-2: 2 < 5 なので、5 を右に一つずらす。
    [ | 5, 8, 1, 9] – インデックス0は空き、インデックス1に 5 が移動。
  • 図1-3: ソート済み部分の先頭に達したので、空いたインデックス0に 2 を挿入。
    [**2**, 5 | 8, 1, 9]

    • ポイント: インデックス0と1がソート済みとなりました。次はインデックス2以降が未ソート部分。

2回目のパス(インデックス2の要素を挿入):
ソート済み部分: [2, 5] (インデックス0, 1)
未ソート部分: [8, 1, 9] (インデックス2以降)

  • 図2-1: 未ソート部分の先頭要素 8 を取り出す。ソート済み部分の末尾(インデックス1)の要素 5 と比較。
    [2, 5 | 8, 1, 9]
  • 図2-2: 8 > 5 なので、8 の挿入位置は 5 の後ろと決定。インデックス2に 8 を挿入。(元々 8 があった位置なので、実質的には移動なし)
    [2, 5, **8** | 1, 9]

    • ポイント: インデックス0から2までがソート済みとなりました。次はインデックス3以降が未ソート部分。

3回目のパス(インデックス3の要素を挿入):
ソート済み部分: [2, 5, 8] (インデックス0, 1, 2)
未ソート部分: [1, 9] (インデックス3以降)

  • 図3-1: 未ソート部分の先頭要素 1 を取り出す。ソート済み部分の末尾(インデックス2)の要素 8 と比較。
    [2, 5, 8 | 1, 9]
  • 図3-2: 1 < 8 なので 8 を右にずらす。次にインデックス1の 5 と比較。
    [2, 5, | 8, 9] – インデックス2が空き、インデックス3に 8 が移動。
  • 図3-3: 1 < 5 なので 5 を右にずらす。次にインデックス0の 2 と比較。
    [2, | 5, 8, 9] – インデックス1が空き、インデックス2に 5 が移動。
  • 図3-4: 1 < 2 なので 2 を右にずらす。次にソート済み部分の先頭(インデックス0)に達した。
    [ | 2, 5, 8, 9] – インデックス0が空き、インデックス1に 2 が移動。
  • 図3-5: 空いたインデックス0に 1 を挿入。
    [**1**, 2, 5, 8 | 9]

    • ポイント: インデックス0から3までがソート済みとなりました。次はインデックス4以降が未ソート部分。

4回目のパス(インデックス4の要素を挿入):
ソート済み部分: [1, 2, 5, 8] (インデックス0から3)
未ソート部分: [9] (インデックス4)

  • 図4-1: 未ソート部分の先頭要素 9 を取り出す。ソート済み部分の末尾(インデックス3)の要素 8 と比較。
    [1, 2, 5, 8 | 9]
  • 図4-2: 9 > 8 なので、9 の挿入位置は 8 の後ろと決定。インデックス4に 9 を挿入。(元々 9 があった位置なので、実質的には移動なし)
    [1, 2, 5, 8, **9**]

    • ポイント: 未ソート部分がなくなりました。

最終状態: [1, 2, 5, 8, 9] (ソート完了)

コード例(Python):

“`python
def insertion_sort(arr):
# 配列の2番目の要素から最後までを未ソート部分として順に見ていく
for i in range(1, len(arr)):
# 未ソート部分の先頭要素(現在の要素)を取り出す
key = arr[i]
# ソート済み部分の末尾のインデックス
j = i – 1

    # 取り出した要素(key)を、ソート済み部分を後ろから見ていきながら適切な位置を探す
    # keyより大きい要素は右に一つずつずらす
    while j >= 0 and arr[j] > key:
        arr[j + 1] = arr[j]
        j -= 1 # 左隣の要素に進む

    # keyより小さい要素が見つかった位置(またはソート済み部分の先頭)の右隣にkeyを挿入
    arr[j + 1] = key

return arr

my_list = [5, 2, 8, 1, 9]
sorted_list = insertion_sort(my_list)
print(sorted_list) # 出力: [1, 2, 5, 8, 9]
“`

特徴:

  • 計算量:
    • 最悪計算量: O(N^2) (完全に逆順に並んでいる場合など)
    • 平均計算量: O(N^2)
    • 最善計算量: O(N) (既にソート済みの配列の場合、各要素の挿入位置がすぐに見つかるため)
  • 空間計算量: O(1)
  • 安定性: 安定なソートアルゴリズムです。(同じ値の要素が現れた場合、その右側には挿入されないため)
  • 利点: アルゴリズムが比較的シンプル。実装が容易。既にソート済みに近いデータに対して非常に高速。要素数が少ない場合に効率的。
  • 欠点: 要素数が多い場合や、完全に逆順に並んでいるデータに対しては効率が悪い(バブルソートや選択ソートと同程度のO(N^2))。
  • 適している場合: 要素数が少ない場合(数百個程度まで)、あるいは、ほとんどソート済みのデータをさらにソートする場合(例: リアルタイムにデータが追加される場合)。

4. マージソート (Merge Sort)

概要:
マージソートは、「分割統治法(ぶんかつとうちほう)」という考え方に基づくソートアルゴリズムです。大きな問題を、同じ構造のより小さな問題に分割していき、それぞれの小さな問題を解いた後に、それらの解を結合(マージ)することで、元の問題を解くという手法です。マージソートでは、「配列を半分に分割 → 分割したそれぞれの配列を再帰的にソート → ソート済みの2つの配列を1つにマージする」という手順を繰り返します。

動作原理(図解イメージ):
1. 分割 (Divide): ソートしたい配列を、要素が1つになるまで繰り返し半分に分割していきます。要素が1つだけの配列は、それ自体がソート済みとみなせます。
例: [5, 2, 8, 1, 9]
-> [5, 2], [8, 1, 9]
-> [5], [2], [8], [1, 9]
-> [5], [2], [8], [1], [9] (要素が1つになったので分割終了)

  1. 統治 (Conquer): 要素が1つになった最小の配列はソート済みとみなします。

  2. 結合 (Combine / Merge): ソート済みの小さな配列同士をマージして、より大きなソート済み配列を作ります。これを繰り返すことで、最終的に全体がソートされた配列が得られます。マージの際は、2つのソート済み配列の先頭要素を比較し、小さい方を新しい配列の次の要素として取り出します。どちらかの配列が空になったら、もう一方の配列に残っている要素を全てコピーします。
    例: 先ほどの分割の逆順でマージ

    • 図1: [5][2] をマージ
      [5] の先頭 5[2] の先頭 2 を比較。2 < 5 なので 2 を取り出し。
      新しい配列: [2][2] は空に。
      [5] はまだ要素があるので、残りの 5 を全てコピー。
      -> マージ結果: [2, 5]

    • 図2: [8][1] をマージ
      [8] の先頭 8[1] の先頭 1 を比較。1 < 8 なので 1 を取り出し。
      新しい配列: [1][1] は空に。
      [8] はまだ要素があるので、残りの 8 を全てコピー。
      -> マージ結果: [1, 8]

    • 図3: 図1の結果 [2, 5] と 図2の結果 [1, 8][9] をマージ…ではなく、分割時のペアに戻る。
      [2, 5][1, 8, 9] をマージ。 まず [1, 9] をマージして [1, 9](要素1つの配列は既にソート済みとみなすので、ここから開始でも良い)
      [1, 9][8] をマージ(これは分割の順番と少し異なりますが、考え方としてはOK)
      [1, 9] の先頭 1[8] の先頭 8 を比較。1 < 8 なので 1 を取り出し。
      新しい配列: [1][1, 9][9] に。
      [9] の先頭 9[8] の先頭 8 を比較。8 < 9 なので 8 を取り出し。
      新しい配列: [1, 8][8] は空に。
      [9] はまだ要素があるので、残りの 9 を全てコピー。
      -> マージ結果: [1, 8, 9] (分割時の [8], [1, 9] から再開したと考えると)

    • 図4: 図1の結果 [2, 5] と 図3の結果 [1, 8, 9] をマージ

      • [2, 5] の先頭 2[1, 8, 9] の先頭 1 を比較。1 < 2 なので 1 を取り出し。 新配列: [1][1, 8, 9][8, 9] に。
      • [2, 5] の先頭 2[8, 9] の先頭 8 を比較。2 < 8 なので 2 を取り出し。 新配列: [1, 2][2, 5][5] に。
      • [5] の先頭 5[8, 9] の先頭 8 を比較。5 < 8 なので 5 を取り出し。 新配列: [1, 2, 5][5] は空に。
      • [2, 5] が空になったので、残りの [8, 9] を全てコピー。 新配列: [1, 2, 5, 8, 9]
        -> マージ結果: [1, 2, 5, 8, 9]

最終状態: [1, 2, 5, 8, 9] (ソート完了)

コード例(Python):

“`python
def merge_sort(arr):
# 要素数が1以下の場合は既にソート済みとみなす(再帰の終了条件)
if len(arr) <= 1:
return arr

# 1. 分割 (Divide)
mid = len(arr) // 2 # 配列をほぼ半分に分割する位置
left_half = arr[:mid] # 前半部分
right_half = arr[mid:] # 後半部分

# 再帰的にそれぞれをソート
left_sorted = merge_sort(left_half)
right_sorted = merge_sort(right_half)

# 2. 結合 (Combine / Merge)
# ソート済みの左右の配列をマージして、新しい配列を作る
return merge(left_sorted, right_sorted)

def merge(left, right):
# マージ後の新しい配列
merged_list = []
# 左右の配列の現在の要素を指すポインタ
i = 0 # left配列用
j = 0 # right配列用

# 左右の配列の両方に要素が残っている間
while i < len(left) and j < len(right):
    # 左側の要素が小さければ、それを新しい配列に追加
    if left[i] <= right[j]:
        merged_list.append(left[i])
        i += 1 # 左側のポインタを進める
    # 右側の要素が小さければ、それを新しい配列に追加
    else:
        merged_list.append(right[j])
        j += 1 # 右側のポインタを進める

# 左側の配列に要素が残っていれば、すべて新しい配列に追加
while i < len(left):
    merged_list.append(left[i])
    i += 1

# 右側の配列に要素が残っていれば、すべて新しい配列に追加
while j < len(right):
    merged_list.append(right[j])
    j += 1

# マージしてできたソート済み配列を返す
return merged_list

my_list = [5, 2, 8, 1, 9]
sorted_list = merge_sort(my_list)
print(sorted_list) # 出力: [1, 2, 5, 8, 9]
“`

特徴:

  • 計算量:
    • 最悪計算量: O(N log N)
    • 平均計算量: O(N log N)
    • 最善計算量: O(N log N) (データの並び方に影響されにくく、安定して高速)
  • 空間計算量: O(N) (マージの際に一時的な配列を作成する必要があるため、入力と同じサイズの追加メモリが必要)
  • 安定性: 安定なソートアルゴリズムです。(マージの際に同じ値が現れた場合、左側の配列から優先的に取り出すことで相対順序を保てるため)
  • 利点: 計算量が常にO(N log N)と効率的で安定している。分割統治法という重要なアルゴリズムデザインパターンを学ぶのに適している。安定ソートである。
  • 欠点: マージのために一時的な配列が必要なため、O(N)の追加メモリが必要(メモリ使用量が多い)。再帰を使う実装の場合、再帰の深さによってはスタックオーバーフローの可能性がある(ただし、繰り返しを使ったボトムアップ実装もある)。
  • 適している場合: 要素数が多い場合で、安定した高速性が必要な場合。連結リストなど、配列のようにランダムアクセスが高速でないデータ構造のソートにも比較的適している。

5. クイックソート (Quick Sort)

概要:
クイックソートは、マージソートと同様に分割統治法に基づくアルゴリズムですが、分割の方法が異なります。配列の中から基準となる要素(「ピボット」と呼びます)を一つ選び、そのピボットより小さい要素を配列の左側に、大きい要素を右側に集めるように配列を「分割(パーティショニング)」します。これにより、ピボットは正しいソート済みの位置に配置されます。次に、分割された左右のサブ配列に対して、再帰的に同じ処理を繰り返します。

動作原理(図解イメージ):
1. 分割 (Partition): 配列の中からピボットを一つ選びます(選び方にはいくつか方法がありますが、ここでは簡単のため、範囲の最後の要素を選びます)。配列の要素を、ピボットより小さいグループと大きいグループに分割し、ピボットをその境界に配置します。この分割操作によって、ピボットは最終的なソート済み位置に移動します。
2. 統治 (Conquer): 分割によってできたピボットの左側のサブ配列と、右側のサブ配列に対して、再帰的にクイックソートを適用します。
3. 結合 (Combine): 分割統治法に基づいているため、左右のサブ配列がソートされれば、全体もソートされます。マージソートのように明確な結合ステップはありません。

例: [5, 2, 8, 1, 9] を昇順にソートする場合
(簡単のため、常に範囲の最後の要素をピボットとします)

初期状態: [5, 2, 8, 1, 9]

1回目のパーティショニング(対象範囲: インデックス0〜4):
配列: [5, 2, 8, 1, 9]
ピボット: 9 (最後の要素)
未ソート部分を、ピボットより小さい要素と大きい要素に分ける。
左側から順に、ピボットより小さい要素を探し、それらを配列の左側に移動させていくイメージです。

  • 図1-1: [5, 2, 8, 1, 9] – ピボット 9
    59 より小さい。 OK。
    29 より小さい。 OK。
    89 より小さい。 OK。
    19 より小さい。 OK。
    すべての要素がピボットより小さいか等しい。
  • 図1-2: ピボット 9 を、すべての要素の中で最も大きい位置(つまり末尾)に置く。
    -> [5, 2, 8, 1, **9**]

    • ポイント: ピボット 9 が最終的な位置(インデックス4)に確定。左側の [5, 2, 8, 1] と右側(要素なし)に分割。

2回目のクイックソート(左側サブ配列: [5, 2, 8, 1], 対象範囲: インデックス0〜3):
配列: [5, 2, 8, 1] (元の配列の該当部分を対象とする)
ピボット: 1 (範囲の最後の要素)

  • 図2-1: [5, 2, 8, 1] – ピボット 1
    51 より大きい。
    21 より大きい。
    81 より大きい。
    11 と等しい。
  • 図2-2: 要素を並べ替え。ピボットより小さい要素を左へ、大きい要素を右へ。
    (パーティショニングの詳細な手順はいくつかありますが、ここでは結果だけイメージ)
    例として、以下の手順でパーティショニングが行われたとする。
    [5, 2, 8, 1] -> ピボット 1
    左端から開始。i=0 (要素 5)。5 > 1
    右端から開始(ピボット除く)。j=2 (要素 8)。8 > 1j-- -> j=1 (要素 2)。2 > 1j-- -> j=0 (要素 5)。 i >= j で停止。
    要素の交換:ピボット 1 と、i が指している要素(または最後にスキャンした要素)を交換するなどの方法がある。
    あるいは、別の典型的なパーティショニング手法(ホーアのパーティション方式など)では、左右から探索して交換を繰り返す。
    例えば、Lomutoのパーティション方式の場合:
    ピボットを最後の要素 1 とし、先頭からピボットより小さい要素を順に前に置いていく。
    [5, 2, 8, 1]
    i = -1 (小さい要素の境界の左側)
    j = 0 (要素 5)。5 > 1。iを増やさずjを進める。
    j = 1 (要素 2)。2 > 1。iを増やさずjを進める。
    j = 2 (要素 8)。8 > 1。iを増やさずjを進める。
    j = 3 (ピボット 1)。1 <= 1i++ -> i=0arr[i] (5) と arr[j] (1) を交換。
    -> [**1**, 2, 8, **5**]
    最後にピボット 1 (現在の位置はインデックス0) と、i+1 の位置の要素 (2) を交換。
    -> [1, **2**, 8, 5] …あれ?ピボットが最初の位置に移動してしまった。これはピボットを最初に交換しておくべきだった。

    より分かりやすいパーティショニングのイメージとして、新しい配列に振り分ける方法を考えます(ただし、これは空間計算量が増えます)。
    ピボット 1
    ピボットより小さいグループ []
    ピボットより大きいグループ []
    5 -> 大きいグループ [5]
    2 -> 大きいグループ [5, 2]
    8 -> 大きいグループ [5, 2, 8]
    1 -> 小さいグループ [1] (あるいはピボット自身)
    結果として、 [1] (ピボットより小さい) + [1] (ピボット自身) + [5, 2, 8] (ピボットより大きい) となる。
    元の配列内で「インプレース」で分割するには、より複雑なポインタ操作が必要。

    インプレースでのパーティショニング(Lomutoのパーティション方式を修正して、ピボットを最後に配置する例):
    配列: [5, 2, 8, 1, 9] のうち [5, 2, 8, 1] (インデックス 0-3)
    ピボットを最後に置く 1 とする。
    [5, 2, 8, 1] – ピボット 1
    i = -1 (小さい要素の右端インデックス)
    j = 0 (要素 5)。 5 > 1。j進む。
    j = 1 (要素 2)。 2 > 1。j進む。
    j = 2 (要素 8)。 8 > 1。j進む。
    j = 3 (要素 1)。 1 <= 1i++ -> i=0arr[i] (5) と arr[j] (1) を交換。
    -> [**1**, 2, 8, **5**]
    ループ終了後、ピボット(最後の要素 5)と arr[i+1]arr[1], 要素 2)を交換。
    -> [1, **5**, 8, **2**] …これはソートできてない。

    ピボット選択とパーティショニングの実装によって結果は変わりますが、基本は「ピボットを正しい位置に置き、左右を再帰的にソート」です。
    ここでは、元の例 [5, 2, 8, 1, 9] に戻り、ピボット 9 が確定した後、左側 [5, 2, 8, 1] のソートを考えます。

    再開(左側サブ配列: [5, 2, 8, 1], インデックス0〜3)
    ピボットを最後の要素 1 とするパーティショニング(Lomutoの方式、修正なし):
    [5, 2, 8, **1**] – ピボットは 1
    i = -1 (小さい要素の右端)
    j = 0 (5)。5 > 1
    j = 1 (2)。2 > 1
    j = 2 (8)。8 > 1
    j = 3 (1)。1 <= 1i++ (i=0)。arr[i] (5) と arr[j] (1) を交換。
    -> [**1**, 2, 8, **5**]
    ループ終了後、ピボット 1arr[i+1] (arr[1], 要素 2) を交換。
    -> [1, **2**, 8, **5**]
    …やはりうまくいかない。

    クイックソートのパーティショニングは、バブルソートや挿入ソートより少し複雑です。典型的なホーアのパーティション方式でイメージを追ってみましょう。
    ピボットを配列の先頭要素 5 とします。
    対象範囲: [5, 2, 8, 1] (インデックス 0-3)
    [5, 2, 8, 1] – ピボット 5
    左ポインタ i=0、右ポインタ j=3
    i を進めてピボットより大きい要素を探す: arr[0]=5 (ピボットと等しいので停止、あるいは進める)、arr[1]=2 (2 <= 5)、arr[2]=8 (8 > 5)。i2 で停止。
    j を戻してピボットより小さい要素を探す: arr[3]=1 (1 <= 5)。j3 で停止。
    i < j (2 < 3) なので arr[i] (8) と arr[j] (1) を交換。
    -> [5, 2, **1**, 8]
    再度探索:
    i を進める: arr[2]=1 (1 <= 5)。i 進める -> i=3 (要素 8)。8 > 5i3 で停止。
    j を戻す: arr[3]=8 (8 > 5)。j 戻す -> j=2 (要素 1)。1 <= 5j2 で停止。
    i < j (3 < 2) が満たされない。探索終了。
    ピボット(元の arr[0]5)と、j が指す位置 arr[2]1 を交換。
    -> [**1**, 2, **5**, 8]
    * ポイント: ピボット 5 が正しい位置(インデックス2)に移動・確定。左側 [1, 2] (インデックス0-1)、右側 [8] (インデックス3) に分割。

    再帰的にソート:
    * 左側サブ配列 [1, 2] (インデックス0-1) をクイックソート。
    * ピボットを 1 とする(先頭)。
    * [1, 2] – ピボット 1i=0, j=1
    * i 進める: arr[0]=1 (1 <= 1)。i=1 (2)。2 > 1i1 で停止。
    * j 戻す: arr[1]=2 (2 > 1)。j=0 (1)。1 <= 1j0 で停止。
    * i < j (1 < 0) が満たされない。探索終了。
    * ピボット 1arr[j] (arr[0], 1) を交換。(位置変わらず)
    * -> [1, 2]
    * ピボット 1 が確定(インデックス0)。左側(要素なし)、右側 [2] (インデックス1) に分割。
    * 右側 [2] は要素1つなのでソート済み。
    * [1, 2] がソート完了。

    • 右側サブ配列 [8] (インデックス3) をクイックソート。要素1つなのでソート済み。

    • 確定済みのピボット 5 (インデックス2) の両側がソートされた結果を合わせる。
      [1, 2] (ソート済み左側) + 5 (ピボット) + [8] (ソート済み右側)
      -> [1, 2, 5, 8]

    最後に、最初のピボット 9 を加える:
    [1, 2, 5, 8] + 9
    -> [1, 2, 5, 8, 9]

最終状態: [1, 2, 5, 8, 9] (ソート完了)

コード例(Python、ホーアのパーティション方式):

“`python
def quick_sort(arr, low, high):
# 再帰の終了条件:サブ配列の要素数が1以下になったら終了
if low < high:
# 配列を分割し、ピボットの最終的な位置を取得
pivot_index = partition(arr, low, high)

    # ピボットの左側のサブ配列を再帰的にソート
    quick_sort(arr, low, pivot_index) # ホーア方式の場合、pivot_index はピボット自身の位置ではなく境界を示すため、範囲にピボット自身を含むことがある
    # ピボットの右側のサブ配列を再帰的にソート
    quick_sort(arr, pivot_index + 1, high)

return arr # 最初の呼び出しに対してソート済み配列を返す

配列を分割する関数(ホーアのパーティション方式)

def partition(arr, low, high):
# ピボットとして範囲の先頭要素を選ぶ
pivot = arr[low]
# 左からのポインタと右からのポインタ
i = low – 1
j = high + 1

while True:
    # 左から進み、ピボット以下の要素を見つける
    i += 1
    while arr[i] < pivot: # arr[i] <= pivot とする実装もある
        i += 1

    # 右から戻り、ピボット以上の要素を見つける
    j -= 1
    while arr[j] > pivot: # arr[j] >= pivot とする実装もある
        j -= 1

    # ポインタが交差したら分割位置が確定
    if i >= j:
        # j がピボットより小さい要素の右端のインデックスとなる
        return j

    # ポインタが交差していなければ要素を交換
    arr[i], arr[j] = arr[j], arr[i]

my_list = [5, 2, 8, 1, 9]

初回呼び出しは配列全体を指定

quick_sort(my_list, 0, len(my_list) – 1)
print(my_list) # 出力: [1, 2, 5, 8, 9]
“`
*注意:上記のPythonコードはホーアのパーティション方式の典型的な実装例ですが、ピボットの選択方法やパーティションの実装にはいくつかのバリエーションがあります。特に、Lomutoのパーティション方式はホーア方式より少し直感的かもしれません(最後の要素をピボットとし、ピボットより小さい要素を先頭から詰めていく)。

特徴:

  • 計算量:
    • 最悪計算量: O(N^2) (毎回ピボットとして最小値や最大値を選んでしまい、配列が極端に偏って分割される場合。例: 既にソート済みの配列を、常に最後の要素をピボットとしてソートする場合など)
    • 平均計算量: O(N log N) (ピボットが適切に選ばれる場合、非常に高速)
    • 最善計算量: O(N log N)
  • 空間計算量: O(log N) (再帰呼び出しによるスタック領域の使用量。最悪の場合はO(N)になる可能性もあるが、工夫次第で抑えられる)
  • 安定性: 不安定なソートアルゴリズムです。(パーティショニングの過程で、同じ値を持つ要素の相対順序が崩れる可能性があるため)
  • 利点: 平均的には最も高速なソートアルゴリズムの一つ。インプレースソート(追加メモリが少ない)である。
  • 欠点: 最悪計算量がO(N^2)になる可能性がある(適切なピボット選択が重要)。不安定ソートである。再帰が深く入りすぎる場合がある。
  • 適している場合: 要素数が多い場合で、平均的な高速性が最も重要であり、安定性は問わない場合。多くの標準ライブラリで採用されているソートアルゴリズムのベースとなっています。

6. ヒープソート (Heap Sort)

概要:
ヒープソートは、選択ソートの考え方(未ソート部分から最大値を見つけて末尾に移動させる)を改良したアルゴリズムです。データの構造として「ヒープ(Heap)」という木構造を利用します。特に「最大ヒープ」という、親ノードの値が子ノードの値より常に大きいという性質を持つヒープを使用します。

動作原理(図解イメージ):
ヒープソートは大きく2つのステップからなります。
1. ヒープの構築 (Heapify): 与えられた配列を、最大ヒープの性質を満たすように並べ替えます。配列を木構造とみなして操作することで、このステップはインプレースで行えます。最大ヒープでは、根(ルート)ノードに常に最大値が位置します。
2. ソート (Sorting): ヒープが構築されたら、根(最大値)と配列の最後の要素を交換します。これにより、最大の要素が配列の最後に移動し、その位置が確定します。次に、最後の要素を除いた残りの要素に対して、再度ヒープの性質が満たされるように調整(ヒープ化)します。この操作(最大値の取り出しとヒープの再調整)を、ヒープのサイズを一つずつ減らしながら繰り返します。根から最大値を取り出すたびに、その位置にはヒープの末尾要素を移動させ、そこから下に降りながら適切な位置に調整していくイメージです。

例: [5, 2, 8, 1, 9] を昇順にソートする場合

ステップ1: 最大ヒープの構築
配列 [5, 2, 8, 1, 9] を木構造としてイメージします(配列のi番目の要素の子は2i+1番目と2i+2番目、親は(i-1)//2番目)。
まず、非葉ノード(子を持つノード)の最後のものから順にヒープ化処理を行います。要素数5の場合、インデックス2 (8)、1 (2)、0 (5) が非葉ノードです。

  • 図1-1: インデックス2の要素 8 をヒープ化(子 1)。8 は子の 1 より大きいのでOK。ヒープ化不要。
    配列: [5, 2, **8**, 1, 9]
  • 図1-2: インデックス1の要素 2 をヒープ化(子 19)。子の中で最大値は 92 < 9 なので 29 を交換。
    配列: [5, **9**, 8, 1, **2**]
    9 は根、2 は末尾に移動。
    現在の状態: [5, 9, 8, 1, 2]
  • 図1-3: インデックス0の要素 5 をヒープ化(子 98)。子の中で最大値は 95 < 9 なので 59 を交換。
    配列: [**9**, 5, 8, 1, 2]
    9 は根、5 はインデックス1に移動。
    インデックス1の要素 5 は、移動先でさらに子(1, 2)とヒープ化が必要かチェック。
    5 の子 1, 2。最大値は 25 > 2 なので、インデックス1でのヒープ化は不要。
    最大ヒープ構築完了: [9, 5, 8, 1, 2] (根に最大値 9 が来ています)

ステップ2: ソート
ヒープサイズを N (=5) から始め、1ずつ減らしていく。

  • 図2-1: ヒープの根 9 と末尾 2 を交換。
    [**2**, 5, 8, 1, **9**]
    末尾の 9 はソート済みとして確定。ヒープサイズを4に減らす(対象範囲: インデックス0〜3)。
    現在の未ソートヒープ: [2, 5, 8, 1]

  • 図2-2: 未ソートヒープ [2, 5, 8, 1] の根 2 をヒープ化(子のインデックス 1, 2)。
    2 の子 5, 8。最大値は 82 < 8 なので 28 を交換。
    [**8**, 5, **2**, 1]
    2 はインデックス2に移動。移動先でさらに子(インデックス5, 6だが、範囲外)とヒープ化が必要かチェック。(今回は子なしなので終了)
    未ソートヒープの状態: [8, 5, 2, 1]

  • 図2-3: 未ソートヒープ [8, 5, 2, 1] の根 8 と末尾 1 を交換。
    [**1**, 5, 2, **8**, 9]
    末尾から2番目の 8 はソート済みとして確定。ヒープサイズを3に減らす(対象範囲: インデックス0〜2)。
    現在の未ソートヒープ: [1, 5, 2]

  • 図2-4: 未ソートヒープ [1, 5, 2] の根 1 をヒープ化(子のインデックス 1, 2)。
    1 の子 5, 2。最大値は 51 < 5 なので 15 を交換。
    [**5**, **1**, 2, 8, 9]
    1 はインデックス1に移動。移動先でさらに子(インデックス3, 4だが、範囲外)とヒープ化が必要かチェック。(今回は子なしなので終了)
    未ソートヒープの状態: [5, 1, 2]

  • 図2-5: 未ソートヒープ [5, 1, 2] の根 5 と末尾 2 を交換。
    [**2**, 1, **5**, 8, 9]
    末尾から3番目の 5 はソート済みとして確定。ヒープサイズを2に減らす(対象範囲: インデックス0〜1)。
    現在の未ソートヒープ: [2, 1]

  • 図2-6: 未ソートヒープ [2, 1] の根 2 をヒープ化(子インデックス1)。
    2 の子 12 > 1 なのでヒープ化不要。
    未ソートヒープの状態: [2, 1]

  • 図2-7: 未ソートヒープ [2, 1] の根 2 と末尾 1 を交換。
    [**1**, **2**, 5, 8, 9]
    末尾から4番目の 2 はソート済みとして確定。ヒープサイズを1に減らす(対象範囲: インデックス0)。
    現在の未ソートヒープ: [1]

  • 図2-8: 未ソートヒープ [1] は要素1つなのでソート済み。ヒープサイズ0になり終了。

最終状態: [1, 2, 5, 8, 9] (ソート完了)

コード例(Python):

“`python
def heap_sort(arr):
n = len(arr)

# 1. 最大ヒープの構築 (Build max heap)
# 配列を最大ヒープの構造に変換
# 非葉ノードの最後のものから根に向かってヒープ化を行う
# 葉ノードは (n//2) から n-1 までのインデックスに位置する
for i in range(n // 2 - 1, -1, -1):
    heapify(arr, n, i)

# 2. ソート (Extract elements from heap)
# ヒープから要素を一つずつ取り出す(根を取り出し、末尾と交換し、ヒープサイズを減らす)
for i in range(n - 1, 0, -1):
    # 根(最大値)と現在のヒープの末尾要素を交換
    arr[i], arr[0] = arr[0], arr[i]
    # 末尾を除いた残りのヒープ(サイズ i)に対して、根からヒープ化を行う
    heapify(arr, i, 0)

return arr

ヒープ化関数: サブツリーを根とするヒープが最大ヒープの性質を満たすように調整する

arr: 配列

n: ヒープのサイズ(調整対象の配列の論理的なサイズ)

i: 調整を開始する根ノードのインデックス

def heapify(arr, n, i):
# 現在の根を最大値のインデックスの仮定値とする
largest = i
# 左の子ノードのインデックス
left_child = 2 * i + 1
# 右の子ノードのインデックス
right_child = 2 * i + 2

# 左の子が存在し、根より大きければ largest を更新
if left_child < n and arr[left_child] > arr[largest]:
    largest = left_child

# 右の子が存在し、現在の largest (根または左の子) より大きければ largest を更新
if right_child < n and arr[right_child] > arr[largest]:
    largest = right_child

# 最大値が根でなければ交換し、影響を受けたサブツリーに対して再帰的にヒープ化を行う
if largest != i:
    arr[i], arr[largest] = arr[largest], arr[i]
    # 交換によって影響を受けた子ノードを根とするサブツリーを再帰的にヒープ化
    heapify(arr, n, largest)

my_list = [5, 2, 8, 1, 9]
sorted_list = heap_sort(my_list)
print(sorted_list) # 出力: [1, 2, 5, 8, 9]
“`

特徴:

  • 計算量:
    • 最悪計算量: O(N log N)
    • 平均計算量: O(N log N)
    • 最善計算量: O(N log N) (データの並び方に影響されにくく、計算量が安定)
  • 空間計算量: O(1) (ヒープ構造自体を配列上に構築するため、追加メモリはごくわずか)
  • 安定性: 不安定なソートアルゴリズムです。(根と末尾を交換する際に、同じ値を持つ要素の相対順序が崩れる可能性があるため)
  • 利点: 計算量が常にO(N log N)と効率的で安定している。インプレースソートであり、追加メモリがほとんど不要。
  • 欠点: アルゴリズムの仕組み(ヒープ構造)の理解が他のO(N log N)ソート(マージソート、クイックソート)よりやや複雑かもしれない。キャッシュ効率があまり良くない場合がある(データが離れた位置にあるノード間で交換されるため)。
  • 適している場合: 要素数が多い場合で、計算量の安定性と追加メモリの少なさが重要視される場合。

7. 計数ソート (Counting Sort)

概要:
これまで見てきたソートアルゴリズムは、要素同士を「比較」することで順番を決定していました(比較ソート)。しかし、計数ソートは「比較」を行いません。代わりに、要素の値そのものを利用してソートを行います。これは、ソート対象のデータが非負の整数であり、かつ値の範囲(最大値と最小値の差)が比較的小さい場合に非常に高速なソートを実現できます。

動作原理(図解イメージ):
1. 値の範囲を調べる: ソート対象の配列に含まれる値の最小値と最大値を調べます。
2. カウント配列の作成: 最小値から最大値までの範囲をカバーできるサイズの「カウント配列」を作成します。この配列の各要素は0で初期化されます。
3. 要素の出現回数を数える: ソート対象の配列を最初から最後まで見ていき、各要素の値が出現した回数をカウント配列に記録します。例えば、値が k の要素が出現したら、カウント配列のインデックス k の値を1増やします。
4. 累積和を計算する: カウント配列の各要素に、それより前の要素の値を足し合わせることで累積和(るいせきわ)を計算します。これにより、カウント配列のインデックス k の値は、「ソート後の配列で、値が k 以下の要素が最後尾から数えて何番目に来るか」を示すようになります(あるいは、少し調整すると「先頭から数えて何番目か」を示すようにもできます)。
5. 新しい配列に配置する: ソート対象の配列を後ろから見ていきます。要素の値が v であった場合、累積和が計算されたカウント配列のインデックス v の値を見ます。その値が、ソート後の配列で v が最後に置かれる位置を示しています。そこに v を配置し、カウント配列のインデックス v の値を1減らします。この操作を繰り返すことで、安定かつ正しくソートされた配列が完成します。後ろから見ていくことで安定性が保証されます。

例: [5, 2, 8, 1, 9, 2, 5] を昇順にソートする場合

ステップ1: 値の範囲を調べる
最小値: 1, 最大値: 9

ステップ2: カウント配列の作成
値の範囲は1から9なので、インデックス0から9までのサイズ10のカウント配列を作成します(最小値が0でない場合はオフセットを考慮します)。
count = [0] * 10
初期状態: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0] (インデックス 0-9)

ステップ3: 要素の出現回数を数える
入力配列 [5, 2, 8, 1, 9, 2, 5] を順に見る。
5: count[5] を +1 -> [0, 0, 0, 0, 0, **1**, 0, 0, 0, 0]
2: count[2] を +1 -> [0, 0, **1**, 0, 0, 1, 0, 0, 0, 0]
8: count[8] を +1 -> [0, 0, 1, 0, 0, 1, 0, 0, **1**, 0]
1: count[1] を +1 -> [0, **1**, 1, 0, 0, 1, 0, 0, 1, 0]
9: count[9] を +1 -> [0, 1, 1, 0, 0, 1, 0, 0, 1, **1**]
2: count[2] を +1 -> [0, 1, **2**, 0, 0, 1, 0, 0, 1, 1]
5: count[5] を +1 -> [0, 1, 2, 0, 0, **2**, 0, 0, 1, 1]
カウント配列の状態: [0, 1, 2, 0, 0, 2, 0, 0, 1, 1]
(インデックス0の値は無視、1が1個、2が2個、5が2個、8が1個、9が1個)

ステップ4: 累積和を計算する
カウント配列 [0, 1, 2, 0, 0, 2, 0, 0, 1, 1]
count[0] = 0
count[1] = count[1] + count[0] = 1 + 0 = 1
count[2] = count[2] + count[1] = 2 + 1 = 3
count[3] = count[3] + count[2] = 0 + 3 = 3
count[4] = count[4] + count[3] = 0 + 3 = 3
count[5] = count[5] + count[4] = 2 + 3 = 5
count[6] = count[6] + count[5] = 0 + 5 = 5
count[7] = count[7] + count[6] = 0 + 5 = 5
count[8] = count[8] + count[7] = 1 + 5 = 6
count[9] = count[9] + count[8] = 1 + 6 = 7
累積和計算後のカウント配列: [0, 1, 3, 3, 3, 5, 5, 5, 6, 7]
(インデックス v の値は、「ソート後の配列で、値が v 以下の要素が合計 count[v] 個ある」ことを意味します。これは「ソート後の配列の count[v]-1 番目のインデックスに、値 v の最後の要素が配置される」ことを示唆します。)

ステップ5: 新しい配列に配置する
入力配列 [5, 2, 8, 1, 9, 2, 5]後ろから見る。ソート後の結果を格納する新しい配列 output (サイズ7) を用意。
output = [0] * 7

  • 入力配列の最後 5 (インデックス6)。値は 5
    count[5]5。これは、値 5 の最後のインスタンスが、ソート後の配列のインデックス 5-1=4 に置かれることを意味する。
    output[4] = 5
    count[5] を -1 -> count[5]4 に。
    output 状態: [0, 0, 0, 0, **5**, 0, 0]

  • 入力配列の次 2 (インデックス5)。値は 2
    count[2]3。これは、値 2 の最後のインスタンスが、ソート後の配列のインデックス 3-1=2 に置かれることを意味する。
    output[2] = 2
    count[2] を -1 -> count[2]2 に。
    output 状態: [0, 0, **2**, 0, 5, 0, 0]

  • 入力配列の次 9 (インデックス4)。値は 9
    count[9]7。インデックス 7-1=6 に配置。
    output[6] = 9
    count[9] を -1 -> count[9]6 に。
    output 状態: [0, 0, 2, 0, 5, 0, **9**]

  • 入力配列の次 1 (インデックス3)。値は 1
    count[1]1。インデックス 1-1=0 に配置。
    output[0] = 1
    count[1] を -1 -> count[1]0 に。
    output 状態: [**1**, 0, 2, 0, 5, 0, 9]

  • 入力配列の次 8 (インデックス2)。値は 8
    count[8]6。インデックス 6-1=5 に配置。
    output[5] = 8
    count[8] を -1 -> count[8]5 に。
    output 状態: [1, 0, 2, 0, 5, **8**, 9]

  • 入力配列の次 2 (インデックス1)。値は 2
    count[2] は現在 2。インデックス 2-1=1 に配置。
    output[1] = 2
    count[2] を -1 -> count[2]1 に。
    output 状態: [1, **2**, 2, 0, 5, 8, 9]

  • 入力配列の次 5 (インデックス0)。値は 5
    count[5] は現在 4。インデックス 4-1=3 に配置。
    output[3] = 5
    count[5] を -1 -> count[5]3 に。
    output 状態: [1, 2, 2, **5**, 5, 8, 9]

入力配列を最後まで見終わったので、ソート完了です。

最終状態: [1, 2, 2, 5, 5, 8, 9] (ソート完了)

コード例(Python):

“`python
def counting_sort(arr):
# 入力配列が空の場合はそのまま返す
if not arr:
return []

# 1. 値の範囲を調べる
# 最小値を0と仮定するか、実際に配列の最小値を計算する必要がある
# ここでは簡単のため、非負整数で最小値が0以上、最大値がkであると仮定
# もし値に負数を含む場合は、オフセットを考慮する必要がある
max_val = max(arr) # 配列の最大値を取得
min_val = min(arr) # 配列の最小値を取得(負数対応する場合は必要)
# 値の範囲 + 1 のサイズのカウント配列
# カウント配列のインデックスを値として扱うため、min_val が 0 でない場合は調整が必要
# ここでは簡単のため、min_val = 0 またはそれを考慮した実装とする
# 例として、値の範囲が 0 から max_val の場合
count_size = max_val + 1 # 最小値が0の場合
# もし min_val が 0 でない場合は count_size = max_val - min_val + 1
# その場合、値 v を count[v - min_val] にマッピングする

# 2. カウント配列の作成
# 実際には、値の範囲 (max_val - min_val + 1) のサイズの配列を作り、
# 各要素 v を count[v - min_val] にマップするのが一般的だが、
# 簡単のため、ここでは値が0以上であることを前提に max_val+1 サイズとする
count = [0] * (max_val + 1)

# 3. 要素の出現回数を数える
for num in arr:
    count[num] += 1 # ここで count[num - min_val] とする

# 4. 累積和を計算する
# count[i] がソート後の i 以下の値の総数となるように更新
for i in range(1, len(count)):
    count[i] += count[i - 1]

# 5. 新しい配列に配置する
# ソート後の結果を格納する配列(入力配列と同じサイズ)
output = [0] * len(arr)
# 入力配列を後ろから見ていくことで安定性を保証
for num in reversed(arr): # reversed(arr) は元の配列を破壊しないイテレータ
    # num の最終的な位置は count[num] - 1
    # count[num] の値は、num 以下の要素の総数
    # そのため、値 num の最後の要素は output[count[num] - 1] に配置される
    output[count[num] - 1] = num
    # 配置が終わったので、その値のカウントを減らす
    # これにより、同じ値の次のインスタンスは一つ前の位置に配置される
    count[num] -= 1

# 結果を返す (または元の配列を output で置き換える)
return output

my_list = [5, 2, 8, 1, 9, 2, 5]
sorted_list = counting_sort(my_list)
print(sorted_list) # 出力: [1, 2, 2, 5, 5, 8, 9]

my_list_with_zero = [5, 2, 8, 0, 9, 2, 5]
sorted_list_zero = counting_sort(my_list_with_zero)
print(sorted_list_zero) # 出力: [0, 2, 2, 5, 5, 8, 9]

※ 負数を含む場合のカウントソートは少し複雑になります。

例: [-2, 5, 0, -2, 3] の場合

min_val = -2, max_val = 5

範囲サイズ = 5 – (-2) + 1 = 8

count配列サイズ 8。インデックス 0, …, 7 がそれぞれ値 -2, -1, …, 5 に対応

値 v は count[v – min_val] = count[v – (-2)] = count[v + 2] にマッピング

例: 値 -2 は count[0]、値 0 は count[2]、値 5 は count[7]

“`

特徴:

  • 計算量:
    • 最悪・平均・最善計算量: O(N + K) (Nは要素数、Kは値の範囲 max – min + 1)。これは比較ソートの限界であるO(N log N)より高速になり得ます。
  • 空間計算量: O(K) (カウント配列のための追加メモリが必要)
  • 安定性: 安定なソートアルゴリズムです。(入力配列を後ろから処理することで、同じ値の要素の相対順序が保たれるように配置できるため)
  • 利点: 特定の条件下(非負整数、値の範囲が小さい)では比較ソートよりはるかに高速。安定ソートである。
  • 欠点: ソート対象が非負整数である必要がある(または負数や他のデータ型を適切にマッピングする必要がある)。値の範囲KがNに対して非常に大きい場合、空間計算量O(K)や時間計算量O(N+K)が非効率になる。
  • 適している場合: 非負整数の配列をソートする場合で、要素数の割に値の範囲が小さい場合(例: 0~100の点数のソートなど)。基数ソートの一部として利用されることもあります。

8. 基数ソート (Radix Sort)

概要:
基数ソートも、比較ソートではないアルゴリズムです。これは、要素を桁(基数)ごとに分けてソートすることを繰り返すことで全体をソートします。例えば10進数であれば、一の位、十の位、百の位…というように、下位の桁から順にソートします。各桁のソートには、通常、計数ソートやバケットソートのような安定なソートアルゴリズムが利用されます。

動作原理(図解イメージ):
1. 最大桁数を調べる: ソート対象の配列に含まれるすべての要素の中で、最も桁数の多い要素の桁数を調べます。
2. 桁ごとのソートを繰り返す: 一番下の桁から順に、最も桁数の多い桁まで、以下の処理を繰り返します。
* 現在の桁の値に基づいて、配列を安定ソートします。安定ソートを使うことが重要です。下位桁でのソート結果を上位桁のソートで崩さないためです。
3. すべての桁についてソートが終われば、配列全体がソートされています。

例: [170, 45, 75, 90, 2, 24, 802, 66] を昇順にソートする場合

ステップ1: 最大桁数を調べる
要素は最大で3桁(例: 170, 802)。したがって、一の位、十の位、百の位の3回ソートを行います。

ステップ2: 一の位で安定ソート
現在の配列: [170, 45, 75, 90, 2, 24, 802, 66]
一の位の値: [0, 5, 5, 0, 2, 4, 2, 6]
これらの値に基づいて安定ソートします(計数ソートを利用する場合、値は0-9)。

図1: 一の位で安定ソート後の配列
[17**0**, 9**0**, 2, 80**2**, 2**4**, 4**5**, 7**5**, 6**6**] (同じ一の位の要素は元の順序を保つ)
結果: [170, 90, 2, 802, 24, 45, 75, 66] (元の配列を書き換える)

ステップ3: 十の位で安定ソート
現在の配列: [170, 90, 2, 802, 24, 45, 75, 66]
十の位の値: [7, 9, 0, 0, 2, 4, 7, 6] (一桁の数は十の位が0とみなす)
これらの値に基づいて安定ソートします。

図2: 十の位で安定ソート後の配列
[2, 80**2**, 2**4**, 4**5**, 6**6**, 1**7**0, 7**5**, 9**0**] (同じ十の位の要素は一の位のソート結果を保つ)
結果: [2, 802, 24, 45, 66, 170, 75, 90]

ステップ4: 百の位で安定ソート
現在の配列: [2, 802, 24, 45, 66, 170, 75, 90]
百の位の値: [0, 8, 0, 0, 0, 1, 0, 0] (一桁・二桁の数は百の位が0とみなす)
これらの値に基づいて安定ソートします。

図3: 百の位で安定ソート後の配列
[**0**02, **0**24, **0**45, **0**66, **0**75, **0**90, **1**70, **8**02] (同じ百の位の要素は十の位までのソート結果を保つ)
結果: [2, 24, 45, 66, 75, 90, 170, 802]

すべての桁についてソートが終わりました。

最終状態: [2, 24, 45, 66, 75, 90, 170, 802] (ソート完了)

コード例(Python、計数ソートを内部ソートとして使用):

“`python

基数ソートで使用する安定ソート関数(計数ソートを改造)

exp は現在の桁の位置を示す指数 (1, 10, 100, …)

def counting_sort_for_radix(arr, exp):
n = len(arr)
output = [0] * n # ソート後の結果を格納する配列
count = [0] * 10 # 桁の値は0-9なのでサイズ10のカウント配列

# 1. 現在の桁の値の出現回数を数える
for i in range(n):
    # (arr[i] // exp) は現在の桁より上位の値を切り捨て
    # % 10 で現在の桁の値 (0-9) を取得
    digit = (arr[i] // exp) % 10
    count[digit] += 1

# 2. 累積和を計算する
# count[i] が現在の桁の値が i 以下である要素の総数となるように更新
for i in range(1, 10):
    count[i] += count[i - 1]

# 3. 新しい配列に配置する (後ろから見て安定性を保証)
i = n - 1 # 入力配列の最後のインデックス
while i >= 0:
    digit = (arr[i] // exp) % 10
    # digit の最終的な位置は count[digit] - 1
    output[count[digit] - 1] = arr[i]
    count[digit] -= 1
    i -= 1

# 結果を入力配列にコピー (インプレースにするため)
for i in range(n):
    arr[i] = output[i]

def radix_sort(arr):
# 入力配列が空の場合はそのまま返す
if not arr:
return

# 最大値を求めて、桁数を決定する
max_val = max(arr)

# 一の位 (exp=1) から始めて、最も大きい桁まで計数ソートを繰り返す
exp = 1
while max_val // exp > 0: # 現在の桁でソートする要素が存在する限り繰り返す
    counting_sort_for_radix(arr, exp)
    exp *= 10 # 次は十の位、百の位...

my_list = [170, 45, 75, 90, 2, 24, 802, 66]
radix_sort(my_list)
print(my_list) # 出力: [2, 24, 45, 66, 75, 90, 170, 802]
“`

特徴:

  • 計算量:
    • 最悪・平均・最善計算量: O(d * (N + b)) (Nは要素数、dは最大桁数、bは基数(例: 10進数なら10))。各桁のソートに安定なO(N+b)のアルゴリズム(計数ソート)を使用した場合。要素数Nが大きくても、最大桁数dや基数bが小さい場合は、O(N log N)の比較ソートより高速になります。特に、bをNに近い値に選ぶとO(N log_b N)となり、これは比較ソートの理論限界O(N log N)に近い性能となります。
  • 空間計算量: O(N + b) (計数ソートのためにO(N+b)の追加メモリが必要)
  • 安定性: 安定なソートアルゴリズムです。(各桁のソートに安定ソートを使用するため)
  • 利点: 特定の条件下(非負整数、値の範囲Kに対して基数bをうまく選べる場合)では比較ソートより高速。安定ソートである。
  • 欠点: ソート対象が整数である必要がある(文字列などにも応用は可能だが、桁の定義や基数、比較方法などを適切に定義する必要がある)。値の範囲や桁数が多い場合は効率が悪くなる。
  • 適している場合: 非負整数の配列をソートする場合で、値の最大値が極端に大きくない場合(桁数が少ない場合)。

アルゴリズムの比較と選び方

ここまでいくつかの主要なソートアルゴリズムを見てきました。それぞれのアルゴリズムには得意なこと、苦手なことがあります。以下に主な特徴をまとめた表を示します。

アルゴリズム 平均計算量 最悪計算量 空間計算量 安定性 実装の容易さ 特徴
バブルソート O(N^2) O(N^2) O(1) 安定 非常に容易 最もシンプルだが非効率。学習用。
選択ソート O(N^2) O(N^2) O(1) 不安定 容易 交換回数が少ない。非効率。
挿入ソート O(N^2) O(N^2) O(1) 安定 容易 要素数が少ない場合や、ほぼソート済みのデータに高速。
マージソート O(N log N) O(N log N) O(N) 安定 中程度 計算量が安定して高速。追加メモリが必要。
クイックソート O(N log N) O(N^2) O(log N) 不安定 中程度 平均的に最も高速なソートの一つ。最悪計算量に注意。
ヒープソート O(N log N) O(N log N) O(1) 不安定 中程度 計算量が安定して高速。追加メモリが少ない。ヒープ構造の理解が必要。
計数ソート O(N + K) O(N + K) O(K) 安定 容易 非負整数で値の範囲Kが小さい場合に非常に高速。比較ソートではない。
基数ソート O(d * (N+b)) O(d * (N+b)) O(N + b) 安定 中程度 整数で特定の条件下に高速。桁ごとの安定ソートを繰り返す。比較ソートではない。
  • N: 要素数
  • K: 計数ソートにおける値の範囲 (max – min + 1)
  • d: 基数ソートにおける最大桁数
  • b: 基数ソートにおける基数

どのアルゴリズムを選べば良いか?

実際のプログラミングで自分でソートアルゴリズムを実装することは少なく、ほとんどの場合、プログラミング言語の標準ライブラリに用意されているソート関数を使用します。これらの標準ソートは、一般的に非常に高速で安定した、高度に最適化されたアルゴリズム(クイックソートの改良版であるイントロソートや、マージソート、ティムソートなど)が使われています。

しかし、ソートアルゴリズムの仕組みを理解することは、以下のような点で非常に重要です。

  • 問題解決能力の向上: アルゴリズム的な思考の基礎となる。
  • 既存コードの理解: 標準ライブラリのソートがなぜ速いのか、どのような仕組みで動いているのかを理解できる。
  • 特殊な状況への対応: 標準ライブラリのソートが使えない、あるいは非効率な特殊な状況(例: 非常に大きなデータセット、特定のデータ構造、特定の制約)で、適切なアルゴリズムを選択したり、独自のソートを実装したりする必要がある場合に役立つ。
  • 性能のボトルネック特定: プログラムの遅い部分がソートにある場合、その原因を理解し、改善策を検討できる。

アルゴリズム選択の一般的な指針は以下のようになります。

  • 要素数が非常に少ない (数百個以下): バブルソート、選択ソート、挿入ソートでも実用的な速度が出ます。挿入ソートは既にソート済みのデータに近い場合に特に高速です。実装が容易なので、簡単な用途には適しています。
  • 要素数が多い (数千個以上): O(N^2) のアルゴリズムは遅すぎて使えません。O(N log N) のアルゴリズム(マージソート、クイックソート、ヒープソート)や、特殊なO(N+K) のアルゴリズム(計数ソート、基数ソート)を検討します。
  • 安定性が必要: マージソート、挿入ソート、計数ソート、基数ソートの中から選びます。クイックソートやヒープソートは不安定です。
  • 追加メモリを節約したい: ヒープソートやインプレースなクイックソートはO(1)またはO(log N)の空間計算量で効率的です。マージソートや計数ソート、基数ソートはO(N)またはO(K)の追加メモリが必要です。
  • データの値の範囲が狭い非負整数: 計数ソートや基数ソートが圧倒的に高速になる可能性があります。
  • 平均的な速さを重視 (多少の最悪ケースを許容): クイックソートは最も広く使われます。
  • 最悪ケースでも安定した速さを重視: マージソートやヒープソートが適しています。

ほとんどの場合、実用上は標準ライブラリのソート関数を使うのが最適解です。しかし、その裏側で何が起きているのかを理解しておくことは、プログラマとして非常に価値のあることです。

まとめ

この記事では、コンピュータサイエンスの基本である「ソートアルゴリズム」について、初心者向けに主要なアルゴリズムの仕組みを図解イメージを交えながら詳しく解説しました。

  • バブルソート、選択ソート、挿入ソートといったO(N^2)のアルゴリズムはシンプルで理解しやすいですが、要素数が多いと実用的ではありません。
  • マージソート、クイックソート、ヒープソートといったO(N log N)のアルゴリズムは、大規模なデータを効率的にソートするために広く使われています。それぞれに安定性や空間計算量といった特徴の違いがあります。
  • 計数ソート、基数ソートといった非比較ソートは、データの性質(非負整数、値の範囲)によってはO(N + K)という非常に高速なソートを実現できます。

ソートアルゴリズムは、単にデータを並べ替える技術というだけでなく、アルゴリズム設計の基本的な考え方(分割統治法など)や、効率性を評価する計算量という概念を学ぶ上でも非常に良い題材です。

この記事を通じて、それぞれのアルゴリズムがどのようにデータを操作し、最終的に整列された状態を作り出すのか、その「からくり」が少しでも掴めていれば幸いです。

ソートの世界は奥深く、ここで紹介した以外にも様々なアルゴリズムやその改良版が存在します。しかし、まずはこれらの基本的なアルゴリズムを理解することが、次のステップに進むための確かな土台となるでしょう。

さあ、この知識を活かして、色々なデータをソートしてみてください。そして、もし興味が湧いたら、ぜひコードを書いて実際に動かしてみたり、他のアルゴリズムについて調べてみたりしてください。アルゴリズムの世界はきっとあなたのプログラミングスキルをさらに向上させてくれるはずです!


コメントする

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

上部へスクロール