Dans Wikibooks 'Haskell , il y a la revendication suivante :
Data.List offre une fonction de tri pour trier les listes. Il n'utilise pas quicksort; au lieu de cela, il utilise une implémentation efficace d'un algorithme appelé mergesort.
Quelle est la raison sous-jacente dans Haskell d'utiliser mergesort sur quicksort? Quicksort a généralement de meilleures performances pratiques, mais peut-être pas dans ce cas. Je suppose que les avantages en place du tri rapide sont difficiles (impossibles?) À faire avec les listes Haskell.
Il y avait une question connexe sur softwareengineering.SE , mais il ne s'agissait pas vraiment de pourquoi mergesort est utilisé.
J'ai mis en œuvre les deux types moi-même pour le profilage. Mergesort était supérieur (environ deux fois plus rapide pour une liste de 2 ^ 20 éléments), mais je ne suis pas sûr que mon implémentation de quicksort était optimale.
Edit: Voici mes implémentations de mergesort et 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..]
Modifier 2 3: On m'a demandé d'indiquer le minutage de mes implémentations par rapport au tri dans Data.List
. Suivant les suggestions de @Will Ness, j'ai compilé this Gist avec l'indicateur -O2
, en modifiant à chaque fois le type fourni en main
et en l'exécutant avec +RTS -s
. La liste triée était une liste [Int]
pseudo-aléatoire, créée à peu de frais, avec 2 ^ 20 éléments. Les résultats sont les suivants:
Data.List.sort
: 0.171smergesort
: 1.092s (~ 6x plus lent que Data.List.sort
)quicksort
: 1.152s (~ 7x plus lent que Data.List.sort
)Dans les langages impératifs, Quicksort est exécuté sur place en mutant un tableau. Comme vous le démontrez dans votre exemple de code, vous pouvez adapter Quicksort à un langage purement fonctionnel, tel que Haskell, en construisant à la place des listes à liens simples, mais ce n'est pas aussi rapide.
En revanche, Mergesort n’est pas un algorithme sur place: une implémentation impérative simple copie les données fusionnées dans une allocation différente. Cela convient mieux à Haskell, qui, de par sa nature, doit de toute façon copier les données.
Revenons un peu en arrière: la performance de Quicksort Edge est une "tradition" - une réputation acquise il y a plusieurs décennies sur des machines très différentes de celles que nous utilisons aujourd'hui. Même si vous utilisez le même langage, ce genre de savoir doit être revérifié de temps en temps, car les faits sur le terrain peuvent changer. Le dernier article de référence que j'ai lu sur ce sujet avait toujours Quicksort au premier rang, mais son avance sur Mergesort était mince, même en C/C++.
Mergesort présente d’autres avantages: il n’a pas besoin d’être peaufiné pour éviter le cas le plus défavorable avec Quicksort O (n ^ 2) et il est naturellement stable. Donc, si vous perdez la différence de performance étroite due à d’autres facteurs, Mergesort est un choix évident.
Je pense que la réponse de @ comingstorm est assez claire, mais voici quelques informations supplémentaires sur l'historique de la fonction de tri de GHC.
Dans le code source de Data.OldList
, vous pouvez trouver le implementation of sort
et vérifier par vous-même qu'il s'agit d'un type de fusion. Juste en dessous de la définition dans ce fichier se trouve le commentaire suivant:
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...
Ainsi, à l’origine, un tri rapide fonctionnel a été utilisé (et la fonction qsort
est toujours présente, mais commentée). Les indices de référence de Ian ont montré que son mergesort était compétitif, avec quicksort dans le cas de la "liste aléatoire" et largement surperformé dans le cas de données déjà triées. Plus tard, la version de Ian a été remplacée par une autre implémentation environ deux fois plus rapide, selon des commentaires supplémentaires dans ce fichier.
Le problème principal avec la qsort
originale était qu’elle n’utilisait pas de pivot aléatoire. Au lieu de cela, il a pivoté sur la première valeur de la liste. Ceci est évidemment assez grave car cela implique que la performance sera le pire des cas (ou proche) pour une entrée triée (ou presque triée). Malheureusement, il est difficile de passer de "pivot sur premier" à une alternative (aléatoire, ou - comme dans votre implémentation - quelque part "au milieu"). Dans un langage fonctionnel sans effets secondaires, la gestion d'une entrée pseudo-aléatoire est un problème, mais supposons que vous résolviez ce problème (peut-être en créant un générateur de nombres aléatoires dans votre fonction de tri). Vous rencontrez toujours le problème suivant: lors du tri d'une liste chaînée immuable, la localisation d'un pivot arbitraire, puis le partitionnement basé sur celle-ci impliquent plusieurs traversées de liste et copies de sous-liste.
Je pense que la seule façon de réaliser les avantages supposés de quicksort serait d'écrire la liste sur un vecteur, de la trier sur place (et de sacrifier la stabilité du tri) et de l'écrire sur une liste. Je ne vois pas que cela pourrait être une victoire globale. D'un autre côté, si vous avez déjà des données dans un vecteur, un tri rapide sur place serait certainement une option raisonnable.
Dans une liste à lien unique, mergesort peut être effectué sur place. De plus, les implémentations naïves parcourent la moitié de la liste pour obtenir le début de la deuxième sous-liste, mais le début de la deuxième sous-liste tombe comme un effet secondaire du tri de la première sous-liste et ne nécessite pas de numérisation supplémentaire. La seule chose que le tri rapide a de mieux à faire avec mergesort est la cohérence du cache. Quicksort fonctionne avec des éléments proches les uns des autres en mémoire. Dès qu'un élément d'indirection entre en jeu, comme lorsque vous triez des tableaux de pointeurs au lieu des données elles-mêmes, cet avantage devient moindre.
Mergesort dispose de garanties solides en ce qui concerne le comportement dans le pire des cas, et il est facile de faire un tri stable avec ce comportement.
Réponse courte:
Quicksort est avantageux pour les baies (en place, rapide, mais pas optimal dans le pire des cas). Mergesort pour les listes chaînées (rapide, pire des cas, optimal, stable, simple).
Quicksort est lent pour les listes, Mergesort n'est pas en place pour les tableaux.
De nombreux arguments expliquant pourquoi Quicksort n'est pas utilisé dans Haskell semblent plausibles. Toutefois, au moins Quicksort n’est pas plus lent que Mergesort pour le cas aléatoire. Basé sur l'implémentation donnée dans le livre de Richard Bird, Penser de manière fonctionnelle en Haskell, j'ai réalisé un Quicksort à 3 voies:
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
J'ai comparé quelques cas, par exemple des listes de taille 10 ^ 4 contenant Int compris entre 0 et 10 ^ 3 ou 10 ^ 4, etc. Le résultat est que le Quicksort à 3 voies ou même la version de Bird sont meilleurs que le Mergesort de GHC, quelque chose comme 1 x ~ 3.x plus rapide que le Mergesort de ghc, en fonction du type de données (plusieurs répétitions? Très rares?). Les statistiques suivantes sont générées par critère :
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)
Cependant, il existe une autre exigence de sort
énoncée dans Haskell 98 / 2010 : elle doit être stable. L'implémentation Quicksort typique utilisant Data.List.partition
est stable, mais celle ci-dessus ne l'est pas.
Ajout ultérieur: un Quicksort stable à 3 réponses mentionné dans le commentaire semble aussi rapide que tqsort
ici.
Je ne suis pas sûr, mais en regardant le code, je ne pense pas que Data.List.sort
soit Mergesort tel que nous le connaissons. Il effectue simplement un seul passage en commençant par la fonction sequences
d'une belle manière récursive mutuelle triangulaire avec les fonctions ascending
et descending
afin d'obtenir une liste des blocs ordonnés déjà ascendants ou descendants dans l'ordre requis. Alors seulement, il commence à fusionner.
C'est une manifestation de la poésie en codage. Contrairement à Quicksort, son pire cas (entrée aléatoire totale) a une complexité temporelle de O(nlogn) et son meilleur cas (déjà trié par ordre croissant ou décroissant) est O (n).
Je ne pense pas qu'aucun autre algorithme de tri puisse le battre.