Wikibooks 'Haskell には、 次のクレーム があります。
Data.Listは、リストをソートするためのソート機能を提供します。クイックソートは使用しません。むしろ、mergesortと呼ばれるアルゴリズムの効率的な実装を使用します。
クイックソートよりもマージソートを使用するHaskellの根本的な理由は何ですか?通常、クイックソートの方が実用的なパフォーマンスが優れていますが、この場合はそうではないかもしれません。クイックソートのその場での利点は、Haskellリストを扱うのが難しい(不可能?)と私は思います。
softwareengineering.SE に関連する質問がありましたが、実際にはなぜマージソートが使用されるかではありませんでした。
プロファイリング用に2種類を自分で実装しました。 Mergesortは優れていました(2 ^ 20要素のリストの約2倍の速さ)が、クイックソートの実装が最適であったかどうかはわかりません。
編集: mergesortとquicksortの実装を次に示します。
mergesort :: Ord a => [a] -> [a]
mergesort [] = []
mergesort [x] = [x]
mergesort l = merge (mergesort left) (mergesort right)
where size = div (length l) 2
(left, right) = splitAt size l
merge :: Ord a => [a] -> [a] -> [a]
merge ls [] = ls
merge [] vs = vs
merge first@(l:ls) second@(v:vs)
| l < v = l : merge ls second
| otherwise = v : merge first vs
quicksort :: Ord a => [a] -> [a]
quicksort [] = []
quicksort [x] = [x]
quicksort l = quicksort less ++ pivot:(quicksort greater)
where pivotIndex = div (length l) 2
pivot = l !! pivotIndex
[less, greater] = foldl addElem [[], []] $ enumerate l
addElem [less, greater] (index, elem)
| index == pivotIndex = [less, greater]
| elem < pivot = [elem:less, greater]
| otherwise = [less, elem:greater]
enumerate :: [a] -> [(Int, a)]
enumerate = Zip [0..]
編集 2 3:Data.List
での並べ替えに対する実装のタイミングを提供するように求められました。 @Will Nessの提案に従い、-O2
フラグを使用して this Gist をコンパイルし、毎回main
で指定されたソートを変更し、+RTS -s
で実行しました。ソートされたリストは、安価に作成された、2 ^ 20個の要素を持つ擬似ランダム[Int]
リストです。結果は次のとおりです。
Data.List.sort
:0.171smergesort
:1.092s(Data.List.sort
よりも6倍遅い)quicksort
:1.152s(Data.List.sort
よりも7倍遅い)命令型言語では、配列を変更することにより、クイックソートがインプレースで実行されます。コードサンプルで示すように、代わりに単一リンクリストを作成することで、Haskellのような純粋な関数型言語にQuicksortを適合させることができますが、これはそれほど高速ではありません。
一方、Mergesortはインプレースアルゴリズムではありません。簡単な命令型実装では、マージされたデータを別の割り当てにコピーします。これは、Haskellに適しています。Haskellは、本来、データをコピーする必要があります。
少し戻ってみましょう。QuicksortのパフォーマンスEdgeは「伝承」です。これは、現在使用しているマシンとはかなり異なるマシンで数十年前に確立された評判です。同じ言語を使用している場合でも、この種の伝承では、事実が変わる可能性があるため、時々再確認する必要があります。このトピックで最後に読んだベンチマークペーパーでは、Quicksortがまだトップにありましたが、MergesortよりもC/C++でもリードがスリムでした。
Mergesortには他にも利点があります。QuicksortのO(n ^ 2)最悪のケースを回避するために微調整する必要はなく、自然に安定しています。そのため、他の要因によりパフォーマンスの狭い差が失われた場合、Mergesortは当然の選択です。
@comingstormの答えはかなり鼻にかかっていると思いますが、GHCのソート機能の歴史に関する詳細情報があります。
Data.OldList
のソースコードでは、 implementation of sort
を見つけて、それがマージソートであることを確認できます。そのファイルの定義のすぐ下に次のコメントがあります。
Quicksort replaced by mergesort, 14/5/2002.
From: Ian Lynagh <[email protected]>
I am curious as to why the List.sort implementation in GHC is a
quicksort algorithm rather than an algorithm that guarantees n log n
time in the worst case? I have attached a mergesort implementation along
with a few scripts to time it's performance...
そのため、元々は機能的なクイックソートが使用されていました(そして関数qsort
はまだ存在していますが、コメント化されています)。 Ianのベンチマークは、彼のマージソートが「ランダムリスト」の場合にクイックソートと競合し、すでにソートされたデータの場合にそれを大幅に上回ることを示しました。その後、そのファイルの追加コメントによると、Ianのバージョンは約2倍の速度の別の実装に置き換えられました。
元のqsort
の主な問題は、ランダムピボットを使用しなかったことです。代わりに、リストの最初の値にピボットしました。ソートされた(またはほぼソートされた)入力のパフォーマンスが最悪の場合(または近い)になることを意味するため、これは明らかにかなり悪いです。残念ながら、「最初にピボット」から代替(ランダム、または実装のように「中間」のどこかに)に切り替えるには、いくつかの課題があります。副作用のない関数型言語では、擬似ランダム入力の管理は少し問題ですが、それを解決するとしましょう(おそらく、乱数ジェネレーターをソート関数に組み込むことによって)。不変のリンクリストを並べ替えるときに、任意のピボットを特定し、それに基づいてパーティション分割を行うと、複数のリストトラバーサルとサブリストコピーが必要になるという問題がまだあります。
クイックソートの想定される利点を実現する唯一の方法は、リストをベクターに書き出し、所定の位置にソートし(そしてソートの安定性を犠牲にして)、リストに書き戻すことだと思います。それが全体的な勝利になるとは思わない。一方、ベクトル内のデータが既にある場合、インプレースクイックソートは間違いなく妥当なオプションです。
単一リンクリストでは、mergesortを適切に実行できます。さらに、単純な実装では、2番目のサブリストの開始を取得するためにリストの半分以上をスキャンしますが、2番目のサブリストの開始は最初のサブリストの並べ替えの副作用として除外され、追加のスキャンは不要です。クイックソートがマージソートを超えていることの1つは、キャッシュの一貫性です。クイックソートは、メモリ内の互いに近い要素で動作します。データ自体の代わりにポインター配列を並べ替えるときのように、間接的な要素がそこに入るとすぐに、その利点は少なくなります。
Mergesortには最悪の場合の動作に対する強い保証があり、それを使用して安定したソートを簡単に行うことができます。
短い答え:
クイックソートは、配列に有利です(インプレース、高速ですが、最悪の場合には最適ではありません)。リンクリストのマージソート(高速、最悪の場合、最適、安定、シンプル)。
リストのクイックソートは遅く、配列の場合はマージソートはインプレースではありません。
QuicksortがHaskellで使用されない理由に関する多くの議論はもっともらしいようです。ただし、ランダムケースの場合、少なくともQuicksortはMergesortより遅くありません。 Richard Birdの本Haskellで機能的に考えるに記載されている実装に基づいて、3方向のクイックソートを作成しました。
tqsort [] = []
tqsort (x:xs) = sortp xs [] [x] []
where
sortp [] us ws vs = tqsort us ++ ws ++ tqsort vs
sortp (y:ys) us ws vs =
case compare y x of
LT -> sortp ys (y:us) ws vs
GT -> sortp ys us ws (y:vs)
_ -> sortp ys us (y:ws) vs
たとえば、Intが0〜10 ^ 3または10 ^ 4などのサイズ10 ^ 4のリストなど、いくつかのケースをベンチマークしました。その結果、データの種類に応じて、3-way QuicksortまたはBirdのバージョンでさえ、GHCのMergesortよりも優れています。次の統計は、 criterion によって生成されます。
benchmarking Data.List.sort/Diverse/10^5
time 223.0 ms (217.0 ms .. 228.8 ms)
1.000 R² (1.000 R² .. 1.000 R²)
mean 226.4 ms (224.5 ms .. 228.3 ms)
std dev 2.591 ms (1.824 ms .. 3.354 ms)
variance introduced by outliers: 14% (moderately inflated)
benchmarking 3-way Quicksort/Diverse/10^5
time 91.45 ms (86.13 ms .. 98.14 ms)
0.996 R² (0.993 R² .. 0.999 R²)
mean 96.65 ms (94.48 ms .. 98.91 ms)
std dev 3.665 ms (2.775 ms .. 4.554 ms)
ただし、Haskellに記載されているsort
には別の要件があります 98 / 201 :stableである必要があります。 Data.List.partition
を使用した典型的なQuicksort実装はstableですが、上記のものはそうではありません。
後で追加する:コメントで言及されている安定した3方向のクイックソートは、ここのtqsort
と同じくらい速いようです。
よくわかりませんが、コードを見ると、Data.List.sort
はMergesortであるとは思いません。 sequences
関数で始まる単一のパスをascending
関数とdescending
関数とともに美しい三角形の相互再帰的な方法で作成し、必要な昇順または降順のチャンクのリストを作成します。注文。その後のみ、マージが開始されます。
それはコーディングにおける詩の現れです。クイックソートとは異なり、その最悪のケース(合計ランダム入力)はO(nlogn)時間の複雑さを持ち、最良のケース(昇順または降順でソート済み)はO(n)です。
他のソートアルゴリズムがこれに勝るものはないと思います。