Tout pointeur sur la façon de résoudre efficacement la fonction suivante dans Haskell, pour les grands nombres (n > 108)
f(n) = max(n, f(n/2) + f(n/3) + f(n/4))
J'ai vu des exemples de mémorisation dans Haskell pour résoudre des nombres de fibonacci, qui impliquaient de calculer (paresseusement) tous les nombres de fibonacci jusqu'au n requis. Mais dans ce cas, pour un n donné, il suffit de calculer très peu de résultats intermédiaires.
Merci
haskell
memoization
Ange de Vicente
la source
la source
Réponses:
Nous pouvons le faire très efficacement en créant une structure que nous pouvons indexer en temps sous-linéaire.
Mais d'abord,
Définissons
f
, mais faisons-lui utiliser la «récursion ouverte» plutôt que de s'appeler directement.Vous pouvez obtenir un démo
f
en utilisantfix f
Cela vous permettra de tester cela
f
fait ce que vous voulez dire pour de petites valeurs def
en appelant, par exemple:fix f 123 = 144
Nous pourrions mémoriser cela en définissant:
Cela fonctionne passablement bien et remplace ce qui allait prendre du temps O (n ^ 3) par quelque chose qui mémorise les résultats intermédiaires.
Mais il faut toujours un temps linéaire pour indexer pour trouver la réponse mémorisée
mf
. Cela signifie que des résultats comme:sont tolérables, mais le résultat ne s'adapte pas beaucoup mieux que cela. On peut faire mieux!
Tout d'abord, définissons un arbre infini:
Et puis nous définirons un moyen de s'y indexer, afin que nous puissions trouver un nœud avec un index
n
en temps O (log n) à la place:... et nous pouvons trouver un arbre plein de nombres naturels pour être pratique pour ne pas avoir à jouer avec ces indices:
Puisque nous pouvons indexer, vous pouvez simplement convertir un arbre en liste:
Vous pouvez vérifier le travail jusqu'à présent en vérifiant que
toList nats
vous[0..]
Maintenant,
fonctionne comme avec la liste ci-dessus, mais au lieu de prendre un temps linéaire pour trouver chaque nœud, peut le poursuivre en temps logarithmique.
Le résultat est considérablement plus rapide:
En fait, c'est tellement plus rapide que vous pouvez passer et remplacer
Int
parInteger
ci-dessus et obtenir des réponses ridiculement volumineuses presque instantanémentla source
f_tree
être défini dans unewhere
clause pour éviter d'enregistrer des chemins inutiles dans l'arborescence entre les appels?La réponse d'Edward est un bijou si merveilleux que je l'ai dupliquée et fourni des implémentations
memoList
et desmemoTree
combinateurs qui mémorisent une fonction sous forme ouverte-récursive.la source
Ce n'est pas le moyen le plus efficace, mais mémorise:
lors de la demande
f !! 144
, il est vérifié qu'ilf !! 143
existe, mais sa valeur exacte n'est pas calculée. Il est toujours défini comme un résultat inconnu d'un calcul. Les seules valeurs exactes calculées sont celles nécessaires.Donc, au départ, pour ce qui a été calculé, le programme ne sait rien.
Lorsque nous faisons la demande
f !! 12
, il commence à faire une correspondance de modèle:Maintenant, il commence à calculer
Cela fait récursivement une autre demande sur f, donc nous calculons
Maintenant, nous pouvons en remonter
Ce qui signifie que le programme sait maintenant:
Continuant à ruisseler:
Ce qui signifie que le programme sait maintenant:
Maintenant, nous continuons notre calcul de
f!!6
:Ce qui signifie que le programme sait maintenant:
Maintenant, nous continuons notre calcul de
f!!12
:Ce qui signifie que le programme sait maintenant:
Le calcul se fait donc assez paresseusement. Le programme sait qu'une valeur de
f !! 8
existe, à laquelle elle est égaleg 8
, mais il n'a aucune idée de ce queg 8
c'est.la source
g n m = (something with) f!!a!!b
Ceci est un addendum à l'excellente réponse d'Edward Kmett.
Quand j'ai essayé son code, les définitions de
nats
etindex
me paraissaient assez mystérieuses, alors j'écris une version alternative que j'ai trouvée plus facile à comprendre.Je définis
index
etnats
en termes deindex'
etnats'
.index' t n
est défini sur la plage[1..]
. (Rappelez-vous quiindex t
est défini sur la plage[0..]
.) Cela fonctionne recherche l'arbre en traitantn
comme une chaîne de bits et en lisant les bits à l'envers. Si le bit est1
, il prend la branche de droite. Si le bit est0
, il prend la branche de gauche. Il s'arrête lorsqu'il atteint le dernier bit (qui doit être a1
).Tout comme il
nats
est défini pourindex
ainsi quiindex nats n == n
est toujours vrai,nats'
est défini pourindex'
.Maintenant,
nats
etindex
sont simplementnats'
etindex'
mais avec les valeurs décalées de 1:la source
Comme indiqué dans la réponse d'Edward Kmett, pour accélérer les choses, vous devez mettre en cache des calculs coûteux et pouvoir y accéder rapidement.
Pour garder la fonction non monadique, la solution consistant à construire un arbre paresseux infini, avec une manière appropriée de l'indexer (comme indiqué dans les articles précédents) remplit cet objectif. Si vous renoncez à la nature non monadique de la fonction, vous pouvez utiliser les conteneurs associatifs standard disponibles dans Haskell en combinaison avec des monades «de type état» (comme State ou ST).
Alors que le principal inconvénient est que vous obtenez une fonction non monadique, vous n'avez plus à indexer la structure vous-même et vous pouvez simplement utiliser des implémentations standard de conteneurs associatifs.
Pour ce faire, vous devez d'abord réécrire votre fonction pour accepter tout type de monade:
Pour vos tests, vous pouvez toujours définir une fonction qui ne fait aucune mémorisation à l'aide de Data.Function.fix, bien qu'elle soit un peu plus verbeuse:
Vous pouvez ensuite utiliser State Monad en combinaison avec Data.Map pour accélérer les choses:
Avec des modifications mineures, vous pouvez adapter le code pour qu'il fonctionne avec Data.HashMap à la place:
Au lieu de structures de données persistantes, vous pouvez également essayer des structures de données mutables (comme le Data.HashTable) en combinaison avec la monade ST:
Par rapport à l'implémentation sans aucune mémorisation, n'importe laquelle de ces implémentations vous permet, pour des entrées énormes, d'obtenir des résultats en micro-secondes au lieu d'avoir à attendre plusieurs secondes.
En utilisant Criterion comme référence, j'ai pu observer que l'implémentation avec Data.HashMap fonctionnait en fait légèrement mieux (environ 20%) que celle Data.Map et Data.HashTable pour lesquelles les horaires étaient très similaires.
J'ai trouvé les résultats du benchmark un peu surprenants. Mon sentiment initial était que le HashTable surpasserait l'implémentation de HashMap car il est modifiable. Il peut y avoir un défaut de performance caché dans cette dernière implémentation.
la source
Quelques années plus tard, j'ai regardé cela et j'ai réalisé qu'il y avait un moyen simple de mémoriser cela en temps linéaire en utilisant
zipWith
et une fonction d'aide:dilate
a la propriété pratique quedilate n xs !! i == xs !! div i n
.Donc, en supposant qu'on nous donne f (0), cela simplifie le calcul à
Ressemblant beaucoup à notre description originale du problème, et donnant une solution linéaire (
sum $ take n fs
prendra O (n)).la source
Encore un autre addendum à la réponse d'Edward Kmett: un exemple autonome:
Utilisez-le comme suit pour mémoriser une fonction avec un seul entier arg (par exemple fibonacci):
Seules les valeurs des arguments non négatifs seront mises en cache.
Pour mettre également en cache les valeurs des arguments négatifs, utilisez
memoInt
, défini comme suit:Pour mettre en cache les valeurs des fonctions avec deux arguments entiers, utilisez
memoIntInt
, défini comme suit:la source
Une solution sans indexation et non basée sur celle d'Edward KMETT.
Je délimite les sous-arbres communs à un parent commun (
f(n/4)
est partagé entref(n/2)
etf(n/4)
etf(n/6)
est partagé entref(2)
etf(3)
). En les sauvegardant comme une seule variable dans le parent, le calcul du sous-arbre est effectué une fois.Le code ne s'étend pas facilement à une fonction de mémorisation générale (du moins, je ne saurais pas comment le faire), et vous devez vraiment réfléchir à la façon dont les sous-problèmes se chevauchent, mais la stratégie devrait fonctionner pour plusieurs paramètres généraux non entiers. . (Je l'ai pensé pour deux paramètres de chaîne.)
Le mémo est supprimé après chaque calcul. (Encore une fois, je pensais à deux paramètres de chaîne.)
Je ne sais pas si c'est plus efficace que les autres réponses. Chaque recherche ne comporte techniquement qu'une ou deux étapes («Regardez votre enfant ou l'enfant de votre enfant»), mais il peut y avoir beaucoup d'utilisation supplémentaire de la mémoire.
Edit: Cette solution n'est pas encore correcte. Le partage est incomplet.Edit: Cela devrait être le partage des sous-enfants correctement maintenant, mais j'ai réalisé que ce problème avait beaucoup de partage non trivial:
n/2/2/2
etn/3/3
pourrait être le même. Le problème ne correspond pas bien à ma stratégie.la source