Comment améliorer l'efficacité avec une programmation fonctionnelle?


20

J'ai récemment parcouru le guide Learn You a Haskell for Great Good et comme pratique, je voulais résoudre le problème 5 du projet Euler avec, qui spécifie:

Quel est le plus petit nombre positif divisible par tous les nombres de 1 à 20?

J'ai décidé d'écrire d'abord une fonction déterminant si un nombre donné est divisible par ces nombres:

divisable x = all (\y -> x `mod` y == 0)[1..20]

Ensuite, j'ai calculé le plus petit en utilisant head:

sm = head [x | x <- [1..], divisable x]

Et enfin écrit la ligne pour afficher le résultat:

main = putStrLn $ show $ sm

Malheureusement, cela a pris environ 30 secondes pour terminer. Faire la même chose avec les nombres 1 à 10 donne un résultat presque immédiatement, mais là encore, le résultat est beaucoup plus petit que la solution pour 1 à 20.

Je l'ai résolu plus tôt en C et là, le résultat pour 1 à 20 a également été calculé presque instantanément. Cela m'amène à croire que je ne comprends pas comment interpréter ce problème pour Haskell. J'ai regardé les solutions des autres et j'ai trouvé ceci:

main = putStrLn $ show $ foldl1 lcm [1..20]

Assez juste, cela utilise une fonction intégrée, mais pourquoi le résultat final est-il tellement plus lent lorsque vous le faites vous-même? Les didacticiels vous expliquent comment utiliser Haskell, mais je ne vois pas beaucoup d'aide pour transformer des algorithmes en code rapide.


6
Je dois souligner que bon nombre des problèmes résolus d'Euler ont des fichiers PDF à côté d'eux qui permettent de résoudre le problème mathématique. Vous pouvez essayer de lire ce pdf et implémenter l'algorithme décrit dans chaque langue, puis le profiler.

Réponses:


25

Vous devez d'abord vous assurer d'avoir un binaire optimisé, avant de penser que la langue est le problème. Lisez le chapitre Profilage et optimisation dans Real Wolrd Haskell. Il convient de noter que dans la plupart des cas, la nature de haut niveau de la langue vous coûte au moins une partie de la performance.

Cependant, notez que l'autre solution n'est pas plus rapide car elle utilise une fonction intégrée, mais simplement parce qu'elle utilise un algorithme beaucoup plus rapide : pour trouver le multiple le moins commun d'un ensemble de nombres, vous devez trouver seulement quelques GCD. Comparez cela avec votre solution, qui parcourt tous les nombres de 1 à foldl lcm [1..20]. Si vous essayez avec 30, la différence entre les temps d'exécution sera encore plus grande.

Jetez un œil aux complexités: votre algorithme a un O(ans*N)runtime, où ansest la réponse et Nle nombre jusqu'à lequel vous vérifiez la divisibilité (20 dans votre cas).
L'autre algorithme exécute Nfois lcm, cependant lcm(a,b) = a*b/gcd(a,b), et GCD a la complexité O(log(max(a,b))). Le deuxième algorithme est donc complexe O(N*log(ans)). Vous pouvez juger par vous-même ce qui est plus rapide.

Donc, pour résumer:
votre problème est votre algorithme, pas la langue.

Notez qu'il existe des langages spécialisés qui sont à la fois fonctionnels et axés sur les programmes mathématiques, comme Mathematica, qui pour les problèmes mathématiques est probablement plus rapide que presque tout le reste. Il a une bibliothèque de fonctions très optimisée, et il prend en charge le paradigme fonctionnel (certes, il prend également en charge la programmation impérative).


3
J'ai récemment eu un problème de performances avec un programme Haskell, puis j'ai réalisé que j'avais compilé avec des optimisations désactivées. Activer l'optimisation des performances a augmenté d'environ 10 fois. Donc, le même programme écrit en C était encore plus rapide, mais Haskell n'était pas beaucoup plus lent (environ 2, 3 fois plus lent, ce qui, je pense, est une bonne performance, étant donné que je n'avais pas essayé d'améliorer le code Haskell plus loin). Conclusion: le profilage et l'optimisation sont une bonne suggestion. +1
Giorgio

3
pense honnêtement que vous pourriez supprimer les deux premiers paragraphes, ils ne répondent pas vraiment à la question et sont probablement inexacts (ils jouent certainement rapidement et librement avec la terminologie, les langues ne peuvent pas avoir une vitesse)
jk.

1
Vous donnez une réponse contradictoire. D'une part, vous affirmez que le PO "n'a rien mal compris", et que la lenteur est inhérente à Haskell. D'un autre côté, vous montrez que le choix de l'algorithme est important! Votre réponse serait bien meilleure si elle sautait les deux premiers paragraphes, qui sont quelque peu contradictoires avec le reste de la réponse.
Andres F.

2
Prendre les commentaires d'Andres F. et jk. J'ai décidé de réduire les deux premiers paragraphes à quelques phrases. Merci pour les commentaires
K.Steff

5

Ma première pensée a été que seuls les nombres divisibles par tous les nombres premiers <= 20 seront divisibles par tous les nombres inférieurs à 20. Il vous suffit donc de considérer les nombres qui sont des multiples de 2 * 3 * 5 * 7 * 11 * 13 * 17 * 19 . Une telle solution vérifie 1/9 699 690 autant de nombres que l'approche par force brute. Mais votre solution rapide Haskell fait mieux que cela.

Si je comprends la solution "Haskell rapide", elle utilise foldl1 pour appliquer la fonction lcm (multiple le moins commun) à la liste des nombres de 1 à 20. Elle appliquerait donc lcm 1 2, donnant 2. Puis lcm 2 3 donnant 6 Puis lcm 6 4 donne 12, et ainsi de suite. De cette façon, la fonction lcm n'est appelée que 19 fois pour donner votre réponse. En notation Big O, il s'agit d'opérations O (n-1) pour arriver à une solution.

Votre solution lente Haskell passe par les numéros 1-20 pour chaque numéro de 1 à votre solution. Si nous appelons la solution s, alors la solution de Haskell lente effectue des opérations O (s * n). Nous savons déjà que s dépasse 9 millions, ce qui explique probablement la lenteur. Même si tous les raccourcis et obtient une moyenne de la moitié de la liste des numéros 1-20, ce n'est toujours que O (s * n / 2).

L'appel headne vous évite pas de faire ces calculs, ils doivent être effectués afin de calculer la première solution.

Merci, c'était une question intéressante. Cela a vraiment étiré mes connaissances Haskell. Je ne pourrais pas y répondre du tout si je n'avais pas étudié les algorithmes l'automne dernier.


En fait, l'approche à laquelle vous en arriviez avec 2 * 3 * 5 * 7 * 11 * 13 * 17 * 19 est probablement au moins aussi rapide que la solution basée sur lcm. Ce dont vous avez spécifiquement besoin est 2 ^ 4 * 3 ^ 2 * 5 * 7 * 11 * 13 * 17 * 19. Parce que 2 ^ 4 est la plus grande puissance de 2 inférieure ou égale à 20, et 3 ^ 2 est la plus grande puissance de 3 inférieur ou égal à 20, etc.
point

@semicolon Bien que nettement plus rapide que les autres alternatives discutées, cette approche nécessite également une liste de nombres premiers pré-calculée, plus petite que le paramètre d'entrée. Si nous tenons compte de cela lors de l'exécution (et, plus important encore, de l'empreinte mémoire), cette approche devient malheureusement moins attrayante
K.Steff

@ K.Steff Vous plaisantez ... vous devez calculer les nombres premiers jusqu'à 19 ... cela prend une toute petite fraction de seconde. Votre déclaration a un sens absolument ZÉRO, la durée totale de mon approche est incroyablement petite, même avec la génération principale. J'ai activé le profilage et mon approche (dans Haskell) a obtenu total time = 0.00 secs (0 ticks @ 1000 us, 1 processor)et total alloc = 51,504 bytes. Le temps d'exécution est une proportion suffisamment petite d'une seconde pour ne même pas s'inscrire sur le profileur.
point

@semicolon J'aurais dû nuancer mon commentaire, désolé. Ma déclaration était liée au prix caché du calcul de tous les nombres premiers jusqu'à N - le naïf Eratosthenes est O (N * log (N) * log (log (N))) et la mémoire O (N) ce qui signifie que c'est le premier composant de l'algorithme qui manquera de mémoire ou de temps si N est vraiment grand. Il ne s'améliore pas beaucoup avec le tamis d'Atkin, j'ai donc conclu que l'algorithme sera moins attrayant que le foldl lcm [1..N], qui a besoin d'un nombre constant de bigints.
K.Steff

@ K.Steff Eh bien, je viens de tester les deux algorithmes. Pour mon algorithme basé sur le premier, le profileur m'a donné (pour n = 100 000): total time = 0.04 secset total alloc = 108,327,328 bytes. Pour l'autre algorithme basé sur lcm, le profileur m'a donné: total time = 0.67 secset total alloc = 1,975,550,160 bytes. Pour n = 1 000 000, j'ai obtenu pour base basée sur: total time = 1.21 secset total alloc = 8,846,768,456 bytes, et pour base cm: total time = 61.12 secset total alloc = 200,846,380,808 bytes. Donc, en d'autres termes, vous vous trompez, le principe de base est bien meilleur.
point

1

Je n'avais pas prévu initialement d'écrire une réponse. Mais on m'a dit après qu'un autre utilisateur a fait l'étrange affirmation que la simple multiplication des premiers nombres premiers était plus coûteuse en calcul que l'application répétée lcm. Voici donc les deux algorithmes et quelques repères:

Mon algorithme:

Algorithme de première génération, me donnant une liste infinie de nombres premiers.

isPrime :: Int -> Bool
isPrime 1 = False
isPrime n = all ((/= 0) . mod n) (takeWhile ((<= n) . (^ 2)) primes)

toPrime :: Int -> Int
toPrime n 
    | isPrime n = n 
    | otherwise = toPrime (n + 1)

primes :: [Int]
primes = 2 : map (toPrime . (+ 1)) primes

Maintenant, en utilisant cette liste principale pour calculer le résultat pour certains N:

solvePrime :: Integer -> Integer
solvePrime n = foldl' (*) 1 $ takeWhile (<= n) (fromIntegral <$> primes)

Maintenant, l'autre algorithme basé sur lcm, qui est certes assez concis, principalement parce que j'ai implémenté la génération principale à partir de zéro (et que je n'ai pas utilisé l'algorithme de compréhension de liste super concise en raison de ses performances médiocres) alors qu'il lcmétait simplement importé de Prelude.

solveLcm :: Integer -> Integer
solveLcm n = foldl' (flip lcm) 1 [2 .. n]
-- Much slower without `flip` on `lcm`

Maintenant pour les benchmarks, le code que j'ai utilisé pour chacun était simple: ( -prof -fprof-auto -O2alors +RTS -p)

main :: IO ()
main = print $ solvePrime n
-- OR
main = print $ solveLcm n

Pour n = 100,000, solvePrime:

total time = 0.04 secs
total alloc = 108,327,328 bytes

vs solveLcm:

total time = 0.12 secs
total alloc = 117,842,152 bytes

Pour n = 1,000,000, solvePrime:

total time = 1.21 secs
total alloc = 8,846,768,456 bytes

vs solveLcm:

total time = 9.10 secs
total alloc = 8,963,508,416 bytes

Pour n = 3,000,000, solvePrime:

total time = 8.99 secs
total alloc = 74,790,070,088 bytes

vs solveLcm:

total time = 86.42 secs
total alloc = 75,145,302,416 bytes

je pense que les résultats parlent d'eux-mêmes.

Le profileur indique que la génération principale occupe un pourcentage de plus en plus petit du temps d'exécution à mesure que les naugmentations. Ce n'est donc pas le goulot d'étranglement, nous pouvons donc l'ignorer pour l'instant.

Cela signifie que nous comparons vraiment l'appel lcmoù un argument va de 1 à n, et l'autre va géométriquement de 1 à ans. Pour appeler *avec la même situation et l'avantage supplémentaire de pouvoir sauter tous les numéros non premiers (asymptotiquement gratuitement, en raison de la nature plus coûteuse de *).

Et il est bien connu que *c'est plus rapide que lcm, comme cela lcmnécessite des applications répétées de mod, et modest asymptotiquement plus lent ( O(n^2)vs ~O(n^1.5)).

Ainsi, les résultats ci-dessus et la brève analyse de l'algorithme devraient rendre très évident quel algorithme est le plus rapide.

En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.