Чому Haskell і Scheme використовують спільно пов'язані списки?


12

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


конструктор списку може вставляти на початок окремо пов'язаного списку, не змінюючи початковий список. Це важливо для функціонального програмування. Двозначно пов'язаний список в основному включає зміни, які не дуже чисті.
tp1

3
Подумайте над цим, як би ви навіть склали подвійний незмінний список? Потрібно мати nextвказівник попереднього елемента на наступний елемент і prevвказівник наступного елемента на попередній елемент. Однак один з цих двох елементів створюється перед іншим, а значить, один з цих елементів повинен мати вказівник, що вказує на об'єкт, який ще не існує! Пам'ятайте, що ви не можете створити спочатку один елемент, потім інший, а потім встановити покажчики - вони незмінні. (Примітка. Я знаю, що існує спосіб, що використовує лінь, який називається "Зав'язування вузла".)
Jörg W Mittag

1
Подвійно пов'язані списки зазвичай є непотрібними у більшості випадків. Якщо вам потрібно було отримати доступ до них у зворотному порядку, висуньте елементи в списку на стек і викладіть їх один за одним для алгоритму розвороту O (n).
Ніл

Відповіді:


23

Що ж, якщо ви заглянете трохи глибше, обидва насправді включають масиви і в основну мову:

  • 5-й переглянутий звіт про схему (R5RS) включає векторний тип , який представляє собою цілочисельні індексовані колекції фіксованого розміру з кращим, ніж лінійний час, для випадкового доступу.
  • Звіт Haskell 98 також має тип масиву .

Однак інструкція з функціонального програмування давно наголошує на односкладених списках над масивами або подвійними зв'язаними списками. Досить ймовірним, насправді. Однак є кілька причин.

По-перше, одне зв'язані списки є одним з найпростіших і в той же час найбільш корисних рекурсивних типів даних. Визначений користувачем еквівалент типу списку Haskell можна визначити так:

data List a           -- A list with element type `a`...
  = Empty             -- is either the empty list...
  | Cell a (List a)   -- or a pair with an `a` and the rest of the list. 

Те, що списки є рекурсивним типом даних, означає, що функції, що працюють над списками, зазвичай використовують структурну рекурсію . У термінах Haskell: ви узгоджуєте структуру списку на конструкторах списку, і повторюєте їх на підрозділі списку. У цих двох основних визначеннях функції я використовую змінну asдля позначення хвоста списку. Тому зауважте, що рекурсивні дзвінки "спускаються" вниз за списком:

map :: (a -> b) -> List a -> List b
map f Empty = Empty
map f (Cell a as) = Cell (f a) (map f as)

filter :: (a -> Bool) -> List a -> List a
filter p Empty = Empty
filter p (Cell a as)
    | p a = Cell a (filter p as)
    | otherwise = filter p as

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

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

Друга причина - це не причина «чому однозв’язані списки», а більше причина «чому не подвійні зв'язані списки або масиви»: ті останні типи даних часто вимагають мутації (модифіковані змінні), які функціональне програмування дуже часто ухиляється від. Отже, як це відбувається:

  • Такою мовою, як "Схема", ви не можете скласти подвійний список без використання мутації.
  • На ледачій мові, як Haskell, ви можете скласти подвійний список, не використовуючи мутації. Але щоразу, коли ви складаєте новий список, виходячи з цього, ви змушені копіювати більшість, якщо не всю структуру оригіналу. У той час як із односхиленими списками ви можете записувати функції, які використовують "спільний доступ до структури" - нові списки можуть використовувати повторно клітини старих списків, коли це доречно.
  • Традиційно, якщо ви використовували масиви незмінним чином, це означало, що кожного разу, коли ви хотіли змінити масив, вам довелося скопіювати всю справу. ( vectorОднак останні бібліотеки Haskell, як-от , знайшли методи, які значно покращують цю проблему).

Третя і остання причина стосується ледачих мов, таких як Haskell, насамперед: ліниві односкладені списки на практиці часто більше схожі на ітератори, ніж на власні списки пам'яті. Якщо ваш код споживає елементи списку послідовно та викидає їх у процесі руху, об'єктний код лише матеріалізує клітинки списку та його вміст під час кроку вперед через список.

Це означає, що весь список не повинен існувати в пам'яті відразу, а лише поточна комірка. Клітини перед поточним може бути зібрано сміття (що неможливо зі списком подвійного зв’язку); комірки пізніше поточного не потрібно обчислювати, поки ви не потрапите туди.

Це іде навіть далі. Існує техніка, яка використовується в декількох популярних бібліотеках Haskell, що називається fusion , де компілятор аналізує ваш код обробки списків і відмічає проміжні списки, які генеруються та споживаються послідовно, а потім «викидаються». За допомогою цих знань компілятор може повністю усунути розподіл пам'яті комірок цих списків. Це означає, що односпрямований список у вихідній програмі Haskell після компіляції може фактично перетворитися на цикл замість структури даних.

Fusion - це також техніка, яку згадана vectorбібліотека використовує для створення ефективного коду для незмінних масивів. Те ж саме стосується надзвичайно популярних bytestring(байтових масивів) та text(рядків Unicode) бібліотек, які були побудовані як заміна не надто великого рідного Stringтипу Haskell (який такий самий, як [Char]односпрямований список символів). Тож у сучасному Haskell існує тенденція, коли незмінні типи масивів із підтримкою синтезу стають дуже поширеними.

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

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


1
Яка чудова відповідь!
Елліот Гороховський

14

Тому що вони добре працюють з незмінністю. Припустимо, у вас є два незмінні списки [1, 2, 3]та [10, 2, 3]. Представлені як окремо пов'язані списки, де кожен елемент у списку - це вузол, що містить елемент та вказівник на решту списку, вони виглядатимуть так:

node -> node -> node -> empty
 1       2       3

node -> node -> node -> empty
 10       2       3

Подивіться, як [2, 3]порції однакові? У структурах даних, що змінюються, вони складаються з двох різних списків, тому що код запису нових даних до однієї з них не повинен впливати на код, використовуючи інший. Однак, маючи незмінні дані, ми знаємо, що вміст списків ніколи не змінюватиметься і код не може записувати нові дані. Таким чином, ми можемо повторно використовувати хвости та мати два списки, які поділяють частину їх структури:

node -> node -> node -> empty
 1      ^ 2       3
        |
node ---+
 10

Оскільки код, що використовує два списки, ніколи їх не буде мутувати, ми ніколи не повинні турбуватися про зміни одного списку, що впливає на інший. Це також означає, що додаючи елемент на передню частину списку, вам не доведеться копіювати та складати зовсім новий список.

Однак якщо ви спробуєте представити [1, 2, 3]і [10, 2, 3]як подвійно пов'язані списки:

node <-> node <-> node <-> empty
 1       2       3

node <-> node <-> node <-> empty
 10       2       3

Тепер хвости вже не однакові. Перший [2, 3]має вказівник 1на голову, але другий має вказівник на 10. Крім того, якщо ви хочете додати новий елемент до заголовка списку, ви повинні вимкнути попередній заголовок списку, щоб він вказував на нову заголовку.

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


8
Хоча спільний обмін хвостом не відбувається так, як ви маєте на увазі. Як правило, ніхто не проходить усі списки в пам’яті і не шукає можливостей для об’єднання загальних суфіксів. Обмін просто відбувається , і це випадає з того, як записані алгоритми, наприклад, якщо функція з параметром xsбудується 1:xsв одному місці та 10:xsв іншому.

0

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

Об'єкти та посилання

Ці мови, як правило, призначають (або припускають) об'єкти, що мають незв'язані динамічні розширення (або, кажучи мовами , термін служби , хоча не зовсім однакові через різницю значень об'єктів серед цих мов, див. Нижче) за замовчуванням, уникаючи посилань на першокласні ( наприклад, вказівники на об'єкти в C) і непередбачувана поведінка в семантичних правилах (наприклад, невизначена поведінка ISO C, пов'язана з семантикою).

Далі, поняття (першокласного) об'єктів у таких мовах є консервативно обмежувальним: жодні "локативні" властивості не визначені та гарантовані за замовчуванням. Це зовсім інше в деяких мовах, схожих на ALGOL, об'єкти яких не мають незв'язаних динамічних розширень (наприклад, на C і C ++), де об'єкти в основному означають певний тип "типового сховища", як правило, поєднаного з місцями пам'яті.

Кодування пам’яті в об'єктах має додаткові переваги, такі як можливість приєднувати детерміновані обчислювальні ефекти протягом усього їхнього життя, але це інша тема.

Проблеми моделювання структур даних

Без першокласних посилань однозначно пов'язані списки не можуть імітувати багато традиційних (нетерплячих / змінних) структур даних ефективно та портативно, через характер представлення цих структур даних та обмеженість примітивних операцій на цих мовах. (Навпаки, в C ви можете отримати зв'язані списки досить легко навіть у строго відповідній програмі .) І такі альтернативні структури даних, як масиви / вектори, мають деякі переважні властивості порівняно зі списками, що зв'язані одночасно. Ось чому R 5 RS вводить нові примітивні операції.

Але існують типи векторів / масивів відмінностей порівняно з подвійно пов'язаними списками. Масив часто передбачається з O (1) складністю часу доступу та меншою накладними витратами, які є чудовими властивостями, які не поділяються списками. (Хоча строго кажучи, це не гарантується ISO C, але користувачі майже завжди цього очікують, і жодна практична реалізація не порушує ці неявні гарантії занадто очевидно.) OTOH, подвійно пов'язаний список часто робить обидва властивості навіть гіршими, ніж окремо пов'язаний список , тоді як ітерація назад / вперед також підтримується масивом або вектором (разом з цілими індексами) з ще меншими накладними витратами. Таким чином, подвійно пов'язаний список не працює в цілому краще. Ще гірше, продуктивність щодо ефективності кеш-пам'яті та затримки в динамічному розподілі пам'яті списків катастрофічно гірша, ніж продуктивність для масивів / векторів при використанні алокатора за замовчуванням, що надається базовим середовищем реалізації (наприклад, libc). Тому без дуже специфічного та «розумного» часу виконання, що сильно оптимізує такі створення об’єктів, масиви / векторні типи часто віддають перевагу зв'язаним спискам. (Наприклад, за допомогою ISO C ++, є застереження, щоstd::vectorслід віддати перевагу std::listза замовчуванням.) Таким чином, введення нових примітивів для спеціально підтримуваних (подвійно) пов'язаних списків, безумовно, не настільки вигідно, як підтримувати масиви / векторні структури даних на практиці.

Справедливості, списки все ще мають деякі конкретні властивості краще, ніж масиви / вектори:

  • Списки засновані на вузлах. Видалення елементів зі списків не приводить до недійсного посилання на інші елементи в інших вузлах. (Це також справедливо для деяких структур даних про дерево або графік.) OTOH, масиви / вектори можуть посилатися на визнання недійсним позиції (з великим перерозподілом в деяких випадках).
  • Списки можуть зрощуватися в O (1) час. Реконструкція нових масивів / векторів з поточними - набагато дорожче.

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

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

Незмінюваність та згладжування

Чистою мовою, як Haskell, об'єкти незмінні. Об'єкт схеми часто використовується без мутації. Такий факт дозволяє ефективно підвищити ефективність пам’яті за допомогою інтернування об’єктів - неявного спільного використання декількох об’єктів з однаковим значенням на льоту.

Це агресивна стратегія оптимізації високого рівня в мовному дизайні. Однак це пов'язано з проблемами впровадження. Він фактично вводить неявні псевдоніми в основні комірки зберігання. Це ускладнює аналіз збудження. В результаті, можливо, буде менше можливостей усунути накладні витрати не першокласних посилань, навіть користувачі їх взагалі ніколи не торкаються. У таких мовах, як схема, коли мутація не виключається повністю, це також заважає паралелізму. Це може бути гаразд у ледачій мові (яка вже має проблеми з роботою, спричинені громом), хоча.

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

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.