Aller au contenu principal
Brouillon non relu. Ce cours est un brouillon non relu par l'enseignant : le contenu est à faire vérifier avant toute utilisation en classe.

I1 — Algorithmes et complexité

Objectif du chapitre. Apprendre à concevoir des algorithmes (recherche séquentielle et dichotomique, tris par insertion et fusion), à introduire la récursivité (le tri fusion en dépend) et à évaluer la complexité d'un algorithme en grand OO. Ce chapitre porte les deux capacités exigibles de l'IPT : I1.dichotomie et I1.complexite-algorithme.

Prérequis

Ce chapitre suppose les bases de Python du chapitre I1 — Bases Python et représentation des nombres (variables, boucles, fonctions, listes).

Notions​

1. La récursivité (introduction)​

Avant d'aborder le tri fusion, il faut comprendre la récursivité : une fonction récursive est une fonction qui s'appelle elle-même pour résoudre un sous-problème plus petit. Deux ingrédients sont indispensables :

  1. un cas de base (la condition d'arrêt, sans laquelle la récursion ne s'arrête jamais) ;
  2. un pas récursif qui réduit le problème à un cas plus petit (qui doit tendre vers le cas de base).

Exemple : factorielle. n!=n×(n−1)!n! = n \times (n-1)!, avec 0!=10! = 1.

def factorielle(n):
if n == 0: # cas de base
return 1
return n * factorielle(n - 1) # pas récursif : problème plus petit

factorielle(4) déroule en 4 * factorielle(3) = 4 * 3 * factorielle(2) = 4 * 3 * 2 * factorielle(1) = 4 * 3 * 2 * 1 * factorielle(0) = 24. Chaque appel réduit nn de 1 jusqu'au cas de base n=0n = 0.

Exemple : somme d'une liste (récursif).

def somme(lst):
if len(lst) == 0: # cas de base
return 0
return lst[0] + somme(lst[1:]) # premier élément + somme du reste
Récursion : cas de base obligatoire

Sans cas de base (ou si le pas récursif ne réduit pas le problème), la récursion ne s'arrête jamais : on épuise la pile d'appels (erreur RecursionError en Python). Il faut toujours vérifier que le pas récursif converge vers le cas de base.

2. Recherche séquentielle et dichotomique​

Recherche séquentielle. La recherche séquentielle (linéaire) parcourt la liste élément par élément ; la complexité est O(n)O(n) (au pire : nn comparaisons) ; elle fonctionne sur une liste non triée.

Recherche dichotomique. La recherche dichotomique (binaire) divise l'intervalle de recherche en deux à chaque itération ; elle exige une liste triée ; la complexité est O(log⁡n)O(\log n) (au pire : ⌈log⁡2n⌉\lceil \log_2 n \rceil comparaisons) ; à chaque étape, on compare l'élément central à la valeur cherchée et on élimine la moitié de l'intervalle.

Exemple (dichotomie).

def recherche_dichotomique(lst, x):
a, b = 0, len(lst) - 1
while a <= b:
m = (a + b) // 2
if lst[m] == x:
return m
elif lst[m] < x:
a = m + 1
else:
b = m - 1
return -1

La complexité est O(log⁡n)O(\log n) : pour n=106n = 10^6, au plus log⁡2(106)≈20\log_2(10^6) \approx 20 comparaisons (contre 10610^6 pour la recherche séquentielle).

Déroulé pas à pas. Cherchons x=23x = 23 dans lst = [2, 5, 8, 12, 16, 23, 38, 45] (n=8n = 8) :

Étapeaabbm=(a+b)//2m=(a+b)//2lst[m]ComparaisonAction
10731212<2312 < 23a=4a = 4 (moitié droite)
24752323=2323 = 23trouvé à l'indice 5

En 2 étapes (au lieu de 6 pour la séquentielle), l'algorithme a trouvé 23. À chaque étape, l'intervalle [a,b][a, b] est divisé par 2 : c'est pourquoi le nombre d'étapes est log⁡2n\log_2 n.

3. Tris : insertion et fusion​

Tri par insertion. Le tri par insertion insère chaque élément à sa place dans la partie triée ; la complexité est O(n2)O(n^2) (au pire) mais O(n)O(n) si la liste est déjà triée ; c'est un tri stable (il préserve l'ordre des éléments égaux) et in place.

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

Tri fusion (merge sort). Le tri fusion divise la liste en deux moitiés, trie récursivement chaque moitié (récursivité du §1), puis fusionne les deux moitiés triées. La complexité est O(nlog⁡n)O(n \log n) toujours (au pire). C'est le premier exemple de récursion utile que l'on utilise :

def tri_fusion(lst):
if len(lst) <= 1: # cas de base : liste de 0 ou 1 élément déjà triée
return lst
milieu = len(lst) // 2
gauche = tri_fusion(lst[:milieu]) # tri récursif de la moitié gauche
droite = tri_fusion(lst[milieu:]) # tri récursif de la moitié droite
return fusion(gauche, droite) # fusion des deux moitiés triées

def fusion(g, d):
res = []
i = j = 0
while i < len(g) and j < len(d):
if g[i] <= d[j]:
res.append(g[i]); i += 1
else:
res.append(d[j]); j += 1
res.extend(g[i:]) # reste éventuel de g
res.extend(d[j:]) # reste éventuel de d
return res

Déroulé pas à pas. Trions [5, 2, 4, 1] :

  1. Diviser : [5, 2, 4, 1] → [5, 2] et [4, 1].
  2. Diviser : [5, 2] → [5] et [2] ; [4, 1] → [4] et [1].
  3. Fusionner (cas de base, listes de 1 élément) : [5]+[2] → [2, 5] ; [4]+[1] → [1, 4].
  4. Fusionner : [2, 5]+[1, 4] → comparer 22 et 11 (prendre 11), 22 et 44 (prendre 22), 55 et 44 (prendre 44), reste [5][5] → [1, 2, 4, 5].

Le tri fusion est stable et en O(nlog⁡n)O(n \log n) indépendamment de l'état initial de la liste (contrairement à l'insertion).

Tri rapide (complément). Le tri rapide (quick sort, hors programme) choisit un pivot, partitionne, puis trie récursivement ; la complexité est O(nlog⁡n)O(n \log n) en moyenne mais O(n2)O(n^2) au pire.

4. Complexité des algorithmes​

La complexité mesure le nombre d'opérations élémentaires (ou le temps) en fonction de la taille nn de l'entrée ; on utilise la notation asymptotique (grand OO, Ω\Omega, Θ\Theta) ; les complexités usuelles (de la plus rapide à la plus lente) sont : O(1)O(1) (constant), O(log⁡n)O(\log n) (dichotomie), O(n)O(n) (recherche linéaire), O(nlog⁡n)O(n \log n) (tri fusion, rapide), O(n2)O(n^2) (tri insertion, bulles), O(2n)O(2^n) (récurrence exponentielle), O(n!)O(n!) (permutations).

Règles. La complexité d'un algorithme séquentiel (blocs successifs) est la somme des complexités ; celle d'une boucle de kk itérations avec un corps en O(f)O(f) est O(k⋅f)O(k \cdot f) ; celle d'une récurrence T(n)=2T(n/2)+O(n)T(n) = 2T(n/2) + O(n) (tri fusion) est O(nlog⁡n)O(n \log n) — résolution directe : en déroulant la récurrence, T(n)=2kT(n/2k)+k O(n)T(n) = 2^k T(n/2^k) + k\,O(n) ; en prenant k=log⁡2nk = \log_2 n (donc n/2k=1n/2^k = 1), T(n)=n T(1)+log⁡2n⋅O(n)=O(nlog⁡n)T(n) = n\,T(1) + \log_2 n \cdot O(n) = O(n \log n) (le théorème maître — complément — généralise ce calcul) ; on ignore les termes inférieurs et les constantes (la complexité asymptotique caractérise le comportement pour n→∞n \to \infty).

Exemple. Une fonction avec une boucle de nn itérations où chaque itération appelle une recherche dichotomique sur une liste de nn éléments : la complexité est O(n⋅log⁡n)O(n \cdot \log n) (la boucle O(n)O(n) multipliée par la recherche O(log⁡n)O(\log n)) ; si la recherche était séquentielle (O(n)O(n)), la complexité serait O(n2)O(n^2).

Capacités exigibles​

  • I1.complexite-algorithme — Évaluer la complexité d'un algorithme : identifier les boucles (leur nombre d'itérations en fonction de nn) et les appels récursifs ; appliquer les règles (blocs successifs : somme ; boucle de kk itérations avec corps O(f)O(f) : O(k⋅f)O(k \cdot f) ; récurrence T(n)=aT(n/b)+O(nd)T(n) = aT(n/b) + O(n^d) : théorème maître — complément) ; exprimer la complexité en grand OO (en ignorant les termes inférieurs et les constantes) ; comparer les complexités (O(log⁡n)<O(n)<O(nlog⁡n)<O(n2)<O(2n)O(\log n) < O(n) < O(n \log n) < O(n^2) < O(2^n)) ; justifier le choix d'un algorithme (par exemple dichotomie O(log⁡n)O(\log n) vs séquentielle O(n)O(n)).
  • I1.dichotomie — Implémenter une recherche dichotomique : vérifier que la liste est triée (sinon la recherche échoue) ; initialiser les bornes a=0a = 0 et b=len(lst)−1b = \text{len}(lst) - 1 ; itérer tant que a≤ba \leq b : calculer le milieu m=(a+b)//2m = (a + b) // 2 ; comparer lst[m] à la valeur cherchée xx ; si égal, retourner mm ; si lst[m] < x, passer à la moitié droite (a=m+1a = m + 1) ; si lst[m] > x, passer à la moitié gauche (b=m−1b = m - 1) ; si la boucle se termine sans trouver xx, retourner −1-1 ; la complexité est O(log⁡n)O(\log n).

Ce qui tombe en colle et en DS​

  • Implémenter une recherche dichotomique et justifier sa complexité O(log⁡n)O(\log n).
  • Évaluer la complexité d'un algorithme donné (identifier boucles et récursions, appliquer les règles).
  • Comparer deux algorithmes (dichotomie vs séquentielle, fusion vs insertion) et justifier un choix.

Pièges classiques​

  • Dichotomie : liste triée : la recherche dichotomique exige une liste triée (sinon elle échoue ou renvoie un résultat faux) ; il ne faut pas l'appliquer à une liste non triée ; si la liste n'est pas triée, il faut d'abord la trier (O(nlog⁡n)O(n \log n)) puis rechercher (O(log⁡n)O(\log n)) ; la complexité totale est O(nlog⁡n)O(n \log n) (dominée par le tri).
  • Dichotomie : bornes incluses : la condition de boucle est a≤ba \leq b (pas a<ba < b) ; il ne faut pas oublier le cas a=ba = b (sinon on manque le dernier élément) ; les mises à jour sont a=m+1a = m + 1 et b=m−1b = m - 1 (pas a=ma = m et b=mb = m, sinon boucle infinie) ; le milieu est m=(a+b)//2m = (a + b) // 2 (pas (a+b)/2(a + b) / 2, sinon flottant).
  • Complexité : somme vs produit : la complexité d'un algorithme séquentiel (blocs successifs) est la somme des complexités ; celle d'une boucle imbriquée est le produit ; il ne faut pas confondre les deux ; par exemple deux boucles successives de nn itérations : O(n)+O(n)=O(n)O(n) + O(n) = O(n) (pas O(n2)O(n^2)) ; deux boucles imbriquées : O(n)×O(n)=O(n2)O(n) \times O(n) = O(n^2).
  • Complexité : constantes ignorées : la complexité asymptotique ignore les constantes et les termes inférieurs ; O(2n+5)=O(n)O(2n + 5) = O(n) ; la complexité caractérise le comportement pour n→∞n \to \infty (pas le nombre exact d'opérations).
  • Récursion : cas de base : sans cas de base (ou si le pas récursif ne réduit pas le problème), la récursion ne s'arrête jamais (RecursionError) ; il faut toujours vérifier que le pas récursif converge vers le cas de base.
  • Tri insertion : pire cas O(n2)O(n^2) : le tri par insertion a une complexité O(n2)O(n^2) au pire (liste inversée) mais O(n)O(n) si la liste est déjà triée ; le tri fusion a une complexité O(nlog⁡n)O(n \log n) toujours (au pire) ; le choix du tri dépend de la taille et de l'état de la liste.

Graphe de référence​

Nombre de comparaisons en fonction de la taille nn de la liste pour la recherche séquentielle (nn comparaisons au pire) et la recherche dichotomique (⌈log⁡2n⌉\lceil \log_2 n \rceil comparaisons au pire) : la courbe séquentielle est linéaire (nn) ; la courbe dichotomique est logarithmique (log⁡2n\log_2 n) ; pour n=1000n = 1000, la séquentielle fait 1000 comparaisons et la dichotomique en fait 10 ; pour n=106n = 10^6, la séquentielle fait 10610^6 comparaisons et la dichotomique en fait 20 ; la dichotomie est beaucoup plus rapide pour les grandes listes.

Comparaisons : recherche séquentielle vs dichotomique

Pour aller plus loin​

Annales utilisables sur ce chapitre : voir la page générale Ressources, exercices et annales pour les manuels recommandés et les banques d'exercices gratuites.