Pourquoi le tri rapide minimaliste, par exemple Haskell, n'est-il pas un «vrai» tri rapide?

118

Le site Web de Haskell présente une fonction de tri rapide en 5 lignes très attrayante , comme illustré ci-dessous.

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

Ils incluent également un "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 );
  }
}

Un lien sous la version C dirige vers une page qui indique "Le tri rapide cité dans l'introduction n'est pas le" vrai "tri rapide et ne s'adapte pas aux listes plus longues comme le fait le code c."

Pourquoi la fonction Haskell ci-dessus n'est-elle pas un véritable tri rapide? Comment échoue-t-il à évoluer pour des listes plus longues?

rybosome
la source
Vous devez ajouter un lien vers la page exacte dont vous parlez.
Staven
14
Ce n'est pas en place, donc assez lent? Bonne question en fait!
fuz
4
@FUZxxl: Les listes Haskell sont immuables, donc aucune opération ne sera en place lors de l'utilisation des types de données par défaut. Quant à sa vitesse, elle ne sera pas nécessairement plus lente; GHC est une technologie de compilation impressionnante et très souvent, les solutions haskell utilisant des structures de données immuables sont à la hauteur d'autres solutions mutables dans d'autres langages.
Callum Rogers
1
N'est-ce pas en fait qsort? N'oubliez pas que qsort a un O(N^2)runtime.
Thomas Eding
2
Il convient de noter que l'exemple ci-dessus est un exemple d'introduction de Haskell et que le tri rapide est un très mauvais choix pour trier les listes. Le tri dans Data.List a été changé en mergesort en 2002: hackage.haskell.org/packages/archive/base/3.0.3.1/doc/html/src/… , vous pouvez également voir l'implémentation précédente du tri rapide. L'implémentation actuelle est un mergesort qui a été fait en 2009: hackage.haskell.org/packages/archive/base/4.4.0.0/doc/html/src/… .
HaskellÉléphant

Réponses:

75

Le vrai tri rapide a deux beaux aspects:

  1. Divisez pour vaincre: divisez le problème en deux problèmes plus petits.
  2. Partitionnez les éléments sur place.

L'exemple court de Haskell montre (1), mais pas (2). Comment (2) est fait peut ne pas être évident si vous ne connaissez pas déjà la technique!

tapoter
la source
17
informit.com/articles/article.aspx?p=1407357&seqNum=3 - Andrey Alexandrescu
The_Ghost
Pour une description claire du processus de partitionnement sur place, consultez interactivepython.org/courselib/static/pythonds/SortSearch/… .
pvillela
57

Véritable tri rapide sur place dans 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
Klapaucius
la source
La source de unstablePartition révèle qu'il s'agit en fait de la même technique d'échange sur place (pour autant que je sache).
Dan Burton
3
Cette solution est incorrecte. unstablePartitionest très similaire à partitionfor quicksort, mais cela ne garantit pas que l'élément à mla position est juste p.
nymk
29

Voici une translittération du "vrai" code C de tri rapide en Haskell. Préparez vous.

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

C'était amusant, non? En fait, j'ai coupé ce gros letau début, ainsi qu'à la wherefin de la fonction, définissant tous les helpers pour rendre le code précédent assez joli.

  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

Et ici, un test stupide pour voir si ça marche.

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]

Je n'écris pas de code impératif très souvent en Haskell, donc je suis sûr qu'il existe de nombreuses façons de nettoyer ce code.

Et alors?

Vous remarquerez que le code ci-dessus est très, très long. Le cœur de celui-ci est à peu près aussi long que le code C, bien que chaque ligne soit souvent un peu plus verbeuse. C'est parce que C fait secrètement beaucoup de choses désagréables que vous pourriez prendre pour acquises. Par exemple a[l] = a[h];,. Cela accède aux variables mutables let h, puis accède au tableau mutable a, puis mute le tableau mutable a. Sainte mutation, batman! Dans Haskell, la mutation et l'accès aux variables mutables sont explicites. Le "faux" qsort est attrayant pour diverses raisons, mais la principale d'entre elles est qu'il n'utilise pas de mutation; cette restriction auto-imposée le rend beaucoup plus facile à comprendre en un coup d'œil.

Dan Burton
la source
3
C'est génial, dans une sorte de malaise. Je me demande quel genre de code GHC produit à partir de quelque chose comme ça?
Ian Ross
@IanRoss: Du tri rapide impur? GHC produit en fait un code assez décent.
JD
"Le" faux "qsort est attrayant pour diverses raisons ..." Je crains que ses performances sans manipulation sur place (comme déjà noté) ne soient horribles. Et toujours prendre le 1er élément comme pivot n'aide pas non plus.
dbaltor
25

À mon avis, dire que ce n'est "pas un vrai tri rapide" exagère le cas. Je pense que c'est une implémentation valide de l' algorithme Quicksort , mais pas particulièrement efficace.

Keith Thompson
la source
9
J'ai eu cette dispute avec quelqu'un une fois: j'ai recherché le papier réel qui spécifiait QuickSort, et est effectivement en place.
ivanm
2
Hyperliens @ivanm ou cela ne s'est pas produit :)
Dan Burton
1
J'adore la façon dont cet article est impératif et inclut même l'astuce pour garantir l'utilisation de l'espace logarithmique (que beaucoup de gens ne connaissent pas) alors que la version récursive (maintenant populaire) d'ALGOL n'est qu'une note de bas de page. Je suppose que je vais devoir chercher cet autre article maintenant ... :)
hugomg
6
Une implémentation «valide» de n'importe quel algorithme devrait avoir les mêmes limites asymptotiques, n'est-ce pas? Le tri rapide Haskell bâtard ne préserve aucune de la complexité de la mémoire de l'algorithme d'origine. Pas même proche. C'est pourquoi il est plus de 1000 fois plus lent que le véritable Quicksort de Sedgewick en C.
JD
16

Je pense que le cas que cet argument essaie de faire est que la raison pour laquelle le tri rapide est couramment utilisé est qu'il est en place et qu'il est donc assez compatible avec le cache. Puisque vous n'avez pas ces avantages avec les listes Haskell, sa principale raison d'être a disparu, et vous pourriez aussi bien utiliser le tri par fusion, qui garantit O (n log n) , alors qu'avec le tri rapide, vous devez soit utiliser la randomisation soit compliquée schémas de partitionnement pour éviter le temps d'exécution O (n 2 ) dans le pire des cas.

Hammar
la source
5
Et Mergesort est un algorithme de tri beaucoup plus naturel pour les listes aimées (immuables), où il est libéré du besoin de travailler avec des tableaux auxiliaires.
hugomg
16

Grâce à l'évaluation paresseuse, un programme Haskell ne le fait pas (presque pas ) ce qu'il semble faire.

Considérez ce programme:

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

Dans une langue enthousiaste, d'abord quicksort courrait, puis show, puis putStrLn. Les arguments d'une fonction sont calculés avant que cette fonction ne commence à s'exécuter.

Dans Haskell, c'est le contraire. La fonction démarre en premier. Les arguments ne sont calculés que lorsque la fonction les utilise réellement. Et un argument composé, comme une liste, est calculé un morceau à la fois, car chaque morceau est utilisé.

Donc, la première chose qui se passe dans ce programme est queputStrLn démarre.

L'implémentation de GHCputStrLn fonctionne en copiant les caractères de l'argument String dans un tampon de sortie. Mais quand il entre dans cette boucle, shown'a pas encore exécuté. Par conséquent, lorsqu'il va copier le premier caractère de la chaîne, Haskell évalue la fraction des appels showet quicksortnécessaires pour calculer ce caractère . Puis putStrLnpasse au personnage suivant. Ainsi , l'exécution des trois fonctions- putStrLn, showet quicksort- est entrelacée. quicksorts'exécute de manière incrémentielle, laissant un graphique de thunks non évalués pendant qu'il se souvient où il s'est arrêté.

Maintenant, c'est très différent de ce à quoi vous pourriez vous attendre si vous connaissez, vous savez, n'importe quel autre langage de programmation. Il n'est pas facile de visualiser comment quicksortse comporte réellement dans Haskell en termes d'accès à la mémoire ou même l'ordre des comparaisons. Si vous pouviez seulement observer le comportement, et non le code source, vous ne reconnaîtrez pas ce qu'il fait comme un tri rapide .

Par exemple, la version C de quicksort partitionne toutes les données avant le premier appel récursif. Dans la version Haskell, le premier élément du résultat sera calculé (et pourrait même apparaître sur votre écran) avant la fin de l'exécution de la première partition - en fait avant que tout travail ne soit effectué greater.

PS Le code Haskell serait plus rapide s'il effectuait le même nombre de comparaisons que quicksort; le code tel qu'il est écrit fait deux fois plus de comparaisons car lesseril greaterest spécifié pour être calculé indépendamment, en effectuant deux balayages linéaires dans la liste. Bien sûr, il est possible en principe que le compilateur soit suffisamment intelligent pour éliminer les comparaisons supplémentaires; ou le code pourrait être changé pour utiliser Data.List.partition.

PPS L'exemple classique des algorithmes de Haskell se révélant ne pas se comporter comme prévu est le tamis d'Eratosthène pour le calcul des nombres premiers.

Jason Orendorff
la source
2
lpaste.net/108190 . - il fait le "tri des arbres déboisés", il y a un vieux fil de discussion reddit à ce sujet. cf. stackoverflow.com/questions/14786904/… et connexes.
Will Ness
1
regarde Oui, c'est une assez bonne caractérisation de ce que fait réellement le programme.
Jason Orendorff
Concernant la remarque tamisée, si elle était écrite comme un équivalent primes = unfoldr (\(p:xs)-> Just (p, filter ((> 0).(`rem` p)) xs)) [2..], son problème le plus immédiat serait peut-être plus clair. Et c'est avant d' envisager de passer au véritable algorithme de tamisage.
Will Ness
Je suis confus par votre définition de ce que le code «ressemble à ça». Votre code "me ressemble" comme s'il appelle putStrLnune application thunked de showà une application thunked de quicksortà une liste littérale --- et c'est exactement ce qu'il fait! (avant l'optimisation --- mais comparez parfois le code C à l'assembleur optimisé!). Peut-être voulez-vous dire "grâce à une évaluation paresseuse, un programme Haskell ne fait pas ce que fait un code similaire dans d'autres langues"?
Jonathan Cast
4
@jcast Je pense qu'il y a une différence pratique entre C et Haskell à cet égard. Il est vraiment difficile de mener un débat agréable sur ce genre de sujet dans un fil de commentaires, autant que j'adorerais l'avoir autour d'un café dans la vraie vie. Faites-moi savoir si vous êtes déjà à Nashville avec une heure à perdre!
Jason Orendorff
13

Je crois que la raison pour laquelle la plupart des gens disent que le joli Haskell Quicksort n'est pas un "vrai" Quicksort est le fait qu'il n'est pas en place - clairement, cela ne peut pas être lors de l'utilisation de types de données immuables. Mais il y a aussi l'objection que ce n'est pas «rapide»: en partie à cause du cher ++, et aussi parce qu'il y a une fuite d'espace - vous vous accrochez à la liste d'entrée tout en faisant l'appel récursif sur les éléments inférieurs, et dans certains cas - par exemple lorsque la liste diminue - cela se traduit par une utilisation de l'espace quadratique. (Vous pourriez dire que le faire fonctionner dans un espace linéaire est le plus proche que vous puissiez obtenir "sur place" en utilisant des données immuables.) Il existe des solutions intéressantes à ces deux problèmes, en utilisant des paramètres d'accumulation, du tupling et de la fusion; voir S7.6.1 de Richard Bird

Jeremy Gibbons
la source
4

Ce n'est pas l'idée de faire muter des éléments en place dans des contextes purement fonctionnels. Les méthodes alternatives de ce fil avec des tableaux mutables ont perdu l'esprit de pureté.

Il y a au moins deux étapes pour optimiser la version de base (qui est la version la plus expressive) du tri rapide.

  1. Optimiser la concaténation (++), qui est une opération linéaire, par accumulateurs:

    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. Optimisez le tri rapide ternaire (partition à 3 voies, mentionnée par Bentley et Sedgewick), pour gérer les éléments dupliqués:

    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. Combinez 2 et 3, reportez-vous au livre de 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

Ou bien si les éléments dupliqués ne sont pas majoritaires:

    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

Malheureusement, la médiane de trois ne peut pas être implémentée avec le même effet, par exemple:

    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] 

car il fonctionne toujours mal dans les 4 cas suivants:

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

Tous ces 4 cas sont bien traités par une approche impérative de la médiane de trois.

En fait, l'algorithme de tri le plus approprié pour un paramètre purement fonctionnel est toujours le tri par fusion, mais pas le tri rapide.

Pour plus de détails, veuillez consulter ma rédaction en cours sur: https://sites.google.com/site/algoxy/dcsort

Larry LIU Xinyu
la source
Il y a une autre optimisation que vous avez manquée: utilisez la partition au lieu de 2 filtres pour produire les sous-listes (ou foldr sur une fonction interne similaire pour produire 3 sous-listes).
Jeremy List
3

Il n'y a pas de définition claire de ce qui est et de ce qui n'est pas un véritable tri rapide.

Ils l'appellent pas un vrai tri rapide, car il ne trie pas sur place:

Véritable tri rapide en C trie sur place

Piotr Praszmo
la source
-1

Parce que prendre le premier élément de la liste entraîne une très mauvaise exécution. Utilisez la médiane de 3: premier, milieu, dernier.

Joshua
la source
2
Prendre le premier élément est correct si la liste est aléatoire.
Keith Thompson
2
Mais trier une liste triée ou presque triée est courant.
Joshua
7
Mais qsort IS O(n^2)
Thomas Eding
8
qsort est la moyenne n log n, le pire n ^ 2.
Joshua
3
Techniquement, ce n'est pas pire que de choisir une valeur aléatoire à moins que l'entrée ne soit déjà triée ou presque triée. Les mauvais pivots sont les pivots qui sont éloignés de la médiane; le premier élément n'est un mauvais pivot que s'il est proche du minimum ou du maximum.
Platinum Azure
-1

Demandez à n'importe qui d'écrire Quicksort dans Haskell, et vous obtiendrez essentiellement le même programme - c'est évidemment quicksort. Voici quelques avantages et inconvénients:

Pro: Il améliore le "vrai" tri rapide en étant stable, c'est-à-dire qu'il préserve l'ordre des séquences entre des éléments égaux.

Pro: Il est trivial de généraliser à une division à trois (<=>), ce qui évite un comportement quadratique dû à une certaine valeur se produisant O (n) fois.

Pro: C'est plus facile à lire - même s'il fallait inclure la définition du filtre.

Con: Il utilise plus de mémoire.

Inconvénient: il est coûteux de généraliser le choix du pivot par un échantillonnage supplémentaire, ce qui pourrait éviter un comportement quadratique sur certains ordres de faible entropie.

mercator
la source