Почему Haskell и Scheme используют односвязные списки?

12

Двусвязный список имеет минимальные накладные расходы (просто еще один указатель на ячейку) и позволяет добавлять к обоим концам, переходить назад и вперед и, как правило, получать массу удовольствия.

Эллиот Гороховский
источник
Конструктор списка можно вставить в начало односвязного списка, без изменения исходного списка. Это важно для функционального программирования. Двусвязный список в значительной степени включает в себя модификации, которые не очень чисты.
tp1
3
Подумайте об этом, как бы вы построили двусвязный неизменный список? Вам нужно, чтобы nextуказатель предыдущего элемента указывал на следующий элемент, а prevуказатель следующего элемента указывал на предыдущий элемент. Однако один из этих двух элементов создается раньше другого, что означает, что один из этих элементов должен иметь указатель, указывающий на объект, который еще не существует! Помните, вы не можете сначала создать один элемент, затем другой, а затем установить указатели - они неизменны. (Примечание: я знаю, что есть способ, использующий лень, который называется «Связывание узла».)
Йорг Миттаг,
1
В большинстве случаев списки с двойной связью обычно не нужны. Если вам нужно было получить к ним обратный доступ, поместите элементы списка в стек и вставьте их один за другим для алгоритма обращения O (n).
Нейл,

Ответы:

23

Что ж, если вы посмотрите немного глубже, на самом деле оба включают массивы и на базовом языке:

  • Пятый пересмотренный Отчет о схеме (R5RS) включает в себя тип вектора , который представляет собой целочисленные индексы фиксированного размера с лучшим, чем линейное время для произвольного доступа.
  • Haskell 98 Report также имеет тип массива .

Однако в инструкциях по функциональному программированию долгое время подчеркивались односвязные списки над массивами или двусвязные списки. Вполне вероятно, переоценена, на самом деле. Однако для этого есть несколько причин.

Во-первых, односвязные списки являются одним из самых простых и в то же время наиболее полезных рекурсивных типов данных. Пользовательский эквивалент типа списка 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

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

Таким образом, односвязные списки, вероятно, являются лучшим типом данных для ознакомления учащихся с этими методами, которые очень важны в функциональном программировании.

Вторая причина - это не столько причина «почему односвязные списки», сколько причина «почему не двойные списки или массивы»: эти последние типы данных часто требуют мутации (изменяемые переменные), что очень часто используется в функциональном программировании. уклоняется от. Итак, как это происходит:

  • В нетерпеливом языке, таком как Scheme, вы не можете создать двойной список без использования мутации.
  • На ленивом языке, таком как Haskell, вы можете создать двойной список без мутации. Но всякий раз, когда вы создаете новый список на основе этого, вы вынуждены копировать большую часть, если не всю структуру оригинала. Принимая во внимание, что с помощью односвязных списков вы можете написать функции, которые используют «совместное использование структуры» - новые списки могут повторно использовать ячейки старых списков, когда это необходимо.
  • Традиционно, если вы использовали массивы неизменным образом, это означало, что каждый раз, когда вы хотели изменить массив, вам приходилось копировать все это. (Недавние библиотеки Haskell, такие как vector, однако, нашли методы, которые значительно улучшают эту проблему).

Третья и последняя причина относится в первую очередь к ленивым языкам, таким как Haskell: ленивые односвязные списки на практике часто больше похожи на итераторы, чем на собственно списки в памяти. Если ваш код потребляет элементы списка последовательно и выбрасывает их по ходу работы, объектный код материализует только ячейки списка и его содержимое по мере продвижения по списку.

Это означает, что весь список не должен существовать в памяти сразу, только текущая ячейка. Ячейки до текущей могут быть собраны мусором (что было бы невозможно с двойным списком); ячейки позже текущей не нужно вычислять, пока вы не доберетесь туда.

Это идет даже дальше, чем это. Существует методика, используемая в нескольких популярных библиотеках Haskell, которая называется fusion , где компилятор анализирует ваш код обработки списков и определяет промежуточные списки, которые генерируются и используются последовательно, а затем «выбрасываются». Обладая этими знаниями, компилятор может полностью исключить выделение памяти для ячеек этих списков. Это означает, что односвязный список в исходной программе на Haskell после компиляции может фактически превратиться в цикл вместо структуры данных.

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

Слияние списков облегчается тем фактом, что в единственном связанном списке вы можете двигаться вперед, но не назад . Это поднимает очень важную тему в функциональном программировании: использование «формы» типа данных для получения «формы» вычислений. Если вы хотите обрабатывать элементы последовательно, односвязный список - это тип данных, который, когда вы используете его со структурной рекурсией, очень естественным образом предоставляет вам этот шаблон доступа. Если вы хотите использовать стратегию «разделяй и властвуй» для атаки на проблему, то древовидные структуры данных, как правило, поддерживают это очень хорошо.

Многие люди рано уходят из функционального программирования, поэтому они получают доступ к односвязным спискам, но не к более продвинутым базовым идеям.

sacundim
источник
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, время жизни , хотя не совсем одно и то же из-за различий в значении объектов в этих языках, см. Ниже) по умолчанию, избегая ссылок первого класса ( например, указатели объектов в 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, объекты неизменны. Объект схемы часто используется без мутаций. Такой факт позволяет эффективно повысить эффективность памяти за счет интернирования объектов - неявного совместного использования нескольких объектов с одинаковым значением на лету.

Это агрессивная стратегия оптимизации высокого уровня в дизайне языка. Однако это связано с проблемами реализации. Это фактически вводит неявные псевдонимы в базовые ячейки памяти. Это затрудняет анализ псевдонимов. В результате, вероятно, будет меньше возможностей для устранения издержек, связанных с ссылками не первого класса, даже пользователи никогда их не трогают. В таких языках, как Scheme, когда мутация не полностью исключена, это также мешает параллелизму. Это может быть хорошо на ленивом языке (у которого уже есть проблемы с производительностью, вызванные thunks в любом случае), все же.

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

FrankHB
источник