3. Le Code

3.1. Introduction

Le code se divise en trois grandes parties: 'main.cpp' contient le code de l'interface en ligne de commande, 'graphe.cpp' contient les structures de données pour représenter et manipuler un ensemble d'arretes, une instance du problème aisni que le code de l'affichage graphique (qui utilise openGL), 'algo.cpp' contient le code spécifique de chaque heuristique. De plus du code général sur les listes chainées, utilisé très fréquemment, se trouve dans lc.cpp et lc.hpp(fonctions génériques: 'templates'), une implantation d'arbre n-aire dans xtree.cpp et xtree.hpp, et un procédé de synchronisation étendant les sémaphores dans synchronizer.cpp Le reste de ce document est consacré au détail de chacune de ces parties.

3.2. Conventions

On adoptera dans la suite les conventions suivantes:

3.3. Main.cpp

Ce fichier contient la fonction main, qui se contente d'appeler en boucle la fonction command avec comme paramètre une ligne de stdin.

La fonction commande execute les connandes de l'utilisateur en appelant les fonctions correspondantes, en tenant à jour un état via des variables globales (option, instance du problème, résultats déja obtenus).Cette partie du code ne présente aucune difficulté ou intéret particulier, et ne sera donc pas détaillé.

3.4. Graphe.cpp

Ce fichier contient des classes utilisés par tous les algorithmes pour manipuler les données relatives au PVC.

3.4.2. Représentation d'un ensemble d'arrètes

Tous les algorithmes effectuant des manipulations sur les arrètes, il était nécessaire d'implanter une classe de gestion d'arrètes, ne consommant pas trop de mémoire (un tableau de N par N booléens n'est pas une solution sérieusement envisageable), et permettant l'ajout, la supression et la consultation rapide de ses arrètes. La solution retenue utilise une double liste chainée: chaque arrete (s1,s2) s1< s2 est dans une liste chainée de toutes les arrètes de même s1, triée selon s2, ainsi que dans une liste chainée de toutes les arrètes de même s2, triée selon s1. Des pointeurs sur le premier élément de ces 2*(N-1) listes chainées sont conservés. L'espace mémoire utilisé est ainsi proportionnel au nombre d'arretes, plus une 'constante' proportionelle à N. Le temps d'ajout/supression, proportionnel à N dans le cas général (parcours d'une liste chainée) est en pratique quasiment constant, car les ensembles d'arrètes utilisés par les algorithmes ont une arité très faible(2 pour tous les algorithmes ne travaillant que sur des chemins). La classe permettant de manipuler cette structure s'apelle tArretes, et implante les opérations de base (ajout, suppression, test) ainsi que deux fonctions permettant d'exporter l'ensemble d'arretes sous forme de tableau ou d'une liste chainée circulaire dans le cas ou cet ensemble définit un chemin. La classe tArrete représente une arrète dans une instance de tArretes, et expose un certain nombre de fonctions spécifiques à l'algorithme 2Opt qui seront détaillés plus bas.

3.5. Algo.cpp

Tous les algorithmes ont pour fonction principale une fonction prennant comme paramètre un EuclidianGraphe représentant l'instance du problème, et un unsigned long indiquant des options d'affichage et de sortie texte (définitions dans algo.h), et retournant un tArretes représentant la solution trouvée. La structure Algorithmes, tableau de PVC_Algo, contient la liste des algorithmes, avec leur nom, une descripion, et un pointeur sur la fonction principale. Le nom des fonctions principales de chaque algorithme est préfixé par PVC_H ou PVC_A selon qu'il s'agisse d'une heuristique ou d'une methode exacte.

La partie du paramètre option concernant l'affichage est un entier codé sur 16 bits, indiquant le temps a attendre entre chaque affichage, par tranche de 10 milisecondes. Une valeur de 0 signifie "desactiver l'affichage".

La partie concernant la sortie texte prend ses valeurs parmis "None","Important","Detailed","UltraDetailed","Verbose" et "Debug". Typiquement ces valeurs limitent la profondeur maximale à laquelle le code est autorisé à générer un message. Ainsi pour un algorithme s'execvutant en O(n*n), des valeurs de Important,Detailed et UltraDetailed génèrerons respectivement de l'ordre de O(1), O(n) et O(n*n) messages.

Certaines heuristiques (Recuit simulé, Elastique) utilisent aussi une partie du champ option, passé en paramètre lors de l'appel a "solve", pour paramétrer leur fonctionnement.

3.5.4. PVC_H2Opt

Cette heuristique fonctionne de la manière suivante: on part d'un chemin quelconque. A chaque étape, on considère le voisinage du chemin actuel défini par l'ensemble des chemins que l'on peut engendrer en permuttant deux arrètes. Si tous les chemins du voisinage sont plus long que le chemin actuel, l'heuristique s'arrète. Sinon le chemin actuel est remplacé par le chemin le plus court du voisinage, et le processus se répète.

On constate que, lorsque les deux arrètes sont permutés, une partie du chemin va ètre parcourue dans le sens inverse. Ainsi que l'on stoque le chemin dans un tableau ou dans une liste chainée circulaire, le temps nécessaire à la permutation de deux arrètes est en O(n).

Cependant il est possible de concevoir une structure de donnée pour laquelle la permutation de deux arrètes s'effectue en temps constant: une liste chainée circulaire complétée par un tableau indiquant pour chaque sommet s'il s'agit d'un point d'inversion du parcours.

Concrètement, si on dispose des variables "sensactuel" indiquant si le sens de parcours actuel est inversé, "sommetactuel" le sommet actuel dans la liste chainée, et du tableau estpointdinversion, l'algorithme pour passer au sommet suivant s'ecrit:

si sensactuel est inverse alors prochainsommet=sommeactuel->precedent
sinon prochainsommet=sommetactuel->suivant
si estpointdinversion[prochainsommet] alors sensactuel= non sensactuel

L'algorithme de permutation de deux arretes consiste a effectuer la permutation dans la liste chainée, et à mettre à jour les champs du tableau correspondant à deux des quatre sommets en jeu. Cette opération, bien qu'un peu complexe, s'effectue en un temps constant. Se repporter au code pour les détails de son fonctionnement.

Une fois la structure de donnée représentant l'information trouvée, le reste de l'implantation de cette heuristique est trivial.

3.5.6. PVC_ASeparationEvaluation

Il s'agit de la seule méthode donnant une solution exacte implantée ici. Elle procède en parcourant l'arbre des solutions par niveau, calculant à chaque sommet la borne inférieur des solutions qui lui sont issues, et élagant ce sommet si cette borne inférieure est supérieure à une solution connue.

La structure de donnée définissant un noeud de l'arbre des solutions est une classe, Arbre, déricant de xTree. Chaque instance dispose d'un pointeur vers le noeud père, le premier noeud fils,le frère suivant et le frère précédent, ce qui permet de créer des arbres d'arité quelconque. L'implantation de cette heuristique est divisée en trois fonctions:

La fonction principale se contente d'initialiser les structures de donnée: l'arbre contient initialement un seul noeud, correspondant au sommet 0. Une première solution est cherchée à l'aide d'une heuristique simple (2 Opt) afin d'améliorer l'élagation. Puis elle apelle la fonction de parcours tant que celle-ci renvoit true, ce qui signifie qu'une expansion est toujours possible.

La fonction de parcours étend l'arbre d'un niveau: elle parcours l'arbre de facon recursive, en élagant tout noeud dont la borne inférieure est supérieure à la meilleure solution connue. A chaque fois qu'elle tombe sur un noeud non développé, elle le développe en créant tous les fils corespondants à une extension du sous-chemin possible, et en appelant la fonction d'évaluation sur chacun de ces fils. Elle renvoit false si aucune expansion n'a été faite, la gestion de la valeur renvoyée étant telle que la valeur finalement renvoyée à la première fonction appelante soit le OU de toutes les valeurs renvoyés par chacun des appels récursifs.

La fonction d'évaluation prend comme paramètre un sous-chemin, définit par l'ensemble des sommets déja atteints, et renvoit une borne inférieure du plus court sous-chemin étendant ce dernier en chemin valide, ainsi que si cette borne est atteinte ou non. Cette borne est définie par Somme( Min{ w(i,j), j=1..N} ,i non encre atteint). Le code procède de la manière suivante: il regarde si M=Min( w(i,currentsommet), i=1..N) est atteint pour au moins un sommet non encore atteint. Si c'est le cas, la fonction s'apelle recursivement sur chacun de ces sommets, renvoit comme borne M+la valeur renvoyée par l'une de ces fonctions(qui est par définition la même pour toutes). De plus la borne est atteinte si au moins une des fonctions appelés recursivements a renvoyé qu'elle était atteinte.

Dans le cas contraire, c'est a dire si le minimum est atteint en un sommet déja atteint, alors la borne n'est pas atteignable, et sa valeur est calculée plus rapidement par la formule ci-dessus.

Afin d'accélérer quelque peu, le cas ou le minimum des w(i,currentsommet) est atteint pour un seul sommet, cas le plus fréquent car l'égalité de deux distances est rare, est traité de manière itérative.

Malgrè le processus d'élagation, la mémoire et le temps de calcul nécessaires à cet algorithme sont tels qu'il est impossible de l'utiliser sur des problèmes dépassant la dizaine de sommets.Bien sur on ne peut raisonnablement le comparer aux autres heuristiques présentées ici, caar il fournit la solution optimale.

3.5.7. PVC_HElastique

Autre méthode basée sur la simulation d'un modèle physique, la méthode de l'élastique simule un élastique soumis à une force d'attraction de chacun de ses sommets vers chacune des villes, ainsi qu'à une force d'attraction entre les sommets voisins, ayant pour effet de diminuer sa longueur.

Les paramètres utilisés sont:

Le grand avantage de cet algrithme est qu'il est massivement parallélisable: en effet chaque sommet de l'élastique peut être traité indépendemment. J'ai donc opté pour une implantation multithread. Détaillons les aspects importants de cette architecture.

Un thread controleur, le thread principal, est chargé de la synchronisation et de la répartition des tâches entre les threads fils. Chaque thread fils a à sa charge un certain nombre de sommets, et un certain nombre de villes. Les threads fils effectuent de manière synchrone chacune des tâches suivantes:

Le procédé de synchronisation est implanté dans synchronizer.cpp et est utilisé de la manière suivante: Tous les threads partagent le même pointeur vers un objet de synchronisation. A chaque fois qu'un thread fils a terminé une des opérations ci-dessus, il appele waitforsignal, ce qui le mets en attente. Le thread principal, lui a appelé waitforwaiters, ce qui a eut pour effet de le mettre en attente jusqu'à ce que tous les thread fils soient en attente du signal. Une fois que tous les threads fils sont en attente, le thread controleur est débloqué. Il incrémente alors la variable indiquant l'opération courante, puis apelle la fonction signal, ce qui débloque tous les threads fils. Il appelle enfin waitforwaiters à nouveau, et le processus se répète.

Ce mécanisme de synchronisation utilise pour fonctionner trois sémaphores: un pour protéger ses données, un pour bloquer les threads en attente du signal, et le dernier pour bloquer les thread controleur, ainsi qu'un compteur indiquant le nombre de threads en attente du signal.

Etant donné que chacune des opérations effectuées par les threads s'effectue en un temps relativement constabnt, la répartition des sommets entre les threads ne s'effectue pas de manière dynamique à chaque itération, mais de manière statique, un même nombre de sommets et de villes étant attribué à chaque thread. Etant donné que le nombre de threads et de sommets dans l'élastique varie au cours du temps, un mécanisme simple a été msi en place pour modifier la répartition rapidement: si on dispose de t threads et de s=n*t+r sommets, n+1 sommets seront attribués aux r premiers threads, et n aux threads restants. La valeur de r est conservée, ainsi à chaque fois qu'un sommet est ajouté, il est ajouté au r+1 ième sommet et r est incrémenté de 1.