(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?
the calculation catches up
? BTW, la mémorisation n'est pas spécifique à haskell: en.wikipedia.org/wiki/MemoizationRéponses:
Juste pour expliquer la mécanique derrière la mémorisation réelle,
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 10000
deux 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:
Vous pouvez donc voir comment l'évaluation
THUNK_4
est beaucoup plus rapide car ses sous-expressions sont déjà évaluées.la source
memo_fib
avec 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)memo_fib 29
etmemo_fib 30
sont 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é.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 x
sera toujours disponible quand ilfib x+1
sera 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 par0
, tandis que la récursivité dans le premier exemple commence parn
et seulement alors demande à propos den-1
. C'est ce qui conduit aux nombreux, nombreux appels de fonctions en double inutiles.la source
memorized_fib 20
par exemple, vous êtes en train d'écriremap 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?fib 2
si souvent qu'elle vous fera tourner la tête - allez-y, notez la fourrure de l'arbre des appels juste une petite valeur commen==5
. Vous n'oublierez plus jamais la mémorisation une fois que vous avez vu ce qu'elle vous fait économiser.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 pourn == 3
, vous obtenez simplementmap fib [0..]!!3
? qui va ensuite dans lafib n
branche du programme ... où puis-je obtenir exactement l'avantage des données pré-calculées?memoized_fib
ça va. C'estslow_fib
cela qui vous fera pleurer si vous le retracez.