Может ли кто-нибудь объяснить концепцию запоминания Хаскелла?

12

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

Я работал над небольшой программой, которая использовала последовательность чисел Фибоначчи в ее уравнении, но я заметил, что, если я набрал определенное число, оно стало мучительно медленным, немного погуглив, я наткнулся на технику в Хаскеле, известную как Memoization: они показали код, работающий так:

-- Traditional implementation of fibonacci, hangs after about 30
slow_fib :: Int -> Integer
slow_fib 0 = 0
slow_fib 1 = 1
slow_fib n = slow_fib (n-2) + slow_fib (n-1)

-- Memorized variant is near instant even after 10000
memoized_fib :: Int -> Integer
memoized_fib = (map fib [0 ..] !!)
   where fib 0 = 0
         fib 1 = 1
         fib n = memoized_fib (n-2) + memoized_fib (n-1)

Итак, мой вопрос к вам, ребята, как или, скорее, почему это работает?

Это потому, что ему как-то удается пройти большую часть списка до того, как вычисление настигнет? Но если haskell ленив, нет никаких вычислений, которые нужно наверстать ... Так как это работает?

Электрический кофе
источник
1
не могли бы вы уточнить, что вы имеете в виду the calculation catches up? Кстати, запоминание не является специфическим для haskell: en.wikipedia.org/wiki/Memoization
Саймон Бергот
см. мое объяснение под ответом киллана
Электрический кофе
2
Люблю твой вопрос; только быстрое примечание: Этот метод называется памяткой я зация, не памятка ри зации.
Рашит

Ответы:

11

Просто, чтобы объяснить механику фактического запоминания,

memo_fib = (map fib [1..] !!)

производит список "thunks", неоцененных вычислений. Думайте об этом как о нераскрытых подарках, пока мы их не трогаем, они не сбегут.

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

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

Как интересное примечание, это особенно быстро по ряду чисел Фибоначчи, которые вычисляются, поскольку этот список оценивается только один раз, а это означает, что если вычислить memo_fib 10000дважды, второй раз должен быть мгновенным. Это потому, что Haskell оценивал аргументы функций только один раз, и вы используете частичное приложение вместо лямбды.

TLDR: сохраняя вычисления в списке, каждый элемент списка оценивается один раз, поэтому каждое число Фибоначчи вычисляется ровно один раз на протяжении всей программы.

Визуализация:

 [THUNK_1, THUNK_2, THUNK_3, THUNK_4, THUNK_5]
 -- Evaluating THUNK_5
 [THUNK_1, THUNK_2, THUNK_3, THUNK_4, THUNK_3 + THUNK_4]
 [THUNK_1, THUNK_2, THUNK_1 + THUNK_2, THUNK_4, THUNK_3 + THUNK_4]
 [1, 1, 1 + 1, THUNK_4, THUNK_3 + THUNK_4]
 [1, 1, 2, THUNK_4, 2 + THUNK4]
 [1, 1, 2, 1 + 2, 2 + THUNK_4]
 [1, 1, 2, 3, 2 + 3]
 [1, 1, 2, 3, 5]

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

Даниэль Гратцер
источник
Не могли бы вы привести пример того, как значения в списке ведут себя для короткой последовательности? Я думаю, что это может добавить к визуализации того, как это должно работать ... И хотя это правда, что если я вызову memo_fibс одним и тем же значением дважды, второй раз будет мгновенным, но если я вызову его со значением на 1 выше, это все еще требуется вечность, чтобы оценить (как, скажем, с 30 до 31)
Электрический кофе
@ElectricCoffee Добавлено
Даниэль Гратцер,
@ElectricCoffee Нет, с тех пор не будет, memo_fib 29и memo_fib 30уже оценены, это займет ровно столько времени, сколько потребуется, чтобы добавить эти два числа :) Как только что-то будет оценено, оно останется скрытым.
Даниэль Гратцер
1
@ElectricCoffee Ваша рекурсия должна пройти по списку, иначе вы не получите никакой производительности
Даниэль Гратцер
2
@ElectricCoffee Да. но 31-й элемент списка не использует прошлые вычисления, вы запоминаете «да», но довольно бесполезно. Повторяющиеся вычисления не вычисляются дважды, но у вас все еще есть рекурсия дерева для каждого нового значения, которое очень, очень медленно
Даниэль Гратцер
1

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

Если вы проследите поток выполнения, вы увидите, что во втором случае значение для fib xвсегда будет доступно при fib x+1выполнении, и система времени выполнения сможет просто прочитать его из памяти, а не через другой рекурсивный вызов, в то время как Первое решение пытается вычислить большее решение, прежде чем станут доступны результаты для меньших значений. В конечном итоге это происходит потому, что итератор [0..n]вычисляется слева направо и поэтому будет начинаться с 0, в то время как рекурсия в первом примере начинается с nи только затем запрашивает n-1. Это то, что приводит к множеству ненужных повторяющихся вызовов функций.

Килиан Фот
источник
о, я понимаю смысл этого, я просто не понял, как это работает, как из того, что я вижу в коде, что когда вы пишете, memorized_fib 20например, вы на самом деле просто пишете map fib [0..] !! 20, ему все равно нужно вычислить весь диапазон номеров до 20, или я что-то здесь упускаю?
Электрический кофе
1
Да, но только один раз для каждого номера. Наивная реализация вычисляет fib 2так часто, что это заставит вашу голову вращаться - вперёд, запишите мех дерева вызовов, как маленькое значение n==5. Вы никогда не забудете памятку снова, когда увидите, что она вас спасает.
Килиан Фот
@ElectricCoffee: Да, он рассчитывает фиб от 1 до 20. Вы ничего не получите от этого звонка. Теперь попробуйте вычислить FIB 21, и вы увидите, что вместо вычисления 1-21 вы можете просто рассчитать 21, потому что у вас уже есть 1-20 и вам не нужно делать это снова.
Phoshi
Я пытаюсь записать дерево вызовов для n = 5, и в настоящее время я достиг точки, где n == 3, пока все хорошо, но, может быть, это просто мой настоятельный ум, думая об этом, но это не значит, что n == 3вы просто получаете map fib [0..]!!3? который затем входит в fib nветку программы ... где именно я могу получить выгоду от предварительно вычисленных данных?
Электрический кофе
1
Нет, memoized_fibвсе в порядке. Это то, slow_fibчто заставит вас плакать, если вы проследите это.
Килиан Фот