Recherche
Peut être aimerez-vous...
Sections du site
Sites Neamar
Lisez ces articles !
| Déterminer si un nombre est premier : d'optimisation en optimisation |
|
| Programmation et tuning - Algorithmie et optimisation | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Écrit par Neamar | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Samedi, 27 Mars 2010 10:36 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Voilà plusieurs fois que je tombe sur des programmes C cherchant à savoir si un nombre est premier ou pas. La plupart du temps, on peut aller beaucoup plus loin que ce qui est proposé. Petit tour d'horizon des optimisations à appliquer ; pas toujours évidentes, mais qui ont l'avantage d'être bien plus rapides que l'implémentation naïve. Définition du cahier des chargesQue doit faire le programme ?Dans cet article, j'examinerai différents algorithmes qui auront pour but de trouver tous les nombres premiers inférieurs à une constante, MAX_NUM et de les renvoyer à l'intérieur d'un tableau d'entiers. Les contraintes suivantes devront être respectées :
Télécharger les codes de tests. Précisons les chosesNombre premierAvant d'aller plus loin, définissons le terme de nombre premier. Il s'agit d'un nombre qui n'admet aucun autre diviseur que 1 et lui-même ; par convention on ne considére pas 1 comme premier. Protocole de testChacun des codes ne sera constitué que d'un seul fichier. Ligne de commande de compilation : gcc fichier.c -lm && time ./a.out Détails pratiquesPour les calculs, on règlera MAX_NUM à 1 000 000 ; cela suffit pour faire travailler le processeur un minimum sans avoir à attendre la fin de la journée (au total, on devra trouver 78 498 nombres premiers dans cet intervalle). DédicaceJe dédicace cet article à un tyran fort pédagogue qui se reconnaîtra sûrement en lisant cet article dont il est l'inspirateur… hém hém. Approches intuitivesJe suis jeune et naïf, je code vite et pas optimiséPremière approche : on veut récupérer tous les nombres premiers inférieurs à MAX_NUM, je vais donc faire une boucle sur ces nombres. Pour chacun des nombres, j'appellerais une fonction qui me renverra true si le nombre est premier, false sinon. Implémentation : code no1 #include <stdio.h> #define MAX_NUM 1000000 /** * Cette fonction prend en paramètre un nombre et essaie de calculer * s'il est premier ou non. * @param p Le nombre dont on veut tester la primalité * @return 0 si p n'est pas premier, 1 sinon. */ int premier(unsigned long p) { unsigned long i; //Pour chacun des nombres inférieurs à p for(i=2;i<p;i++) { //i est-il un diviseur de p ? //NOTE : % dénote l'opération modulo, //qui renvoie le reste de la division euclidienne de p par i. //Exemple : 5 % 2 = 1 (car 5 = 2*2 + 1) if(p % i == 0) return 0; } //Aucun des nombres ne le divise, p est premier return 1; } int main() { unsigned long Premiers[MAX_NUM]; unsigned long premiers_trouves = 0; unsigned long nombre; //On commence la boucle à deux car 1 n'est pas premier. for(nombre=2; nombre<MAX_NUM;nombre++) { if(premier(nombre) == 1) { //Le nombre i est premier, l'ajouter. Premiers[premiers_trouves++] = nombre; } } printf("Total : %lu\n",premiers_trouves); return 0; } Effectivement, ça marche. Mais c'est long, très long… Temps du code : 8m54,778s Je suis jeune et un peu moins naïf, j'ai déjà fait des mathsL'idée : s'arrêter à la racine carrée pendant les tests. Le code précédent effectue un nombre incroyable de tests inutiles. Implémentation : code no2 #include <stdio.h> #include <math.h> #define MAX_NUM 1000000 int premier(unsigned long p) { unsigned long i; //Pour chacun des nombres inférieurs à racine(p) unsigned long sqrt_p = sqrt(p) + 1; for(i=2;i<sqrt_p;i++) { if(p % i == 0) return 0; } //Aucun des nombres ne le divise, p est premier return 1; } int main() { unsigned long Premiers[MAX_NUM]; unsigned long premiers_trouves = 0; unsigned long nombre; for(nombre=2; nombre<MAX_NUM;nombre++) { if(premier(nombre)) { Premiers[premiers_trouves++] = nombre; } } printf("Total : %lu\n",premiers_trouves); return 0; } Mieux, beaucoup mieux. Cette fois, il ne faut qu'un peu plus d'une seconde pour faire les mêmes calculs ; c'est une énorme amélioration ! Mais c'est loin d'être fini. Temps du code : 0m1,013 s Je suis jeune et encore moins naïf, j'ai déjà fait des maths et de l'algorithmieL'idée : les nombres pairs ne sont jamais premiers. Avez-vous remarqué que l'on continue de faire des calculs inutiles ? Si si, regardez : on teste pour savoir si les nombres 4, 6, 8, etc. sont premiers. Mais c'est stupide ! Le seul nombre pair premier, c'est deux ; calculer les autres est un gâchis de précieux temps. Implémentation : code no3 #include <stdio.h> #include <math.h> #define MAX_NUM 1000000 int premier(unsigned long p) { unsigned long i; //Pour chacun des nombres impairs inférieurs à racine(p) unsigned long sqrt_p = sqrt(p) + 1; for(i=3;i<sqrt_p;i += 2) { if(p % i == 0) return 0; } return 1; } int main() { unsigned long Premiers[MAX_NUM/2]; unsigned long premiers_trouves = 0; unsigned long nombre; //Placer le 2 dans la liste car la boucle le saute directement. Premiers[premiers_trouves++]=2; //On commence la boucle à trois et on va de nombre impair en nombre impair for(nombre=3; nombre<MAX_NUM;nombre+=2) { if(premier(nombre)) { Premiers[premiers_trouves++] = nombre; } } printf("Total : %lu\n",premiers_trouves); return 0; } Notez aussi que la taille du tableau contenant les nombres premiers a été réduite par deux elle-aussi. En théorie on aurait pu faire une meilleure approximation (en prenant une borne supérieure de x/ln(x) par exemple), en pratique on se contentera de cela, n'allons pas tout compliquer. Temps du code : 0m0,504 s Je suis jeune, pas trop naïf et j'ai de la suite dans les idéesLa dernière fois, nous avons modifié notre code pour sauter automatiquement les multiples de deux. Mais pourquoi s'arrêter en si bon chemin ? Sautons aussi les multiples de 3 ! Implémentation : code no4 #include <stdio.h> #include <math.h> #define MAX_NUM 1000000 int premier(unsigned long p) { unsigned long i; //Pour chacun des nombres impairs inférieurs à racine(p) unsigned long sqrt_p = sqrt(p) + 1; int pas = 4; for(i=5;i<sqrt_p;i += pas) { if(p % i == 0) return 0; //Mettre à jour le pas pas = 6 - pas; } return 1; } int main() { unsigned long Premiers[MAX_NUM/2]; unsigned long premiers_trouves = 0; unsigned long nombre; //Placer les 2 et 3 dans la liste car la boucle les saute directement. Premiers[premiers_trouves++]=2; Premiers[premiers_trouves++]=3; //On commence la boucle à sept et on va de nombre impair en nombre impair unsigned int pas = 4; for(nombre=5; nombre<MAX_NUM; nombre+=pas) { if(premier(nombre)) { Premiers[premiers_trouves++] = nombre; } //Mettre à jour le pas pas = 6 - pas; } printf("Total : %lu\n",premiers_trouves); return 0; } Vous devriez vous dire qu'on peut continuer ainsi longtemps, en enlevant les diviseurs. Après 2 et 3, on vire le 5, puis le 7… sauf que le calcul du pas augmente alors en complexité, et on y perd tout avantage. En fait, l'approche naïve est maintenant bien optimisée : on peut difficilement faire mieux2. Temps du code : 0m0,361 s Passons aux choses sérieusesLa première approche est maintenant rapide, on ne l'améliorera pas significativement. Pour progresser encore, il faut revoir notre stratégie et se poser la question : « qu'est-ce qui fait ramer ? ».
Retour à la base avec la nouvelle approchePour ne pas aller trop vite, je vais reprendre le tout premier code en lui appliquant la nouvelle architecture. Il sera donc plus lent que le code no4, mais plus rapide que le test no1 avec lequel il doit être comparé. Implémentation : code no2-1 #include <stdio.h> #define MAX_NUM 1000000 int main() { unsigned long Premiers[MAX_NUM/2]; unsigned long premiers_trouves = 0; unsigned long nombre; unsigned long i; for(nombre=2; nombre<MAX_NUM; nombre++) { //Test de primalité direct : for(i=2; nombre % i;i++); //----Notez l'absence de corps : //tout s'effectue dans les conditions du for. if(i == nombre) { Premiers[premiers_trouves++]=i; } } printf("Total : %lu\n",premiers_trouves); return 0; } Que dire ? D'abord, le code est beaucoup plus concis que son équivalent « naïf » : le nombre d'instructions est beaucoup plus limité, et la « zone critique » (la boucle for imbriquée) n'est pas encombrée de tests : en fait, la boucle for n'a même pas de corps (il y a un point virgule à la fin, pas un bloc entouré par des accolades comme on en a l'habitude). Temps du code : 8m6,025s. Ça n'en a pas l'air, mais c'est une amélioration de presque 10% ! Toujours plus loin !L'idée : replacer toutes les améliorations précédentes dans la nouvelle approche. Implémentation : code no2-2 #include <stdio.h> #include <math.h> #define MAX_NUM 1000000 int main() { unsigned long Premiers[MAX_NUM/2]; unsigned long premiers_trouves = 0; unsigned long nombre; unsigned long i; Premiers[premiers_trouves++]=2; Premiers[premiers_trouves++]=3; int pasNombre = 4; for(nombre=5; nombre<MAX_NUM; nombre+=(pasNombre=6-pasNombre)) { int pasI = 4; unsigned long sqrt_nombre=sqrt(nombre) + 1; //Test de primalité direct : for(i=5; (i<sqrt_nombre) && (nombre % i);i+=(pasI = 6 - pasI)); if(i >= sqrt_nombre) { Premiers[premiers_trouves++]=nombre; } } printf("Total : %lu\n",premiers_trouves); return 0; } Notez la concision du code, au détriment de la lisibilité… en particulier, la deuxième boucle devient peu digeste. Temps du code : 0m0,348s. C'est quelques millisecondes de plus par rapport à notre précédent record ! Tant et plus ?Vous pensez qu'on ne fera pas mieux ? Détrompez-vous ! Implémentation : code no2-3 #include <stdio.h> #include <math.h> #define MAX_NUM 1000000 int main() { unsigned long Premiers[MAX_NUM/2]; unsigned long premiers_trouves = 0; unsigned long nombre; unsigned long i; Premiers[premiers_trouves++]=2; Premiers[premiers_trouves++]=3; int pasNombre = 4; for(nombre=5; nombre<MAX_NUM; nombre+=(pasNombre=6-pasNombre)) { unsigned long pointeurCourant=0; unsigned long sqrt_nombre=sqrt(nombre) + 1; for(i=Premiers[pointeurCourant]; (i<sqrt_nombre) && (nombre % i);i=Premiers[pointeurCourant++]); if(i >= sqrt_nombre) { Premiers[premiers_trouves++]=nombre; } } printf("Total : %lu\n",premiers_trouves); return 0; } Temps du code : 0m0,211s. Et pour finir en beauté…Pour finir en beauté, on va encore pousser l'approche précédente. Soyons simple pour l'instant et dé-considérons la suppression des multiples de 2 et de 3 ; on revient sur une boucle for « standard ». Vous avez le principe ? Complexifions : on reprend notre pas qui évite directement les multiples de 2 et 3. En conséquence, on ne trouvera jamais 2 × NombrePremier qui est de façon évidente un multiple de 2. De même, 3 × NombrePremier sera sauté (multiple de 3). 5 × NombrePremier sera trouvé quand on le comparera au multiple de 5 actuellement en mémoire, 7 × NombrePremier aussi… finalement, le prochain multiple qu'on peut rencontrer pour un nombre n est n2 (testez pour n=5 : 10 est éliminé par le pas, 15 aussi, 20 de même, seul 25 arrive). À la découverte d'un nombre premier, on ajoute donc dans la table des multiples ledit nombre premier au carré. Et à chaque fois qu'on passe au dessus (25 est éliminé par comparaison à la table des multiples, valeur suivante : 29. 29>25), on met à jour le multiple. Dans la version simple, on ajoutait simplement la valeur de base (5), mais ici c'est inutile (le multiple est impair [les multiples de deux ne sont pas considérés] et la valeur ajoutée est impaire : on obtiendrait un multiple pair, ce qui ne sert à rien) : on ajoutera donc au multiple deux fois la valeur de base (25 + 2 × 5 = 35). Toujours pas convaincu par cette « simplification » des multiples ? Prenons 7 : 14 est multiple de 2 et n'est pas considéré, 21 multiple de 3, 28 de deux, 35 n'est multiple ni de deux ni de trois, mais est dans la table des multiples [valeur associée à 5 une fois passé le cap des 25], la prochaine valeur intéressante à considérer est donc bien 72=49). Allez, c'est pas facile, je vais donner un exemple concret. Pour initaliser, on place dans la liste des nombres premiers 5 et son premier multiple à considérer : 253.
Implémentation : code no2-4 #include <stdio.h> #define MAX_NUM 1000000 int main() { unsigned long Premiers[MAX_NUM/2]; unsigned long Multiples[MAX_NUM/2]; unsigned long premiers_trouves = 0; unsigned long nombre; unsigned long i; Multiples[premiers_trouves]=5*5; Premiers[premiers_trouves++]=5; int pasNombre = 2; for(nombre=7; nombre<MAX_NUM; nombre+=(pasNombre=6-pasNombre)) { unsigned long pointeurCourant=0; //Test de primalité direct : for(i=Premiers[pointeurCourant]; (nombre!=Multiples[pointeurCourant]) && i*i < nombre;i=Premiers[++pointeurCourant]) { if(nombre>Multiples[pointeurCourant]) Multiples[pointeurCourant] +=2*i; } if(nombre!=Multiples[pointeurCourant]) { Multiples[premiers_trouves] = nombre * nombre; Premiers[premiers_trouves++] = nombre; } } Premiers[premiers_trouves++]=2; Premiers[premiers_trouves++]=3; printf("Total : %lu\n",premiers_trouves); return 0; } Temps du code : 0m0,120s. En conclusionCet article vous aura, je l'espère interessé. Il montre l'importance du choix de l'algorithme, mais aussi la nécessité de comprendre les mécanismes internes du langage (je pense par exemple aux appels de fonction, ou aux possibilités offertes par les boucles for). Soyons franc cependant : améliorer de 445 000 % son code n'est pas un événement courant. Mais l'important est de comprendre le principe… Télécharger les codes de tests.
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Mise à jour le Dimanche, 25 Avril 2010 09:17 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
