foldl является хвостовой рекурсивной, так почему же foldr работает быстрее, чем foldl?

Я хотел проверить сложение против сложения. Из того, что я видел, вы должны использовать foldl over foldr, когда это возможно, из-за оптимизации рекурсии хвоста.

Это имеет смысл. Однако после выполнения этого теста я запутался:

foldr (занимает 0,057 с при использовании команды времени):

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

main = putStrLn(show ( sum (foldr a [] [0.. 100000])))

foldl (занимает 0,089 с при использовании команды времени):

b::[b] -> b -> [b]
b xs = ( ++ xs). (\y->[y])

main = putStrLn(show ( sum (foldl b [] [0.. 100000])))

Понятно, что этот пример тривиален, но Я не понимаю, почему Фолдер бьет Фолд. Разве это не должно быть явным случаем, когда победит фолдл?

68
задан AndrewC 21 October 2012 в 07:51
поделиться

5 ответов

Добро пожаловать в мир ленивого оценивания.

Когда вы думаете об этом с точки зрения строгой оценки, foldl выглядит "хорошо", а foldr - "плохо", потому что foldl является хвостовой рекурсивной, но foldr должна была бы построить башню в стеке, чтобы сначала обработать последний элемент. .

Однако ленивое вычисление меняет положение вещей. Возьмем, к примеру, определение функции карты:

map :: (a -> b) -> [a] -> [b]
map _ []     = []
map f (x:xs) = f x : map f xs

Это было бы не очень хорошо, если бы Haskell использовал строгое вычисление, поскольку ему пришлось бы сначала вычислить хвост, а затем добавить элемент (для всех элементов в списке) . Похоже, единственный способ сделать это эффективно - построить элементы в обратном порядке.

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

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

Итак, ленивое вычисление укусит. Например, попробуйте выполнить сумму [1..1000000]. Это приводит к переполнению стека. Зачем это нужно? Он должен просто оценивать слева направо, верно?

Давайте посмотрим, как это оценивает Haskell:

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

К счастью, в Data.List есть специальная функция под названием foldl ', которая работает строго. foldl '(+) 0 [1..1000000] не приведет к переполнению стека. (Примечание: в вашем тесте я попытался заменить foldl на foldl ', но на самом деле это замедлило его работу.)

95
ответ дан 24 November 2019 в 14:13
поделиться

РЕДАКТИРОВАТЬ: Посмотрев на эту проблему еще раз, я думаю, что все текущие объяснения несколько недостаточны, поэтому я написал более подробное объяснение.

Разница в том, как 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 может начать работать немедленно, а значения могут быть объединены по мере их создания. . С фреймворками слияния, такими как вектор , даже промежуточные списки, вероятно, будут слиты.

Сравните это с функцией foldl :

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-версии.

Это также тот случай, когда foldl ' не поможет.Дополнительная строгость foldl ' не меняет способ создания промежуточного списка. Заголовок списка остается недоступным до завершения свертывания, поэтому результат все равно будет медленнее, чем с foldr .

Я использую следующее правило, чтобы определить лучший выбор складки

  • Для складок, которые являются редукцией , используйте свертку (например, это будет единственная / final traversal)
  • В противном случае используйте foldr .
  • Не используйте foldl .

В большинстве случаев foldr является лучшей функцией сворачивания, поскольку направление обхода оптимально для ленивого вычисления списков. Кроме того, это единственный способ обрабатывать бесконечные списки. Дополнительная строгость foldl ' может в некоторых случаях сделать его быстрее, но это зависит от того, как вы будете использовать эту структуру и насколько она ленивая.

27
ответ дан 24 November 2019 в 14:13
поделиться

Ни foldl, ни foldr не оптимизированы для хвоста. Это только foldl'.

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

1
ответ дан 24 November 2019 в 14:13
поделиться

Для a список [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 выполняется быстрее, потому что он начинает с раскрытия полного списка, а затем просто продолжает перемещать указатель от конца списка к началу.

2
ответ дан 24 November 2019 в 14:13
поделиться

Что ж, позвольте мне переписать ваши функции таким образом, чтобы разница была очевидна -

a :: a -> [a] -> [a]
a = (:)

b :: [b] -> b -> [b]
b = flip (:)

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

// редактировать: Но временная сложность такая же, поэтому я бы особо не беспокоился об этом.

-3
ответ дан 24 November 2019 в 14:13
поделиться
Другие вопросы по тегам:

Похожие вопросы: