Quelqu'un peut-il expliquer le concept derrière la mémorisation de Haskell?

12

(notez que je pose la question ici parce qu'il s'agit de la mécanique conceptuelle de celui-ci, plutôt que d'un problème de codage)

Je travaillais sur un petit programme, qui utilisait une séquence de nombres de fibonacci dans son équasion, mais j'ai remarqué que si je dépassais un certain nombre, cela devenait douloureusement lent, en parcourant un peu les choses Memoization, je suis tombé sur une technique à Haskell connue sous le nom de , ils ont montré que le code fonctionnait comme ceci:

-- 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)

Donc, ma question est la suivante: comment ou plutôt pourquoi cela fonctionne-t-il?

Est-ce parce qu'il parvient en quelque sorte à parcourir la majeure partie de la liste avant que le calcul ne rattrape? Mais si haskell est paresseux, il n'y a pas vraiment de calcul à rattraper ... Alors comment ça marche?

Café électrique
la source
1
pourriez-vous préciser ce que vous entendez par the calculation catches up? BTW, la mémorisation n'est pas spécifique à haskell: en.wikipedia.org/wiki/Memoization
Simon Bergot
voir mon explication sous la réponse de killan
Electric Coffee
2
Aimez votre question; juste une petite note: la technique est appelée mémo i zation, pas mémo ri zation.
Racheet

Réponses:

11

Juste pour expliquer la mécanique derrière la mémorisation réelle,

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

produit une liste de "thunks", calculs non évalués. Pensez à ces cadeaux comme non ouverts, tant que nous ne les toucherons pas, ils ne fonctionneront pas.

Maintenant, une fois que nous évaluons un thunk, nous ne l'évaluons plus jamais. Il s'agit en fait de la seule forme de mutation dans le haskell "normal", les thunks mutent une fois évalués pour devenir des valeurs concrètes.

Donc, revenons à votre code, vous avez une liste de thunks, et vous faites toujours cette récursivité d'arbre, mais vous récursivement en utilisant la liste, et une fois qu'un élément de la liste est évalué, il n'est plus jamais calculé à nouveau. Ainsi, nous évitons la récursivité des arbres dans la fonction fib naïve.

Comme note tangentiellement intéressante, cela est particulièrement rapide sur une série de nombres de fibonnaci sont calculés car cette liste n'est évaluée qu'une seule fois, ce qui signifie que si vous calculez memo_fib 10000deux fois, la deuxième fois doit être instantanée. En effet, Haskell n'a évalué les arguments des fonctions qu'une seule fois et vous utilisez une application partielle au lieu d'un lambda.

TLDR: En stockant les calculs dans une liste, chaque élément de la liste est évalué une fois, par conséquent, chaque nombre de fibonnacci est calculé exactement une fois tout au long du programme.

Visualisation:

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

Vous pouvez donc voir comment l'évaluation THUNK_4est beaucoup plus rapide car ses sous-expressions sont déjà évaluées.

Daniel Gratzer
la source
pourriez-vous fournir un exemple de la façon dont les valeurs de la liste se comportent pour une courte séquence? Je pense que cela peut ajouter à la visualisation de la façon dont il est censé fonctionner ... Et bien qu'il soit vrai que si j'appelle memo_fibavec la même valeur deux fois, la deuxième fois sera instantané, mais si je l'appelle avec une valeur 1 plus élevée, il il faut encore une éternité pour évaluer (comme dire passer du 30 au 31)
Electric Coffee
@ElectricCoffee Added
Daniel Gratzer
@ElectricCoffee Non, ce ne sera pas le cas depuis memo_fib 29et memo_fib 30sont déjà évalués, il faudra exactement autant de temps pour ajouter ces deux nombres :) Une fois que quelque chose est évalué, il reste évalué.
Daniel Gratzer
1
@ElectricCoffee Votre récursion doit passer par la liste, sinon vous ne gagnez aucune performance
Daniel Gratzer
2
@ElectricCoffee Oui. mais le 31ème élément de la liste n'utilise pas les calculs passés, vous mémorisez oui, mais d'une manière assez inutile. Les calculs qui sont répétés ne sont pas calculés deux fois, mais vous avez toujours une récursivité d'arbre pour chaque nouvelle valeur qui est très, très lent
Daniel Gratzer
1

Le point de mémorisation n'est jamais de calculer deux fois la même fonction - cela est extrêmement utile pour accélérer des calculs purement fonctionnels, c'est-à-dire sans effets secondaires, car pour ceux-ci, le processus peut être entièrement automatisé sans affecter l'exactitude. Ceci est particulièrement nécessaire pour des fonctions comme fibo, qui conduisent à une récursivité d'arbre , c'est-à-dire un effort exponentiel, lorsqu'elles sont implémentées naïvement. (C'est une des raisons pour lesquelles les nombres de Fibonacci sont en fait un très mauvais exemple pour enseigner la récursivité - presque toutes les implémentations de démonstration que vous trouvez dans les tutoriels ou les livres sont inutilisables pour les grandes valeurs d'entrée.)

Si vous tracez le flux d'exécution, vous verrez que dans le second cas, la valeur de fib xsera toujours disponible quand il fib x+1sera exécuté, et le système d'exécution pourra simplement le lire depuis la mémoire plutôt que via un autre appel récursif, tandis que le la première solution essaie de calculer la plus grande solution avant que les résultats pour les valeurs plus petites ne soient disponibles. C'est finalement parce que l'itérateur [0..n]est évalué de gauche à droite et va donc commencer par 0, tandis que la récursivité dans le premier exemple commence par net seulement alors demande à propos de n-1. C'est ce qui conduit aux nombreux, nombreux appels de fonctions en double inutiles.

Kilian Foth
la source
oh je comprends le point de cela, je ne comprenais tout simplement pas comment cela fonctionne, comme d'après ce que je peux voir dans le code, c'est que lorsque vous écrivez memorized_fib 20par exemple, vous êtes en train d'écrire map fib [0..] !! 20, il faudrait quand même calculer le toute la gamme de nombres jusqu'à 20, ou est-ce que je manque quelque chose ici?
Café électrique
1
Oui, mais une seule fois pour chaque numéro. L'implémentation naïve calcule fib 2si souvent qu'elle vous fera tourner la tête - allez-y, notez la fourrure de l'arbre des appels juste une petite valeur comme n==5. Vous n'oublierez plus jamais la mémorisation une fois que vous avez vu ce qu'elle vous fait économiser.
Kilian Foth
@ElectricCoffee: Oui, il calculera le fib de 1 à 20. Vous ne gagnez rien de cet appel. Essayez maintenant de calculer fib 21, et vous verrez qu'au lieu de calculer 1-21, vous pouvez simplement calculer 21 parce que vous avez déjà 1-20 calculé et que vous n'avez pas besoin de le faire à nouveau.
Phoshi
J'essaie d'écrire l'arbre des appels pour n = 5, et je suis actuellement au point où n == 3, jusqu'ici tout va bien, mais peut-être que c'est juste mon esprit impératif de penser cela, mais cela ne signifie-t-il pas simplement que pour n == 3, vous obtenez simplement map fib [0..]!!3? qui va ensuite dans la fib nbranche du programme ... où puis-je obtenir exactement l'avantage des données pré-calculées?
Café électrique
1
Non, memoized_fibça va. C'est slow_fibcela qui vous fera pleurer si vous le retracez.
Kilian Foth