¿Cómo se memoriza esta función de fibonacci?

114

¿Mediante qué mecanismo se memoriza esta función de fibonacci?

fib = (map fib' [0..] !!)                 
     where fib' 1 = 1                                                        
           fib' 2 = 1                                                        
           fib' n = fib (n-2) + fib (n-1)                    

Y en una nota relacionada, ¿por qué esta versión no?

fib n = (map fib' [0..] !! n)                                               
     where fib' 1 = 1                                                        
           fib' 2 = 1                                                        
           fib' n = fib (n-2) + fib (n-1)                    
bjornars
fuente
13
Ligeramente sin relación, fib 0no termina: probablemente quieras que los casos base fib'sean fib' 0 = 0y fib' 1 = 1.
huon
1
Tenga en cuenta que la primera versión podría ser más concisa: fibs = 1:1:zipWith (+) fibs (tail fibs)y fib = (fibs !!).
Bastian

Respuestas:

95

El mecanismo de evaluación en Haskell es por necesidad : cuando se necesita un valor, se calcula y se mantiene listo en caso de que se vuelva a solicitar. Si definimos alguna lista, xs=[0..]y luego preguntamos por su elemento número 100 xs!!99, el espacio número 100 de la lista se "completa", manteniendo el número 99ahora, listo para el siguiente acceso.

Eso es lo que está explotando ese truco de "revisar una lista". En la definición normal de Fibonacci doblemente recurrente fib n = fib (n-1) + fib (n-2), la función en sí se llama, dos veces desde arriba, lo que provoca la explosión exponencial. Pero con ese truco, establecemos una lista para los resultados provisionales y revisamos "la lista":

fib n = (xs!!(n-1)) + (xs!!(n-2)) where xs = 0:1:map fib [2..]

El truco es hacer que se cree esa lista y hacer que esa lista no desaparezca (mediante la recolección de basura) entre llamadas a fib. La forma más sencilla de lograrlo es nombrar esa lista. "Si lo nombras, se quedará".


Su primera versión define una constante monomórfica y la segunda define una función polimórfica. Una función polimórfica no puede usar la misma lista interna para diferentes tipos que podría necesitar servir, por lo que no se comparte , es decir, no se memoriza.

Con la primera versión, el compilador está siendo generoso con nosotros, eliminando esa subexpresión constante ( map fib' [0..]) y convirtiéndola en una entidad que se puede compartir por separado, pero no tiene ninguna obligación de hacerlo. y hay casos en los que no queremos que lo haga por nosotros automáticamente.

( editar :) Considere estas reescrituras:

fib1 = f                     fib2 n = f n                 fib3 n = f n          
 where                        where                        where                
  f i = xs !! i                f i = xs !! i                f i = xs !! i       
  xs = map fib' [0..]          xs = map fib' [0..]          xs = map fib' [0..] 
  fib' 1 = 1                   fib' 1 = 1                   fib' 1 = 1          
  fib' 2 = 1                   fib' 2 = 1                   fib' 2 = 1          
  fib' i=fib1(i-2)+fib1(i-1)   fib' i=fib2(i-2)+fib2(i-1)   fib' i=f(i-2)+f(i-1)

Entonces, la verdadera historia parece ser sobre las definiciones de alcance anidadas. No hay un alcance externo con la primera definición, y la tercera tiene cuidado de no llamar al alcance externo fib3, sino al mismo nivel f.

Cada nueva invocación de fib2parece crear sus definiciones anidadas de nuevo porque cualquiera de ellas podría (en teoría) definirse de manera diferente dependiendo del valor de n(gracias a Vitus y Tikhon por señalar eso). Con la primera definición no hay de nqué depender, y con la tercera hay una dependencia, pero cada llamada separada a fib3llamadas a las fque se tiene cuidado de llamar solo definiciones del alcance del mismo nivel, interno a esta invocación específica de fib3, por lo que lo mismo se xsobtiene reutilizado (es decir, compartido) para esa invocación de fib3.

Pero nada impide que el compilador reconozca que las definiciones internas en cualquiera de las versiones anteriores son de hecho independientes del nenlace externo , para realizar el levantamiento lambda después de todo, lo que resulta en una memorización completa (a excepción de las definiciones polimórficas). De hecho, eso es exactamente lo que sucede con las tres versiones cuando se declaran con tipos monomórficos y se compilan con el indicador -O2. Con declaraciones de tipo polimórfico, fib3exhibe compartición local y fib2no compartición en absoluto.

En última instancia, según el compilador y las optimizaciones del compilador utilizadas, y cómo lo prueba (cargando archivos en GHCI, compilados o no, con -O2 o no, o independiente), y si obtiene un tipo monomórfico o polimórfico, el comportamiento podría cambiar por completo, ya sea que muestre uso compartido local (por llamada) (es decir, tiempo lineal en cada llamada), memorización (es decir, tiempo lineal en la primera llamada y tiempo 0 en llamadas posteriores con el mismo argumento o un argumento menor), o sin compartir en absoluto ( tiempo exponencial).

La respuesta corta es que es cosa de compiladores. :)

Will Ness
fuente
4
Solo para arreglar un pequeño detalle: la segunda versión no se comparte principalmente porque la función local fib'se redefine para todos ny, por lo tanto, fib'en fib 1fib'in fib 2, lo que también implica que las listas son diferentes. Incluso si corrige el tipo para que sea monomórfico, todavía presenta este comportamiento.
Vitus
1
wherelas cláusulas introducen el intercambio de letexpresiones muy parecidas , pero tienden a ocultar problemas como éste. Reescribiéndolo un poco más explícitamente, obtienes esto: hpaste.org/71406
Vitus
1
Otro punto interesante sobre su reescritura: si les da un tipo monomórfico (es decir Int -> Integer), luego se fib2ejecuta en tiempo exponencial, fib1y fib3ambos se ejecutan en tiempo lineal pero fib1también se memorizan, nuevamente porque fib3las definiciones locales se redefinen para todos n.
Vitus
1
@misterbee Pero de hecho sería bueno tener algún tipo de seguridad por parte del compilador; algún tipo de control sobre la residencia de la memoria de una entidad específica. A veces queremos compartir, a veces queremos evitarlo. Me imagino / espero que sea posible ...
Will Ness
1
@ElizaBrandt lo que quise decir es que a veces queremos recalcular algo pesado para que no se retenga para nosotros en la memoria, es decir, el costo de recalcular es menor que el costo de una gran retención de memoria. un ejemplo es la creación de powerset: pwr (x:xs) = pwr xs ++ map (x:) pwr xs ; pwr [] = [[]]queremos pwr xsque se calcule de forma independiente, dos veces, para que se pueda recolectar la basura sobre la marcha a medida que se produce y consume.
Will Ness
23

No estoy del todo seguro, pero he aquí una suposición fundamentada:

El compilador asume que fib npodría ser diferente en una diferente ny por lo tanto necesitaría recalcular la lista cada vez. Después de todo, los bits dentro de la wheredeclaración podrían depender n. Es decir, en este caso, la lista completa de números es esencialmente una función de n.

La versión sin n puede crear la lista una vez y envolverla en una función. La lista no puede depender del valor de npasado y esto es fácil de verificar. La lista es una constante que luego se indexa. Es, por supuesto, una constante que se evalúa de forma perezosa, por lo que su programa no intenta obtener la lista completa (infinita) inmediatamente. Dado que es una constante, se puede compartir entre las llamadas a funciones.

Se memoriza en absoluto porque la llamada recursiva solo tiene que buscar un valor en una lista. Dado que la fibversión crea la lista una vez de forma perezosa, solo calcula lo suficiente para obtener la respuesta sin hacer cálculos redundantes. Aquí, "lazy" significa que cada entrada en la lista es un procesador (una expresión no evaluada). Al no evaluar el golpe seco, se convierte en un valor, por lo que el acceso a la próxima vez no sin repetir el cálculo. Dado que la lista se puede compartir entre llamadas, todas las entradas anteriores ya están calculadas para cuando necesite la siguiente.

Es esencialmente una forma inteligente y de bajo costo de programación dinámica basada en la semántica perezosa de GHC. Creo que el estándar solo especifica que tiene que ser no estricto , por lo que un compilador compatible podría compilar este código para no memorizar. Sin embargo, en la práctica, todo compilador razonable será vago.

Para obtener más información sobre por qué el segundo caso funciona, lea Comprensión de una lista definida de forma recursiva (fibs en términos de zipWith) .

Tikhon Jelvis
fuente
¿quiso decir " fib' npodría ser diferente en un diferente n" quizás?
Will Ness
Creo que no lo tuve muy claro: lo que quise decir es que todo lo que hay adentro fib, incluido fib', puede ser diferente en todos los aspectos n. Creo que el ejemplo original es un poco confuso porque fib'también depende de sí mismo lo nque ensombrece al otro n.
Tikhon Jelvis
20

Primero, con ghc-7.4.2, compilado con -O2, la versión no memorizada no es tan mala, la lista interna de números de Fibonacci todavía se memoriza para cada llamada de nivel superior a la función. Pero no se puede memorizar, ni razonablemente, en las diferentes llamadas de alto nivel. Sin embargo, para la otra versión, la lista se comparte entre llamadas.

Eso se debe a la restricción del monomorfismo.

El primero está limitado por un patrón de enlace simple (solo el nombre, sin argumentos), por lo tanto, por la restricción de monomorfismo debe obtener un tipo monomorfo. El tipo inferido es

fib :: (Num n) => Int -> n

y dicha restricción se establece por defecto (en ausencia de una declaración predeterminada que diga lo contrario) a Integer, fijando el tipo como

fib :: Int -> Integer

Por lo tanto, solo hay una lista (de tipo [Integer]) para memorizar.

El segundo se define con un argumento de función, por lo que sigue siendo polimórfico, y si las listas internas se memorizaran a través de las llamadas, se tendría que memorizar una lista para cada tipo Num. Eso no es práctico.

Compile ambas versiones con la restricción de monomorfismo desactivada o con firmas de tipo idénticas, y ambas exhiben exactamente el mismo comportamiento. (Eso no fue cierto para las versiones anteriores del compilador, no sé qué versión lo hizo primero).

Daniel Fischer
fuente
¿Por qué no es práctico memorizar una lista para cada tipo? En principio, ¿podría GHC crear un diccionario (como para llamar a funciones restringidas por clase de tipo) para contener listas parcialmente calculadas para cada tipo de Num encontrado durante el tiempo de ejecución?
misterbee
1
@misterbee En principio, podría, pero si el programa llama fib 1000000a muchos tipos, eso consume una tonelada de memoria. Para evitar eso, se necesitaría una heurística que arroje listas de la caché cuando crezca demasiado. Y tal estrategia de memorización también se aplicaría a otras funciones o valores, presumiblemente, por lo que el compilador tendría que lidiar con un número potencialmente grande de cosas para memorizar potencialmente para muchos tipos. Creo que sería posible implementar la memorización polimórfica (parcial) con una heurística razonablemente buena, pero dudo que valga la pena.
Daniel Fischer
5

No necesita la función de memoria para Haskell. Solo el lenguaje de programación empirativo necesita esas funciones. Sin embargo, Haskel es lenguaje funcional y ...

Entonces, este es un ejemplo de algoritmo de Fibonacci muy rápido:

fib = zipWith (+) (0:(1:fib)) (1:fib)

zipWith es una función de Prelude estándar:

zipWith :: (a->b->c) -> [a]->[b]->[c]
zipWith op (n1:val1) (n2:val2) = (n1 + n2) : (zipWith op val1 val2)
zipWith _ _ _ = []

Prueba:

print $ take 100 fib

Salida:

[1,2,3,5,8,13,21,34,55,89,144,233,377,610,987,1597,2584,4181,6765,10946,17711,28657,46368,75025,121393,196418,317811,514229,832040,1346269,2178309,3524578,5702887,9227465,14930352,24157817,39088169,63245986,102334155,165580141,267914296,433494437,701408733,1134903170,1836311903,2971215073,4807526976,7778742049,12586269025,20365011074,32951280099,53316291173,86267571272,139583862445,225851433717,365435296162,591286729879,956722026041,1548008755920,2504730781961,4052739537881,6557470319842,10610209857723,17167680177565,27777890035288,44945570212853,72723460248141,117669030460994,190392490709135,308061521170129,498454011879264,806515533049393,1304969544928657,2111485077978050,3416454622906707,5527939700884757,8944394323791464,14472334024676221,23416728348467685,37889062373143906,61305790721611591,99194853094755497,160500643816367088,259695496911122585,420196140727489673,679891637638612258,1100087778366101931,1779979416004714189,2880067194370816120,4660046610375530309,7540113804746346429,12200160415121876738,19740274219868223167,31940434634990099905,51680708854858323072,83621143489848422977,135301852344706746049,218922995834555169026,354224848179261915075,573147844013817084101]

Tiempo transcurrido: 0.00018s

Валерий Кобзарь
fuente
¡Esta solución es asombrosa!
Larry