17 - Algorithmes de tri

Exercice 1 : QCM – vérification des prérequis

Pour chaque question, une seule réponse est correcte.

1. Qu’est-ce qu’un tri « en place » ?

  • A. Un tri qui ne modifie pas la liste d’origine
  • B. Un tri qui trie directement dans la structure de données initiale, sans créer de nouvelle liste
  • C. Un tri qui place chaque élément à une position aléatoire
  • D. Un tri qui fonctionne uniquement sur des listes d’entiers
Correction

Réponse : B. Un tri en place modifie directement la liste passée en paramètre sans allouer de structure auxiliaire de même taille.

  • A est faux : c’est la définition d’un tri non en place (qui crée une copie).
  • C est faux : cela n’a aucun rapport avec le tri.
  • D est faux : le caractère « en place » ne dépend pas du type des éléments.

2. Qu’est-ce qu’un tri « stable » ?

  • A. Un tri qui ne plante jamais
  • B. Un tri dont la complexité ne varie pas selon les données
  • C. Un tri qui préserve l’ordre relatif des éléments considérés comme égaux
  • D. Un tri qui fonctionne uniquement sur des listes déjà presque triées
Correction

Réponse : C. Si deux éléments ont la même clé de tri, un tri stable conserve leur ordre d’apparition initial.

  • A est faux : la stabilité ne concerne pas la fiabilité du programme.
  • B est faux : cela décrit la complexité constante, pas la stabilité.
  • D est faux : un tri stable fonctionne sur n’importe quelle liste.

3. Quelle est la complexité au pire cas du tri par sélection et du tri par insertion ?

  • A. Les deux sont en $O(n)$
  • B. Le tri par sélection est en $O(n)$, le tri par insertion est en $O(n^2)$
  • C. Les deux sont en $O(n^2)$
  • D. Le tri par sélection est en $O(n^2)$, le tri par insertion est en $O(n \log n)$
Correction

Réponse : C. Dans le pire cas, les deux algorithmes effectuent de l’ordre de $n(n-1)/2$ comparaisons, soit une complexité quadratique $O(n^2)$.

  • A est faux : $O(n)$ est la complexité d’un simple parcours, pas d’un tri par comparaison naïf.
  • B est faux : le tri par sélection parcourt toujours toute la sous-liste, ce n’est jamais $O(n)$.
  • D est faux : $O(n \log n)$ est atteint par des algorithmes plus avancés (tri fusion, tri rapide en moyenne), pas par le tri par insertion.

4. Dans quel cas le tri par insertion est-il plus efficace que le tri par sélection ?

  • A. Quand la liste est triée en ordre décroissant
  • B. Quand la liste est déjà triée ou presque triée
  • C. Quand la liste contient des doublons
  • D. Les deux ont toujours exactement les mêmes performances
Correction

Réponse : B. Sur une liste déjà triée, le tri par insertion effectue seulement $n - 1$ comparaisons ($O(n)$), car aucun décalage n’est nécessaire. Le tri par sélection, lui, effectue toujours $n(n-1)/2$ comparaisons, quelle que soit la liste.

  • A est faux : c’est le pire cas du tri par insertion (chaque élément doit remonter jusqu’au début).
  • C est faux : les doublons ne changent pas fondamentalement les performances relatives.
  • D est faux : le tri par insertion a un meilleur cas en $O(n)$, contrairement au tri par sélection.

Exercice 2 : exemple travaillé – tri par insertion pas à pas

Problème : trier la liste [5, 2, 8, 3] par insertion.

Principe : on parcourt la liste de gauche à droite. À chaque étape, on insère l’élément courant à sa place dans la partie déjà triée (à gauche).

Trace d’exécution :

ÉtapeÉlément à insérerPartie triée (avant)Décalages effectuésListe après insertion
$i = 1$2[5]2 < 5, on décale 5[2, 5, 8, 3]
$i = 2$8[2, 5]8 ≥ 5, aucun décalage[2, 5, 8, 3]
$i = 3$3[2, 5, 8]3 < 8, décale 8 ; 3 < 5, décale 5 ; 3 ≥ 2, stop[2, 3, 5, 8]

Algorithme :

def tri_insertion(L):
    for i in range(1, len(L)):
        en_cours = L[i]
        j = i
        while j > 0 and L[j - 1] > en_cours:
            L[j] = L[j - 1]  # décalage vers la droite
            j -= 1
        L[j] = en_cours       # insertion à la bonne place
    return L

Terminaison : la boucle for parcourt un nombre fini d’indices. La boucle while se termine car $j$ décroît strictement à chaque itération (variant : $j$).

Invariant : à la fin de l’itération $i$, les éléments L[0:i+1] sont triés entre eux.

Complexité : au pire (liste triée en ordre décroissant), chaque élément $i$ nécessite $i$ décalages. Total : $1 + 2 + \cdots + (n-1) = \frac{n(n-1)}{2}$, soit $O(n^2)$.


Exercice 3 : traces guidées

3.1 Trace du tri par sélection

Le tri par sélection consiste à chercher le minimum de la partie non triée, puis à l’échanger avec le premier élément non trié.

Compléter la trace pour la liste [7, 3, 9, 1, 5] :

ÉtapePartie triéePartie non triéeMinimum trouvéÉchangeListe après
$i = 0$[][7, 3, 9, 1, 5]… (indice …)7 ↔ …[..., ..., ..., ..., ...]
$i = 1$[1][3, 9, 7, 5]… (indice …)… ↔ …[..., ..., ..., ..., ...]
$i = 2$[1, 3][9, 7, 5]… (indice …)… ↔ …[..., ..., ..., ..., ...]
$i = 3$[1, 3, 5][7, 9]… (indice …)… ↔ …[..., ..., ..., ..., ...]
Correction
ÉtapePartie triéePartie non triéeMinimum trouvéÉchangeListe après
$i = 0$[][7, 3, 9, 1, 5]1 (indice 3)7 ↔ 1[1, 3, 9, 7, 5]
$i = 1$[1][3, 9, 7, 5]3 (indice 1)3 ↔ 3[1, 3, 9, 7, 5]
$i = 2$[1, 3][9, 7, 5]5 (indice 4)9 ↔ 5[1, 3, 5, 7, 9]
$i = 3$[1, 3, 5][7, 9]7 (indice 3)7 ↔ 7[1, 3, 5, 7, 9]

À l’étape $i = 1$, le minimum est déjà en place : l’échange est un échange « à vide » (l’élément avec lui-même). À l’étape $i = 2$, l’échange de 9 et 5 place directement trois éléments dans l’ordre.

3.2 Compléter le tri par sélection

Compléter la fonction suivante :

def tri_selection(L):
    for i in range(len(L) - 1):
        i_min = ___
        for j in range(___, len(L)):
            if L[___] < L[___]:
                i_min = ___
        L[i], L[___] = L[___], L[i]
    return L
Correction
def tri_selection(L):
    for i in range(len(L) - 1):
        i_min = i
        for j in range(i + 1, len(L)):
            if L[j] < L[i_min]:
                i_min = j
        L[i], L[i_min] = L[i_min], L[i]
    return L

assert tri_selection([7, 3, 9, 1, 5]) == [1, 3, 5, 7, 9]
assert tri_selection([1, 2, 3]) == [1, 2, 3]
assert tri_selection([3, 1]) == [1, 3]
assert tri_selection([]) == []
print("Tests OK")
  • i_min = i : on suppose que le minimum est à la position courante.
  • range(i + 1, len(L)) : on cherche dans la partie non encore triée.
  • L[j] < L[i_min] : on compare chaque élément au minimum provisoire.
  • L’échange final place le minimum trouvé à la position $i$.

Exercice 4 : tri par insertion – cas particuliers

1. Tracer l’exécution du tri par insertion sur la liste [1, 2, 3, 4, 5] (déjà triée). Combien de comparaisons sont effectuées ?

2. Tracer l’exécution sur la liste [5, 4, 3, 2, 1] (ordre décroissant). Combien de comparaisons et de décalages sont effectués ?

3. Conclure : dans quel cas le tri par insertion est-il le plus rapide ? Le plus lent ?

Correction

1. Liste [1, 2, 3, 4, 5] :

ÉtapeÉlémentComparaisonDécalagesListe
$i = 1$22 ≥ 1, stop0[1, 2, 3, 4, 5]
$i = 2$33 ≥ 2, stop0[1, 2, 3, 4, 5]
$i = 3$44 ≥ 3, stop0[1, 2, 3, 4, 5]
$i = 4$55 ≥ 4, stop0[1, 2, 3, 4, 5]

Total : quatre comparaisons, zéro décalage. C’est le meilleur cas : $O(n)$.

2. Liste [5, 4, 3, 2, 1] :

ÉtapeÉlémentComparaisonsDécalagesListe
$i = 1$44 < 51[4, 5, 3, 2, 1]
$i = 2$33 < 5, 3 < 42[3, 4, 5, 2, 1]
$i = 3$22 < 5, 2 < 4, 2 < 33[2, 3, 4, 5, 1]
$i = 4$11 < 5, 1 < 4, 1 < 3, 1 < 24[1, 2, 3, 4, 5]

Total : $1 + 2 + 3 + 4 = 10$ comparaisons et 10 décalages. C’est le pire cas : $O(n^2)$.

3. Le tri par insertion est le plus rapide sur une liste déjà triée (complexité linéaire) et le plus lent sur une liste triée en ordre décroissant (complexité quadratique). C’est pourquoi il est particulièrement adapté aux données « presque triées ».


Exercice 5 : compter les opérations (mathématiques)

1. Le tri par sélection effectue toujours le même nombre de comparaisons, quelle que soit la liste. Pour une liste de $n$ éléments, montrer que ce nombre vaut : $$C(n) = \frac{n(n-1)}{2}$$

2. Calculer $C(10)$, $C(100)$ et $C(1000)$.

3. Si le tri par sélection met 0,5 seconde pour trier 1000 éléments, estimer le temps nécessaire pour 10 000 éléments. Justifier.

4. Le tri par insertion, dans le meilleur cas, effectue $n - 1$ comparaisons. Combien de fois est-il plus rapide que le tri par sélection sur une liste déjà triée de 1000 éléments ?

Correction

1. À l’étape $i$ (pour $i$ allant de 0 à $n - 2$), on parcourt les éléments de $i + 1$ à $n - 1$, soit $n - 1 - i$ comparaisons. Le total est : $$C(n) = \sum_{i=0}^{n-2} (n - 1 - i) = (n-1) + (n-2) + \cdots + 1 = \frac{n(n-1)}{2}$$

2.

  • $C(10) = \frac{10 \times 9}{2} = 45$
  • $C(100) = \frac{100 \times 99}{2} = 4,950$
  • $C(1000) = \frac{1000 \times 999}{2} = 499,500$

3. La complexité est quadratique. Si la taille est multipliée par 10, le temps est multiplié par $10^2 = 100$. Donc pour 10 000 éléments, le temps estimé est $0{,}5 \times 100 = 50$ secondes.

On peut vérifier : $\frac{C(10,000)}{C(1000)} = \frac{10,000 \times 9999}{1000 \times 999} \approx 100{,}1$, ce qui confirme le facteur 100.

4. Le tri par sélection effectue $C(1000) = 499,500$ comparaisons. Le tri par insertion dans le meilleur cas en effectue $999$. Le rapport est $\frac{499,500}{999} \approx 500$. Le tri par insertion est donc environ 500 fois plus rapide sur une liste déjà triée.


Exercice 6 : tri de données réelles

6.1 Classement sportif (sport)

Les temps (en secondes) de six nageurs sur un 50 m sont :

temps = [28.4, 25.1, 27.8, 24.9, 26.3, 25.7]
noms = ["Alice", "Bob", "Clara", "David", "Eva", "Franck"]
  1. Adapter le tri par sélection pour trier la liste temps tout en réorganisant la liste noms de la même façon (quand on échange deux temps, on échange aussi les noms correspondants).
  2. Afficher le classement final (du plus rapide au plus lent).
Correction
def tri_classement(temps, noms):
    """Trie temps par ordre croissant et réorganise noms en parallèle."""
    for i in range(len(temps) - 1):
        i_min = i
        for j in range(i + 1, len(temps)):
            if temps[j] < temps[i_min]:
                i_min = j
        temps[i], temps[i_min] = temps[i_min], temps[i]
        noms[i], noms[i_min] = noms[i_min], noms[i]

temps = [28.4, 25.1, 27.8, 24.9, 26.3, 25.7]
noms = ["Alice", "Bob", "Clara", "David", "Eva", "Franck"]
tri_classement(temps, noms)

for i in range(len(noms)):
    print(f"{i + 1}. {noms[i]} : {temps[i]} s")

Résultat :

1. David : 24.9 s
2. Bob : 25.1 s
3. Franck : 25.7 s
4. Eva : 26.3 s
5. Clara : 27.8 s
6. Alice : 28.4 s

Point clé : quand on échange temps[i] et temps[i_min], il faut aussi échanger noms[i] et noms[i_min] pour que la correspondance nom/temps soit conservée.

6.2 Tri alphabétique (vie courante)

On dispose d’une liste de prénoms : ["Zoé", "Alice", "Marc", "Léa", "Hugo"].

  1. Trier cette liste par ordre alphabétique en utilisant le tri par insertion.
  2. Rappel : en Python, les chaînes de caractères sont comparables avec <, >, etc. (ordre lexicographique).
Correction
def tri_insertion(L):
    for i in range(1, len(L)):
        en_cours = L[i]
        j = i
        while j > 0 and L[j - 1] > en_cours:
            L[j] = L[j - 1]
            j -= 1
        L[j] = en_cours
    return L

prenoms = ["Zoé", "Alice", "Marc", "Léa", "Hugo"]
print(tri_insertion(prenoms))
# ['Alice', 'Hugo', 'Léa', 'Marc', 'Zoé']

Le tri par insertion fonctionne identiquement sur des chaînes de caractères, car Python compare les chaînes caractère par caractère selon l’ordre Unicode (qui respecte l’ordre alphabétique pour les lettres sans accent).

Attention : les lettres accentuées (é, è, etc.) ont un code Unicode supérieur aux lettres non accentuées. Pour un tri alphabétique parfait en français, il faudrait utiliser une clé de tri spécifique (hors programme).


Exercice 7 : preuve de correction

7.1 Invariant du tri par sélection

On considère le tri par sélection :

def tri_selection(L):
    for i in range(len(L) - 1):
        i_min = i
        for j in range(i + 1, len(L)):
            if L[j] < L[i_min]:
                i_min = j
        L[i], L[i_min] = L[i_min], L[i]
    return L

Invariant proposé : « à la fin de l’itération $i$, les éléments L[0], ..., L[i] sont les $i + 1$ plus petits éléments de la liste originale, et ils sont triés. »

  1. Initialisation : vérifier que l’invariant est vrai après la première itération ($i = 0$).
  2. Conservation : montrer que si l’invariant est vrai après l’itération $i$, il est encore vrai après l’itération $i + 1$.
  3. Conclusion : en déduire que le tri par sélection est correct.
Correction

1. Initialisation ($i = 0$) : la boucle interne cherche le minimum de toute la liste. Après l’échange, L[0] contient le plus petit élément de la liste. L’invariant est vérifié : L[0] est bien le seul élément des « $0 + 1 = 1$ plus petits », et il est trié (un seul élément).

2. Conservation : supposons l’invariant vrai après l’itération $i$ : L[0], ..., L[i] sont les $i + 1$ plus petits éléments de la liste, triés. À l’itération $i + 1$, la boucle interne cherche le minimum de L[i+1], ..., L[n-1]. Ce minimum est le plus petit des éléments restants, c’est-à-dire le $(i + 2)$-ème plus petit élément de la liste (puisque les $i + 1$ plus petits sont déjà placés). Après l’échange, L[i+1] contient ce minimum. On a donc $L[0] \leq \cdots \leq L[i] \leq L[i+1]$, et les $i + 2$ premiers éléments sont les $i + 2$ plus petits, triés. L’invariant est conservé.

3. Conclusion : après la dernière itération ($i = n - 2$), l’invariant garantit que L[0], ..., L[n-2] sont les $n - 1$ plus petits éléments, triés. Le dernier élément L[n-1] est donc nécessairement le plus grand. La liste entière est triée.

7.2 Terminaison

  1. Justifier la terminaison du tri par sélection (la boucle for suffit-elle ?).
  2. Justifier la terminaison du tri par insertion. Quel est le variant de la boucle while interne ?
Correction

1. Le tri par sélection utilise deux boucles for imbriquées, dont les bornes sont fixées par len(L). Les boucles for en Python terminent toujours car elles parcourent un itérable fini. Il n’y a pas de boucle while, donc la terminaison est garantie.

2. Le tri par insertion utilise une boucle for (terminaison garantie) et une boucle while interne. Pour la boucle while, le variant est $j$ : il est initialisé à $i > 0$, il décroît de 1 à chaque itération (j -= 1), et la boucle s’arrête quand $j = 0$ (ou plus tôt si la condition L[j-1] > en_cours est fausse). Comme $j$ est un entier positif strictement décroissant, la boucle while termine.


Exercice 8 : synthèse – comparaison expérimentale

On souhaite comparer expérimentalement les deux algorithmes de tri.

  1. Écrire une fonction compter_comparaisons_selection(L) qui trie la liste par sélection et renvoie le nombre de comparaisons effectuées.

  2. Écrire une fonction compter_comparaisons_insertion(L) qui trie la liste par insertion et renvoie le nombre de comparaisons effectuées.

  3. Tester les deux fonctions sur les trois cas suivants (avec $n = 10$) :

    • une liste aléatoire ;
    • une liste déjà triée ;
    • une liste triée en ordre décroissant.
  4. Que constate-t-on ? Les résultats sont-ils conformes à l’analyse théorique ?

Correction
import random

def compter_comparaisons_selection(L):
    """Tri par sélection avec compteur de comparaisons."""
    nb_comp = 0
    for i in range(len(L) - 1):
        i_min = i
        for j in range(i + 1, len(L)):
            nb_comp += 1
            if L[j] < L[i_min]:
                i_min = j
        L[i], L[i_min] = L[i_min], L[i]
    return nb_comp

def compter_comparaisons_insertion(L):
    """Tri par insertion avec compteur de comparaisons."""
    nb_comp = 0
    for i in range(1, len(L)):
        en_cours = L[i]
        j = i
        while j > 0:
            nb_comp += 1
            if L[j - 1] > en_cours:
                L[j] = L[j - 1]
                j -= 1
            else:
                break
        L[j] = en_cours
    return nb_comp

n = 10

# Cas 1 : liste aléatoire
L = [random.randint(0, 100) for _ in range(n)]
c_sel = compter_comparaisons_selection(L.copy())
c_ins = compter_comparaisons_insertion(L.copy())
print(f"Aléatoire  : sélection = {c_sel}, insertion = {c_ins}")

# Cas 2 : liste triée
L = list(range(n))
c_sel = compter_comparaisons_selection(L.copy())
c_ins = compter_comparaisons_insertion(L.copy())
print(f"Triée      : sélection = {c_sel}, insertion = {c_ins}")

# Cas 3 : liste inversée
L = list(range(n, 0, -1))
c_sel = compter_comparaisons_selection(L.copy())
c_ins = compter_comparaisons_insertion(L.copy())
print(f"Inversée   : sélection = {c_sel}, insertion = {c_ins}")

Résultat attendu :

Aléatoire  : sélection = 45, insertion = ~30
Triée      : sélection = 45, insertion = 9
Inversée   : sélection = 45, insertion = 45

Analyse :

  • Le tri par sélection effectue toujours $\frac{n(n-1)}{2} = 45$ comparaisons, quel que soit l’ordre initial.
  • Le tri par insertion effectue seulement $n - 1 = 9$ comparaisons sur une liste triée (meilleur cas, $O(n)$), mais $\frac{n(n-1)}{2} = 45$ comparaisons sur une liste inversée (pire cas, $O(n^2)$).
  • Sur une liste aléatoire, le tri par insertion effectue en moyenne environ $\frac{n(n-1)}{4} \approx 23$ comparaisons.

Ces résultats confirment l’analyse théorique : le tri par insertion est adaptatif (il tire profit de l’ordre existant), contrairement au tri par sélection.