クイックソートは、効率的な汎用ソートアルゴリズムです。クイックソートは、1959年にイギリスのコンピュータ科学者トニー・ホーアによって開発され[ 1 ] [ 2 ]、1961年に発表されました[ 3 ]。現在でもソートによく使われるアルゴリズムです。全体的に、特に大規模な分布の場合、ランダム化されたデータに対してマージソートやヒープソートよりもわずかに高速です[ 4 ] 。
クイックソートは分割統治アルゴリズムです。配列から「ピボット」要素を選択し、他の要素をピボットより小さいか大きいかに応じて2つのサブ配列に分割することで機能します。このため、分割交換ソートと呼ばれることもあります。[ 5 ]次に、サブ配列が再帰的にソートされます。これはインプレースで実行でき、ソートを実行するために少量の追加メモリが必要です。
クイックソートは比較ソートであり、つまり「より小さい」関係(正式には全順序)が定義されているあらゆる型の項目をソートできます。要素aとbは、以前の比較結果の推移閉包で相対的な順序が得られた場合にのみ交換されるため、比較に基づくソートです。クイックソートのほとんどの実装は安定ソートではなく、等しいソート項目の相対的な順序は保持されません。
クイックソートの数学的分析によると、平均して、このアルゴリズムは を実行します。n個のアイテムをソートするための比較。最悪の場合、比較。
クイックソートアルゴリズムは、1959 年にトニー・ホーアがモスクワ国立大学に客員学生として滞在中に開発されました。当時、ホーアは国立物理研究所の機械翻訳プロジェクトに取り組んでいました。翻訳プロセスの一環として、ロシア語の文中の単語を、磁気テープにアルファベット順に記録されたロシア語-英語辞書で調べる前にソートする必要がありました。[ 6 ]最初のアイデアである挿入ソートが遅いことに気づいた後、彼は新しいアイデアを思いつきました。彼はパーティション部分をマーキュリーオートコードで記述しましたが、ソートされていないセグメントのリストの処理に苦労しました。イギリスに戻ると、シェルソートのコードを書くように依頼されました。ホーアは上司に、より高速なアルゴリズムを知っていると伝え、上司は彼が知らないことに6 ペンスを賭けました。上司は最終的に賭けに負けたことを認めました。ホーアは、理論分析を含む自身のアルゴリズムに関する論文を、 1962 年、コンピュータジャーナル第 5 巻、第 1 号、10 ~ 16 ページに発表しました。その後、ホアはALGOLとその再帰機能について学び、当時最高のコンピュータ科学誌であったCommunications of the Association for Computing MachineryにALGOLでアルゴリズムの改良版を発表することができました。 [ 3 ] [ 7 ] ALGOLコードはCommunications of the ACM (CACM)、第4巻、第7号、1961年7月、321ページに掲載されています。アルゴリズム63:分割、アルゴリズム64:クイックソート。
クイックソートは広く普及し、例えばUnixではデフォルトのライブラリソートサブルーチンとして登場しました。そのため、 C標準ライブラリサブルーチンqsort [ 8 ]やJavaのリファレンス実装にもその名前が付けられました。
1975 年のRobert Sedgewickの博士論文は、クイックソートの研究における画期的な論文とみなされており、Samplesort、Van Emden による適応型パーティショニング[ 9 ]などのさまざまなピボット選択スキームの分析に関連する多くの未解決問題を解決し、比較と交換の期待値の導出も行いました。[ 8 ] 1993 年にJon BentleyとDoug McIlroy は、プログラミングライブラリで使用するためにさまざまな改良を加えました。これには、等しい要素を処理する手法や、9 つの要素のサンプルを 3 つのグループに分割し、3 つのグループの 3 つの中央値の中央値を選択する擬似中央値 9と呼ばれるピボット スキームが含まれます。 [ 8 ] Bentley は、 Nico Lomutoによるものとされる、より単純でコンパクトな別のパーティショニング スキームを著書Programming Pearlsで説明しました。後に Bentley は、Hoare のバージョンを何年も使用していたが、実際には理解していなかったが、Lomuto のバージョンは正しいことを証明できるほど単純だったと書いています。[ 10 ]ベントレーは、同じエッセイの中でクイックソートを「私がこれまでに書いた中で最も美しいコード」と評した。ロムートの分割方式も教科書『アルゴリズム入門』で普及したが、平均して3倍のスワップを行い、すべての要素が等しい場合は実行時間がO ( n 2 )に低下するため、ホアの方式より劣る。[ 11 ]マキルロイは1998年にさらにアンチクイックソート( aqsort )関数を作成し、その場で敵対的データを生成することで、1993年のクイックソートの変種でさえも常に2次的な動作に追い込んだ。[ 12 ]

クイックソートは、分割ルーチンに基づいて配列をソートする分割統治アルゴリズムの一種です。この分割の詳細は多少異なる場合があるため、クイックソートは実際には密接に関連したアルゴリズムのファミリーと言えます。少なくとも2つの要素からなる範囲に適用すると、分割によって2つの連続する空でない部分範囲に分割されます。このとき、最初の部分範囲のどの要素も、2番目の部分範囲のどの要素よりも大きくはなりません。この分割を適用した後、クイックソートは部分範囲を再帰的にソートします。分割時点で既に最終的な位置にあることがわかっている要素は、部分範囲から除外される場合があります。再帰的な性質のため、クイックソート(分割ルーチンと同様)は、最終的な目標が配列全体をソートすることであっても、より大きな配列内の範囲に対して呼び出し可能なように定式化する必要があります。インプレースクイックソートの手順は次のとおりです。
これらは、正しく動作するための基本的な要件です。上記で指定されていない詳細、特に分割アルゴリズム、中でもピボットの選択方法は、入力配列によってはアルゴリズムのパフォーマンスに大きな影響を与える可能性があります。したがって、クイックソートの効率について議論するには、まずこれらの選択肢を明確にする必要があります。ここでは、2つの具体的な分割方法について説明します。
この方式は Nico Lomuto によるもので、Bentley が著書Programming Pearls [ 13 ]で、また Cormen らが著書Introduction to Algorithms [ 14 ]で普及させた。ほとんどの定式化では、この方式は配列の最後の要素をピボットとして選択する。アルゴリズムは、別のインデックスjを使用して配列をスキャンしながらインデックスiを維持し、 loからi-1 (両端を含む)までの要素がピボットより小さく、iからj (両端を含む) までの要素がピボット以上となるようにする。この方式はより簡潔で理解しやすいため、入門教材でよく使用されるが、すべての要素が等しい場合など、Hoare の元の方式よりも効率が悪い。[ 15 ]この方式による Quicksort の複雑さは、配列が既に順序付けられている場合、分割が最悪の分割となるためO ( n 2 )に低下する。 [ 11 ]パフォーマンスを向上させるために、ピボットの選択方法、等しい要素の処理方法、小さな配列に対する挿入ソートなどの他のソートアルゴリズムの使用など、さまざまなバリアントが提案されています。擬似コードでは、配列Aのloからhi (両端を含む) の要素をソートするクイックソートは次のように表現できます。[ 14 ]
// 配列の一部をソートし、それをパーティションに分割してから、それらのパーティションをソートするアルゴリズムquicksort(A, lo, hi)は// インデックスが正しい順序になっていることを確認するif lo >= hi || lo < 0 then 戻る // 配列を分割し、ピボットインデックスを取得する p := partition(A, lo, hi) // 2つのパーティションをソートする quicksort(A, lo, p - 1) // ピボットの左側 quicksort(A, p + 1, hi) // ピボットの右側// 配列を2つのパーティションに分割するアルゴリズムpartition(A, lo, hi) is pivot := A[hi] // 最後の要素をピボットとして選択する// 一時的なピボットインデックス i := lo for j := lo to hi - 1 do // 現在の要素がピボット以下の場合if A[j] <= pivot then // 現在の要素を一時的なピボットインデックスの要素と交換する swap A[i] with A[j] // 一時的なピボットインデックスを前方に移動する i := i + 1 // ピボットを最後の要素と交換する swap A[i] with A[hi] return i // ピボットのインデックス
配列全体をソートするには、quicksort(A, 0, length(A) - 1)を使用します。

iとj)を示し、黒い枠線はソートされた要素の位置を示し、塗りつぶされた黒い四角は比較対象の値(pivot)を示します。Tony Hoareが記述した元の分割スキームでは、分割対象の配列の両端から開始し、互いに近づいていく 2 つのポインタ (範囲へのインデックス) を使用し、反転を検出するまで続けます。反転とは、最初のポインタでピボットより大きい要素と、2 番目のポインタでピボットより小さい要素のペアです。この時点で最初のポインタがまだ 2 番目のポインタより前にある場合、これらの要素は互いに間違った順序になっているため、交換されます。[ 16 ]その後、ポインタは内側に移動し、反転の検索が繰り返されます。最終的にポインタが交差 (最初のポインタが 2 番目のポインタより後を指す) すると、交換は行われません。有効な分割が見つかり、分割点は交差したポインタの間にあります (交差したポインタの間に厳密に存在するエントリはピボットと等しく、形成された両方のサブ範囲から除外できます)。この定式化では、1 つのサブ範囲が元の範囲全体になる可能性があり、その場合、アルゴリズムが進まなくなります。したがって、ホアは、最終的に、ピボット要素(まだ元の位置にある)を含む部分範囲のサイズを、必要に応じて分離点に最も近い部分範囲要素と交換した後、そのピボットを除外することによって縮小できると規定している。これにより、クイックソートの終了が保証される。
この元の記述に関して、実装ではしばしば些細ながらも重要な変更が加えられます。特に、以下に示す方式では、反転候補の中にピボットと等しい要素が含まれています(そのため、「より大きい」と「より小さい」の代わりに「以上」と「以下」のテストがそれぞれ使用されます。これは、この定式化が、実際には厳密な比較演算子の使用によって反映されているように、繰り返し...までではなく、 do ... whileを使用しているためです)。ピボットと等しい要素を交換する理由はありませんが、この変更により、ポインタ自体に対するテストを省略できます。そうでなければ、ポインタが範囲外にならないようにするために、これらのテストが必要になります。実際、ピボット値のインスタンスが少なくとも1つ範囲内に存在するため、包括的なテストを使用すると、どちらのポインタも最初の前進でこのインスタンスを越えることはできません。交換が実行されると、これらの交換された要素は両方とも、それらを見つけたポインタよりも厳密に先行するため、そのポインタが範囲外に走り出すのを防ぎます。 (後者は使用するテストに関係なく真であるため、最初の反転を探すときのみ包括的テストを使用することが可能です。ただし、全体を通して包括的テストを使用すると、範囲内のすべての要素が等しい場合に中央付近で分割が見つかることも保証され、等しい要素が多い配列をソートする際に重要な効率向上につながります。) 非進行分離が発生するリスクは、Hoare が説明した方法とは異なる方法で回避されます。このような分離は、反転が見つからず、最初の反復で両方のポインタがピボット要素に進む場合にのみ発生します(この場合、ポインタは交差したとみなされ、交換は行われません)。
擬似コードでは、[ 14 ]
// 配列の一部をソートし、それをパーティションに分割してから、それらのパーティションをソートするアルゴリズムquicksort(A, lo, hi)は、 lo >= 0 && hi >= 0 && lo < hiの場合、 p := partition(A, lo, hi) quicksort(A, lo, p) // 注: ピボットが組み込まれました クイックソート(A, p + 1, hi) // 配列を2つのパーティションに分割するアルゴリズムpartition(A, lo, hi) is // ピボット値 pivot := A[lo] // 最初の要素をピボットとして選択// 左インデックス i := lo - 1 // 右インデックス j := hi + 1 無限ループ// 左インデックスを少なくとも1回右に移動し、左インデックスの要素がピボットより小さい 間、i := i + 1 を実行し、A[i] < pivotの間ループする// 右インデックスを少なくとも1回左に移動し、右インデックスの要素がピボットより大きい 間、j := j - 1 を実行し、A[j] > pivotの間、ループを繰り返す。// インデックスが交差した場合、i >= jならばjを返す// 左インデックスと右インデックスの要素を入れ替える A[i]とA[j]を入れ替える
配列全体はクイックソート(A, 0, length(A) - 1)によってソートされます。
Hoare の方式は、平均して交換回数が 3 分の 1 であるため、Lomuto の分割方式よりも効率的です。また、前述のように、すべての値が等しい場合でも、提示された実装はバランスの取れたパーティションを作成します。[ 11 ]これは Lomuto の方式では実現されません。Lomuto の分割方式と同様に、Hoare の分割も、ピボットが最初または最後の要素として選択された場合、既にソートされた入力に対して Quicksort をO ( n 2 )に劣化させます。ただし、中央の要素をピボットとした場合、ソートされたデータは、等しいサイズのパーティションで (ほぼ) 交換なしで結果となり、Quicksort の最良の動作、つまりO ( n log( n ))につながります。他の方式と同様に、Hoare の分割は安定ソートを生成しません。この方式では、ピボットとピボットに等しい要素は、分割ステップの後、分割内のどこにでも配置される可能性があり、再帰によって単一要素の分割の基本ケースに到達するまでソートされない可能性があるため、ピボットの最終的な位置は必ずしも返されるインデックスにあるとは限りません。したがって、メインアルゴリズムが再帰する次の 2 つのセグメントは、 Lomuto の方式の (lo..p−1) と(p+1..hi)ではなく、 ( lo..p ) (要素 ≤ ピボット)と(p+1..hi) (要素 ≥ ピボット)となります。
メインアルゴリズムが再帰する次の 2 つのセグメントについて、もう少し詳しく説明しましょう。範囲外にならないように「do...while」ループで厳密な比較演算子 (>、<) を使用しているため、分割関数内でピボット自体が他の要素と入れ替わる可能性があります。したがって、分割関数で返されるインデックスは、実際のピボットの位置とは必ずしも一致しません。例として[5, 2, 3, 1, 0]を考えてみましょう。このスキームに従うと、最初の分割後、配列は[0, 2, 1, 3, 5]になります。返される「インデックス」は 2 ですが、これは数値 1 です。実際のピボット、つまり分割を開始するために選択した数値は 3 です。この例から、後続の再帰に分割関数の返されたインデックスを含める必要があることがわかります。その結果、(lo..p)と(p+1..hi)を再帰的に行うか、(lo..p−1)と(p..hi)を再帰的に行うかの選択肢が提示されます。どちらのオプションを選択するかは、インデックスが交差するときに分割関数でどのインデックス( iまたはj )を返すか、および分割関数でピボットをどのように選択するか(床関数か天井関数か)によって決まります。
まず、 (lo..p)と(p+1..hi)の再帰の選択について、複数の同一要素が存在する配列[0, 0] をソートする例で検討します。分割関数でインデックスが交差した後にインデックス i (「後」のインデックス) が返される場合、最初の分割後にはインデックス 1 が返されます。(lo..p)に対するその後の再帰は(0, 1) に対して行われ、これは同じ配列[0, 0]に対応します。無限再帰を引き起こす非進行分離が発生します。したがって、(lo..p)と(p+1..hi)に対して再帰を行う場合、再帰の左半分に返されたインデックスが含まれるため、非進行シナリオでは「末尾」を除外するのが分割関数の役割であることは明らかです。つまり、インデックス i の代わりにインデックス j (インデックスが交差する際の「前」のインデックス) を返す必要があります。同様の論理で、既にソート済みの配列[0, 1]を例にとると、ポインタが「前者」で停止するように、ピボットの選択は「floor」にする必要があります(「ceiling」をピボットにすると、インデックス 1 が返され、(lo..p)に含まれるため、無限再帰が発生します)。最後の要素をピボットに選択してはいけないのも、同じ理由からです。
(lo..p−1)と(p..hi)で再帰を行うという選択は、上記と同じ論理に従います。再帰の右半分には返されたインデックスが含まれるため、非進行シナリオでは「先頭」を除外するのが分割関数の役割です。分割関数では、インデックス i (インデックスが交差した後の「後」のインデックス) を返す必要があり、「天井値」をピボットとして選択する必要があります。この 2 つのニュアンスは、それぞれ複数の同一要素が存在する配列 ( [0, 0] ) と既にソートされた配列[0, 1]のソートの例を考えると、再び明確になります。このバージョンの再帰では、同じ理由で、最初の要素をピボットとして選択することは避ける必要があることに注意してください。
クイックソートの初期バージョンでは、パーティションの左端の要素がピボット要素として選択されることがよくありました。残念ながら、これは既にソートされた配列で最悪の動作を引き起こします。これはかなり一般的な使用例です。[ 17 ]この問題は、ピボットにランダムなインデックスを選択するか、パーティションの中央のインデックスを選択するか、または(特に長いパーティションの場合)パーティションの最初、中央、最後の要素の中央値をピボットとして選択することによって簡単に解決されました( Sedgewickが推奨)。[ 18 ]この「3つの中央値」ルールは、ソート済み(または逆ソート済み)の入力の場合に対抗し、入力の順序に関する情報がまったくない場合に、単一の要素を選択するよりも最適なピボット(真の中央値)のより良い推定値を提供します。
Lomutoパーティションの3つの中央値コードスニペット:
mid := ⌊(lo + hi) / 2⌋ if A[mid] < A[lo] A[lo]とA[mid]を入れ替える A[hi] < A[lo]の場合 A[lo]とA[hi]を入れ替える A[mid] < A[hi]の場合 A[mid]とA[hi]を交換する ピボット := A[hi]
これはまず中央値を に入れA[hi]、次にその新しい の値をA[hi]ピボットとして使用します。これは、上記で示した基本的なアルゴリズムと同様です。
具体的には、ランダムなピボット選択でn個の要素をソートするために必要な比較回数の期待値は1.386 n log nです( § 平均ケース分析を参照) 。 3 つの中央値ピボットを使用すると、スワップの期待値が 3% 増加する代わりに、この値はC n , 2 ≈ 1.188 n log nに減少します。 [ 8 ]より大きな配列の場合、さらに強力なピボット規則は、9 番目の要素を選択する再帰的中央値 3 (Mo3) であり、次のように定義されます[ 8 ] 。
ピボット要素の選択は、整数オーバーフローの存在によっても複雑になります。ソート対象のサブ配列の境界インデックスが十分に大きい場合、中央インデックスの単純な式( lo + hi )/2ではオーバーフローが発生し、無効なピボットインデックスが与えられます。これは、例えばlo + ( hi − lo )/2を使用して中央要素をインデックス付けすることで回避できますが、演算がより複雑になります。同様の問題は、ピボット要素を選択する他のいくつかの方法でも発生します。
前述のLomuto分割方式のような分割アルゴリズム(適切なピボット値を選択するものであっても)では、繰り返し要素が多い入力に対してクイックソートのパフォーマンスが低下します。この問題は、入力要素がすべて等しい場合に顕著になります。各再帰において、左側のパーティションは空になり(入力値にピボット値より小さい値はありません)、右側のパーティションは要素が1つ減っただけです(ピボット値が削除されます)。その結果、Lomuto分割方式では、等しい値の配列をソートするのに2乗時間かかります。しかし、Hoare分割方式のような分割アルゴリズムでは、繰り返し要素があると一般的に分割効率が向上し、ピボット値と等しい要素の不要なスワップが発生する可能性はあるものの、繰り返し要素の数が増えるにつれて実行時間は一般的に短くなります(メモリキャッシュによってスワップのオーバーヘッドが削減されるため)。すべての要素が等しい場合、Hoare分割方式では要素が不要なスワップされますが、上記のHoare分割の項で述べたように、分割自体は最良のケースとなります。
ロムート分割スキーム問題(オランダ国旗問題[ 8 ]とも呼ばれる)を解決するために、値をピボットより小さい値、ピボットと等しい値、ピボットより大きい値の3つのグループに分割する、線形時間で実行可能な代替分割ルーチンを使用できます。(ベントレーとマッキルロイはこれを「ファットパーティション」と呼び、Unix バージョン7のqsortに既に実装されていました[ 8 ] )ピボットと等しい値は既にソートされているため、ピボットより小さい値と大きい値のパーティションのみを再帰的にソートする必要があります。擬似コードでは、クイックソートアルゴリズムは次のようになります。
// 配列の一部をソートし、それをパーティションに分割してから、それらのパーティションをソートするアルゴリズムquicksort(A, lo, hi)は、if lo >= 0 && lo < hi then lt, gt := partition(A, lo, hi)です。 // 複数の戻り値 クイックソート(A, lo, lt - 1) クイックソート(A, gt + 1, hi) // 配列を 3 つのパーティションに分割するアルゴリズムpartition(A, lo, hi) is // ピボット値 pivot := A[(lo + hi) / 2] // 中央の要素をピボットとして選択 (整数除算)// 小さい、等しい、大きいインデックス lt := lo eq := lo gt := こんにちは // すべての要素を反復処理し、ピボットと比較しますwhile eq <= gt do if A[eq] < pivot then // 等しいインデックスと小さいインデックスの要素を交換しますswap A[eq] with A[lt] // 小さいインデックスをインクリメントします lt := lt + 1 // 等号のインデックスを増やす eq := eq + 1 else if A[eq] > pivot then // 等しいインデックスとより大きいインデックスの要素を交換するswap A[eq] with A[gt] // より大きいインデックスを減らす gt := gt - 1 else // A[eq] = pivotの場合// 等しいインデックスを増やす eq := eq + 1 // 小さいインデックスと大きいインデックスを返すreturn lt, gt
このpartitionアルゴリズムは、中央のパーティションの最初の(「左端」)項目と最後の(「右端」)項目のインデックスを返します。パーティションの他のすべての項目はピボットと等しいため、ソートされています。したがって、パーティションの項目は の再帰呼び出しに含める必要はありませんquicksort。
このアルゴリズムにとって最適なケースは、すべての要素が等しい場合(または、k ≪ n個の要素からなる小さな集合から選択される場合)です。すべての要素が等しい場合、修正されたクイックソートは空のサブ配列に対して 2 回の再帰呼び出しのみを実行するため、線形時間で完了します(partitionサブルーチンの実行時間が線形時間を超えないと仮定した場合)。
セジウィックによって提案され、実際に広く使用されているその他の重要な最適化は次のとおりです。[ 19 ] [ 20 ]
クイックソートの分割統治方式は、タスク並列処理による並列化に適しています。分割ステップは、並列プレフィックス和アルゴリズムを使用して、分割された配列の各セクションのインデックスを計算することによって実行されます。[ 23 ] [ 24 ]サイズnの配列が与えられた場合、分割ステップはO( n )の作業をO (log n )時間で実行し、 O( n ) の追加スクラッチ領域を必要とします。配列が分割された後、2 つのパーティションを再帰的に並列にソートできます。ピボットの理想的な選択を仮定すると、並列クイックソートは、O( n log n )の作業をO(log 2 n )時間で実行し、 O( n )の追加領域を使用して、サイズnの配列をソートします。
クイックソートは、マージソートなどの他のソートアルゴリズムと比較すると、効率的な並列化を困難にするいくつかの欠点があります。クイックソートの分割統治ツリーの深さはアルゴリズムのスケーラビリティに直接影響し、この深さはアルゴリズムのピボットの選択に大きく依存します。さらに、分割ステップを効率的にインプレースで並列化することは困難です。スクラッチ領域を使用することで分割ステップは簡素化されますが、アルゴリズムのメモリ使用量と定数オーバーヘッドが増加します。
他のより高度な並列ソートアルゴリズムでは、さらに優れた時間制限を達成できます。[ 25 ]例えば、1991年にDavid MW Powersは、n個のプロセッサを備えたCRCW (同時読み出しと同時書き込み) PRAM (並列ランダムアクセスマシン)上で、暗黙的にパーティショニングを実行することでO(log n)時間で動作する並列クイックソート(および関連する基数ソート)について説明しました。[ 26 ]
最も不均衡な分割は、分割ルーチンによって返されるサブリストの 1 つがサイズn − 1の場合に発生します。[ 27 ]これは、ピボットがたまたまリスト内の最小または最大の要素である場合、または一部の実装 (上記で説明した Lomuto 分割スキームなど) ではすべての要素が等しい場合に発生する可能性があります。
これが各パーティションで繰り返し発生する場合、各再帰呼び出しは、前のリストよりサイズが 1 少ないリストを処理します。したがって、サイズ 1 のリストに到達するまでにn − 1 回のネストされた呼び出しが必要になります。これは、呼び出しツリーがn − 1 回のネストされた呼び出しの線形チェーンであることを意味します。i番目の呼び出しは、パーティションを実行するためにO ( n − i )の作業を行い、そのため、その場合はクイックソートはO ( n² )の時間で実行されます。
最もバランスの取れたケースでは、各パーティションによってリストはほぼ等しい2つの部分に分割されます。これは、各再帰呼び出しが半分のサイズのリストを処理することを意味します。結果として、サイズ1のリストに到達するまでに実行できるネストされた呼び出しはlog 2 n回のみです。これは、呼び出しツリーの深さがlog 2 nであることを意味します。ただし、呼び出しツリーの同じレベルの2つの呼び出しは、元のリストの同じ部分を処理することはありません。したがって、各レベルの呼び出しは合計でO ( n )の時間しか必要としません(各呼び出しには一定のオーバーヘッドがありますが、各レベルでの呼び出しはO ( n )回だけなので、これはO ( n )の係数に含まれます)。結果として、このアルゴリズムはO ( n log n )の時間しか使用しません。
n 個の異なる要素の配列をソートする場合、クイックソートは、 n個の要素のn !通りの順列を等しい確率で平均すると、期待値としてO ( n log n )の時間を要する。あるいは、アルゴリズムが入力配列からピボットを均一にランダムに選択する場合、同じ分析を使用して任意の入力シーケンスの期待実行時間を制限することができる。この場合、期待値はアルゴリズムによって行われるランダムな選択について取られる (Cormen et al. , Introduction to Algorithms , [ 14 ] Section 7.3)。
この主張を証明する一般的な方法として、パーセンタイル、漸化式、二分探索木を用いるものが3つあり、それぞれがクイックソートの動作原理について異なる洞察を与えてくれる。
各ピボットの順位が中央50%、つまり25パーセンタイルから75パーセンタイルの間にある場合、各要素は少なくとも25%、最大75%の割合で分割されます。このようなピボットを一貫して選択すれば、リストを分割する必要のある要素は最大でもサイズ1のリストに到達するまでに何回も実行され、O ( n log n )のアルゴリズムが得られます。
入力がランダムな順列の場合、ピボットのランクはランダムであるため、中央50%に入ることは保証されません。しかし、ランダムな順列から開始する場合、各再帰呼び出しのピボットはリスト内でランダムなランクを持つため、約半分の確率で中央50%に入ります。コインを投げることを想像してみてください。表が出ればピボットのランクは中央50%に入り、裏が出ればそうではありません。ここで、コインをk回表が出るまで何度も投げ続けることを想像してみてください。これには時間がかかるかもしれませんが、平均的には2k回の投げだけで済み、 100k回投げてもk回表が出ない確率は非常に低いです(これはチェルノフ境界を用いて厳密に証明できます)。同様の議論により、クイックソートの再帰は平均してわずか2k回の呼び出し深度で終了します。しかし、平均呼び出し深度がO (log n )で、呼び出しツリーの各レベルが最大n個の要素を処理する場合、平均して行われる作業の総量は、積O ( n log n )になります。アルゴリズムは、ピボットが中央半分にあることを、それが一定回数である限り検証する必要はありません。
より慎重な議論を用いることで、この証明をピボットがランダムに選択されるクイックソートのバージョンに拡張し、高い確率で成り立つ時間制限を示すことができる。具体的には、任意の与えられた、 させてすると、少なくとも確率で比較回数は超えません[ 28 ]
別の方法として、サイズnのリストをソートするのに必要な時間であるT ( n )の係数に対する漸化式を立てる方法がある。最も不均衡なケースでは、1 回のクイックソート呼び出しにO ( n )の作業と、サイズ0およびn -1のリストに対する 2 つの再帰呼び出しが含まれるため、漸化式は次のようになる。
これは挿入ソートと選択ソートと同じ関係であり、最悪の場合T ( n ) = O ( n2 )に解決されます。
最もバランスの取れたケースでは、1回のクイックソート呼び出しにはO ( n )の作業とサイズn /2のリストに対する2回の再帰呼び出しが含まれるため、漸化式は次のようになります。
分割統治漸化式のマスター定理によれば、T ( n ) = O ( n log n )となります。
O ( n log n )の期待時間計算量の正式な証明の概要を以下に示します。重複がないと仮定します。重複は線形時間の前処理と後処理で処理できるか、分析したケースよりも簡単なケースとして考慮できます。入力がランダムな順列の場合、ピボットのランクは 0 からn − 1まで一様ランダムです。すると、分割の結果得られる部分のサイズはiとn − i − 1になり、i は 0 からn − 1まで一様ランダムです。したがって、すべての可能な分割について平均し、分割の比較回数がn − 1であることに注意すると、入力シーケンスのすべての順列についての平均比較回数は、次の漸化式を解くことで正確に推定できます。
漸化式を解くと、C ( n ) = 2 n ln n ≈ 1.39 n log 2 nとなる。
これは、平均的にクイックソートのパフォーマンスが最良の場合と比べて約 39% しか悪くないことを意味します。この意味で、最悪の場合よりも最良の場合に近いと言えます。比較ソートでは、 n個の項目をソートするのに平均してlog 2 ( n !)未満の比較は使用できません(比較ソートの記事で説明されています) 。nが大きい場合、スターリングの近似式ではlog 2 ( n !) ≈ n (log 2 n − log 2 e )となるため、クイックソートは理想的な比較ソートと比べてそれほど悪くありません。この平均実行時間の速さは、クイックソートが他のソートアルゴリズムよりも実質的に優れているもう 1 つの理由です。
以下の二分探索木(BST)は、クイックソートの各実行に対応します。初期ピボットはルートノードであり、左半分のピボットは左サブツリーのルート、右半分のピボットは右サブツリーのルート、といった具合です。クイックソートの実行における比較回数は、一連の挿入によるBSTの構築中の比較回数と等しくなります。したがって、ランダム化クイックソートの平均比較回数は、挿入された値に基づいてBSTを構築する平均コストと等しくなります。ランダムな順列を形成する。
シーケンスの挿入によって作成されたBSTを考えてみましょうランダムな順列を形成する値の集合。C をBSTの作成コストとします。、 どこ挿入中に比較対象があった。
期待値の線形性により、期待値はCの。
iとj < iを固定します。ソートされたら、j +1 個の区間を定義します。コアとなる構造的観察は次のとおりです。と比較されるアルゴリズムにおいて、に隣接する 2 つの区間のいずれかに収まる。
なぜならランダムな順列です。もランダムな順列なので、に隣接していますまさに。
簡略化すると、次のようになります。
クイックソートが使用するメモリ容量は、使用するバージョンによって異なります。
インプレース版のクイックソートは、以下の戦略を用いて慎重に実装した場合、最悪の場合でも空間計算量はO (log n )となります。
インプレースかつ不安定なパーティショニングを用いたクイックソートは、再帰呼び出しを行う前に一定量の追加スペースのみを使用します。クイックソートは、ネストされた再帰呼び出しごとに一定量の情報を格納する必要があります。最良の場合、ネストされた再帰呼び出しは最大でO (log n )回なので、使用するスペースはO (log n )です。しかし、再帰呼び出しを制限するセジウィックのトリックがない場合、最悪の場合、クイックソートはO ( n )回のネストされた再帰呼び出しを行い、 O ( n )の補助スペースを必要とする可能性があります。
ビット複雑度の観点から見ると、loやhiのような変数は定数空間を使用しません。n 個の項目を持つリストにインデックスを付けるには O (log n ) ビットが必要です。このような変数はすべてのスタック フレームに存在するため、セジウィックのトリックを使用したクイック ソートにはO ((log n ) 2 )ビットの空間が必要です。ただし、リストに異なる要素が含まれていれば、少なくともO ( n log n )ビットの空間が必要になるため、この空間要件はそれほどひどいものではありません。
クイックソートのスタックフリー版が提案されている。これらは追加のスペース(より正確には、レコードを交換するためのソート済みレコードのタイプのセル1つと、インデックスとして使用される定数個の整数変数)。[ 29 ]
もう一つの、あまり一般的ではない非インプレース型のクイックソートは、作業用ストレージにO ( n )の空間を使用し、安定ソートを実装できます。作業用ストレージにより、入力配列を安定的に分割し、後続の再帰呼び出しのために入力配列にコピーし直すことができます。セジウィックの最適化は依然として有効です。
クイックソートは、バイナリツリーソートのスペース最適化バージョンです。明示的なツリーに項目を順次挿入する代わりに、クイックソートは再帰呼び出しによって暗示されるツリーに項目を並行して配置します。アルゴリズムは全く同じ比較を行いますが、順序が異なります。ソートアルゴリズムに求められる特性として、安定性があります。つまり、等しいと比較される要素の順序は変更されず、マルチキーテーブル(ディレクトリやフォルダのリストなど)の順序を自然な方法で制御できます。この特性は、インプレースクイックソート(ポインタとバッファに定数の追加スペース、明示的または暗黙的な再帰の管理にO (log n )の追加スペースのみを使用する)では維持が困難です。ポインタ(リストやツリーなど)またはファイル(実質的にはリスト)を使用した表現による追加メモリを伴うバリアントクイックソートでは、安定性を維持するのは容易です。より複雑な、またはディスクバウンドなデータ構造は、一般的に仮想メモリまたはディスクの使用が増加するため、時間コストが増加する傾向があります。
クイックソートの最も直接的な競合はヒープソートです。ヒープソートは、シンプルさと最悪の場合の実行時間がO ( n log n )であるという利点がありますが、ヒープソートの平均実行時間は、参照の局所性が悪いため、通常、インプレースクイックソートよりも遅いと考えられています。[ 30 ]この結果には議論の余地があり、反対の結果を示す論文もあります。[ 31 ] [ 32 ]クイックソートの主な欠点は、悪いピボット選択を回避するために必要な実装の複雑さと、結果としてのO ( n 2 )のパフォーマンスです。 イントロソートは、悪いケースが検出されたときにヒープソートに切り替えることでこの問題を解決するクイックソートの変種です。C++ (GNU および LLVM 実装) などの主要なプログラミング言語は、イントロソートを使用しています。[ 33 ]
クイックソートは、同じくO ( n log n )のソートアルゴリズムであるマージソートとも競合します。マージソートの主な利点は、安定ソートであり、最悪ケースのパフォーマンスが非常に優れていることです。マージソートの主な欠点は、アウトオブプレースアルゴリズムであるため、配列を操作する場合、効率的な実装にはO ( n ) の補助空間が必要になることです(インプレースパーティショニングと末尾再帰を使用したクイックソートではO (log n ) 、ヒープソートではO (1)です)。
マージソートは連結リストに対して非常に効果的で、必要な補助記憶容量は少量で一定です。クイックソートは連結リストを用いた安定ソートとして実装できますが、そうする理由はありません。ランダムアクセスがない場合、ピボット選択が不適切になることが多く、基本的にマージソートよりも常に劣ります。また、マージソートは、ディスクストレージやネットワーク接続ストレージなど、アクセス速度の遅いメディアに保存された非常に大きなデータセットを外部からソートする場合にも最適なアルゴリズムです。
2つのバケットを使用するバケットソートはクイックソートと非常によく似ています。この場合、ピボットは実質的に値の範囲の中央の値であり、均一に分布した入力に対しては平均的に良好な結果をもたらします。
選択アルゴリズムは、数値のリストの中からk番目に小さい値を選択します。これは一般的にソートよりも簡単な問題です。シンプルながら効果的な選択アルゴリズムの一つに、クイックソートとほぼ同じように動作するものがあり、クイックセレクトと呼ばれています。違いは、両方のサブリストに対して再帰呼び出しを行う代わりに、目的の要素を含むサブリストに対して末尾再帰呼び出しを1回だけ行う点です。この変更により、平均的な計算量は線形時間、つまりO ( n )にまで低下し、選択には最適ですが、最悪の場合、選択アルゴリズムの計算量は依然としてO ( n² )となります。
クイックセレクトの変種である中央値中央値アルゴリズムは、ピボットをより慎重に選択し、ピボットがデータの中央付近(30パーセンタイルから70パーセンタイルの間)にあることを保証するため、線形時間(O ( n))が保証されます。この同じピボット戦略を使用して、 O ( n log n )の時間で動作するクイックソートの変種(中央値中央値クイックソート)を構築することもできます。ただし、ピボットを選択する際のオーバーヘッドが大きいため、実際にはあまり使用されません。
より抽象的に言えば、O ( n )の選択アルゴリズムが与えられた場合、それを用いてクイックソートの各ステップで理想的なピボット(中央値)を見つけることで、O ( n log n )の実行時間を持つソートアルゴリズムを生成できる。この変種の実際の実装は平均的にかなり遅いが、最適な選択アルゴリズムが最適なソートアルゴリズムを生み出す可能性があることを示しているため、理論的には興味深い。
単一のピボットを使用して 2 つのサブ配列に分割する代わりに、マルチピボットクイックソート (マルチクイックソート[ 22 ]とも呼ばれる) は、 s − 1 個のピボットを使用して入力をs個のサブ配列に分割します。デュアルピボット ( s = 3 ) の場合については、1970 年代半ばに Sedgewick らが検討しましたが、結果として得られたアルゴリズムは、実際には「古典的な」クイックソートよりも高速ではありませんでした。[ 34 ]プロセッサキャッシュを効率的に使用するために調整された可変数のピボットを持つマルチクイックソートの 1999 年の調査では、命令数が約 20% 増加することがわかりましたが、シミュレーション結果では、非常に大きな入力に対してはより効率的になることが示唆されました。[ 22 ] 2009 年に Yaroslavskiy によって開発されたデュアルピボットクイックソートのバージョン[ 35 ]は、 Java 7にプリミティブ配列をソートする標準アルゴリズムとして実装されるのに十分な速さであることが判明しました[ 36 ] (オブジェクトの配列のソートはTimsortを使用して行われます)。[ 37 ]このアルゴリズムのパフォーマンス上の利点は、その後、主にキャッシュのパフォーマンスに関連していることが判明し [ 38 ]、実験結果によると、3 つのピボットのバリアントは最新のマシンでさらに優れたパフォーマンスを発揮する可能性があります。[ 39 ] [ 40 ]
ディスクファイルの場合、クイックソートに似たパーティショニングに基づく外部ソートが可能です。外部マージソートよりは遅いですが、追加のディスク容量は必要ありません。入力用に2つ、出力用に2つの計4つのバッファが使用されます。ファイル内のレコード数、バッファあたりのレコード数、ファイル内のバッファセグメントの数。データはファイルの両端から内側に向かって読み込まれ(そして書き込まれ)ます。ファイルの先頭から始まるセグメントを表し、ファイルの末尾から始まるセグメントを表します。データは読み込まれます。そしてバッファを読み取ります。ピボットレコードが選択され、そしてピボットレコード以外のバッファは、バッファを昇順で書き込み、ピボットレコードとの比較に基づいてバッファを降順で書き込みます。またはバッファがいっぱいになると、ファイルに書き込まれ、次のまたはバッファがファイルから読み込まれます。すべてのセグメントが読み込まれ、書き込みバッファが 1 つ残るまで処理が続きます。そのバッファが書き込みバッファにピボットレコードが追加され、バッファが書き込まれます。そのバッファが書き込みバッファでは、ピボットレコードが先頭に追加されます。バッファとバッファが書き込まれました。これはファイルの1つのパーティションステップを構成し、ファイルは2つのサブファイルで構成されます。各サブファイルの開始位置と終了位置は、再帰によってスタンドアロンスタックまたはメインスタックにプッシュ/ポップされます。スタックスペースを制限するために小さいサブファイルが最初に処理されます。スタンドアロンスタックの場合は、大きいサブファイルのパラメータをスタックにプッシュし、小さいサブファイルを反復処理します。再帰の場合は、まず小さいサブファイルを再帰処理し、次に大きいサブファイルを反復処理します。サブファイルのレコード数が 4 B 以下になると、クイックソートによってその場でソートされ、ファイルに書き込まれます。これで、そのサブファイルはソートされ、ファイル内に配置されます。すべてのサブファイルがソートされ、配置されるまで、この処理が続けられます。ファイルに対する平均パス数は約しかし最悪の場合のパターンは合格(最悪の場合の内部ソートの場合)。[ 41 ]
このアルゴリズムは、基数ソートとクイックソートを組み合わせたものです。配列から要素 (ピボット) を選択し、文字列 (マルチキー) の最初の文字 (キー) を考慮します。残りの要素を、対応する文字がピボットの文字より小さい、等しい、大きいの 3 つのセットに分割します。「より小さい」と「より大きい」のパーティションを同じ文字で再帰的にソートします。「等しい」のパーティションを次の文字 (キー) で再帰的にソートします。バイトまたは長さWビットのワードを使用してソートする場合、最良のケースはO ( KN )、最悪のケースはO (2 K N )または少なくとも標準クイックソートと同じO ( N 2 )です。これは、一意のキーN <2 Kの場合で、Kはクイックソートを含むすべての標準比較ソートアルゴリズムで隠された定数です。これは、中央のパーティションがピボットと完全に等しい要素の (自明に) ソートされたサブ配列を表す一種の 3 方向クイックソートです。
また、Powers によってO ( K )の並列PRAMアルゴリズムとして開発されました。これも基数ソートとクイックソートの組み合わせですが、クイックソートの左右分割の決定はキーの連続するビットで行われるため、N Kビットキーの場合はO ( KN )となります。すべての比較ソートアルゴリズムは、暗黙のうちにKがΘ (log N )の超二分法モデルを仮定しています。Kが小さい場合は、ハッシュテーブルまたは整数ソートを使用してO ( N )時間でソートできます。K ≫ log N ですが、要素が O (log N ) ビット内で一意である場合、残りのビットはクイックソートまたはクイック基数ソートのどちらでも調べられません。それができない場合、すべての比較ソートアルゴリズムは、 O ( K ) の比較的役に立たないビットを調べるという同じオーバーヘッドを持ちますが、クイック基数ソートは、標準クイックソートと基数クイックソートの最悪ケースO ( N 2 )の動作を回避し、 uniqueprefix( K ) ≫ log Nのこれらの条件下では、これらの比較アルゴリズムの最良のケースでも高速になります。比較、基数、および並列ソートの隠れたオーバーヘッドの詳細については、Powers [ 42 ]を参照してください。
比較ベースのソートアルゴリズムでは、比較回数を最小限に抑えるには、各比較から得られる情報量を最大化する必要があります。つまり、比較結果は予測不可能です。これにより、頻繁に分岐の誤予測が発生し、パフォーマンスが制限されます。[ 43 ] BlockQuicksort [ 44 ]は、クイックソートの計算を再配置して、予測不可能な分岐をデータ依存性に変換します。パーティショニングでは、入力は中サイズのブロック(データキャッシュに容易に収まる)に分割され、2 つの配列に交換する要素の位置が格納されます。(条件分岐を回避するために、位置は無条件に配列の末尾に格納され、交換が必要な場合は末尾のインデックスがインクリメントされます。)2 回目のパスでは、配列で指定された位置の要素が交換されます。どちらのループにも、終了のテストという 1 つの条件分岐のみがあり、通常は実行されます。
BlockQuicksort の手法はLLVMの C++ STL 実装である libcxx に組み込まれており、ランダムな整数シーケンスに対して 50% の改善を実現しています。パターン回避クイックソート ( pdqsort ) は、イントロソートの一種で、この手法を採用しています。[ 33 ]
クイックソートには、入力データから最小または最大のk個の要素を分離するいくつかのバリエーションが存在する。
リチャード・コールとデビッド・C・カンダティルは2004年に、パーティションソートと呼ばれる1パラメータのソートアルゴリズムのファミリーを発見し、平均的には(すべての入力順序が等しい確率で発生する場合)最大で比較(情報理論の下限に近い)と最悪の場合、彼らは比較(および演算)は、インプレースで行われ、追加の処理のみが必要です。空間。最適化されたクイックソート(セジウィックとベントレー-マキルロイ)と比較して、実用的な効率性とパフォーマンスのばらつきの小ささが実証された。 [ 45 ]
小さなサブ配列を最後に保存することは命令数の観点からは理にかなっているが、キャッシュのパフォーマンスの観点からはまさに間違った行為である。
ヒープソートはクイックソートよりもかなり遅く(我々の実験ではn = 2 10の場合30%以上遅い)、大規模なインスタンスではキャッシュの挙動が悪いため問題が生じます(我々の実験では2 28要素のソートでクイックソートより8倍以上遅い)。