はい、承知いたしました。「ソートアルゴリズム入門:基本から種類まで徹底解説」の詳細な説明を含む記事を約5000語で記述します。
ソートアルゴリズム入門:基本から種類まで徹底解説
はじめに:ソートとは何か、なぜ重要なのか
コンピュータ科学とプログラミングにおいて、「ソート(Sort)」すなわち「並べ替え」は、最も基本的かつ重要な操作の一つです。データが特定の順序(昇順または降順など)に並べ替えられていると、その後のデータ処理が劇的に効率化されることが多いためです。例えば、データベースで特定のデータを探す際、データがID順にソートされていれば、二分探索のような非常に高速な探索アルゴリズムを利用できます。また、統計処理やグラフ描画、データ分析など、様々な分野でソートは不可欠な前処理となります。
ソートアルゴリズムとは、コンピュータがデータを効率的に並べ替えるための「手順」や「手法」のことです。古くから多くの研究者が効率的なソート方法を考案しており、現在では様々なアルゴリズムが存在します。それぞれのアルゴリズムには得意な状況や苦手な状況があり、扱うデータの量や種類、必要な処理速度、利用可能なメモリ容量などによって、最適なアルゴリズムを選択する必要があります。
この記事では、ソートアルゴリズムの基本的な考え方から始め、代表的なアルゴリズムの種類、それぞれの仕組み、計算量(効率性)、特徴、メリット・デメリットなどを詳細に解説します。初心者の方でもソートの概念と主要なアルゴリズムについて深く理解できるよう、丁寧に説明を進めていきます。
ソートアルゴリズムの評価基準:効率性をどう測るか
ソートアルゴリズムを評価する際には、主に以下の要素が考慮されます。
-
計算量 (Computational Complexity):
- アルゴリズムが完了するまでに必要な時間の目安(時間計算量)や、使用するメモリ容量の目安(空間計算量)を示します。データの要素数
n
が増えたときに、計算時間やメモリ使用量がどのように増大するかを表すために、「O記法(オーダー記法)」がよく用いられます。 - 時間計算量は、最悪の場合(Worst Case)、最良の場合(Best Case)、平均的な場合(Average Case)で評価されることが多いです。
- O記法は、
n
が非常に大きくなったときの、最も支配的な項だけを取り出して表現します。例えば、O(n^2)
は計算時間が要素数の2乗に比例して増えることを、O(n log n)
はn
にlog n
を掛けた値に比例して増えることを意味します。一般的に、O(n log n)
はO(n^2)
よりもはるかに高速です。O(n)
は要素数に比例する最も効率の良い部類に入ります。
- アルゴリズムが完了するまでに必要な時間の目安(時間計算量)や、使用するメモリ容量の目安(空間計算量)を示します。データの要素数
-
安定性 (Stability):
- ソート対象のデータに、同じ値を持つ複数の要素が存在する場合、ソート前のそれらの要素の相対的な順序が、ソート後も維持されるかどうかを示します。例えば、同じ点数の学生を点数順にソートする際、同点の場合にソート前の出席番号順が保たれるなら、そのソートアルゴリズムは安定的であると言えます。
-
インプレース性 (In-place):
- ソートを行う際に、ソート対象の配列やリストとは別に、追加で必要となるメモリ容量が、元のデータのサイズ
n
に対して無視できるほど小さい(通常O(1)
やO(log n)
など)かどうかを示します。追加のメモリをほとんど使用しないアルゴリズムをインプレースなアルゴリズムと呼びます。メモリ容量が限られている場合に重要となる特性です。
- ソートを行う際に、ソート対象の配列やリストとは別に、追加で必要となるメモリ容量が、元のデータのサイズ
これらの基準を理解することは、様々なソートアルゴリズムの特性を把握し、目的に合ったものを選ぶ上で不可欠です。
基本的なソートアルゴリズム:シンプルだが効率は限定的
まずは、アルゴリズムの仕組みが比較的シンプルで理解しやすいものの、効率性においてはより洗練されたアルゴリズムに劣る「基本的なソートアルゴリズム」を3つ紹介します。これらは小規模なデータのソートや、アルゴリズム学習の最初のステップとして適しています。
1. バブルソート (Bubble Sort)
仕組み:
バブルソートは、隣り合う要素を比較し、順序が逆であれば交換するという操作を繰り返すことでデータをソートします。この操作を、配列の末尾から先頭に向かって(または先頭から末尾に向かって)繰り返し適用することで、徐々に大きい要素が「泡のように」配列の後方へ移動していく様子からこの名前が付けられました。配列全体に対してこの操作を、少なくとも配列のサイズ-1回繰り返せば、データは完全にソートされます。ただし、実際には一度も交換が行われなかったパスがあれば、その時点でソートは完了していると判断できます。
具体的には、以下のステップを繰り返します(昇順ソートの場合):
1. 配列の先頭から末尾まで順番に隣り合う要素を比較する。
2. 配列[i]
と 配列[i+1]
を比較し、配列[i] > 配列[i+1]
であれば、これら2つの要素を交換する。
3. この操作を配列の末尾まで行うと、最も大きい要素が配列の末尾に配置される(1回目のパス)。
4. 次に、末尾の一つ手前までの要素に対して、同様の比較・交換操作を行う(2回目のパス)。これで2番目に大きい要素が末尾から2番目に配置される。
5. この処理を、未ソート部分の要素数が1つになるまで繰り返す。
計算量:
* 時間計算量:
* 最悪の場合(逆順にソートされている場合):すべての要素ペアに対して比較・交換が必要となり、パスごとに比較対象が1つ減るため、必要な比較回数は (n-1) + (n-2) + ... + 1 = n(n-1)/2
となります。したがって、時間計算量は O(n^2)
です。
* 最良の場合(既にソートされている場合):最初のパスで一度も交換が行われないため、1回のパスだけでソート完了と判断できます。比較回数は n-1
回です。この場合の計算量は O(n)
となります。
* 平均的な場合:やはり O(n^2)
となります。
* 空間計算量: 要素の交換に一時的な変数を使用するだけで、追加のメモリはほとんど必要ありません。したがって、空間計算量は O(1)
であり、インプレースなソートアルゴリズムです。
特徴・メリット・デメリット:
* メリット: アルゴリズムが非常に単純で理解しやすく、実装も容易です。インプレースソートです。
* デメリット: 時間計算量が O(n^2)
と非常に効率が悪いため、要素数が多いデータのソートには実用的ではありません。特に最悪ケースでの性能が低いです。安定性はあります。
擬似コード (昇順ソート):
function bubbleSort(array):
n = length(array)
for i from 0 to n-2:
swapped = false
for j from 0 to n-2-i: // 未ソート部分の末尾まで
if array[j] > array[j+1]:
swap(array[j], array[j+1])
swapped = true
if not swapped: // 一度も交換が行われなかったらソート完了
break
2. 選択ソート (Selection Sort)
仕組み:
選択ソートは、未ソート部分から最小(または最大)の要素を見つけ出し、それを未ソート部分の先頭の要素と交換するという操作を繰り返すことでデータをソートします。バブルソートと似ていますが、バブルソートが隣接要素を交換するのに対し、選択ソートは未ソート部分全体から最小(または最大)要素を「選択」して、正しい位置に「配置」していく点が異なります。
具体的には、以下のステップを繰り返します(昇順ソートの場合):
1. 配列全体を未ソート部分とする。
2. 未ソート部分の最初の要素から最後の要素までの範囲で、最小値を持つ要素を探す。
3. 見つかった最小値要素と、未ソート部分の最初の要素を交換する。これにより、最小値要素が配列の最初の位置に配置される。
4. 次に、最初の要素を除いた残りの配列を未ソート部分として、同様の操作を行う。2番目に小さい要素が2番目の位置に配置される。
5. この処理を、未ソート部分の要素数が1つになるまで繰り返す。
計算量:
* 時間計算量:
* 最悪、最良、平均のいずれの場合でも、未ソート部分から最小値を探すために、未ソート部分のすべての要素を走査する必要があります。1回目のパスでは n-1
個の要素を比較、2回目のパスでは n-2
個の要素を比較、…、最後のパスでは1個の要素を比較します。したがって、必要な比較回数は (n-1) + (n-2) + ... + 1 = n(n-1)/2
となります。交換回数はパスごとに最大1回、合計 n-1
回です。時間計算量は O(n^2)
となります。
* 空間計算量: 要素の交換に一時的な変数を使用するだけで、追加のメモリはほとんど必要ありません。空間計算量は O(1)
であり、インプレースなソートアルゴリズムです。
特徴・メリット・デメリット:
* メリット: アルゴリズムが単純で理解しやすく、実装も容易です。交換回数が最大 n-1
回と、他の O(n^2)
アルゴリズム(特にバブルソート)に比べて少なく済むため、要素の交換コストが高い場合に有利になることがあります。インプレースソートです。
* デメリット: 時間計算量が O(n^2)
と効率が悪いため、要素数が多いデータのソートには不向きです。安定性はありません(同じ値を持つ要素の相対的な順序が保たれない場合があります)。
擬似コード (昇順ソート):
function selectionSort(array):
n = length(array)
for i from 0 to n-2: // ソート済みの部分の末尾を示すインデックス
minIndex = i
for j from i+1 to n-1: // 未ソート部分から最小値を探す
if array[j] < array[minIndex]:
minIndex = j
if minIndex != i: // 見つかった最小値が現在の先頭要素でなければ交換
swap(array[i], array[minIndex])
3. 挿入ソート (Insertion Sort)
仕組み:
挿入ソートは、配列を「ソート済み部分」と「未ソート部分」に分け、未ソート部分の最初の要素を一つ取り出して、ソート済み部分の適切な位置に「挿入」していくことでデータをソートします。トランプのカードを整理する際に、手札のカード(ソート済み部分)に新しいカード(未ソート部分の先頭要素)を適切な位置に差し込む操作に似ています。
具体的には、以下のステップを繰り返します(昇順ソートの場合):
1. 配列の最初の要素をソート済み部分とする。残りを未ソート部分とする。
2. 未ソート部分の最初の要素(key
とする)を取り出す。
3. ソート済み部分の要素を、key
より大きい場合は一つ後ろにずらす操作を、key
より小さい要素が見つかるか、ソート済み部分の先頭に達するまで繰り返す。
4. 要素をずらして空いた位置に key
を挿入する。
5. この操作を、未ソート部分がなくなるまで繰り返す。
計算量:
* 時間計算量:
* 最悪の場合(逆順にソートされている場合):未ソート部分の各要素を挿入する際に、ソート済み部分のすべての要素を比較・移動させる必要があります。1番目の要素を挿入するのに0回の比較、2番目を挿入するのに最大1回の比較、k番目を挿入するのに最大k-1回の比較が必要です。合計の比較回数は 0 + 1 + ... + (n-1) = n(n-1)/2
となります。時間計算量は O(n^2)
です。
* 最良の場合(既にソートされている場合):未ソート部分の各要素は、ソート済み部分の最後の要素と比較するだけで適切な位置(現在の位置)に挿入できます。比較回数は n-1
回です。この場合の計算量は O(n)
となります。
* 平均的な場合:やはり O(n^2)
となります。
* 空間計算量: 挿入する要素を一時的に保持するための変数が必要ですが、追加のメモリはほとんど必要ありません。空間計算量は O(1)
であり、インプレースなソートアルゴリズムです。
特徴・メリット・デメリット:
* メリット: アルゴリズムが単純で実装が容易です。既に大部分がソートされているデータ(ほぼ整列状態のデータ)に対しては非常に効率が良い(O(n)
に近い性能を発揮します)。要素数が少ないデータのソートにも適しています。安定性があります。インプレースソートです。
* デメリット: 時間計算量が O(n^2)
と効率が悪いため、要素数が多い完全にランダムなデータや逆順のデータのソートには不向きです。
擬似コード (昇順ソート):
“`
function insertionSort(array):
n = length(array)
for i from 1 to n-1: // 未ソート部分の最初の要素から順に取り出す
key = array[i]
j = i – 1 // ソート済み部分の最後のインデックス
// key をソート済み部分の適切な位置に挿入するために要素をずらす
while j >= 0 and array[j] > key:
array[j+1] = array[j]
j = j - 1
array[j+1] = key // key を挿入
“`
効率的なソートアルゴリズム:高速な処理を実現する
前述の基本的なソートアルゴリズムはシンプルで理解しやすいですが、O(n^2)
の計算量を持つため、大量のデータを扱うには効率が不十分です。ここでは、より高速な計算量 O(n log n)
を持つ代表的なソートアルゴリズムを3つ紹介します。これらは分割統治法や特定のデータ構造を利用することで、効率的なソートを実現しています。
4. マージソート (Merge Sort)
仕組み:
マージソートは「分割統治法(Divide and Conquer)」に基づいたアルゴリズムです。大きな問題を小さな問題に分割し、小さな問題を解決してから、それらを結合して元の問題を解決するというアプローチを取ります。
マージソートのステップは以下の通りです(昇順ソートの場合):
1. 分割 (Divide): ソート対象の配列をほぼ同じサイズの2つの部分配列に分割します。
2. 統治 (Conquer): 分割されたそれぞれの部分配列に対して、再帰的にマージソートを適用します。分割は部分配列の要素数が1つになるまで繰り返されます。要素数が1つの配列は、それ自体で既にソートされているとみなせます。
3. 結合 (Combine / Merge): 再帰呼び出しから戻る際に、ソートされた2つの部分配列を結合(マージ)して、1つのソートされた配列を作成します。この「マージ」操作がマージソートの核となります。マージは、2つの既にソートされた配列の先頭から要素を順に比較し、小さい方を新しい配列に追加していくことで効率的に行えます。
マージ操作の詳細:
2つのソート済み配列 A と B があるとします。新しい空の配列 C を用意します。A と B のそれぞれで、次に処理すべき要素を指すポインタを用意します(最初は両方とも先頭を指す)。
1. ポインタが指す A の要素と B の要素を比較します。
2. 小さい方の要素を C の末尾に追加し、その要素があった配列のポインタを一つ進めます。
3. どちらかの配列の要素が全て C に追加されるまで 1-2 を繰り返します。
4. 残りの配列に残っているすべての要素を、そのまま C の末尾に追加します(それらは既にソートされているからです)。
計算量:
* 時間計算量:
* マージソートでは、データを半分に分割していくため、分割の深さは log n
となります。各分割レベルにおいて、マージ操作はソート対象の全要素数に対して行われます。2つのソート済み部分配列をマージする操作は、要素数 k
の配列に対して O(k)
の時間で完了します。したがって、各再帰レベルでの全体の処理時間は O(n)
となります。分割の深さが log n
であるため、合計の時間計算量は O(n log n)
となります。これは最悪、最良、平均のいずれの場合でも変わりません。
* 空間計算量: マージ操作を行う際に、一時的にソート済み部分配列を格納したり、新しいソート済み配列を構築したりするための追加のメモリが必要です。通常、元の配列と同じサイズの追加メモリ O(n)
が必要となります。そのため、マージソートはインプレースなソートアルゴリズムではありません。
特徴・メリット・デメリット:
* メリット: 時間計算量が O(n log n)
であり、要素数が多い場合でも高速に動作します。最悪ケースでも O(n log n)
を保証するため、性能が安定しています。安定性があります。
* デメリット: データをマージするために追加のメモリ O(n)
が必要となります。これはメモリ容量が限られているシステムでは問題となる場合があります。アルゴリズムの実装が他の基本的なソートに比べて少し複雑になります。
擬似コード (昇順ソート):
“`
function mergeSort(array, left, right):
if left < right:
middle = floor((left + right) / 2)
// 配列を二つに分割し、それぞれ再帰的にソート
mergeSort(array, left, middle)
mergeSort(array, middle + 1, right)
// ソートされた二つの部分配列をマージ
merge(array, left, middle, right)
function merge(array, left, middle, right):
// 一時的な配列にコピー
leftArraySize = middle – left + 1
rightArraySize = right – middle
leftArray = new Array(leftArraySize)
rightArray = new Array(rightArraySize)
for i from 0 to leftArraySize – 1:
leftArray[i] = array[left + i]
for j from 0 to rightArraySize – 1:
rightArray[j] = array[middle + 1 + j]
// 二つの部分配列をマージ
i = 0 // leftArray のインデックス
j = 0 // rightArray のインデックス
k = left // array に戻す位置のインデックス
while i < leftArraySize and j < rightArraySize:
if leftArray[i] <= rightArray[j]:
array[k] = leftArray[i]
i = i + 1
else:
array[k] = rightArray[j]
j = j + 1
k = k + 1
// 残った要素をコピー
while i < leftArraySize:
array[k] = leftArray[i]
i = i + 1
k = k + 1
while j < rightArraySize:
array[k] = rightArray[j]
j = j + 1
k = k + 1
“`
5. クイックソート (Quick Sort)
仕組み:
クイックソートもマージソートと同様に「分割統治法」に基づいたアルゴリズムですが、分割と結合のアプローチが異なります。クイックソートでは、「ピボット(Pivot)」と呼ばれる基準要素を一つ選び、そのピボットを基準に配列を2つの部分に分割します。一方の部分にはピボットより小さい(または等しい)要素を、もう一方の部分にはピボットより大きい(または等しい)要素を集めます。この分割操作(パーティション操作)により、ピボット要素は最終的にソートされた位置に配置されます。その後、分割された2つの部分配列に対して再帰的にクイックソートを適用します。
クイックソートのステップは以下の通りです(昇順ソートの場合):
1. 分割 (Partition): 配列の中からピボットとなる要素を一つ選びます(選び方にはいくつかの方法があります)。
2. 配列を再配置し、ピボットより小さい要素をピボットの前に、大きい要素をピボットの後に移動させます。この操作により、ピボット要素は正しいソート済みの位置に置かれます。この分割操作が終わると、ピボットの左側の部分はすべてピボット以下の値になり、右側の部分はすべてピボット以上の値になります。
3. 統治 (Conquer): ピボットの左側の部分配列と右側の部分配列に対して、再帰的にクイックソートを適用します。
4. 結合 (Combine): クイックソートは分割操作によってピボットが正しい位置に配置されるため、再帰呼び出しから戻ってきた際には、特に結合操作を行う必要はありません。部分配列がソートされれば、全体としてもソートが完了します。
ピボットの選び方:
ピボットの選び方はクイックソートの性能に大きく影響します。
* 最初の要素または最後の要素: 最も単純ですが、既にソートされているか逆順にソートされている配列の場合、ピボットが常に最小値または最大値となり、偏った分割が発生し、性能が著しく低下する原因となります(最悪ケース)。
* 中央の要素: 少し改善されますが、特定のデータパターンには弱い場合があります。
* ランダムな要素: 最悪ケースの発生確率を低く抑えられます。
* 3つの中央値 (Median-of-three): 配列の最初、中央、最後の3つの要素から中央値を選びピボットとする方法。偏りを減らす効果が期待できます。
計算量:
* 時間計算量:
* 最良/平均の場合:ピボットが常にほぼ中央の値を分割する場合、分割の深さは log n
となり、各分割レベルでのパーティション操作は O(n)
の時間で完了します。合計の時間計算量は O(n log n)
となります。
* 最悪の場合:ピボットの選択が偏り、常に最小値または最大値が選ばれるような場合(例: 既にソート済みの配列を最初の要素をピボットとしてソートする場合)、分割が非常に偏り、再帰の深さが n
になります。この場合、時間計算量は O(n^2)
となります。
* 空間計算量: 再帰呼び出しのスタック領域を使用します。最良/平均の場合、再帰の深さは log n
なので空間計算量は O(log n)
です。最悪の場合、再帰の深さが n
になるため、空間計算量は O(n)
となります。ただし、適切なピボット選択戦略や、再帰呼び出しの工夫(末尾再帰最適化など)により、空間計算量を改善できる場合があります。パーティション操作自体はインプレースで行うことができます。
特徴・メリット・デメリット:
* メリット: 平均的な時間計算量が O(n log n)
であり、実際に多くのデータセットで高速に動作します。定数係数が小さく、同じ O(n log n)
のマージソートやヒープソートよりも一般的に高速です。追加のメモリ使用量が比較的少ない(平均 O(log n)
、インプレースでのパーティションが可能)です。
* デメリット: 最悪ケースでの時間計算量が O(n^2)
となり、性能が保証されません。安定性がありません。再帰の実装が他の基本的なソートより複雑です。
擬似コード (昇順ソート):
“`
function quickSort(array, low, high):
if low < high:
// パーティション操作を行い、ピボットのインデックスを取得
pivotIndex = partition(array, low, high)
// ピボットの前後に対して再帰的にクイックソートを適用
quickSort(array, low, pivotIndex - 1)
quickSort(array, pivotIndex + 1, high)
function partition(array, low, high):
// ピボットを選択 (ここでは最後の要素を選択)
pivot = array[high]
// i はピボットより小さい要素の境界を示すインデックス
i = low – 1
// low から high-1 までの要素を走査
for j from low to high – 1:
// 現在の要素がピボット以下であれば
if array[j] <= pivot:
i = i + 1 // i を進めて
swap(array[i], array[j]) // array[i] と array[j] を交換
// ピボットを正しい位置 (i+1) に配置
swap(array[i + 1], array[high])
// ピボットの最終的なインデックスを返す
return i + 1
“`
注:上記擬似コードのパーティション関数はホーアのパーティションスキーム(Lomuto Partition Scheme)の一種に基づいています。パーティションの実装方法は他にもあります。
6. ヒープソート (Heap Sort)
仕組み:
ヒープソートは、データの「ヒープ(Heap)」という特殊な二分木構造を利用してソートを行います。ヒープソートでは、まずソート対象の配列を「最大ヒープ(Max Heap)」または「最小ヒープ(Min Heap)」と呼ばれる構造に変換します(昇順ソートの場合は最大ヒープを利用します)。最大ヒープでは、親ノードの値がその子ノードの値よりも常に大きいという性質を持ちます。この性質により、ルートノード(根)には常にヒープ内の最大値が格納されます。
ヒープソートのステップは以下の通りです(昇順ソートの場合):
1. ヒープ構築 (Build Heap): ソート対象の配列を最大ヒープ構造に変換します。これは、配列を木のノードとみなし、親子の関係(インデックス計算で特定できる)に基づいてヒープの性質を満たすように要素を配置し直す操作です。配列の末尾から遡って、親ノードについてヒープの性質を壊していないか確認し、必要に応じて子ノードと交換してヒープの性質を満たすように調整する「ヒープ化(heapify)」操作を繰り返します。
2. ソート (Sort): 最大ヒープが構築されると、ルートノード(配列の最初の要素)には常に最大値が格納されています。この最大値と配列の最後の要素を交換します。これにより、最大値が配列の正しいソート済みの位置(末尾)に配置されます。次に、交換によってルートノードに移動してきた要素に対して、残りのヒープサイズが小さくなった部分でヒープ化操作を行い、再びルートノードにその時点での最大値が来るように調整します。この「ルート要素と末尾要素の交換 → ヒープサイズの縮小 → ヒープ化」という操作を、ヒープサイズが1になるまで繰り返します。これにより、要素が最大値から順に配列の末尾から配置されていき、最終的に配列全体がソートされます。
計算量:
* 時間計算量:
* ヒープ構築フェーズ:配列のサイズ n
に対して、O(n)
の時間で完了します。
* ソートフェーズ:配列のサイズが n, n-1, ..., 2
と変化しながら、それぞれのサイズのヒープに対してヒープ化操作を行います。ヒープ化操作は、ヒープの高さ(log n
)に比例する時間で完了します。ソートフェーズではこれを n-1
回繰り返すため、時間計算量は O(n log n)
となります。
* 合計の時間計算量は、ヒープ構築とソートフェーズを合わせて O(n + n log n) = O(n log n)
となります。これは最悪、最良、平均のいずれの場合でも保証されます。
* 空間計算量: ヒープ化操作や要素の交換は、元の配列のメモリ空間内で完結します。追加のメモリは一時的な変数に少量必要なだけです。空間計算量は O(1)
であり、インプレースなソートアルゴリズムです。
特徴・メリット・デメリット:
* メリット: 時間計算量が最悪ケースでも O(n log n)
と性能が保証されており、インプレースソートです。大量のデータを効率的にソートできます。
* デメリット: アルゴリズムの仕組みが他のソートに比べてやや複雑で、実装が難しく感じる場合があります。同じ O(n log n)
のクイックソートに比べて、一般的に定数係数が大きく、実際の実行速度では劣ることが多いです。安定性がありません。
擬似コード (昇順ソート):
“`
function heapSort(array):
n = length(array)
// 1. ヒープ構築 (Build Max Heap)
// 最初の葉ノードの親 (n/2 – 1) からルート (0) まで遡ってヒープ化
for i from floor(n / 2) – 1 down to 0:
heapify(array, n, i)
// 2. ソート
// ヒープのルート要素 (最大値) を末尾と交換し、ヒープサイズを小さくして再ヒープ化
for i from n – 1 down to 1:
// ルート要素と現在の末尾要素を交換
swap(array[0], array[i])
// 残りのヒープ (array[0...i-1]) に対してヒープ化
heapify(array, i, 0) // heapify のサイズは i に、ルートは 0 に指定
// 指定されたルートインデックス i の部分木をヒープ化する関数
// heapSize は現在のヒープのサイズ (ソートが進むにつれて小さくなる)
function heapify(array, heapSize, i):
largest = i // 現在のルートを最大値の候補とする
left = 2 * i + 1 // 左の子ノードのインデックス
right = 2 * i + 2 // 右の子ノードのインデックス
// 左の子ノードが存在し、かつ親ノードより大きい場合、最大値の候補を更新
if left < heapSize and array[left] > array[largest]:
largest = left
// 右の子ノードが存在し、かつ現在の最大値候補より大きい場合、最大値の候補を更新
if right < heapSize and array[right] > array[largest]:
largest = right
// 最大値の候補が現在のルートと異なる場合 (つまり子が親より大きい場合)
if largest != i:
// 親ノードと最大値を持つ子ノードを交換
swap(array[i], array[largest])
// 交換した子ノードをルートとする部分木に対して再帰的にヒープ化
heapify(array, heapSize, largest)
“`
その他のソートアルゴリズム:特定の状況で高い効率を発揮
ここまで紹介したソートアルゴリズムは、要素間の比較に基づいて順序を決定する「比較ソート」に分類されます。比較ソートの時間計算量には、原理的に O(n log n)
という下限が存在することが知られています。しかし、ソート対象のデータに特定の性質がある場合には、比較を行わずにソートを行うことで、この下限を超える効率を達成できるアルゴリズムが存在します。これらは「非比較ソート」と呼ばれます。
7. シェルソート (Shell Sort)
仕組み:
シェルソートは、挿入ソートを改良したアルゴリズムです。挿入ソートは、既にソートされている(またはほぼソートされている)データに対して非常に効率的ですが、データが大きく乱れている場合は非効率になります。これは、挿入ソートが隣接する要素の比較・交換しか行わないため、遠くにある要素を正しい位置に移動させるのに多くのステップが必要になるためです。
シェルソートは、まず離れた位置にある要素を比較・交換して、データ全体を大まかにソートします。これを「要素間隔(gap)」と呼びます。最初は大きな間隔で挿入ソートのような操作を行い、要素を大まかに正しい位置に移動させます。徐々に間隔を小さくしていき、最後は間隔を1として通常の挿入ソートを行います。大きな間隔でのソートにより、要素は遠くの正しい位置へ素早く移動するため、最後の間隔1での挿入ソート時には、データは既にかなり整列された状態になっており、挿入ソートの効率が良いという特性を活かすことができます。
間隔の系列の選び方によって性能が大きく変わります。有名な間隔系列としては、Donald Shell が提案した n/2, n/4, ..., 1
や、Hibbard、Sedgewickなどが提案したより複雑な系列があります。
計算量:
* 時間計算量:シェルソートの時間計算量は、使用する間隔系列によって大きく変動し、正確な解析は難しいとされています。最悪ケースでの計算量は間隔系列に依存しますが、O(n^2)
よりは優れていることが多く、適切な間隔系列を選ぶと O(n^(3/2))
や O(n^(4/3))
、さらには O(n log^2 n)
に近い性能を発揮するとも言われています。平均的な計算量も間隔系列に依存します。
* 空間計算量:要素の交換に一時的な変数を使用するだけで、追加のメモリはほとんど必要ありません。空間計算量は O(1)
であり、インプレースなソートアルゴリズムです。
特徴・メリット・デメリット:
* メリット: 実装が比較的容易で、O(n^2)
のソートよりは高速に動作します。特に要素数が中程度のデータセットに対して有効です。インプレースソートです。
* デメリット: 厳密な時間計算量が確立されておらず、性能は間隔系列に依存します。安定性はありません。
8. 計数ソート (Counting Sort)
仕組み:
計数ソートは、非比較ソートの一種です。ソート対象の要素が、比較的狭い範囲の整数である場合に非常に効率的です。
計数ソートのステップは以下の通りです:
1. ソート対象の配列の要素の最大値と最小値を特定します。これにより、値の範囲(k)がわかります。
2. 要素の取りうる値の範囲 0
から k
までの各値が、元の配列に何回出現するかを数えるための「計数配列」を作成します。
3. 元の配列を走査し、各要素の値に対応する計数配列のインデックスの値をインクリメントします。これにより、計数配列には各要素の出現回数が格納されます。
4. 計数配列を累積和配列に変換します。つまり、計数配列の各要素に、それ以前のすべての要素の合計値を加算します。累積和配列の各要素 cumulativeCount[v]
は、「元の配列において、値 v
以下の要素が全部で何個あるか」を示します。これは、ソート後の配列において、値 v
を持つ最後の要素が配置されるべきインデックスを示唆します。
5. 元の配列を後ろから走査し、各要素 value
について、累積和配列 cumulativeCount[value]
が示す位置にその要素を配置します。配置したら、cumulativeCount[value]
の値をデクリメントします。これにより、同じ値を持つ要素がソート前の相対的な順序を保ったまま配置されます。
計算量:
* 時間計算量:計数配列の初期化に O(k)
、元の配列を走査して計数配列を構築するのに O(n)
、計数配列を累積和に変換するのに O(k)
、ソート済み配列を構築するのに O(n)
の時間が必要です。合計の時間計算量は O(n + k)
となります。ここで n
は要素数、k
は要素の値の範囲です。k
が n
に比べて極端に大きくない場合に非常に効率的です。
* 空間計算量:計数配列と、結果を格納するための出力配列が必要です。空間計算量は O(n + k)
となります。
特徴・メリット・デメリット:
* メリット: k
が小さい場合、比較ソートの O(n log n)
よりも高速な O(n + k)
でソートできます。安定性があります。
* デメリット: ソート対象が整数であり、その値の範囲 k
が大きすぎないという強い制約があります。浮動小数点数や文字列には直接適用できません。値の範囲 k
が非常に大きい場合、空間計算量 O(k)
が大きくなりすぎる問題があります。インプレースではありません(出力配列が必要です)。
9. 基数ソート (Radix Sort)
仕組み:
基数ソートも非比較ソートの一種で、計数ソートと同様に特定の条件下で非常に効率的です。基数ソートは、整数を桁ごとに分解し、下位の桁から(または上位の桁から)、安定なソートアルゴリズム(例えば計数ソート)を使ってソートを繰り返すことで、最終的に全体をソートします。
基数ソートのステップは以下の通りです(下位桁から行う場合):
1. ソート対象の配列の中で、最も桁数が多い要素の桁数(d
)を特定します。
2. 最も下位の桁から始め、最も上位の桁まで、各桁に対して以下の操作を繰り返します。
* 現在の桁の値に基づいて、配列を安定ソートします。この安定ソートには通常、計数ソートが使われます。例えば10進数の場合、0から9までの10個の「バケット」(入れ物)を用意し、各要素を現在の桁の値に応じたバケットに入れます。その後、バケットを順に取り出して配列に戻します。これにより、現在の桁の値でソートされた状態になります。このソートは安定である必要があります。
3. 最も上位の桁までこの操作を繰り返すと、配列全体がソートされます。
計算量:
* 時間計算量:ソート対象のデータが d
桁の数値であり、各桁が取りうる値の範囲が k
であるとします(10進数なら k=10
)。各桁に対する安定ソート(計数ソートを使用する場合)の時間計算量は O(n + k)
です。これを d
回繰り返すため、合計の時間計算量は O(d * (n + k))
となります。数値の大きさが最大 M
である場合、桁数 d
は log_k(M)
に比例します。したがって、時間計算量は O(n * log_k(M) + k * log_k(M))
とも表現できます。k
が小さく(例: 10進数)、M
が n
に対して極端に大きくない場合に、O(n log n)
より高速になる可能性があります。
* 空間計算量:各桁のソートに使用する安定ソート(計数ソート)に必要な空間と、バケットなどの一時的な空間が必要です。通常、O(n + k)
の空間が必要となります。
特徴・メリット・デメリット:
* メリット: 特定の条件(整数で、桁数や基数が極端に大きくない)を満たすデータに対しては、比較ソートよりも高速な O(d * (n + k))
でソートできます。安定性があります(安定な内部ソートを利用した場合)。
* デメリット: ソート対象が整数またはそれに類するデータである必要があります。桁数が多い、または各桁の取りうる値の範囲 k
が非常に大きい場合には効率が悪くなります。インプレースではありません。
10. バケットソート (Bucket Sort)
仕組み:
バケットソートは非比較ソートの一種で、ソート対象のデータが特定の範囲に均一に分布している場合に効率的です。
バケットソートのステップは以下の通りです:
1. ソート対象のデータの値の範囲をいくつかの「バケット(Bucket)」に分割します。
2. 元の配列の各要素を、その値が属するバケットに分配します。
3. 各バケット内の要素を、それぞれ独立してソートします。各バケット内の要素数は平均的には少なくなるため、ここで挿入ソートのようなシンプルなアルゴリズムを用いても効率が良いことが多いです。
4. すべてのバケットの要素を、バケットの順序に従って結合し、最終的にソートされた配列を得ます。
計算量:
* 時間計算量:データの分布に大きく依存します。要素がバケット間に均一に分布している場合、各バケットの要素数は平均して n/m
となります(m
はバケット数)。各バケットのソートに O((n/m) log (n/m))
(比較ソート使用時)または O((n/m)^2)
(挿入ソート使用時)かかります。合計の時間計算量は、バケットへの分配が O(n)
、各バケットのソート合計が m * O((n/m) log (n/m))
または m * O((n/m)^2)
、バケットの結合が O(n)
となります。要素が均一に分布し、各バケットのソートに挿入ソートを使う場合、平均的な時間計算量は O(n)
となります。しかし、データが偏っている場合(例えばほとんどの要素が同じバケットに集中している場合)、最悪ケースの時間計算量は O(n^2)
になる可能性があります。
* 空間計算量:バケットを格納するための空間が必要です。要素数 n
とバケット数 m
に対して、空間計算量は O(n + m)
となります。
特徴・メリット・デメリット:
* メリット: データが均一に分布している場合に、平均的に O(n)
という非常に高速なソートが可能です。
* デメリット: データの分布に関する強い仮定が必要です。分布が偏っている場合は効率が悪くなります。インプレースではありません。
ソートアルゴリズムの比較
ここまで見てきた主要なソートアルゴリズムについて、その特徴をまとめて比較してみましょう。
アルゴリズム名 | 平均時間計算量 | 最悪時間計算量 | 空間計算量 | 安定性 | インプレース性 | 特徴 |
---|---|---|---|---|---|---|
バブルソート | O(n^2) |
O(n^2) |
O(1) |
はい | はい | シンプル。教育用。小規模データ or ほぼソート済みデータ。交換コスト低。 |
選択ソート | O(n^2) |
O(n^2) |
O(1) |
いいえ | はい | シンプル。交換回数が少ない。小規模データ。 |
挿入ソート | O(n^2) |
O(n^2) |
O(1) |
はい | はい | シンプル。小規模データ or ほぼソート済みデータに高速 (O(n) )。 |
マージソート | 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 log^2 n) など) |
依存 (O(n^2) よりは速い傾向) |
O(1) |
いいえ | はい | 挿入ソート改良版。中規模データに有効。インプレース。 |
計数ソート | O(n + k) |
O(n + k) |
O(n + k) |
はい | いいえ | 非比較ソート。値の範囲が狭い整数に高速 (k は値の範囲)。 |
基数ソート | O(d * (n + k)) |
O(d * (n + k)) |
O(n + k) |
はい | いいえ | 非比較ソート。整数や文字列に有効。桁数・基数に依存。安定ソートが必要。 |
バケットソート | O(n) (平均) |
O(n^2) (最悪) |
O(n + m) |
はい | いいえ | 非比較ソート。データが均一分布している場合に最速 (m はバケット数)。 |
注:空間計算量の O(log n)
は、一般的にスタックフレームによる再帰深度を示唆します。インプレース性は、追加のデータ構造に O(n)
や O(k)
のメモリが必要かどうかを基準としています。
どのソートアルゴリズムを選ぶか
様々なソートアルゴリズムがある中で、どのアルゴリズムを選ぶべきかは、ソートしたいデータの性質や利用シーンによって異なります。
- 要素数が非常に少ない場合: バブルソート、選択ソート、挿入ソートのような
O(n^2)
のアルゴリズムでも十分高速であり、実装の容易さからこれらの選択肢が考えられます。特に挿入ソートは、データがほぼソートされている場合に高速です。 - 要素数が多く、高速なソートが必要な場合:
- 一般的なデータ: クイックソートが平均的には最速であり、多くの標準ライブラリで採用されています。ただし、最悪ケース
O(n^2)
の可能性を許容できるか、または対策が取られているか(例:ランダムピボット選択、イントロソートなど)を確認する必要があります。 - 最悪ケースでも性能保証が必要な場合: マージソートやヒープソートが適しています。両方とも
O(n log n)
を保証します。 - 安定性が必要な場合: マージソートが適しています。バブルソートや挿入ソートも安定ですが、大規模データには向きません。計数ソートや基数ソートも安定です。
- 使用できるメモリ容量が限られている場合: ヒープソートや、空間計算量が平均
O(log n)
のクイックソートがインプレースまたはそれに近いソートとして適しています。
- 一般的なデータ: クイックソートが平均的には最速であり、多くの標準ライブラリで採用されています。ただし、最悪ケース
- ソート対象が特定の性質を持つ場合(非比較ソート):
- 値の範囲が狭い整数: 計数ソートが非常に高速です。
- 整数や文字列で、桁数や基数に基づいてソートできる場合: 基数ソートが高速になる可能性があります。
- データが特定の範囲に均一に分布している場合: バケットソートが平均的に非常に高速です。
多くのプログラミング言語に組み込まれている標準ライブラリのソート関数は、単一のアルゴリズムではなく、通常は複数のアルゴリズムを組み合わせたハイブリッドソート(例:JavaのArrays.sort
はプリミティブ型にクイックソート系の Dual-Pivot Quicksort、オブジェクト型にマージソート、Pythonのlist.sort
とsorted()
はTimsortというマージソートと挿入ソートを組み合わせたもの)を採用しています。これは、様々な状況で高い性能を発揮できるように最適化されているためです。特別な理由がない限りは、標準ライブラリのソート関数を利用するのが最も効率的で安全な方法と言えるでしょう。しかし、アルゴリズムの仕組みを理解することは、これらのライブラリがどのように機能しているかを理解する上で非常に重要です。
まとめ
この記事では、ソートアルゴリズムの基本的な考え方から、バブルソート、選択ソート、挿入ソートといったシンプルなアルゴリズム、そしてマージソート、クイックソート、ヒープソートといった効率的な O(n log n)
アルゴリズム、さらには計数ソートや基数ソートのような非比較ソートまで、様々な種類のソートアルゴリズムについて詳細に解説しました。
それぞれのアルゴリズムは異なる仕組みを持ち、計算量、安定性、インプレース性といった異なる特性を持っています。これらの特性を理解することで、扱うデータの種類、サイズ、求められる性能に応じて、最適なソートアルゴリズムを選択できるようになります。
ソートアルゴリズムの学習は、計算量の概念やアルゴリズム設計の基本的な考え方を学ぶ上で非常に良い題材となります。今回紹介したアルゴリズムの仕組みや計算量を理解することは、今後のより高度なアルゴリズムやデータ構造を学ぶ上での強固な基礎となるでしょう。
実際にコードを書いてこれらのアルゴリズムを実装してみることも、理解を深めるための良い方法です。様々な入力データで試して、計算量の違いを体感してみるのも面白いでしょう。
これで「ソートアルゴリズム入門:基本から種類まで徹底解説」の記事を終わります。この情報が、ソートアルゴリズムの世界への理解を深める一助となれば幸いです。