web-dev-qa-db-ja.com

Haskellがクイックソートの代わりにマージソートを使用するのはなぜですか?

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.171s
  • mergesort:1.092s(Data.List.sortよりも6倍遅い)
  • quicksort:1.152s(Data.List.sortよりも7倍遅い)
62
rwbogl

命令型言語では、配列を変更することにより、クイックソートがインプレースで実行されます。コードサンプルで示すように、代わりに単一リンクリストを作成することで、Haskellのような純粋な関数型言語にQuicksortを適合させることができますが、これはそれほど高速ではありません。

一方、Mergesortはインプレースアルゴリズムではありません。簡単な命令型実装では、マージされたデータを別の割り当てにコピーします。これは、Haskellに適しています。Haskellは、本来、データをコピーする必要があります。

少し戻ってみましょう。QuicksortのパフォーマンスEdgeは「伝承」です。これは、現在使用しているマシンとはかなり異なるマシンで数十年前に確立された評判です。同じ言語を使用している場合でも、この種の伝承では、事実が変わる可能性があるため、時々再確認する必要があります。このトピックで最後に読んだベンチマークペーパーでは、Quicksortがまだトップにありましたが、MergesortよりもC/C++でもリードがスリムでした。

Mergesortには他にも利点があります。QuicksortのO(n ^ 2)最悪のケースを回避するために微調整する必要はなく、自然に安定しています。そのため、他の要因によりパフォーマンスの狭い差が失われた場合、Mergesortは当然の選択です。

70
comingstorm

@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の主な問題は、ランダムピボットを使用しなかったことです。代わりに、リストの最初の値にピボットしました。ソートされた(またはほぼソートされた)入力のパフォーマンスが最悪の場合(または近い)になることを意味するため、これは明らかにかなり悪いです。残念ながら、「最初にピボット」から代替(ランダム、または実装のように「中間」のどこかに)に切り替えるには、いくつかの課題があります。副作用のない関数型言語では、擬似ランダム入力の管理は少し問題ですが、それを解決するとしましょう(おそらく、乱数ジェネレーターをソート関数に組み込むことによって)。不変のリンクリストを並べ替えるときに、任意のピボットを特定し、それに基づいてパーティション分割を行うと、複数のリストトラバーサルとサブリストコピーが必要になるという問題がまだあります。

クイックソートの想定される利点を実現する唯一の方法は、リストをベクターに書き出し、所定の位置にソートし(そしてソートの安定性を犠牲にして)、リストに書き戻すことだと思います。それが全体的な勝利になるとは思わない。一方、ベクトル内のデータが既にある場合、インプレースクイックソートは間違いなく妥当なオプションです。

28
K. A. Buhr

単一リンクリストでは、mergesortを適切に実行できます。さらに、単純な実装では、2番目のサブリストの開始を取得するためにリストの半分以上をスキャンしますが、2番目のサブリストの開始は最初のサブリストの並べ替えの副作用として除外され、追加のスキャンは不要です。クイックソートがマージソートを超えていることの1つは、キャッシュの一貫性です。クイックソートは、メモリ内の互いに近い要素で動作します。データ自体の代わりにポインター配列を並べ替えるときのように、間接的な要素がそこに入るとすぐに、その利点は少なくなります。

Mergesortには最悪の場合の動作に対する強い保証があり、それを使用して安定したソートを簡単に行うことができます。

5
user10339366

短い答え:

クイックソートは、配列に有利です(インプレース、高速ですが、最悪の場合には最適ではありません)。リンクリストのマージソート(高速、最悪の場合、最適、安定、シンプル)。

リストのクイックソートは遅く、配列の場合はマージソートはインプレースではありません。

3
Yves Daoust

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 / 201stableである必要があります。 Data.List.partitionを使用した典型的なQuicksort実装はstableですが、上記のものはそうではありません。


後で追加する:コメントで言及されている安定した3方向のクイックソートは、ここのtqsortと同じくらい速いようです。

1
L.-T. Chen

よくわかりませんが、コードを見ると、Data.List.sortはMergesortであるとは思いません。 sequences関数で始まる単一のパスをascending関数とdescending関数とともに美しい三角形の相互再帰的な方法で作成し、必要な昇順または降順のチャンクのリストを作成します。注文。その後のみ、マージが開始されます。

それはコーディングにおける詩の現れです。クイックソートとは異なり、その最悪のケース(合計ランダム入力)はO(nlogn)時間の複雑さを持ち、最良のケース(昇順または降順でソート済み)はO(n)です。

他のソートアルゴリズムがこれに勝るものはないと思います。

0
Redu