Retour au cours

backend / algorithmes-structures-donnees

Structures avancées : le Trie

Leçon 211 exercice

Explication

Ce que vous allez apprendre

  • Expliquer pourquoi un Trie évite de parcourir toute une liste de mots pour l'autocomplétion
  • Construire un Trie où chaque noeud représente une lettre et chaque chemin un préfixe partagé
  • Utiliser le drapeau "fin de mot" pour distinguer un préfixe valide d'un mot réellement enregistré
  • Justifier pourquoi recherche et insertion dans un Trie sont en O(longueur du mot), indépendamment du nombre total de mots stockés
  • Comparer le coût mémoire d'un Trie à celui d'un simple ensemble de chaînes (hash set)

Dans quel contexte ?

Un moteur de recherche interne d'une bibliothèque numérique doit proposer des suggestions de titres dès que l'utilisateur tape les trois premières lettres, parmi 2 millions de titres de livres. Avec un simple tableau de chaînes, il faudrait comparer le préfixe tapé à chacun des 2 millions de titres à chaque frappe. Avec un Trie, la recherche des suggestions ne dépend que de la longueur du préfixe tapé (quelques caractères), totalement indépendante du nombre de titres stockés : c'est instantané, même à 2 millions d'entrées.

D'abord, un problème concret : l'autocomplétion

Imagine un moteur d'autocomplétion : l'utilisateur tape "cha" et il faut proposer instantanément "chat", "chameau", "chaise", parmi peut-être des millions de mots stockés. Parcourir toute la liste à chaque frappe serait beaucoup trop lent.

Prérequis

Le Trie s'appuie sur les mêmes idées d'arbre que les leçons 9 à 11, mais chaque noeud a potentiellement plusieurs enfants (un par lettre de l'alphabet), pas seulement deux comme un arbre binaire.

L'intuition qui résout ce problème

Le Trie (de "retrieval") construit un arbre où chaque nœud représente une lettre, plutôt que de stocker chaque mot séparément.

Comment cet arbre est organisé

Deux mots qui commencent pareil, comme "chat" et "chameau", partagent le même chemin depuis la racine jusqu'à l'endroit où ils divergent réellement.

StructureRecherche d'un préfixeMémoireCas d'usage
TrieO(longueur du préfixe)Plus élevée (un noeud par lettre)Autocomplétion, correcteur orthographique
Hash set de chaînesO(n) pour lister les mots à ce préfixeCompacteTest d'appartenance exact uniquement

Un détail indispensable : marquer la fin d'un mot

Un nœud marqué "fin de mot" indique qu'un mot complet se termine exactement à cet endroit, ce qui permet de distinguer "cha" (un simple préfixe présent) de "chat" (un mot réellement enregistré).

Le piège à comprendre sur ce point

Un chemin qui existe dans l'arbre ne signifie pas qu'un mot y est stocké : "cha" peut être un préfixe valide de plusieurs mots sans être lui-même un mot enregistré. Sans le drapeau "fin de mot", impossible de faire cette distinction.

Piège fréquent

Confondre "ce préfixe existe dans le Trie" avec "ce mot est enregistré" est l'erreur la plus fréquente : vérifiez toujours le drapeau de fin de mot avant d'affirmer qu'un mot complet a été trouvé.

Pourquoi cette structure est si rapide

La recherche, l'insertion et le test de préfixe ne dépendent que de la longueur du mot recherché, jamais du nombre total de mots stockés. Chercher "chat" prend le même temps, que le Trie contienne 10 mots ou 10 millions.

Vers la suite

Le Trie accélère un type précis de requête au prix d'un peu de mémoire supplémentaire. La prochaine leçon présente une autre structure spécialisée dans ce même esprit, Union-Find, conçue cette fois pour suivre des groupes qui fusionnent au fil du temps.

Commandes & code

Structures avancées : le Trie (arbre préfixe)

python
# Un Trie stocke des chaînes de caractères en partageant les préfixes communs -- très efficace
# pour l'autocomplétion, la vérification orthographique, le routage IP (préfixes CIDR).

class NoeudTrie:
    def __init__(self):
        self.enfants: dict[str, "NoeudTrie"] = {}
        self.fin_de_mot: bool = False

class Trie:
    def __init__(self):
        self.racine = NoeudTrie()

    def inserer(self, mot: str) -> None:
        # O(longueur du mot) -- indépendant du nombre de mots déjà stockés
        noeud = self.racine
        for caractere in mot:
            if caractere not in noeud.enfants:
                noeud.enfants[caractere] = NoeudTrie()
            noeud = noeud.enfants[caractere]
        noeud.fin_de_mot = True

    def rechercher(self, mot: str) -> bool:
        # O(longueur du mot) -- le mot exact doit exister ET se terminer ici
        noeud = self._parcourir_prefixe(mot)
        return noeud is not None and noeud.fin_de_mot

    def commence_par(self, prefixe: str) -> bool:
        # O(longueur du préfixe) -- utile pour l'autocomplétion
        return self._parcourir_prefixe(prefixe) is not None

    def _parcourir_prefixe(self, prefixe: str) -> NoeudTrie | None:
        noeud = self.racine
        for caractere in prefixe:
            if caractere not in noeud.enfants:
                return None
            noeud = noeud.enfants[caractere]
        return noeud

    def mots_avec_prefixe(self, prefixe: str) -> list[str]:
        # Retourne tous les mots stockés qui commencent par ce préfixe
        noeud = self._parcourir_prefixe(prefixe)
        if noeud is None:
            return []
        resultats = []
        self._collecter_mots(noeud, prefixe, resultats)
        return resultats

    def _collecter_mots(self, noeud: NoeudTrie, prefixe_courant: str, resultats: list) -> None:
        if noeud.fin_de_mot:
            resultats.append(prefixe_courant)
        for caractere, enfant in noeud.enfants.items():
            self._collecter_mots(enfant, prefixe_courant + caractere, resultats)

    def supprimer(self, mot: str) -> bool:
        def _supprimer_recursif(noeud: NoeudTrie, mot: str, profondeur: int) -> bool:
            if profondeur == len(mot):
                if not noeud.fin_de_mot:
                    return False
                noeud.fin_de_mot = False
                return len(noeud.enfants) == 0   # True si le noeud peut être élagué
            caractere = mot[profondeur]
            if caractere not in noeud.enfants:
                return False
            doit_elaguer = _supprimer_recursif(noeud.enfants[caractere], mot, profondeur + 1)
            if doit_elaguer:
                del noeud.enfants[caractere]
                return len(noeud.enfants) == 0 and not noeud.fin_de_mot
            return False
        return _supprimer_recursif(self.racine, mot, 0)

trie = Trie()
for mot in ["chat", "chien", "chameau", "chaise"]:
    trie.inserer(mot)

assert trie.rechercher("chat") is True
assert trie.rechercher("cha") is False        # préfixe mais pas un mot complet
assert trie.commence_par("cha") is True
assert sorted(trie.mots_avec_prefixe("cha")) == ["chaise", "chameau", "chat"]

# --- Application : autocomplétion avec limite de suggestions ---
def suggerer(trie: Trie, prefixe: str, limite: int = 5) -> list[str]:
    return trie.mots_avec_prefixe(prefixe)[:limite]

# --- Application : compter les mots ayant un préfixe donné (variante avec compteur par noeud) ---
class NoeudTrieAvecCompteur:
    def __init__(self):
        self.enfants: dict[str, "NoeudTrieAvecCompteur"] = {}
        self.compteur_prefixe = 0   # nombre de mots passant par ce noeud
        self.fin_de_mot = False

class TrieAvecCompteur:
    def __init__(self):
        self.racine = NoeudTrieAvecCompteur()

    def inserer(self, mot: str) -> None:
        noeud = self.racine
        for c in mot:
            if c not in noeud.enfants:
                noeud.enfants[c] = NoeudTrieAvecCompteur()
            noeud = noeud.enfants[c]
            noeud.compteur_prefixe += 1
        noeud.fin_de_mot = True

    def compter_avec_prefixe(self, prefixe: str) -> int:
        # O(longueur du préfixe) -- pas besoin de parcourir tous les mots stockés
        noeud = self.racine
        for c in prefixe:
            if c not in noeud.enfants:
                return 0
            noeud = noeud.enfants[c]
        return noeud.compteur_prefixe

Résumé

  • Un Trie permet insertion et recherche en O(longueur du mot), indépendamment du nombre total de mots stockés.
  • Les préfixes communs sont partagés en mémoire : très économe pour un grand dictionnaire de mots proches.
  • commence_par/autocomplétion s'exécutent en O(longueur du préfixe), bien plus rapide qu'un filtrage linéaire sur une liste de mots.
  • Un compteur par noeud permet de répondre en O(longueur du préfixe) à "combien de mots ont ce préfixe" sans énumération.

Exercices pratiques

1 disponible
1

Mission : réparer l'autocomplétion qui confond préfixe et mot complet

Objectif : Diagnostiquer une confusion préfixe/mot dans un Trie et raisonner sur le coût comparé à une recherche linéaire.

Contexte

Le moteur d'autocomplétion d'une bibliothèque numérique utilise un Trie contenant les titres "chat", "chien", "chameau", "chaise". Un utilisateur signale un bug : quand il tape exactement "cha" et valide, l'application affiche "Livre trouvé" alors qu'aucun livre ne s'appelle "cha".

Résoudre l’exercice →