Haskell: listas, matrizes, vetores, sequências

230

Estou aprendendo Haskell e leio alguns artigos sobre diferenças de desempenho das listas Haskell e (insira seu idioma) as matrizes.

Como aprendiz, obviamente, apenas uso listas sem nem pensar na diferença de desempenho. Recentemente, comecei a investigar e encontrei inúmeras bibliotecas de estrutura de dados disponíveis em Haskell.

Alguém pode explicar a diferença entre Listas, Matrizes, Vetores, Sequências sem aprofundar a teoria das estruturas de dados em ciência da computação?

Além disso, existem alguns padrões comuns em que você usaria uma estrutura de dados em vez de outra?

Existem outras formas de estruturas de dados que estão faltando e que podem ser úteis?

r.sendecky
fonte
1
Veja esta resposta sobre listas versus matrizes: stackoverflow.com/questions/8196667/haskell-arrays-vs-lists Os vetores têm basicamente o mesmo desempenho que as matrizes, mas uma API maior.
Grzegorz Chrupała
Seria bom ver o Data.Map discutido aqui também. Parece uma estrutura de dados útil, especialmente para dados multidimensionais.
Martin Capodici

Respostas:

339

Listas de rock

De longe, a estrutura de dados mais amigável para dados seqüenciais em Haskell é a Lista

 data [a] = a:[a] | []

As listas fornecem cons (1) contras e correspondência de padrões. A biblioteca padrão, e para que o assunto o prelúdio, é cheio de funções de lista úteis que deve ninhada seu código ( foldr, map, filter). As listas são persistentes , também conhecidas como puramente funcionais, o que é muito bom. As listas de Haskell não são realmente "listas" porque são coindutivas (outros idiomas chamam esses fluxos), então coisas como

ones :: [Integer]
ones = 1:ones

twos = map (+1) ones

tenTwos = take 10 twos

trabalhar maravilhosamente. Estruturas infinitas de dados.

As listas em Haskell fornecem uma interface semelhante aos iteradores em linguagens imperativas (devido à preguiça). Portanto, faz sentido que eles sejam amplamente utilizados.

Por outro lado

O primeiro problema com as listas é que indexá-las (!!)leva ϴ (k), o que é irritante. Além disso, os anexos podem ser lentos++ , mas o modelo de avaliação preguiçoso de Haskell significa que eles podem ser tratados como totalmente amortizados, se acontecerem.

O segundo problema com as listas é que elas têm pouca localidade de dados. Processadores reais incorrem em altas constantes quando objetos na memória não são dispostos um ao lado do outro. Portanto, em C ++ std::vector, o "snoc" é mais rápido (colocando objetos no final) do que qualquer estrutura de dados de lista vinculada pura que eu conheço, embora essa não seja uma estrutura de dados persistente tão menos amigável que as listas de Haskell.

O terceiro problema com as listas é que elas têm pouca eficiência de espaço. Muitos ponteiros extras aumentam seu armazenamento (por um fator constante).

Sequências são funcionais

Data.Sequenceé baseado internamente em árvores de dedos (eu sei, você não quer saber disso), o que significa que elas têm algumas propriedades legais

  1. Puramente funcional. Data.Sequenceé uma estrutura de dados totalmente persistente.
  2. Acesso rápido ao início e fim da árvore. ϴ (1) (amortizado) para obter o primeiro ou o último elemento ou anexar árvores. Nas listas de coisas mais rápidas, Data.Sequenceé no máximo uma constante mais lenta.
  3. Log (log n) acesso ao meio da sequência. Isso inclui inserir valores para criar novas seqüências
  4. API de alta qualidade

Por outro lado, Data.Sequencenão faz muito pelo problema da localidade dos dados e funciona apenas para coleções finitas (é menos preguiçoso que as listas)

Matrizes não são para os fracos de coração

As matrizes são uma das estruturas de dados mais importantes no CS, mas não se encaixam muito bem no mundo funcional puro e preguiçoso. As matrizes fornecem ϴ (1) acesso ao meio da coleta e excepcionalmente boa localização de dados / fatores constantes. Mas, como eles não se encaixam muito bem em Haskell, eles são uma dor de usar. Na verdade, existem vários tipos diferentes de matrizes na biblioteca padrão atual. Isso inclui matrizes totalmente persistentes, matrizes mutáveis ​​para a mônada de E / S, matrizes mutáveis ​​para a mônada ST e versões sem caixa das opções acima. Para saber mais, confira o wiki do haskell

Vetor é uma matriz "melhor"

O Data.Vectorpacote fornece toda a qualidade da matriz, em uma API mais limpa e de nível superior. A menos que você realmente saiba o que está fazendo, use-os se precisar de uma matriz como desempenho. Obviamente, algumas advertências ainda se aplicam - array mutável, como estruturas de dados, simplesmente não funcionam bem em linguagens lentas e puras. Ainda assim, às vezes você deseja o desempenho O (1) e o Data.Vectorentrega em um pacote utilizável.

Você tem outras opções

Se você deseja apenas listas com a capacidade de inserir com eficiência no final, use uma lista de diferenças . O melhor exemplo de lista que estraga o desempenho tende a vir de [Char]onde o prelúdio aliasou como String. CharAs listas são convenientes, mas tendem a ser executadas na ordem 20 vezes mais lenta que as seqüências C, portanto, fique à vontade para usar Data.Textou muito rapidamente Data.ByteString. Tenho certeza de que existem outras bibliotecas orientadas a sequência nas quais não estou pensando agora.

Conclusão

Mais de 90% do tempo em que preciso de uma coleção seqüencial nas listas Haskell são a estrutura de dados correta. As listas são como iteradores, funções que consomem listas podem ser facilmente usadas com qualquer uma dessas outras estruturas de dados, usando as toListfunções fornecidas. Em um mundo melhor, o prelúdio seria totalmente paramétrico quanto ao tipo de contêiner que ele usa, mas atualmente []desarruma a biblioteca padrão. Então, usar listas (quase) em todos os lugares é definitivamente bom.
Você pode obter versões totalmente paramétricas da maioria das funções da lista (e é nobre usá-las)

Prelude.map                --->  Prelude.fmap (works for every Functor)
Prelude.foldr/foldl/etc    --->  Data.Foldable.foldr/foldl/etc
Prelude.sequence           --->  Data.Traversable.sequence
etc

De fato, Data.Traversabledefine uma API que é mais ou menos universal em qualquer "lista como".

Ainda assim, embora você possa ser bom e escrever apenas códigos totalmente paramétricos, a maioria de nós não é e usa a lista em todo o lugar. Se você está aprendendo, sugiro fortemente que você também.


EDIT: Com base nos comentários eu percebo que eu nunca explicou quando usar Data.Vectorvs Data.Sequence. Matrizes e vetores fornecem operações de indexação e divisão extremamente rápidas, mas são estruturas de dados fundamentalmente transitórias (imperativas). Estruturas de dados funcionais puras gostam Data.Sequencee []permitem produzir eficientemente novos valores a partir de valores antigos, como se você tivesse modificado os valores antigos.

  newList oldList = 7 : drop 5 oldList

não modifica a lista antiga e não precisa copiá-la. Portanto, mesmo que oldListseja incrivelmente longa, essa "modificação" será muito rápida. similarmente

  newSequence newValue oldSequence = Sequence.update 3000 newValue oldSequence 

produzirá uma nova sequência com a newValueno lugar de seu elemento 3000. Novamente, não destrói a sequência antiga, apenas cria uma nova. Mas isso é feito com muita eficiência, usando O (log (min (k, kn)), em que n é o comprimento da sequência ek é o índice que você modifica.

Você não pode fazer isso facilmente com Vectorse Arrays. Eles podem ser modificados, mas essa é uma modificação imperativa real e, portanto, não pode ser feita no código Haskell regular. Isso significa operações no Vectorpacote que fazem modificações snoce consprecisam copiar o vetor inteiro, para levar O(n)tempo. A única exceção é que você pode usar a versão mutável ( Vector.Mutable) dentro da STmônada (ou IO) e fazer todas as suas modificações como faria em uma linguagem imperativa. Quando você termina, "congela" seu vetor para se transformar na estrutura imutável que deseja usar com código puro.

Meu sentimento é que você deve usar o padrão Data.Sequencese uma lista não for apropriada. Use Data.Vectorapenas se o seu padrão de uso não envolver muitas modificações ou se você precisar de desempenho extremamente alto nas mônadas do ST / IO.

Se toda essa conversa sobre a STmônada está deixando você confuso: mais um motivo para se ater ao puro rápido e bonito Data.Sequence.

Philip JF
fonte
45
Um insight que ouvi é que as listas são basicamente tanto uma estrutura de controle quanto uma estrutura de dados em Haskell. E isso faz sentido: onde você usaria um loop for C-style em um idioma diferente, usaria uma [1..]lista em Haskell. As listas também podem ser usadas para coisas divertidas, como retroceder. Pensar nelas como estruturas de controle (mais ou menos) realmente ajudou a entender como elas são usadas.
Tikhon Jelvis
21
Excelente resposta. Minha única reclamação é que "Seqüências são funcionais" está subestimando um pouco. As sequências são uma solução funcional impressionante. Um outro bônus para eles é a junção e divisão rápidas (log n).
22812 Dan Burton
3
@DanBurton Fair. Eu provavelmente vendi menos Data.Sequence. As árvores de dedos são uma das invenções mais impressionantes da história da computação (Guibas provavelmente deve receber um prêmio Turing algum dia) e Data.Sequenceé uma excelente implementação e possui uma API muito útil.
Philip JF
3
"UseData.Vector apenas se o padrão de uso não envolve fazer muitas modificações, ou se precisar de desempenho extremamente alto dentro das mônadas ST / io .." texto interessante, porque se você está fazendo muitas modificações (como repetidamente (100k vezes) evoluindo 100k elementos), então você fazer necessidade ST / IO Vector para obter um desempenho aceitável,
misterbee
4
As preocupações com os vectores (puro) e a cópia são parcialmente atenuadas através de fusão de fluxo, por exemplo, esta: import qualified Data.Vector.Unboxed as VU; main = print (VU.cons 'a' (VU.replicate 100 'b'))compila para uma única alocação de 404 bytes (101 caracteres) no núcleo: hpaste.org/65015
FunctorSalad