Implémentation du dictionnaire 

Untitled-2026-05-16-1838-fr.png

Voici un petit schéma rapide pour mieux visualiser la base générale du compilateur de dictionnaire tel qu'il est implémenté dans Skrabby.  
Nous allons reprendre point par point les différents éléments en référençant le code actuel.

1. La configuration préliminaire

L'implémentation de Skrabby se trouve ici.Celle-ci n'est pas utilisable directement, sans avoir mis toute l'infrasxtructure de Skrabby en place. Mais pour créer ces dict(ionnaires, il existe 2 impératifs. Il faut une liste de mots, et il faut ensuite configurer le jeu de caractères contenu dans cette liste. Pour une application de scrabble cette liste ne comportera que des lettres non accentuées. Mais il est possible pour d'autres applicartions d'envisager d'inclure les lettres accentuées ou n'importe quel autre caractère, cela est défini dans la configuration des lettres.

TilesConfigurationDiagram.png

TileConfiguration

Char

string

Le caractère représenté :Typiquement de A à Z.
Mais on peut imaginer n'importe
quel caractère pour d'autres applications ou des variantes de jeux. 

Letter

byte

Représentation numérique du caractère de 0 à 251. Ce sera la valeur stockée dans le Dawg/GadDag. La lettre joker doit être impérativement 252 soit TileUstils.Joker

IsConsonant

bool

Est-ce une consonne ?

IsVowel

bool

Est-ce une voyelle ? 
Au scrabble francophopne le Y peut-être considéré comme une consonne ou comme une voyelle. Les 2 seront donc mis à VRAI

IsJoker

bool

Est-ce une lettre joker ? Si oui la valeuir de Letter doit être TilesUrils.Joker (252)

Points

int

La valeur en poins de la lettre

TotalLetters

int

Le nombre de lettres présentes dans le sac initial.

TileConfiguration configure le jeu de caractère utilisé dans le dictionnaire, en plus des spécificités liées au scrabble. 

Voici un lien vers un Json qui vous permet de configurer un jeu en français. Ce Json ou autre structure doit être transformée dans TilesConfiguration qui est ensuite passé dans TilesIUtils. Le TilesUtils est quant à lui injecté dans le compilateur de dictionnaire.

TilesConfiguration

Cette classe est utilisée pour mapper un caractère vers sa valeur en byte, et inversément pour mapper sa valeur en, byte vers son caractère correspondant. Voici un extrait de code permettant de créer cette configuration.

        public static TilesConfiguration ReadLetterConfig(ILetterConfigModel letterData)
        {
            var config = new TilesConfiguration();

            config.Name = letterData.Name;
            foreach (var letter in letterData.Letters)
            {
                config.LettersByByte.Add(letter.Letter, letter);
                config.LettersByChar.Add(letter.Char[0], letter);
            }
            return config;
        }

TilesUtils

TilesUtils contient les fonctions nécessaires pour convertir des tuiles en mots, ou des mots en tuiles. Dans le cas qui nous occupe, la création du dictionnaire, seul ConvertWordToBytes.

Les classes du compilateur

Nous avons notre liste de mots, la configuration de nos lettres et un TilesUtils prêt à l'emploi. Nous pouvons dès lors lancer la compilation de notre dictionnaire,

A nous de choisir le format que l'on veut utiliser ; DAWG ou GADDAG. 
Dans le repository il y a un exemple d'utilisation du compilateur : Crolow.FastDico.Console

L'implémentation est assez simple :

    public void TestGadDag(bool compile, ITilesUtils tilesUtils)
    {
        GadDagDictionary gaddag = new GadDagDictionary(tilesUtils);
        if (compile)
        {
            List<string> words = System.IO.File
                  .ReadAllLines("C:\\dev\\ODS9-complet.txt")
                  .Select(p => p.ToLower()).ToList();
            gaddag.Build(words);
            gaddag.SaveToFile("gaddag_fr.gz");
            Console.WriteLine($"GADDAG saved to GADDAG_data.gz");
        }
    }

DicoCompiler.png

2. La création du trie initial (1e passe)

Diapositive1.PNG

La création du dictionnaire se fait par delà la fonction Build(List<string> words). Elle se déroule en 2 phases. La première phase est de créer une aborescence qui reprend tous les mots envoyés. L'arboresence consiste en des listes de noeuds où chaque noeud correspond à une lettre.

Cette transformation est spécifique à chaque dictionnaire, c'est pour cela que dans les classes GadDagDictionary et DawgDictionary on y retrouve les fonctions Insert(string word). C'est cette fonction qui permet de créer l'arborescence. Comme expliqué précédemment la structure des dictionnaires est différentes. Le Dawg ne permet de parcourir le dico qu'à partir de la première lettre du mot, le GadDag est conçu pour pouvoir retrouver un mot au départ de n'importe quelle lettre du mot.

Le Trie est composé d'un seul type d'object qui est LetterNode.

LetterNode

letternode.png

Children

List<ILetterNode>

Une liste de toutes les lettres possibles suivant la lettre courante.

Control

byte

Pourrait être mergé avec Letter. Actuellement il ne contient que l'indicateur de fin de mot. Un bit pour un byte,

Letter

byte

La lettre représentée où l'indicateur de pivot (TilesUtils.Pivot)

IsEnd

bool

Est à true, si le noeud réprésente aussi une fin de mot. Cela n'empêche aucunement que le trie s'étende encore =>

CHAT peut s'étendre avec CHATS, CHATTE, etc.

IsPivot

bool

Utilisé par le GadDag uniquement il indique que la suite de la hiérarchie est renversée, et que l'on va donc étendre le mot en le préfixant noeud par noeud.. C'est-à-dire que Le GADDAG stocke toutes les partitions possibles d’un mot autour d’un pivot :

LIEN#IHC => est transformé d'abord en LIEN, puis le pivot, indique que l'on va préfixer la suite du mot en : ILIEN, HILIEN, CHILIEN.

SetEnd()

Active le bit indicateur de fin de mot

3. Compression du dictionnaire

Diapositive2.PNG

On constate ainsi qu'à la base, un trie peut s'élargir de façon exponentielle. Mais en compressant les noeuds uniques terminaux on réduit drastiquement le nombre de noeuds.

Une fois le trie crée, on peut procéder à la compression. Pour cela une méthode dans la classe BaseDictionary : Optimize

  1. On crée un cache qui va contenir tous les noeuds uniques du dictionnaire. L'unicité d'un noeud se calcule par une valeur unique qui reprend tous ses descendants (GetNodeSignature)

  2. On crée un cache qui va contenir tous les noeuds (List<ILetterNode> nodesToProcess) à processer, le premier étant la racine du dictionnaire

  3. Tant que nodesToProcess n'est pas vide :

    • On trie les enfants du noeud alphabétiquement

    • On calcule la signature de tous les noeuds enfants :

      • Si la signature du noeud existe, on référence l'enfant au noeud existant.

      • Si la signature du noeud n'existe pas, on rajoute sa référence dans le cache, et on ajoute le noeud dans le cache nodesToProcess

On profite de .NET et du référencement des objets pour lier un embranchement de l'arbre existant à un autre noeud qui présente exactement la même structure.

4. Sérialisation du dictionnaire

La sérialisation du dictionnaire est customisée. On ne peut en effet pas sérialiser le dictionnaire en utilisant la sérialisation binaire standard de .NET. La sérialisation .NET ne tient pas compte des noeuds identiques, chaque noeud sera resérialiser autant de fois qu'il est utilisé 'logiquement', et nous mènerait à un fichier énormissime, et qui serait évidemment aussi long à recharger en mémoire et à recalculer tous les noeuds. Pour cela nous avons deux options : SaveToFile ou WriteToStream

4.1 Déterminer l'ordre d'écriture des noeuds.

Le dictionnaire est à ce stade compressé. Il faut maintenant s'assurer que l'on puisse écrire cette structure aussi d'une façon minimale. Pour cela avant la sérialisation dans le stream nous allons définir l'ordre d'écriture des noeuds et attribuer à chaque noeud un ID => Dictionary<ILetterNode, int> nodeToId  associant à chaque noeud un Id et List<ILetterNode> writeOrder = new List<ILetterNode>() contenant les noeuds à sérialiser.

4. 2 Sérialisation des noeuds

Pour la sérialisation, nous allons écrire chaque noeud de writeOrder dans le stream. Chaque noeud est composé de : 

  1. Le bit de contrôle (byte)

  2. Le nombre d'enfants (int)

  3. Pour chaque enfant :

    1. La lettre correspondant au noeud (byte)

    2. Le NodeId qui ramène au point 1.. (int)

  4.3 Compression finale

Pour gagner encore un peu de stockage, le stream est décoré d'un GZipStream qui permet de gagner encore un gros pourcentage d'espace et ainsi optimiser le transfert du dictionnaire à travers le net

Désérialisation du dictionnaire

La désérialisation, tout comme la sérialisation n,'est pas celle standard au .NET. Il faut relire noeud par noeud et recréer le dictionnaire, une fois le stream dézippé, on peut désérialiser le dictionnaire :

  1. On lit le nombre de noeuds présents dans le dictionnaire

  2. On crée une Array de LetterNode de la taille du dictionnaire.

  3. Ensuite pour chaque noeud nous allons relire séquentiellement le contenu avec ReadNodeWithId(nodeList[i], reader, nodeList);

    1. On lit le bit de Control

    2. Le nombre d'enfants

      1. Pour chaque enfant : 

        1. on lit sa lettre.

        2. on lit son Id, on récupère l'enfant dans nodeList 

        3. et on ajoute à LetterNode.Children le noeud récupéré.

Une fois que tous les noeuds ont été lus, on retrouve l'état du dictionnaire juste après sa compilation, en ayant recrée les références vers les noeuds identiques.

Améliorations possibles

Voici une liste des améliorations à apporter :

  • Dans LetterNode, utiliser un Array pour les lettres enfants au lieu d'une liste

  • Remplacer les fonctions LINQ ou les foreach en travaillant sur les enfants avec des fonctions customisées : Cela a très peu d'impact sur le module de recherche, mais cela pourrait améliorer les performances dans la résolution d'une partie de scrabble où chaque coup génère des millions de possibilités et de traversée de noeuds

Conclusion

Au final, nous avons 2 représentations du dictionnaire :

  • En mémoire, le dictionnaire est représenté sous forme de trie compressé (DAWG), où les nœuds sont reliés par des références objet partagées.

  • Lors de la sérialisation, cette structure est transformée en un graphe indexé linéarisé, où chaque nœud est identifié par un identifiant entier stable et les relations sont représentées par des indices.

Les voilà, les dictionnaires prêts à l'emploi, nous allons commencer par la recherche de mots dans un dictionnaire. Ceci sera dans le prochain chapitre.