foldl является хвостовой рекурсивной, так как же foldr работает быстрее, чем foldl?
Я хотел проверить foldl против foldr. Из того, что я видел, вы должны использовать foldl над foldr, когда вы можете из-за оптимизации хвостовой рекурсии.
это имеет смысл. Однако после запуска этого теста я запутался:
foldr (занимает 0.057 с при использовании команды time):
a::a -> [a] -> [a]
a x = ([x] ++ )
main = putStrLn(show ( sum (foldr a [] [0.. 100000])))
foldl (занимает 0.089 с при использовании команды time):
b::[b] -> b -> [b]
b xs = ( ++ xs). (y->[y])
main = putStrLn(show ( sum (foldl b [] [0.. 100000])))
ясно, что этот пример тривиален, но я смущен тем, почему foldr бьет foldl. Разве это не должно быть ясно, где foldl выигрывает?
7 ответов:
Добро пожаловать в мир ленивости.
когда вы думаете об этом с точки зрения строгой оценки, foldl выглядит "хорошо", а foldr выглядит "плохо", потому что foldl является хвостовым рекурсивным, но foldr должен был бы построить башню в стеке, чтобы он мог сначала обработать последний элемент.
однако, ленивая оценка переворачивает таблицы. Возьмем, к примеру, определение функции map:
map :: (a -> b) -> [a] -> [b] map _ [] = [] map f (x:xs) = f x : map f xs
это было бы не слишком хорошо, если бы Хаскелл использовал строгую оценку, поскольку сначала нужно будет вычислить хвост, затем добавьте элемент (для всех элементов в списке). Кажется, единственный способ сделать это эффективно-построить элементы в обратном порядке.
однако, благодаря ленивой оценке Хаскелла, эта функция карты на самом деле эффективна. Списки в Haskell можно рассматривать как генераторы, и эта функция карты генерирует свой первый элемент, применяя f к первому элементу входного списка. Когда ему нужен второй элемент, он просто делает то же самое опять же (без использования дополнительного пространства).
получается, что
map
можно описать в терминахfoldr
:map f xs = foldr (\x ys -> f x : ys) [] xs
трудно сказать, глядя на него, но ленивая оценка срабатывает, потому что foldr может дать
f
первый аргумент сразу:foldr f z [] = z foldr f z (x:xs) = f x (foldr f z xs)
потому что
f
определеноmap
может вернуть первый элемент списка результатов, используя только первый параметр, створка может работать лениво в постоянном пространстве.теперь, ленивая оценка действительно кусается. Например, попробуйте запустить sum [1..1000000]. Это дает переполнение стека. С чего бы это? Он должен просто оценивать слева направо, верно?
давайте посмотрим, как Хаскелл оценивает его:
foldl f z [] = z foldl f z (x:xs) = foldl f (f z x) xs sum = foldl (+) 0 sum [1..1000000] = foldl (+) 0 [1..1000000] = foldl (+) ((+) 0 1) [2..1000000] = foldl (+) ((+) ((+) 0 1) 2) [3..1000000] = foldl (+) ((+) ((+) ((+) 0 1) 2) 3) [4..1000000] ... = (+) ((+) ((+) (...) 999999) 1000000)
Haskell слишком ленив, чтобы выполнять дополнения, как это происходит. Вместо этого он заканчивается башней недооцененных Громов, которые должны быть вынуждены получить номер. Переполнение стека происходит во время этой оценки, так как он должен рекурсировать глубоко чтобы оценить все удары.
к счастью, есть специальная функция в данных.Список называется
foldl'
это работает строго.foldl' (+) 0 [1..1000000]
не будет переполнения стека. (Примечание: Я попытался заменитьfoldl
Сfoldl'
в вашем тесте, но это на самом деле заставило его работать медленнее.)
EDIT: при повторном рассмотрении этой проблемы я думаю, что все текущие объяснения несколько недостаточны, поэтому я написал более длинное объяснение.
разница в том, как
foldl
иfoldr
применить их функцию сокращения. Глядя наfoldr
случае, мы можем расширить его какfoldr (\x -> [x] ++ ) [] [0..10000] [0] ++ foldr a [] [1..10000] [0] ++ ([1] ++ foldr a [] [2..10000]) ...
этот список обрабатывается
sum
, который потребляет его следующим образом:sum = foldl' (+) 0 foldl' (+) 0 ([0] ++ ([1] ++ ... ++ [10000])) foldl' (+) 0 (0 : [1] ++ ... ++ [10000]) -- get head of list from '++' definition foldl' (+) 0 ([1] ++ [2] ++ ... ++ [10000]) -- add accumulator and head of list foldl' (+) 0 (1 : [2] ++ ... ++ [10000]) foldl' (+) 1 ([2] ++ ... ++ [10000]) ...
я опустил детали конкатенации списка, но вот как сокращение продолжается. Важная часть заключается в том, что все обрабатывается, чтобы свести к минимуму обход списка. Элемент
foldr
только пересекает список один раз, конкатенации не требуют непрерывных обходов списка, иsum
наконец потребляет список за один проход. Критически важно, что глава списка доступен изfoldr
немедленноsum
, так чтоsum
может начать работать немедленно и значения могут быть gc'D, как они генерируются. С помощью фреймворков слияния, таких какvector
, даже промежуточные списки, скорее всего, будут расплавлены.сравните это с :
b xs = ( ++xs) . (\y->[y]) foldl b [] [0..10000] foldl b ( [0] ++ [] ) [1..10000] foldl b ( [1] ++ ([0] ++ []) ) [2..10000] foldl b ( [2] ++ ([1] ++ ([0] ++ [])) ) [3..10000] ...
обратите внимание, что теперь глава списка недоступен до
foldl
закончил. Это означает, что весь список должен быть построен в память передsum
может начать работать. Это гораздо менее эффективно. Запуск двух версий с помощью+RTS -s
показывает жалкую производительность сборки мусора из версии foldl.это тоже a случай, когда
foldl'
не поможет. Добавленная строгостьfoldl'
не изменяет способ создания промежуточного списка. Глава списка остается недоступной до завершения foldl', поэтому результат все равно будет медленнее, чем сfoldr
.я использую следующее правило, чтобы определить лучший выбор
fold
- для складок, которые являются сокращение используйте
foldl'
(например, это будет единственный / окончательный обход)- в противном случае используйте
foldr
.- не используйте
foldl
.в большинстве случаев
foldr
является лучшей функцией сгиба, потому что направление обхода оптимально для ленивой оценки списков. Это также единственный способ обработки бесконечных списков. Дополнительная строгостьfoldl'
можете сделать это быстрее, в некоторых случаях, но это зависит от того, как вы будете использовать эту структуру и, как леность.
проблема в том, что оптимизация хвостовой рекурсии-это оптимизация памяти, а не оптимизация времени выполнения!
оптимизация хвостовой рекурсии позволяет избежать необходимости запоминать значения для каждого рекурсивного вызова.
Итак, foldl на самом деле" хороший", а foldr - "плохой".
например, рассматривая определения foldr и foldl:
foldl f z [] = z foldl f z (x:xs) = foldl f (z `f` x) xs foldr f z [] = z foldr f z (x:xs) = x `f` (foldr f z xs)
вот как вычисляется выражение "foldl (+) 0 [1,2,3]":
foldl (+) 0 [1, 2, 3] foldl (+) (0+1) [2, 3] foldl (+) ((0+1)+2) [3] foldl (+) (((0+1)+2)+3) [ ] (((0+1)+2)+3) ((1+2)+3) (3+3) 6
обратите внимание, что foldl не запоминает значения 0, 1, 2..., но передайте все выражение (((0+1)+2)+3) в качестве аргумента лениво и не оценивает его до последней оценки foldl, где он достигает базового случая и возвращает значение, переданное в качестве второго параметра (z), который еще не оценен.
С другой стороны, вот как работает foldr:
foldr (+) 0 [1, 2, 3] 1 + (foldr (+) 0 [2, 3]) 1 + (2 + (foldr (+) 0 [3])) 1 + (2 + (3 + (foldr (+) 0 []))) 1 + (2 + (3 + 0))) 1 + (2 + 3) 1 + 5 6
важным отличием здесь является то, что где foldl оценивает все выражение в последнем вызове, избегая необходимости возвращаться чтобы достичь вспомнил, значения, выражения производится не. foldr запоминает одно целое число для каждого вызова и выполняет сложение в каждом вызове.
важно иметь в виду, что foldr и foldl не всегда являются эквивалентами. Например, попробуйте вычислить это выражение в объятиях:
foldr (&&) True (False:(repeat True)) foldl (&&) True (False:(repeat True))
foldr и foldl эквивалентны только при определенных условиях, описанных здесь
(извините за мой плохой английский)
Я не думаю, что кто-то на самом деле сказал настоящий ответ на этот вопрос, если я чего-то не упустил (что вполне может быть правдой и приветствуется downvotes).
Я думаю, что самое большое различие в этом случае заключается в том, что
foldr
строит список следующим образом:[0] ++ ([1] ++ ([2] ++ (... ++ [1000000])))
, тогда как
foldl
строит список следующим образом:((([0] ++ [1]) ++ [2]) ++ ... ) ++ [999888]) ++ [999999]) ++ [1000000]
в разница в тонком, но заметьте, что в
,foldr
версия++
всегда содержит только один элемент списка в качестве левого аргумента. С помощьюfoldl
версия, есть до 999999 элементов в++
левый аргумент (в среднем около 500000), но только один элемент в правом аргументе.++
занимает время, пропорциональное размеру левого аргумента, так как он должен выглядеть, хотя весь список левых аргументов до конца, а затем переставить этот последний элемент в первый элемент правильного аргумента (в лучшем случае, возможно, это действительно нужно сделать копию). Правильный список аргументов остается неизменным, поэтому не имеет значения, насколько он велик.вот почему
foldl
версия намного медленнее. На мой взгляд, это не имеет ничего общего с ленью.
на тег
[0.. 100000]
список должен быть расширен сразу, чтобы foldr можно было начать с последнего элемента. Затем, когда он складывает вещи вместе, промежуточные результаты[100000] [99999, 100000] [99998, 99999, 100000] ... [0.. 100000] -- i.e., the original list
поскольку никто не может изменить это значение списка (Haskell-это чистый функциональный язык), компилятор может использовать это значение повторно. Промежуточные значения, например
[99999, 100000]
может быть даже просто указатели в расширенном[0.. 100000]
список вместо отдельных списков.для b, посмотрите промежуточные значения:
[0] [0, 1] [0, 1, 2] ... [0, 1, ..., 99999] [0.. 100000]
каждый из этих промежуточных списков не может быть использован повторно, потому что если вы измените конец списка, то вы изменили любые другие значения, которые указывают на него. Таким образом, вы создаете кучу дополнительных списков, которые требуют времени для создания в памяти. Поэтому в этом случае вы тратите гораздо больше времени на выделение и заполнение этих списков, которые являются промежуточными значениями.
так как вы просто делаете копию списка, A работает быстрее, потому что он начинается с расширения полный список, а затем просто продолжает перемещать указатель из задней части списка в переднюю.
ни
foldl
, ниfoldr
оптимизирован хвост. Это толькоfoldl'
.но в вашем случае с помощью
++
Сfoldl'
не очень хорошая идея, потому что последовательная оценка++
причинит пересекать растущий аккумулятор снова и снова.
ну, позвольте мне переписать ваши функции таким образом, что разница должна быть очевидной -
a :: a -> [a] -> [a] a = (:) b :: [b] -> b -> [b] b = flip (:)
вы видите, что B является более сложным, чем. Если вы хотите быть точным
a
требуется один шаг сокращения для вычисления значения, ноb
требуются два. Это делает разницу во времени, которую вы измеряете, во втором примере должно быть выполнено вдвое больше сокращений.//edit: но сложность времени одинакова, поэтому я бы не стал сильно беспокоиться об этом.