Haskell: Списки, масиви, вектори, послідовності


230

Я вивчаю Haskell і читаю пару статей щодо відмінностей у списках Haskell та (вставте вашу мову) масивів.

Будучи студентом, я, очевидно, просто використовую списки, навіть не замислюючись про різницю в роботі. Нещодавно я почав досліджувати і знайшов численні бібліотеки структури даних, доступні в Haskell.

Чи може хтось, будь ласка, пояснити різницю між списками, масивами, векторами, послідовностями, не заглиблюючись у теорію інформатики структур даних?

Крім того, чи є деякі загальні закономірності, де ви б використовували одну структуру даних замість іншої?

Чи є інші форми структур даних, які мені відсутні, і які можуть бути корисними?


1
Подивіться на цю відповідь щодо списків проти масивів: stackoverflow.com/questions/8196667/haskell-arrays-vs-lists Вектори в основному мають таку ж ефективність, як масиви, але більші API.
Grzegorz Chrupała

Було б приємно побачити, як тут обговорювали дані. Це здається корисною структурою даних, особливо для багатовимірних даних.
Мартін Каподічі

Відповіді:


339

Списки рок

На сьогоднішній день найбільш дружньою структурою даних для послідовних даних у Haskell є Список

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

Списки дають ϴ (1) мінуси та відповідність шаблонів. Стандартна бібліотека, а для цього важливо , прелюдія, сповнена корисних функцій списку , які повинні послід ваш код ( foldr, map, filter). Списки є стійкими , вони є суто функціональними, що дуже приємно. Списки Haskell насправді не "списки", тому що вони є спільними (інші мови називають ці потоки), тому подібні речі

ones :: [Integer]
ones = 1:ones

twos = map (+1) ones

tenTwos = take 10 twos

працювати чудово. Нескінченні структури даних гойдаються.

Списки в Haskell надають інтерфейс, подібний до ітераторів на імперативних мовах (через лінь). Отже, має сенс, що вони широко використовуються.

З іншої сторони

Перша проблема зі списками полягає в тому, що для їх індексації (!!)потрібно ϴ (k) час, що дратує. Також придатки можуть бути повільними++ , але лінива модель оцінки Хаскелла означає, що їх можна розглядати як повністю амортизовані, якщо вони взагалі трапляються.

Друга проблема зі списками полягає в тому, що вони мають погану локальність даних. Реальні процесори мають високі константи, коли об'єкти в пам’яті не розташовуються поруч. Так, у С ++std::vector є швидший "snoc" (розміщення об'єктів у кінці), ніж будь-яка чиста структура пов'язаних списків, про яку я знаю, хоча це не стійка структура даних, настільки менш дружна, як списки Haskell.

Третя проблема зі списками полягає в тому, що вони мають низьку ефективність використання простору. Пучки додаткових покажчиків збільшують ваше сховище (постійним фактором).

Послідовності функціональні

Data.Sequenceоснована на пальцевих деревах (я знаю, ви цього не хочете знати), це означає, що вони мають деякі приємні властивості

  1. Чисто функціональний. Data.Sequenceє повністю стійкою структурою даних.
  2. Швидкий доступ до початку та кінця дерева. ϴ (1) (амортизовано), щоб отримати перший або останній елемент, або додати дерева. У речах списки найшвидші, Data.Sequenceмаксимум постійні повільніше.
  3. ϴ (log n) доступ до середини послідовності. Сюди входить вставка значень для створення нових послідовностей
  4. Висока якість API

З іншої сторони, Data.Sequence це не робить багато для проблеми локалізації даних, а працює лише для обмежених колекцій (це менш ліниво, ніж списки)

Масиви не для слабкого серця

Масиви є однією з найважливіших структур даних у CS, але вони не дуже добре вписуються у лінивий чистий функціональний світ. Масиви забезпечують ϴ (1) доступ до середини колекції та надзвичайно гарну локальність даних / постійні фактори. Але, оскільки вони не дуже добре вписуються в Haskell, вони є біль для використання. У поточній стандартній бібліотеці насправді існує безліч різних типів масивів. До них відносяться повністю стійкі масиви, змінні масиви для монади вводу-виводу, змінні масиви для монади СТ та недіагностичні версії вищевказаних. Для більш детальної перевірки вікі haskell

Вектор - це "кращий" масив

У Data.Vectorпакеті надається вся корисність масиву на більш високому рівні та більш чистий API. Якщо ви дійсно не знаєте, чим займаєтесь, вам слід скористатися ними, якщо вам потрібен масив, як продуктивність. Звичайно, деякі застереження все ще застосовуються - змінні масиви, подібні структурам даних, просто не грають добре на чисто лінивих мовах. Тим не менш, іноді ви хочете, щоб O (1) продуктивність, і Data.Vectorдає вам це в корисному пакеті.

У вас є інші варіанти

Якщо ви просто хочете, щоб списки з можливістю ефективно вставляти в кінці, ви можете використовувати список різниць . Найкращий приклад списків, що виконуються на виконання продуктивності, має тенденцію виходити з того, [Char]що прелюдія відійшла як String. Charсписки зручні, але, як правило, працюють на 20 разів повільніше, ніж рядки C, тому сміливо використовуйте Data.Textабо дуже швидкоData.ByteString . Я впевнений, що є інші бібліотеки, орієнтовані на послідовність, про які я зараз не думаю.

Висновок

90 +% часу, коли мені потрібна послідовна колекція у списках Haskell, є правильною структурою даних. Списки подібно до ітераторів, функції, які споживають списки, легко використовуються з будь-якою з цих інших структур даних, використовуючи toListфункції, з якими вони входять. У кращому світі прелюдія була б повністю параметричною щодо типу контейнера, який він використовує, однак на даний момент []є стандартною бібліотекою. Отже, використовуючи списки (майже) кожного, де, безумовно, добре.
Ви можете отримати повністю параметричні версії більшості функцій списку (і їх благородно використовувати)

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

Фактично, Data.Traversableвизначає API, який є більш-менш універсальним для будь-якої речі "подібного списку".

І все-таки, хоча ти можеш бути хорошим і писати лише повністю параметричний код, більшість з нас не є і використовують список у всьому місці. Якщо ви навчаєтесь, я настійно пропоную вам зробити це теж.


EDIT: На основі коментарів , які я розумію , що я ніколи не пояснював , коли використовувати Data.Vectorпроти Data.Sequence. Масиви та вектори забезпечують надзвичайно швидкі операції індексації та нарізки, але є принципово перехідними (імперативними) структурами даних. Чисті функціональні структури даних люблять Data.Sequenceі []дозволяють ефективно створювати нові значення зі старих значень, як якщо б ви змінили старі значення.

  newList oldList = 7 : drop 5 oldList

не змінює старий список, і його не потрібно копіювати. Тож навіть якщо oldListце неймовірно довго, ця "модифікація" буде дуже швидкою. Аналогічно

  newSequence newValue oldSequence = Sequence.update 3000 newValue oldSequence 

створить нову послідовність з а newValueна місці свого 3000 елемента. Знову ж таки, це не знищує стару послідовність, вона просто створює нову. Але це робить це дуже ефективно, беручи O (log (min (k, kn)), де n - довжина послідовності, а k - індекс, який ви змінюєте.

Ви не можете легко зробити це за допомогою Vectorsта Arrays. Вони можуть бути змінені, але це реальна імперативна модифікація, і це не можна робити в звичайному коді Haskell. Це означає, що операції в Vectorпакеті, які вносять модифікації на зразок snocі consповинні копіювати весь вектор, так що потребують O(n)часу. Єдиним винятком з цього є те, що ви можете використовувати мутаційну версію ( Vector.Mutable) всередині STмонади (або IO) і робити всі свої модифікації так, як ви хотіли б в обов'язковому порядку. Коли ви закінчите, ви "заморожуєте" свій вектор, щоб перетворитись на незмінну структуру, яку ви хочете використовувати з чистим кодом.

Моє відчуття, що ви повинні використовувати за замовчуванням, Data.Sequenceякщо список не відповідає. Використовуйте Data.Vectorлише в тому випадку, якщо ваша схема використання не передбачає внесення багатьох модифікацій або якщо вам потрібні надзвичайно високі показники роботи в монадах ST / IO.

Якщо всі ці розмови про STмонаду залишають вас розгубленими: тим більше причин дотримуватися чистого швидкого і красивого Data.Sequence.


45
Я чула, що списки - це в основному стільки контрольної структури, скільки структури даних в Haskell. І це має сенс: там, де ви б використовували C-стиль для циклу на іншій мові, ви б використовували [1..]список у Haskell. Списки також можна використовувати для цікавих речей, таких як зворотний трек. Думаючи про них як про структурах управління (на зразок), справді допомогло зрозуміти, як вони використовуються.
Тихон Єлвіс

21
Відмінна відповідь. Моя єдина скарга - це те, що "Послідовності функціональні" - це їх небагато. Послідовності - це функціональний ефект. Ще один бонус до них - це швидке приєднання та розщеплення (log n).
Ден Бертон

3
@DanBurton Fair. Я, мабуть, недооцінював Data.Sequence. Пальчикові дерева - одне з найдивовижніших винаходів в історії обчислень (Гібас, мабуть, колись повинен отримати нагороду Тюрінга) і Data.Sequenceє чудовою реалізацією та має дуже корисний API.
Філіп JF

3
«UseData.Vector тільки якщо ваш шаблон використання не треба робити багато змін, або якщо потрібно дуже висока продуктивність в межах монади ST / IO ..» Цікава формулювання, тому що якщо ви будете робити багато модифікацій (наприклад , неодноразово (100k раз) еволюціонує 100k елементів), то робити треба ST / IO Vector , щоб отримати прийнятну продуктивність,
misterbee

4
Занепокоєння щодо (чистих) векторів та копіювання частково зменшується синтезом потоків, наприклад, це: import qualified Data.Vector.Unboxed as VU; main = print (VU.cons 'a' (VU.replicate 100 'b'))збирається в єдине виділення 404 байти (101 символ
FunctorSalad
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.