Cours Algorithmes Complet 🧠
Les 12 chapitres essentiels — complexité, tri, recherche, récursion, graphes et programmation dynamique
Introduction et complexité
Un algorithme est une suite finie d'instructions qui transforme une entrée en sortie pour résoudre un problème. Une recette de cuisine est un algorithme : des ingrédients (entrée), des étapes (instructions), un plat (sortie). En informatique, on mesure la qualité d'un algorithme par deux critères : sa vitesse (temps) et sa consommation mémoire (espace). Deux algorithmes peuvent résoudre le même problème — l'un en 1 seconde, l'autre en 1 heure. Savoir choisir le bon algorithme est la compétence fondamentale du développeur.
# Approche 1 : Recherche linéaire — O(n)
# On parcourt chaque élément un par un
def recherche_lineaire(liste, cible):
for element in liste:
if element == cible:
return True
return False
# Approche 2 : Recherche binaire — O(log n)
# On divise la liste en deux à chaque étape (liste triée)
def recherche_binaire(liste, cible):
gauche, droite = 0, len(liste) – 1
while gauche <= droite:
milieu = (gauche + droite) // 2
if liste[milieu] == cible:
return True
elif liste[milieu] < cible:
gauche = milieu + 1
else:
droite = milieu – 1
return False
# Sur 1 milliard d'éléments :
# Recherche linéaire : jusqu'à 1 000 000 000 comparaisons
# Recherche binaire : maximum 30 comparaisons (log₂ de 10⁹)
Notation Big O
| Big O | Nom | n = 1M | Exemple | Verdict |
|---|---|---|---|---|
| O(1) | Constant | 1 op | Accès array par index, hashmap get | 🟢 Idéal |
| O(log n) | Logarithmique | 20 ops | Recherche binaire, arbre BST | 🟢 Excellent |
| O(n) | Linéaire | 1M ops | Parcours de liste, recherche linéaire | 🟢 Bon |
| O(n log n) | Linéarithmique | 20M ops | Merge sort, quick sort, Tim sort | 🟡 Correct |
| O(n²) | Quadratique | 10¹² ops | Bubble sort, 2 boucles imbriquées | 🔴 Lent |
| O(2ⁿ) | Exponentiel | ∞ | Fibonacci récursif naïf, sous-ensembles | 🔴 Inutilisable |
| O(n!) | Factoriel | ∞ | Permutations, voyageur de commerce brut | 🔴 Impossible |
# O(n² + n + 1000) → O(n²) — n² domine quand n est grand
# Règle 2 : On ignore les constantes
# O(3n) → O(n) — la constante 3 disparaît
# Règle 3 : Boucles imbriquées = multiplication
for i in range(n): # O(n)
for j in range(n): # × O(n)
print(i, j) # = O(n²)
# Règle 4 : Boucles séquentielles = addition
for i in range(n): # O(n)
print(i)
for j in range(m): # + O(m)
print(j) # = O(n + m)
# Règle 5 : Diviser par 2 à chaque itération = O(log n)
while n > 1:
n = n // 2 # O(log n)
# Complexité spatiale (mémoire)
# O(1) = pas de mémoire supplémentaire (in-place)
# O(n) = copie de la liste (ex: merge sort)
# O(n²) = matrice n×n
Recherche
def recherche_lineaire(arr, cible):
for i, val in enumerate(arr):
if val == cible:
return i
return –1
# RECHERCHE BINAIRE — O(log n) — ⚠️ liste TRIÉE obligatoire
def recherche_binaire(arr, cible):
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
# Variante : bisect (Python standard library)
import bisect
i = bisect.bisect_left(arr, cible) # Position d'insertion
found = i < len(arr) and arr[i] == cible
# Variante : trouver le premier élément ≥ cible
def lower_bound(arr, cible):
g, d = 0, len(arr)
while g < d:
m = (g + d) // 2
if arr[m] < cible:
g = m + 1
else:
d = m
return g
La recherche binaire est l'algorithme le plus important à maîtriser. Elle divise l'espace de recherche en deux à chaque itération. Applicable partout : tableaux triés, recherche de seuil, minimiser/maximiser une valeur, trouver une racine. Piège classique : (g + d) // 2 peut overflow en Java/C++ — utiliser g + (d – g) // 2.
Tri basique
# Idée : trouver le minimum, le placer au début, répéter
def tri_selection(arr):
for i in range(len(arr)):
min_idx = i
for j in range(i + 1, len(arr)):
if arr[j] < arr[min_idx]:
min_idx = j
arr[i], arr[min_idx] = arr[min_idx], arr[i]
# TRI PAR INSERTION — O(n²) pire cas, O(n) meilleur cas
# Idée : insérer chaque élément à sa bonne place
# ✅ Très rapide sur des données presque triées
def tri_insertion(arr):
for i in range(1, len(arr)):
cle = arr[i]
j = i – 1
while j >= 0 and arr[j] > cle:
arr[j + 1] = arr[j]
j -= 1
arr[j + 1] = cle
# TRI À BULLES — O(n²) — le plus simple mais le plus lent
# Idée : comparer les éléments adjacents, échanger si mal placés
def tri_bulle(arr):
n = len(arr)
for i in range(n):
swapped = False
for j in range(0, n – i – 1):
if arr[j] > arr[j + 1]:
arr[j], arr[j + 1] = arr[j + 1], arr[j]
swapped = True
if not swapped: # Optimisation : arrêter si déjà trié
break
Ces algorithmes O(n²) sont pédagogiques mais inutilisables en production sur de grandes données. Utilisez-les seulement pour comprendre les bases du tri. En pratique, utilisez les algorithmes O(n log n) du chapitre suivant. Seul le tri par insertion reste utile : il est optimal sur les petites listes (< 50 éléments) et les données presque triées — c'est pour ça que Tim sort (Python) l'utilise en interne.
Tri avancé
# 1. Diviser la liste en deux moitiés
# 2. Trier chaque moitié récursivement
# 3. Fusionner les deux moitiés triées
def merge_sort(arr):
if len(arr) <= 1:
return arr
mid = len(arr) // 2
gauche = merge_sort(arr[:mid])
droite = merge_sort(arr[mid:])
return fusionner(gauche, droite)
def fusionner(g, d):
resultat = []
i = j = 0
while i < len(g) and j < len(d):
if g[i] <= d[j]: # <= pour la stabilité
resultat.append(g[i]); i += 1
else:
resultat.append(d[j]); j += 1
resultat.extend(g[i:])
resultat.extend(d[j:])
return resultat
# 1. Choisir un pivot
# 2. Placer les éléments < pivot à gauche, > pivot à droite
# 3. Récursion sur chaque côté
def quick_sort(arr):
if len(arr) <= 1:
return arr
pivot = arr[len(arr) // 2] # Pivot au milieu (évite O(n²) trié)
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 quick_sort(gauche) + milieu + quick_sort(droite)
| Algorithme | Temps moyen | Pire cas | Espace | Stable | Usage |
|---|---|---|---|---|---|
| Merge sort | O(n log n) | O(n log n) | O(n) | ✅ Oui | Données sur disque, listes chaînées |
| Quick sort | O(n log n) | O(n²) | O(log n) | ❌ Non | Arrays en mémoire (le plus rapide en pratique) |
| Tim sort | O(n log n) | O(n log n) | O(n) | ✅ Oui | Python sorted(), Java Arrays.sort() |
| Heap sort | O(n log n) | O(n log n) | O(1) | ❌ Non | Mémoire limitée (in-place garanti) |
| Counting sort | O(n + k) | O(n + k) | O(k) | ✅ Oui | Petites valeurs entières (0 à k) |
En pratique, utilisez simplement sorted() en Python ou .sort() en JavaScript. Ces fonctions utilisent Tim sort (hybride merge sort + insertion sort), optimal dans 99% des cas. Comprenez merge sort et quick sort pour les entretiens techniques et pour savoir pourquoi les tris naïfs O(n²) sont mauvais.
Récursion
# 1. CAS DE BASE — quand s'arrêter (sinon boucle infinie)
# 2. CAS RÉCURSIF — appeler la fonction sur un sous-problème
# Factorielle : n! = n × (n-1)!
def factorielle(n):
if n <= 1: # Cas de base
return 1
return n * factorielle(n – 1) # Cas récursif
# Fibonacci NAÏF — O(2ⁿ) ❌ TERRIBLE
def fib_naif(n):
if n <= 1: return n
return fib_naif(n–1) + fib_naif(n–2) # Recalcule tout !
# Fibonacci avec MÉMOÏSATION — O(n) ✅
from functools import lru_cache
@lru_cache(maxsize=None)
def fib_memo(n):
if n <= 1: return n
return fib_memo(n–1) + fib_memo(n–2)
# Fibonacci ITÉRATIF — O(n) temps, O(1) espace ✅✅
def fib_iter(n):
a, b = 0, 1
for _ in range(n):
a, b = b, a + b
return a
Fibonacci illustre le piège de la récursion naïve : fib(50) naïf prend des millions d'années car il recalcule les mêmes valeurs des milliards de fois. fib_memo(50) prend des microsecondes. Si vous voyez une récursion avec des sous-problèmes qui se répètent → c'est de la programmation dynamique (chapitre 8).
Algorithmes de graphes
# Ex : réseau social, carte routière, dépendances de packages
# Représentation : liste d'adjacence (la plus courante)
graphe =
# BFS — Parcours en largeur — O(V + E)
# Explore niveau par niveau (file/queue)
# ✅ Chemin le plus court (graphe non pondéré)
from collections import deque
def bfs(graphe, depart):
visite = set()
queue = deque([depart])
visite.add(depart)
while queue:
noeud = queue.popleft()
print(noeud, end= » « )
for voisin in graphe[noeud]:
if voisin not in visite:
visite.add(voisin)
queue.append(voisin)
# DFS — Parcours en profondeur — O(V + E)
# Explore le plus loin possible (pile/stack)
# ✅ Détection de cycles, tri topologique
def dfs(graphe, noeud, visite=None):
if visite is None:
visite = set()
visite.add(noeud)
print(noeud, end= » « )
for voisin in graphe[noeud]:
if voisin not in visite:
dfs(graphe, voisin, visite)
# ✅ Chemin le plus court dans un graphe PONDÉRÉ (poids ≥ 0)
import heapq
def dijkstra(graphe, depart):
distances =
distances[depart] = 0
heap = [(0, depart)] # (distance, noeud)
while heap:
dist, noeud = heapq.heappop(heap)
if dist > distances[noeud]:
continue
for voisin, poids in graphe[noeud]:
nouvelle_dist = dist + poids
if nouvelle_dist < distances[voisin]:
distances[voisin] = nouvelle_dist
heapq.heappush(heap, (nouvelle_dist, voisin))
return distances
# Graphe pondéré (noeud → [(voisin, poids)])
graphe_p =
print(dijkstra(graphe_p, « A »))
# →
Programmation dynamique
La programmation dynamique (DP) résout un problème complexe en le décomposant en sous-problèmes qui se chevauchent. Au lieu de recalculer les mêmes résultats (comme Fibonacci naïf), on les stocke dans un tableau. Deux approches : top-down (récursion + mémoïsation) ou bottom-up (itératif, remplir un tableau du plus petit au plus grand). La DP transforme des problèmes exponentiels O(2ⁿ) en polynomiaux O(n²) ou O(n).
# Objets : [(poids, valeur)]
# Capacité du sac : W
def knapsack(objets, W):
n = len(objets)
# dp[i][w] = valeur max avec les i premiers objets et capacité w
dp = [[0] * (W + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
poids, valeur = objets[i – 1]
for w in range(W + 1):
if poids > w:
dp[i][w] = dp[i–1][w] # Ne prend pas l'objet
else:
dp[i][w] = max(
dp[i–1][w], # Ne prend pas
dp[i–1][w – poids] + valeur # Prend l'objet
)
return dp[n][W]
objets = [(2, 3), (3, 4), (4, 5), (5, 6)] print(knapsack(objets, 8)) # → 10
# « ABCDE » et « ACE » → « ACE » (longueur 3)
# Utilisé dans : diff (git), bioinformatique, correcteurs
def lcs(s1, s2):
m, n = len(s1), len(s2)
dp = [[0] * (n + 1) for _ in range(m + 1)]
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] + 1
else:
dp[i][j] = max(dp[i–1][j], dp[i][j–1])
return dp[m][n]
Algorithmes gloutons
# Rapide et simple, mais ne donne PAS toujours la solution optimale
# RENDU DE MONNAIE — Glouton fonctionne avec les pièces standard
def rendu_monnaie(montant, pieces=[200, 100, 50, 20, 10, 5, 2, 1]):
resultat = []
for piece in pieces:
while montant >= piece:
resultat.append(piece)
montant -= piece
return resultat
print(rendu_monnaie(167)) # → [100, 50, 10, 5, 2]
# PLANIFICATION D'ACTIVITÉS — trier par fin, prendre si compatible
def activites_max(activites):
# activites = [(debut, fin), …]
activites.sort(key=lambda x: x[1]) # Trier par heure de fin
resultat = [activites[0]]
for debut, fin in activites[1:]:
if debut >= resultat[–1][1]: # Pas de chevauchement
resultat.append((debut, fin))
return resultat
Glouton ≠ toujours optimal. Le rendu de monnaie avec les pièces [1, 3, 4] : pour 6, le glouton donne [4, 1, 1] (3 pièces) mais l'optimal est [3, 3] (2 pièces). Quand le glouton ne marche pas → programmation dynamique. Quand il marche, c'est plus rapide et plus simple que la DP.
Backtracking
# Si un chemin ne mène nulle part → revenir en arrière (backtrack)
# PERMUTATIONS — générer toutes les permutations
def permutations(arr):
resultat = []
def backtrack(chemin, restant):
if not restant:
resultat.append(chemin[:])
return
for i in range(len(restant)):
chemin.append(restant[i])
backtrack(chemin, restant[:i] + restant[i+1:])
chemin.pop() # ← Backtrack !
backtrack([], arr)
return resultat
# N-QUEENS — placer N reines sans conflit
def n_queens(n):
solutions = []
def backtrack(row, cols, diag1, diag2, board):
if row == n:
solutions.append([r[:] for r in board])
return
for col in range(n):
if col in cols or (row–col) in diag1 or (row+col) in diag2:
continue
board[row][col] = « Q »
backtrack(row+1, cols|, diag1|, diag2|, board)
board[row][col] = « . » # ← Backtrack
board = [[« . »] * n for _ in range(n)]
backtrack(0, set(), set(), set(), board)
return solutions
Le backtracking est la base de la résolution de puzzles : Sudoku, N-Queens, labyrinthe, mots croisés. Le pattern est toujours le même : faire un choix → explorer → annuler le choix (backtrack). Souvent O(n!) ou O(2ⁿ) — on accélère avec du pruning (élagage) en coupant les branches impossibles le plus tôt possible.
Entretiens techniques
| Pattern | Quand l'utiliser | Exemple |
|---|---|---|
| Two Pointers | Array trié, paire avec somme = cible | Two Sum (array trié), palindrome |
| Sliding Window | Sous-array/substring contigu(ë) optimal(e) | Max sum subarray, longest substring |
| HashMap/Set | Lookup O(1), comptage, doublons | Two Sum, anagrammes, fréquences |
| Binary Search | Donnée triée, chercher un seuil | Search in rotated array, Koko bananas |
| BFS / DFS | Graphes, arbres, matrices | Nombre d'îles, chemin le plus court |
| Dynamic Programming | Sous-problèmes qui se chevauchent | Climbing stairs, coin change, LCS |
| Backtracking | Explorer toutes les combinaisons | Permutations, N-Queens, Sudoku |
| Stack/Queue | Parenthèses, monotone stack | Valid parentheses, next greater element |
def two_sum_sorted(arr, cible):
g, d = 0, len(arr) – 1
while g < d:
s = arr[g] + arr[d] if s == cible:
return [g, d] elif s < cible:
g += 1
else:
d -= 1
return []
# SLIDING WINDOW — Somme max d'un sous-array de taille k — O(n)
def max_sum_k(arr, k):
window = sum(arr[:k])
max_sum = window
for i in range(k, len(arr)):
window += arr[i] – arr[i – k] # Glisser la fenêtre
max_sum = max(max_sum, window)
return max_sum
Bonnes pratiques
| Problème | Technique | Complexité |
|---|---|---|
| Chercher dans une liste triée | Recherche binaire | O(log n) |
| Trier des données | sorted() / Tim sort | O(n log n) |
| Chercher un élément | HashMap / Set | O(1) amorti |
| Chemin le plus court (non pondéré) | BFS | O(V + E) |
| Chemin le plus court (pondéré) | Dijkstra | O((V+E) log V) |
| Sous-problèmes qui se chevauchent | Programmation dynamique | Variable |
| Choix localement optimal suffit | Glouton | O(n log n) souvent |
| Explorer toutes les combinaisons | Backtracking | O(2ⁿ) ou O(n!) |
| Sous-array/substring contigu | Sliding window | O(n) |
| Paire dans un array trié | Two pointers | O(n) |
✅ À FAIRE
• Analyser la complexité AVANT de coder
• Commencer par la solution brute force
• Identifier le pattern (two pointers, DP, BFS…)
• Tester avec des cas limites (vide, 1 élément, max)
• Utiliser les structures de données standards (dict, set, deque)
• sorted() en Python = Tim sort O(n log n)
• bisect pour la recherche binaire
• heapq pour les files de priorité
• Apprendre les 8 patterns d'entretien
• Pratiquer sur LeetCode / HackerRank
❌ À ÉVITER
• Optimiser prématurément (d'abord correct, puis rapide)
• Boucles imbriquées inutiles (O(n²) → O(n) avec hashmap)
• Récursion sans cas de base (stack overflow)
• Fibonacci naïf O(2ⁿ) (utiliser mémoïsation/itératif)
• Tri à bulles en production (sorted() est toujours mieux)
• Ignorer la complexité spatiale (O(n²) mémoire = crash)
• Copier des listes dans une boucle (O(n²) caché)
• DFS récursif sur des données très profondes (pile Python limitée)
• Supposer que l'input est valide sans vérifier
• Confondre stable/instable (order matters)
🏠 Hub Programmation
📦 Cours Structures de données
🐍 Cours Python
⚡ Cours JavaScript
☕ Cours Java
