it-swarm.com.de

Warum verwendet Haskell Mergesort statt Quicksort?

In Wikibooks 'Haskell gibt es den folgenden Anspruch :

Data.List bietet eine Sortierfunktion zum Sortieren von Listen. Es wird kein Quicksort verwendet. Stattdessen wird eine effiziente Implementierung eines Algorithmus namens Mergesort verwendet.

Was ist der Grund in Haskell, Mergesort über Quicksort zu verwenden? Quicksort hat normalerweise eine bessere praktische Leistung, aber in diesem Fall vielleicht nicht. Ich schätze, dass die In-Place-Vorteile von Quicksort mit Haskell-Listen schwer (unmöglich?) Zu tun sind.

Es gab eine verwandte Frage zu softwareengineering.SE , aber es ging nicht wirklich um warum mergesort.

Ich habe die beiden Sorten selbst für die Profilerstellung implementiert. Mergesort war überlegen (etwa doppelt so schnell für eine Liste von 2 ^ 20 Elementen), aber ich bin mir nicht sicher, ob meine Implementierung von Quicksort optimal war.

Edit: Hier sind meine Implementierungen von Mergesort und 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 [email protected](l:ls) [email protected](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..]

Bearbeiten 2 3: Ich wurde gebeten, Timings für meine Implementierungen gegenüber der Sortierung in Data.List anzugeben. Den Anregungen von @Will Ness folgend, kompilierte ich this Gist mit dem -O2-Flag, änderte jedes Mal die angegebene Sortierung in main und führte sie mit +RTS -s aus. Die sortierte Liste war eine billig erstellte, pseudozufällige [Int]-Liste mit 2 ^ 20 Elementen. Die Ergebnisse waren wie folgt:

  • Data.List.sort: 0,171 s
  • mergesort: 1.092s (~ 6x langsamer als Data.List.sort)
  • quicksort: 1.152s (~ 7x langsamer als Data.List.sort)
61
rwbogl

In imperativen Sprachen wird Quicksort direkt durch Mutieren eines Arrays ausgeführt. Wie Sie in Ihrem Codebeispiel demonstrieren, können Sie Quicksort an eine reine Funktionssprache wie Haskell anpassen, indem Sie stattdessen einfach verknüpfte Listen erstellen. Dies ist jedoch nicht so schnell.

Auf der anderen Seite ist Mergesort kein In-Place-Algorithmus: Eine unkomplizierte, imperative Implementierung kopiert die zusammengeführten Daten in eine andere Zuordnung. Dies ist eine bessere Lösung für Haskell, die die Daten ohnehin kopieren muss.

Lassen Sie uns ein wenig zurückgehen: Die Leistung von Quicksort ist "Wissen" - ein Ruf, der vor Jahrzehnten auf Maschinen aufgebaut wurde, die sich deutlich von den heutigen unterscheiden. Selbst wenn Sie dieselbe Sprache verwenden, muss diese Art von Überlieferung von Zeit zu Zeit überprüft werden, da sich die Fakten vor Ort ändern können. Das letzte Benchmarking-Papier, das ich zu diesem Thema las, hatte Quicksort immer noch oben, aber der Vorsprung vor Mergesort war gering, selbst in C/C++.

Mergesort hat andere Vorteile: Es muss nicht angepasst werden, um den schlimmsten Fall von Quicksort (n ^ 2) zu vermeiden, und es ist von Natur aus stabil. Wenn Sie also den engen Leistungsunterschied aufgrund anderer Faktoren verlieren, ist Mergesort die naheliegende Wahl.

69
comingstorm

Ich denke, @ comingstorms Antwort liegt ziemlich auf der Nase, aber hier sind einige weitere Informationen zur Geschichte der Sortierfunktion von GHC.

Im Quellcode für Data.OldList können Sie die Implementierung von sort finden und sich selbst davon überzeugen, dass es sich um eine Zusammenführungssorte handelt. Direkt unterhalb der Definition in dieser Datei befindet sich folgender Kommentar:

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

Ursprünglich wurde ein funktionaler Quicksort verwendet (und die Funktion qsort ist immer noch vorhanden, jedoch auskommentiert). Die Benchmarks von Ian zeigten, dass sein Mergesort im "Zufallslisten" -Fall mit Quicksort konkurrierte und bei bereits sortierten Daten massiv übertraf. Später wurde Ians Version durch eine andere Implementierung ersetzt, die laut weiteren Kommentaren in dieser Datei etwa doppelt so schnell war.

Das Hauptproblem bei der ursprünglichen qsort war, dass kein zufälliger Pivot verwendet wurde. Stattdessen drehte es um den ersten Wert in der Liste. Dies ist offensichtlich ziemlich schlecht, da dies für die sortierten (oder fast sortierten) Eingaben den schlechtesten Fall (oder das Schließen) bedeutet. Leider gibt es einige Herausforderungen, wenn Sie von "Pivot on first" zu einer Alternative wechseln (entweder zufällig oder - wie in Ihrer Implementierung - irgendwo in "der Mitte"). In einer funktionalen Sprache ohne Nebeneffekte ist das Verwalten einer pseudozufälligen Eingabe ein Problem, aber nehmen wir an, Sie lösen das Problem (indem Sie beispielsweise einen Zufallszahlengenerator in Ihre Sortierfunktion einbauen). Sie haben immer noch das Problem, dass beim Sortieren einer unveränderlichen verknüpften Liste das Auffinden eines beliebigen Pivots und die darauf basierende Partitionierung mehrere Listendurchquerungen und Unterlistenkopien erfordern.

Ich denke, der einzige Weg, die vermeintlichen Vorteile von Quicksort zu realisieren, wäre, die Liste in einen Vektor zu schreiben, an Ort und Stelle zu sortieren (und Sortenstabilität zu opfern) und sie wieder in eine Liste zu schreiben. Ich sehe nicht, dass das jemals ein Gesamtsieg sein könnte. Wenn Sie jedoch bereits Daten in einem Vektor haben, wäre ein In-Place-Quicksort auf jeden Fall eine vernünftige Option.

27
K. A. Buhr

Auf einer einzeln verknüpften Liste kann Mergesort an Ort und Stelle ausgeführt werden. Darüber hinaus scannen naive Implementierungen mehr als die Hälfte der Liste, um den Beginn der zweiten Unterliste zu erhalten. Der Beginn der zweiten Unterliste fällt jedoch als Nebeneffekt beim Sortieren der ersten Unterliste aus und erfordert kein zusätzliches Scannen. Das einzige, was Quicksort über Mergesort hat, ist die Cache-Kohärenz. Quicksort arbeitet mit Elementen, die im Speicher nahe beieinander liegen. Sobald ein Indirektionselement vorhanden ist, z. B. wenn Sie Zeigerarrays anstelle der Daten selbst sortieren, wird dieser Vorteil geringer.

Mergesort bietet harte Garantien für das Worst-Case-Verhalten und es ist leicht, eine stabile Sortierung durchzuführen.

5
user10339366

Kurze Antwort:

Quicksort ist für Arrays von Vorteil (In-Place, schnell, aber nicht optimal). Mergesort für verknüpfte Listen (schnell, Worst-Case-Optimal, stabil, einfach).

Quicksort ist für Listen langsam, Mergesort ist für Arrays nicht vorhanden.

3
Yves Daoust

Viele Argumente, warum Quicksort in Haskell nicht verwendet wird, erscheinen plausibel. Zumindest ist Quicksort für den Zufallsfall jedoch nicht langsamer als Mergesort. Basierend auf der Implementierung in Richard Birds Buch Funktionell in Haskell denken habe ich einen 3-Wege-Quicksort gemacht:

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

Ich überprüfte einige Fälle, z. B. Listen der Größe 10 ^ 4 mit Int zwischen 0 und 10 ^ 3 oder 10 ^ 4 und so weiter. Das Ergebnis ist, dass der 3-Wege-Quicksort oder sogar die Bird-Version besser sind als der Mergesort von GHC. Etwa 1.x ~ 3.x ist schneller als der Mergesort von ghc, abhängig von der Art der Daten (viele Wiederholungen? Sehr spärlich?). Die folgenden Statistiken werden nach Kriterium generiert:

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)

Es gibt jedoch eine weitere Anforderung von sort in Haskell 98 / 2010 : Es muss stable sein. Die typische Quicksort-Implementierung mit Data.List.partition ist stable, die obige jedoch nicht. 


Späterer Zusatz: Ein stabiler 3-Wege-Quicksort, der im Kommentar erwähnt wird, scheint hier so schnell wie tqsort

1
L.-T. Chen

Ich bin nicht sicher, aber wenn ich mir den Code anschaue, glaube ich nicht, dass Data.List.sort Mergesort ist, wie wir ihn kennen. Es führt einfach einen einzigen Durchlauf aus, beginnend mit der Funktion sequences auf eine dreieckige, gegenseitige rekursive dreieckige Funktion mit den Funktionen ascending und descending, um eine Liste bereits auf- oder absteigender geordneter Abschnitte in der erforderlichen Reihenfolge zu erhalten. Erst dann fängt es an zu verschmelzen.

Es ist eine Manifestation von Poesie in der Kodierung. Im Gegensatz zu Quicksort hat der ungünstigste Fall (gesamte zufällige Eingabe) die Zeitkomplexität O(nlogn), und der beste Fall (bereits aufsteigend oder absteigend sortiert) ist O (n).

Ich glaube nicht, dass ein anderer Sortieralgorithmus das schlagen kann.

0
Redu