web-dev-qa-db-ja.com

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

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

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

また、 "Cの真のクイックソート"も含まれます。

// 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バージョンの下のリンクは、と記載されているページに移動します。

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

108
rybosome

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

  1. 分割して征服する:問題を2つの小さな問題に分割します。
  2. 要素をその場で分割します。

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

64
pat

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
55
klapaucius

以下は、「真の」クイックソート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];。これは、可変変数lおよびhにアクセスし、可変配列aにアクセスして、可変配列aを変更します。神聖な突然変異、バットマン! Haskellでは、突然変異と可変変数へのアクセスは明示的です。 「偽物」のqsortはさまざまな理由で魅力的ですが、その主なものは突然変異を使用しないことです。この自主規制により、一目で理解しやすくなります。

29
Dan Burton

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

24
Keith Thompson

この引数を作成しようとしているのは、クイックソートが一般的に使用される理由は、その結果、その場でキャッシュにかなり対応しているからだと思います。 Haskellリストにはこれらの利点がないため、その主な存在理由はなくなり、マージソートを使用することもできます。これによりO(n log n)が保証されますが、 O(n2最悪の場合の実行時間。

15
hammar

遅延評価のおかげで、Haskellプログラムは(ほとんどcant)しません。

このプログラムを検討してください。

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

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

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

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

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

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

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

追伸Haskellコードは、クイックソートと同じ数の比較を行うと、よりクイックソートのようになります。 lessergreaterが個別に計算されるように指定されているため、記述されたコードは2倍の比較を行い、リストを2回線形スキャンします。もちろん、コンパイラーは余分な比較を排除するのに十分なほどスマートであることが原則として可能です。または、コードを変更して Data.List.partition

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

15
Jason Orendorff

ほとんどの人がかなりのHaskellクイックソートが「本当の」クイックソートではないと言う理由は、それがインプレースではないという事実だと思います-明らかに、不変のデータ型を使用するときはそうではありません。しかし、「迅速」ではないという反論もあります。一部は高価な++によるものであり、スペースリークがあるためです-小さい要素で再帰呼び出しを行う間、入力リストに固執します。場合によっては(たとえば、リストが減少しているとき)、これにより2次スペースが使用されます。 (線形空間で実行することは、不変データを使用して「インプレース」に到達できる最も近いと言うかもしれません。)両方の問題に対するきちんとした解決策があります。 Richard BirdのS7.6.1を参照してください Haskell を使用した関数型プログラミングの紹介.

11
Jeremy Gibbons

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

彼らはそれを本当のクイックソートではないと呼んでいます。

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

3
Piotr Praszmo

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

クイックソートの基本バージョン(最も表現力のあるバージョン)を最適化するには、少なくとも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進クイックソート(3分割、BentleyとSedgewickが言及)に最適化します。

    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

3
Larry LIU Xinyu

Haskellでクイックソートを作成するように誰かに依頼すると、本質的に同じプログラムが得られます。これは明らかにクイックソートです。以下にいくつかの長所と短所を示します。

利点:安定していることにより、「真の」クイックソートを改善します。つまり、等しい要素間でシーケンスの順序を保持します。

利点:3分割(<=>)に一般化するのは簡単です。これにより、値がO(n)回発生することによる2次的な動作を回避できます。

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

欠点:より多くのメモリを使用します。

短所:さらにサンプリングしてピボットの選択を一般化すると、特定の低エントロピー順序での2次的な動作を回避できる可能性があります。

1
mercator

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

0
Joshua