Février 2015 Rapport de projet Jérémy Schneiter Grégori Tirsatine Projet M2 Informatique tutoré par : M. Julien Bernard 2 Table des matières Introduction 5 1 Présentation 1.1 Règles du Scrabble duplicate 1.2 Problèmatique 1.3 Existants et choix du langage 1.4 Modélisation du programme 6 6 6 7 7 2 Compression du dictionnaire 2.1 Structures basées sur les anagrammes 2.1.1. Anagramme version 1 : clés entières 2.1.2. Anagramme version 2 : clé string 2.2 Structures basées sur les Abres/Graphes 2.2.1. Dictionnaire sous forme d'Arbre 2.2.2. Première amélioration : Trie 2.2.3. Deuxième amélioration : Dawg 2.2.4. Une autre structure : le Gaddag 2.3 Comparatif 8 8 8 9 12 12 13 14 17 18 3 Recherche et placement des mots 3.1 Algorithme brute force 3.2 Algorithme de Jacobson et Appel 3.2.1. Principe de l'algorithme 3.2.2. Description de l'algorithme 3.2.3. Preuve informelle 19 19 20 20 23 25 4 Bilan 4.1 Travaux supplémentaires 4.1.1. Interface graphique 4.1.2. Chargement d'une partie 4.2 Méthodes de travail 4.3 Bilan pédagogique 27 27 27 29 29 29 Conclusion 30 Bibliographie 31 3 Tables des figures Fig 1.1 Tableau comparatif des critères de choix de langage Fig 1.2 Structure générale du programme 7 7 Fig 2.1 Génération du dictionnaire d'anagrammes Fig 2.2 Algorithme de recherche des mots, structure Anagrammes Fig 2.3 Algorithme de permutations Fig 2.4 Dictionnaire sous forme d'arbre Fig 2.5 Dictionnaire sous forme de trie Fig 2.6 Algorithme d'insertion d'un mot dans un trie Fig 2.7 Dictionnaire sous forme de dawg Fig 2.8 Algorithme de fusion des nœuds Fig 2.9 Tableau comparatif des structures 9 10 11 12 13 14 14 16 18 Fig 3.1 Algorithme de placement des mots Fig 3.2 Algorithme de placement des mots (extension) 23 23 Fig 4.1 Interface graphique Fig 4.2 Interface console 28 28 4 Introduction Dans la version traditionnelle du jeu de Scrabble, une certaine part est laissée à la chance, chaque joueur ayant des lettres différentes et jouant sur une configuration de grille changeant à chaque tour de jeu. En 1971, un avocat bruxellois nommé Hippolyte Wouters invente la formule duplicate, censée éliminer le facteur chance. Chaque joueur jouant alors avec les mêmes lettres, et la même configuration de plateau. Nous présentons dans ce document la réalisation d'un programme capable de trouver les meilleures combinaisons permettant à chaque tour d'effectuer le score optimal. La problèmatique se situe principalement dans les stratégies élaborées pour permettre une recherche de mots à partir d'une séquence de lettres, et trouver le meilleur emplacement. Ceci doit être réalisé en un temps qui permet à une partie de se dérouler correctement. Nous présenterons dans un premier temps les règles nécessaires à la compréhension du duplicate ainsi que les différents travaux de recherche étudiés pour la conception du programme. Après avoir explicité la problèmatique liée au développement d'un solveur, nous aborderons les différents choix de conception mis en place au cours de ce projet. Nous discuterons notamment des structures choisies pour représenter le dictionnaire afin de permettre une recherche rapide de formation de mots à partir d'une séquence de lettres, puis nous traiterons de manière détaillée la stratégie de recherche et de placement optimal des mots sur un plateau donné. 5 Chapitre 1 Présentation 1.1 Les règles du Scrabble duplicate Le Scrabble duplicate est une variante du jeu de Scrabble familial, dont l'objectif par rapport à la version traditionnelle est d'éliminer totalement la part de hasard due aux tirages. Le but à chaque étape de la partie est d'effectuer le meilleur score en fonction d'un tirage de lettres commun à tous les participants. Ainsi une partie de Scrabble duplicate peut se faire seul, avec pour objectif d'obtenir le meilleur score, ou comme dans certaines compétitions avec des centaines, voires des milliers de joueurs (par exemple la compétition "Simultané Mondial" réunissant 5000 participants). La solution offrant le plus de points est retenue et placée sur le plateau, qui demeure donc identique pour tous les joueurs à chaque étape de la partie. Dans cette version il n'y a plus de notion de stratégie, puisqu'il s'agit de faire le score optimal pour chaque coup joué, sans analyser les coups suivants possibles. 1.2 Problèmatique Le jeu de Scrabble se base sur un dictionnaire pour valider ou invalider les mots proposés. Nous avons choisi un dictionnaire francophone contenant 131 896 mots. A chaque tirage de lettres, les différentes façons de combiner les lettres sont testées par le solveur. Le programme doit donc régulièrement parcourir un grand ensemble d'éléments afin de vérifier l'existence ou non des mots formés. La nature d'un dictionnaire sous forme d'un fichier listant les mots autorisés rendrait l'utilisation d'un algorithme de recherche particulièrement long. Il s'agit donc dans un premier temps de trouver une structure de données adaptée pour représenter l'ensemble des mots, afin de permettre de nombreuses recherches d'existence de mots en un minimum de temps, et ainsi proposer une solution jouable. Ceci revient à compresser le dictionnaire, afin de réduire l'espace de recherche. Une deuxième difficulté majeure consiste à élaborer une stratégie de recherche des mots par rapport à un plateau donné, en utilisant les contraintes liées aux règles (connexion obligatoire entre les mots, non modification des lettres déjà posées, formation de mots corrects avec les lettres adjacentes) dans un temps permettant un rythme de jeu convenable. Ces deux thèmes seront décrits de manière détaillée en chapitre 2 et chapitre 3. 1.3 Existants et choix du langage 6 Les travaux sur l'automatisation de résolution de Scrabble duplicate ont commencé dans les années 1980 avec des logiciels lents et non exhaustifs. En 1988, Andrew W. Appel et Guy J. Jacobson publient leurs travaux de recherche intitulés The World's Fastest Scrabble Program, travaux sur lesquels nous nous sommes appuyés pour la conception du solveur. Cette publication traite de la représentation d'un dictionnaire sous forme de graphe orienté acyclique, et propose un algorithme de placement des mots adapté. Un second document écrit par Steven A. Gordon traite en 1993 de ce qui se veut être une amélioration des résultats obtenus par les travaux de Appel et Jacobson, dans une publication intitulée A Scrabble Faster Move Generation Algorithm. Nous avons étudié particulièrement ces deux publications pour orienter nos choix de conception. D'un point de vue technique, il a fallu effectuer un choix parmi les deux langages orientés objets suivants : Java - C++. Java C++ Vitesse d'exécution Connaissance du langage Fig 1.1 Tableau comparatif des critères de choix de langage Le fait que le langage C++ offre une plus grande rapidité d'exécution est un critère important dans le contexte du projet, la vitesse étant la clé de voûte du solveur. Nous souhaitions de plus élargir notre champs de compétences en utilisant un langage hors cursus universitaire sur un projet suffisemment ambitieux. 1.4 Modélisation du programme Le programme peut se découper en quatre grandes parties conceptuelles : i. les objets représentant les abstractions relatives au jeu (chevalet, plateau, lettres,...) ii. la structure de données représentant le dictionnaire et ses méthodes propres iii. l'algorithme de recherche et de placement des mots iv. l'interface utilisateur Plateau, Chevalet, Lettres, ... Interface Utilisateur Dictionnaire Solveur Fig 1.2 Structure générale du programme 7 Chapitre 2 Compression du dictionnaire Un des rôles majeurs du solveur de Scrabble est de former des mots à partir d'un ensemble de lettres. Sans structure adaptée, pour effectuer une recherche de combinaisons valides à partir des 7 lettres du chevalets, il faudrait parcourir l'ensemble du dictionnaire pour les 5 040 possibilités de combinaisons. Soit dans notre cas un parcours des 131 896 lignes du dictionnaire multiplié par 5 040, uniquement dans le cadre d'une simple recherche de mots à partir du chevalet. Nous avons expérimenté trois structures de données comprenant leurs propres méthodes de recherche afin de gagner en efficacité. 2.1 Structures basées sur les anagrammes 2.1.2 Anagramme version 1 : clés entières Une première méthode pour éviter un parcours total du dictionnaire est de le partitionner en classes d'équivalence. On peut considérer que deux mots sont équivalents s'ils sont constitués des mêmes lettres. L'idée est de créer ces groupes d’anagrammes et de les identifier de manière unique par une clé entière afin de réduire l'ensemble de cas à explorer. Soit le dictionnaire composé des mots suivants : "epi, pie, ere, erg, gre, reg, concret, concert" Et soient les lettres dont nous disposons : E,E,R,R,I,P,S. Un premier travail consiste à regrouper les anagrammes du dictionnaire et construire un nouveau fichier de la forme suivante : 1 2 3 4 : : : : epi pie ere erg gre reg concert concret De cette manière, le dictionnaire n'est plus à parcourir dans son intégralité, puisqu'il suffit de tester si un mot de chaque groupe est contenu parmi les lettres dont on dispose. Si c'est le cas, alors tous les mots du groupe peuvent être formés. Les lettres dont nous disposons permettent de créer les mots epi et ere. Par conséquent, les mots pouvant être construits sont ceux appartenant aux groupes 1 et 2 : epi, pie, ere. 8 Ce traitement effectue une réduction du dictionnaire de 15 335 lignes, le fichier passant à une taille de 116 534 lignes. Toutefois la construction du dictionnaire réduit est particulièrement lente. En effet nous ne sommes pas parvenus à générer le dictionnaire dans son intégralité (temps supérieur à 2h). À titre d'exemple, nous avons expérimenté cette compression sur un dictionnaire de 200 mots de longueur inférieure à 8 caractères. Malgré la taille réduite du fichier de base, cette réduction s'est exécutée en 2 secondes. Pour accélérer la vitesse de génération du dictionnaire transformé, nous avons effectué un premier tri dans le dictionnaire d'origine par longueur de mot, puis nous avons créé un fichier par taille de mots (un fichier contenant les mots de longueur 1, un second pour les mot de longueur 2, etc). La création des groupes d'anagrammes s'effectuait alors sur des dictionnaires de tailles très réduites. Une fois ceux-ci créés, ils sont alors mis en commun par un parcours successif des dictionnaires. tri par longueur de mots séparation création des groupes dictionnaire d'origine fusion dictionnaire généré Fig 2.1 Génération du dictionnaire d'anagrammes En appliquant cette méthode, la construction du dictionnaire d'anagrammes s'est effectuée en 48 minutes. Cependant, un inconvénient résulte de la nature de la clé entière, qui ne permet pas la recherche des anagrammes correspondants de manière efficace. En effet, le dictionnaire généré possède 116 534 groupes d‘anagrammes qu‘il faut parcourir entièrement à chaque recherche de mot. Nous avons donc envisagé une solution améliorée se servant de l'efficacité de recherche dans une structure "map". 2.1.2 Anagramme version 2 : clé string Cette seconde version se base également sur des groupes d’anagrammes. Au lieu d’utiliser des entiers pour identifier les différents groupes, nous les avons remplacé par des chaînes de caractères pouvant être comparées directement au mot lu. Cette chaîne correspond aux anagrammes du groupe triés par ordre alphabétique. 9 Exemple : L'identifiant du groupe d'anagrammes du mot "maison" sera la chaîne "aimnos". Ainsi pour générer le dictionnaire d'anagrammes, nous parcourons le dictionnaire original, et nous enregistrons le mot lu dans une map dont la clé est ce mot trié par ordre alphabétique. Pour ne pas avoir à effectuer cette transformation à chaque lancement du solveur, nous avons engregistré la map dans un fichier texte. Cette précaution est cependant dérisoire puisque la génération du dictionnaire en adoptant cette stratégie est nettement accélérée, avec un temps inférieur à 1s. Recherche des solutions à partir d'un ensemble de lettres : Avec ce nouveau dictionnaire, la recherche des combinaisons de lettres formant un mot valide s'effectue ainsi : Création de toutes les combinaisons de lettres (de longueurs 1, 2, 3, ... , taille du chevalet) Pour chaque combinaison obtenue, les lettres sont triées par ordre alphabétique L'accès à la liste de solutions se fait en utilisant le résultat trié comme clé de la map. L'opération de tri et d'accès à un élément d'une map sont tous deux de complexité O( n log(n) ). Nous avons donc porté une attention particulière à l'algorithme générant les combinaisons afin de conserver une bonne efficacité. L'objectif consiste à ne pas former de combinaisons redondantes. Si nous possèdons les lettres a, b et c, la combinaison abc est identique à bac, cab, acb, ... et aux autres permutations. void FindWords(string letters) : string tmp FOR k = 1 to letters.length -1 int *i = new int[k] FOR j = 0 to k-1 i[j] = j endFOR DO tmp = "" FOR j = 0 to k-1 tmp += letters[ i[j] ] endFOR sort(tmp) findAnagrams(tmp) WHILE next(letters.length, k, i) endFOR endFindWords Fig 2.2 Algorithme de recherche des mots, structure Anagrammes 10 boolean Next(int n, int k, int* i) : return next_rec(n, k, i, k – 1) endNext boolean Next_rec(int n, int k, int* i, int j) : i[j]++ IF i[j] == n - (k - j) + 1 IF j == 0 return false; endIF IF not next_rec(n, k, i, j - 1) return false endIF i[j] = i[j - 1] + 1 endIF return true endNext_rec Fig 2.3 Algorithme de permutations Déroulement partiel de l'algorithme : ➢ Contenu du chevalet : maison. ➢ Appel de la fonction findWords(maison). ➢ Prenons la valeur k = 4, cette étape correspond à la recherche de toutes les combinaisons de 4 lettres. Le tableau des indices vaut initialement i = [0,1,2,3]. Ce tableau d'indices va permettre de créer un premier mot : → letters[i[0]]+letters[i[1]]+letters[i[2]]+letters[i[3]] → "mais" ➢ La fonction Next_rec calcule de manière récursive toutes les combinaisons de 4 indices sans tenir compte des permutations. Les combinaisons calculées par l'algorithme dans le cas de cet exemple sont : • • • mais,maio,main,maso,masn,maon,miso,misn,mion,mson aiso,aisn,aion,ason ison Soit 15 mots au lieu des 35 possibilités. Concernant les lettres blanches, une première méthode est appelée avant de rechercher les mots. Cette méthode cherche et remplace la ou les lettre(s) blanche(s) successivement par chacune des lettres de l'alphabet. 11 2.2 Structures basées sur les Arbres/Graphes 2.2.1 Dictionnaire sous forme d'Arbre Un dictionnaire sous forme d'arbre dont les arêtes sont étiquetées par une lettre peut permettre d'utiliser une stratégie efficace de backtracking. Un chemin entre la racine et un nœud terminal représente un mot valide du dictionnaire. Exemple : Soit le dictionnaire sous forme d'arbre orienté formé des mots : "dent, dont, pates, pats, pote, pots, va, vent, vont" D D P P P V V V E O A A O O N N T T T T T T E S E V A S E O N N T T Fig 2.4 Dictionnaire sous forme d'arbre Les nœuds doublement cerclés représentent les nœuds terminaux. On peut donc représenter ce dictionnaire sommaire par un arbre de 36 nœuds. Chercher l'existence d'un mot revient à chercher si un chemin existe en utilisant les lettres de la séquence, et aboutit à un nœud terminal. Le principe du backtracking est alors utilisé, si l'on ne peut plus poursuivre le chemin, le mot n'existe pas et ne peut être prolongé, la recherche est interrompue. On peut compresser cette structure en réunissant les préfixes communs, cette nouvelle structure est appelée "trie", ou arbre préfixe. 12 2.2.2 Premiere amélioration : Trie Un trie, ou arbre préfixe, est un arbre orienté dont la racine est associée à la chaîne vide, et dont chaque arête est associée à un caractère. Un chemin entre la racine et un nœud terminal est donc associé à un mot. La particularité d'un trie est que pour tout nœud, ses descendants ont un préfixe commun. Contrairement à un arbre binaire de recherche, aucun nœud dans le trie ne stocke la chaîne à laquelle il est associé. C'est la position du nœud dans l'arbre qui détermine la chaîne correspondante. Un nœud peut être terminal, ce qui signifie que la chaîne correspondante représente un mot autorisé. Avec le dictionnaire précédent, on obtient alors la figure ci-dessous. Fig 2.5 Dictionnaire sous forme de trie La structure représentant le dictionnaire est maintenant représentée par un arbre de 24 nœuds. Dans notre cas, le dictionnaire possède 1 270 938 nœuds sous forme d'arbre, et 169 200 sous forme de trie. La compression du dictionnaire, ou réduction du nombre de nœuds dans notre cas, a pour objectif d'accélérer la recherche des mots en diminuant l'espace de possibilités. La construction du trie se fait incrémentalement, à chaque insertion de mot. 13 InsertWord(string word) : Node current_node = head of trie Node next_node FOR i = 0 to word.length()-1 IF current_node has a successor by letter word[i] next_node = successor current_node = next_node ELSE number_of_nodes++ Node new_node current_node.appendEdge(word[i], new_node) current_node = new_node endIF endFOR current_node is terminal endInserWord Fig 2.6 Algorithme d'insertion d'un mot dans un trie La construction du trie est inférieure à 1s. 2.2.3 Deuxieme amélioration : Dawg Un dawg (Directed Acyclic Word Graph) est un graphe orienté acyclique dont les chemins entre racine et nœuds terminaux représentent également des chaînes de caractères. A la différence d'un trie, cette structure ne se contente pas de factoriser les préfixes, mais fusionne également tous les nœuds équivalents. Exemple Le dawg est composé de 9 nœuds Fig 2.7 Dictionnaire sous forme de dawg 14 Méthode de construction du Dawg Il existe dans la littérature deux familles de méthodes pour construire une telle structure. a) A la volée : Cette méthode consiste à insérer un mot et fusionner immédiatement les nœuds équivalents (c'est à dire de même terminaison et dont les fils sont équivalents). Le dictionnaire initial est vide, on y insère le mot "PATE" P A T E On insère ensuite un second mot de la même manière que dans un trie, "POTE" P A T E O T E Puis on fusionne les nœuds équivalents. Un nœud est équivalent à un autre si : • Ils sont tous les deux terminaux, ou non terminaux. • Chaque fils a exactement un nœud équivalent parmi les fils du second nœud. La fusion des nœuds s'exécute de la feuille jusqu'à un nœud non équivalent en remontant dans le graphe. Dans cet exemple on obtiendra donc P A T O T E 1) O E T 2) P A 3) T E A P T E O Cet algorithme offre la certitude que l'on obtient un graphe minimal. En revanche, sa construction est plus longue que si l'on utilise la méthode suivante. 15 b) Construction du trie, puis fusion : Nous avons implémenté cette deuxième stratégie, qui consiste à construire intégralement le dictionnaire sous forme de trie, puis à le réduire à la fin en fusionnant les nœuds équivalents. Cette méthode n'offre plus aucune garantie sur la minimalisation du graphe, mais sa construction est bien plus rapide (7min) Nous nous sommes appuyés sur l'algorithme de Jan Daciuk, dont la complexité moyenne est de O(s*log(log n)), où n représente le nombre de nœuds et s la taille de l'alphabet. Minimisation du trie en dawg : Minimize(Node n , Set of Nodes : unchecked_nodes, Node* save_eq_node) : Node eq_node; Node father; IF n has an equivalent node in unchecked_nodes eq_node = equivalent node from unchecked_nodes father = n.getLastFather; father_letter = letter between father and n; deleteNode(n, father, father_letter, unchecked_leaf_nodes); IF save_eq_node is not null //Le nœud equivalent precedent save_eq_node->deleteLastFather(); endIF unchecked_nodes = eq_node->getFathers(); father->appendEdge(father_letter, eq_node); eq_node->appendFather(father); n = father; IF father is not head father is father.getFather endIF save_eq_node = eq_node; minimize(n, unchecked_nodes, save_eq_node); endIF endMinimize Fig 2.8 Algorithme de fusion des nœuds La recherche de nœuds équivalents ne s'effectue pas dans la totalité du graphe. En effet, un nœud ne peut être équivalent qu'avec un nœud de même ditance par rapport à la feuille. Initialement unchecked_nodes contient toutes les feuilles. Pour chaque feuille, on recherche un nœud équivalent parmi les candidats. 16 Si un nœud équivalent est trouvé : Si le nœud étudié n'a qu'un père, on le sauvegarde , on le supprime, puis on redirige l'arête du père vers le nœud équivalent. Ensuite on lance la fusion sur le père. Si le nœud a plusieurs pères : on ne le supprime pas (il reste dans la liste des unchecked_node s'il en faisait partie) on redirige uniquement pour le dernier père, et on poursuit. Le Dawg ainsi formé peut ensuite être stocké sous forme de fichier pour être utilisé ultérieurement. Nous avons représenté les relations entres les nœuds en utilisant le numéro de ligne pour indiquer le nœud origine, et une séquence "numéro du nœud cible" concaténé à la lettre par laquelle se fait la transition. La terminaison d'un nœud est indiquée par le symbole dièse. Par exemple : 0 b a 4 sera codé : "1a# - 4b" à la ligne 0 du fichier. 1 Ainsi, le temps de compression ne sera plus nécessaire pour utiliser cette structure et le chargement s'effectue de manière transparente pour l'utilisateur. Avant de sauvegarder le dawg, nous lançons une opération de renumérotation des nœuds, par un parcours du graphe, afin que les numéros restent cohérents. En effet, la fusion peut très bien laisser un numéro dépassant de loin le nombre total de nœuds. Nous avons également écrit une fonction parcourant le graphe et sortant un fichier correspondant au format graphViz afin de pouvoir générer les graphes sous forme d'images et vérifier la cohérence de ceux-ci (cf figures 2.5 et 2.7). 2.2.4 Une autre structure : le GADDAG Une troisième structure est décrite dans la publication de Steven A. Gordon intitulée "A Faster Scrabble Move Generation Algorithm". Gordon introduit un caractère spécial en plus des caractères de l'alphabet, que nous noterons ◊ . Cet opérateur peut être décrit ainsi : Soient u et v deux groupes de lettres formant un mot uv. Pour un mot uv, tous les mots REV(u)◊v sont introduits dans le graphe, où REV est une fonction inversant l'ordre des lettres. Exemple : Pour le mot "car", nous n'aurons pas un unique chemin possible dans le GADDAG, mais 3 : • C ◊A R • AC◊R • RAC ◊ 17 L'avantage de cette structure est qu'elle offre une notion de "bidirectionnalité". En fonction d'une lettre, nous pouvons étudier toutes les formations de mots possibles dès la première arête, quelle que soit la place de la lettre dans le mot. L'opérateur ◊ permet ensuite de reformer correctement le mot. D'après Gordon, pour un dictionnaire conséquent, cette structure est 5 fois plus large qu'un dawg, et offre une vitesse de recherche de mots deux fois plus rapide. 2.3 Comparatif Nous avons implémenté les deux structures d'anagrammes, ainsi que le trie et le dawg. Voici un tableau comparatif des résultats : Anagramme v1 Anagramme v2 Trie Dawg Nombre de clés/nœuds 116 534 116 534 169 200 21 416 Construction 48min <1s <1s 7 min Chargement <1s <1s <1s <1s 2.9 Tableau comparatif des structures 18 Chapitre 3 Recherche et placement des mots 3.1 Algorithme brute force Cette phase de jeu soulève les questions suivantes : • Où chercher à construire un mot ? • Comment s'assurer que toutes les solutions sont étudiées ? • Comment prendre en compte les lettres posées ? Une méthode assurant une étude exhaustive de toutes les possibilités serait d'appliquer une stratégie de brute force. On peut pour cela parcourir chaque case de chaque ligne, puis chaque case de chaque colonne du plateau, afin de tenter la construction d'un mot en prenant en compte les lettres déjà posées. Exemple : 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 A B E X E M P L E S U C C I N T C D E F G H I J K L M N O Recherche horizontale : Ligne 1 : case 1 : pas de mot possible (aucune connexion) case 2 : idem ... case 15: pas de mot possible Ligne 2 case 1 : case adjacente occupée, on peut donc tenter la création d'un mot et tester s'il forme des mots valides avec les lettres déjà posées. Ainsi de suite jusqu'à la 15ème case. 19 Ensuite il faut effectuer une répétition des recherches sur les colonnes, pour trouver les solutions verticales. Cette méthode permet effectivement de tester tous les cas possibles. A l'aide de tests de connexion et de matching avec les lettres posées, nous conjecturions que de nombreux cas seraient rapidement écartés, la recherche permettant alors de se faire en un temps raisonnable. Cependant, nous n'avons pas implémenté cette méthode étant donné le nombre d'essais superflus. 3.2 Algorithme de Jacobson et Appel 3.2.1 Principe de l'algorithme Nous nous sommes alors appuyés sur les travaux de Jacobson et Appel, qui décrivent un algorithme de placement des mots, basé sur une structure de dictionnaire sous forme de graphe. L'idée est de n'effectuer des recherches qu'à partir des cases qui peuvent servir à poser un mot. Nous appellerons ces cases "ancres", ou points de connexion. Définissons précisément une ancre : - c'est une case à partir de laquelle on va rechercher un ensemble de mots possibles - elle est adjacente à une lettre posée sur le plateau (sauf l'ancre initiale qui est en milieu de plateau) - on doit construire a chaque tour un ensemble d'ancres, qui seront tous les endroits à étudier pour placer un mot. Nous noterons les ancres par le symbole %. Premère étape du jeu : plateau vide 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 A B C D E F G % H I J K L M N O 1 seule ancre, le mot doit obligatoirement recouvrir cette case Si le mot "CAR" est posé, l'ensemble des ancres est recalculé (ajout des nouvelles ancres et suppression des ancres recouvertes par une lettre posée) 20 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 A B C D E F % % % % C A R % % % % G H I J K L M N O Chaque recherche de mot se fera uniquement à partir de cet ensemble de points de connexion. Fonctionnement de l'algorithme de recherche d'un mot relativement à une ancre : Cette construction se fait en deux parties – Construction d'un préfixe – Extension du mot Nous étudierons ici le cas d'une recherche de mot horizontale, sachant que la stratégie est identique dans le sens vertical. Pour l'exemple, choisissons l'ancre située en (H,6) devant le mot CAR Etape 1 : Construction d'un préfixe. L'algorithme produit toutes les combinaisons possibles, de longueur 0, 1, 2, jusqu'à un nombre Nlimite, ou jusqu'à la taille du chevalet, si Nlimite > 7. Nlimite représente le nombre de cases vide à gauche de l'ancre. Les combinaisons sont retenues si on peut tracer dans le dawg un chemin qui leur correspond. Pour chacune de ces combinaisons, appelées "préfixe", nous allons ensuite tenter d'étendre le mot par la droite, toujours en vérifiant que le chemin dans le graphe existe. Si une lettre est déjà posée sur le plateau, elle est utilisée, sinon une lettre du chevalet est testée. Exemple : avec le plateau ci-dessus et les lettres "uatsioz" Préfixe possibles : ɛ (préfixe vide), a, at, aut, auto , ... Chaque préfixe va être concaténé au mot et va être étendu par la droite, dans la mesure du possible. 21 Etape 2 : extension par la droite : Cas du prefixe = ɛ : - On concatène le préfixe et le mot posé, ce qui donne "car". Ce mot existe, il est terminal, mais aucune lettre n'a été jouée, la solution n'est donc pas retenue. - On cherche à étendre le mot par la droite, on obtient : cara, cars, cart, cari (Remarque : Touts ces mots représentent des débuts de mots existants.) - "cars" correspond à un nœud terminal dans le dawg, donc il est retenu. On tentera de poursuivre l'extension des mots cara, cart, cars, et cari, sans succès. Cas des prefixes = a, at, aut : La concaténation donne un échec : atcar, autcar. acar pourrait définir le début d'un mot, mais aucune lettre du chevalet ne permettra de l'étendre par la droite. Cas du préfixe = auto : "autocar" est retenu car il abouti à un nœud terminal. L'extension se poursuit tant que possible, ce sera finalement le mot "autocars" qui sera proposé comme meilleure solution. La recherche se déroule de manière identique pour la direction verticale, puis pour toutes les autres ancres de l'ensemble. Cas des mots adjacents : A chaque lettre étudiée, une vérification est effectuée sur l'occupation des cases adjacentes : si la recherche est horizontale, on se préoccupe des lettres au-dessus et en dessous de la case étudiée. Si la recherche est verticale, l'attention est portée sur les cases de droite et de gauche. Si le mot construit n'est pas présent dans le dawg, la recherche est abandonnée pour cette lettre et l'algorithme poursuit son exécution. Exemple : Dans le cas suivant, l'algorithme ne cherchera pas à étendre le mot "acar", puisque le mot formé verticalement "ab" n'existe pas. 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 A B C D E F % A C % B A % % % % A R % S % % G H I J K L M N O 22 3.2.2 Description de l'algorithme LeftPart(string PartialWord, node N, limit) : extendRight(PartialWord, N, AnchorSquare) IF limit>0 then FOR each edge E out of N IF the letter l labelling E is in rack then remove letter l from the rack N' = the node reached by following E LeftPart(PartialWord+l, N', limit-1) put l back into the rack endFOR endIF end Fig 3.1 Algorithme de placement des mots L'extension est appelée en premier, afin de prendre en compte le préfixe vide. Ensuite, l'algorithme essaie chaque combinaison d'une lettre, puis l'appel récursif produira les préfixes de 2, 3, ... , jusqu'au maximum de lettres que l'on peut poser tant que le préfixe correspond à un chemin dans le dictionnaire. extendRight(string PartialWord, node N, square) if(square is free) then if N is terminal then store PartialWord for each edge E out of N if the letter l labelling E is in the rack and crossCheck then remove l from rack N' = node by following E next_square = square to the right of square extendRight(PartialWord+l, N', next_square) put l back into the rack else l = the letter occupying square if N has an edge labeled by l then N' = node by following edge netx_square = next square to the right extendRight(PartialWord+l, N', next_square) Fig 3.2 Algorithme de placement des mots (extension) 23 Si une case n'est pas libre, et que le nœud courant a une arête labellisée par cette lettre, l'algorithme poursuit l'exploration du dawg en empruntant l'arête, puis exécute récursivement extendRight sur le mot concaténé de la lettre, à partir du nouveau nœud. Si en revanche une case est libre, alors on teste si le mot est valide (nœud terminal) , dans ce cas il est enregistré. Sinon on cherche à l'étendre avec une lettre du chevalet, et l'extension se poursuit. Illustration : Initialement, l'algorithme est lancé avec une chaîne vide, et le nœud est la racine du dawg. LeftPart("", root, pos_anchor) Le préfixe "auto" a pu être construit puisqu'il existe effectivement un chemin dans le graphe. 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 A A B U C D T E O F % % % A U T O C A R % % % % C G H A I R J K S L M N O Ensuite , la fonction extendRight est appelée, les lettres sont lues et ajoutées au mot, parallèlement le chemin est poursuivi dans le dawg. 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 A B C D E F % % % A U T O C A R % % % % G H I J K A U T O C A R S L M N O 24 "Autocar" est retenu, l'extension se poursuit 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 A B C D E F % % % A U T O C A R S % % % A U T O G H C A I J K R S L M N O "Autocars" aboutit à un nœud terminal, le mot est retenu. L'extension ne pourra pas aller plus loin car il n'y a plus de successeurs. 3.2.3 Preuve informelle • Prise en compte des lettres croisées : à chaque lettre posée , une vérification est faite pour déterminer si les cases adjacentes sont libres ou occupées. Si elles sont occupées, le mot nouvellement formé est lu et vérifié dans le dictionnaire. Si ce n'est pas le cas, la situation est oubliée et une autre tentative est exécutée avec la lettre suivante. • Connectivité des mots : le système d'ancres permet d'assurer la connectivité des mots (en cas de préfixe vide, un test de connectivité du mot est lancé pour chaque lettre jusqu'à ce qu'il devienne vrai). • Exhaustivité des solutions : toutes les ancres sont testées, horizontalement et verticalement, ainsi chaque position pouvant donner lieu à un mot est prise en compte. Le solveur est capable de résoudre toutes les situations décrites ci-dessous, à partir d'une ancre posée devant le mot (par exemple ici "%car" ) : Dans le cas où aucune lettre n'est posée à gauche de l'ancre : i) les extensions simples du mot (cas de prefixe vide) : carREFOUR ii) les prefixages du mot (cas sans extension) : AUTOcar iii) la complétion du mot : AUTOcarS iv) les rattachements de mots distants : carRosSE v) préfixage - rattachement - extension de mots distants : INcarNATionS 25 Dans le cas où une lettre est posée devant l'ancre : vi) Cas "y%x" s'il n'y a pas d'espace entre y et l'ancre, aucun préfixage de x ne sera testé, les seules actions entreprises seront les extensions de x. La jointure entre y et x si elle est possible sera étudiée par l'ancre précédant y. vii) Cas "y %x" Si au moins un espace est présent, les préfixes générés pour x auront une longueur maximale égale au nombre d'espaces entre l'encre et y. De même, si une jointure est possible entre y et x celle-ci sera testée par l'ancre devant y. 26 Chapitre 4 Bilan 4.1 Travaux supplémentaires 4.1.1 Interface graphique Afin de rendre l'application plus agréable d'utilisation, nous avons créé une interface graphique en utilisant l'API Qt. Celle-ci permet de jouer une partie dans son intégralité, avec les actions • Selectionner des lettres • Chercher le meilleur mot • Placer le mot Sélectionner des lettres : → Remplit le chevalet en retirant les lettres du paquet. Chercher le meilleur mot : → Renvoie le mot faisant le maximum de points. S'il y en a plusieurs, c'est le premier qui est retourné. Placer le mot : → Nous avions initialement prévu d'afficher tout ou une partie des solutions possibles. Cette fonction aurait alors permis de choisir le mot à placer. Finalement, un seul mot étant renvoyé, cette action place le mot sur la grille. 27 Fig 4.1 Interface graphique La partie peut également se jouer en version console, destinée au débuggage, avec représentation des ancres. Ici, tous les mots sont proposés à l'utilisateur. Fig 4.2 Interface console 28 4.1.2 Chargement d'une partie L'application offre la possibilité de charger un plateau de jeu avec des lettres déjà disposées, afin de répondre à un besoin précis. Le plateau doit être enregistré dans un fichier au format suivant : "mot abscisse ordonnée direction" (direction = 0 : placement horizontal, direction = 1 : placement vertical) Exemple : solveur 6 7 0 points 7 6 1 rhume 10 5 1 4.2 Méthodes de travail D'un point de vue organisationnel, le travail s'est effectué en pair-programming pour la phase de conception des objets du jeu et de leurs méthodes, puis nous avons choisi de travailler séparément sur les structures de données, afin de pouvoir explorer plusieurs stratégies (Anagrammes/Graphes) Une partie du travail a consisté tout d'abord à se documenter sur les différentes façons de gagner en rapidité pour un tel programme. Nous avons étudié ainsi certaines idées personnelles, comme celles ayant fait l'objet de publications. D'un point de vue technique, nous avons utilisé tout au long du projet le gestionnaire de versions GitHub. 4.3 Bilan pédagogique Nous avons pu au cours de ce projet acquérir de nouvelles connaissances techniques (C++ , Qt, GitHub), en nous appuyant sur les bases enseignées lors de notre cursus ( notamment Java, et SVN ). Nous avons également puisé dans les connaissances étudiées en Algorithmique pour manipuler les structures de données adaptées aux besoin de rapidité (parcours de graphe, d'arbre, ...). Au delà de l'aspect programmation de l'application, nous avons pu entrevoir le travail de recherche en effectuant régulièrement des études d'existants dans le domaine et des lectures de publications. Ce problème n'ayant pas de solution optimale et unique, nous avons eu recours aux travaux antérieurs afin de mener notre propre cheminement vers une solution. Bien que l'application soit menée à son terme, nous aurions souhaité également développer un algorithme de placement des mots adapté aux anagrammes afin de pouvoir établir un comparatif de résultats et tirer des conclusions constructives quant au choix des structures de dictionnaire. 29 Conclusion Le développement d'un solveur de Scrabble duplicate est à la fois un enjeu concret, puisqu'utile aux compétiteurs, et un domaine de recherche dont l'optimisation peut sans cesse être améliorée. Il repose avant tout sur un problème de réduction du domaine de recherche, aussi bien dans le dictionnaire, que sur le plateau de jeu. Il existe de multiples moyens pour optimiser la vitesse d'exécution : – matériel : programmation multi-coeurs, – logiciel : choix de langage adapté, – conceptuel : étude de complexité des algorithmes de recherche. Nous nous sommes particulièrement intéressés à la partie conceptuelle, en cherchant à établir des structures adaptées et des algorithmes réduisant les calculs superflus. Il s'agit cependant d'un projet touchant de nombreux domaines (théorie des graphes, des automates, algorithmique, complexité, optimisation, analyse combinatoire) dont chacun peut être approfondi et qui reste ouvert sur de nombreuses améliorations. 30 Bibliographie [1] Andrew W. Appel et Guy J. Jacobson, The World's Fastest Scrabble Program (1988) [2] Steven A. Gordon A Scrabble Faster Move Generation Algorithm (1993) [3] Jan Daciuk, Bruce W. Watson, et Richard E. Watson, Incremental construction of minimal acyclic finite state automata (1998) [4] John Hopcroft An nlog(n) algorithm for minimizing states in a finite automaton (1971) 31