(tenga en cuenta que estoy haciendo la pregunta aquí porque se trata de la mecánica conceptual de la misma, en lugar de un problema de codificación)
Estaba trabajando en un pequeño programa, que usaba una secuencia de números de Fibonacci en su ecuación, pero noté que si superaba un cierto número se volvía dolorosamente lento, buscando en Google un poco me topé con una técnica en Haskell conocida como Memoization
, mostraron código que funciona así:
-- 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)
Entonces mi pregunta para ustedes es, ¿cómo o más bien por qué funciona esto?
¿Es porque de alguna manera logra ejecutar la mayor parte de la lista antes de que el cálculo se ponga al día? Pero si Haskell es vago, no hay realmente ningún cálculo que deba ponerse al día ... Entonces, ¿cómo funciona?
the calculation catches up
? Por cierto, la memorización no es específica de haskell: en.wikipedia.org/wiki/MemoizationRespuestas:
Solo para explicar la mecánica detrás de la memoria real,
produce una lista de "thunks", cálculos no evaluados. Piense en estos como regalos sin abrir, mientras no los toquemos, no se ejecutarán.
Ahora, una vez que evaluamos un thunk, nunca lo volvemos a evaluar. Esta es en realidad la única forma de mutación en Haskell "normal", los thunks mutan una vez evaluados para convertirse en valores concretos.
Entonces, volviendo a su código, tiene una lista de thunks, y aún hace esta recursión de árbol, pero recurre usando la lista, y una vez que se evalúa un elemento de la lista, nunca se vuelve a calcular. Por lo tanto, evitamos la recursión de los árboles en la función fibrosa ingenua.
Como una nota tangencialmente interesante, esto es especialmente rápido cuando se calculan una serie de números de fibonnaci ya que esa lista solo se evalúa una vez, lo que significa que si calcula
memo_fib 10000
dos veces, la segunda vez debería ser instantánea. Esto se debe a que Haskell solo evaluó los argumentos de las funciones una vez y está utilizando una aplicación parcial en lugar de una lambda.TLDR: Al almacenar los cálculos en una lista, cada elemento de la lista se evalúa una vez, por lo tanto, cada número de Fibonnacci se calcula exactamente una vez en todo el programa.
Visualización:
Entonces puede ver cómo evaluar
THUNK_4
es mucho más rápido ya que sus subexpresiones ya están evaluadas.fuente
memo_fib
con el mismo valor dos veces, la segunda vez será instantánea, pero si lo llamo con un valor 1 más alto, todavía tarda una eternidad en evaluar (por ejemplo, pasar del 30 al 31)memo_fib 29
ymemo_fib 30
ya están evaluados, tomará exactamente el tiempo necesario para agregar esos dos números :) Una vez que se evalúa algo, permanece evadido.El objetivo de la memorización nunca es calcular la misma función dos veces; esto es extremadamente útil para acelerar los cálculos que son puramente funcionales, es decir, sin efectos secundarios, porque para ellos el proceso puede ser completamente automatizado sin afectar la corrección. Esto es particularmente necesario para funciones como
fibo
, que conducen a la recursividad del árbol , es decir, un esfuerzo exponencial, cuando se implementan ingenuamente. (Esta es una razón por la cual los números de Fibonacci son en realidad un muy mal ejemplo para enseñar la recursividad: casi todas las implementaciones de demostración que encuentre en los tutoriales o libros no se pueden usar para valores de entrada grandes).Si rastrea el flujo de ejecución, verá que en el segundo caso, el valor de
fib x
siempre estará disponible cuandofib x+1
se ejecute, y el sistema de tiempo de ejecución podrá simplemente leerlo desde la memoria en lugar de hacerlo a través de otra llamada recursiva, mientras que el La primera solución intenta calcular la solución más grande antes de que estén disponibles los resultados para valores más pequeños. En última instancia, esto se debe a que el iterador[0..n]
se evalúa de izquierda a derecha y, por lo tanto, comenzará con0
, mientras que la recursividad en el primer ejemplo comienzan
y solo luego preguntan-1
. Esto es lo que lleva a las muchas, muchas llamadas innecesarias a funciones duplicadas.fuente
memorized_fib 20
por ejemplo, en realidad solo estás escribiendomap fib [0..] !! 20
, aún necesitaría calcular el todo el rango de números hasta 20, ¿o me estoy perdiendo algo aquí?fib 2
tanta frecuencia que hará que la cabeza gire: adelante, escriba la piel del árbol de llamadas solo como un pequeño valorn==5
. Nunca volverás a olvidar la memorización una vez que hayas visto lo que te salva.n = 5
, y actualmente llegué al punto en el quen == 3
, hasta ahora todo bien, pero tal vez es solo mi mente imperativa pensar esto, pero eso no solo significa eson == 3
, solo obtienesmap fib [0..]!!3
? que luego entra en lafib n
rama del programa ... ¿dónde obtengo exactamente el beneficio de los datos precalculados?memoized_fib
esta bien. Es loslow_fib
que te hará llorar si lo trazas.