Retour au cours

backend / algorithmes-structures-donnees

Tables de hachage et gestion des collisions

Leçon 81 exercice

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 dict de 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égieEn cas de collisionMémoireSensibilité au remplissage
ChaînageAjout dans une liste à la même caseSupplémentaire par caseDégradation progressive
Adressage ouvertRecherche de la prochaine case libreCompacte, sans structure annexeDé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

python
# 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

1 disponible
1

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.

Résoudre l’exercice →