Перечислить производительность манипуляции в Haskell

В настоящее время я изучаю Haskell, и мне интересно узнать следующее:

Если я добавлю элемент в список в Haskell, Haskell вернет новый (полностью?) новый список и не будет работать с исходным.

Теперь скажем, что у меня есть список из миллиона элементов, и я добавляю один элемент в конце. Haskell "копирует" весь список (1 миллион элементов) и добавляет элемент в эту копию? Или есть аккуратный "трюк", идущий за кулисами, чтобы избежать копирования всего списка?

И если нет "трюка", процесс копирования больших списков не так дорог, как мне кажется?

Ответ 1

Это зависит от структуры данных, которую вы используете. Если вы используете обычные списки Haskell, они будут аналогичны типичной реализации связанных списков в C или С++. С этой структурой добавляется сложность O (n), в то время как prepends - это сложность O (1). Если вы попытаетесь добавить миллион элементов, тогда потребуется O (500000500000) time (O (1) + O (2) + O (3) +... + O (1000000)) приблизительно 500000500000 операций. Это независимо от того, какой язык вы используете, Haskell, C, С++, Python, Java, С# или даже Assembler.

Однако, если вы должны использовать структуру типа Data.Sequence.Seq, тогда она использует внутреннюю структуру, чтобы обеспечить O (1) prepends и добавляет, но стоимость в том, что она может занимать немного больше оперативной памяти. Все структуры данных имеют компромиссы, однако, это зависит от того, какой из них вы хотите использовать.

В качестве альтернативы вы также можете использовать Data.Vector.Vector или Data.Array.Array, которые обеспечивают фиксированные и непрерывные массивы памяти, но добавление и добавление являются дорогостоящими, потому что вам нужно скопировать весь массив в новое место в ОЗУ. Индексирование - это O (1), хотя отображение или сворачивание по одной из этих структур было бы намного быстрее, потому что куски массива могут вписываться в ваш кеш процессора одновременно, в отличие от связанных списков или последовательностей, которые имеют элементы, разбросанные по всему вашей оперативной памяти.

Предоставляет ли Haskell "копию" всего списка (1 миллион элементов) и добавляет элемент в эту копию?

Не обязательно, компилятор может определить, можно ли просто изменить последнее значение next указателя на новое значение вместо пустого списка, или если это небезопасно, может потребоваться скопировать весь список. Однако эти проблемы присущи структуре данных, а не языку. В общем, я бы сказал, что списки Haskell лучше, чем C-связанные списки, потому что компилятор более способен анализировать, когда это безопасно, чем программист, а компилятор C не будет делать такого рода анализ, они просто делают то же, что и они сказал.

Ответ 2

Это удивительно сложный вопрос из-за двух особенностей Haskell и GHC:

  • ленивая оценка
  • Список fusion

Список fusion означает, что в некоторых ситуациях GHC может переписать код обработки списка в цикл, который не выделяет ячейки списка. Поэтому в зависимости от контекста, в котором он используется, такой же код может нести никаких дополнительных затрат.

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

example = take 10 ([1..1000000] ++ [1000001])

Фактически, в этом коде take 10 может сливаться с добавлением списка, поэтому он будет таким же, как только [1..10].

Но давайте предположим, что мы потребляем все элементы всех списков, которые мы делаем, и что компилятор не сливает наши операции с списком. Теперь на ваши вопросы:

Если я добавлю элемент в список в Haskell, Haskell вернет новый (полностью?) новый список и не будет работать с исходным. Теперь позвольте сказать, что у меня есть список из миллиона элементов, и я добавляю один элемент в конце. Haskell "копирует" весь список (1 миллион элементов) и добавляет элемент в эту копию? Или есть аккуратный "трюк", идущий за кулисами, чтобы избежать копирования всего списка?

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

(++) :: [a] -> [a] -> [a]
[] ++ ys = ys
(x:xs) ++ ys = x : xs ++ ys

Рассматривая это определение, вы можете сказать, что список ys будет повторно использован в результате. Поэтому, если у нас есть xs = [1..3], ys = [4..5] и xs ++ ys, все они полностью оцениваются и сохраняются в памяти сразу, это будет выглядеть примерно так:

           +---+---+    +---+---+    +---+---+
      xs = | 1 | -----> | 2 | -----> | 3 | -----> []
           +---+---+    +---+---+    +---+---+

           +---+---+    +---+---+ 
      ys = | 4 | -----> | 5 | -----> []
           +---+---+    +---+---+    
             ^
             |
             +------------------------------------+
                                                  |
           +---+---+    +---+---+    +---+---+    |
xs ++ ys = | 1 | -----> | 2 | -----> | 3 | -------+
           +---+---+    +---+---+    +---+---+

Это длинный способ сказать следующее: если вы делаете xs ++ ys, и он не сливается, и вы потребляете весь список, то это создаст копию xs, но повторно использует память для ys.

Но теперь посмотрим еще раз на этот вопрос:

Теперь скажем, что у меня есть список из миллиона элементов, и я добавляю один элемент в конце. Haskell "копирует" весь список (1 миллион элементов) и добавляет элемент в эту копию?

Это будет что-то вроде [1..1000000] ++ [1000001], и да, это скопирует весь миллион элементов. Но, с другой стороны, [0] ++ [1..1000000] будет копировать только [0]. Это эмпирическое правило:

  • Добавление элементов в начале списка наиболее эффективно.
  • Добавление элементов в конце списка часто неэффективно, особенно если вы делаете это снова и снова.

Общие решения такого рода задач:

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

Ответ 3

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

Возьмите добавление "third" в ["first", "second"]: новый список (:) "first" ((:) "second" ((:) "third" [])). Таким образом, первый конструктор должен быть новым, поскольку второй аргумент должен быть новым значением как... Строки не дублируются. Новый список указывает на те же строки в памяти.

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

Теперь, если ваша программа добавляет много к спискам, вы можете использовать разные структуры данных, чтобы иметь возможность добавлять в O (1), например DList форму пакета DList. (https://hackage.haskell.org/package/dlist-0.5/docs/Data-DList.html)