Warum ist der minimalistische Haskell-Quicksort kein „echter“ Quicksort?


118

Auf der Haskell-Website wird eine sehr attraktive 5-Zeilen- Quicksort-Funktion vorgestellt (siehe unten).

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

Sie enthalten auch eine "True Quicksort in 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 );
  }
}

Ein Link unter der C-Version verweist auf eine Seite mit der Angabe "Der in Einführung zitierte Quicksort ist nicht der" echte "Quicksort und lässt sich nicht wie der C-Code für längere Listen skalieren."

Warum ist die obige Haskell-Funktion keine echte Quicksortierung? Wie kann es nicht für längere Listen skaliert werden?


Sie sollten einen Link zu der genauen Seite hinzufügen, über die Sie sprechen.
Staven

14
Es ist nicht vorhanden, also ziemlich langsam? Gute Frage eigentlich!
Fuz

4
@FUZxxl: Haskell-Listen sind unveränderlich, sodass bei Verwendung der Standarddatentypen keine Operation ausgeführt wird. Die Geschwindigkeit wird nicht unbedingt langsamer sein. GHC ist ein beeindruckendes Stück Compilertechnologie, und sehr oft sind Haskell-Lösungen, die unveränderliche Datenstrukturen verwenden, mit anderen veränderlichen in anderen Sprachen auf dem neuesten Stand.
Callum Rogers

1
Ist es eigentlich nicht qsort? Denken Sie daran, dass qsort O(N^2)Laufzeit hat.
Thomas Eding

2
Es ist zu beachten, dass das obige Beispiel ein einführendes Beispiel für Haskell ist und dass Quicksort eine sehr schlechte Wahl für das Sortieren von Listen ist. Die Sortierung in Data.List wurde bereits 2002 in Mergesort geändert: hackage.haskell.org/packages/archive/base/3.0.3.1/doc/html/src/… . Dort können Sie auch die vorherige schnelle Sortierimplementierung sehen. Die aktuelle Implementierung ist eine Zusammenführung, die 2009 durchgeführt wurde: hackage.haskell.org/packages/archive/base/4.4.0.0/doc/html/src/… .
HaskellElephant

Antworten:


75

Die wahre Quicksort hat zwei schöne Aspekte:

  1. Teilen und erobern: Teilen Sie das Problem in zwei kleinere Probleme auf.
  2. Partitionieren Sie die Elemente an Ort und Stelle.

Das kurze Haskell-Beispiel zeigt (1), aber nicht (2). Wie (2) gemacht wird, ist möglicherweise nicht offensichtlich, wenn Sie die Technik noch nicht kennen!



Eine klare Beschreibung des Prozesses der Partitionierung an Ort und Stelle finden Sie unteractivepython.org/courselib/static/pythonds/SortSearch/… .
Pvillela

57

Echte Inplace-Quicksort in 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

Die Quelle für unstablePartition zeigt, dass es sich tatsächlich um dieselbe In-Place-Swap-Technik handelt (soweit ich das beurteilen kann).
Dan Burton

3
Diese Lösung ist falsch. unstablePartitionist sehr ähnlich zu partitionfor quicksort, garantiert jedoch nicht, dass das Element an mder Position gerecht ist p.
Nymk

29

Hier ist eine Transliteration des "wahren" Quicksort-C-Codes in Haskell. Mach dich bereit.

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

Das hat Spaß gemacht, nicht wahr? Ich habe diese Größe sowohl letam Anfang als auch wheream Ende der Funktion ausgeschnitten und alle Helfer definiert, um den vorhergehenden Code etwas hübsch zu machen.

  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

Und hier ein dummer Test, um zu sehen, ob es funktioniert.

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]

Ich schreibe nicht sehr oft imperativen Code in Haskell, daher gibt es sicher viele Möglichkeiten, diesen Code zu bereinigen.

Na und?

Sie werden feststellen, dass der obige Code sehr, sehr lang ist. Das Herzstück ist ungefähr so ​​lang wie der C-Code, obwohl jede Zeile oft etwas ausführlicher ist. Dies liegt daran, dass C heimlich viele böse Dinge tut, die Sie für selbstverständlich halten könnten. Zum Beispiel a[l] = a[h];. Dies greift auf die veränderlichen Variablen lund zu hund greift dann auf das veränderbare Array zu aund mutiert dann das veränderbare Array a. Heilige Mutation, Batman! In Haskell ist die Mutation und der Zugriff auf veränderbare Variablen explizit. Das "gefälschte" Qsort ist aus verschiedenen Gründen attraktiv, aber das Wichtigste unter ihnen ist, dass es keine Mutation verwendet. Diese selbst auferlegte Einschränkung erleichtert das Verständnis auf einen Blick.


3
Das ist großartig, auf eine Art mulmige Art und Weise. Ich frage mich, welche Art von Code GHC aus so etwas erzeugt.
Ian Ross

@ IanRoss: Aus dem unreinen Quicksort? GHC produziert tatsächlich ziemlich anständigen Code.
JD

"Die" gefälschte "qsort ist aus verschiedenen Gründen attraktiv ..." Ich befürchte, dass ihre Leistung ohne Manipulation an Ort und Stelle (wie bereits erwähnt) schrecklich wäre. Und es hilft auch nicht, immer das 1. Element als Drehpunkt zu nehmen.
dbaltor

25

Meiner Meinung nach übertreibt die Aussage, dass es sich nicht um eine echte Quicksortierung handelt, den Fall. Ich denke, es ist eine gültige Implementierung des Quicksort-Algorithmus , nur keine besonders effiziente.


9
Ich hatte dieses Argument einmal mit jemandem: Ich habe das eigentliche Papier nachgeschlagen, in dem QuickSort angegeben ist, und es ist tatsächlich vorhanden.
ivanm

2
@ivanm Hyperlinks oder es ist nicht passiert :)
Dan Burton

1
Ich finde es toll, dass dieses Papier unbedingt erforderlich ist und sogar den Trick enthält, die logarithmische Raumnutzung zu gewährleisten (von der viele Menschen nichts wissen), während die (jetzt beliebte) rekursive Version in ALGOL nur eine Fußnote ist. Ich denke, ich muss jetzt nach dem anderen Papier suchen ... :)
hugomg

6
Eine "gültige" Implementierung eines Algorithmus sollte die gleichen asymptotischen Grenzen haben, finden Sie nicht? Der bastardisierte Haskell-Quicksort bewahrt nicht die Speicherkomplexität des ursprünglichen Algorithmus. Nicht annähernd. Deshalb ist es über 1.000x langsamer als Sedgewicks echtes Quicksort in C.
JD

16

Ich denke, der Fall, den dieses Argument anstrebt, ist, dass der Grund, warum Quicksort häufig verwendet wird, darin besteht, dass es vorhanden und daher ziemlich cachefreundlich ist. Da Sie diese Vorteile bei Haskell-Listen nicht haben, ist die Hauptaufgabe weg, und Sie können auch die Zusammenführungssortierung verwenden, die O (n log n) garantiert , während Sie bei Quicksort entweder Randomisierung oder Kompliziertheit verwenden müssen Partitionierungsschemata zur Vermeidung der Laufzeit von O (n 2 ) im schlimmsten Fall.


5
Und Mergesort ist ein viel natürlicherer Sortieralgorithmus für (unveränderliche) beliebte Listen, bei dem es nicht mehr erforderlich ist, mit Hilfsarrays zu arbeiten.
Hugomg

16

Dank der verzögerten Auswertung kann ein Haskell-Programm nicht (fast nicht ) das tun, wie es aussieht.

Betrachten Sie dieses Programm:

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

In einer eifrigen Sprache quicksortwürde dann zuerst laufen show, dann putStrLn. Die Argumente einer Funktion werden berechnet, bevor diese Funktion ausgeführt wird.

In Haskell ist es umgekehrt. Die Funktion wird zuerst ausgeführt. Die Argumente werden nur berechnet, wenn die Funktion sie tatsächlich verwendet. Und ein zusammengesetztes Argument wird wie eine Liste Stück für Stück berechnet, da jedes Stück davon verwendet wird.

Das erste , was in diesem Programm passiert, ist, dass es gestartet wird putStrLn.

Die Implementierung von GHCputStrLn funktioniert durch Kopieren der Zeichen des Arguments String in einen Ausgabepuffer. Aber wenn es in diese Schleife eintritt, showist es noch nicht gelaufen. Wenn das erste Zeichen aus der Zeichenfolge kopiert wird, wertet Haskell daher den Bruchteil der showund quicksortAufrufe aus, die zur Berechnung dieses Zeichens erforderlich sind . Dann putStrLnbewegt sich auf das nächste Zeichen. Die Ausführung aller drei Funktionen putStrLn- show, und quicksort- ist also verschachtelt. quicksortwird schrittweise ausgeführt und hinterlässt ein Diagramm mit nicht bewerteten Thunks, um sich daran zu erinnern, wo es aufgehört hat.

Dies unterscheidet sich grundlegend von dem, was Sie erwarten könnten, wenn Sie mit einer anderen Programmiersprache vertraut sind. Es ist nicht einfach zu visualisieren, wie quicksortsich Haskell in Bezug auf Speicherzugriffe oder sogar die Reihenfolge der Vergleiche tatsächlich verhält. Wenn Sie nur das Verhalten und nicht den Quellcode beobachten könnten, würden Sie nicht erkennen, was es als Quicksort tut .

Beispielsweise partitioniert die C-Version von Quicksort alle Daten vor dem ersten rekursiven Aufruf. In der Haskell-Version wird das erste Element des Ergebnisses berechnet (und kann sogar auf Ihrem Bildschirm angezeigt werden), bevor die erste Partition ausgeführt wird - tatsächlich bevor überhaupt daran gearbeitet wird greater.

PS Der Haskell-Code wäre schneller, wenn er die gleiche Anzahl von Vergleichen wie Quicksort durchführen würde. der Code geschrieben hat doppelt so viele Vergleiche da lesserund greaterspezifiziert unabhängig berechnet werden, zwei lineare Scans durch die Liste zu tun. Natürlich ist es im Prinzip möglich, dass der Compiler intelligent genug ist, um die zusätzlichen Vergleiche zu eliminieren. oder der Code könnte geändert werden, um zu verwenden Data.List.partition.

PPS Das klassische Beispiel für Haskell-Algorithmen, die sich nicht so verhalten, wie Sie es erwartet haben, ist das Eratosthenes-Sieb für die Berechnung von Primzahlen.


2
lpaste.net/108190 . - Es macht die "abgeholzte Baumsorte", es gibt einen alten reddit Thread darüber. vgl. stackoverflow.com/questions/14786904/… und verwandte.
Will Ness

1
sieht aus Ja, das ist eine ziemlich gute Charakterisierung dessen, was das Programm tatsächlich tut.
Jason Orendorff

re das Sieb Bemerkung, wurden sie als Äquivalent geschrieben primes = unfoldr (\(p:xs)-> Just (p, filter ((> 0).(`rem` p)) xs)) [2..], seine unmittelbarste Problem wäre vielleicht klarer. Und das ist, bevor wir überlegen, auf den echten Siebalgorithmus umzusteigen.
Will Ness

Ich bin verwirrt über Ihre Definition, welcher Code "so aussieht". Ihr Code "sieht" für mich so aus, als würde er putStrLneine Thunked-Anwendung showauf eine Thunked-Anwendung auf ein Listenliteral aufrufen quicksort- und genau das tut er! (vor der Optimierung --- aber vergleichen Sie den C-Code irgendwann mit dem optimierten Assembler!). Vielleicht meinen Sie "dank der verzögerten Auswertung macht ein Haskell-Programm nicht das, was ähnlich aussehender Code in anderen Sprachen macht"?
Jonathan Cast

4
@jcast Ich denke, es gibt in dieser Hinsicht einen praktischen Unterschied zwischen C und Haskell. Es ist wirklich schwer, eine angenehme Debatte über diese Art von Thema in einem Kommentarthread zu führen, so sehr ich es gerne im wirklichen Leben beim Kaffee trinken würde. Lassen Sie mich wissen, ob Sie jemals eine Stunde Zeit in Nashville haben!
Jason Orendorff

12

Ich glaube, der Grund, warum die meisten Leute sagen, dass das hübsche Haskell Quicksort kein "echtes" Quicksort ist, ist die Tatsache, dass es nicht vorhanden ist - klar, es kann nicht sein, wenn unveränderliche Datentypen verwendet werden. Es gibt aber auch den Einwand, dass es nicht "schnell" ist: teilweise wegen des teuren ++ und auch wegen eines Speicherplatzlecks - Sie halten an der Eingabeliste fest, während Sie den rekursiven Aufruf für die kleineren Elemente ausführen, und In einigen Fällen - z. B. wenn die Liste abnimmt - führt dies zu einer quadratischen Raumnutzung. (Man könnte sagen, dass es im linearen Raum am ehesten "an Ort und Stelle" ist, wenn unveränderliche Daten verwendet werden.) Für beide Probleme gibt es gute Lösungen: Akkumulieren von Parametern, Tupeln und Verschmelzen. siehe S7.6.1 von Richard Bird '


4

Es ist nicht die Idee, Elemente in rein funktionalen Umgebungen zu mutieren. Die alternativen Methoden in diesem Thread mit veränderlichen Arrays haben den Geist der Reinheit verloren.

Es gibt mindestens zwei Schritte, um die Basisversion (die ausdrucksstärkste Version) der Schnellsortierung zu optimieren.

  1. Optimieren Sie die Verkettung (++), eine lineare Operation, durch Akkumulatoren:

    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. Optimieren Sie auf ternäre schnelle Sortierung (3-Wege-Partition, von Bentley und Sedgewick erwähnt), um doppelte Elemente zu verarbeiten:

    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. Kombinieren Sie 2 und 3, siehe Richard Birds Buch:

    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

Oder alternativ, wenn die duplizierten Elemente nicht die Mehrheit sind:

    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

Leider kann der Median-of-Three nicht mit demselben Effekt implementiert werden, zum Beispiel:

    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] 

weil es in den folgenden 4 Fällen immer noch schlecht abschneidet:

  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, ...]

Alle diese 4 Fälle werden durch den imperativen Median-of-Three-Ansatz gut behandelt.

Tatsächlich ist der am besten geeignete Sortieralgorithmus für eine rein funktionale Einstellung immer noch das Zusammenführen, aber nicht das schnelle Sortieren.

Weitere Informationen finden Sie in meinem laufenden Artikel unter: https://sites.google.com/site/algoxy/dcsort


Es gibt noch eine weitere Optimierung, die Sie verpasst haben: Verwenden Sie Partition anstelle von 2 Filtern, um die Unterlisten zu erstellen (oder foldr auf einer ähnlichen inneren Funktion, um 3 Unterlisten zu erstellen).
Jeremy List

3

Es gibt keine klare Definition dessen, was ein echter Quicksort ist und was nicht.

Sie nennen es keine echte Quicksortierung, weil es nicht an Ort und Stelle sortiert wird:

Echte Quicksortierung in C-Sortierungen an Ort und Stelle


-1

Weil das erste Element aus der Liste zu einer sehr schlechten Laufzeit führt. Verwenden Sie den Median 3: zuerst, mittel, zuletzt.


2
Das erste Element zu nehmen ist in Ordnung, wenn die Liste zufällig ist.
Keith Thompson

2
Das Sortieren einer sortierten oder fast sortierten Liste ist jedoch üblich.
Joshua

7
Aber qsort IS O(n^2)
Thomas Eding

8
qsort ist der Durchschnitt n log n, der schlechteste n ^ 2.
Joshua

3
Technisch gesehen ist es nicht schlimmer als einen zufälligen Wert auszuwählen, es sei denn, die Eingabe ist bereits sortiert oder fast sortiert. Schlechte Drehpunkte sind die Drehpunkte, die vom Median entfernt sind. Das erste Element ist nur dann ein schlechter Drehpunkt, wenn es nahe am Minimum oder Maximum liegt.
Platinum Azure

-1

Wenn Sie jemanden bitten, in Haskell Quicksort zu schreiben, erhalten Sie im Wesentlichen das gleiche Programm - es ist offensichtlich Quicksort. Hier einige Vor- und Nachteile:

Pro: Es verbessert die "echte" Quicksortierung, indem es stabil ist, dh es behält die Sequenzreihenfolge zwischen gleichen Elementen bei.

Pro: Es ist trivial, auf eine Drei-Wege-Aufteilung (<=>) zu verallgemeinern, die ein quadratisches Verhalten aufgrund eines Wertes vermeidet, der O (n) mal auftritt.

Pro: Es ist einfacher zu lesen - auch wenn man die Definition des Filters einschließen musste.

Con: Es verwendet mehr Speicher.

Con: Es ist kostspielig, die Auswahl des Pivots durch weitere Abtastung zu verallgemeinern, wodurch ein quadratisches Verhalten bei bestimmten Ordnungen mit niedriger Entropie vermieden werden könnte.

Durch die Nutzung unserer Website bestätigen Sie, dass Sie unsere Cookie-Richtlinie und Datenschutzrichtlinie gelesen und verstanden haben.
Licensed under cc by-sa 3.0 with attribution required.