Почему списки различий более эффективны, чем регулярные конкатенации?
В настоящее время я прохожу через книгу Learn you a haskell online и пришел к главе, где автор объясняет, что некоторые конкатенации списков могут быть неэффективными: например
((((a ++ b) ++ c) ++ d) ++ e) ++ f
Предполагается, что он неэффективен. Решение, которое предлагает автор, заключается в использовании «списков различий», определенных как
newtype DiffList a = DiffList {getDiffList :: [a] -> [a] } instance Monoid (DiffList a) where mempty = DiffList (\xs -> [] ++ xs) (DiffList f) `mappend` (DiffList g) = DiffList (\xs -> f (g xs))
Я изо всех сил пытаюсь понять, почему DiffList более эффективен в вычислительной мере, чем простая конкатенация в некоторых случаях. Может ли кто-нибудь объяснить мне простыми словами, почему приведенный выше пример настолько неэффективен, и каким образом DiffList решает эту проблему?
Проблема в
((((a ++ b) ++ c) ++ d) ++ e) ++ f
является гнездованием. Приложения (++)
левые-вложенные, и это плохо; правой вложенности
a ++ (b ++ (c ++ (d ++ (e ++f))))
не будет проблемой. Это потому, что (++)
определяется как
[] ++ ys = ys (x:xs) ++ ys = x : (xs ++ ys)
поэтому, чтобы найти, какое уравнение использовать, реализация должна погрузиться в дерево выражений
(++) / \ (++) f / \ (++) e / \ (++) d / \ (++) c / \ ab
пока не выяснит, пуст ли левый операнд или нет. Если он не пуст, его голова берется и пузырится вверх, но хвост левого операнда остается нетронутым, поэтому, когда требуется следующий элемент конкатенации, повторяет ту же процедуру.
Когда конкатенации правильны, левый операнд (++)
всегда находится наверху, а проверка пустоты / пузырька по голове – O (1).
Но когда конкатенации оставлены вложенными, n
слоев глубокими, чтобы достигнуть первого элемента, нужно пройти n
узлов дерева, для каждого элемента результата (исходящего из первого списка, n-1
для тех, которые идут от второго и т.д.).
Рассмотрим a = "hello"
в
hi = ((((a ++ b) ++ c) ++ d) ++ e) ++ f
и мы хотим оценить take 5 hi
. Итак, сначала нужно проверить,
(((a ++ b) ++ c) ++ d) ++ e
пусто. Для этого необходимо проверить,
((a ++ b) ++ c) ++ d
пусто. Для этого необходимо проверить,
(a ++ b) ++ c
пусто. Для этого необходимо проверить,
a ++ b
пусто. Для этого необходимо проверить,
a
пусто. Уф. Это не так, поэтому мы можем снова всплыть, собирать
a ++ b = 'h':("ello" ++ b) (a ++ b) ++ c = 'h':(("ello" ++ b) ++ c) ((a ++ b) ++ c) ++ d = 'h':((("ello" ++ b) ++ c) ++ d) (((a ++ b) ++ c) ++ d) ++ e = 'h':(((("ello" ++ b) ++ c) ++ d) ++ e) ((((a ++ b) ++ c) ++ d) ++ e) ++ f = 'h':((((("ello" ++ b) ++ c) ++ d) ++ e) ++ f)
и для 'e'
мы должны повторить, а для 'l'
тоже …
Рисуя часть дерева, пузырь идет так:
(++) / \ (++) c / \ 'h':"ello" b
становится первым
(++) / \ (:) c / \ 'h' (++) / \ "ello" b
а потом
(:) / \ 'h' (++) / \ (++) c / \ "ello" b
вплоть до верха. Структура дерева, которая становится правильным дочерним элементом верхнего уровня (:)
наконец, точно такая же, как структура исходного дерева, если только левый список пуст, когда
(++) / \ [] b
узлы свернуты до значения b
.
Поэтому, если у вас есть вложенные вложенные конкатенации коротких списков, конкатенация становится квадратичной, поскольку для получения заголовка конкатенации используется операция O (nesting-depth). В общем, конкатенация левого гнезда
(...((a_d ++ a_{d-1}) ++ a_{d-2}) ...) ++ a_2) ++ a_1
O(sum [i * length a_i | i <- [1 .. d]])
для полной оценки.
С разностными списками (без новой оболочки для простоты изложения) не важно, оставлены ли композиции вложенными
((((a ++) . (b ++)) . (c ++)) . (d ++)) . (e ++)
или правильно вложен. После того, как вы пересекли гнездо для достижения (a ++)
, то (++)
поднимается вверху дерева выражений, поэтому получение каждого элемента a
снова O (1).
Фактически, вся композиция повторно привязана к разностным спискам, как только вам понадобится первый элемент,
((((a ++) . (b ++)) . (c ++)) . (d ++)) . (e ++) $ f
становится
((((a ++) . (b ++)) . (c ++)) . (d ++)) $ (e ++) f (((a ++) . (b ++)) . (c ++)) $ (d ++) ((e ++) f) ((a ++) . (b ++)) $ (c ++) ((d ++) ((e ++) f)) (a ++) $ (b ++) ((c ++) ((d ++) ((e ++) f))) a ++ (b ++ (c ++ (d ++ (e ++ f))))
и после этого каждый список является непосредственным левым операндом верхнего уровня (++)
после того, как был использован предыдущий список.
Важно то, что добавочная функция (a ++)
может начать производить свой результат, не проверяя его аргумент, так что повторная ассоциация из
($) / \ (.) f / \ (.) (e ++) / \ (.) (d ++) / \ (.) (c ++) / \ (a ++) (b ++)
с помощью
($)--------- / \ (.) ($) / \ / \ (.) (d ++) (e ++) f / \ (.) (c ++) / \ (a ++) (b ++)
в
($) / \ (a ++) ($) / \ (b ++) ($) / \ (c ++) ($) / \ (d ++) ($) / \ (e ++) f
не нужно ничего знать о составных функциях окончательного списка f
, поэтому это просто переписывание O(depth)
. Затем верхний уровень
($) / \ (a ++) stuff
становится
(++) / \ a stuff
и все элементы a
могут быть получены за один шаг. В этом примере, когда мы имели чистое левое гнездование, необходима только одна переписывание. Если вместо (например) (d ++)
функция в этом месте была левой вложенной композицией, (((g ++) . (h ++)) . (i ++)) . (j ++)
(((g ++) . (h ++)) . (i ++)) . (j ++)
, переопределение верхнего уровня оставило бы это нетронутым, и это было бы повторно привязано, когда оно станет левым операндом верхнего уровня ($)
после того, как все предыдущие списки будут использованы.
Общая работа, необходимая для всех повторных ассоциаций, - это O(number of lists)
, поэтому общая стоимость конкатенации равна O(number of lists + sum (map length lists))
. (Это означает, что вы также можете принести плохую производительность, добавив много глубоко вложенных ([] ++)
.)
newtype DiffList a = DiffList {getDiffList :: [a] -> [a] } instance Monoid (DiffList a) where mempty = DiffList (\xs -> [] ++ xs) (DiffList f) `mappend` (DiffList g) = DiffList (\xs -> f (g xs))
просто обертывает это так, что более удобно обрабатывать абстрактно.
DiffList (a ++) `mappend` DiffList (b ++) ~> DiffList ((a ++) . (b++))
Обратите внимание, что он эффективен только для функций, которые не нуждаются в проверке их аргумента, чтобы начать выпуск вывода, если произвольные функции завернуты в DiffList
s, у вас нет таких гарантий эффективности. В частности, appending ( (++ a)
, завернутый или нет) может создавать левые вложенные деревья (++)
когда они составлены правильно вложенными, поэтому вы можете создать поведение конкатенации O(n²)
с этим, если конструктор DiffList
подвергаются.
Это может помочь взглянуть на определение конкатенации:
[] ++ ys = ys (x:xs) ++ ys = x : (xs ++ ys)
Как вы можете видеть, чтобы объединить два списка, вам нужно перейти в левый список и создать «копию» его, чтобы вы могли изменить его конец (это потому, что вы не можете напрямую изменить конец старого список из-за неизменности).
Если вы выполняете свои конкатенации в правильном ассоциативном режиме, проблем нет. После того, как вы вставили, список больше не нужно будет трогать (обратите внимание, что определение ++ никогда не проверяет список справа), поэтому каждый элемент списка вставляется только «один раз» для общего времени O (N).
--This is O(n) (a ++ (b ++ (c ++ (d ++ (e ++ f)))))
Однако, если вы выполняете конкатенацию левым ассоциативным способом, тогда «текущий» список должен быть «снесен» и «перестроить» каждый раз, когда вы добавите еще один fragment списка в конец. Каждый элемент списка будет повторяться, когда он вставлен и всякий раз, когда будут добавлены будущие fragmentы! Это похоже на проблему, которую вы получаете на C, если вы наивно вызываете strcat несколько раз подряд.
Что касается списков различий, трюк заключается в том, что они видят явное «отверстие» в конце. Когда вы преобразовываете DList обратно в обычный список, вы передаете ему то, что хотите быть в яме, и оно будет готово к работе. Обычные списки, с другой стороны, всегда подключают отверстие в конце с помощью []
поэтому, если вы хотите его изменить (при конкатенации), вам нужно разорвать список, чтобы добраться до этой точки.
Определение списков разностей с функциями может сначала выглядеть пугающе, но на самом деле это довольно просто. Вы можете просматривать их с объектно-ориентированной точки зрения, считая их непрозрачными объектами. Метод «toList», который получает список, который вы должны вставить в отверстие в конце, возвращает внутренний префикс DL плюс предоставленный хвост. Это эффективно, потому что вы только затыкаете «отверстие» в конце после того, как вы закончили преобразование всего.