ミニマリストのHaskellクイックソートが「真の」クイックソートではないのはなぜですか?


118

HaskellのWebサイトには、以下に示すように、非常に魅力的な5行のクイックソート機能が導入されています。

quicksort [] = []
quicksort (p:xs) = (quicksort lesser) ++ [p] ++ (quicksort greater)
    where
        lesser = filter (< p) xs
        greater = filter (>= p) xs

また、「CでのTrueクイックソート」も含まれています。

// To sort array a[] of size n: qsort(a,0,n-1)

void qsort(int a[], int lo, int hi) 
{
  int h, l, p, t;

  if (lo < hi) {
    l = lo;
    h = hi;
    p = a[hi];

    do {
      while ((l < h) && (a[l] <= p)) 
          l = l+1;
      while ((h > l) && (a[h] >= p))
          h = h-1;
      if (l < h) {
          t = a[l];
          a[l] = a[h];
          a[h] = t;
      }
    } while (l < h);

    a[hi] = a[l];
    a[l] = p;

    qsort( a, lo, l-1 );
    qsort( a, l+1, hi );
  }
}

Cバージョンの下のリンクは、「はじめにで引用されたクイックソートは「実際の」クイックソートではなく、cコードのように長いリストに合わせてスケーリングされない」というページにリンクしています。

上記のHaskell関数が真のクイックソートではないのはなぜですか?より長いリストのスケーリングにどのように失敗しますか?


あなたが話している正確なページへのリンクを追加する必要があります。
Staven

14
インプレースではないので、かなり遅いですか?いい質問だね!
fuz

4
@FUZxxl:Haskellリストは不変であるため、デフォルトのデータ型を使用している間、操作は実行されません。速度については、必ずしも遅くなるとは限りません。GHCは素晴らしいコンパイラテクノロジーであり、不変のデータ構造を使用するhaskellソリューションは、他の言語の他の可変なものと同じくらい高速です。
Callum Rogers

1
それは実際にはqsortではありませんか?qsortにはO(N^2)ランタイムがあることに注意してください。
Thomas Eding、2011年

2
上記の例はHaskellの導入例であり、クイックソートはリストのソートには非常に悪い選択です。Data.Listのソートは、2002年にmergesortに変更されました:hackage.haskell.org/packages/archive/base/3.0.3.1/doc/html/src/…、以前のクイックソートの実装も確認できます。現在の実装は2009年に作成されたマージソートです:hackage.haskell.org/packages/archive/base/4.4.0.0/doc/html/src/…
HaskellElephant

回答:


75

真のクイックソートには2つの美しい側面があります。

  1. 分割して征服する:問題を2つの小さな問題に分割します。
  2. 要素をインプレースで分割します。

短いHaskellの例は(1)を示していますが(2)は示していません。(2)がどのように行われるかは、テクニックをまだ知らない場合は明らかではないかもしれません!



パーティション分割プロセスの明確な説明については、interactivepython.org /courselib/static/pythonds/SortSearch/…を参照してください。
pvillela 2017

57

Haskellの真のインプレースクイックソート:

import qualified Data.Vector.Generic as V 
import qualified Data.Vector.Generic.Mutable as M 

qsort :: (V.Vector v a, Ord a) => v a -> v a
qsort = V.modify go where
    go xs | M.length xs < 2 = return ()
          | otherwise = do
            p <- M.read xs (M.length xs `div` 2)
            j <- M.unstablePartition (< p) xs
            let (l, pr) = M.splitAt j xs 
            k <- M.unstablePartition (== p) pr
            go l; go $ M.drop k pr

ソースunstablePartitionは、それは(私の知る限り)実際にインプレース技術を交換し、同じであることが明らかになりました。
Dan Burton

3
このソリューションは正しくありません。unstablePartitionpartitionfor と非常に似ていますがquicksort、これはm位置の要素がであることを保証するものではありませんp
nymk 2013年

29

これは、「真の」クイックソートCコードのHaskellへの音訳です。あなた自身を支えます。

import Control.Monad
import Data.Array.IO
import Data.IORef

qsort :: IOUArray Int Int -> Int -> Int -> IO ()
qsort a lo hi = do
  (h,l,p,t) <- liftM4 (,,,) z z z z

  when (lo < hi) $ do
    l .= lo
    h .= hi
    p .=. (a!hi)

    doWhile (get l .< get h) $ do
      while ((get l .< get h) .&& ((a.!l) .<= get p)) $ do
        modifyIORef l succ
      while ((get h .> get l) .&& ((a.!h) .>= get p)) $ do
        modifyIORef h pred
      b <- get l .< get h
      when b $ do
        t .=. (a.!l)
        lVal <- get l
        hVal <- get h
        writeArray a lVal =<< a!hVal
        writeArray a hVal =<< get t

    lVal <- get l
    writeArray a hi =<< a!lVal
    writeArray a lVal =<< get p

    hi' <- fmap pred (get l)
    qsort a lo hi'
    lo' <- fmap succ (get l)
    qsort a lo' hi

楽しかったですね。関数letの最初とwhere最後にこれを実際に切り取って、前のコードをいくらかきれいにするためにすべてのヘルパーを定義しました。

  let z :: IO (IORef Int)
      z = newIORef 0
      (.=) = writeIORef
      ref .=. action = do v <- action; ref .= v
      (!) = readArray
      (.!) a ref = readArray a =<< get ref
      get = readIORef
      (.<) = liftM2 (<)
      (.>) = liftM2 (>)
      (.<=) = liftM2 (<=)
      (.>=) = liftM2 (>=)
      (.&&) = liftM2 (&&)
  -- ...
  where doWhile cond foo = do
          foo
          b <- cond
          when b $ doWhile cond foo
        while cond foo = do
          b <- cond
          when b $ foo >> while cond foo

そして、ここでは、それが機能するかどうかを確認するためのダムテスト。

main = do
    a <- (newListArray (0,9) [10,9..1]) :: IO (IOUArray Int Int)
    printArr a
    putStrLn "Sorting..."
    qsort a 0 9
    putStrLn "Sorted."
    printArr a
  where printArr a = mapM_ (\x -> print =<< readArray a x) [0..9]

私はHaskellで命令型コードを頻繁に記述しないので、このコードをクリーンアップする方法はたくさんあると思います。

だから何?

上記のコードが非常に長いことに気づくでしょう。その中心はCコードとほぼ同じですが、各行は多くの場合少し冗長です。これは、Cがあなたが当たり前だと思うかもしれない多くの厄介なことを密かに行うためです。たとえば、a[l] = a[h];。これは、可変変数land hにアクセスし、次に可変配列aにアクセスして、可変配列を変更しaます。聖なる突然変異、バットマン!Haskellでは、変更と可変変数へのアクセスは明示的です。"偽の" qsortはさまざまな理由で魅力的ですが、その中でも主なものは、変異を使用しないことです。この自主規制により、一目で理解しやすくなります。


3
それは、一種の馬鹿げたやり方で素晴らしいです。GHCはそのようなものからどのようなコードを生成するのでしょうか?
Ian Ross

@IanRoss:不純なクイックソートから?GHCは実際にはかなりまともなコードを生成します。
JDは

「「偽の」qsortはさまざまな理由で魅力的です...」(既に述べたように)インプレース操作なしでのパフォーマンスはひどいと思います。また、常に最初の要素をピボットとして使用しても効果はありません。
dbaltor

25

私の意見では、それは「真のクイックソートではない」と言うことはケースを誇張します。これはQuicksortアルゴリズムの有効な実装であり、特に効率的な実装ではないと思います。


9
私はかつて誰かとこの議論をしました:QuickSortを指定した実際の論文を調べたところ、確かにその場にありました。
ivanm 2011年

2
@ivanmハイパーリンクまたはそれが発生しませんでした:)
Dan Burton

1
ALGOLの(現在人気のある)再帰バージョンは脚注にすぎませんが、このペーパーがすべて必須であり、(多くの人が知らない)対数スペースの使用を保証するためのトリックも含まれています。今、他の論文を探す必要があると思います... :)
hugomg

6
アルゴリズムの「有効な」実装は、同じ漸近的境界を持つ必要がありますね。粗悪なHaskellクイックソートは、元のアルゴリズムのメモリの複雑さを保持しません。程遠い。そのため、CのSedgewickの純正Quicksortよりも1,000倍以上遅い
JD

16

この議論がしようとしているのは、クイックソートが一般的に使用されている理由は、結果としてその場所にあり、かなりキャッシュに優しいからだと思います。Haskellリストではこれらの利点がないため、その主な存在理由はなくなり、O(n log n)を保証するマージソートを使用することもできますが、クイックソートではランダム化または複雑な方法を使用する必要があります最悪の場合のO(n 2実行時間を回避するための分割スキーム。


5
そして、Mergesortは、(不変の)いいねリストのより自然なソートアルゴリズムで、補助配列を操作する必要がありません。
hugomg 2011年

16

遅延評価のおかげで、Haskellプログラムは、そのように見えることを実行しません(ほとんど実行できません)。

このプログラムを考えてみましょう:

main = putStrLn (show (quicksort [8, 6, 7, 5, 3, 0, 9]))

熱心な言語では、最初quicksortに実行され、次にshow、次にputStrLn。関数の引数は、その関数が実行を開始する前に計算されます。

Haskellでは、それは逆です。関数が最初に実行を開始します。引数は、関数が実際に使用するときにのみ計算されます。そして、リストのような複合引数は、それが使用されるときに一度に1つずつ計算されます。

したがって、このプログラムで最初に発生するのは、putStrLn実行を開始することです。

GHCの実装はputStrLn、引数ストリングの文字を出力バッファーにコピーすることによって機能します。ただし、このループに入ると、showまだ実行されていません。したがって、文字列から最初の文字をコピーする場合、Haskellはの割合を評価し、その文字の計算に必要な呼び出しをshow行いquicksortます。次にputStrLn、次の文字に移動します。すべての3つのfunctions-の実行は、そうputStrLnshowquicksort-インターリーブされます。quicksortインクリメンタルに実行され、中断された場所を記憶するために、未評価のサンクのグラフを残します。

これは、これまでに他のプログラミング言語に精通している場合に予想されるものとは大きく異なります。quicksortHaskellで実際にどのように動作するかをメモリアクセスや比較の順序で視覚化することは簡単ではありません。ソースコードではなく、動作のみを観察できた場合、それが何をしているのかをクイックソートとして認識できません

たとえば、Cバージョンのクイックソートは、最初の再帰呼び出しの前にすべてのデータを分割します。Haskellバージョンでは、結果の最初の要素は、最初のパーティションの実行が完了する前に、実際にはで作業が行われる前に計算されます(画面に表示されることさえあります)greater

PS Haskellコードは、クイックソートと同じ数の比較を行った場合、よりクイックソートのようになります。記述されたコードは2倍の比較を行います。lessergreater、リストを介して2回のリニアスキャンを行う、独立して計算されるように指定されています。もちろん、原則として、コンパイラーが余分な比較を排除するのに十分なほどスマートになることは可能です。または、コードを変更して使用することができますData.List.partition

PPS Haskellアルゴリズムの典型的な例は、期待どおりに動作しないことが判明したため、素数を計算するためのエラトステネスふるいです


2
lpaste.net/108190。-「森林破壊されたツリーソート」を実行しています。古いredditスレッドがあります。cf. stackoverflow.com/questions/14786904/…および関連しています。
Will Ness

1
ルックスはい、それはプログラムが実際に何をするかのかなり良い特性です。
Jason Orendorff 2014

ふるいの発言について、それが同等のものとして書かれていればprimes = unfoldr (\(p:xs)-> Just (p, filter ((> 0).(`rem` p)) xs)) [2..]その最も直接的な問題はおそらくより明確になるでしょう。そして、それは、本当のふるいアルゴリズムへの切り替えを検討する前です。
ネスは

「あなたのコードはどのように見えるのか」というあなたの定義に混乱しています。あなたのコードはputStrLn、サンクされたアプリケーションshowからサンクされたアプリケーションをquicksortリストリテラルに呼び出すように私に「見えます」---そして、それはまさにそれが何をするかです!(最適化前--- Cコードを最適化されたアセンブラと比較してみてください!)たぶん、「遅延評価のおかげで、Haskellプログラムは、他の言語で見た目が似ているコードと同じことをしない」という意味でしょうか?
ジョナサンキャスト

4
@jcast私はこの点でCとHaskellの間に実際的な違いがあると思います。この種の話題についてコメントスレッドで楽しい討論を続けるのは本当に難しいですが、実際にコーヒーを飲みながらやりたいのです。ナッシュビルに1時間余裕があるかどうか教えてください。
Jason Orendorff

12

ほとんどの人がかなりのHaskell Quicksortが「本当の」Quicksortでないと言う理由は、それがインプレースではないという事実です。しかし、それが「迅速」ではないという異論もあります。高額な++が原因であり、スペースリークもあるためです。少ない要素で再帰呼び出しを実行しているときに、入力リストを待機します。場合によっては(リストが減少している場合など)、これにより2次スペースが使用されます。(線形空間で実行することが、不変データを使用して「インプレース」に到達できる最も近いと言えるかもしれません。)累積パラメーター、タプリング、フュージョンを使用することで、両方の問題に対して適切な解決策があります。Richard BirdのS7.6.1を参照してください。


4

純粋に機能的な設定で要素をインプレースで変更するという考えではありません。可変配列を持つこのスレッドの代替メソッドは、純粋さの精神を失いました。

クイックソートの基本バージョン(最も表現力の高いバージョン)を最適化するには、少なくとも2つの手順があります。

  1. アキュムレータによる線形演算である連結(++)を最適化します。

    qsort xs = qsort' xs []
    
    qsort' [] r = r
    qsort' [x] r = x:r
    qsort' (x:xs) r = qpart xs [] [] r where
        qpart [] as bs r = qsort' as (x:qsort' bs r)
        qpart (x':xs') as bs r | x' <= x = qpart xs' (x':as) bs r
                               | x' >  x = qpart xs' as (x':bs) r
  2. 重複要素を処理するために、3値クイックソート(BentleyとSedgewickによって言及された3ウェイパーティション)に最適化します。

    tsort :: (Ord a) => [a] -> [a]
    tsort [] = []
    tsort (x:xs) = tsort [a | a<-xs, a<x] ++ x:[b | b<-xs, b==x] ++ tsort [c | c<-xs, c>x]
  3. 2と3を組み合わせて、Richard Birdの本を参照してください。

    psort xs = concat $ pass xs []
    
    pass [] xss = xss
    pass (x:xs) xss = step xs [] [x] [] xss where
        step [] as bs cs xss = pass as (bs:pass cs xss)
        step (x':xs') as bs cs xss | x' <  x = step xs' (x':as) bs cs xss
                                   | x' == x = step xs' as (x':bs) cs xss
                                   | x' >  x = step xs' as bs (x':cs) xss

または、複製された要素が過半数ではない場合:

    tqsort xs = tqsort' xs []

    tqsort' []     r = r
    tqsort' (x:xs) r = qpart xs [] [x] [] r where
        qpart [] as bs cs r = tqsort' as (bs ++ tqsort' cs r)
        qpart (x':xs') as bs cs r | x' <  x = qpart xs' (x':as) bs cs r
                                  | x' == x = qpart xs' as (x':bs) cs r
                                  | x' >  x = qpart xs' as bs (x':cs) r

残念ながら、3つの中央値を同じ効果で実装することはできません。次に例を示します。

    qsort [] = []
    qsort [x] = [x]
    qsort [x, y] = [min x y, max x y]
    qsort (x:y:z:rest) = qsort (filter (< m) (s:rest)) ++ [m] ++ qsort (filter (>= m) (l:rest)) where
        xs = [x, y, z]
        [s, m, l] = [minimum xs, median xs, maximum xs] 

次の4つのケースではパフォーマンスが低下するためです。

  1. [1、2、3、4、...、n]

  2. [n、n-1、n-2、...、1]

  3. [m-1、m-2、... 3、2、1、m + 1、m + 2、...、n]

  4. [n、1、n-1、2、...]

これらの4つのケースはすべて、必須の3つの中央値アプローチによって適切に処理されます。

実際、純粋に機能的な設定に最も適したソートアルゴリズムは、マージソートですが、クイックソートではありません。

詳細については、https//sites.google.com/site/algoxy/dcsortで執筆中の記事を ご覧ください。


見逃した別の最適化があります。2つのフィルターの代わりにパーティションを使用してサブリストを作成します(または同様の内部関数をフォルダーで処理して3つのサブリストを作成します)。
ジェレミーリスト14

3

何が真で何が真のクイックソートではないのか、明確な定義はありません。

インプレースでソートされないため、彼らはそれを本当のクイックソートではないと呼んでいます:

Cの真のクイックソートはインプレースでソートされます


-1

リストから最初の要素を取得すると、ランタイムが非常に悪くなるためです。中央値3を使用:最初、中間、最後。


2
リストがランダムな場合、最初の要素を取得しても問題ありません。
キーストンプソン、

2
しかし、ソートされたリストまたはほぼソートされたリストのソートは一般的です。
ジョシュア

7
しかしqsort IS O(n^2)
Thomas Eding、2011年

8
qsortは平均n log n、最悪n ^ 2です。
ジョシュア

3
技術的には、入力が既にソートされているか、ほとんどソートされていない限り、ランダムな値を選択するよりも悪くはありません。不良ピボットは、中央値から離れたピボットです。最初の要素は、それが最小値または最大値に近い場合にのみ悪いピボットです。
プラチナAzure

-1

Haskellでクイックソートを書くように誰かに頼んでください、そうすれば本質的に同じプログラムを手に入れるでしょう-それは明らかにクイックソートです。いくつかの長所と短所があります。

長所:安定していることにより、 "真の"クイックソートが改善されます。つまり、等しい要素間のシーケンス順序が保持されます。

長所:O(n)回発生するいくつかの値による2次の動作を回避する3分割(<=>)に一般化するのは簡単です。

長所:フィルターの定義を含める必要がある場合でも、読みやすくなります。

短所:メモリをより多く使用します。

欠点:特定の低エントロピー順序での2次の動作を回避できる可能性がある、さらなるサンプリングによってピボットの選択を一般化することはコストがかかります。

弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.