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.
| Notation | Nom | Exemple | n = 1 000 |
|---|---|---|---|
| O(1) | Constant | Accès tableau par index | 1 op. |
| O(log n) | Logarithmique | Recherche binaire | ~10 op. |
| O(n) | Linéaire | Parcours de liste | 1 000 op. |
| O(n log n) | Quasi-linéaire | Quicksort, Mergesort | ~10 000 op. |
| O(n²) | Quadratique | Tri à bulles, double boucle | 1 000 000 op. |
| O(2ⁿ) | Exponentiel | Sous-ensembles, force brute | 2^1000 — impossible |
| O(n!) | Factoriel | Permutations, 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).
# 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
| Algorithme | Meilleur cas | Cas moyen | Pire cas | Mémoire | Stable | Idé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²).
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.
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.
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)
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.
# 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.
# 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'adjacence | Matrice d'adjacence | |
|---|---|---|
| Mémoire | O(V + E) | O(V²) |
| Voisins de X | O(degré) | O(V) |
| X et Y liés ? | O(degré) | O(1) |
| Idéal pour | Graphes épars | Graphes 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.
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.
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.
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.
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.
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
| Heuristique | Contexte |
|---|---|
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 surestimer | Rè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.
# 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)
# 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.
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]))
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.
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
# 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).
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 \ w | 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
| 0 (aucun) | 0 | 0 | 0 | 0 | 0 | 0 |
| 1 (p=2,v=3) | 0 | 0 | 3 | 3 | 3 | 3 |
| 2 (p=3,v=4) | 0 | 0 | 3 | 4 | 4 | 7 |
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.
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"
| "" | A | C | |
|---|---|---|---|
| "" | 0 | 0 | 0 |
| A | 0 | 1 | 1 |
| B | 0 | 1 | 1 |
| C | 0 | 1 | 2 |
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.
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.
# 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é
# 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
# 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 !
| Algo | Meilleur | Moyen | Pire | Stable | Avantage clé |
|---|---|---|---|---|---|
| Bulles | O(n)* | O(n²) | O(n²) | ✓ | Détecte si trié |
| Insertion | O(n) | O(n²) | O(n²) | ✓ | Efficace si presque trié |
| Sélection | O(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.
| Algorithme | Temps | Espace | Explication mémoire |
|---|---|---|---|
| Tri bulles / insertion / sélection | O(n²) | O(1) | Tri en place — seule une variable de swap |
| Quicksort | O(n log n) | O(log n) | Pile des appels récursifs (profondeur log n) |
| Merge sort | O(n log n) | O(n) | Tableaux auxiliaires pour fusionner |
| BFS / DFS | O(V + E) | O(V) | File/pile + tableau des visités |
| Dijkstra | O(E log V) | O(V) | Distances + file de priorité |
| Floyd-Warshall | O(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/1 | O(n·W) | O(n·W) | Matrice n×W — optimisable en O(W) |
# 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.
# 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
# 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
# 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).
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
| Algo | Temps | Espace | Poids négatifs | Utilisation |
|---|---|---|---|---|
| BFS | O(V+E) | O(V) | ✗ | Graphes non pondérés |
| Dijkstra | O(E log V) | O(V) | ✗ | 1 source, poids positifs |
| Bellman-Ford | O(V·E) | O(V) | ✓ | 1 source, poids négatifs |
| Floyd-Warshall | O(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 −∞.
# 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).
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
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)
Étant donné un tableau d'entiers et une cible, trouver les deux indices dont la somme des valeurs égale la cible.
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
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
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é
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
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)
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 tableau | O(1) |
| Recherche binaire | O(log n) |
| Parcours liste | O(n) |
| Quicksort / Mergesort | O(n log n) |
| Double boucle imbriquée | O(n²) |
| Sous-ensembles | O(2ⁿ) |
| Permutations | O(n!) |
Choisir un algorithme
| Trier en Python | sorted() — Timsort |
| Chercher dans trié | bisect / recherche binaire |
| Plus court chemin (arêtes) | BFS |
| Explorer / cycles | DFS |
| Plus court chemin (poids+) | Dijkstra |
| Plus court chemin guidé | A* |
| Sous-problèmes répétés | DP + @cache |
Structures Python utiles
| deque | File BFS O(1) popleft |
| heapq | Min-heap / Dijkstra |
| set / dict | Lookup O(1) |
| @cache / @lru_cache | Mémoïsation auto |
| bisect | Recherche binaire |
| defaultdict(list) | Graphe liste d'adj. |
Patterns DP classiques
| Fibonacci / escalier | dp[n] = dp[n-1] + dp[n-2] |
| Sac à dos 0/1 | dp[i][w] = max(sans, avec) |
| LCS | dp[i][j] = match ou max |
| Levenshtein | dp[i][j] = 1 + min(3 voisins) |
| Chemin dans grille | dp[i][j] = dp[i-1][j] + dp[i][j-1] |