Fiche 0

Fiches d'algorithmes pour débutants — version fusionnée et corrigée

Bases : Grokking Algorithms (1re éd., 2016) et Introduction to Algorithms (CLRS, 3e éd., 2009), complétées par les mises à jour connues au 10 novembre 2026.

Sommaire

  1. Ce fichier : mises à jour, corrections Python 2 → 3, plan de révision, sources
  2. Grand O
  3. Recherche dichotomique
  4. Mémoire, tableaux et listes chaînées
  5. Récursivité et pile d'appels
  6. Diviser pour régner et tris
  7. Tables de hachage
  8. Graphes et parcours en largeur (BFS)
  9. Plus courts chemins : Dijkstra et Bellman-Ford
  10. Algorithmes gloutons et problèmes NP
  11. Programmation dynamique
  12. k plus proches voisins et machine learning
  13. Arbres
  14. Pour aller plus loin (Grokking ch. 11 et CLRS)
  15. Aide-mémoire final

Chaque fiche suit le même plan : idée en une phrase, explication, code Python 3 testé, complexité, pièges, exercices corrigés, renvoi aux livres.

Conventions : n = nombre d'éléments, V = sommets, E = arêtes. Les complexités sont écrites en texte simple (O(n log n)) pour s'afficher partout, sans LaTeX. Les numéros de chapitres CLRS suivent la 3e édition, sauf mention contraire.

Ce qui a changé depuis tes deux livres

Grokking Algorithms, 2e édition (mars 2024)

  • Code passé en Python 3 (ton PDF est en Python 2.7).
  • Nouveaux chapitres sur les arbres : arbres binaires de recherche, arbres équilibrés, B-arbres.
  • Couverture des problèmes NP-complets étoffée.
  • Discussion des performances qui tient compte des processeurs modernes.
  • Plan : 1 Introduction, 2 Tri par sélection, 3 Récursivité, 4 Diviser pour régner (quicksort), 5 Tables de hachage, 6 Parcours en largeur, 7 Arbres, 8 Arbres équilibrés, 9 Dijkstra, 10 Glouton, 11 Programmation dynamique, 12 k plus proches voisins, 13 Pour aller plus loin.

CLRS, 4e édition (avril 2022)

  • Nouveaux chapitres : appariements dans les graphes bipartis, algorithmes en ligne, algorithmes de machine learning.
  • Le chapitre de machine learning (ch. 33 de la 4e éd.) traite le regroupement k-means, les pondérations multiplicatives et la descente de gradient.
  • Nouveau contenu sur : résolution des récurrences, tables de hachage, fonctions de potentiel (analyse amortie), tableaux de suffixes.
  • Retirés de la 4e édition : tas de Fibonacci, arbres de van Emde Boas, géométrie algorithmique (toujours dans la 3e).
  • Implémentations en Python de tous les algorithmes, sur le site de MIT Press ; illustrations en couleur ; 140 nouveaux exercices et 22 nouveaux problèmes.

Recherche récente

  • Plus courts chemins (2025) : Duan, Mao, Mao, Shu et Yin (prix du meilleur article, STOC 2025) ont présenté un algorithme déterministe en O(m log^(2/3) n) pour les graphes orientés à poids positifs ou nuls. Il bat le O(m + n log n) de Dijkstra sur les graphes peu denses, dans le modèle « comparaison-addition ». C'est un résultat théorique : Dijkstra reste l'algorithme à apprendre et à utiliser.

Ce qui n'a pas changé

Grand O, dichotomie, tris, hachage, BFS, Dijkstra, programmation dynamique : les bases restent valables.

Corriger le code de ton PDF Grokking (Python 2 → 3)

  • print x devient print(x).
  • mid = (low + high) / 2 devient mid = (low + high) // 2 (en Python 3, / donne un décimal : erreur d'indice).
  • raw_input() devient input().
  • Depuis Python 3.7, un dict conserve l'ordre d'insertion.

Plan de révision en 4 semaines

  1. Semaine 1 : fiches 1 à 4. Recoder dichotomie, liste chaînée, récursivité.
  2. Semaine 2 : fiches 5 à 7. Recoder tri fusion, quicksort, BFS avec chemin.
  3. Semaine 3 : fiches 8 à 10. Remplir à la main la grille du sac à dos.
  4. Semaine 4 : fiches 11 à 14, puis refaire tous les exercices sans regarder.

Sources

Corrections par rapport aux versions précédentes

Cette version corrige les erreurs repérées lors de la revue : exemple de planification de la fiche gloutons, réponses « nombre d'étapes » de la dichotomie (formule exacte : floor(log2 n) + 1), mémoire du tri rapide, distinction NP-difficile / NP-complet, BFS qui reconstruit le chemin, KeyError sur les graphes, récursivité sur liste vide, titre « plus longue sous-chaîne ».

Revue du 10 novembre 2026 : tout le code a été réexécuté (Python 3.15) avec des tests ; corrections sur la hauteur d'un arbre (niveaux contre arêtes), le nombre de comparaisons du tri par sélection, l'usage de bisect_left, la définition du tas, un exemple de graphe pour Dijkstra, la limite de pile de lru_cache, et le corrigé de l'exercice compter (sans copie de liste).

Retour au sommaire

Fiche 1

Le Grand O

Grokking ch. 1 · CLRS ch. 1 à 3

L'idée en une phrase

Le Grand O décrit comment le nombre d'opérations (ou la mémoire) d'un algorithme grandit quand la taille des données grandit. Ce n'est pas un temps en secondes.

Explication

Pour chercher un nom dans une liste de n éléments :

  • Recherche simple : on regarde les éléments un par un, jusqu'à n essais → O(n).
  • Recherche dichotomique (liste triée) : on élimine la moitié à chaque étape → O(log n).

Sur 100 éléments la différence est minime ; sur 1 milliard, c'est 1 milliard d'étapes contre une trentaine.

Définition (un peu plus formelle)

T(n) = O(f(n)) signifie qu'à partir d'une certaine taille, T(n) ne dépasse pas un multiple constant de f(n).

  • On ignore les constantes : 3n, 100n et n sont tous O(n).
  • On ignore les termes secondaires : n² + 100n + 7 est O(n²).
  • Exemple : 2n² + 500n + 10 opérations → O(n²).

Les complexités courantes (du plus rapide au plus lent)

NotationNomExemple
O(1)constantelire liste[3]
O(log n)logarithmiquerecherche dichotomique
O(n)linéaireparcourir une liste
O(n log n)quasi-linéairetri fusion, quicksort (moyenne)
O(n²)quadratiquedouble boucle imbriquée, tri par sélection
O(2^n)exponentielleessayer tous les sous-ensembles
O(n!)factorielletous les ordres possibles (10 villes : 3 628 800)

Les logarithmes simplement

log2 n répond à : « combien de fois diviser par 2 pour arriver à 1 ? ». Ex. 8 → 4 → 2 → 1 : log2 8 = 3.

nlog2 n
83
1287
1 02410
1 000 000environ 20
1 000 000 000environ 30

Précision utile : le nombre maximal d'étapes de la dichotomie sur n éléments est floor(log2 n) + 1. Ex. : 100 → 7 ; 128 → 8 ; 1 024 → 11. Dans le Grand O, cette différence de 1 disparaît.

Pire cas, meilleur cas, cas moyen

  • Pire cas : la situation la plus défavorable (c'est celle que le Grand O décrit par défaut).
  • Meilleur cas : la plus favorable (trouver tout de suite).
  • Cas moyen : le comportement typique.

Trois notations (CLRS ch. 3)

  • O : borne supérieure (« au plus »).
  • Ω (oméga) : borne inférieure (« au moins »).
  • Θ (thêta) : borne serrée (les deux à la fois).

Code : repérer la complexité

Python
def afficher(liste):            # O(n) : une boucle
    for element in liste:
        print(element)

def comparer_paires(liste):     # O(n²) : deux boucles imbriquées
    for a in liste:
        for b in liste:
            print(a, b)

premier = [10, 20, 30][0]       # O(1) : accès direct

Méthode

  1. Repérer la taille de l'entrée. 2. Compter les boucles. 3. Repérer les boucles imbriquées. 4. Garder le terme dominant. 5. Ignorer les constantes. 6. Préciser le cas étudié.

Pièges

  • Croire que O(n) veut dire « n secondes ».
  • Croire que O(1) veut dire « rapide » : c'est « indépendant de n ».
  • Confondre log n (très lent à grandir) et n log n.
  • Oublier la mémoire, qui a aussi un Grand O.
  • Oublier que la constante compte parfois (c'est pourquoi quicksort bat souvent le tri fusion).

Exercices

  1. Afficher le premier élément d'une liste.
  2. Afficher tous les éléments.
  3. Trouver le plus grand élément d'une liste non triée.
  4. Chercher dans une liste triée par dichotomie.
  5. Comparer chaque élément à tous les autres.
  6. Générer tous les ordres possibles de 10 villes.
  7. Simplifier O(2n + 100).

Réponses : 1. O(1) · 2. O(n) · 3. O(n) · 4. O(log n) · 5. O(n²) · 6. O(n!) · 7. O(n).

À retenir

Le Grand O mesure la croissance, pas des secondes ; O(log n) est excellent, O(n) raisonnable, O(n²) vite pénible, O(n!) inutilisable dès 15 éléments environ.

Retour au sommaire

Fiche 2

La recherche dichotomique

Grokking ch. 1 · CLRS ch. 2 (exercices sur la recherche binaire)

L'idée en une phrase

Dans une liste triée, on regarde l'élément du milieu et on élimine la moitié qui ne peut pas contenir la cible, puis on recommence.

Principe

  1. Regarder le milieu. 2. Si c'est la cible, renvoyer son indice. 3. Si la cible est plus petite, chercher à gauche. 4. Si elle est plus grande, chercher à droite. 5. Répéter jusqu'à trouver ou jusqu'à ce que la zone soit vide.

Exemple : chercher 7 dans [1, 3, 5, 7, 9] : milieu = 5 (< 7) → zone [7, 9] → milieu = 7 : trouvé à l'indice 3.

Code Python 3

Python
def recherche_dichotomique(liste, cible):
    bas, haut = 0, len(liste) - 1
    while bas <= haut:
        milieu = (bas + haut) // 2      # // et non / en Python 3
        valeur = liste[milieu]
        if valeur == cible:
            return milieu
        elif valeur < cible:
            bas = milieu + 1
        else:
            haut = milieu - 1
    return None

En pratique, le module standard bisect fait ce travail. Attention : bisect_left renvoie une position d'insertion, pas « trouvé / pas trouvé » ; il faut vérifier :

Python
from bisect import bisect_left

def chercher(liste, cible):
    i = bisect_left(liste, cible)
    return i if i < len(liste) and liste[i] == cible else None

Complexité

CasComplexité
MeilleurO(1)
Pire / moyenO(log n)
MémoireO(1)

Nombre maximal d'étapes : floor(log2 n) + 1.

Pièges

  • Liste non triée : le résultat est faux.
  • Oublier de mettre à jour bas ou haut (boucle infinie).
  • Utiliser / au lieu de //.
  • Confondre indice et valeur.

Exercices

  1. Chercher 9 dans [1, 3, 5, 7, 9].
  2. Chercher 4 dans la même liste.
  3. Nombre maximal d'étapes pour 128 éléments ?
  4. Pour 256 ? Pour 1 024 ?

Réponses : 1. indice 4 · 2. None · 3. 8 · 4. 9 et 11.

Retour au sommaire

Fiche 3

Mémoire, tableaux et listes chaînées

Grokking ch. 2 · CLRS ch. 10

L'idée en une phrase

La façon de ranger les données en mémoire décide de ce qui est rapide ou lent.

Explication

La mémoire est une armoire à tiroirs numérotés.

  • Tableau : éléments côte à côte. L'adresse du i-ème se calcule directement : lecture instantanée. Insérer au milieu oblige à décaler les suivants. Tous les éléments ont le même type.
  • Liste chaînée : éléments dispersés, chacun contient l'adresse du suivant. Insérer ou supprimer = changer un lien. Pour atteindre le 100e, il faut passer par les 99 premiers.

Image de Grokking : un tableau, c'est réserver des places côte à côte au cinéma (impossible d'ajouter un ami si la rangée est pleine) ; une liste chaînée, c'est être dispersés dans la salle, chacun sachant où est le suivant.

Terminologie

  • Accès aléatoire : aller directement à l'élément i (tableaux).
  • Accès séquentiel : lire dans l'ordre (listes chaînées).

Code : liste chaînée

Python
class Noeud:
    def __init__(self, valeur):
        self.valeur = valeur
        self.suivant = None

class ListeChainee:
    def __init__(self):
        self.tete = None

    def ajouter_debut(self, valeur):          # O(1)
        nouveau = Noeud(valeur)
        nouveau.suivant = self.tete
        self.tete = nouveau

    def rechercher(self, valeur):             # O(n)
        courant = self.tete
        while courant is not None:
            if courant.valeur == valeur:
                return courant
            courant = courant.suivant
        return None

    def supprimer(self, valeur):              # O(n) pour trouver, O(1) pour délier
        precedent, courant = None, self.tete
        while courant is not None:
            if courant.valeur == valeur:
                if precedent is None:
                    self.tete = courant.suivant
                else:
                    precedent.suivant = courant.suivant
                return True
            precedent, courant = courant, courant.suivant
        return False

    def vers_liste(self):
        resultat, courant = [], self.tete
        while courant is not None:
            resultat.append(courant.valeur)
            courant = courant.suivant
        return resultat

Comparaison

OpérationTableauListe chaînée
Accès à l'élément iO(1)O(n)
Recherche d'une valeurO(n)O(n)
Insertion au débutO(n)O(1)
Suppression au débutO(n)O(1)
Insertion / suppression au milieuO(n)O(1) si le nœud précédent est déjà connu

Quand choisir quoi ?

  • Accès fréquent par position, ou dichotomie → tableau.
  • Beaucoup d'insertions / suppressions en tête → liste chaînée.
  • File d'attente : en Python, collections.deque.

Pour aller plus loin

  • CLRS ch. 10 : piles, files, listes simplement, doublement et circulairement chaînées.
  • Le list de Python est un tableau dynamique : ajouter à la fin coûte O(1) en moyenne (analyse amortie, CLRS ch. 17), insérer au début coûte O(n).
  • Sur les machines actuelles, les tableaux profitent mieux du cache du processeur : un point que Grokking 2e éd. développe.

Pièges

  • Croire qu'une liste chaînée est « toujours meilleure pour insérer » : il faut d'abord trouver la position (O(n)).
  • Oublier que les indices commencent à 0 en Python (CLRS, lui, commence à 1 dans son pseudo-code).

Exercices

  1. Quelle structure pour lire souvent des éléments au hasard ?
  2. Combien de comparaisons fait le tri par sélection sur 1 000 éléments (ordre de grandeur) ? (voir fiche 5)
  3. Écrire ajouter_debut puis vers_liste pour obtenir [3, 2, 1] après avoir ajouté 1, 2, 3.

Réponses : 1. tableau · 2. environ 500 000 · 3. l = ListeChainee(); [l.ajouter_debut(x) for x in (1,2,3)]; l.vers_liste() → [3, 2, 1].

Retour au sommaire

Fiche 4

Récursivité et pile d'appels

Grokking ch. 3 · CLRS ch. 2 et 4

L'idée en une phrase

Une fonction s'appelle elle-même sur un problème plus petit, jusqu'à atteindre un cas trivial.

Explication

Pour savoir si une boîte contient une clé : on l'ouvre ; si elle contient d'autres boîtes, on applique la même méthode à chacune.

Toute fonction récursive a deux parties :

  1. Cas de base : cas si simple qu'on répond directement (la récursion s'arrête).
  2. Cas récursif : on se ramène à un problème plus petit.
Python
def factorielle(n):
    if n <= 1:                       # cas de base
        return 1
    return n * factorielle(n - 1)    # cas récursif

def somme(liste):
    if not liste:                    # cas de base : liste vide
        return 0
    return liste[0] + somme(liste[1:])

Déroulé de somme([2, 4, 6]) : 2 + (4 + (6 + somme([]))) = 12.

La pile d'appels

  • Une pile est une structure LIFO (dernier entré, premier sorti) avec deux opérations : push (empiler) et pop (dépiler).
  • Chaque appel de fonction empile un cadre (variables locales, point de retour). Quand l'appel se termine, son cadre est dépilé.
  • Avec la récursivité, la pile peut devenir très haute : coût mémoire, et erreur RecursionError si on dépasse la limite (1 000 par défaut dans CPython).
  • Python n'optimise pas la récursion terminale.

Version sans copies (meilleure pratique)

liste[1:] copie la liste à chaque appel : somme devient O(n²). Avec des indices, on reste en O(n) :

Python
def somme_indices(liste, i=0):
    if i == len(liste):
        return 0
    return liste[i] + somme_indices(liste, i + 1)

def maximum(liste, i=0):
    if not liste:
        raise ValueError("liste vide")
    if i == len(liste) - 1:
        return liste[i]
    reste = maximum(liste, i + 1)
    return liste[i] if liste[i] > reste else reste

Récursivité ou boucle ?

SituationSouvent préférable
Parcourir une liste simpleboucle
Parcourir un arbre ou des dossiers imbriquésrécursivité
Sous-problèmes identiques plus petitsrécursivité
Très grande profondeurboucle

Pour aller plus loin (CLRS ch. 4)

Pour analyser un algorithme récursif, on écrit une récurrence : T(n) = 2T(n/2) + n (tri fusion) donne Θ(n log n). Le théorème maître résout T(n) = aT(n/b) + f(n). La 4e édition a retravaillé cette partie.

Pièges

  • Oublier le cas de base (récursion infinie).
  • Un cas récursif qui ne se rapproche jamais du cas de base.
  • Copier des listes à chaque appel sans y penser.
  • Ignorer la mémoire de la pile.

Exercices

  1. Que vaut factorielle(4) et combien de cadres au maximum sur la pile ?
  2. Écrire une fonction récursive qui compte les éléments d'une liste.
  3. Pourquoi une fonction sans cas de base échoue-t-elle ?

Réponses : 1. 24 ; 4 cadres · 2. def compter(l, i=0): return 0 if i == len(l) else 1 + compter(l, i + 1) (avec des indices, sans copier la liste) · 3. elle s'appelle sans fin jusqu'au débordement de la pile (RecursionError).

Retour au sommaire

Fiche 5

Diviser pour régner et algorithmes de tri

Grokking ch. 2 et 4 · CLRS ch. 2, 4, 6, 7, 8

L'idée en une phrase

Diviser pour régner : on découpe un problème en morceaux plus petits du même type, on les résout, puis on recombine. Trier, c'est réorganiser des éléments selon un ordre.

Diviser pour régner (Grokking ch. 4)

  1. Trouver le cas de base (très simple : liste vide ou à un élément).
  2. Réduire le problème jusqu'à l'atteindre.

Exemples : partager un terrain en carrés égaux les plus grands possibles (algorithme d'Euclide) ; additionner une liste = premier élément + somme du reste.

Comparaison des tris

TriMeilleurMoyenPireMémoire en plus
SélectionO(n²)O(n²)O(n²)O(1)
InsertionO(n)O(n²)O(n²)O(1)
FusionO(n log n)O(n log n)O(n log n)O(n)
Quicksort (en place, pivot aléatoire)O(n log n)O(n log n)O(n²)O(log n)
Quicksort (version « listes » ci-dessous)O(n log n)O(n log n)O(n²)O(n)
Heapsort (CLRS ch. 6)O(n log n)O(n log n)O(n log n)O(1)

Tri par sélection

On cherche le plus petit élément restant et on le place en tête.

Python
def tri_selection(liste):
    for i in range(len(liste) - 1):
        indice_min = i
        for j in range(i + 1, len(liste)):
            if liste[j] < liste[indice_min]:
                indice_min = j
        liste[i], liste[indice_min] = liste[indice_min], liste[i]
    return liste

Coût : (n-1) + (n-2) + ... + 1 = n(n-1)/2 comparaisons, donc O(n²).

Tri par insertion

On insère chaque élément à sa place dans la partie déjà triée. Très rapide sur de petites listes ou des listes presque triées.

Python
def tri_insertion(liste):
    for i in range(1, len(liste)):
        cle = liste[i]
        j = i - 1
        while j >= 0 and liste[j] > cle:
            liste[j + 1] = liste[j]
            j -= 1
        liste[j + 1] = cle
    return liste

Tri fusion

On coupe en deux, on trie chaque moitié, on fusionne. Toujours O(n log n).

Python
def tri_fusion(liste):
    if len(liste) <= 1:
        return liste
    milieu = len(liste) // 2
    return fusionner(tri_fusion(liste[:milieu]), tri_fusion(liste[milieu:]))

def fusionner(gauche, droite):
    resultat, i, j = [], 0, 0
    while i < len(gauche) and j < len(droite):
        if gauche[i] <= droite[j]:
            resultat.append(gauche[i]); i += 1
        else:
            resultat.append(droite[j]); j += 1
    resultat.extend(gauche[i:])
    resultat.extend(droite[j:])
    return resultat

Quicksort (tri rapide)

  1. Choisir un pivot. 2. Partitionner : plus petits à gauche, plus grands à droite. 3. Trier les deux côtés récursivement.

Version « listes » (lisible, mais O(n) de mémoire) :

Python
import random

def tri_rapide(liste):
    if len(liste) <= 1:
        return liste
    pivot = random.choice(liste)            # pivot aléatoire
    petits = [x for x in liste if x < pivot]
    egaux = [x for x in liste if x == pivot]
    grands = [x for x in liste if x > pivot]
    return tri_rapide(petits) + egaux + tri_rapide(grands)

Version en place (O(log n) de pile) : on récurse sur le plus petit côté et on boucle sur l'autre.

Python
import random

def tri_rapide_en_place(a, bas=0, haut=None):
    if haut is None:
        haut = len(a) - 1
    while bas < haut:
        p = random.randint(bas, haut)
        a[p], a[haut] = a[haut], a[p]
        pivot, i = a[haut], bas
        for j in range(bas, haut):
            if a[j] < pivot:
                a[i], a[j] = a[j], a[i]
                i += 1
        a[i], a[haut] = a[haut], a[i]
        if i - bas < haut - i:
            tri_rapide_en_place(a, bas, i - 1)
            bas = i + 1
        else:
            tri_rapide_en_place(a, i + 1, haut)
            haut = i - 1
    return a

Pourquoi un pivot aléatoire ?

Avec toujours le premier élément comme pivot, une liste déjà triée donne des partitions déséquilibrées (0 et n-1 éléments) : O(n²). Un pivot aléatoire rend ce cas très improbable : CLRS ch. 7 montre un temps moyen espéré de O(n log n) quelle que soit l'entrée. Attention : la version en place ci-dessus dégrade en O(n²) si tous les éléments sont égaux ; la version « listes » gère bien les doublons grâce à egaux.

Pourquoi quicksort bat souvent le tri fusion

Les deux sont O(n log n) en moyenne, mais la constante du quicksort est plus petite. Le Grand O ignore les constantes ; elles comptent pourtant.

Pour aller plus loin (CLRS)

  • Borne inférieure (ch. 8) : tout tri par comparaisons demande Ω(n log n).
  • On fait mieux (O(n)) seulement avec d'autres hypothèses : tri par dénombrement, par base (radix), par paquets.
  • Mise à jour pratique : sorted() et list.sort() de Python utilisent Timsort (hybride fusion + insertion, stable), pas quicksort. Dans un vrai projet, utilise-les.
  • Stable = conserve l'ordre des éléments égaux ; en place = peu de mémoire supplémentaire. Ce sont deux notions différentes.

Pièges

  • Pivot mal choisi (liste triée + pivot fixe).
  • Confondre « en place » et « stable ».
  • Croire que le tri par sélection est meilleur au meilleur cas : il reste O(n²).

Exercices

  1. Pourquoi choisir le pivot au hasard ?
  2. Quel tri garantit O(n log n) au pire ?
  3. Quel est le meilleur cas du tri par insertion ?
  4. Trier [5, 3, 6, 2, 10] avec chaque fonction ci-dessus.

Réponses : 1. éviter le pire cas sur des entrées particulières · 2. fusion et heapsort · 3. O(n), liste déjà triée · 4. [2, 3, 5, 6, 10].

Retour au sommaire

Fiche 6

Tables de hachage

Grokking ch. 5 · CLRS ch. 11 (nouveautés dans la 4e éd.)

L'idée en une phrase

Une table de hachage associe une clé à une valeur et retrouve la valeur en temps constant en moyenne.

Explication

Une fonction de hachage transforme une clé (mot, nom…) en un indice de tableau, comme un annuaire où le nom donne directement la page.

Une bonne fonction de hachage :

  • est déterministe (même clé, même indice, toujours) ;
  • répartit les clés uniformément dans le tableau ;
  • associe, autant que possible, des clés différentes à des cases différentes.

Cas d'usage (Grokking)

  1. Associer une clé à une valeur (annuaire).
  2. Éviter les doublons (« ai-je déjà vu cette valeur ? »).
  3. Cache : mémoriser un résultat déjà calculé.
Python
annuaire = {"alice": "alice@exemple.fr", "bob": "bob@exemple.fr"}
annuaire["carole"] = "carole@exemple.fr"

def compter_mots(texte):
    compteur = {}
    for mot in texte.split():
        compteur[mot] = compteur.get(mot, 0) + 1
    return compteur

def sans_doublons(liste):
    vus, resultat = set(), []
    for x in liste:
        if x not in vus:
            vus.add(x)
            resultat.append(x)
    return resultat

cache = {}
def carre_lent(n):                 # exemple de cache
    if n not in cache:
        cache[n] = n * n           # imagine un calcul coûteux
    return cache[n]

Collisions et performance

  • Collision : deux clés tombent dans la même case.
  • Chaînage (Grokking) : chaque case contient une liste de paires clé-valeur.
  • Adressage ouvert : si la case est occupée, on cherche une autre case libre.
  • Facteur de charge = nombre d'éléments / nombre de cases. Grokking conseille de redimensionner quand il dépasse environ 0,7.
OpérationMoyenPire cas
RechercheO(1)O(n)
InsertionO(1)O(n)
SuppressionO(1)O(n)

Le pire cas arrive quand tout s'entasse dans la même case (mauvaise fonction de hachage).

Pour aller plus loin (CLRS ch. 11)

  • Hachage universel : choisir la fonction au hasard dans une famille pour éviter les entrées « piégées ».
  • Hachage parfait : O(1) garanti au pire cas pour un ensemble de clés fixe.
  • Mise à jour : en Python, dict utilise l'adressage ouvert, pas le chaînage de Grokking. Depuis Python 3.7, il garde l'ordre d'insertion.

Pièges

  • Utiliser une clé modifiable : une list n'est pas hachable (utiliser un tuple).
  • Croire que O(1) veut dire « instantané ».
  • Chercher un ordre trié dans un dict : il garde l'ordre d'insertion, pas un ordre de tri.

Exercices

  1. Pourquoi une table de hachage n'est-elle pas triée par clé ?
  2. Que fait-on quand le facteur de charge est trop grand ?
  3. Compter les mots de "a b a c b a".

Réponses : 1. la fonction de hachage disperse les clés · 2. on redimensionne · 3. {'a': 3, 'b': 2, 'c': 1}.

Retour au sommaire

Fiche 7

Graphes et parcours en largeur (BFS)

Grokking ch. 6 · CLRS ch. 22

L'idée en une phrase

Un graphe modélise des relations (sommets + arêtes) ; le BFS trouve le chemin avec le moins d'arêtes dans un graphe sans poids.

Vocabulaire

  • Sommet (nœud) : un objet. Arête : un lien.
  • Orienté : le lien a un sens (A doit de l'argent à B). Non orienté : relation dans les deux sens.
  • File : structure FIFO (premier entré, premier sorti). Pile : LIFO.

Représentation (liste d'adjacence, via un dict)

Python
graphe = {
    "vous": ["alice", "bob", "claire"],
    "alice": ["peggy"],
    "bob": ["peggy", "anuj"],
    "claire": ["thom", "jonny"],
    "peggy": [], "anuj": [], "thom": [], "jonny": [],
}

Principe du BFS

  1. Mettre le départ dans une file. 2. Retirer le premier sommet. 3. Examiner ses voisins. 4. Ajouter à la file ceux pas encore vus. 5. Répéter jusqu'à trouver la cible ou vider la file.

La file garantit qu'on traite d'abord les voisins directs, puis les voisins des voisins : le premier chemin trouvé est donc le plus court en nombre d'arêtes.

Code : BFS qui renvoie le chemin

Python
from collections import deque

def plus_court_chemin(graphe, depart, cible):
    parents = {depart: None}
    file = deque([depart])
    while file:
        sommet = file.popleft()
        if sommet == cible:
            chemin = []
            while sommet is not None:
                chemin.append(sommet)
                sommet = parents[sommet]
            return chemin[::-1]
        for voisin in graphe.get(sommet, []):     # .get : pas de KeyError
            if voisin not in parents:
                parents[voisin] = sommet
                file.append(voisin)
    return None

parents joue aussi le rôle de l'ensemble des sommets déjà vus.

Complexité

O(V + E) : chaque sommet et chaque arête sont examinés une fois.

Pour aller plus loin (CLRS ch. 22)

  • CLRS colore les sommets (blanc, gris, noir) et calcule les distances et prédécesseurs.
  • Parcours en profondeur (DFS) : explore à fond avant de revenir (pile ou récursivité). Il sert au tri topologique (ordonner des tâches avec dépendances) et aux composantes fortement connexes.
  • Liste d'adjacence (graphes peu denses) ou matrice d'adjacence (graphes denses).

Pièges

  • Oublier de marquer les sommets déjà vus : boucle infinie si le graphe a un cycle.
  • Utiliser une pile à la place de la file : on obtient un DFS, pas le plus court chemin.
  • Utiliser BFS avec des poids : il faut Dijkstra (fiche 8).

Exercices

  1. Pile ou file pour BFS ?
  2. Coût de BFS sur 1 000 sommets et 5 000 arêtes ?
  3. Chemin de "vous" à "peggy" dans le graphe ci-dessus ?

Réponses : 1. file · 2. environ 6 000 opérations (V + E) · 3. ['vous', 'alice', 'peggy'].

Retour au sommaire

Fiche 8

Plus courts chemins : Dijkstra et Bellman-Ford

Grokking ch. 7 (9 dans la 2e éd.) · CLRS ch. 24 et 25

L'idée en une phrase

Dans un graphe dont les arêtes ont un poids (temps, prix, distance), Dijkstra trouve le chemin de coût total minimal si tous les poids sont positifs ou nuls.

Exemple

Schéma
A -> B : 5 minutes
A -> C : 2 minutes
C -> B : 1 minute

A -> C -> B coûte 3 minutes, A -> B coûte 5 : Dijkstra choisit A -> C -> B.

Principe

  1. Coût du départ = 0, tous les autres = infini.
  2. Prendre le sommet non traité de coût minimal.
  3. Mettre à jour ses voisins si passer par lui est moins cher (et noter le parent).
  4. Répéter jusqu'à avoir traité tous les sommets accessibles.
  5. Reconstruire le chemin avec les parents.

Grokking utilise trois tables de hachage : le graphe, les coûts et les parents, plus une liste des sommets traités.

Code Python 3

Python
import heapq

def dijkstra(graphe, depart):
    couts = {depart: 0}
    parents = {depart: None}
    tas = [(0, depart)]
    while tas:
        cout, sommet = heapq.heappop(tas)
        if cout > couts.get(sommet, float("inf")):
            continue                              # entrée périmée
        for voisin, poids in graphe.get(sommet, {}).items():
            nouveau = cout + poids
            if nouveau < couts.get(voisin, float("inf")):
                couts[voisin] = nouveau
                parents[voisin] = sommet
                heapq.heappush(tas, (nouveau, voisin))
    return couts, parents

def reconstruire_chemin(parents, cible):
    if cible not in parents:
        return None
    chemin = []
    while cible is not None:
        chemin.append(cible)
        cible = parents[cible]
    return chemin[::-1]

graphe = {"A": {"B": 5, "C": 2}, "C": {"B": 1}}   # sommet -> {voisin: poids}
couts, parents = dijkstra(graphe, "A")
print(couts["B"], reconstruire_chemin(parents, "B"))   # 3 ['A', 'C', 'B']

Règles d'or

  • BFS : sans poids. Dijkstra : poids ≥ 0. Bellman-Ford : poids négatifs permis.
  • Avec un poids négatif, Dijkstra peut se tromper : un sommet déjà « fini » pourrait encore être amélioré.
  • Dans un graphe non orienté, chaque arête est un cycle : sans importance tant que les poids sont positifs.

Bellman-Ford (poids négatifs)

Python
def bellman_ford(sommets, aretes, depart):
    d = {s: float("inf") for s in sommets}
    d[depart] = 0
    for _ in range(len(sommets) - 1):
        for u, v, w in aretes:
            if d[u] + w < d[v]:
                d[v] = d[u] + w
    for u, v, w in aretes:                        # détection de cycle négatif
        if d[u] + w < d[v]:
            raise ValueError("cycle de poids négatif")
    return d

Complexité

AlgorithmeComplexité
Dijkstra, tableau simpleO(V²)
Dijkstra, tas binaire (code ci-dessus)O((V + E) log V)
Dijkstra, tas de Fibonacci (CLRS 3e éd. ch. 19, retiré de la 4e)O(E + V log V)
Bellman-FordO(V · E)
Graphe orienté acyclique, ordre topologiqueO(V + E) (même avec poids négatifs)
Floyd-Warshall, tous les couples (CLRS ch. 25)O(V³)

Mise à jour 2025

Un algorithme déterministe en O(m log^(2/3) n) bat Dijkstra sur les graphes orientés peu denses (Duan et al., STOC 2025). Il n'a pas rendu Dijkstra obsolète : à connaître comme culture générale.

Pièges

  • Poids négatifs avec Dijkstra.
  • Oublier de mémoriser les parents : on a le coût mais pas le chemin.
  • Un sommet inaccessible reste absent de couts (ou à l'infini).

Exercices

  1. BFS ou Dijkstra pour un métro où chaque trajet dure un temps différent ?
  2. Quel algorithme avec une arête de poids -3 ?
  3. Sur l'exemple A, B, C : coût et chemin vers B ?

Réponses : 1. Dijkstra · 2. Bellman-Ford · 3. 3 minutes, ['A', 'C', 'B'].

Retour au sommaire

Fiche 9

Algorithmes gloutons et problèmes NP

Grokking ch. 8 (10 dans la 2e éd.) · CLRS ch. 16, 34 et 35

L'idée en une phrase

Un algorithme glouton choisit à chaque étape l'option qui paraît la meilleure sur le moment, sans jamais revenir en arrière. Simple et rapide, il n'est pas toujours optimal.

Exemple qui marche : planning de réunions

On veut placer le plus de réunions sans chevauchement. Stratégie : prendre celle qui finit le plus tôt, puis la suivante compatible.

RéunionDébutFin
Art9 h 0010 h 00
Anglais9 h 3010 h 30
Maths10 h 0011 h 00
Info10 h 3011 h 30
Musique11 h 0012 h 00

Résultat : Art, Maths, Musique = 3 réunions, ce qui est le maximum ici.

Python
def planning(reunions):
    """reunions : liste de (nom, debut, fin) ; heures en décimal (9.5 = 9 h 30)."""
    choisies, fin_derniere = [], float("-inf")
    for nom, debut, fin in sorted(reunions, key=lambda r: r[2]):
        if debut >= fin_derniere:
            choisies.append(nom)
            fin_derniere = fin
    return choisies

Exemple qui échoue : sac à dos (0/1)

ObjetPoidsValeur
Chaîne hi-fi30 kg3 000 €
Ordinateur20 kg2 000 €
Guitare15 kg1 500 €

Sac de 35 kg. Glouton « le plus cher d'abord » : hi-fi (30 kg), il reste 5 kg, rien d'autre ne rentre : 3 000 €. Mieux : ordinateur + guitare = 35 kg, 3 500 €. Le glouton n'est donc ici qu'une approximation. Pour le sac à dos 0/1, utilise la programmation dynamique (fiche 10). Pour le sac à dos fractionnable, trier par valeur/poids est optimal.

Couverture d'ensemble (Grokking)

Choisir le moins de stations de radio possible pour couvrir tous les États voulus : à chaque tour, prendre la station qui couvre le plus d'États pas encore couverts.

Python
def couverture(etats_voulus, stations):
    restants, choix = set(etats_voulus), []
    while restants:
        meilleure, couverts = None, set()
        for station, etats in stations.items():
            nouveaux = restants & etats
            if len(nouveaux) > len(couverts):
                meilleure, couverts = station, nouveaux
        if meilleure is None:
            raise ValueError("couverture impossible")
        restants -= couverts
        choix.append(meilleure)
    return choix

La solution est proche de l'optimum, pas garantie optimale.

Problèmes NP : vocabulaire précis

  • P : problèmes résolubles en temps polynomial (rapide).
  • NP : problèmes dont une solution proposée se vérifie rapidement.
  • NP-difficile : au moins aussi dur que tous les problèmes de NP (n'est pas forcément dans NP).
  • NP-complet : NP-difficile et dans NP. Aucun algorithme rapide connu ; savoir si P = NP reste une question ouverte (l'un des problèmes du prix du millénaire).
  • Exemples classiques : voyageur de commerce, couverture d'ensemble, couverture par sommets, SAT, sac à dos (version exacte).

Comment les repérer (Grokking)

Rapide avec peu d'éléments, très lent dès qu'on en ajoute ; on te demande « toutes les combinaisons de X » ; ça ressemble au voyageur de commerce ou à la couverture d'ensemble.

Que faire ?

Un algorithme d'approximation : rapide, proche de l'optimum. Un glouton est un bon candidat. Autres options : heuristiques, programmation dynamique sur des cas restreints.

Pour aller plus loin (CLRS)

  • Quand le glouton est-il correct ? Il faut la propriété de choix glouton et la sous-structure optimale (ch. 16). Exemples : sélection d'activités, codage de Huffman, arbres couvrants de poids minimal (Kruskal, Prim, ch. 23).
  • NP-complétude (ch. 34) : réductions polynomiales. Approximation (ch. 35) : par exemple, facteur 2 pour la couverture par sommets.
  • Le chapitre sur les gloutons a été remanié dans la 4e édition.

Pièges

  • Supposer qu'un glouton est optimal sans le démontrer (rendu de monnaie avec des pièces atypiques).
  • Confondre NP-difficile et NP-complet.
  • Chercher un algorithme exact rapide pour un problème NP-complet de grande taille.

Exercices

  1. Le glouton est-il toujours optimal ?
  2. Combien de réunions dans l'exemple ci-dessus ?
  3. Que faire d'un problème NP-complet de grande taille ?

Réponses : 1. non · 2. 3 (Art, Maths, Musique) · 3. approximation ou heuristique.

Retour au sommaire

Fiche 10

Programmation dynamique

Grokking ch. 9 (11 dans la 2e éd.) · CLRS ch. 15

L'idée en une phrase

On résout un problème difficile en le découpant en sous-problèmes dont on mémorise les réponses (dans une grille ou un cache) pour ne jamais les recalculer.

Quand l'utiliser ?

  • Sous-structure optimale : la solution optimale contient des solutions optimales de sous-problèmes.
  • Sous-problèmes qui se chevauchent : on les rencontre plusieurs fois.
  • Les sous-problèmes sont indépendants et discrets : on ne peut pas voler une fraction d'objet (dans ce cas, un glouton suffit).

Le sac à dos (exemple de Grokking)

ObjetPoidsValeur
Guitare1 kg1 500 €
Ordinateur3 kg2 000 €
Chaîne hi-fi4 kg3 000 €

Capacité 4 kg. Meilleur choix : guitare + ordinateur = 3 500 €.

Grille : chaque ligne ajoute un objet, chaque colonne est une capacité (1 kg, 2 kg…). Chaque case = meilleure valeur possible. case = max( case_du_dessus , valeur_objet + case_du_dessus_avec_capacité_restante )

Python
def sac_a_dos(poids, valeurs, capacite):
    n = len(poids)
    table = [[0] * (capacite + 1) for _ in range(n + 1)]
    for i in range(1, n + 1):
        for w in range(capacite + 1):
            table[i][w] = table[i - 1][w]
            if poids[i - 1] <= w:
                table[i][w] = max(table[i][w],
                                  valeurs[i - 1] + table[i - 1][w - poids[i - 1]])
    return table[n][capacite]

Complexité : O(n · W) (n objets, capacité W). C'est dit « pseudo-polynomial » : W est une valeur numérique, pas une taille de données.

Plus longue sous-chaîne commune (lettres consécutives)

Si les lettres correspondent : case = diagonale haut-gauche + 1 ; sinon 0. La réponse est le maximum de la grille.

Python
def plus_longue_sous_chaine(a, b):
    t = [[0] * (len(b) + 1) for _ in range(len(a) + 1)]
    meilleur = 0
    for i in range(1, len(a) + 1):
        for j in range(1, len(b) + 1):
            if a[i - 1] == b[j - 1]:
                t[i][j] = t[i - 1][j - 1] + 1
                meilleur = max(meilleur, t[i][j])
    return meilleur

Plus longue sous-séquence commune (lettres dans l'ordre, pas forcément consécutives)

Si correspondance : diagonale + 1 ; sinon max(case du haut, case de gauche). Utilisée par diff et en génétique.

Python
def plus_longue_sous_sequence(a, b):
    t = [[0] * (len(b) + 1) for _ in range(len(a) + 1)]
    for i in range(1, len(a) + 1):
        for j in range(1, len(b) + 1):
            if a[i - 1] == b[j - 1]:
                t[i][j] = t[i - 1][j - 1] + 1
            else:
                t[i][j] = max(t[i - 1][j], t[i][j - 1])
    return t[len(a)][len(b)]

Exemple : "fish" et "fosh" : sous-chaîne 2 (sh), sous-séquence 3 (fsh).

Deux styles (CLRS ch. 15)

  • Descendant avec mémoïsation : récursif + cache.
  • Ascendant : remplir la grille (comme ci-dessus).
Python
from functools import lru_cache

@lru_cache(maxsize=None)
def fib(n):
    return n if n < 2 else fib(n - 1) + fib(n - 2)   # O(n) au lieu de O(2^n)

Limite : la version récursive reste soumise à la limite de pile de Python (fib(5000) lève RecursionError au premier appel) ; la version ascendante n'a pas ce problème.

Problèmes classiques de CLRS : découpe de barres, produit de matrices en chaîne, plus longue sous-séquence commune, arbres de recherche optimaux.

Pièges

  • Mal définir ce que représente une case.
  • Mauvais ordre de remplissage (une case dépend de cases déjà calculées).
  • Appliquer la programmation dynamique à un problème dont les sous-problèmes dépendent les uns des autres.

Exercices

  1. Pourquoi Fibonacci naïf est-il lent ?
  2. Glouton ou programmation dynamique pour un sac à dos fractionnable ?
  3. sac_a_dos([1, 3, 4], [1500, 2000, 3000], 4) ?
  4. Longueurs pour "fish" / "fosh" ?

Réponses : 1. mêmes sous-problèmes recalculés : O(2^n) · 2. glouton · 3. 3500 · 4. sous-chaîne 2, sous-séquence 3.

Retour au sommaire

Fiche 11

k plus proches voisins et machine learning

Grokking ch. 10 (12 dans la 2e éd.) · CLRS 4e éd. (nouveau chapitre ML)

L'idée en une phrase

Pour classer ou prédire un nouvel élément, on regarde les k exemples connus les plus proches et on s'aligne sur eux.

Principe

  1. Représenter chaque objet par des nombres : les caractéristiques (features). C'est l'extraction de caractéristiques.
  2. Calculer la distance entre le nouvel objet et les exemples connus.
  3. Prendre les k plus proches.
  4. Classification : classe majoritaire. Régression : moyenne des valeurs des voisins.

Distance euclidienne : racine carrée de la somme des carrés des écarts. Grokking mentionne aussi la similarité cosinus, qui compare des directions plutôt que des tailles (utile quand les utilisateurs notent sur des échelles différentes).

Code Python 3

Python
import math
from collections import Counter

def distance(a, b):
    return math.sqrt(sum((x - y) ** 2 for x, y in zip(a, b)))

def knn_classe(donnees, cible, k=3):
    voisins = sorted(donnees, key=lambda e: distance(e["point"], cible))[:k]
    return Counter(e["classe"] for e in voisins).most_common(1)[0][0]

def knn_regression(donnees, cible, k=3):
    voisins = sorted(donnees, key=lambda e: distance(e["point"], cible))[:k]
    return sum(e["valeur"] for e in voisins) / len(voisins)

def normaliser(points):
    """Ramène chaque caractéristique entre 0 et 1 (min-max)."""
    colonnes = list(zip(*points))
    mins = [min(c) for c in colonnes]
    maxs = [max(c) for c in colonnes]
    return [[(x - lo) / (hi - lo) if hi > lo else 0.0
             for x, lo, hi in zip(p, mins, maxs)] for p in points]

donnees = [
    {"point": [3, 104], "classe": "pamplemousse"},
    {"point": [2, 100], "classe": "pamplemousse"},
    {"point": [1, 81], "classe": "orange"},
    {"point": [1, 75], "classe": "orange"},
]
print(knn_classe(donnees, [2, 95], k=3))   # pamplemousse

Choisir de bonnes caractéristiques

  • Elles doivent être liées à ce qu'on veut prédire et sans biais.
  • Normaliser : sinon une unité en kilomètres écrase une unité en grammes.
  • k : trop petit = sensible à un voisin atypique ; trop grand = on lisse trop. On le choisit en testant. Un k impair évite les égalités en classification à deux classes.

Applications citées dans Grokking

OCR (reconnaissance de caractères), filtre anti-spam (méthode bayésienne naïve), prédiction boursière (très difficile : le passé ne garantit pas l'avenir).

Mises à jour

  • Depuis 2016, le machine learning s'est surtout développé avec l'apprentissage profond. L'idée de chercher les plus proches voisins reste très présente sous forme d'embeddings et de recherche de voisins approchés dans les bases de données vectorielles.
  • CLRS 4e éd., chapitre 33 : k-means (regroupement non supervisé), pondérations multiplicatives (décisions en ligne) et descente de gradient (optimisation).

Limites

  • Lent avec beaucoup de données (il faut comparer à tous les exemples).
  • Sensible aux échelles et aux caractéristiques inutiles.
  • Le choix de k est important.

Exercices

  1. Classification ou régression pour prédire un prix ?
  2. Pourquoi normaliser les caractéristiques ?
  3. Quelle classe pour [2, 95] avec les données ci-dessus et k = 3 ?

Réponses : 1. régression · 2. pour qu'aucune ne domine la distance · 3. pamplemousse (2 voisins pamplemousse sur 3).

Retour au sommaire

Fiche 12

Arbres

Grokking 2e éd. ch. 7 et 8 (1re éd. : ch. 11) · CLRS ch. 6, 12, 13, 14, 18

Ton PDF ne traite les arbres que brièvement (chapitre « Where to go next »). Cette fiche comble ce manque, comme le fait la 2e édition.

L'idée en une phrase

Un arbre est une structure hiérarchique de nœuds reliés par des relations parent-enfant ; l'arbre binaire de recherche (ABR) garde ses données ordonnées pour chercher vite.

Vocabulaire

Racine (tout en haut), nœud, enfant, feuille (sans enfant), hauteur (nombre de niveaux du plus long chemin racine → feuille ; c'est ce que compte la fonction hauteur ci-dessous). Attention : CLRS compte plutôt les arêtes, soit un de moins (l'arbre ci-dessous a 3 niveaux, donc une hauteur de 2 au sens de CLRS).

Arbre binaire de recherche

Chaque nœud a au plus deux enfants. Règle : tout ce qui est à gauche est plus petit, tout ce qui est à droite est plus grand.

Schéma
        8
       / \
      3   10
     / \    \
    1   6    14
Python
class Noeud:
    def __init__(self, valeur):
        self.valeur = valeur
        self.gauche = None
        self.droite = None

def inserer(racine, valeur):
    if racine is None:
        return Noeud(valeur)
    if valeur < racine.valeur:
        racine.gauche = inserer(racine.gauche, valeur)
    else:
        racine.droite = inserer(racine.droite, valeur)
    return racine

def rechercher(racine, valeur):
    if racine is None:
        return False
    if valeur == racine.valeur:
        return True
    cote = racine.gauche if valeur < racine.valeur else racine.droite
    return rechercher(cote, valeur)

def parcours_infixe(racine):
    """Renvoie les valeurs triées (gauche, racine, droite)."""
    if racine is None:
        return []
    return parcours_infixe(racine.gauche) + [racine.valeur] + parcours_infixe(racine.droite)

def hauteur(racine):
    if racine is None:
        return 0
    return 1 + max(hauteur(racine.gauche), hauteur(racine.droite))

def supprimer(racine, valeur):
    if racine is None:
        return None
    if valeur < racine.valeur:
        racine.gauche = supprimer(racine.gauche, valeur)
    elif valeur > racine.valeur:
        racine.droite = supprimer(racine.droite, valeur)
    else:
        if racine.gauche is None:
            return racine.droite
        if racine.droite is None:
            return racine.gauche
        successeur = racine.droite              # plus petit de la branche droite
        while successeur.gauche is not None:
            successeur = successeur.gauche
        racine.valeur = successeur.valeur
        racine.droite = supprimer(racine.droite, successeur.valeur)
    return racine

Complexité

Les opérations coûtent O(h), où h est la hauteur.

OpérationArbre équilibréArbre déséquilibré
RechercheO(log n)O(n)
InsertionO(log n)O(n)
SuppressionO(log n)O(n)

Si on insère des valeurs déjà triées, l'arbre dégénère en liste. Avec ce code récursif, un arbre très profond peut aussi provoquer une RecursionError.

Arbres équilibrés

  • Ils se réorganisent (rotations) pour garder h = O(log n). Les plus connus : AVL et rouge-noir (CLRS ch. 13 : hauteur au plus 2 log(n+1)).
  • Arbres augmentés (ch. 14) : on ajoute de l'information dans les nœuds (taille du sous-arbre) pour répondre à « quel est le k-ième plus petit ? ».
  • La bibliothèque standard de Python n'a pas d'arbre de recherche équilibré ; on utilise bisect sur une liste triée ou une bibliothèque tierce.

B-arbres (CLRS ch. 18)

Nœuds larges contenant beaucoup de clés : très peu d'accès disque. Utilisés dans les bases de données et systèmes de fichiers. Sur les machines actuelles, réduire le nombre d'accès lents compte autant que le nombre d'opérations.

Tas (heap, CLRS ch. 6)

Arbre binaire presque complet où chaque parent est inférieur ou égal (tas-min) ou supérieur ou égal (tas-max) à ses enfants. Trouver l'extrême : O(1) ; insérer / retirer : O(log n). Sert à la file de priorité (Dijkstra) et à heapsort.

Python
import heapq

tas = []
for x in (5, 1, 3):
    heapq.heappush(tas, x)
print(heapq.heappop(tas))        # 1

def tri_par_tas(liste):
    tas = list(liste)
    heapq.heapify(tas)           # O(n)
    return [heapq.heappop(tas) for _ in range(len(tas))]

Hachage ou arbre ?

  • Table de hachage : recherche O(1) en moyenne, pas d'ordre de tri.
  • Arbre équilibré : O(log n), ordre conservé (parcours trié, plus proche voisin, intervalles).

Exercices

  1. Pourquoi équilibrer un ABR ?
  2. Pourquoi des B-arbres pour une base de données ?
  3. Insérer 8, 3, 10, 1, 6, 14 puis donner le parcours infixe et la hauteur.

Réponses : 1. garder la hauteur en log n · 2. moins d'accès disque · 3. [1, 3, 6, 8, 10, 14] et hauteur 3 niveaux (2 arêtes au sens de CLRS).

Retour au sommaire

Fiche 13

Pour aller plus loin

Idées citées dans Grokking (ch. 11 de la 1re éd., 13 dans la 2e)

  • Index inversé : pour chaque mot, la liste des pages qui le contiennent (base des moteurs de recherche).
  • Transformée de Fourier : décompose un signal en fréquences (compression audio, JPEG). CLRS ch. 30 : FFT en O(n log n).
  • Algorithmes parallèles et MapReduce : map applique une fonction à chaque élément, reduce les combine ; utile pour répartir le travail sur plusieurs machines.
  • Filtres de Bloom et HyperLogLog : structures approximatives très économes en mémoire (appartenance, comptage d'éléments distincts).
  • Algorithmes SHA : empreintes de fichiers ou de mots de passe. Mise à jour sécurité : SHA-1 est considéré comme cassé (collisions publiques depuis 2017) ; préférer SHA-256 ou SHA-3. Pour stocker des mots de passe, utiliser un algorithme volontairement lent (bcrypt, scrypt, Argon2), pas un simple SHA.
  • Hachage sensible à la localité : les objets similaires obtiennent des empreintes proches.
  • Diffie-Hellman : accord sur une clé secrète via un canal public. Aujourd'hui, on utilise surtout ses variantes sur courbes elliptiques (ECDH).
  • Programmation linéaire : optimiser un objectif sous contraintes (CLRS ch. 29, simplexe).

Chapitres de CLRS absents de Grokking

  • Analyse amortie (ch. 17) : coût moyen sur une suite d'opérations (ex. ajout à un tableau dynamique).
  • Union-find (ch. 21) et arbre couvrant minimal (ch. 23) : Kruskal, Prim.
  • Flot maximum (ch. 26) : Ford-Fulkerson ; 4e éd. : nouveau chapitre sur les appariements dans les graphes bipartis.
  • Chaînes de caractères (ch. 32) : Rabin-Karp, automates, Knuth-Morris-Pratt ; 4e éd. : tableaux de suffixes.
  • Algorithmes randomisés (ch. 5), statistiques d'ordre (ch. 9 : médiane en O(n)), géométrie algorithmique (ch. 33 de la 3e éd.), arithmétique et RSA (ch. 31), multithreading (ch. 27).
  • Algorithmes en ligne (4e éd.) : décisions prises sans connaître l'avenir.

Exercices

  1. Quelle structure approximative pour tester l'appartenance avec très peu de mémoire ?
  2. Pourquoi ne pas stocker un mot de passe avec SHA-256 seul ?
  3. Quel algorithme pour un arbre couvrant de poids minimal ?

Réponses : 1. filtre de Bloom · 2. trop rapide à attaquer : utiliser bcrypt, scrypt ou Argon2 · 3. Kruskal ou Prim.

Retour au sommaire

Fiche 14

Aide-mémoire final

Complexités à connaître par cœur

  • Recherche simple O(n) · dichotomie O(log n).
  • Tri par sélection / insertion O(n²) · tri fusion, heapsort O(n log n) · quicksort O(n log n) en moyenne, O(n²) au pire.
  • Tableau : lecture O(1), insertion / suppression O(n). Liste chaînée : lecture O(n), insertion / suppression O(1) à une position connue.
  • Table de hachage : O(1) en moyenne, O(n) au pire.
  • ABR équilibré : O(log n). Tas : minimum O(1), insertion / suppression O(log n).
  • BFS / DFS : O(V + E). Dijkstra (tas) : O((V + E) log V). Bellman-Ford : O(V · E). Floyd-Warshall : O(V³).
  • Sac à dos (programmation dynamique) : O(n · W).
  • Voyageur de commerce par force brute : O(n!).

Quel algorithme choisir ?

  • Liste triée à fouiller → dichotomie (bisect).
  • Trier → fonction de tri du langage (Timsort) ; comprendre fusion et quicksort pour les examens.
  • Associer clé → valeur, éviter les doublons, mettre en cache → table de hachage.
  • Plus court chemin sans poids → BFS ; poids positifs → Dijkstra ; poids négatifs → Bellman-Ford.
  • Sous-problèmes qui se répètent → programmation dynamique.
  • Optimisation NP-complète → approximation / glouton.
  • Classer ou prédire par ressemblance → k plus proches voisins.
  • File de priorité → tas (heapq).
  • Données ordonnées avec insertions fréquentes → arbre équilibré.

Les 10 pièges les plus fréquents

  1. Dichotomie sur une liste non triée.
  2. / au lieu de // en Python 3.
  3. Récursivité sans cas de base.
  4. Copier des listes à chaque appel récursif.
  5. Pivot fixe sur une liste triée.
  6. BFS sans ensemble de sommets vus.
  7. Dijkstra avec des poids négatifs.
  8. Glouton non démontré.
  9. Clé de dictionnaire modifiable.
  10. Confondre NP-difficile et NP-complet.

Checklist avant un examen

  • Je sais écrire dichotomie, tri fusion, BFS, Dijkstra et le sac à dos de mémoire.
  • Je sais donner la complexité de chacun et son cas défavorable.
  • Je sais expliquer pourquoi, pas seulement comment.

Retour au sommaire