:: Enseignements :: Master :: M1 :: 2024-2025 :: Java Avancé ::
[LOGO]

Hacher menu


Implantation d'une table de hachage, classe interne, type paramétré.
Le but de ces deux exercices est d'implanter une représentation d'un ensemble d'éléments (de n'importe quel type) sans doublons.
Comme les éléments ne sont pas forcément consécutifs, ils n'est pas possible d'utiliser un tableau ou un bitset pour représenter les valeurs (explosion en mémoire) ni une liste (algos d'ajout/recherche/suppression en O(n)), on utilisera donc une table de hachage où les collisions sont stockées dans une liste chaînée.
La taille de la table de hachage sera toujours une puissance de 2 (2, 4, 8, 16, 32, etc...) pour permettre l'écriture d'une fonction de hachage rapide.
Le but du TD est de ré-écrire une table de hachage, pas d'en utiliser une qui existe déjà, donc pour ce TD, nous n'utiliserons aucune des collections de java.util.

Exercice 1 - Maven

Pour ce TP, nous allons utiliser Maven avec une configuration (le pom.xml) très similaire à celle habituelle, mais nous allons activer les preview features pour bénéficier des constructeurs flexibles (JEP 482).
       <project xmlns="http://maven.apache.org/POM/4.0.0"
                xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
                xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">

         <modelVersion>4.0.0</modelVersion>
         <groupId>fr.uge.set</groupId>
         <artifactId>set</artifactId>
         <version>0.0.1-SNAPSHOT</version>

         <properties>
           <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
         </properties>

         <dependencies>
             <dependency>
                 <groupId>org.junit.jupiter</groupId>
                 <artifactId>junit-jupiter-api</artifactId>
                 <version>5.11.0</version>
                 <scope>test</scope>
             </dependency>
         </dependencies>

         <build>
             <plugins>
                 <plugin>
                     <groupId>org.apache.maven.plugins</groupId>
                     <artifactId>maven-compiler-plugin</artifactId>
                     <version>3.13.0</version>
                     <configuration>
                         <release>23</release>
                         <compilerArgs>
                             <compilerArg>--enable-preview</compilerArg>
                         </compilerArgs>
                     </configuration>
             </plugin>

             <plugin>
                 <groupId>org.apache.maven.plugins</groupId>
                 <artifactId>maven-surefire-plugin</artifactId>
                 <version>3.5.0</version>
                 <configuration>
                   <argLine>--enable-preview</argLine>
                 </configuration>
             </plugin>
         </plugins>
         </build>
       </project>
     
Comme précédemment, créer un projet Maven, au niveau du premier écran, cocher create simple project puis passer à l'écran suivant en indiquant Next.
Pour ce TP, le groupId est fr.uge.set , l'artefactId est set et la version est 0.0.1-SNAPSHOT. Puis cliquer sur Finish.

Exercice 2 - HashTableSet

Dans ce TP, une table de hachage est un tableau contenant des listes chaînées. Lorsque l'on veut insérer/rechercher un élément, on va dans un premier temps trouver dans quelle case du tableau doit se situer l'élément puis dans un second temps, on va parcourir la liste chaînée. En cas d'insertion, si l'élément n'existe pas on va l'insérer en tête de la liste, sinon on ne fait rien. En cas de recherche, on renvoie vrai si l'élément fait partie de la liste chaînée.
Pour trouver la case de tableau, on va demander à l'élément de renvoyer son hashCode, puis on choisira la case du tableau avec une opération de modulo (% comme en C).
Par exemple si on a un tableau de 4 cases, et que l'on veut insérer "Jane", le hashCode de "Jane" est 2301262, modulo 4 cela fait 2, donc il faut ajouter dans la liste chaînée à la case 2, un maillon (Entry) contenant "Jane".
On obtient la table de hachage suivante
     0:  [null]
     1:  [null]
     2:  [] -> Entry("Jane", null)
     3:  [null]
    
Si l'on veut maintenant insérer "John", le hashCode de "John" est 2314539, modulo 4, on obtient 3. On insère donc la maillon contenant "John" comme ceci
     0:  [null]
     1:  [null]
     2:  [] -> Entry("Jane", null)
     3:  [] -> Entry("John", null)
    
Enfin, si l'on veut ajouter "Arthur", la hashCode est 1969735650, modulo 4 cela fait 2, donc on ajoute le maillon contenant "Arthur" devant le maillon contenant "Jane".
     0:  [null]
     1:  [null]
     2:  [] -> Entry("Arthur", ) -> Entry("Jane", null)
     3:  [] -> Entry("John", null)
    

En pratique, on utilise rarement le modulo car le hashCode peut être négatif et le modulo d'un nombre négatif est négatif. On s'arrange pour que la taille de la table soit une puissance de deux car dans ce cas, h % length est équivalent à h & (length - 1) (et % est aussi une opération assez lente, enfin comparée à &).
Vous pouvez regarder la première page du Hacker's Delight si vous voulez comprendre pourquoi on peut écrire un % avec un & quand la taille est une puissance de 2.
Reste à savoir comment on dimensionne le tableau en fonction du nombre d'éléments... Il a plusieurs stratégies, celle considérée comme la plus efficace mais au détriment de la taille en mémoire, consiste à avoir toujours au moins 2 fois plus de cases du tableau que d'éléments.

Nous allons, dans un premier temps, implanter un ensemble utilisant une table de hachage d'Object puis dans un second temps, nous introduirons la notion de type paramétré.

Les tests JUnit 5 de cet exercice sont HashTableSetTest.java.

  1. Quels doivent être les champs de la classe Entry correspondant à une case d'une des listes chaînées utilisées par table de hachage
    Note : on va pas utiliser java.util.LinkedList, car on veut une liste simplement chaînée.
    Rappeler quelle est l'intérêt de déclarer Entry comme membre de la classe HashTableSet plutôt que comme une classe à coté dans le même package que HashTableSet ?
    Ne pourrait-on pas utiliser un record plutôt qu'une classe, ici ? Si oui, pourquoi ? Sinon, pourquoi ?
    Écrire la classe HashTableSet dans le package fr.uge.set et ajouter Entry en tant que classe interne.
    Vérifier que les tests marqués "Q1" passent.

  2. On souhaite maintenant ajouter un constructeur sans paramètre, une méthode add qui permet d'ajouter un élément non null et une méthode size qui renvoie le nombre d'éléments insérés (avec une complexité en O(1)).
    Pour l'instant, on va dire que la taille du tableau est toujours 16, on fera en sorte que la table de hachage s'agrandisse toute seule plus tard.
    Dans la classe HashTableSet, implanter le constructeur et les méthodes add et size.
    Vérifier que les tests marqués "Q2" passent.
    ATTENTION : add ne doit pas permettre d'ajouter deux fois le même élément, un ensemble/set ne permet pas les doublons.

  3. On cherche maintenant à implanter une méthode forEach qui prend en paramètre une fonction. La méthode forEach parcourt tous les éléments insérés et pour chaque élément, appelle la fonction prise en paramètre avec l'élément courant.
    Quelle doit être la signature de la functional interface prise en paramètre de la méthode forEach ?
    Quel est le nom de la classe du package java.util.function qui a une méthode ayant la même signature ?
    Écrire la méthode forEach.
    Vérifier que les tests marqués "Q3" passent.
    Note : si vous êtes balèze, forEach peut s'écrire avec un Stream.

  4. On souhaite maintenant ajouter une méthode contains qui renvoie si un objet pris en paramètre est un élément de l'ensemble ou pas, sous forme d'un booléen.
    Expliquer pourquoi nous n'allons pas utiliser forEach pour implanter contains (Il y a deux raisons, une algorithmique et une spécifique à Java).
    Écrire la méthode contains.
    Vérifier que les tests marqués "Q4" passent.

  5. On veut maintenant faire en sorte que la table de hachage se redimensionne toute seule. Pour cela, lors de l'ajout d'un élément, on peut avoir à agrandir la table pour garder comme invariant que la taille du tableau est au moins 2 fois plus grande que le nombre d'éléments.
    Pour agrandir la table, on va créer un nouveau tableau deux fois plus grand et recopier tous les éléments dans ce nouveau tableau à la bonne place. Ensuite, il suffit de remplacer l'ancien tableau par le nouveau.
    Expliquer pourquoi, en plus d'être plus lisible, en termes de performance, l'agrandissement doit se faire dans sa propre méthode.
    Modifier votre implantation pour que la table s'agrandisse dynamiquement.
    Vérifier que les tests marqués "Q5" passent.
    Note : vous pouvez utiliser forEach pour parcourir les éléments de l'ancienne table.

  6. L'implantation actuelle a un problème : même si on n'ajoute que des String lorsque l'on utilise forEach, l'utilisateur va probablement devoir faire des cast parce que les éléments envoyés par forEach sont typés Object.
    Par exemple, si on veut vérifier que toutes les chaînes de caractères contenues dans un ensemble commencent par "f", le code ci-dessous ne fonctionne pas
        var set = new HashTableSet();
        set.add("foo");
        set.add("five");
        set.add("fallout");
        set.forEach(element -> assertTrue(element.startsWith("f")));
       
    Pour que ce code fonctionne, il faut dire que le type des éléments que l'on ajoute avec add et le type des éléments que l'on reçoit dans la lambda du forEach est le même. Pour cela, il faut déclarer HashTableSet comme un type paramétré.
    Rappeler pourquoi en Java, il n'est pas possible de créer un tableau de type paramétré ? Quel est le work around ? Pourquoi celui-ci génère-t-il un warning ? Et dans quel cas et comment peut-on supprimer ce warning ?
    Mettez en commentaire votre ancien code, puis dupliquez-le pour faire les changements qui permettent d'avoir un ensemble paramétré par le type de ses éléments.
    Vérifier que les tests marqués "Q6" passent.
    Attention: il faut que les tests écrits précédemment continuent de compiler (avec des warnings), donc les changements que vous effectuez doivent être rétrocompatibles avec l'ancienne version de votre code.

  7. [Revision] En fait, la signature de la méthode forEach que vous avez écrite n'est pas la bonne. En effet, forEach appelle la lambda avec des éléments de type E, donc la fonction peut prendre en paramètre des valeurs qui sont des super-types de E.
    Regarder comme on déclare un super-type dans un type paramétré en regardant la javadoc de la méthode forEach de ArrayList.
    Modifier votre code en conséquence.
    Vérifier que les tests marqués "Q7" passent.

  8. [Revision] On souhaite maintenant écrire une méthode addAll qui permet d'ajouter tous les éléments d'un HashTableSet dans le HashTableSet courant.
    Note : les éléments du HashTableSet pris en paramètre peuvent être des sous-types du type du HashTableSet courant. Si vous ne savez pas comment déclarer un sous-type dans un type paramétré, vous pouvez regarder la méthode Collection.addAll() des API du JDK.
    Écrire la méthode addAll.
    Vérifier que les tests marqués "Q8" passent.

  9. [Revision] Enfin, pour les plus balèzes, on souhaite pouvoir tester si deux HashTableSet sont égaux avec la méthode equals.
    Modifier votre code en conséquence.
    Vérifier que les tests marqués "Q9" passent.