Por que o quicksort minimalista, exemplo Haskell não é um quicksort “verdadeiro”?


118

O site de Haskell apresenta uma função quicksort de 5 linhas muito atraente , como mostrado abaixo.

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

Eles também incluem uma "classificação rápida verdadeira em 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 );
  }
}

Um link abaixo da versão C direciona para uma página que afirma 'O quicksort citado na Introdução não é o quicksort' real 'e não escalona para listas mais longas como o código c faz.'

Por que a função Haskell acima não é uma classificação rápida verdadeira? Como ele não consegue escalar para listas mais longas?


Você deve adicionar um link para a página exata de que está falando.
— Staven,

14
Não está no lugar, portanto, é bastante lento? Boa pergunta, na verdade!
— fuz

4
@FUZxxl: Listas Haskell são imutáveis, então nenhuma operação será realizada durante o uso dos tipos de dados padrão. Quanto à velocidade - não será necessariamente mais lento; GHC é uma peça impressionante de tecnologia de compilador e muitas vezes as soluções haskell que usam estruturas de dados imutáveis ​​estão à altura de outras mutáveis ​​em outras linguagens.
— Callum Rogers,

1
Não é qsort? Lembre-se de que o qsort tem O(N^2)tempo de execução.
— Thomas Eding,

2
Deve-se notar que o exemplo acima é um exemplo introdutório de Haskell, e que quicksort é uma péssima escolha para classificar listas. A classificação em Data.List foi alterada para mergesort em 2002: hackage.haskell.org/packages/archive/base/3.0.3.1/doc/html/src/… , lá você também pode ver a implementação de classificação rápida anterior. A implementação atual é um mergesort feito em 2009: hackage.haskell.org/packages/archive/base/4.4.0.0/doc/html/src/… .
— HaskellElephant

Respostas:


75

O verdadeiro quicksort tem dois belos aspectos:

  1. Divida e conquiste: divida o problema em dois problemas menores.
  2. Particione os elementos no local.

O exemplo curto de Haskell demonstra (1), mas não (2). Como (2) é feito pode não ser óbvio se você ainda não conhece a técnica!



Para obter uma descrição clara do processo de particionamento no local, consulte interativopython.org/courselib/static/pythonds/SortSearch/… .
— pvillela

57

Quicksort local verdadeiro em 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

A fonte para unstablePartition revela que é de fato a mesma técnica de troca in-loco (pelo que posso dizer).
— Dan Burton de

3
Esta solução está incorreta. unstablePartitioné muito semelhante a partitionfor quicksort, mas não garante que o elemento na mposição seja justo p.
— nymk

29

Aqui está uma transliteração do "verdadeiro" código quicksort C para Haskell. Prepare-se.

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

Foi divertido, não foi? Na verdade, cortei esse tamanho grande letno início, bem como whereno final da função, definindo todos os auxiliares para tornar o código anterior um tanto 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

E aqui, um teste idiota para ver se 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]

Não escrevo código imperativo com muita frequência em Haskell, então tenho certeza de que há muitas maneiras de limpar esse código.

E daí?

Você notará que o código acima é muito, muito longo. O coração disso é quase tão longo quanto o código C, embora cada linha seja um pouco mais prolixa. Isso ocorre porque C secretamente faz muitas coisas desagradáveis ​​que você pode considerar certas. Por exemplo a[l] = a[h];,. Isso acessa as variáveis ​​mutáveis le h, em seguida, acessa a matriz mutável ae altera a matriz mutávela . Santa mutação, batman! Em Haskell, a mutação e o acesso a variáveis ​​mutáveis ​​são explícitos. O qsort "falso" é atraente por vários motivos, mas o principal deles é que não usa mutação; essa restrição autoimposta torna muito mais fácil de entender à primeira vista.


3
Isso é incrível, de uma forma meio enjoativa. Eu me pergunto que tipo de código GHC produz a partir de algo assim?
— Ian Ross de

@IanRoss: Do quicksort impuro? GHC realmente produz um código bastante decente.
— JD

"O qsort" falso "é atraente por vários motivos ..." Temo que seu desempenho sem manipulação no local (como já observado) seria péssimo. E sempre tomar o primeiro elemento como pivô também não ajuda.
— dbaltor

25

Em minha opinião, dizer que "não é um verdadeiro achado" exagera o caso. Acho que é uma implementação válida do algoritmo Quicksort , mas não particularmente eficiente.


9
Eu tive esta discussão com alguém uma vez: eu pesquisei o documento real que especificava QuickSort, e está de fato no lugar.
— ivanm de

2
@ivanm hyperlinks ou não aconteceu :)
— Dan Burton

1
Eu adoro como este artigo é imperativo e até inclui o truque para garantir o uso logarítmico do espaço (que muitas pessoas não conhecem), enquanto a (agora popular) versão recursiva do ALGOL é apenas uma nota de rodapé. Acho que terei que procurar por aquele outro artigo agora ... :)
— hugomg

6
Uma implementação "válida" de qualquer algoritmo deve ter os mesmos limites assintóticos, você não acha? O bastardo Quicksort Haskell não preserva nada da complexidade de memória do algoritmo original. Nem mesmo perto. É por isso que é 1.000x mais lento do que o Quicksort genuíno de Sedgewick em C.
— JD

16

Acho que o caso que esse argumento tenta apresentar é que a razão pela qual o quicksort é comumente usado é que ele está no local e, como resultado, é bastante amigável ao cache. Como você não tem esses benefícios com as listas de Haskell, sua principal razão de ser se foi, e você também pode usar merge sort, que garante O (n log n) , enquanto com quicksort você precisa usar randomização ou esquemas de particionamento para evitar o tempo de execução O (n 2 ) no pior caso.


5
E Mergesort é um algoritmo de classificação muito mais natural para listas curtidas (imutáveis), onde fica livre da necessidade de trabalhar com matrizes auxiliares.
— hugomg

16

Graças à avaliação preguiçosa, um programa Haskell não (quase não pode ) fazer o que parece que faz.

Considere este programa:

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

Em uma linguagem ansiosa, primeiro quicksortiria correr, então show, então putStrLn. Os argumentos de uma função são calculados antes que a função comece a ser executada.

Em Haskell, é o oposto. A função começa a funcionar primeiro. Os argumentos são calculados apenas quando a função realmente os usa. E um argumento composto, como uma lista, é calculado uma parte de cada vez, conforme cada parte dele é usada.

Então, a primeira coisa que acontece neste programa é queputStrLn começa a funcionar.

A implementação do GHCputStrLn funciona copiando os caracteres do argumento String para um buffer de saída. Mas quando ele entra neste loop, showainda não foi executado. Portanto, quando vai copiar o primeiro caractere da string, Haskell avalia a fração de showe as quicksortchamadas necessárias para calcular esse caractere . Em seguida, putStrLnpassa para o próximo personagem. Portanto, a execução de todas as três funções putStrLn- show, e quicksort- é intercalada. quicksortexecuta de forma incremental, deixando um gráfico de thunks não avaliados à medida que vai para lembrar onde parou.

Agora, isso é totalmente diferente do que você pode esperar se estiver familiarizado com, você sabe, qualquer outra linguagem de programação. Não é fácil visualizar como quicksortrealmente se comporta em Haskell em termos de acessos à memória ou mesmo a ordem das comparações. Se você pudesse apenas observar o comportamento, e não o código-fonte, não reconheceria o que ele está fazendo como um quicksort .

Por exemplo, a versão C do quicksort particiona todos os dados antes da primeira chamada recursiva. Na versão Haskell, o primeiro elemento do resultado será calculado (e pode até aparecer em sua tela) antes que a execução da primeira partição termine - na verdade, antes que qualquer trabalho seja concluído greater.

PS O código Haskell seria mais semelhante a quicksort se fizesse o mesmo número de comparações que quicksort; o código conforme escrito faz o dobro de comparações porque lessere greatersão especificados para serem calculados independentemente, fazendo duas varreduras lineares na lista. É claro que, em princípio, é possível que o compilador seja inteligente o suficiente para eliminar as comparações extras; ou o código pode ser alterado para uso Data.List.partition.

PPS O exemplo clássico de algoritmos Haskell que não se comportam como você esperava é a peneira de Eratóstenes para computar os primos.


2
lpaste.net/108190 . - está fazendo o "tipo de árvore desmatada", há um velho tópico do reddit sobre isso. cf. stackoverflow.com/questions/14786904/… e relacionados.
— Will Ness

1
parece Sim, essa é uma caracterização muito boa do que o programa realmente faz.
— Jason Orendorff

Sobre a observação da peneira, se fosse escrita como equivalente primes = unfoldr (\(p:xs)-> Just (p, filter ((> 0).(`rem` p)) xs)) [2..], seu problema mais imediato seria talvez mais claro. E isso antes de considerarmos a mudança para o algoritmo de peneira verdadeiro.
— Will Ness

Estou confuso com a sua definição de que código "parece que tem". Seu código "parece" para mim como se putStrLnchamasse um aplicativo convertido de showpara um aplicativo convertido de quicksortpara uma lista literal --- e é exatamente isso que ele faz! (antes da otimização --- mas compare o código C com o montador otimizado algum dia!). Talvez você queira dizer "graças à avaliação preguiçosa, um programa Haskell não faz o que um código de aparência semelhante faz em outras linguagens"?
— Jonathan Cast

4
@jcast Eu realmente acho que há uma diferença prática entre C e Haskell a esse respeito. É realmente difícil manter um debate agradável sobre esse tipo de assunto em um tópico de comentários, por mais que eu adoraria discuti-lo durante um café na vida real. Avise-me se você estiver em Nashville com uma hora de sobra!
— Jason Orendorff

12

Eu acredito que a razão pela qual a maioria das pessoas diz que o bonito Haskell Quicksort não é um "verdadeiro" Quicksort é o fato de que ele não está no lugar - claramente, não pode ser quando se usa tipos de dados imutáveis. Mas também há a objeção de que não é "rápido": em parte por causa do ++ caro, e também porque há um vazamento de espaço - você se agarra à lista de entrada enquanto faz a chamada recursiva nos elementos menores, e em alguns casos - por exemplo, quando a lista está diminuindo - isso resulta em uso de espaço quadrático. (Você pode dizer que fazê-lo funcionar no espaço linear é o mais próximo que você pode chegar do "local" usando dados imutáveis.) Existem soluções simples para ambos os problemas, usando parâmetros de acumulação, tuplagem e fusão; consulte S7.6.1 de Richard Bird '


4

Não é a ideia de elementos mutantes no local em ambientes puramente funcionais. Os métodos alternativos neste segmento com matrizes mutáveis ​​perderam o espírito de pureza.

Existem pelo menos duas etapas para otimizar a versão básica (que é a versão mais expressiva) de classificação rápida.

  1. Otimize a concatenação (++), que é uma operação linear, 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. Otimize para classificação rápida ternária (partição de 3 vias, mencionada por Bentley e Sedgewick), para lidar com 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 e 3, consulte o livro 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, alternativamente, se os elementos duplicados não forem a maioria:

    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

Infelizmente, mediana de três não pode ser implementada com o mesmo efeito, por exemplo:

    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 ainda tem um desempenho insatisfatório nos 4 casos a seguir:

  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 esses 4 casos são bem tratados pela abordagem imperativa de mediana de três.

Na verdade, o algoritmo de classificação mais adequado para uma configuração puramente funcional ainda é a classificação por mesclagem, mas não a classificação rápida.

Para obter detalhes, visite minha redação contínua em: https://sites.google.com/site/algoxy/dcsort


Há outra otimização que você perdeu: use a partição em vez de 2 filtros para produzir as sublistas (ou dobre em uma função interna semelhante para produzir 3 sublistas).
— Jeremy List

3

Não há uma definição clara do que é e do que não é um verdadeiro quicksort.

Eles estão chamando de não uma classificação rápida verdadeira, porque não classifica no local:

A verdadeira classificação rápida em C classifica no local


-1

Porque tirar o primeiro elemento da lista resulta em um tempo de execução muito ruim. Use a mediana de 3: primeiro, meio, último.


2
Pegar o primeiro elemento está ok se a lista for aleatória.
— Keith Thompson,

2
Mas classificar uma lista classificada ou quase classificada é comum.
— Josué,

7
Mas qsort IS O(n^2)
— Thomas Eding,

8
qsort é médio n log n, pior n ^ 2.
— Josué,

3
Tecnicamente, não é pior do que escolher um valor aleatório, a menos que a entrada já esteja classificada ou quase classificada. Os pivôs ruins são os pivôs que estão longe da mediana; o primeiro elemento só é um pivô ruim se estiver próximo do mínimo ou máximo.
— Platinum Azure

-1

Peça a qualquer pessoa para escrever quicksort em Haskell, e você obterá essencialmente o mesmo programa - é obviamente um quicksort. Aqui estão algumas vantagens e desvantagens:

Pro: melhora a classificação rápida "verdadeira" por ser estável, ou seja, preserva a ordem da sequência entre elementos iguais.

Pro: É trivial generalizar para uma divisão de três vias (<=>), o que evita o comportamento quadrático devido a algum valor ocorrer O (n) vezes.

Pro: é mais fácil de ler - mesmo que fosse necessário incluir a definição de filtro.

Contra: usa mais memória.

Contra: É caro generalizar a escolha do pivô por amostragem adicional, o que poderia evitar o comportamento quadrático em certas ordenações de baixa entropia.

Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.