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?

ribossomo
fonte
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!

tapinha
fonte
17
informit.com/articles/article.aspx?p=1407357&seqNum=3 - Andrey Alexandrescu
The_Ghost
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
Klapaucius
fonte
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.

Dan Burton
fonte
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.

Keith Thompson
fonte
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.

Hammar
fonte
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.

Jason Orendorff
fonte
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 '

Jeremy Gibbons
fonte
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

Larry LIU Xinyu
fonte
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

Piotr Praszmo
fonte
-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.

Joshua
fonte
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.

mercator
fonte