¿Por qué el minimalista, ejemplo Haskell quicksort no es un quicksort "verdadero"?


118

El sitio web de Haskell presenta una función de ordenación rápida de 5 líneas muy atractiva , como se ve a continuación.

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

También incluyen una "clasificación rápida verdadera en 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 enlace debajo de la versión C dirige a una página que dice 'El ordenamiento rápido citado en Introducción no es el ordenamiento rápido "real" y no escala para listas más largas como lo hace el código c'.

¿Por qué la función Haskell anterior no es una verdadera clasificación rápida? ¿Cómo no se puede escalar para listas más largas?


Debe agregar un enlace a la página exacta de la que está hablando.
Staven

14
¿No está en su lugar, por lo tanto, es bastante lento? ¡Buena pregunta en realidad!
fuz

4
@FUZxxl: las listas de Haskell son inmutables, por lo que no se realizará ninguna operación mientras se utilicen los tipos de datos predeterminados. En cuanto a su velocidad, no necesariamente será más lenta; GHC es una pieza impresionante de tecnología de compilación y, muy a menudo, las soluciones de Haskell que utilizan estructuras de datos inmutables están al día con otras mutables en otros lenguajes.
Callum Rogers

1
¿No es realmente qsort? Recuerde que qsort tiene O(N^2)tiempo de ejecución.
Thomas Eding

2
Cabe señalar que el ejemplo anterior es un ejemplo introductorio de Haskell, y que la ordenación rápida es una muy mala elección para ordenar listas. La ordenación en Data.List se cambió a mergesort en 2002: hackage.haskell.org/packages/archive/base/3.0.3.1/doc/html/src/… , allí también puede ver la implementación de ordenación rápida anterior. La implementación actual es un mergesort que se realizó en 2009: hackage.haskell.org/packages/archive/base/4.4.0.0/doc/html/src/… .
HaskellElephant

Respuestas:


75

La verdadera clasificación rápida tiene dos aspectos hermosos:

  1. Divide y vencerás: divide el problema en dos problemas más pequeños.
  2. Divida los elementos en el lugar.

El ejemplo corto de Haskell demuestra (1), pero no (2). ¡Cómo se hace (2) puede no ser obvio si aún no conoce la técnica!



Para obtener una descripción clara del proceso de particionado en el lugar, consulte interactivepython.org/courselib/static/pythonds/SortSearch/… .
pvillela

57

Verdadero ordenamiento rápido en 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

La fuente de unstablePartition revela que de hecho es la misma técnica de intercambio en el lugar (por lo que puedo decir).
Dan Burton

3
Esta solución es incorrecta. unstablePartitiones muy similar a partitionfor quicksort, pero no garantiza que el elemento en mla posición sea justo p.
nymk

29

Aquí hay una transliteración del código C de clasificación rápida "verdadero" a Haskell. Prepárate.

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

Eso fue divertido, ¿no? De hecho, corté este tamaño letal principio, así como whereal final de la función, definiendo todos los ayudantes para hacer que el código anterior sea algo bonito.

  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

Y aquí, una prueba tonta para ver si funciona.

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]

No escribo código imperativo muy a menudo en Haskell, así que estoy seguro de que hay muchas formas de limpiar este código.

¿Y qué?

Notará que el código anterior es muy, muy largo. La esencia es tan larga como el código C, aunque cada línea suele ser un poco más detallada. Esto se debe a que C secretamente hace muchas cosas desagradables que podrías dar por sentado. Por ejemplo a[l] = a[h];,. Esto accede a las variables mutables ly h, y luego accede a la matriz mutable ay luego muta la matriz mutable a. ¡Santa mutación, Batman! En Haskell, la mutación y el acceso a variables mutables son explícitos. El qsort "falso" es atractivo por varias razones, pero la principal de ellas es que no usa mutación; esta restricción autoimpuesta hace que sea mucho más fácil de entender de un vistazo.


3
Eso es asombroso, en una especie de forma mareada. Me pregunto qué tipo de código produce GHC a partir de algo así.
Ian Ross

@IanRoss: ¿De la clasificación rápida impura? GHC en realidad produce un código bastante decente.
JD

"El qsort" falso "es atractivo por varias razones ..." Me temo que su rendimiento sin manipulación en el lugar (como ya se señaló) sería horrible. Y tomar siempre el primer elemento como pivote tampoco ayuda.
dbaltor

25

En mi opinión, decir que "no es una verdadera selección rápida" exagera el caso. Creo que es una implementación válida del algoritmo Quicksort , pero no particularmente eficiente.


9
Una vez tuve esta discusión con alguien: busqué el documento real que especificaba QuickSort, y de hecho está en su lugar.
ivanm

2
@ivanm hipervínculos o no sucedió :)
Dan Burton

1
Me encanta cómo este documento es imperativo e incluso incluye el truco para garantizar el uso del espacio logarítmico (que mucha gente no conoce) mientras que la versión recursiva (ahora popular) en ALGOL es solo una nota al pie. Supongo que tendré que buscar ese otro periódico ahora ... :)
hugomg

6
Una implementación "válida" de cualquier algoritmo debería tener los mismos límites asintóticos, ¿no crees? La ordenación rápida bastarda de Haskell no conserva nada de la complejidad de memoria del algoritmo original. Ni siquiera cerca. Es por eso que es 1,000 veces más lento que el Quicksort genuino de Sedgewick en C.
JD

16

Creo que el caso que intenta demostrar este argumento es que la razón por la que se usa comúnmente la ordenación rápida es que está en el lugar y, como resultado, es bastante compatible con el caché. Dado que no tiene esos beneficios con las listas de Haskell, su principal razón de ser se ha ido, y también podría usar la ordenación por fusión, que garantiza O (n log n) , mientras que con la ordenación rápida debe usar la aleatorización o complicada esquemas de particionamiento para evitar el tiempo de ejecución O (n 2 ) en el peor de los casos.


5
Y Mergesort es un algoritmo de ordenación mucho más natural para listas de Me gusta (inmutables), donde se libera de la necesidad de trabajar con matrices auxiliares.
hugomg

16

Gracias a la evaluación perezosa, un programa Haskell no hace (casi no puede ) hacer lo que parece.

Considere este programa:

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

En un lenguaje ávido, primero quicksortcorrería, luego show, luego putStrLn. Los argumentos de una función se calculan antes de que esa función comience a ejecutarse.

En Haskell, es todo lo contrario. La función comienza a ejecutarse primero. Los argumentos solo se calculan cuando la función realmente los usa. Y un argumento compuesto, como una lista, se calcula una pieza a la vez, a medida que se utiliza cada pieza.

Entonces, lo primero que sucede en este programa es queputStrLn comienza a ejecutarse.

La implementación de GHC deputStrLn funciona copiando los caracteres del argumento String en un búfer de salida. Pero cuando entra en este bucle, showaún no se ha ejecutado. Por lo tanto, cuando va a copiar el primer carácter de la cadena, Haskell evalúa la fracción de showy las quicksortllamadas necesarias para calcular ese carácter . Luego putStrLnpasa al siguiente personaje. Por lo que la ejecución de los tres funciones- putStrLn, showy quicksort- se intercalan. quicksortse ejecuta de forma incremental, dejando un gráfico de procesadores no evaluados a medida que avanza para recordar dónde se quedó.

Ahora bien, esto es tremendamente diferente de lo que podría esperar si está familiarizado con, ya sabe, cualquier otro lenguaje de programación. No es fácil visualizar cómo se quicksortcomporta realmente Haskell en términos de accesos a la memoria o incluso el orden de las comparaciones. Si solo pudiera observar el comportamiento, y no el código fuente, no reconocería lo que está haciendo como una clasificación rápida .

Por ejemplo, la versión C de quicksort particiona todos los datos antes de la primera llamada recursiva. En la versión Haskell, el primer elemento del resultado se calculará (e incluso podría aparecer en su pantalla) antes de que termine de ejecutarse la primera partición, de hecho, antes de que se realice ningún trabajo greater.

PD: El código de Haskell sería más parecido a una ordenación rápida si hiciera el mismo número de comparaciones que la ordenación rápida; el código tal como está escrito hace el doble de comparaciones porque lessery greaterse especifican para ser calculados de forma independiente, haciendo dos escaneos lineales a través de la lista. Por supuesto, en principio es posible que el compilador sea lo suficientemente inteligente como para eliminar las comparaciones adicionales; o el código podría cambiarse para usarData.List.partition .

PPS El ejemplo clásico de que los algoritmos de Haskell no se comportan como se esperaba es el tamiz de Eratóstenes para calcular números primos.


2
lpaste.net/108190 . - está haciendo el "tipo de árbol deforestado", hay un viejo hilo de Reddit al respecto. cf. stackoverflow.com/questions/14786904/… y relacionados.
Will Ness

1
Parece Sí, esa es una caracterización bastante buena de lo que realmente hace el programa.
Jason Orendorff

En cuanto a la observación del tamiz, si se escribiera como equivalente primes = unfoldr (\(p:xs)-> Just (p, filter ((> 0).(`rem` p)) xs)) [2..], su problema más inmediato sería quizás más claro. Y eso es antes de que consideremos cambiar al verdadero algoritmo de tamiz.
Will Ness

Estoy confundido por su definición de lo que el código "parece". Su código me "parece" como si fuera putStrLnuna aplicación thunked de showa una aplicación thunked de quicksorta una lista literal --- ¡y eso es exactamente lo que hace! (antes de la optimización --- ¡pero compare el código C con el ensamblador optimizado en algún momento!). ¿Quizás te refieres a "gracias a la evaluación perezosa, un programa Haskell no hace lo que hace un código similar en otros lenguajes"?
Jonathan Cast

4
@jcast Creo que hay una diferencia práctica entre C y Haskell en este sentido. Es realmente difícil mantener un debate agradable sobre este tipo de tema en un hilo de comentarios, por mucho que me encantaría tomar un café en la vida real. ¡Avísame si alguna vez estás en Nashville con una hora de sobra!
Jason Orendorff

12

Creo que la razón por la que la mayoría de la gente dice que la bonita ordenación rápida de Haskell no es una ordenación rápida "verdadera" es el hecho de que no está en el lugar; claramente, no puede serlo cuando se usan tipos de datos inmutables. Pero también existe la objeción de que no es "rápido": en parte debido al costoso ++, y también porque hay una fuga de espacio: te aferras a la lista de entrada mientras haces la llamada recursiva en los elementos menores, y en algunos casos, por ejemplo, cuando la lista disminuye, esto da como resultado un uso de espacio cuadrático. (Se podría decir que hacer que se ejecute en un espacio lineal es lo más cercano a "in situ" usando datos inmutables). Hay soluciones claras para ambos problemas, usando parámetros acumulativos, tuples y fusión; ver S7.6.1 de Richard Bird '


4

No es la idea de mutar elementos in situ en entornos puramente funcionales. Los métodos alternativos en este hilo con matrices mutables perdieron el espíritu de pureza.

Hay al menos dos pasos para optimizar la versión básica (que es la versión más expresiva) de clasificación rápida.

  1. Optimiza la concatenación (++), que es una operación lineal, por acumuladores:

    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. Optimice la clasificación ternaria rápida (partición de 3 vías, mencionada por Bentley y Sedgewick), para manejar elementos duplicados:

    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. Combine 2 y 3, consulte el libro 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

O alternativamente si los elementos duplicados no son la mayoría:

    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

Desafortunadamente, la mediana de tres no se puede implementar con el mismo efecto, por ejemplo:

    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] 

porque todavía funciona mal en los siguientes 4 casos:

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

Todos estos 4 casos se manejan bien con un enfoque imperativo de mediana de tres.

En realidad, el algoritmo de ordenación más adecuado para una configuración puramente funcional sigue siendo la ordenación por combinación, pero no la ordenación rápida.

Para obtener más detalles, visite mi escritura en curso en: https://sites.google.com/site/algoxy/dcsort


Hay otra optimización que se perdió: use la partición en lugar de 2 filtros para producir las sublistas (o foldr en una función interna similar para producir 3 sublistas).
Jeremy List

3

No existe una definición clara de lo que es y lo que no es una verdadera selección rápida.

Lo llaman no una verdadera ordenación rápida, porque no ordena en el lugar:

Verdadero ordenamiento rápido en ordenamientos C in situ


-1

Porque tomar el primer elemento de la lista da como resultado un tiempo de ejecución muy malo. Utilice una mediana de 3: primero, medio, último.


2
Tomar el primer elemento está bien si la lista es aleatoria.
Keith Thompson

2
Pero ordenar una lista ordenada o casi ordenada es común.
Joshua

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

8
qsort es el promedio n log n, el peor n ^ 2.
Joshua

3
Técnicamente, no es peor que elegir un valor aleatorio a menos que la entrada ya esté ordenada o casi ordenada. Los malos pivotes son los pivotes que están alejados de la mediana; el primer elemento es solo un pivote incorrecto si está cerca del mínimo o máximo.
Platinum Azure

-1

Pídale a cualquiera que escriba quicksort en Haskell y obtendrá básicamente el mismo programa: obviamente es quicksort. A continuación se muestran algunas ventajas y desventajas:

Ventaja: Mejora la ordenación rápida "verdadera" al ser estable, es decir, conserva el orden de secuencia entre elementos iguales.

Ventaja: Es trivial generalizar a una división de tres vías (<=>), que evita el comportamiento cuadrático debido a que algún valor ocurre O (n) veces.

Ventaja: es más fácil de leer, incluso si se tuviera que incluir la definición de filtro.

Desventaja: usa más memoria.

Desventaja: es costoso generalizar la elección del pivote mediante un muestreo adicional, lo que podría evitar el comportamiento cuadrático en ciertos ordenamientos de baja entropía.

Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.