¿Alguien puede explicar el concepto detrás de la memorización de Haskell?

12

(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?

Cafe electrico
fuente
1
¿podrías aclarar a qué te refieres the calculation catches up? Por cierto, la memorización no es específica de haskell: en.wikipedia.org/wiki/Memoization
Simon Bergot
vea mi explicación bajo la respuesta de Killan
Electric Coffee
2
Amo tu pregunta; Sólo un breve comentario: La técnica se llama memo i zación, no memo ri zación.
Racheet

Respuestas:

11

Solo para explicar la mecánica detrás de la memoria real,

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

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 10000dos 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:

 [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]

Entonces puede ver cómo evaluar THUNK_4es mucho más rápido ya que sus subexpresiones ya están evaluadas.

Daniel Gratzer
fuente
¿podría proporcionar un ejemplo de cómo se comportan los valores de la lista para una secuencia corta? Creo que puede aumentar la visualización de cómo se supone que debe funcionar ... Y si bien es cierto que si llamo memo_fibcon 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)
Electric Coffee
@ElectricCoffee Added
Daniel Gratzer
@ElectricCoffee No, no lo hará desde entonces memo_fib 29y memo_fib 30ya están evaluados, tomará exactamente el tiempo necesario para agregar esos dos números :) Una vez que se evalúa algo, permanece evadido.
Daniel Gratzer
1
@ElectricCoffee Su recursión tiene que pasar por la lista, de lo contrario no obtendrá ningún rendimiento
Daniel Gratzer
2
@ElectricCoffee Sí. pero el elemento 31 de la lista no está usando cálculos pasados, está recordando que sí, pero de una manera bastante inútil. Los cálculos que se repiten no se calculan dos veces, pero aún tiene una recursión de árbol para cada nuevo valor que es muy, muy lento
Daniel Gratzer
1

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 xsiempre estará disponible cuando fib x+1se 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á con 0, mientras que la recursividad en el primer ejemplo comienza ny solo luego pregunta n-1. Esto es lo que lleva a las muchas, muchas llamadas innecesarias a funciones duplicadas.

Kilian Foth
fuente
oh, entiendo el punto, simplemente no entendí cómo funciona, como por lo que puedo ver en el código, es que cuando escribes, memorized_fib 20por ejemplo, en realidad solo estás escribiendo map fib [0..] !! 20, aún necesitaría calcular el todo el rango de números hasta 20, ¿o me estoy perdiendo algo aquí?
Café eléctrico
1
Sí, pero solo una vez por cada número. La implementación ingenua calcula con fib 2tanta frecuencia que hará que la cabeza gire: adelante, escriba la piel del árbol de llamadas solo como un pequeño valor n==5. Nunca volverás a olvidar la memorización una vez que hayas visto lo que te salva.
Kilian Foth
@ElectricCoffee: Sí, calculará fib de 1 a 20. No ganas nada con esa llamada. Ahora intente calcular 21 fib, y verá que en lugar de calcular 1-21, puede calcular 21 porque ya tiene 1-20 calculado y no necesita volver a hacerlo.
Phoshi
Estoy tratando de escribir el árbol de llamadas n = 5, y actualmente llegué al punto en el que n == 3, hasta ahora todo bien, pero tal vez es solo mi mente imperativa pensar esto, pero eso no solo significa eso n == 3, solo obtienes map fib [0..]!!3? que luego entra en la fib nrama del programa ... ¿dónde obtengo exactamente el beneficio de los datos precalculados?
Café eléctrico
1
No, memoized_fibesta bien. Es lo slow_fibque te hará llorar si lo trazas.
Kilian Foth