Cを使用してマージソートとクイックソートを実装しました(4 GBで実行されているUbuntu 10.04のGCC 4.4.3 RAM 2GHzのIntel DUO CPUを搭載したラップトップ))のパフォーマンスを比較したかった2つのアルゴリズム。
ソート関数のプロトタイプは次のとおりです。
void merge_sort(const char **lines, int start, int end);
void quick_sort(const char **lines, int start, int end);
つまり、両方とも文字列へのポインタの配列を取り、インデックスiで要素をソートします。開始<= i <=終了。
平均4.5文字の長さのランダムな文字列を含むファイルをいくつか作成しました。テストファイルの範囲は100行から10000000行です。
クイックソートがO(n ^ 2)であるときにマージソートが複雑なO(n log(n))であることを知っていても、平均的なクイックソートはマージソートと同じくらい速い。しかし、私の結果は次のとおりです。
だから私の質問は:私の結果は期待どおりです。つまり、クイックソートは小さな入力のソートをマージする速度と同等ですが、入力データが大きくなると、その複雑さが二次であるという事実が明らかになりますか?
これが私がしたことのスケッチです。マージソートの実装では、パーティション化はマージソートを再帰的に呼び出すことで構成されます。
merge_sort(lines, start, (start + end) / 2);
merge_sort(lines, 1 + (start + end) / 2, end);
並べ替えられた2つのサブ配列のマージは、配列lines
からデータを読み取り、それをポインターのグローバル一時配列に書き込みます(このグローバル配列は一度だけ割り当てられます)。各マージの後、ポインタは元の配列にコピーされます。したがって、文字列は1回格納されますが、ポインタ用に2倍のメモリが必要です。
クイックソートの場合、パーティション関数は配列の最後の要素をピボットとしてソートし、前の要素を1つのループでスキャンします。タイプのパーティションを作成した後
start ... {elements <= pivot} ... pivotIndex ... {elements > pivot} ... end
それ自身を再帰的に呼び出します:
quick_sort(lines, start, pivotIndex - 1);
quick_sort(lines, pivotIndex + 1, end);
このクイックソート実装は配列をインプレースでソートし、追加のメモリを必要としないため、マージソート実装よりもメモリ効率が良いことに注意してください。
だから私の質問は、試してみる価値のあるクイックソートを実装するより良い方法はありますか?クイックソートの実装を改善し、さまざまなデータセットでより多くのテストを実行すると(さまざまなデータセットの実行時間の平均を計算します)、クイックソートwrtマージソートのパフォーマンスが向上しますか?
[〜#〜]編集[〜#〜]
回答ありがとうございます。
私の実装はインプレースであり、セクションの wikipedia で見つけた疑似コードに基づいていますインプレースバージョン :
function partition(array, 'left', 'right', 'pivotIndex')
ここで、ピボットとしてソートする範囲の最後の要素を選択します。つまり、pivotIndex:=右です。私は何度も何度もコードをチェックしましたが、それは私には正しいようです。間違った実装を使用しているケースを排除するために、ソースコードを github にアップロードしました(参考にしたい場合)。
あなたの答えは、私が間違ったテストデータを使用していることを示唆しているようです。それを調べて、さまざまなテストデータセットを試します。結果が出次第報告します。
あなたがあなたを交換するためのあなたのコードを見れば:
// If current element is lower than pivot
// then swap it with the element at store_index
// and move the store_index to the right.
ただし、スワップしたばかりの文字列を50%ほど戻す必要があるため、より高速なマージソートが両端から同時に動作します。
次に、各再帰呼び出しを行う前に、最初の要素と最後の要素が同じかどうかを確認する場合は、関数をすばやく終了するためだけに関数を呼び出す時間を無駄にしないようにします。これは、最終テストで10000000発生し、顕著な時間が追加されます。
使用する、
if(pivot_index -1> start)quick_sort(lines、start、pivot_index-1);
if(pivot_index + 1 <end)quick_sort(lines、pivot_index + 1、end);
イニシャルif(開始<終了)を実行する外部関数がまだ必要ですが、これは一度だけ実行する必要があるため、関数は外部比較なしで安全でないバージョンのコードを呼び出すことができます。
また、ランダムなピボットを選択すると、N ^ 2の最悪のケースの結果が回避される傾向がありますが、ランダムデータセットではそれほど重要ではありません。
最後に、隠れた問題は、QuickSortがこれまでより接近しているより小さなバケット内の文字列を比較していることです。
(編集:つまり、AAAAA、AAAAB、AAAAC、AAAAD、次にAAAAA、AAAABです。そのため、strcmpは、文字列の有用な部分を探す前に、多くのAを処理する必要があります。)
ただし、マージソートでは、ランダムに変化する最小のバケットが最初に表示されます。 Mergsortsの最終パスは、互いに近い多くの文字列を比較しますが、その場合はそれほど問題ではありません。文字列のクイックソートを高速化する1つの方法は、外部文字列の最初の桁を比較し、同じ場合は内部比較を行うときにそれらを無視することですが、すべての文字列に十分な桁があるため、スキップしないように注意する必要がありますnullターミネータ。
私の結果は期待どおりですか?
マージソートには、次のパフォーマンス特性があります。
O(n log n)
O(n log n)
O(n log n)
Quicksortには、次のパフォーマンス特性があります。
O(n log n)
O(n log n)
O(n^2)
注意: Big-O表記 は漸近的境界を示します 定数因子を無視する 。
クイックソートは、選択したピボット要素がサブ範囲を均等に分割する傾向がある場合に、最高のパフォーマンスを発揮します。入力が逆に並べ替えられている場合や、逆に並べ替えられている場合など、逆の場合、最悪の2次性能を示します。クイックソートにはさまざまな種類があり、ピボット要素の選択方法も一部異なります。
マージソートのパフォーマンスは、クイックソートのパフォーマンスよりもはるかに制約が多く、予測可能です。その信頼性の代償は、マージソートの平均的なケースがクイックソートの平均的なケースよりも遅いということですマージソートの定数係数が大きい。ただし、この定数係数は、実装の特定の詳細に大きく依存します。マージソートを適切に実装すると、クイックソートを実装しなかった場合よりも平均パフォーマンスが向上します。
これを展望してみるために、標準ライブラリから何が期待できるかを考えてみましょう。アイデアを得るために、私はこれをC++で書きました:
#include <iostream>
#include <algorithm>
#include <vector>
#include <iterator>
#include <string>
#include <time.h>
std::string gen_random() {
size_t len = Rand() % 25 + 5;
std::string x;
std::generate_n(std::back_inserter(x), len, Rand);
return x;
}
static const int num = 10000000;
int main(){
std::vector<std::string> strings;
std::generate_n(std::back_inserter(strings), num, gen_random);
clock_t start = clock();
std::sort(strings.begin(), strings.end());
clock_t ticks = clock() - start;
std::cout << ticks / (double)CLOCKS_PER_SEC;
return 0;
}
これにより、指定された数の文字列(それぞれ5〜30文字)が生成され、並べ替えられます。私のマシン(おそらくあなたのマシンより多少遅い)では、ソートに約14秒かかります。これはIntrosortとして実装されていると思います。通常の場合、IntrosortのパフォーマンスはQuicksortとほぼ同じだと思います。
結論:マージソートで得られる結果はかなり合理的ですが、クイックソートから得られる結果は、実装に深刻な問題があることを示しています。
あなたの結果は間違いなく予想されていません。実際、クイックソートが使用されるのは、平均的なケースではマージソートよりもかなり速いになる傾向があるためです。
この警告は、最初に試すべきことにもヒントを与えます。クイックソートのピボット要素をランダムに選択することで、(部分的に)事前にソートされたデータの問題を排除します。 「調整された」クイックソートは、3つまたは5つのランダム要素を選択し、ピボットの選択が不均衡な影響を与えるため、初期の実行の中央値を取ります。
そしてもちろん、クイックソートの実装に欠陥があるだけかもしれません(実際に正しく実装するのは、思ったより難しいです)。
Wikiの疑似コードは実用的ではないことに注意してください。解らないように書かれています。
配列が[0,0,0,1]ピボットが1で、他の要素がピボットより小さい場合、
そしてswapping(t <-a、a <-b、b <-t)はt <-a、a <-a、a <-tとして機能します。
この場合、スワッピングは何も機能しません。
配列が[4,2,1,3]の場合、[4、2,1,3]-> [ 2、4、1,3]-> [2,1、4、3]-> [2,1,3、4]。
この場合、「4」はバブルソートのように移動します。
新しい疑似コードを提案します。
quicksort(A, i, k)
if i < k
p := partition(A, i, k)
quicksort(A, i, p - 1)
quicksort(A, p + 1, k)
partition(array, left, right)
hole := choosePivot(Array, left, right)
pivot := array[hole] // Dig a hole
array[hole] := array[right]
hole := right // move the hole
while left < hole
if array[left] >= pivot
array[hole] := array[left]
hole := left
while right > hole
if array[right] < pivot
array[hole] := array[right]
hole := right
right := right - 1
left := left + 1
array[hole] := pivot // bury the hole
return hole
いくつかの要因に依存するため、パフォーマンスの比較は困難です。
例:N = 100000、32バイト/要素、ピボットは中間要素、strcmp(3)、連続メモリ
qsort_middle() usec = 522870 call = 999999 compare = 28048465 copy = 15404514
merge_sort() usec = 533722 call = 999999 compare = 18673585 copy = 19673584
ソースCプログラムとスクリプトは github に投稿されます。 Threはさまざまなクイックソートとマージソートです。
私はマイケルに同意します。クイックソートを正しく実装することは困難です。大学時代に私の最終試験(並べ替えアルゴリズムの比較)を書いていたときに、私のQuickSort実装はWindows NTコンピューターをブルースクリーンで管理していました(GPFを引き起こさず、BSODしました)。
クイックソートで一般的に遭遇する最大の問題はピボット選択です。 QuickSortのアキレス腱はかなり一般的であることを覚えておいてください。ほぼソートされたリスト。そのため、余分な複雑さにもかかわらず、適切なピボットの選択は非常に重要です。サブアレイの中央の要素を選択するほど単純なものでも、通常はどちらかの端で選択するよりも優れています。ほぼソートされたリストと真にランダムなリストの2つの最も一般的なケースでは、これは一般的に良い結果をもたらします。 3の中央値はさらに優れています。
チェックするもう1つのことは、インテリジェントな「ベースケース」があることです。 2つの要素のパーティションは簡単に並べ替えることができます(順序どおりですか?そうでない場合は、スワップem)。そのため、2要素の配列をピボットして、ゼロまたは1要素の基本ケースに到達することは無駄です。
もう1つは、アルゴリズムが「インプレース」であり(「直感的な」QuickSortが「直感的な」MergeSortよりも優れている点です。メモリの割り当てにはコストがかかります)、その間にピボットを維持しようとしないことです。ピボットしている間の2つの半分。ピボットを選択し、最後の要素と入れ替えて、左側から「大きな」要素を探し、次に右側から「小さな」要素を探します。入れ替えて、「大きい」値が見つかるまで、それと入れ替える「小さい」値がなくなるまで続けます(最後の要素でピボットと入れ替えて再帰します)。これにより、ピボットを2つの半分の「中間」に保つための不要なスワッピングが防止されます。
標準ライブラリqsort(または標準C++ソート)関数を呼び出していません。あなたが呼んでいるのは、多かれ少なかれ賢いあるランダムな人によって書かれた2つのランダムな関数であり、それはパフォーマンスに反映されます。 2つのアルゴリズムのパフォーマンスを比較するのではなく、2つのランダムな実装のパフォーマンスを比較します。
通常、最善の方法は、ほとんどの人が使用しているものを使用することです。この場合は、qsortを呼び出します。あなたが仮定できるものは、物事をできるだけ早くソートするでしょう。ソートが非常に重要であるため、qsort実装はプレーンクイックソートである可能性は低く、明らかなパフォーマンス問題(使用したquick_sort関数など)を含むプレーンクイックソートの実装である可能性はさらに低くなります。
運がよければ、qsortは、たとえば、並べ替えられた配列を線形時間で並べ替え、ほぼ線形の時間で並べ替えられます。
あなたの分析も失敗していると思うのは、あなたのデータを考慮していないことです。
配列内のすべての要素がまったく同じ場合、マージソートとクイックソートはどうなりますか?この問題を解決できますか、それともこの「エッジ」ケースが実行時間を同じに保ちますか?パフォーマンスは誰が優れていますか?
データが完全にランダムな場合はどうなりますか?パフォーマンスは誰が優れていますか?
だから私の質問は、試してみる価値のあるクイックソートを実装するより良い方法はありますか?
配列が100要素未満の場合、クイックソート内で挿入ソートを実行することを検討しましたか?挿入ソートの使用を開始するには、どの値が最も効率的ですか?
ピボットの選択方法は、クイックソートランタイムにとって重要です。ピボットをどのように選択していますか?どっちがいい?
クイックソートを実装する方法は実際には2つありますが、両方を発見しましたか?
あなたはこれらの質問について考えるのによく役立つと思います。