backend / algorithmes-structures-donnees
Tables de hachage et gestion des collisions
Explication
Ce que vous allez apprendre
- Expliquer comment une fonction de hachage transforme une clé arbitraire en index de tableau
- Justifier pourquoi les collisions sont mathématiquement inévitables et connaître les deux stratégies principales pour les gérer
- Définir le facteur de charge et expliquer pourquoi le redimensionnement automatique évite la dégradation vers O(n)
- Comparer chaînage et adressage ouvert selon la mémoire utilisée et la sensibilité au remplissage
- Relier ces mécanismes au fonctionnement réel du
dictde Python
Dans quel contexte ?
Un développeur backend construit un cache pour éviter de recalculer, à chaque requête, le résultat coûteux d'une fonction. Avec une liste, il faudrait comparer la requête à toutes les entrées déjà en cache pour savoir si elle existe : O(n) par vérification. Avec un dict Python, qui est une table de hachage, la même vérification est en O(1) en moyenne, ce qui permet de servir des dizaines de milliers de requêtes par seconde sans ralentissement, même quand le cache contient des millions d'entrées.
D'abord, le point de départ : le tableau est déjà rapide, mais limité
Un tableau offre un accès instantané en O(1), mais seulement via un index numérique. La question naturelle devient : peut-on avoir cette même rapidité avec une clé quelconque, comme une chaîne de caractères ou un objet ?
Étape 1 : transformer n'importe quelle clé en index
Une fonction de hachage répond à cette question : elle transforme une clé quelconque en un nombre, utilisé ensuite comme index dans un tableau. Trouver une valeur revient alors à calculer directement où elle "devrait" être, plutôt qu'à la chercher case par case.
Piège fréquent
Utiliser un objet mutable (comme une liste) comme clé de dictionnaire Python est impossible et lève une TypeError: unhashable type — car sa valeur de hachage pourrait changer après insertion, ce qui casserait la structure interne. Utilisez un tuple ou une chaîne de caractères à la place.
Il reste un problème inévitable : les collisions
Deux clés différentes peuvent produire le même index calculé. Ce n'est pas un bug, c'est mathématiquement inévitable dès que le nombre de clés possibles dépasse la taille de la table.
Étape 2 : une première solution, le chaînage
Chaque case du tableau contient une petite liste, plutôt qu'une seule valeur. En cas de collision, on ajoute simplement la nouvelle clé à cette liste, et la recherche parcourt cette petite liste (généralement très courte si tout va bien).
Étape 3 : une autre solution, l'adressage ouvert
Ici, chaque case ne garde qu'une seule clé. En cas de collision, l'algorithme cherche la prochaine case libre selon une règle systématique, par exemple en avançant d'une case à la fois (sondage linéaire).
| Stratégie | En cas de collision | Mémoire | Sensibilité au remplissage |
|---|---|---|---|
| Chaînage | Ajout dans une liste à la même case | Supplémentaire par case | Dégradation progressive |
| Adressage ouvert | Recherche de la prochaine case libre | Compacte, sans structure annexe | Dégradation brutale près de 100% |
Pourquoi le remplissage de la table compte tant
Plus la table se remplit, plus les collisions deviennent fréquentes, et plus les listes de chaînage (ou les séquences de sondage) s'allongent, ce qui dégrade les performances vers O(n) au pire cas.
Astuce
Le facteur de charge (nombre d'éléments divisé par taille de la table) est le signal clé pour savoir quand redimensionner. CPython redimensionne son dict automatiquement dès qu'il est environ aux deux tiers plein, pour garder l'accès proche de O(1).
La solution pour éviter cette dégradation
Une bonne implémentation surveille le "facteur de charge" (le rapport entre le nombre d'éléments et la taille de la table) et redimensionne toute la table, en re-hachant chaque clé, dès qu'un seuil (souvent 0,7 à 0,75) est franchi.
Pourquoi cette structure est partout
L'accès O(1) en moyenne pour insérer, lire et supprimer en fait l'une des structures les plus utilisées du monde réel : dictionnaires, caches, index de bases de données, déduplication.
Vers la suite
Le tableau et la table de hachage sont deux structures "à plat". La prochaine leçon introduit une dimension nouvelle, la hiérarchie, avec les arbres binaires.
Commandes & code
Tables de hachage (hash maps) et collisions
# dict en Python EST une table de hachage : O(1) en moyenne pour get/set/delete
cache = {}
cache["utilisateur:42"] = {"nom": "Alice"} # O(1) moyen
valeur = cache.get("utilisateur:42") # O(1) moyen
del cache["utilisateur:42"] # O(1) moyen
# Fonction de hachage simple (à but pédagogique -- Python utilise hash() en interne)
def hachage_simple(cle: str, taille_table: int) -> int:
total = sum(ord(c) for c in cle)
return total % taille_table
# --- Implémentation d'une table de hachage avec gestion de collisions par chaînage ---
class TableHachageChainage:
def __init__(self, capacite: int = 16):
self._capacite = capacite
self._taille = 0
self._buckets: list[list] = [[] for _ in range(capacite)]
def _index(self, cle) -> int:
return hash(cle) % self._capacite
def inserer(self, cle, valeur) -> None:
# facteur de charge = taille / capacité -- au-delà de ~0.75, on redimensionne
if self._taille / self._capacite > 0.75:
self._redimensionner()
bucket = self._buckets[self._index(cle)]
for i, (k, _) in enumerate(bucket):
if k == cle:
bucket[i] = (cle, valeur) # met à jour une clé existante
return
bucket.append((cle, valeur)) # O(1) amorti ; O(n_bucket) si beaucoup de collisions
self._taille += 1
def obtenir(self, cle):
bucket = self._buckets[self._index(cle)]
for k, v in bucket:
if k == cle:
return v
raise KeyError(cle)
def supprimer(self, cle) -> None:
bucket = self._buckets[self._index(cle)]
for i, (k, _) in enumerate(bucket):
if k == cle:
del bucket[i]
self._taille -= 1
return
raise KeyError(cle)
def _redimensionner(self) -> None:
anciens_buckets = self._buckets
self._capacite *= 2
self._buckets = [[] for _ in range(self._capacite)]
self._taille = 0
for bucket in anciens_buckets:
for cle, valeur in bucket:
self.inserer(cle, valeur) # ré-hache tout dans la nouvelle capacité
def __len__(self) -> int:
return self._taille
t = TableHachageChainage()
t.inserer("a", 1); t.inserer("b", 2)
assert t.obtenir("a") == 1
# --- Adressage ouvert (open addressing) : sondage linéaire, sans listes par bucket ---
class TableHachageSondageLineaire:
_VIDE = object()
_SUPPRIME = object()
def __init__(self, capacite: int = 16):
self._capacite = capacite
self._cles = [self._VIDE] * capacite
self._valeurs = [None] * capacite
self._taille = 0
def inserer(self, cle, valeur) -> None:
index = hash(cle) % self._capacite
while self._cles[index] not in (self._VIDE, self._SUPPRIME):
if self._cles[index] == cle:
self._valeurs[index] = valeur
return
index = (index + 1) % self._capacite # sonde la case suivante en cas de collision
self._cles[index] = cle
self._valeurs[index] = valeur
self._taille += 1
def obtenir(self, cle):
index = hash(cle) % self._capacite
depart = index
while self._cles[index] is not self._VIDE:
if self._cles[index] == cle:
return self._valeurs[index]
index = (index + 1) % self._capacite
if index == depart: # a fait le tour complet sans trouver la clé
break
raise KeyError(cle)
# Application classique : détecter les doublons / anagrammes avec un set/dict
def groupes_anagrammes(mots: list[str]) -> list[list[str]]:
# O(n * k log k) où k = longueur moyenne des mots (tri de chaque mot comme clé de hachage)
groupes: dict[str, list[str]] = {}
for mot in mots:
cle = "".join(sorted(mot))
groupes.setdefault(cle, []).append(mot)
return list(groupes.values())
assert sorted(groupes_anagrammes(["eat", "tea", "tan", "ate", "nat", "bat"]), key=len) == \
sorted([["bat"], ["eat", "tea", "ate"], ["tan", "nat"]], key=len)Résumé
- Une table de hachage offre O(1) en moyenne pour insertion/lecture/suppression, O(n) au pire cas (toutes les clés en collision).
- Le chaînage (liste par bucket) et l'adressage ouvert (sondage linéaire) sont les deux stratégies principales de gestion des collisions.
- Le facteur de charge (taille/capacité) doit rester sous un seuil (souvent 0.7-0.75) : au-delà, on redimensionne et on ré-hache tout.
- Trier les caractères d'un mot comme clé de hachage est un pattern classique pour regrouper des anagrammes.
Exercices pratiques
Mission : diagnostiquer un cache qui redevient lent
Objectif : Comprendre pourquoi une table de hachage se dégrade avec un facteur de charge élevé, puis proposer une correction.
Contexte
Le cache de résultats coûteux d'une API, basé sur une TableHachageChainage maison de capacité fixe (jamais redimensionnée par erreur de configuration), servait des dizaines de milliers de requêtes par seconde en O(1). Depuis que le nombre d'entrées en cache a été multiplié par dix sans jamais toucher à la capacité de la table, les temps de réponse se dégradent nettement.
Tu dois expliquer précisément pourquoi, puis vérifier la correction attendue.