Haskell: Elenchi, matrici, vettori, sequenze


230

Sto imparando Haskell e leggendo un paio di articoli riguardanti le differenze di rendimento degli elenchi Haskell e (inserisci la tua lingua) gli array.

Essendo uno studente, ovviamente uso solo elenchi senza nemmeno pensare alla differenza di prestazioni. Di recente ho iniziato a indagare e ho trovato numerose librerie di strutture dati disponibili in Haskell.

Qualcuno può spiegare la differenza tra Elenchi, matrici, vettori, sequenze senza approfondire la teoria dell'informatica delle strutture di dati?

Inoltre, ci sono alcuni schemi comuni in cui useresti una struttura di dati anziché un'altra?

Esistono altre forme di strutture di dati che mi mancano e potrebbero essere utili?


1
Dai un'occhiata a questa risposta sugli elenchi rispetto agli array: stackoverflow.com/questions/8196667/haskell-arrays-vs-lists I vettori hanno per lo più le stesse prestazioni degli array, ma un'API più grande.
Grzegorz Chrupała

Sarebbe bello vedere anche Data.Map discusso qui. Sembra un'utile struttura di dati soprattutto per i dati multidimensionali.
Martin Capodici,

Risposte:


339

Elenca Rock

La struttura di gran lunga più amichevole per i dati sequenziali in Haskell è l'Elenco

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

Gli elenchi ti danno ϴ (1) contro e corrispondenza dei pattern. La libreria standard, e per questo il preludio, è pieno di funzioni utile elenco che dovrebbe lettiera il codice ( foldr, map, filter). Le liste sono persistenti , anche puramente funzionali, il che è molto bello. Le liste di Haskell non sono in realtà "liste" perché sono coinduttive (altre lingue chiamano questi flussi) quindi cose del genere

ones :: [Integer]
ones = 1:ones

twos = map (+1) ones

tenTwos = take 10 twos

funziona meravigliosamente. Strutture di dati infinite oscillano.

Gli elenchi di Haskell forniscono un'interfaccia molto simile agli iteratori delle lingue imperative (a causa della pigrizia). Quindi, ha senso che siano ampiamente utilizzati.

D'altro canto

Il primo problema con le liste è che indicizzarle (!!)richiede ϴ (k) tempo, il che è fastidioso. Inoltre, le appendici possono essere lente ++, ma il modello di valutazione pigro di Haskell significa che possono essere trattate come completamente ammortizzate, se accadono affatto.

Il secondo problema con gli elenchi è che hanno una scarsa localizzazione dei dati. I processori reali presentano costanti elevate quando gli oggetti in memoria non sono disposti uno accanto all'altro. Quindi, in C ++ std::vectorha "snoc" più veloce (mettendo gli oggetti alla fine) rispetto a qualsiasi struttura di dati di elenchi collegati pura che io conosca, sebbene questa non sia una struttura di dati persistenti così meno amichevole rispetto agli elenchi di Haskell.

Il terzo problema con le liste è che hanno scarsa efficienza di spazio. Mazzi di puntatori extra aumentano la tua memoria (di un fattore costante).

Le sequenze sono funzionali

Data.Sequenceè internamente basato su alberi delle dita (lo so, non lo vuoi sapere), il che significa che hanno delle belle proprietà

  1. Puramente funzionale. Data.Sequenceè una struttura di dati completamente persistente.
  2. Accedi rapidamente all'inizio e alla fine dell'albero. ϴ (1) (ammortizzato) per ottenere il primo o l'ultimo elemento o per aggiungere alberi. Al momento gli elenchi delle cose sono più veloci, Data.Sequenceal massimo è costantemente più lento.
  3. ϴ (registro n) accesso al centro della sequenza. Ciò include l'inserimento di valori per creare nuove sequenze
  4. API di alta qualità

D'altra parte, Data.Sequencenon fa molto per il problema della localizzazione dei dati e funziona solo per raccolte finite (è meno pigro degli elenchi)

Le matrici non sono per i deboli di cuore

Le matrici sono una delle strutture di dati più importanti in CS, ma non si adattano molto bene al pigro mondo funzionale puro. Le matrici forniscono ϴ (1) accesso al centro della raccolta e una localizzazione dei dati eccezionalmente buona / fattori costanti. Ma dal momento che non si adattano molto bene a Haskell, sono un dolore da usare. Ci sono in realtà una moltitudine di diversi tipi di array nella libreria standard corrente. Questi includono array completamente persistenti, array mutabili per la monade IO, array mutabili per la monade ST e versioni non boxate di quanto sopra. Per ulteriori informazioni, consulta il wiki di haskell

Il vettore è un array "migliore"

Il Data.Vectorpacchetto fornisce tutta la bontà dell'array, in un'API di livello superiore e più pulita. A meno che tu non sappia davvero cosa stai facendo, dovresti usarli se hai bisogno di prestazioni simili ad array. Ovviamente, si applicano ancora alcuni avvertimenti: array mutabili come strutture di dati non giocano proprio in linguaggi pigri puri. Tuttavia, a volte vuoi quella O (1) performance e te la Data.Vectoroffre in un pacchetto utilizzabile.

Hai altre opzioni

Se desideri solo elenchi con la possibilità di inserirli in modo efficiente alla fine, puoi utilizzare un elenco di differenze . Il miglior esempio di elenchi che rovinano le prestazioni tende a venire da [Char]cui il preludio è aliasato String. Chargli elenchi sono comodi, ma tendono a funzionare nell'ordine 20 volte più lento delle stringhe C, quindi sentiti libero di usare Data.Texto molto veloce Data.ByteString. Sono sicuro che ci sono altre librerie orientate alla sequenza a cui non sto pensando in questo momento.

Conclusione

Il 90% delle volte in cui ho bisogno di una raccolta sequenziale negli elenchi di Haskell è la giusta struttura di dati. Gli elenchi sono come gli iteratori, le funzioni che consumano elenchi possono essere facilmente utilizzate con una qualsiasi di queste altre strutture di dati utilizzando le toListfunzioni fornite. In un mondo migliore il preludio sarebbe completamente parametrico sul tipo di contenitore che utilizza, ma attualmente []sporca la libreria standard. Quindi, usare le liste (quasi) ogni dove va decisamente bene.
Puoi ottenere versioni completamente parametriche della maggior parte delle funzioni dell'elenco (e non puoi usarle)

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

In effetti, Data.Traversabledefinisce un'API che è più o meno universale in qualsiasi cosa "elenca".

Tuttavia, sebbene tu possa essere bravo e scrivere solo codice completamente parametrico, la maggior parte di noi non lo è e usa la lista ovunque. Se stai imparando, ti consiglio vivamente di farlo anche tu.


EDIT: Sulla base di commenti mi rendo conto che non ho mai spiegato quando utilizzare Data.Vectorvs Data.Sequence. Le matrici e i vettori forniscono operazioni di indicizzazione e suddivisione estremamente veloci, ma sono strutture di dati fondamentalmente transitorie (imperative). Strutture di dati funzionali puri gradiscono Data.Sequencee []consentono di produrre in modo efficiente nuovi valori da vecchi valori come se avessi modificato i vecchi valori.

  newList oldList = 7 : drop 5 oldList

non modifica il vecchio elenco e non deve copiarlo. Quindi, anche se oldListè incredibilmente lunga, questa "modifica" sarà molto veloce. allo stesso modo

  newSequence newValue oldSequence = Sequence.update 3000 newValue oldSequence 

produrrà una nuova sequenza con un newValuefor al posto del suo 3000 elemento. Ancora una volta, non distrugge la vecchia sequenza, ma ne crea solo una nuova. Ma lo fa in modo molto efficiente, prendendo O (log (min (k, kn)) dove n è la lunghezza della sequenza e k è l'indice che si modifica.

Non puoi farlo facilmente con Vectorse Arrays. Possono essere modificati, ma questa è una vera e propria modifica imperativa, e quindi non può essere eseguita nel normale codice Haskell. Ciò significa che le operazioni nel Vectorpacchetto che apportano modifiche come snoce consdevono copiare l'intero vettore, quindi richiedono O(n)tempo. L'unica eccezione a questo è che puoi usare la versione mutabile ( Vector.Mutable) all'interno della STmonade (o IO) e fare tutte le tue modifiche proprio come faresti in un linguaggio imperativo. Quando hai finito, "congeli" il tuo vettore per trasformarlo nella struttura immutabile che vuoi usare con puro codice.

La mia sensazione è che dovresti usare di default Data.Sequencese un elenco non è appropriato. Utilizzare Data.Vectorsolo se il proprio modello di utilizzo non comporta l'esecuzione di numerose modifiche o se sono necessarie prestazioni estremamente elevate all'interno delle monadi ST / IO.

Se tutti questi discorsi sulla STmonade ti lasciano confuso: una ragione in più per restare fedeli al puro, veloce e bello Data.Sequence.


45
Un'intuizione che ho sentito è che gli elenchi sono sostanzialmente tanto una struttura di controllo quanto una struttura di dati in Haskell. E questo ha senso: dove useresti uno stile C per loop in una lingua diversa, useresti un [1..]elenco in Haskell. Le liste possono anche essere usate per cose divertenti come il backtracking. Pensare a loro come a strutture di controllo (una sorta di) ha davvero contribuito a dare un senso a come vengono utilizzate.
Tikhon Jelvis

21
Risposta eccellente. La mia unica lamentela è che "Le sequenze sono funzionali" le sta sottovalutando un po '. Le sequenze sono sorprendenti dal punto di vista funzionale. Un altro bonus per loro è unire e dividere rapidamente (log n).
Dan Burton

3
@DanBurton Fair. Probabilmente ho sottovalutato Data.Sequence. Gli alberi delle dita sono una delle invenzioni più straordinarie nella storia dell'informatica (un giorno Guibas dovrebbe probabilmente ottenere un premio Turing) ed Data.Sequenceè un'implementazione eccellente e ha un'API molto utilizzabile.
Philip JF

3
"UseData.Vector solo se il vostro modello di utilizzo non comporta fare molte modifiche, o se avete bisogno di prestazioni estremamente elevate all'interno delle monadi ST / io .." formulazione Interessante, perché se si stanno facendo molte modifiche (come più volte (100k volte) evoluzione 100k elementi), allora si fa necessità ST / IO vettore per ottenere prestazioni accettabili,
misterbee

4
Le preoccupazioni relative ai vettori (puri) e alla copia sono parzialmente alleviate dalla fusione dei flussi, ad esempio: import qualified Data.Vector.Unboxed as VU; main = print (VU.cons 'a' (VU.replicate 100 'b'))compila in un'unica allocazione di 404 byte (101 caratteri) in Core: hpaste.org/65015
FunctorSalad
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.