Séance 3 — Tables de hachage

Du tableau indexé au coût moyen constant

Dr. El Hadji Bassirou TOURÉ

Département de Mathématiques et Informatique, Faculté des Sciences et Techniques, Université Cheikh Anta Diop de Dakar

Objectifs de la séance

Ce que la séance construit

La table de hachage {(hash table)} : transformer une clé en indice d’un tableau, loger les collisions par chaînage ou par sondage linéaire, et redimensionner pour garder un coût moyen constant. Chaque mécanisme est montré sur le code Java du cours, pas à pas.

  • Expliquer pourquoi une liste ou un tableau trié ne donne pas \(\mathcal{O}(1)\) par clé.
  • Calculer hashCode() d’une chaîne et l’indice de sa case, sans tomber dans le piège des négatifs.
  • Énoncer le contrat equals / hashCode et le bogue qui naît quand on l’oublie.
  • Dérouler l’insertion de clés par chaînage séparé et par sondage linéaire, et compter comparaisons et sondages.
  • Calculer le facteur de charge, prévoir les rehashs et l’état de la table après un rehash.
  • Comparer les coûts moyens (\(1+\alpha/2\), formules de Knuth) aux mesures.

Le carnet dans une liste, pas à pas

Sortie \(\rightarrow\) 

Fatou Sall 3
null 3

Lecture. Ligne 1 : une ListeMap vide, deux listes parallèles cles et vals.

Lecture. Ligne 2 : put parcourt la liste (vide) pour vérifier l’absence de la clé, puis ajoute la paire en fin.

Lecture. Ligne 3 : même chose ; le parcours a déjà coûté une comparaison.

Lecture. Ligne 4 : trois paires, dans l’ordre d’insertion.

Lecture. Ligne 5 : get compare la clé cherchée aux clés 0, 1, puis 2 : 3 comparaisons pour la troisième insérée.

Lecture. Ligne 6 : affichage de la valeur trouvée et du compteur.

Lecture. Ligne 7 : le compteur d’observation est remis à zéro.

Lecture. Ligne 8 : clé absente : il faut épuiser la liste, 3 comparaisons, avant de rendre null.

Lecture. Ligne 9 : null et 3 : pour \(n\) paires, une clé absente coûte \(n\) comparaisons.

Le coût mesuré de la liste

ListeMap, \(n=1\,000\) à \(10\,000\) numéros fictifs tirés avec la graine 221 ; comparaisons de clés par recherche.

Lecture. Les mesures tombent sur \((n+1)/2\) (présente) et \(n\) (absente) : \(5\,000{,}5\) et \(10\,000\) pour \(10\,000\) contacts. Un million de contacts consultés \(10\,000\) fois par seconde : environ \(5\cdot10^9\) comparaisons par seconde.

Les types abstraits Set et Map

Map : paires (clé, valeur) aux clés distinctes. Set : un Map sans valeurs (HashSet repose sur un HashMap).

Set<String> inscrits = new HashSet<>();
System.out.println(inscrits.add("2026-00412") + " " + inscrits.add("2026-00412"));
System.out.println(inscrits.contains("2026-00412") + " " + inscrits.size());
Map<String, String> carnet = new HashMap<>();
System.out.println(carnet.put("77 555 01 23", "Awa Diop"));
System.out.println(carnet.put("77 555 01 23", "Awa Diop Ndiaye"));
System.out.println(carnet.get("77 555 01 23") + " " + carnet.size());

Résultat \(\rightarrow\)

true false
true 1
null
Awa Diop
Awa Diop Ndiaye 1

Lecture. Le second add rend false ; le second put rend l’ancienne valeur et la remplace : taille 1.

La fonction de hachage, formellement

Analogie

Au secrétariat d’une école de Thiès, une règle fixe appliquée au nom de l’élève donne le numéro de l’armoire de son dossier : on y va directement.

Une fonction en deux temps

\[ h(k)=c\big(\mathrm{code}(k)\big)\in\{0,\dots,m-1\} \]

\(\mathrm{code}(k)\) : k.hashCode(), un int qui ne dépend que de la clé ; \(c\) : la compression en indice d’un tableau de \(m\) cases {(buckets)}. La paire de clé \(k\) est rangée dans la case \(h(k)\).

Deux propriétés

Déterminisme : la même clé donne toujours le même indice. Répartition uniforme : les clés se répartissent à peu près également entre les \(m\) cases ; l’analyse des coûts suppose chaque case atteinte avec la probabilité \(1/m\).

Le hashCode d’une chaîne, pas à pas

Sortie \(\rightarrow\) 

66251 66251
-1984412114

Lecture. Ligne 1 : le code commence à 0.

Lecture. Ligne 2 : schéma de Horner, \(h\leftarrow31\,h+s_i\) ; un char vaut son code ('A' = 65).

Lecture. Ligne 3 : \(31\times65+119=2\,134\).

Lecture. Ligne 4 : \(31\times2\,134+97=66\,251\) : c’est \(65\cdot31^2+119\cdot31+97\).

Lecture. Ligne 5 : Java calcule "Awa".hashCode() par la même formule : même valeur.

Lecture. Ligne 6 : dès six caractères, la somme exacte peut dépasser \(2^{31}-1\) ; l’arithmétique int (modulo \(2^{32}\)) rend un code négatif.

La compression en indice, pas à pas

Sortie \(\rightarrow\) 

-1633979013
-5
11
11
11

Lecture. Ligne 1 : le code de "Awa Diop" est négatif ; la table compte 16 cases.

Lecture. Ligne 2 : affichage du code.

Lecture. Ligne 3 : le reste % de Java a le signe du dividende : \(-5\), qui n’est pas un indice.

Lecture. Ligne 4 : Math.floorMod rend le reste mathématique : \(-1\,633\,979\,013=16\times(-102\,123\,689)+11\).

Lecture. Ligne 5 : pour \(m=2^4\), garder les 4 bits de poids faible donne le même reste.

Lecture. Ligne 6 : effacer le bit de signe (Sedgewick et Wayne) puis % : 11 encore, car 16 divise \(2^{31}\).

Le piège de Math.abs

\(|h|\bmod m\) n’est pas toujours un indice

La formule \(|\mathrm{code}(k)|\bmod m\) suppose \(|h|\geq0\). Or \(-2^{31}\) n’a pas d’opposé en int : Math.abs le rend inchangé.

String cle = "polygenelubricants";
int h = cle.hashCode();
System.out.println(h + " " + Math.abs(h) + " " + Math.abs(h) % 100);
String[] table = new String[100];
table[Math.abs(h) % 100] = cle;  // indice "positif" ?

Résultat \(\rightarrow\)

-2147483648 -2147483648 -48

Erreur \(\rightarrow\)

Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: Index -48 out of bounds for length 100
    at ExAbs.main(ExAbs.java:8)

Lecture. Code \(-2^{31}\), reste \(-48\) : l’écriture lève une exception. D’où la compression (h & 0x7fffffff) % m.

Le contrat oublié

static class Contact {
    final String nom, numero;
    @Override public boolean equals(Object o) {  // egaux si meme numero
        return o instanceof Contact c && numero.equals(c.numero);
    }
}                                                // pas de hashCode()
// dans main :
Map<Contact, String> ville = new HashMap<>();
Contact b = new Contact("Awa Diop", "77 555 01 23");
ville.put(b, "Thies");
Contact a = new Contact("Awa", "77 555 01 23");  // meme numero
System.out.println(a.equals(b) + " " + ville.get(a) + " " + ville.containsKey(a));

Compilation \(\rightarrow\)

ExContrat.java:6: warning: [overrides] Class Contact overrides equals, but neither it nor any superclass overrides hashCode method

Résultat \(\rightarrow\)

true null false

Lecture. Constructeur non montré. Contacts égaux, codes d’Object différents : get rend null.

Le contrat equals / hashCode

Le contrat (documentation de java.lang.Object)

\[ a.\texttt{equals}(b)\ \Longrightarrow\ a.\texttt{hashCode}()=b.\texttt{hashCode}() \]

La réciproque n’est pas exigée : deux clés différentes peuvent partager un code (une collision). HashMap compare d’abord les codes, puis appelle equals.

@Override public int hashCode() {  // le champ lu par equals
    return numero.hashCode();
}

Résultat \(\rightarrow\)

true Thies true

Lecture. Même programme, ce hashCode ajouté : get rend Thies. Le code ne dépend que du champ lu par equals. Et une clé ne doit pas changer après l’insertion : son code changerait, la paire ne serait plus cherchée dans la bonne case.

Le cas idéal : DirectMap, pas à pas

Sortie \(\rightarrow\) 

Cheikh
null

Lecture. Ligne 1 : 10 cases, une par clé possible (tables d’examen 0 à 9), toutes à null.

Lecture. Ligne 2 : la clé 3 est l’indice : une écriture, sans recherche.

Lecture. Ligne 3 : même chose en 7.

Lecture. Ligne 4 : et en 0.

Lecture. Ligne 5 : get(7) lit la case 7, à l’adresse début \(+\,7\times\)taille d’une référence : \(\Theta(1)\) au pire.

Lecture. Ligne 6 : la case 5 est vide : null, absente, en une lecture aussi.

Le prix de l’accès direct

Une case par clé possible

L’accès direct coûte \(\Theta(1)\) au pire, mais \(u\) cases pour un univers de \(u\) clés : 10 tables d’examen, 40 octets ; des numéros à 9 chiffres, \(10^9\) cases, 4 Go pour \(7\,500\) contacts ; des noms, aucun indice à lire. (Références de 4 octets, compressées ; sans l’en-tête du tableau.)

// cle : le numero a 9 chiffres lu comme un entier, 771234567 pour 77 123 45 67
DirectMap<String> annuaire = new DirectMap<>(1_000_000_000);
annuaire.put(771234567, "Awa Diop");
System.out.println(annuaire.get(771234567));

Commande \(\rightarrow\)

$ java -Xmx256m -cp build ExDirectEchelle

Erreur \(\rightarrow\)

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
    at DirectMap.<init>(DirectMap.java:6)
    at ExDirectEchelle.main(ExDirectEchelle.java:5)

Lecture. Tas de 256 Mo : un milliard de références ne peut être alloué ; \(h\) ramène l’univers à \(m\) cases.

Exercice : calculer un code et un indice

Les clés "Pape" et "Mariama", \(m=8\)

P = 80, a = 97, p = 112, e = 101. Aides : \(80\times31=2\,480\) ; \(2\,577\times31=79\,887\) ; \(79\,999\times31=2\,479\,969\) ; \(1\,791\,126\,806=8\times223\,890\,850+6\).

  1. Calculer "Pape".hashCode() par Horner.
  2. Son indice pour \(m=8\).
  3. "Mariama".hashCode() vaut \(-1\,791\,126\,806\) : que valent h % 8, Math.floorMod(h, 8), (h & 0x7fffffff) % 8 ?
  4. Placer les deux clés dans la table.

Correction

  1. \(80\), \(2\,480+97=2\,577\), \(79\,887+112=79\,999\), \(2\,479\,969+101=2\,480\,070\).
  2. \(2\,480\,070=8\times310\,008+6\) : case 6.
  3. \(-6\) (signe du dividende) ; \(-6+8=2\) ; \(2\) (car 8 divise \(2^{31}\)).
  4. Case 6 : Pape ; case 2 : Mariama.
0 1 2 3 4 5 6 7
Mariama Pape

Le principe des tiroirs

{Principe des tiroirs {(pigeonhole principle)}}

\(n>m\) objets dans \(m\) tiroirs : un tiroir en contient deux. Collision : \(k_1\neq k_2\), \(h(k_1)=h(k_2)\) ; \(2^{32}\) codes, bien plus de chaînes. Comme \(\mathrm{code}(\texttt{"Aa"})=65\times31+97=2\,112=66\times31+66=\mathrm{code}(\texttt{"BB"})\), toute concaténation d’un même nombre de blocs "Aa" et "BB" garde le même code.

System.out.println("Aa".hashCode() + " " + "BB".hashCode());
for (String s : Cles.collisions(2)) System.out.print(s + ":" + s.hashCode() + " ");
System.out.println();
List<String> cles = Cles.collisions(12);  // 2^12 chaines de 24 lettres
Set<Integer> codes = new HashSet<>();
for (String s : cles) codes.add(s.hashCode());
System.out.println(cles.size() + " cles, " + codes.size() + " hashCode : " + codes);

Résultat \(\rightarrow\)

2112 2112
AaAa:2031744 AaBB:2031744 BBAa:2031744 BBBB:2031744 
4096 cles, 1 hashCode : [-1133886720]

Lecture. Un seul code pour \(4\,096\) chaînes : il faut gérer les collisions.

Le paradoxe des anniversaires

\(n\) clés uniformes, \(m\) cases

\[ P_0=\prod_{i=0}^{n-1}\Big(1-\frac im\Big)\approx e^{-n(n-1)/(2m)} \]

\(P_0\) : probabilité de n’avoir aucune collision. La probabilité d’une collision dépasse \(1/2\) dès \(n\approx1{,}18\sqrt m\) : 23 clés pour \(m=365\) (\(0{,}5073\)), 119 pour \(m=10\,000\).

Calcul exact (trait) et mesure Java (points), \(m=365\).

Lecture. Une table de \(10\,000\) cases a une chance sur deux de connaître sa première collision avant d’être remplie à \(1{,}2\,\%\).

Les anniversaires, mesurés

Random r = new Random(221);
int m = 365, essais = 10000;       // 365 cases : les jours
for (int n : new int[] {10, 23, 41, 57}) {
    int avecCollision = 0;
    for (int e = 0; e < essais; e++) {
        boolean[] pris = new boolean[m];
        boolean collision = false;
        for (int k = 0; k < n && !collision; k++) {
            int a = r.nextInt(m);  // case uniforme
            collision = pris[a];
            pris[a] = true;
        }
        if (collision) avecCollision++;
    }
    System.out.print(n + " : " + (double) avecCollision / essais + "   ");
}

Résultat \(\rightarrow\)

10 : 0.1145   23 : 0.5065   41 : 0.8984   57 : 0.9905   

Lecture. Calcul exact : \(0{,}1169\), \(0{,}5073\), \(0{,}9032\), \(0{,}9901\) ; la mesure s’en écarte de moins de \(0{,}005\).

Exercice : prévoir les collisions

Une table de \(m=100\) cases

Aides : \(\prod_{i=0}^{9}(1-i/100)=0{,}6282\), \(e^{-0{,}45}=0{,}6376\) ; au moins une collision : \(0{,}4968\) (12 clés), \(0{,}5572\) (13).

  1. \(1\,001\) clés distinctes : combien, au moins, dans une même case ?
  2. 10 clés uniformes : probabilité exacte d’une collision au moins.
  3. La même, approchée par l’exponentielle.
  4. Dès combien de clés dépasse-t-elle \(1/2\) ?

Correction

  1. \(\lceil1\,001/100\rceil=11\) clés dans une même case au moins.
  2. \(1-0{,}6282=0{,}3718\).
  3. \(\frac{10\times9}{2\times100}=0{,}45\), d’où \(1-0{,}6376=0{,}3624\).
  4. 13 clés (\(0{,}5572\)) ; \(1{,}18\sqrt{100}\approx11{,}8\) en donne l’ordre de grandeur.
clés \(1\,001\) 10 (exacte) 10 (approchée) 12 13
résultat \(\geq11\) dans une case \(0{,}3718\) \(0{,}3624\) \(0{,}4968\) \(0{,}5572>1/2\)

Le chaînage séparé : l’idée

Analogie

À la cité universitaire de Dakar, le courrier est déposé par numéro de chambre ; quand trois étudiants partagent une chambre, leurs lettres s’empilent dans le même casier.

Chaînage séparé

Chaque case contient une liste chaînée des paires dont la clé y est hachée : une case ne contient donc pas une paire, mais une liste, éventuellement vide. Chercher \(k\), c’est parcourir la seule liste de la case \(h(k)\) ; insérer, c’est ajouter en queue si la clé est absente.

Lecture. Les collisions ne posent plus de problème de place : le coût d’une recherche devient la longueur d’une liste.

Le chaînage séparé, pas à pas

Sortie \(\rightarrow\) 

5 2
null 2

Lecture. Ligne 1 : 8 cases vides (taille fixe : seuil 0, pas de redimensionnement ici). Chaque case contiendra une liste.

Lecture. Ligne 2 : Awa est haché en 3 ; case vide, la paire est ajoutée en queue de sa liste.

Lecture. Ligne 3 : Cheikh est haché en 6 ; case vide, la paire est ajoutée en queue de sa liste.

Lecture. Ligne 4 : Fatou est haché en 7 ; case vide, la paire est ajoutée en queue de sa liste.

Lecture. Ligne 5 : Moussa tombe en 6, déjà occupée par Cheikh : une collision, ajoutée en queue de la liste.

Lecture. Ligne 6 : Amadou est haché en 5 ; case vide, la paire est ajoutée en queue de sa liste.

Lecture. Ligne 7 : Coumba rejoint Awa en 3 : six clés dans quatre cases.

Lecture. Ligne 8 : get ne parcourt que la liste de la case 3 : Awa, puis Coumba, trouvée en 2 comparaisons (valeur 5).

Lecture. Ligne 9 : affichage de la valeur et du compteur.

Lecture. Ligne 10 : remise à zéro du compteur.

Lecture. Ligne 11 : Ndeye, absente, est hachée en 3 : la liste est épuisée en 2 comparaisons, puis null.

Lecture. Ligne 12 : null 2 : une recherche infructueuse ne coûte que la longueur d’une chaîne.

put et get en Java

public V put(K cle, V valeur) {
    int i = indice(cle);
    Noeud<K, V> dernier = null;
    for (var x = table[i]; x != null; x = x.suivant) {
        if (cle.equals(x.cle)) {     // deja presente
            V ancienne = x.valeur;
            x.valeur = valeur;
            return ancienne;
        }
        dernier = x;
    }
    Noeud<K, V> nouveau = new Noeud<>(cle, valeur);
    if (dernier == null) table[i] = nouveau;
    else dernier.suivant = nouveau;  // ajout en queue
    n++;
    verifierCharge();                // alpha > seuil ?
    return null;
}
public V get(K cle) {
    Noeud<K, V> x = table[indice(cle)];
    while (x != null) {
        comparaisons++;  // 1 comparaison
        if (cle.equals(x.cle)) return x.valeur;
        x = x.suivant;
    }
    return null;         // absente
}

Lecture. indice(cle) : (cle.hashCode() & 0x7fffffff) % m. put et get ne parcourent que la chaîne de cette case.

Le coût moyen, formellement

Facteur de charge et coûts sous hachage uniforme

\[ \alpha=\frac nm,\qquad \text{échec}~:~E[L_{h(k)}]=\alpha,\qquad \text{succès}~:~\frac1n\sum_{i=1}^{n}\Big(1+\frac{i-1}{m}\Big)=1+\frac{n-1}{2m}\approx1+\frac\alpha2 \]

\(\alpha\) : longueur moyenne d’une chaîne ; \(L_{h(k)}\) : la longueur de la chaîne de \(k\). Une clé absente est comparée à toute sa chaîne ; la \(i\)-ième clé insérée est comparée à elle-même et aux \(i-1\) précédentes, chacune dans sa case avec la probabilité \(1/m\). Avec le calcul de \(h\) : \(\Theta(1+\alpha)\).

Lecture. Tant que \(m\) reste proportionnel à \(n\), \(\alpha\) est borné et le coût moyen est \(\Theta(1)\) : à \(\alpha=0{,}75\), \(1{,}375\) comparaisons en succès, \(0{,}75\) en échec.

Les coûts mesurés à \(\alpha=0{,}75\)

List<String> t = Cles.telephones(12500, new Random(221));
ChainedHashMap<String, Integer> c = new ChainedHashMap<>(10000, 0);
for (int i = 0; i < 7500; i++) c.put(t.get(i), i);   // alpha = 0,75
c.comparaisons = 0;
for (int i = 0; i < 7500; i++) c.get(t.get(i));      // 7 500 presentes
double succes = c.comparaisons / 7500.0;
c.comparaisons = 0;
for (int i = 7500; i < 12500; i++) c.get(t.get(i));  // 5 000 absentes
System.out.println(succes + " " + c.comparaisons / 5000.0);
int[] h = c.histogrammeLongueurs(5);                 // h[l] : cases de longueur l
System.out.println(Arrays.toString(h) + " " + c.longueurMax());

Résultat \(\rightarrow\)

1.3714666666666666 0.7478
[4715, 3551, 1334, 328, 63, 9] 5

Lecture. \(1{,}3715\) comparaisons en succès (formule \(1{,}37495\)), \(0{,}7478\) en échec (formule \(0{,}75\)). Le tableau h : \(4\,715\) cases vides, \(3\,551\) chaînes d’un nœud, puis \(1\,334\), \(328\), \(63\), \(9\) ; la plus longue compte 5 nœuds.

Le pire cas : une seule chaîne

List<String> cles = Cles.collisions(12);  // 4 096 cles, un hashCode
ChainedHashMap<String, Integer> c = new ChainedHashMap<>(8192, 0);
for (String s : cles) c.put(s, 0);        // alpha = 0,5
c.comparaisons = 0;
for (String s : cles) c.get(s);
System.out.println(c.longueurMax() + " " + c.comparaisons / 4096.0);
c.comparaisons = 0;
c.get("C#" + cles.get(0).substring(2));   // absente, meme hashCode
System.out.println(c.comparaisons);

Résultat \(\rightarrow\)

4096 2048.5
4096

Lecture. Les \(4\,096\) clés de même code tombent dans une seule case : \(2\,048{,}5=(n+1)/2\) comparaisons en succès, \(4\,096\) en échec, exactement la liste du début, bien que \(\alpha=0{,}5\).

Quoi, pourquoi, quand ça échoue : le chaînage

Quoi

Un tableau de \(m\) listes ; get, put, remove parcourent la seule liste de \(h(k)\).

Pourquoi c’est constant en moyenne

Sous hachage uniforme, une chaîne compte \(\alpha\) clés en moyenne : \(1+\alpha/2\) comparaisons en succès, \(\alpha\) en échec, et \(\alpha\) peut dépasser 1 sans rien casser.

Quand ça échoue

Des clés de même code (4 096 clés, \(2\,048{,}5\) comparaisons) : \(\Theta(n)\) au pire. Et un nœud alloué par paire, dispersé en mémoire.

Exercice : une trace de chaînage

Cinq numéros de chambre, \(m=7\)

Integer.hashCode() rend la valeur. On insère, dans cet ordre, 116, 138, 135, 109, 124 (ajout en queue).

  1. Indices \(k\bmod7\).
  2. État final de la table.
  3. Comparaisons pour get(124), puis get(130).
  4. Coût moyen d’une recherche réussie, comparé à \(1+(n-1)/(2m)\).
0 1 2 3 4 5 6
135 116 \(\to\) 109 138 \(\to\) 124

Correction

  1. \(116=7\times16+4\) : 4 ; 138 : 5 ; 135 : 2 ; 109 : 4 ; 124 : 5.
  2. Deux collisions : 109 rejoint 116, 124 rejoint 138 ; cases 0, 1, 3, 6 vides.
  3. 124 : chaîne 5, second nœud, 2 ; 130 : \(130\bmod7=4\), chaîne de 2 nœuds épuisée, 2.
  4. \((1+1+1+2+2)/5=1{,}4\), contre \(1+4/14=1{,}29\) (\(\alpha=5/7\)) : la formule est une moyenne sur toutes les répartitions possibles.

Le sondage linéaire : l’idée

Analogie

Au parking du marché Sandaga, chaque chauffeur vise la place de son ticket ; si elle est prise, il prend la suivante libre. Pour retrouver une voiture, on part de la place du ticket et l’on avance jusqu’à la voiture, ou jusqu’à une place vide.

Sondage linéaire

\[ h(k,i)=\big(h(k)+i\big)\bmod m,\qquad i=0,1,\dots,m-1 \]

Une paire au plus par case ; put range la clé dans la première case libre de la suite, get suit la même suite jusqu’à la clé ou une case vide. Il faut \(n<m\) : \(\alpha<1\). Le coût se compte en sondages, cases examinées.

Le sondage linéaire, pas à pas

Sortie \(\rightarrow\) 

3 3
null 7

Lecture. Ligne 1 : 8 cases, une paire au plus par case.

Lecture. Ligne 2 : Awa visée en 3, case libre : un sondage.

Lecture. Ligne 3 : Cheikh visée en 6, case libre : un sondage.

Lecture. Ligne 4 : Fatou visée en 7, case libre : un sondage.

Lecture. Ligne 5 : Moussa vise 6, prise, puis 7, prise, et revient en 0 : 3 sondages.

Lecture. Ligne 6 : Amadou visée en 5, case libre : un sondage.

Lecture. Ligne 7 : Coumba vise 3, prise par Awa, et s’arrête en 4 : les cases 3 à 7 et 0 forment un seul cluster.

Lecture. Ligne 8 : get suit le même chemin, 6, 7, 0 : trouvée (valeur 3) en 3 sondages.

Lecture. Ligne 9 : affichage de la valeur et du compteur.

Lecture. Ligne 10 : remise à zéro du compteur.

Lecture. Ligne 11 : Ndeye, hachée en 3, parcourt tout le cluster jusqu’à la case vide 1 : 7 sondages pour conclure à l’absence.

Lecture. Ligne 12 : null 7 : dans un cluster, une recherche infructueuse coûte cher.

put et get par sondage en Java

public void put(K cle, V valeur) {
    int m = cles.length;
    int i = indice(cle);
    while (cles[i] != null) {       // case prise
        if (cles[i].equals(cle)) {  // presente
            vals[i] = valeur;
            return;
        }
        i = (i + 1) % m;            // case suivante
    }
    cles[i] = cle;                  // case libre
    vals[i] = valeur;
    n++;
}
public V get(K cle) {
    int m = cles.length;
    int i = indice(cle);
    while (true) {
        sondages++;  // 1 sondage
        if (cles[i] == null) return null;
        if (cles[i].equals(cle)) return vals[i];
        i = (i + 1) % m;
    }
}

Lecture. Aucune liste, aucun nœud : deux tableaux cles et vals. Une case vide arrête get : une clé insérée ne saute jamais une case vide. put suppose \(n<m\) (le redimensionnement le garantit).

Les clusters

Clustering primaire

Un cluster : des cases occupées consécutives. Une clé hachée dans un cluster de longueur \(L\) ou sur l’une des deux cases vides qui le bordent l’allonge, avec la probabilité \((L+2)/m\) : les grands clusters grandissent plus vite.

Lecture. \(\alpha=0{,}75\) : 36 clusters, dont un de 32 cases ; une clé hachée dans ce cluster ou sur ses bords l’allonge avec la probabilité \(34/251\approx0{,}14\).

La suppression par sondage, pas à pas

Sortie \(\rightarrow\) 

3
null
3

Lecture. Ligne 1 : la table des six noms (celle du sondage pas à pas).

Lecture. Ligne 2 : Moussa est trouvée en 0, après être passée par 6 et 7.

Lecture. Ligne 3 : suppression naïve : la case 6 est vidée.

Lecture. Ligne 4 : la recherche s’arrête sur le trou et rend null, alors que Moussa est toujours en 0.

Lecture. Ligne 5 : on repart de la table des six noms.

Lecture. Ligne 6 : supprimer vide la case 6, puis réinsère la suite du cluster : Fatou reste en 7, Moussa (hachée en 6) prend 6 ; la case 1, vide, arrête.

Lecture. Ligne 7 : trouvée en 1 sondage. Autre remède : une pierre tombale (tombstone, marque « supprimée ») qui ne termine pas la recherche.

Les coûts moyens, formellement

Sondages moyens sous hachage uniforme (Knuth)

\[ S(\alpha)\approx\frac12\Big(1+\frac1{1-\alpha}\Big) \text{(succès)},\qquad U(\alpha)\approx\frac12\Big(1+\frac1{(1-\alpha)^2}\Big) \text{(échec)} \]

\(\alpha=0{,}5\) \(\alpha=0{,}75\) \(\alpha=0{,}9\) chaînage, \(\alpha=2\)
succès \(\frac12(1+2)=1{,}5\) \(\frac12(1+4)=2{,}5\) \(\frac12(1+10)=5{,}5\) \(1+2/2=2\)
échec \(\frac12(1+4)=2{,}5\) \(\frac12(1+16)=8{,}5\) \(\frac12(1+100)=50{,}5\) \(2\)

Lecture. Le terme en \(1/(1-\alpha)^2\) décide de tout : de \(0{,}75\) à \(0{,}9\), une recherche infructueuse coûte six fois plus. Le chaînage reste linéaire en \(\alpha\), même au-delà de 1.

Knuth contre la mesure

List<String> t = Cles.telephones(14000, new Random(221));
for (double a : new double[] {0.5, 0.75, 0.9}) {
    int m = 10000, n = (int) Math.round(a * m);
    LinearProbingMap<String, Integer> lp = new LinearProbingMap<>(m);
    for (int i = 0; i < n; i++) lp.put(t.get(i), i);
    lp.sondages = 0;
    for (int i = 0; i < n; i++) lp.get(t.get(i));         // presentes
    double s = (double) lp.sondages / n, ks = 0.5 * (1 + 1 / (1 - a));
    lp.sondages = 0;
    for (int i = n; i < n + 5000; i++) lp.get(t.get(i));  // 5 000 absentes
    double e = lp.sondages / 5000.0, ke = 0.5 * (1 + 1 / ((1 - a) * (1 - a)));
    System.out.printf(Locale.ROOT, "%.2f  %.3f %.3f  %.3f %.3f%n", a, s, ks, e, ke);
}

Résultat \(\rightarrow\)

0.50  1.483 1.500  2.473 2.500
0.75  2.573 2.500  8.705 8.500
0.90  5.459 5.500  48.359 50.500

Lecture. \(\alpha\), succès (mesure, Knuth), échec (mesure, Knuth) : écart sous \(0{,}21\), sauf \(48{,}4\) contre \(50{,}5\) à \(0{,}9\).

Quoi, pourquoi, quand ça échoue : le sondage linéaire

Quoi

Un tableau de paires ; suite \(h(k), h(k)+1,\dots\) jusqu’à la clé ou une case vide.

Pourquoi on l’utilise

Aucun nœud ni référence de chaînage, des cases contiguës, lues ensemble par le cache du processeur : à \(\alpha\leq0{,}5\), \(S\leq1{,}5\) et \(U\leq2{,}5\).

Quand ça échoue

\(\alpha\) proche de 1 (48 sondages en échec à \(0{,}9\)) ; les clusters ; la suppression, qui ne peut pas vider une case sans pierre tombale ni réinsertion.

Exercice : une trace de sondage linéaire

Les mêmes clés, \(m=7\)

On insère 116, 138, 135, 109, 124 (indices 4, 5, 2, 4, 5) par sondage linéaire.

  1. État final et nombre de sondages de chaque insertion.
  2. Coût moyen d’une recherche réussie, comparé à \(S(5/7)\).
  3. Cases visitées par get(130).
  4. On vide la case de 138 : que rend get(109) ?
0 1 2 3 4 5 6
124 135 116 ---138 109

Correction

  1. 116 en 4, 138 en 5, 135 en 2 (1 sondage chacune) ; 109 : 4, 5, puis 6 (3) ; 124 : 5, 6, puis 0 (3).
  2. \((1+1+1+3+3)/5=1{,}8\) ; \(S(5/7)=\frac12(1+3{,}5)=2{,}25\).
  3. 130 est haché en 4 : 4, 5, 6, 0, puis 1, vide : 5 sondages, absent.
  4. null : la recherche part de 4 (116), trouve 5 vide et s’arrête, alors que 109 est en 6.

Le facteur de charge et la règle de redimensionnement

{Facteur de charge {(load factor)}}

\[ \alpha=\frac{n}{m}\qquad\text{($n$ paires, $m$ cases)} \]

Les coûts moyens ne dépendent que de \(\alpha\). Pour qu’ils restent constants quand \(n\) grandit, \(m\) doit grandir avec \(n\).

Règle du cours

Après une insertion, si \(\alpha\) dépasse le seuil \(0{,}75\), on alloue un tableau de \(2m\) cases et l’on réinsère chaque paire (rehash) : son indice \(h(k)\) dépend de \(m\), il faut le recalculer.

Exemple

\(m=8\) : après la 6e paire, \(\alpha=0{,}75\), on garde ; la 7e donne \(7/8=0{,}875>0{,}75\) : \(m\) passe à 16, \(\alpha=7/16=0{,}4375\).

Le redimensionnement, pas à pas

Sortie \(\rightarrow\) 

8 0.75
16 0.4375

Lecture. Ligne 1 : 8 cases, seuil \(0{,}75\) : la table doublera dès que \(n/m>0{,}75\).

Lecture. Ligne 2 : \(n=1\), \(\alpha=1/8\).

Lecture. Ligne 3 : \(n=2\), \(\alpha=2/8\).

Lecture. Ligne 4 : \(n=3\), \(\alpha=3/8\).

Lecture. Ligne 5 : \(n=4\), \(\alpha=4/8\).

Lecture. Ligne 6 : \(n=5\), \(\alpha=5/8\).

Lecture. Ligne 7 : \(n=6\), \(\alpha=6/8\) : égal au seuil, pas au-dessus.

Lecture. Ligne 8 : facteur de charge \(6/8=0{,}75\), égal au seuil.

Lecture. Ligne 9 : capacité 8, facteur de charge \(0{,}75\).

Lecture. Ligne 10 : Ibrahima est d’abord ajoutée en 5 : \(7/8>0{,}75\), verifierCharge déclenche le redimensionnement.

Lecture. Ligne 10 (suite) : tableau de 16 cases, les 7 paires réinsérées : l’indice modulo 16 vaut \(j\) ou \(j+8\) (en rouge, les clés déplacées en \(j+8\) à cette étape). Plus aucune collision.

Lecture. Ligne 11 : \(\alpha=7/16=0{,}4375\), divisé par deux.

Lecture. Ligne 12 : capacité 16 et facteur de charge \(0{,}4375\).

Le rehash en Java

private void redimensionner(int nouveauM) {
    Noeud<K, V>[] ancienne = table;
    table = (Noeud<K, V>[]) new Noeud[nouveauM];
    n = 0;
    redimensionnements++;
    for (Noeud<K, V> tete : ancienne)  // chaque paire est reinseree :
        for (Noeud<K, V> x = tete; x != null; x = x.suivant) {
            put(x.cle, x.valeur);      // son indice depend de m
            copies++;
        }
}

Lecture. Un tableau neuf de nouveauM cases ; chaque paire de l’ancien est réinsérée par put, qui recalcule son indice modulo le nouveau \(m\) (n repart de 0 et retrouve sa valeur). copies compte ces réinsertions.

Les redimensionnements observés

ChainedHashMap<Integer, Integer> c = new ChainedHashMap<>(8, 0.75);
for (int k = 1; k <= 30; k++) {
    int avant = c.capacite();
    c.put(k, k);
    if (c.capacite() != avant)  // un redimensionnement a eu lieu
        System.out.println(k + " : " + avant + " -> " + c.capacite()
            + ", alpha = " + c.facteurDeCharge() + ", copies = " + c.copies);
}

Résultat \(\rightarrow\)

7 : 8 -> 16, alpha = 0.4375, copies = 7
13 : 16 -> 32, alpha = 0.40625, copies = 20
25 : 32 -> 64, alpha = 0.390625, copies = 45

Lecture. Le redimensionnement a lieu aux insertions 7, 13 et 25 : \(6=0{,}75\times8\), \(12=0{,}75\times16\), \(24=0{,}75\times32\). Chaque fois, toutes les paires présentes sont recopiées : \(7\), puis \(7+13=20\), puis \(20+25=45\) copies.

Le coût amorti, formellement

Copies pour \(N\) insertions, \(m_0=8\), doublement

\[ \text{copies}(N)=\sum_{j=0}^{K}\big(6\cdot2^j+1\big)=6\big(2^{K+1}-1\big)+K+1\leq2N+K-7 \]

Le redimensionnement \(j\) (\(j=0,\dots,K\)) a lieu à l’insertion \(6\cdot2^j+1\leq N\) et recopie toutes les paires présentes. Au total, au plus \(2N+K-7\) copies, soit \(2+\mathcal{O}(\log N/N)\) par insertion : \(\mathcal{O}(1)\) en amorti.

Agrandir d’une quantité fixe

Avec \(m\leftarrow m+c\), le nombre de redimensionnements croît comme \(N/c\) et chacun recopie jusqu’à \(N\) paires : \(\Theta(N^2/c)\) copies au total, \(\Theta(N/c)\) par insertion.

Doublement contre ajout de 1 024 cases

for (int pas : new int[] {0, 1024}) {  // 0 : doubler ; 1024 : m + 1024
    ChainedHashMap<Integer, Integer> c = new ChainedHashMap<>(8, 0.75, pas);
    for (int i = 0; i < 100_000; i++) c.put(i, i);
    System.out.println(pas + " : " + c.redimensionnements + " redimensionnements, "
        + c.copies + " copies, " + (double) c.copies / 100_000 + " par insertion");
}

Résultat \(\rightarrow\)

0 : 15 redimensionnements, 196617 copies, 1.96617 par insertion
1024 : 131 redimensionnements, 6540437 copies, 65.40437 par insertion

Lecture. Pour \(N=100\,000\) insertions : \(196\,617\) copies en doublant, \(K=14\), sous la borne \(2N+K-7=200\,007\) ; en ajoutant 1 024 cases, 131 redimensionnements et \(6\,540\,437\) copies, 33 fois plus. Le doublement garde le coût par insertion constant.

java.util.HashMap

Capacité 16 au premier put, \(2^p\), seuil \(0{,}75\), indice (n - 1) & (h ^ (h >>> 16)) ; au-delà de 8 nœuds, une case devient un arbre rouge-noir si la table a au moins 64 cases, sinon la table double.

HashMap<Integer, Integer> m = new HashMap<>();
System.out.print(capacite(m));           // 0 : pas encore de tableau
for (int k = 1; k <= 13; k++) {          // capacite apres k insertions
    m.put(k, k);
    System.out.print(" " + capacite(m));
}
HashMap<String, Integer> c = new HashMap<>();
List<String> cles = Cles.collisions(4);  // 16 cles, un seul hashCode
for (int k = 1; k <= 11; k++) {          // k : capacite : type de noeud
    c.put(cles.get(k - 1), k);
    if (k >= 8) System.out.print(k + ":" + capacite(c) + ":" + noeud(c) + "  ");
}

Résultat \(\rightarrow\)

0 16 16 16 16 16 16 16 16 16 16 16 16 32
8:16:Node  9:32:Node  10:64:Node  11:64:TreeNode  

Lecture. Réflexion : --add-opens java.base/java.util=ALL-UNNAMED. 9e, 10e clés : doublement ; 11e : arbre.

Exercice : un rehash

Chaînage, \(m=4\), seuil \(0{,}75\)

Avec \(h(k)=k\bmod m\), on insère 5, 12, 7, 20, 9, en queue de chaîne, en doublant \(m\) dès que \(\alpha>0{,}75\) après une insertion.

  1. État après 5, 12, 7.
  2. Quelle insertion déclenche le rehash ? État après.
  3. État final et facteur de charge.

Correction

  1. \(5\bmod4=1\), \(12\bmod4=0\), \(7\bmod4=3\) : \(\alpha=3/4\), pas de rehash.
  2. 20 : \(\alpha=4/4>0{,}75\) ; \(m=8\) : \(12\to4\), puis \(20\to4\) derrière 12, \(5\to5\), \(7\to7\).
  3. \(9\bmod8=1\) ; \(\alpha=5/8=0{,}625\).
0 1 2 3
12 5 7
0 1 2 3 4 5 6 7
9 12 \(\to\) 20 5 7

Exercice : prévoir les redimensionnements

Table du cours, \(m_0=8\), seuil \(0{,}75\)

On insère 100 clés distinctes.

  1. À quelles insertions la table double-t-elle ?
  2. Valeurs finales de \(m\) et de \(\alpha\).
  3. Nombre total de copies.
  4. Même question (a) pour HashMap, capacité 16.

Correction

  1. Quand \(n>0{,}75\,m\) : 7, 13, 25, 49, 97 (\(m\) : 16, 32, 64, 128, 256).
  2. \(m=256\), \(\alpha=100/256\approx0{,}39\).
  3. \(7+13+25+49+97=191\), sous la borne \(2N+K-7=197\).
  4. 13, 25, 49, 97 : capacité finale 256.
insertion \(m\) après \(\alpha\) après copies cumulées **HashMap**
7 16 \(0{,}44\) 7 16
13 32 \(0{,}41\) 20 32
25 64 \(0{,}39\) 45 64
49 128 \(0{,}38\) 94 128
97 256 \(0{,}38\) 191 256
100 256 \(0{,}39\) 191 256

Les structures côte à côte

structure get moyen get pire put mesuré, \(n=7\,500\)
présente / absente
liste de paires \(\Theta(n)\) \(\Theta(n)\) \(\Theta(n)\) \(3\,750{,}5\) / \(7\,500\)
tableau trié + dichotomie \(\Theta(\log n)\) \(\Theta(\log n)\) \(\Theta(n)\) \(11{,}91\) / \(12{,}92\)
accès direct \(\Theta(1)\) \(\Theta(1)\) \(\Theta(1)\) — (\(u\) cases)
chaînage, \(\alpha=0{,}75\) \(\Theta(1+\alpha)\) \(\Theta(n)\) \(\Theta(1)\) amorti \(1{,}371\) / \(0{,}748\)
sondage linéaire,
\(\alpha=0{,}75\)
\(\Theta\big(\frac1{1-\alpha}\big)\) en succès,
\(\Theta\big(\frac1{(1-\alpha)^2}\big)\) en échec
\(\Theta(n)\) \(\Theta(1)\) amorti \(2{,}573\) / \(8{,}705\) sondages

Lecture. Comparaisons de clés (sondages pour le sondage linéaire) ; mêmes \(7\,500\) numéros (graine 221), \(m=10\,000\), \(5\,000\) clés absentes. Les tables de hachage visent \(\Theta(1)\) pour get et put à la fois, en moyenne seulement, et sans ordre : ni minimum, ni parcours trié.

À retenir

À retenir

  • Hachage : \(h(k)=c(\mathrm{code}(k))\) ; String.hashCode par Horner en int, souvent négatif ; compression (h & 0x7fffffff) % m, jamais h % m ni Math.abs.
  • Contrat : \(a.\texttt{equals}(b)\Rightarrow\) codes égaux ; l’oublier rend get aveugle.
  • Collisions : certaines si \(n>m\), probables dès \(n\approx1{,}18\sqrt m\) (23 pour 365).
  • Chaînage : une liste par case ; \(1+\alpha/2\) comparaisons en succès, \(\alpha\) en échec.
  • Sondage linéaire : case suivante libre ; \(\frac12(1+\frac1{1-\alpha})\) et \(\frac12(1+\frac1{(1-\alpha)^2})\) sondages ; les clusters ; ne jamais vider une case sans réinsérer la suite.
  • Redimensionnement : doubler quand \(\alpha>0{,}75\) et réinsérer chaque paire (rehash) ; au plus \(2N+K-7\) copies pour \(N\) insertions, soit \(2+\mathcal{O}(\log N/N)\) par insertion.
  • HashMap : capacité \(2^p\), seuil \(0{,}75\), arbre rouge-noir au-delà de 8 nœuds par case si la table a au moins 64 cases, sinon elle double.

Prochaine séance

Séance 4 — Arbres et tas

Une table de hachage n’a aucun ordre : ni plus petite clé, ni clés dans \([k_1,k_2]\), ni parcours trié. La Séance 4 construit, avec des opérations en \(\mathcal{O}(\log n)\) qui gardent l’ordre :

  • l’arbre binaire de recherche : recherche, insertion, les trois cas de la suppression, parcours infixe, et son arbre dégénéré ;
  • l’arbre AVL et ses rotations ;
  • le tas binaire rangé dans un tableau (enfants en \(2i+1\) et \(2i+2\)) et le tri par tas {(heapsort)}.

À préparer : le TP de la Séance 3 (une ChainedHashMap complète, avec redimensionnement et tests JUnit).

Références

  • Sedgewick, Wayne — Algorithms, 4e éd., Addison-Wesley, 2011 (§ 3.4 : tables de hachage).
  • Cormen, Leiserson, Rivest, Stein — Introduction to Algorithms, 4e éd., MIT Press, 2022 (ch. 11).
  • Knuth — The Art of Computer Programming, vol. 3 : Sorting and Searching, 2e éd., Addison-Wesley, 1998 (§ 6.4).
  • Skiena — The Algorithm Design Manual, 3e éd., Springer, 2020 (§ 3.7).
  • Bloch — Effective Java, 3e éd., Addison-Wesley, 2018 (items 10 et 11 : equals et hashCode).
  • OpenJDK — code source de java.util.HashMap et de java.lang.String, JDK 21.