backend / algorithmes-structures-donnees
Structures avancées : le Trie
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.
| Structure | Recherche d'un préfixe | Mémoire | Cas d'usage |
|---|---|---|---|
| Trie | O(longueur du préfixe) | Plus élevée (un noeud par lettre) | Autocomplétion, correcteur orthographique |
| Hash set de chaînes | O(n) pour lister les mots à ce préfixe | Compacte | Test 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)
# 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_prefixeRé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
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".