(обратите внимание, что я задаю вопрос здесь, потому что речь идет о его концептуальной механике, а не о проблеме кодирования)
Я работал над небольшой программой, которая использовала последовательность чисел Фибоначчи в ее уравнении, но я заметил, что, если я набрал определенное число, оно стало мучительно медленным, немного погуглив, я наткнулся на технику в Хаскеле, известную как 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 ленив, нет никаких вычислений, которые нужно наверстать ... Так как это работает?
the calculation catches up
? Кстати, запоминание не является специфическим для haskell: en.wikipedia.org/wiki/MemoizationОтветы:
Просто, чтобы объяснить механику фактического запоминания,
производит список "thunks", неоцененных вычислений. Думайте об этом как о нераскрытых подарках, пока мы их не трогаем, они не сбегут.
Теперь, когда мы оцениваем Thunk, мы никогда не оцениваем его снова. На самом деле это единственная форма мутации в «нормальном» хаскеле, когда муки превращаются в мутировавшие оценки, чтобы стать конкретными значениями.
Итак, вернемся к вашему коду, у вас есть список группировок, и вы все еще выполняете эту рекурсию дерева, но вы рекурсивно используете этот список, и как только элемент в списке оценивается, он никогда не вычисляется снова. Таким образом, мы избегаем рекурсии дерева в наивной функции fib.
Как интересное примечание, это особенно быстро по ряду чисел Фибоначчи, которые вычисляются, поскольку этот список оценивается только один раз, а это означает, что если вычислить
memo_fib 10000
дважды, второй раз должен быть мгновенным. Это потому, что Haskell оценивал аргументы функций только один раз, и вы используете частичное приложение вместо лямбды.TLDR: сохраняя вычисления в списке, каждый элемент списка оценивается один раз, поэтому каждое число Фибоначчи вычисляется ровно один раз на протяжении всей программы.
Визуализация:
Таким образом, вы можете увидеть, как оценка
THUNK_4
выполняется намного быстрее, поскольку ее подвыражения уже оценены.источник
memo_fib
с одним и тем же значением дважды, второй раз будет мгновенным, но если я вызову его со значением на 1 выше, это все еще требуется вечность, чтобы оценить (как, скажем, с 30 до 31)memo_fib 29
иmemo_fib 30
уже оценены, это займет ровно столько времени, сколько потребуется, чтобы добавить эти два числа :) Как только что-то будет оценено, оно останется скрытым.Цель запоминания - никогда не вычислять одну и ту же функцию дважды - это чрезвычайно полезно для ускорения вычислений, которые являются чисто функциональными, то есть без побочных эффектов, потому что для них процесс может быть полностью автоматизирован без влияния на правильность. Это особенно необходимо для функций типа
fibo
, которые приводят к рекурсии дерева , то есть экспоненциальному усилию, когда реализованы наивно. (Это одна из причин, почему числа Фибоначчи на самом деле являются очень плохим примером для обучения рекурсии - почти все демонстрационные реализации, которые вы найдете в учебниках или книгах, непригодны для больших входных значений.)Если вы проследите поток выполнения, вы увидите, что во втором случае значение для
fib x
всегда будет доступно приfib x+1
выполнении, и система времени выполнения сможет просто прочитать его из памяти, а не через другой рекурсивный вызов, в то время как Первое решение пытается вычислить большее решение, прежде чем станут доступны результаты для меньших значений. В конечном итоге это происходит потому, что итератор[0..n]
вычисляется слева направо и поэтому будет начинаться с0
, в то время как рекурсия в первом примере начинается сn
и только затем запрашиваетn-1
. Это то, что приводит к множеству ненужных повторяющихся вызовов функций.источник
memorized_fib 20
например, вы на самом деле просто пишетеmap fib [0..] !! 20
, ему все равно нужно вычислить весь диапазон номеров до 20, или я что-то здесь упускаю?fib 2
так часто, что это заставит вашу голову вращаться - вперёд, запишите мех дерева вызовов, как маленькое значениеn==5
. Вы никогда не забудете памятку снова, когда увидите, что она вас спасает.n = 5
, и в настоящее время я достиг точки, гдеn == 3
, пока все хорошо, но, может быть, это просто мой настоятельный ум, думая об этом, но это не значит, чтоn == 3
вы просто получаетеmap fib [0..]!!3
? который затем входит вfib n
ветку программы ... где именно я могу получить выгоду от предварительно вычисленных данных?memoized_fib
все в порядке. Это то,slow_fib
что заставит вас плакать, если вы проследите это.