web-dev-qa-db-fra.com

Pourquoi le quicksort minimaliste, par exemple Haskell, n'est-il pas un "vrai" quicksort?

Le site Web de Haskell présente un très attractif 5 lignes fonction de tri rapide , comme indiqué 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 renvoie à une page qui indique "Le tri rapide cité dans l'introduction n'est pas le" vrai "tri rapide et ne se met pas à l'échelle pour des listes plus longues comme le code c."

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

108
rybosome

Le véritable tri rapide a deux beaux aspects:

  1. Diviser pour mieux régner: divisez le problème en deux problèmes plus petits.
  2. Partitionnez les éléments en place.

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

64
pat

Véritable tri rapide sur place à 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

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

24
Keith Thompson

Je pense que l'argument que cet argument essaie de démontrer est que la raison pour laquelle le tri rapide est couramment utilisé est qu'il est en place et assez convivial pour 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, ce qui garantit O (n log n), alors qu'avec quicksort vous soit utiliser la randomisation ou des schémas de partitionnement compliqués pour éviter O (n2) temps d'exécution dans le pire des cas.

15
hammar

Grâce à une évaluation paresseuse, un programme Haskell ne fait pas (presque ne peut pas) ce à quoi il ressemble.

Considérez ce programme:

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

Dans une langue désireuse, d'abord quicksort s'exécuterait, puis show, puis putStrLn. Les arguments d'une fonction sont calculés avant que cette fonction ne démarre.

À 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é une pièce à la fois, à mesure que chaque pièce est utilisée.

Ainsi, la chose d'abord qui se produit dans ce programme est que putStrLn commence à fonctionner.

L'implémentation par GHC de putStrLn fonctionne en copiant les caractères de l'argument String dans un tampon de sortie. Mais lorsqu'il entre dans cette boucle, show n'est pas encore exécuté. Par conséquent, lorsqu'il copie le premier caractère de la chaîne, Haskell évalue la fraction des appels show et quicksort nécessaires pour calculer ce caractère. Puis putStrLn passe au caractère suivant. Ainsi, l'exécution des trois fonctions —putStrLn, show et quicksort— est entrelacée. quicksort s'exécute de manière incrémentielle, laissant un graphique de thunks non évalués au fur et à mesure qu'il se souvient où il s'était 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 quicksort se comporte réellement dans Haskell en termes d'accès à la mémoire ou même dans l'ordre des comparaisons. Si vous pouviez seulement observer le comportement, et non le code source, , vous ne reconnaîtriez pas ce qu'il fait en tant que 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 que la partition d'abord ne soit finie - en fait avant tout travail sur greater.

P.S. Le code Haskell serait plus semblable au tri rapide s'il faisait le même nombre de comparaisons que le tri rapide; le code tel qu'il est écrit fait deux fois plus de comparaisons car lesser et greater sont spécifiés pour être calculés indépendamment, en effectuant deux analyses 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 modifié pour utiliser Data.List.partition .

P.P.S. L'exemple classique des algorithmes de Haskell qui s'avèrent ne pas se comporter comme prévu était le tamis d'Eratosthène pour le calcul des nombres premiers.

15
Jason Orendorff

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, il ne peut pas l'être lors de l'utilisation de types de données immuables. Mais il y a aussi l'objection selon laquelle ce n'est pas "rapide": en partie à cause du ++ coûteux, 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 moindres, et dans certains cas - par exemple lorsque la liste diminue - cela se traduit par une utilisation d'espace quadratique. (Vous pourriez dire que le faire fonctionner dans un espace linéaire est le plus proche que vous pouvez obtenir "sur place" en utilisant des données immuables.) Il existe des solutions intéressantes aux deux problèmes, en utilisant l'accumulation de paramètres, le tuplage et la fusion; voir S7.6.1 de Richard Bird Introduction à la programmation fonctionnelle utilisant Haskell .

11
Jeremy Gibbons

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 véritable tri rapide, car il ne trie pas sur place:

Véritable tri rapide en C trié sur place

3
Piotr Praszmo

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

Il existe 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 des 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 au 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 des 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 visiter mon écriture en cours à: https://sites.google.com/site/algoxy/dcsort

3
Larry LIU Xinyu

Demandez à n'importe qui d'écrire quicksort en 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 les éléments égaux.

Pro: Il est trivial de généraliser à un fractionnement à trois voies (<=>), ce qui évite un comportement quadratique en raison 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énients: 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 à faible entropie.

1
mercator

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.

0
Joshua