Programmation

Algorithmes
Avancés

Tri, recherche, graphes, diviser pour régner et programmation dynamique — avec exemples Python et pseudocode.

Big-O — mesurer la complexité

La notation Big-O décrit comment le temps d'exécution (ou la mémoire) d'un algorithme évolue quand la taille de l'entrée n augmente. On s'intéresse au pire cas et on ignore les constantes.

NotationNomExemplen = 1 000
O(1)ConstantAccès tableau par index1 op.
O(log n)LogarithmiqueRecherche binaire~10 op.
O(n)LinéaireParcours de liste1 000 op.
O(n log n)Quasi-linéaireQuicksort, Mergesort~10 000 op.
O(n²)QuadratiqueTri à bulles, double boucle1 000 000 op.
O(2ⁿ)ExponentielSous-ensembles, force brute2^1000 — impossible
O(n!)FactorielPermutations, voyageur de commerce— impossible
ℹ️

Règles pratiques : une boucle simple sur n éléments = O(n). Deux boucles imbriquées = O(n²). Couper le problème en deux à chaque étape = O(log n). Boucle + division = O(n log n).

Exemples Python — identifier la complexité
# O(1) — accès direct, peu importe n
def premier(lst):
    return lst[0]

# O(n) — une passe sur la liste
def somme(lst):
    total = 0
    for x in lst:
        total += x
    return total

# O(n²) — boucles imbriquées
def paires_doublons(lst):
    for i in range(len(lst)):
        for j in range(i + 1, len(lst)):
            if lst[i] == lst[j]:
                print(lst[i], lst[j])

# O(n) avec dict — la même chose en mieux !
def paires_doublons_v2(lst):
    vus = set()
    for x in lst:
        if x in vus:
            print(x)
        vus.add(x)

Comparaison des algorithmes de tri

AlgorithmeMeilleur casCas moyenPire casMémoireStableIdéal pour
Quicksort O(n log n) O(n log n) O(n²) O(log n) Usage général, très rapide en pratique
Merge sort O(n log n) O(n log n) O(n log n) O(n) Stabilité requise, listes chaînées, externe
Heap sort O(n log n) O(n log n) O(n log n) O(1) Mémoire limitée, pire cas garanti
Timsort (Python) O(n) O(n log n) O(n log n) O(n) list.sort() et sorted() en Python
Tri insertion O(n) O(n²) O(n²) O(1) Petits tableaux, presque triés
Tri bulles O(n) O(n²) O(n²) O(1) Pédagogie uniquement
Counting sort O(n+k) O(n+k) O(n+k) O(k) Entiers bornés (k = max), tri sans comparaison
💡

En Python, toujours préférer sorted() ou .sort() (Timsort, O(n log n) garanti, stable). N'implémenter un tri soi-même qu'à des fins pédagogiques ou pour des cas très spécifiques.

Quicksort — diviser par pivot

Quicksort choisit un pivot, place les éléments plus petits à sa gauche et les plus grands à droite, puis recommence récursivement sur chaque partie. Très rapide en pratique malgré un pire cas O(n²).

# Pseudocode fonction quicksort(tableau, gauche, droite): si gauche >= droite: retourner pivot_pos = partitionner(tableau, gauche, droite) quicksort(tableau, gauche, pivot_pos - 1) quicksort(tableau, pivot_pos + 1, droite) fonction partitionner(tableau, gauche, droite): pivot = tableau[droite] # pivot = dernier élément i = gauche - 1 pour j de gauche à droite - 1: si tableau[j] <= pivot: i = i + 1 échanger(tableau[i], tableau[j]) échanger(tableau[i+1], tableau[droite]) retourner i + 1 # position finale du pivot
Python
def quicksort(arr: list, g: int = 0, d: int = None) -> None:
    if d is None: d = len(arr) - 1
    if g >= d: return

    pivot_pos = _partitionner(arr, g, d)
    quicksort(arr, g, pivot_pos - 1)
    quicksort(arr, pivot_pos + 1, d)

def _partitionner(arr, g, d):
    pivot = arr[d]
    i = g - 1
    for j in range(g, d):
        if arr[j] <= pivot:
            i += 1
            arr[i], arr[j] = arr[j], arr[i]
    arr[i + 1], arr[d] = arr[d], arr[i + 1]
    return i + 1

# Version fonctionnelle (plus lisible, O(n) mémoire)
def qs(arr):
    if len(arr) <= 1: return arr
    pivot = arr[len(arr) // 2]
    gauche  = [x for x in arr if x <  pivot]
    milieu  = [x for x in arr if x == pivot]
    droite  = [x for x in arr if x >  pivot]
    return qs(gauche) + milieu + qs(droite)

arr = [3, 6, 8, 10, 1, 2, 1]
print(qs(arr))  # [1, 1, 2, 3, 6, 8, 10]
ℹ️

Pire cas O(n²) : tableau déjà trié + pivot = dernier élément → partition toujours déséquilibrée (1 / n-1). Remède : choisir le pivot aléatoirement (randomized quicksort) ou prendre la médiane de 3 valeurs. En pratique, le pire cas est rarissime.

Merge sort — fusionner

Merge sort divise le tableau en deux moitiés, trie chacune récursivement, puis fusionne les deux moitiés triées. Garantit O(n log n) dans tous les cas. Stable et prévisible.

fonction mergesort(tableau): si longueur(tableau) <= 1: retourner tableau milieu = longueur(tableau) // 2 gauche = mergesort(tableau[:milieu]) droite = mergesort(tableau[milieu:]) retourner fusionner(gauche, droite) fonction fusionner(gauche, droite): resultat = [] i = j = 0 tant que i < lon(gauche) et j < lon(droite): si gauche[i] <= droite[j]: ajouter gauche[i], i++ sinon: ajouter droite[j], j++ # Ajouter les restes ajouter gauche[i:] + droite[j:] retourner resultat
Python
def mergesort(arr: list) -> list:
    if len(arr) <= 1:
        return arr

    mid = len(arr) // 2
    gauche = mergesort(arr[:mid])
    droite = mergesort(arr[mid:])
    return _fusionner(gauche, droite)

def _fusionner(gauche: list, droite: list) -> list:
    resultat = []
    i = j = 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

    # Ajouter les éléments restants
    resultat.extend(gauche[i:])
    resultat.extend(droite[j:])
    return resultat

arr = [38, 27, 43, 3, 9, 82, 10]
print(mergesort(arr))
# [3, 9, 10, 27, 38, 43, 82]

Heap sort — le tas binaire

Heap sort utilise une structure de tas binaire (max-heap) — un arbre où chaque nœud est supérieur à ses enfants. Extraire le maximum répétitivement donne le tableau trié. O(n log n) garanti, O(1) mémoire.

Python
def heapify(arr, n, i):
    """Maintenir la propriété max-heap à partir de i."""
    plus_grand = i
    gauche  = 2 * i + 1
    droite  = 2 * i + 2

    if gauche < n and arr[gauche] > arr[plus_grand]:
        plus_grand = gauche
    if droite < n and arr[droite] > arr[plus_grand]:
        plus_grand = droite

    if plus_grand != i:
        arr[i], arr[plus_grand] = arr[plus_grand], arr[i]
        heapify(arr, n, plus_grand)  # récursif vers le bas

def heapsort(arr):
    n = len(arr)
    # Phase 1 : construire le max-heap
    for i in range(n // 2 - 1, -1, -1):
        heapify(arr, n, i)

    # Phase 2 : extraire les maximums un par un
    for i in range(n - 1, 0, -1):
        arr[0], arr[i] = arr[i], arr[0]  # max → fin
        heapify(arr, i, 0)               # restaurer le heap

# Python — heapq (min-heap natif)
import heapq
arr = [3, 1, 4, 1, 5]
heapq.heapify(arr)           # O(n)
min_val = heapq.heappop(arr) # O(log n)
# Structure d'un max-heap dans un tableau # Indice 0 = racine (le maximum) # Parent de i = (i-1) // 2 # Enfant gauche = 2*i + 1 # Enfant droit = 2*i + 2 Tableau : [82, 43, 38, 27, 9, 10, 3] Représentation arbre : 82 ← toujours le max / \ 43 38 / \ / \ 27 9 10 3 Chaque parent ≥ ses deux enfants = propriété heap
ℹ️

heapq en Python est un min-heap (le plus petit est toujours au sommet). Pour simuler un max-heap, insérer les valeurs négées : heapq.heappush(h, -val).

Recherche binaire — O(log n)

Sur un tableau trié, la recherche binaire divise l'espace de recherche par deux à chaque étape. Trouver une valeur parmi 1 000 000 éléments ne prend que ~20 comparaisons.

fonction recherche_binaire(tableau, cible): gauche = 0 droite = longueur(tableau) - 1 tant que gauche <= droite: milieu = (gauche + droite) // 2 si tableau[milieu] == cible: retourner milieu # trouvé ! sinon si tableau[milieu] < cible: gauche = milieu + 1 # chercher à droite sinon: droite = milieu - 1 # chercher à gauche retourner -1 # pas trouvé
Python — itératif et récursif
# Itératif — préférable (pas de pile d'appels)
def recherche_binaire(arr: list, cible) -> int:
    g, d = 0, len(arr) - 1
    while g <= d:
        m = (g + d) // 2
        if   arr[m] == cible: return m
        elif arr[m] <  cible: g = m + 1
        else:                 d = m - 1
    return -1

# Module bisect — recherche binaire en Python std
import bisect
arr = [1, 3, 5, 7, 9, 11]
i = bisect.bisect_left(arr, 7)   # → 3
# bisect_left  : position d'insertion à gauche
# bisect_right : position d'insertion à droite

# Application : trouver le plus proche
def plus_proche(arr, cible):
    i = bisect.bisect_left(arr, cible)
    if i == 0: return arr[0]
    if i == len(arr): return arr[-1]
    if arr[i] - cible < cible - arr[i-1]:
        return arr[i]
    return arr[i - 1]

Représenter un graphe

Un graphe est un ensemble de nœuds (sommets) reliés par des arêtes. Il modélise des réseaux, cartes, dépendances, réseaux sociaux, etc.

Liste d'adjacence — représentation standard
# Graphe non orienté :
#   A — B — D
#   |   |
#   C — E

graphe = {
    "A": ["B", "C"],
    "B": ["A", "D", "E"],
    "C": ["A", "E"],
    "D": ["B"],
    "E": ["B", "C"],
}

# Graphe pondéré (avec poids sur les arêtes) :
graphe_pondere = {
    "A": {"B": 4, "C": 2},
    "B": {"A": 4, "D": 5, "E": 1},
    "C": {"A": 2, "E": 3},
    "D": {"B": 5},
    "E": {"B": 1, "C": 3},
}
Liste d'adjacenceMatrice d'adjacence
MémoireO(V + E)O(V²)
Voisins de XO(degré)O(V)
X et Y liés ?O(degré)O(1)
Idéal pourGraphes éparsGraphes denses
ℹ️

Vocabulaire : V = nombre de sommets (Vertices), E = nombre d'arêtes (Edges). Un graphe est orienté si les arêtes ont une direction (A→B ≠ B→A). Il est pondéré si les arêtes ont un poids (distance, coût). Un graphe connexe est entièrement accessible depuis n'importe quel nœud.

BFS — Parcours en largeur

BFS (Breadth-First Search) explore le graphe niveau par niveau à partir d'un nœud source, en utilisant une file (FIFO). Trouve le plus court chemin en nombre d'arêtes.

fonction BFS(graphe, depart): visites = {depart} file = [depart] tant que file non vide: noeud = defiler(file) # FIFO ! traiter(noeud) pour voisin dans graphe[noeud]: si voisin pas dans visites: enfiler(voisin) visites.ajouter(voisin) # Ordre exploration depuis A : # Niveau 0 : A # Niveau 1 : B, C (voisins de A) # Niveau 2 : D, E (voisins de B et C)
💡

Quand utiliser BFS ? Plus court chemin non pondéré (jeux, labyrinthes), niveaux d'un arbre, connexité d'un graphe, "degré de séparation" entre deux nœuds.

Python — BFS + reconstruction du chemin
from collections import deque

def bfs(graphe: dict, depart: str) -> list:
    """Retourne l'ordre de visite des nœuds."""
    visite = {depart}
    file = deque([depart])
    ordre = []

    while file:
        noeud = file.popleft()  # O(1) avec deque
        ordre.append(noeud)
        for voisin in graphe[noeud]:
            if voisin not in visite:
                visite.add(voisin)
                file.append(voisin)
    return ordre

def chemin_bfs(graphe, depart, arrivee):
    """Plus court chemin (arêtes) entre deux nœuds."""
    predecesseur = {depart: None}
    file = deque([depart])

    while file:
        noeud = file.popleft()
        if noeud == arrivee:
            break
        for v in graphe[noeud]:
            if v not in predecesseur:
                predecesseur[v] = noeud
                file.append(v)

    # Reconstruire le chemin à rebours
    chemin, noeud = [], arrivee
    while noeud is not None:
        chemin.append(noeud)
        noeud = predecesseur.get(noeud)
    return chemin[::-1]

DFS — Parcours en profondeur

DFS (Depth-First Search) s'enfonce le plus loin possible dans une branche avant de revenir en arrière (backtracking). Utilise une pile (LIFO) — naturellement récursif.

# Version récursive (naturelle) fonction DFS(graphe, noeud, visites={}): visites.ajouter(noeud) traiter(noeud) pour voisin dans graphe[noeud]: si voisin pas dans visites: DFS(graphe, voisin, visites) # Version itérative (avec pile explicite) fonction DFS_iter(graphe, depart): visites = {} pile = [depart] tant que pile non vide: noeud = dépiler(pile) # LIFO ! si noeud pas dans visites: visites.ajouter(noeud) traiter(noeud) pour voisin dans graphe[noeud]: empiler(voisin)
Python — DFS + détection de cycle
def dfs(graphe, noeud, visite=None):
    if visite is None: visite = set()
    visite.add(noeud)
    print(noeud)
    for v in graphe[noeud]:
        if v not in visite:
            dfs(graphe, v, visite)

# Détection de cycle dans un graphe orienté
def a_un_cycle(graphe) -> bool:
    blanc = set(graphe)   # non visité
    gris  = set()         # en cours de visite
    noir  = set()         # terminé

    def dfs_cycle(n):
        blanc.discard(n)
        gris.add(n)
        for v in graphe.get(n, []):
            if v in gris: return True   # cycle !
            if v in blanc and dfs_cycle(v): return True
        gris.discard(n); noir.add(n)
        return False

    return any(dfs_cycle(n) for n in list(blanc))
💡

Quand utiliser DFS ? Détection de cycles, tri topologique (ordonnancement de tâches), résoudre des labyrinthes, trouver les composantes connexes, génération de solutions (backtracking).

Dijkstra — plus court chemin pondéré

Dijkstra trouve le plus court chemin depuis un nœud source vers tous les autres dans un graphe à poids positifs. Il utilise une file de priorité (min-heap) pour toujours explorer le nœud le moins coûteux en premier.

fonction dijkstra(graphe, depart): # dist[n] = distance minimale connue depuis depart dist = {n: +∞ pour n dans graphe} dist[depart] = 0 file_prio = [(coût=0, noeud=depart)] tant que file_prio non vide: coût, noeud = extraire_min(file_prio) si coût > dist[noeud]: continuer # déjà mieux pour (voisin, poids) dans graphe[noeud]: nouvelle_dist = dist[noeud] + poids si nouvelle_dist < dist[voisin]: dist[voisin] = nouvelle_dist insérer(file_prio, (nouvelle_dist, voisin)) retourner dist
Python — Dijkstra avec heapq
import heapq

def dijkstra(graphe: dict, depart: str) -> dict:
    """
    graphe = {"A": {"B": 4, "C": 2}, ...}
    Retourne distances minimales depuis depart.
    """
    dist = {n: float("inf") for n in graphe}
    dist[depart] = 0
    precedent = {n: None for n in graphe}
    heap = [(0, depart)]  # (coût, nœud)

    while heap:
        cout, noeud = heapq.heappop(heap)
        if cout > dist[noeud]:
            continue  # entrée obsolète
        for voisin, poids in graphe[noeud].items():
            nd = dist[noeud] + poids
            if nd < dist[voisin]:
                dist[voisin] = nd
                precedent[voisin] = noeud
                heapq.heappush(heap, (nd, voisin))

    return dist, precedent

# Reconstruire le chemin
def reconstruire(precedent, arrivee):
    chemin = []
    while arrivee:
        chemin.append(arrivee)
        arrivee = precedent[arrivee]
    return chemin[::-1]
⚠️

Limitation de Dijkstra : ne fonctionne pas avec des poids négatifs (utiliser Bellman-Ford dans ce cas). Complexité : O((V + E) log V) avec un min-heap. Pour des graphes denses, une matrice + tableau simple peut être préférable.

A* — Dijkstra guidé par heuristique

A* améliore Dijkstra en utilisant une heuristique h(n) — une estimation du coût restant jusqu'à la destination. Il explore en priorité les nœuds qui semblent prometteurs, sans explorer tout le graphe.

Python — A* sur grille 2D
import heapq

def heuristique(a, b):
    """Distance de Manhattan — parfaite pour une grille."""
    return abs(a[0] - b[0]) + abs(a[1] - b[1])

def astar(grille, depart, arrivee):
    """
    grille = liste 2D (0=libre, 1=mur)
    depart, arrivee = (ligne, colonne)
    """
    rows, cols = len(grille), len(grille[0])
    directions = [(0,1),(0,-1),(1,0),(-1,0)]

    g = {depart: 0}          # coût réel depuis depart
    pred = {depart: None}
    heap = [(0 + heuristique(depart, arrivee), depart)]

    while heap:
        f, pos = heapq.heappop(heap)
        if pos == arrivee:
            # Reconstruire le chemin
            chemin = []
            while pos: chemin.append(pos); pos = pred[pos]
            return chemin[::-1]

        for dr, dc in directions:
            voisin = (pos[0]+dr, pos[1]+dc)
            r, c = voisin
            if not (0 <= r < rows and 0 <= c < cols): continue
            if grille[r][c] == 1: continue  # mur
            ng = g[pos] + 1
            if voisin not in g or ng < g[voisin]:
                g[voisin] = ng
                pred[voisin] = pos
                heapq.heappush(heap, (ng + heuristique(voisin, arrivee), voisin))
    return None  # pas de chemin
# Fonction d'évaluation A* : # f(n) = g(n) + h(n) # # g(n) = coût réel depuis la source jusqu'à n # h(n) = heuristique — estimation du coût # restant de n jusqu'à la destination # # → toujours explorer le nœud avec le plus # petit f(n) en premier # # Si h(n) = 0 partout → Dijkstra # Si h(n) = distance exacte → 0 exploration inutile
HeuristiqueContexte
Distance de Manhattan |Δx| + |Δy|Grille avec 4 directions
Distance Chebyshev max(|Δx|, |Δy|)Grille avec 8 directions
Distance euclidienne √(Δx²+Δy²)Espace continu
Jamais surestimerRègle d'admissibilité — garantit l'optimalité

Principe — diviser, résoudre, combiner

Diviser pour régner (Divide and Conquer) décompose un problème en sous-problèmes indépendants plus petits, les résout récursivement, puis combine les résultats. Merge sort et Quicksort en sont les exemples canoniques.

Python — recherche max & min simultanés
# Problème : trouver min ET max d'un tableau
# Naïf : 2(n-1) comparaisons
# Diviser pour régner : ~3n/2 comparaisons

def min_max(arr, g, d):
    """Retourne (min, max) de arr[g..d]."""
    # Cas de base
    if g == d:
        return arr[g], arr[g]
    if d - g == 1:
        return (arr[g], arr[d]) if arr[g] < arr[d] else (arr[d], arr[g])

    # Diviser
    m = (g + d) // 2
    min_g, max_g = min_max(arr, g, m)
    min_d, max_d = min_max(arr, m + 1, d)

    # Combiner
    return min(min_g, min_d), max(max_g, max_d)

# Puissance rapide — O(log n) au lieu de O(n)
def puissance(base, exp):
    if exp == 0: return 1
    if exp % 2 == 0:
        moitie = puissance(base, exp // 2)
        return moitie * moitie          # ← diviser
    return base * puissance(base, exp - 1)
Sous-tableau de somme maximale — Kadane / D&C
# Problème : trouver le sous-tableau contigu
# avec la plus grande somme.
# [-2, 1, -3, 4, -1, 2, 1, -5, 4]
# Réponse : [4, -1, 2, 1] → somme = 6

# Kadane — O(n) (approche glissante, plus simple)
def max_sous_tableau(arr):
    max_global = max_courant = arr[0]
    debut = fin = 0
    temp_debut = 0

    for i in range(1, len(arr)):
        if arr[i] > max_courant + arr[i]:
            max_courant = arr[i]
            temp_debut = i
        else:
            max_courant += arr[i]

        if max_courant > max_global:
            max_global = max_courant
            debut, fin = temp_debut, i

    return max_global, arr[debut:fin+1]

arr = [-2, 1, -3, 4, -1, 2, 1, -5, 4]
print(max_sous_tableau(arr))  # (6, [4,-1,2,1])

Récursion avancée — backtracking

Le backtracking est une technique qui construit une solution pas à pas, et revient en arrière dès qu'un chemin mène à une impasse. Parfait pour les problèmes combinatoires.

Permutations d'une liste
def permutations(elements: list) -> list:
    """Génère toutes les permutations — O(n! × n)."""
    if len(elements) <= 1:
        return [elements]

    resultat = []
    for i, elem in enumerate(elements):
        reste = elements[:i] + elements[i+1:]
        for perm in permutations(reste):
            resultat.append([elem] + perm)
    return resultat

# Équivalent Python :
from itertools import permutations as perms
list(perms([1, 2, 3]))
N-Reines — backtracking classique
def n_reines(n: int) -> list[list[int]]:
    """Toutes les solutions pour placer n reines
    sur un échiquier n×n sans qu'elles s'attaquent."""
    solutions = []

    def backtrack(ligne, cols, diag1, diag2, placement):
        if ligne == n:
            solutions.append(placement[:])
            return
        for col in range(n):
            if col in cols \
            or (ligne - col) in diag1 \
            or (ligne + col) in diag2:
                continue   # ← backtrack ici
            placement.append(col)
            cols.add(col)
            diag1.add(ligne - col)
            diag2.add(ligne + col)
            backtrack(ligne+1, cols, diag1, diag2, placement)
            # Annuler le choix (backtrack)
            placement.pop()
            cols.discard(col)
            diag1.discard(ligne - col)
            diag2.discard(ligne + col)

    backtrack(0, set(), set(), set(), [])
    return solutions

Principe — mémoïsation vs tabulation

La programmation dynamique (DP) résout des problèmes en décomposant en sous-problèmes qui se répètent, et en mémorisant leurs résultats pour ne les calculer qu'une seule fois. Différence clé avec diviser pour régner : les sous-problèmes se chevauchent.

ℹ️

2 approches équivalentes :
Top-down (mémoïsation) — écrire la récursion naturellement, ajouter un cache. Plus intuitif, ne calcule que ce qui est nécessaire.

Bottom-up (tabulation) — remplir un tableau des plus petits sous-problèmes aux plus grands. Plus efficace (pas de pile d'appels), parfois moins lisible.

# Pattern type — DP top-down cache = {} fonction dp(état): si état dans cache: retourner cache[état] si cas_de_base(état): retourner valeur_base resultat = combiner( dp(sous_état_1), dp(sous_état_2), ... ) cache[état] = resultat retourner resultat
Python — @lru_cache pour la mémoïsation
from functools import lru_cache

# Tout problème récursif peut bénéficier de @lru_cache
# si les mêmes appels se répètent avec les mêmes args

# Avant :
def fib_lent(n):
    if n <= 1: return n
    return fib_lent(n-1) + fib_lent(n-2)  # O(2ⁿ)

# Après (une ligne !) :
@lru_cache(maxsize=None)
def fib(n):
    if n <= 1: return n
    return fib(n-1) + fib(n-2)    # O(n) !

# @cache = @lru_cache(maxsize=None) depuis Python 3.9
from functools import cache

@cache
def fib(n):
    if n <= 1: return n
    return fib(n-1) + fib(n-2)

Fibonacci & escalier — deux variantes

## Fibonacci sans mémo : arbre d'appels (n=5) fib(5) ├── fib(4) │ ├── fib(3) │ │ ├── fib(2)→1 fib(1)→1 │ └── fib(2)→1 ← recalculé ! └── fib(3) ← recalculé ! ├── fib(2)→1 ← recalculé ! └── fib(1)→1 ## Avec mémo : chaque valeur calculée UNE FOIS fib(5) ├── fib(4) │ ├── fib(3) │ │ ├── fib(2)→1 fib(1)→1 │ └── fib(2)→cache[2]=1 ✓ └── fib(3)→cache[3]=2 ✓
Escalier — combinaisons de 1 ou 2 marches
# Problème : pour monter n marches en faisant
# des pas de 1 ou 2, combien de façons différentes ?
# n=1 → 1 façon  (1)
# n=2 → 2 façons (1+1, 2)
# n=3 → 3 façons (1+1+1, 1+2, 2+1)
# n=4 → 5 façons  → c'est Fibonacci !

# Top-down
from functools import cache

@cache
def escalier(n: int) -> int:
    if n <= 2: return n
    return escalier(n - 1) + escalier(n - 2)

# Bottom-up — O(n) temps, O(1) mémoire
def escalier_v2(n: int) -> int:
    if n <= 2: return n
    a, b = 1, 2
    for _ in range(3, n + 1):
        a, b = b, a + b
    return b

# Extension : pas de 1, 2 ou 3 marches
@cache
def escalier3(n: int) -> int:
    if n == 0: return 1
    if n < 0: return 0
    return escalier3(n-1) + escalier3(n-2) + escalier3(n-3)

Sac à dos 0/1 — choisir sans dépasser

Problème classique : on a n objets avec chacun un poids et une valeur. Le sac a une capacité W. Quels objets prendre pour maximiser la valeur sans dépasser W ? Chaque objet : pris (1) ou pas (0).

Python — sac à dos bottom-up
def sac_a_dos(poids: list, valeurs: list, W: int) -> int:
    """
    Valeur maximale pour une capacité W.
    poids   = [2, 3, 4, 5]
    valeurs = [3, 4, 5, 6]
    W = 5 → valeur max = 7 (objets 0 et 1)
    """
    n = len(poids)
    # dp[i][w] = valeur max avec i premiers objets, capacité w
    dp = [[0] * (W + 1) for _ in range(n + 1)]

    for i in range(1, n + 1):
        for w in range(W + 1):
            # Ne pas prendre l'objet i-1
            dp[i][w] = dp[i-1][w]
            # Le prendre si le poids le permet
            if poids[i-1] <= w:
                avec = dp[i-1][w - poids[i-1]] + valeurs[i-1]
                dp[i][w] = max(dp[i][w], avec)

    return dp[n][W]

Table DP pour poids=[2,3], valeurs=[3,4], W=5

i \ w012345
0 (aucun) 000000
1 (p=2,v=3) 003333
2 (p=3,v=4) 003447
ℹ️

Lecture : dp[2][5] = 7 → on peut atteindre une valeur de 7 avec 2 objets et une capacité de 5. On a pris l'objet 1 (poids 2, valeur 3) + l'objet 2 (poids 3, valeur 4) = poids total 5 ✓

Plus longue sous-séquence commune (LCS)

La LCS (Longest Common Subsequence) trouve la plus longue séquence de caractères commune à deux chaînes, dans le même ordre mais pas forcément contiguës. Utilisée dans git diff, bioinformatique, correcteurs.

Python — LCS bottom-up
def lcs(X: str, Y: str) -> str:
    """Retourne la LCS de X et Y."""
    m, n = len(X), len(Y)
    dp = [[0] * (n + 1) for _ in range(m + 1)]

    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if X[i-1] == Y[j-1]:
                dp[i][j] = dp[i-1][j-1] + 1  # match !
            else:
                dp[i][j] = max(dp[i-1][j], dp[i][j-1])

    # Reconstruire la chaîne
    resultat, i, j = [], m, n
    while i > 0 and j > 0:
        if X[i-1] == Y[j-1]:
            resultat.append(X[i-1])
            i -= 1; j -= 1
        elif dp[i-1][j] > dp[i][j-1]:
            i -= 1
        else:
            j -= 1
    return "".join(reversed(resultat))

X, Y = "ABCBDAB", "BDCAB"
print(lcs(X, Y))   # "BCAB" ou "BDAB" — longueur 4

Table DP pour "ABC" et "AC"

""AC
""000
A011
B011
C012
💡

Problèmes apparentés à LCS : distance d'édition (Levenshtein — coût pour transformer une chaîne en une autre), diff de fichiers (git diff), alignement de séquences ADN. La structure de la table DP est identique, seule la récurrence change.

Distance d'édition (Levenshtein)
def edit_distance(s1: str, s2: str) -> int:
    m, n = len(s1), len(s2)
    dp = [[0]*(n+1) for _ in range(m+1)]
    for i in range(m+1): dp[i][0] = i
    for j in range(n+1): dp[0][j] = j
    for i in range(1, m+1):
        for j in range(1, n+1):
            if s1[i-1] == s2[j-1]: dp[i][j] = dp[i-1][j-1]
            else: dp[i][j] = 1 + min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1])
    return dp[m][n]
print(edit_distance("kitten", "sitting"))  # 3

Tri à bulles, par insertion, par sélection

Ces trois algorithmes sont O(n²) — inutilisables sur de grandes données. Leur intérêt est pédagogique : ils illustrent clairement les notions de comparaison, d'échange et de progression.

Tri à bulles — Bubble Sort
# Idée : à chaque passe, faire "remonter" le plus grand élément
# comme une bulle qui remonte à la surface.

def tri_bulles(lst):
    n = len(lst)
    for i in range(n - 1):            # n-1 passes
        for j in range(n - 1 - i):    # la fin est déjà triée
            if lst[j] > lst[j + 1]:
                lst[j], lst[j+1] = lst[j+1], lst[j]  # échange
    return lst

# Version optimisée — arrêt si déjà trié
def tri_bulles_v2(lst):
    n = len(lst)
    for i in range(n - 1):
        echange = False
        for j in range(n - 1 - i):
            if lst[j] > lst[j + 1]:
                lst[j], lst[j+1] = lst[j+1], lst[j]
                echange = True
        if not echange:   # aucun échange → déjà trié
            break
    return lst

# Trace sur [5, 3, 8, 1] :
# Passe 1 : [3,5,8,1] → [3,5,8,1] → [3,5,1,8]   fin=8 placé
# Passe 2 : [3,5,1,8] → [3,1,5,8]                fin=5 placé
# Passe 3 : [1,3,5,8]                             terminé
Tri par sélection — Selection Sort
# Idée : trouver le minimum du sous-tableau non trié,
# le placer en tête.

def tri_selection(lst):
    n = len(lst)
    for i in range(n - 1):
        idx_min = i
        for j in range(i + 1, n):
            if lst[j] < lst[idx_min]:
                idx_min = j
        lst[i], lst[idx_min] = lst[idx_min], lst[i]  # 1 seul échange/passe
    return lst

# Avantage : exactement n-1 échanges (utile si l'échange est coûteux)
# Inconvénient : O(n²) même si déjà trié — pas d'optimisation possible
Tri par insertion — Insertion Sort
# Idée : construire le tableau trié élément par élément,
# comme on trie des cartes à la main.

def tri_insertion(lst):
    for i in range(1, len(lst)):
        cle = lst[i]       # l'élément à insérer
        j = i - 1
        while j >= 0 and lst[j] > cle:
            lst[j + 1] = lst[j]   # décaler vers la droite
            j -= 1
        lst[j + 1] = cle          # insérer à la bonne position
    return lst

# Trace sur [5, 3, 8, 1] :
# i=1 : clé=3, décaler 5 → [5,5,8,1] → insérer → [3,5,8,1]
# i=2 : clé=8, 8>5 ? non → [3,5,8,1]
# i=3 : clé=1, décaler 8,5,3 → insérer → [1,3,5,8]

# Meilleur cas O(n) : tableau déjà trié — aucun décalage
# C'est pourquoi Timsort l'utilise sur les petits blocs !
AlgoMeilleurMoyenPireStableAvantage clé
BullesO(n)*O(n²)O(n²)Détecte si trié
InsertionO(n)O(n²)O(n²)Efficace si presque trié
SélectionO(n²)O(n²)O(n²)Peu d'échanges mémoire
⚠️

Ces trois algorithmes sont exclusivement pédagogiques. En production : utiliser sorted() ou .sort() (Timsort, O(n log n)).

Complexité spatiale — pas que le temps !

La complexité spatiale mesure la mémoire supplémentaire qu'un algorithme utilise (en plus de l'entrée). Elle suit la même notation Big-O que la complexité temporelle.

AlgorithmeTempsEspaceExplication mémoire
Tri bulles / insertion / sélectionO(n²)O(1)Tri en place — seule une variable de swap
QuicksortO(n log n)O(log n)Pile des appels récursifs (profondeur log n)
Merge sortO(n log n)O(n)Tableaux auxiliaires pour fusionner
BFS / DFSO(V + E)O(V)File/pile + tableau des visités
DijkstraO(E log V)O(V)Distances + file de priorité
Floyd-WarshallO(V³)O(V²)Matrice n×n des distances
DP Fibonacci (table)O(n)O(n)Tableau de n valeurs
DP Fibonacci (2 vars)O(n)O(1)Seulement les 2 dernières valeurs
DP Sac à dos 0/1O(n·W)O(n·W)Matrice n×W — optimisable en O(W)
Réduire la complexité spatiale — exemples
# DP Fibonacci — O(n) espace → O(1)
def fib_space_optimise(n):
    if n <= 1: return n
    a, b = 0, 1               # seulement 2 variables
    for _ in range(2, n + 1):
        a, b = b, a + b
    return b

# DP Sac à dos — O(n·W) → O(W)
# Utiliser une seule ligne du tableau (parcours inversé)
def sac_a_dos_optimise(poids, valeurs, W):
    dp = [0] * (W + 1)           # une seule ligne
    for i in range(len(poids)):
        for w in range(W, poids[i] - 1, -1):  # ← parcours inversé !
            dp[w] = max(dp[w], dp[w - poids[i]] + valeurs[i])
    return dp[W]

# Merge sort — trier en place pour O(1) espace
# (plus complexe, mais possible)

# Complexité de la pile de récursion
import sys
sys.getrecursionlimit()   # 1000 par défaut
# Chaque appel récursif occupe de l'espace dans la pile
# DFS récursif sur un graphe de 10 000 nœuds → stack overflow !
# → Convertir en itératif avec une pile explicite
💡

Compromis temps/espace : souvent, réduire l'espace coûte du temps (et vice versa). La mémoïsation échange de l'espace contre du temps. Le Fibonacci O(1)/O(n) en est l'exemple parfait.

Greedy — faire le meilleur choix local

Un algorithme glouton fait à chaque étape le choix qui semble localement optimal, sans revenir en arrière. Il ne garantit pas toujours l'optimal global — mais quand il le fait, c'est en général très efficace.

Problème du rendu de monnaie
# Rendre la monnaie avec le moins de pièces possible.
# Stratégie gloutonne : toujours prendre la plus grande pièce ≤ montant restant.

# ✓ Optimal pour les systèmes de pièces standards (1, 2, 5, 10, 20, 50...)
# ✗ PAS optimal pour un système arbitraire : pièces [1, 3, 4], montant 6
#    Greedy : 4+1+1 = 3 pièces
#    Optimal : 3+3   = 2 pièces

def rendu_monnaie(montant, pieces):
    pieces_triees = sorted(pieces, reverse=True)
    rendu = []
    for piece in pieces_triees:
        while montant >= piece:
            rendu.append(piece)
            montant -= piece
    return rendu if montant == 0 else None  # None si impossible

print(rendu_monnaie(87, [50, 20, 10, 5, 2, 1]))
# [50, 20, 10, 5, 2]  = 5 pièces
Planification d'activités (Activity Selection)
# Sélectionner le maximum d'activités non chevauchantes.
# Stratégie : trier par heure de fin, toujours choisir
# l'activité compatible qui se termine le plus tôt.

def selection_activites(activites):
    # activites = [(debut, fin, nom), ...]
    triees = sorted(activites, key=lambda a: a[1])  # trier par fin
    selectionnees = [triees[0]]
    for debut, fin, nom in triees[1:]:
        if debut >= selectionnees[-1][1]:  # commence après la fin de la dernière
            selectionnees.append((debut, fin, nom))
    return selectionnees

activites = [(1,4,'A'), (3,5,'B'), (0,6,'C'), (5,7,'D'), (8,9,'E')]
print(selection_activites(activites))
# [(1,4,'A'), (5,7,'D'), (8,9,'E')]  — 3 activités max
Sac à dos fractionnable — Fractional Knapsack
# Contrairement au sac à dos 0/1, on peut prendre des fractions.
# Stratégie : trier par ratio valeur/poids décroissant.
# ← Greedy donne ici l'optimal garanti !

def sac_fractionnable(items, capacite):
    # items = [(poids, valeur, nom), ...]
    triees = sorted(items, key=lambda x: x[1]/x[0], reverse=True)
    valeur_totale = 0.0
    for poids, valeur, nom in triees:
        if capacite >= poids:
            valeur_totale += valeur
            capacite -= poids
        else:
            valeur_totale += valeur * (capacite / poids)
            break
    return valeur_totale

items = [(10, 60, 'Or'), (20, 100, 'Argent'), (30, 120, 'Bronze')]
print(sac_fractionnable(items, 50))  # 240.0
🎯

Quand le greedy est optimal : sélection d'activités, sac à dos fractionnable, arbre couvrant minimum (Kruskal/Prim), codes de Huffman. Quand il échoue : sac à dos 0/1, rendu de monnaie avec pièces arbitraires → il faut la programmation dynamique.

Floyd-Warshall — tous les plus courts chemins

Dijkstra calcule les plus courts chemins depuis un seul sommet. Floyd-Warshall calcule les plus courts chemins entre toutes les paires de sommets — en O(V³). Il gère les poids négatifs (mais pas les cycles négatifs).

Floyd-Warshall — implémentation
def floyd_warshall(graphe):
    """
    graphe : matrice d'adjacence n×n
    graphe[i][j] = poids de l'arête i→j
                 = float('inf') si pas d'arête
                 = 0            si i == j
    """
    n = len(graphe)
    dist = [row[:] for row in graphe]  # copie
    next_node = [[j if graphe[i][j] != float('inf') else None
                  for j in range(n)] for i in range(n)]

    for k in range(n):          # sommet intermédiaire
        for i in range(n):      # sommet de départ
            for j in range(n):  # sommet d'arrivée
                if dist[i][k] + dist[k][j] < dist[i][j]:
                    dist[i][j] = dist[i][k] + dist[k][j]
                    next_node[i][j] = next_node[i][k]  # reconstruire chemin

    # Détecter les cycles négatifs
    for i in range(n):
        if dist[i][i] < 0:
            raise ValueError("Cycle négatif détecté !")

    return dist, next_node

def reconstruire_chemin(next_node, i, j):
    if next_node[i][j] is None: return []
    chemin = [i]
    while i != j:
        i = next_node[i][j]
        chemin.append(i)
    return chemin

INF = float('inf')
G = [
    [0,   3,  INF, 7  ],
    [8,   0,  2,  INF],
    [5,   INF, 0,  1  ],
    [2,   INF, INF, 0  ],
]
dist, _ = floyd_warshall(G)
print(dist[0])  # [0, 3, 5, 6]  — depuis le sommet 0
AlgoTempsEspacePoids négatifsUtilisation
BFSO(V+E)O(V)Graphes non pondérés
DijkstraO(E log V)O(V)1 source, poids positifs
Bellman-FordO(V·E)O(V)1 source, poids négatifs
Floyd-WarshallO(V³)O(V²)✓*Toutes les paires
💡

Quand choisir Floyd-Warshall ? Quand on a besoin du plus court chemin entre n'importe quelle paire et que n est petit (V ≤ 500 typiquement). Pour de grands graphes creux : lancer Dijkstra depuis chaque sommet.

⚠️

* Floyd-Warshall détecte les cycles négatifs (dist[i][i] < 0) mais ne peut pas calculer les plus courts chemins en présence d'un tel cycle — le résultat serait −∞.

Application — fermeture transitive
# Est-ce que j est accessible depuis i ?
# Variante booléenne de Floyd-Warshall

def fermeture_transitive(adj):
    n = len(adj)
    atteint = [[adj[i][j] for j in range(n)] for i in range(n)]
    for k in range(n):
        for i in range(n):
            for j in range(n):
                atteint[i][j] = atteint[i][j] or (atteint[i][k] and atteint[k][j])
    return atteint

Tri topologique — ordonner les dépendances

Le tri topologique ordonne les sommets d'un graphe orienté acyclique (DAG) de sorte que pour chaque arête u→v, u apparaisse avant v. Application directe : résolution des dépendances (make, npm, pip).

Algorithme de Kahn (BFS) — recommandé
from collections import deque, defaultdict

def tri_topologique_kahn(n, aretes):
    """
    n : nombre de sommets (0..n-1)
    aretes : liste de (u, v) = u doit précéder v
    """
    in_degree = [0] * n
    voisins = defaultdict(list)

    for u, v in aretes:
        voisins[u].append(v)
        in_degree[v] += 1

    # Commencer avec les sommets sans prérequis
    file = deque(i for i in range(n) if in_degree[i] == 0)
    resultat = []

    while file:
        u = file.popleft()
        resultat.append(u)
        for v in voisins[u]:
            in_degree[v] -= 1
            if in_degree[v] == 0:
                file.append(v)

    if len(resultat) != n:
        raise ValueError("Cycle détecté ! Pas un DAG.")
    return resultat

# Exemple : cours avec prérequis
# 0=Maths, 1=Algo, 2=POO, 3=Réseaux, 4=Projet
aretes = [(0,1), (0,2), (1,4), (2,4), (3,4)]
print(tri_topologique_kahn(5, aretes))
# [0, 3, 1, 2, 4] ou [0, 3, 2, 1, 4]
# → Maths et Réseaux d'abord, Projet en dernier
Tri topologique par DFS
def tri_topo_dfs(n, voisins):
    BLANC, GRIS, NOIR = 0, 1, 2
    couleur = [BLANC] * n
    ordre = []

    def dfs(u):
        couleur[u] = GRIS     # en cours de visite
        for v in voisins.get(u, []):
            if couleur[v] == GRIS:
                raise ValueError("Cycle détecté !")
            if couleur[v] == BLANC:
                dfs(v)
        couleur[u] = NOIR     # traitement terminé
        ordre.append(u)       # ← insérer en fin

    for i in range(n):
        if couleur[i] == BLANC:
            dfs(i)

    return list(reversed(ordre))  # renverser pour l'ordre correct
🔧

Applications concrètes : compilation (gcc/make — compiler les dépendances en premier), gestionnaires de paquets (pip, npm, apt), ordonnancement de tâches, scripts de déploiement, feuilles de calcul (ordre des formules).

💡

Kahn vs DFS : Kahn (BFS) est plus intuitif et détecte facilement les cycles. DFS est plus compact. Les deux sont O(V+E).

Problèmes types avec solutions

Six exercices classiques d'examens et d'entretiens, couvrant les thèmes de ce cours. Chaque problème indique la technique à utiliser.

🔢 Deux sommes (Two Sum)

O(n)

Étant donné un tableau d'entiers et une cible, trouver les deux indices dont la somme des valeurs égale la cible.

Solution — Hash map O(n)
def two_sum(nums, target):
    vu = {}           # valeur → index
    for i, n in enumerate(nums):
        complement = target - n
        if complement in vu:
            return [vu[complement], i]
        vu[n] = i
    return []

print(two_sum([2,7,11,15], 9))  # [0, 1]
# Technique : complémentaire dans un dict
# Éviter la double boucle O(n²) !

🔤 Anagrammes

O(n)
Grouper les anagrammes
from collections import defaultdict

def grouper_anagrammes(mots):
    groupes = defaultdict(list)
    for mot in mots:
        cle = tuple(sorted(mot))  # "eat" → ('a','e','t')
        groupes[cle].append(mot)
    return list(groupes.values())

mots = ["eat","tea","tan","ate","nat","bat"]
print(grouper_anagrammes(mots))
# [['eat','tea','ate'], ['tan','nat'], ['bat']]

📈 Sous-tableau de somme maximale

O(n)
Algorithme de Kadane — DP O(n)
def sous_tableau_max(nums):
    max_courant = max_global = nums[0]
    for n in nums[1:]:
        # Vaut-il mieux étendre ou repartir de n ?
        max_courant = max(n, max_courant + n)
        max_global  = max(max_global, max_courant)
    return max_global

nums = [-2,1,-3,4,-1,2,1,-5,4]
print(sous_tableau_max(nums))  # 6  (sous-tableau [4,-1,2,1])

🔍 Recherche dans tableau 2D trié

O(m+n)
Coin supérieur droit — O(m+n)
def chercher_matrice(matrice, cible):
    if not matrice: return False
    ligne, col = 0, len(matrice[0]) - 1
    while ligne < len(matrice) and col >= 0:
        val = matrice[ligne][col]
        if   val == cible: return True
        elif val >  cible: col -= 1    # aller à gauche
        else:             ligne += 1  # aller en bas
    return False
# Matrice triée par lignes et colonnes
# Partir du coin haut-droit : chaque pas élimine une ligne ou colonne

🔗 Détecter un cycle dans un graphe

O(V+E)
DFS avec coloration (3 couleurs)
def a_un_cycle(n, aretes):
    BLANC, GRIS, NOIR = 0, 1, 2
    voisins = defaultdict(list)
    for u, v in aretes:
        voisins[u].append(v)
    couleur = [BLANC] * n

    def dfs(u):
        couleur[u] = GRIS
        for v in voisins[u]:
            if couleur[v] == GRIS: return True   # arête de retour = cycle
            if couleur[v] == BLANC and dfs(v): return True
        couleur[u] = NOIR
        return False

    return any(dfs(i) for i in range(n) if couleur[i] == BLANC)

🏔️ Nombre d'îles (BFS/DFS sur grille)

O(m·n)
Flood-fill par DFS
def nombre_iles(grille):
    m, n = len(grille), len(grille[0])
    compteur = 0

    def noyer(i, j):
        if i < 0 or i >= m or j < 0 or j >= n: return
        if grille[i][j] != '1': return
        grille[i][j] = '0'         # marquer visité
        for di, dj in [(1,0),(-1,0),(0,1),(0,-1)]:
            noyer(i+di, j+dj)       # submerger les voisins

    for i in range(m):
        for j in range(n):
            if grille[i][j] == '1':
                noyer(i, j)
                compteur += 1
    return compteur

Cheat sheet

Complexités à retenir

Accès tableauO(1)
Recherche binaireO(log n)
Parcours listeO(n)
Quicksort / MergesortO(n log n)
Double boucle imbriquéeO(n²)
Sous-ensemblesO(2ⁿ)
PermutationsO(n!)

Choisir un algorithme

Trier en Pythonsorted() — Timsort
Chercher dans triébisect / recherche binaire
Plus court chemin (arêtes)BFS
Explorer / cyclesDFS
Plus court chemin (poids+)Dijkstra
Plus court chemin guidéA*
Sous-problèmes répétésDP + @cache

Structures Python utiles

dequeFile BFS O(1) popleft
heapqMin-heap / Dijkstra
set / dictLookup O(1)
@cache / @lru_cacheMémoïsation auto
bisectRecherche binaire
defaultdict(list)Graphe liste d'adj.

Patterns DP classiques

Fibonacci / escalierdp[n] = dp[n-1] + dp[n-2]
Sac à dos 0/1dp[i][w] = max(sans, avec)
LCSdp[i][j] = match ou max
Levenshteindp[i][j] = 1 + min(3 voisins)
Chemin dans grilledp[i][j] = dp[i-1][j] + dp[i][j-1]