Solveur de Scrabble

publicité
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
Téléchargement