(observe que estou colocando a pergunta aqui porque se trata da mecânica conceitual dela, e não de um problema de codificação)
Eu estava trabalhando em um pequeno programa, que usava uma sequência de números de fibonacci em sua equasão, mas notei que, se eu superasse um certo número, ele ficaria dolorosamente lento, pesquisando um pouco, encontrei uma técnica em Haskell conhecida como Memoization
: eles mostraram código funcionando assim:
-- 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)
Então, minha pergunta para vocês é: como ou melhor, por que isso funciona?
É porque, de alguma maneira, ele consegue percorrer a maior parte da lista antes que o cálculo o recupere? Mas se o haskell é preguiçoso, não há realmente nenhum cálculo que precise ser alcançado ... Então, como isso funciona?
the calculation catches up
? BTW, memoization não é específico para Haskell: en.wikipedia.org/wiki/MemoizationRespostas:
Apenas para explicar a mecânica por trás da memorização real,
produz uma lista de "thunks", cálculos não avaliados. Pense neles como presentes fechados, desde que não os tocemos, eles não serão executados.
Agora, quando avaliamos um thunk, nunca mais o avaliamos. Esta é realmente a única forma de mutação no haskell "normal", os thunks sofrem mutações uma vez avaliadas para se tornarem valores concretos.
Então, voltando ao seu código, você tem uma lista de thunks e ainda faz essa recursão em árvore, mas você recorre usando a lista e, uma vez que um elemento da lista é avaliado, ele nunca é computado novamente. Assim, evitamos a recursão da árvore na função ingênua da fib.
Como uma nota tangencialmente interessante, isso é especialmente rápido em uma série de números de fibonnaci são calculados, pois essa lista é avaliada apenas uma vez, o que significa que se você calcular
memo_fib 10000
duas vezes, a segunda vez deve ser instantânea. Isso ocorre porque Haskell avaliou apenas argumentos para funções uma vez e você está usando um aplicativo parcial em vez de um lambda.TLDR: Armazenando cálculos em uma lista, cada elemento da lista é avaliado uma vez; portanto, cada número de fibonnacci é calculado exatamente uma vez durante todo o programa.
Visualização:
Assim, você pode ver como a avaliação
THUNK_4
é muito mais rápida, pois suas subexpressões já foram avaliadas.fonte
memo_fib
com o mesmo valor duas vezes, a segunda vez será instantânea, mas se eu chamar com um valor 1 maior, ainda leva uma eternidade para avaliar (como dizem que vai de 30 a 31)memo_fib 29
ememo_fib 30
já foi avaliado, levará exatamente o tempo necessário para adicionar esses dois números :) Depois que algo é avaliado, ele é avaliado.O objetivo da memorização nunca é calcular a mesma função duas vezes - isso é extremamente útil para acelerar cálculos puramente funcionais, ou seja, sem efeitos colaterais, porque para aqueles o processo pode ser totalmente automatizado sem afetar a correção. Isso é particularmente necessário para funções como
fibo
, que levam à recursão em árvore , ou seja, esforço exponencial, quando implementadas ingenuamente. (Essa é uma das razões pelas quais os números de Fibonacci são realmente um exemplo muito ruim para o ensino de recursão - quase todas as implementações de demonstração encontradas em tutoriais ou livros são inutilizáveis para grandes valores de entrada.)Se você rastrear o fluxo de execução, verá que, no segundo caso, o valor para
fib x
estará sempre disponível quandofib x+1
for executado, e o sistema de tempo de execução poderá simplesmente lê-lo da memória, e não através de outra chamada recursiva, enquanto o A primeira solução tenta calcular a solução maior antes que os resultados para valores menores estejam disponíveis. Em última análise, isso ocorre porque o iterador[0..n]
é avaliado da esquerda para a direita e, portanto, inicia com0
, enquanto a recursão no primeiro exemplo começa comn
e só então pergunta sobren-1
. É isso que leva a muitas, muitas chamadas de funções duplicadas desnecessárias.fonte
memorized_fib 20
por exemplo, está escrevendomap fib [0..] !! 20
, ele ainda precisa calcular o valor intervalo inteiro de números até 20, ou estou faltando alguma coisa aqui?fib 2
tanta frequência que fará sua cabeça girar - vá em frente, anote a árvore da chamada com apenas um pequeno valorn==5
. Você nunca mais esquecerá a memorização depois de ver o que ela salva.n = 5
e, atualmente, cheguei ao ponto em quen == 3
, até agora tudo bem, mas talvez seja apenas a minha mente imperativa pensando isso, mas isso não significa apenas paran == 3
vocêmap fib [0..]!!3
? que então entra nofib n
ramo do programa ... onde exatamente eu recebo o benefício dos dados pré-computados?memoized_fib
está bem. Éslow_fib
isso que fará você chorar se você rastrear.