Du tableau indexé au coût moyen constant
Département de Mathématiques et Informatique, Faculté des Sciences et Techniques, Université Cheikh Anta Diop de Dakar
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.
hashCode() d’une chaîne et l’indice de sa case, sans tomber dans le piège des négatifs.equals / hashCode et le bogue qui naît quand on l’oublie.D’où l’on vient, où l’on va
Amont. Les types abstraits et les interfaces Java (Séance 1) ; la recherche linéaire en \(\mathcal{O}(n)\), la dichotomie en \(\mathcal{O}(\log n)\) et l’analyse en moyenne et au pire (Séance 2).
Aval. Le hachage n’ordonne pas les clés : la Séance 4 construit les arbres binaires de recherche, les arbres AVL et les tas, qui gardent l’ordre.
Sortie \(\rightarrow\)
Fatou Sall 3
null 3
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.
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.
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\).
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.
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}\).
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é.
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.
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.
equals / hashCodeLe 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.
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.
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.
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.)
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.
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\).
"Pape".hashCode() par Horner."Mariama".hashCode() vaut \(-1\,791\,126\,806\) : que valent h % 8, Math.floorMod(h, 8), (h & 0x7fffffff) % 8 ?Correction
Pape ; case 2 : Mariama.| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|
Mariama |
Pape |
{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.
\(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\,\%\).
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\).
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).
Correction
| 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\) |
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.
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 Javapublic 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;
}Lecture. indice(cle) : (cle.hashCode() & 0x7fffffff) % m. put et get ne parcourent que la chaîne de cette case.
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.
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.
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
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.
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).
get(124), puis get(130).| 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|
| 135 | 116 \(\to\) 109 | 138 \(\to\) 124 |
Correction
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.
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 JavaLecture. 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).
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\).
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.
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.
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
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.
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.
get(130).get(109) ?| 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|
| 124 | 135 | 116 | ---138 | 109 |
Correction
null : la recherche part de 4 (116), trouve 5 vide et s’arrête, alors que 109 est en 6.{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\).
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\).
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.
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.
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.
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.HashMapCapacité 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.
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.
Correction
| 0 | 1 | 2 | 3 |
|---|---|---|---|
| 12 | 5 | 7 |
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|
| 9 | 12 \(\to\) 20 | 5 | 7 |
Table du cours, \(m_0=8\), seuil \(0{,}75\)
On insère 100 clés distinctes.
HashMap, capacité 16.Correction
| 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 |
| 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
String.hashCode par Horner en int, souvent négatif ; compression (h & 0x7fffffff) % m, jamais h % m ni Math.abs.get aveugle.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.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 :
À préparer : le TP de la Séance 3 (une ChainedHashMap complète, avec redimensionnement et tests JUnit).
equals et hashCode).java.util.HashMap et de java.lang.String, JDK 21.Structures de données et algorithmes avancés — Séance 3 Dr. El Hadji Bassirou TOURÉ • DMI/FST/UCAD
Lecture. Ligne 1 : une
ListeMapvide, deux listes parallèlesclesetvals.Lecture. Ligne 2 :
putparcourt 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 :
getcompare 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 :
nullet 3 : pour \(n\) paires, une clé absente coûte \(n\) comparaisons.