CPGE 2ᵉ année — MP / PSI · Informatique

Les algorithmes de tri

Pr. EL HADIQ Zouhair

Au-delà des tris simples vus en première année, deux méthodes efficaces fondées sur « diviser pour régner » : le tri rapide et le tri par fusion.

🎯 Objectifs du chapitre
Sommaire
  1. Rappels : les tris simples de première année
  2. Deux propriétés clés : tri en place et stabilité
  3. Le tri rapide (quicksort)
  4. Le tri par fusion (merge sort)
  5. Comparaison des algorithmes de tri

1. Rappels : les tris simples de première année

En première année, trois tris ont été étudiés. Tous ont une complexité quadratique \(O(n^2)\) dans le pire des cas, ce qui les rend lents sur de grandes listes.

Tri Principe Complexité (pire cas)
Sélection Chercher le minimum et le placer en tête, puis recommencer sur le reste. \(O(n^2)\)
Insertion Insérer chaque élément à sa place dans la partie déjà triée. \(O(n^2)\)
À bulles Échanger les éléments adjacents mal ordonnés, en plusieurs passes. \(O(n^2)\)

L'objectif de ce chapitre est d'atteindre une complexité bien meilleure, \(O(n \log n)\). Reprenons d'abord en détail les deux tris simples les plus utilisés.

1.1 Le tri par sélection

Principe. À l'étape \(i\), on cherche le minimum de la partie non triée \(t[i..n-1]\) et on l'échange avec \(t[i]\). La partie gauche grandit d'une case à chaque étape jusqu'à ce que tout le tableau soit trié.

Exemple de déroulement sur le tableau \(t = [29,\ 10,\ 14,\ 37,\ 13]\). À chaque passe, le minimum de la zone non triée (en gras) est amené en tête de cette zone :

Passe Zone non triée Minimum trouvé Tableau après échange
1 [29, 10, 14, 37, 13] 10 (indice 1) 10 | 29, 14, 37, 13
2 29, 14, 37, 13 13 (indice 4) 10, 13 | 14, 37, 29
3 14, 37, 29 14 (déjà en place) 10, 13, 14 | 37, 29
4 37, 29 29 (indice 4) 10, 13, 14, 29 | 37
Fin 10, 13, 14, 29, 37

Implémentation (en place). Une seule application directe du principe :

def tri_selection(t):
    n = len(t)
    for i in range(n - 1):
        imin = i
        for j in range(i + 1, n):
            if t[j] < t[imin]:
                imin = j
        t[i], t[imin] = t[imin], t[i]   # échange le minimum en position i
    return t
Exemple d'application. Comme le tri par sélection effectue au plus \(n-1\) échanges (un seul par passe), il est adapté aux situations où écrire une donnée coûte cher : tri de gros enregistrements en mémoire, ou supports dont le nombre d'écritures est limité (mémoires flash). Il minimise le nombre de déplacements, même s'il fait toujours autant de comparaisons.
Exemple de calcul de complexité. À la passe \(i\) (pour \(i\) de \(0\) à \(n-2\)), la recherche du minimum de \(t[i..n-1]\) demande \(n-1-i\) comparaisons. Le nombre total de comparaisons est donc \[ C(n) = \sum_{i=0}^{n-2} (n-1-i) = (n-1) + (n-2) + \dots + 1 = \frac{n(n-1)}{2}. \] Ce nombre ne dépend pas du contenu du tableau : la complexité est \(\Theta(n^2)\) dans tous les cas (meilleur, moyen, pire). Les échanges, eux, sont au plus \(n-1\), soit \(O(n)\).

1.2 Le tri par insertion

Principe. On parcourt le tableau de gauche à droite. À l'étape \(i\), l'élément \(t[i]\) est inséré à sa place dans la partie déjà triée \(t[0..i-1]\), en le décalant vers la gauche tant qu'il est plus petit que son voisin.

def tri_insertion(t):
    for i in range(1, len(t)):
        cle = t[i]
        j = i - 1
        while j >= 0 and t[j] > cle:
            t[j + 1] = t[j]
            j -= 1
        t[j + 1] = cle
    return t
✍️ Exercice (à faire avant de regarder la solution).
  1. Déroulez le tri par insertion sur le tableau \(t = [5,\ 2,\ 4,\ 6,\ 1,\ 3]\) en donnant l'état du tableau après chaque insertion.
  2. Calculez la complexité du tri par insertion dans le meilleur et dans le pire des cas.
👁️ Afficher / masquer la solution

1. Déroulement sur \([5, 2, 4, 6, 1, 3]\). La barre | sépare la partie triée (à gauche) du reste.

Élément inséré Tableau après insertion
départ5 | 2, 4, 6, 1, 3
22, 5 | 4, 6, 1, 3
42, 4, 5 | 6, 1, 3
62, 4, 5, 6 | 1, 3
11, 2, 4, 5, 6 | 3
31, 2, 3, 4, 5, 6

2. Complexité. Notons \(c_i\) le nombre de décalages effectués pour insérer \(t[i]\).

Meilleur cas — tableau déjà trié : la condition t[j] > cle est fausse immédiatement, donc \(1\) comparaison par étape et \(0\) décalage. Total \(= n-1 = \Theta(n)\).

Pire cas — tableau trié à l'envers : insérer \(t[i]\) demande \(i\) décalages. Le total est \[ \sum_{i=1}^{n-1} i = \frac{n(n-1)}{2} = \Theta(n^2). \]

Le tri par insertion est donc \(O(n^2)\) au pire, mais très efficace sur des données presque triées (proche de \(O(n)\)) — c'est sa grande qualité par rapport au tri par sélection.

2. Deux propriétés clés : tri en place et stabilité

Pour caractériser un algorithme de tri, deux propriétés sont importantes :

Tri en place. Un tri est en place s'il n'utilise qu'une quantité de mémoire supplémentaire constante \(O(1)\), en réorganisant les éléments à l'intérieur du tableau d'origine (sans créer de copie de taille n).
Stabilité. Un tri est stable s'il conserve l'ordre relatif initial des éléments ayant une clé égale. Utile lorsqu'on trie selon plusieurs critères successifs.
Définition mathématique. Soit un tableau \(t = (t_0, t_1, \dots, t_{n-1})\) muni d'une fonction de clé \(\text{cl\'e}\). Un tri est stable si, pour tout couple d'indices \(i < j\) tel que \(\text{cl\'e}(t_i) = \text{cl\'e}(t_j)\), l'élément issu de l'indice \(i\) apparaît avant celui issu de l'indice \(j\) dans le tableau trié.
\( i < j \ \text{ et } \ \text{cl\'e}(t_i) = \text{cl\'e}(t_j) \quad \Longrightarrow \quad \sigma(i) < \sigma(j) \)
où \(\sigma\) est la permutation appliquée par le tri (position finale de chaque élément).

Pour voir la différence, ajoutons une étiquette à chaque valeur et trions selon la valeur uniquement. Partons de \([\,(3,a),\ (3,c),\ (2,b)\,]\) (l'étiquette \(a\) est avant \(c\) au départ) :

Tri Résultat Ordre de (3,a) et (3,c)
Tri par insertion (stable) (2,b), (3,a), (3,c) conservé : a avant c ✔
Tri par sélection (non stable) (2,b), (3,c), (3,a) inversé : c avant a ✘

Pourquoi le tri par sélection casse l'ordre ici : à la première passe, le minimum \(2\) (étiquette \(b\), en position 2) est échangé avec la position 0, occupée par \((3,a)\). Cet échange envoie \((3,a)\) après \((3,c)\), détruisant leur ordre initial. C'est le mécanisme d'échange « à distance » qui rend le tri par sélection non stable.

Application concrète : on trie une liste de personnes déjà triées par prénom, cette fois par âge. Un tri stable garde, parmi les personnes de même âge, l'ordre alphabétique acquis précédemment ; un tri non stable peut le détruire.

3. Le tri rapide (quicksort)

Principe (diviser pour régner). On choisit un élément appelé pivot. On réorganise la liste (étape de partition) de sorte que les éléments plus petits que le pivot soient à sa gauche et les plus grands à sa droite. Le pivot est alors à sa place définitive. On applique ensuite récursivement le même procédé aux deux sous-listes gauche et droite.

Illustration graphique (arbre d'appels) sur \([3,\ 8,\ 1,\ 5,\ 2]\), pivot = premier élément. Chaque nœud montre la liste reçue ; le pivot est en rouge, et les flèches mènent aux sous-listes « \(\leq\) pivot » (gauche) et « \(>\) pivot » (droite). On remonte ensuite en concaténant gauche + pivot + droite.

≤3 >3 ≤1 >1 ≤8 3, 8, 1, 5, 2 1, 2 8, 5 [ ] [2] [5] Résultat en remontant : (1,2) + 3 + (5,8) [1, 2, 3, 5, 8]

Une implémentation simple et lisible (non en place, qui construit deux sous-listes) :

def tri_rapide(L):
    if len(L) <= 1:
        return L
    pivot = L[0]
    petits = [x for x in L[1:] if x <= pivot]
    grands = [x for x in L[1:] if x > pivot]
    return tri_rapide(petits) + [pivot] + tri_rapide(grands)

L'étape de partition d'une liste de taille \(n\) parcourt les \(n-1\) autres éléments : son coût est \(\Theta(n)\). Notons \(T(n)\) le coût total du tri. Détaillons les deux cas extrêmes.

Meilleur des cas — pivot toujours « médian ». La partition coupe la liste en deux moitiés de taille \(\approx n/2\) : \[ T(n) = 2\,T\!\left(\tfrac{n}{2}\right) + c\,n. \] En déroulant, au niveau \(k\) il y a \(2^k\) sous-listes de taille \(n/2^k\), donc un travail de \(2^k \cdot c\,\tfrac{n}{2^k} = c\,n\) par niveau. L'arbre a \(\log_2 n\) niveaux, d'où \[ T(n) = c\,n \times \log_2 n = \Theta(n \log n). \]
Pire des cas — pivot toujours extrémal (minimum ou maximum, par ex. sur une liste déjà triée avec ce choix de pivot). Une seule sous-liste est non vide et perd un seul élément à chaque étape : \[ T(n) = T(n-1) + c\,n. \] En déroulant : \[ T(n) = c\,n + c\,(n-1) + \dots + c\cdot 1 = c\,\frac{n(n+1)}{2} = \Theta(n^2). \]

En moyenne (pivot quelconque), on démontre que le coût reste \(\Theta(n \log n)\) : le pire cas est rare dès que le pivot est bien choisi.

Choix du pivot. Prendre systématiquement le premier élément rend le pire cas fréquent (listes déjà triées). On préfère un pivot aléatoire, ou la médiane de trois (premier, milieu, dernier), pour rendre le pire cas très improbable et garantir \(O(n \log n)\) en pratique.

Le tri rapide « classique » (avec partition à l'intérieur du tableau) est en place (\(O(1)\) d'espace auxiliaire, hormis la pile de récursion) mais non stable.

4. Le tri par fusion (merge sort)

Principe (diviser pour régner). On coupe la liste en deux moitiés, on trie récursivement chacune, puis on fusionne les deux moitiés triées en une seule liste triée. La fusion parcourt les deux listes en parallèle et choisit à chaque étape le plus petit élément de tête.

Illustration graphique (arbre de division puis de fusion) sur \([3,\ 8,\ 1,\ 5,\ 2]\). On descend en coupant en deux jusqu'aux singletons, puis on remonte en fusionnant :

3, 8, 1, 5, 2 3, 8 1, 5, 2 [3] [8] [1] 5, 2 [5] [2] Fusions : [3,8] · [2,5] → [1,2,5] · puis tout → [1, 2, 3, 5, 8]

L'étape de fusion de deux listes déjà triées :

def fusion(A, B):
    C = []
    i, j = 0, 0
    while i < len(A) and j < len(B):
        if A[i] <= B[j]:
            C.append(A[i]); i += 1
        else:
            C.append(B[j]); j += 1
    return C + A[i:] + B[j:]

Le tri par fusion lui-même :

def tri_fusion(L):
    if len(L) <= 1:
        return L
    m = len(L) // 2
    gauche = tri_fusion(L[:m])
    droite = tri_fusion(L[m:])
    return fusion(gauche, droite)
✍️ Exercice (à faire avant de regarder la solution).
  1. Déroulez la fusion des deux listes triées \(A = [1,\ 4,\ 7]\) et \(B = [2,\ 3,\ 9]\), étape par étape.
  2. Établissez la relation de récurrence vérifiée par \(T(n)\), le coût du tri par fusion, et déduisez-en la complexité dans le pire des cas.
👁️ Afficher / masquer la solution

1. Fusion de \(A=[1,4,7]\) et \(B=[2,3,9]\). À chaque étape on compare les têtes \(A[i]\) et \(B[j]\) et on prend la plus petite.

Comparaison Élément ajouté C (résultat)
1 ≤ 21 (de A)[1]
4 > 22 (de B)[1, 2]
4 > 33 (de B)[1, 2, 3]
4 ≤ 94 (de A)[1, 2, 3, 4]
7 ≤ 97 (de A)[1, 2, 3, 4, 7]
A vide9 (reste de B)[1, 2, 3, 4, 7, 9]

La fusion de deux listes de tailles \(m\) et \(p\) coûte \(\Theta(m+p)\) : chaque élément est ajouté une seule fois.

2. Récurrence et pire cas. Le tri coupe la liste en deux moitiés de taille \(n/2\), les trie, puis les fusionne en \(\Theta(n)\). D'où, dans tous les cas : \[ T(n) = 2\,T\!\left(\tfrac{n}{2}\right) + c\,n. \] L'arbre de division a \(\log_2 n\) niveaux ; à chaque niveau, l'ensemble des fusions traite \(n\) éléments, soit un coût \(c\,n\) par niveau. Donc \[ T(n) = c\,n \times \log_2 n = \Theta(n \log n). \] Contrairement au tri rapide, la coupe en deux moitiés égales est garantie quel que soit le contenu : il n'y a pas de pire cas dégradé. La complexité est \(\Theta(n\log n)\) au meilleur, en moyenne et au pire.

En contrepartie, le tri par fusion n'est pas en place : il nécessite \(O(n)\) de mémoire supplémentaire pour les listes fusionnées. Il est en revanche stable (à condition d'utiliser <= dans la fusion).

5. Comparaison des algorithmes de tri

Tri Moyenne Pire cas En place Stable
Sélection \(O(n^2)\) \(O(n^2)\) Oui Non
Insertion \(O(n^2)\) \(O(n^2)\) Oui Oui
Tri rapide \(O(n \log n)\) \(O(n^2)\) Oui Non
Tri fusion \(O(n \log n)\) \(O(n \log n)\) Non Oui
✅ Points essentiels.

CPGE MP/PSI — Informatique · Chapitre : Les algorithmes de tri · Conforme au programme officiel 2023