1Le probleme du tri et les tris quadratiques
Le tri est le probleme algorithmique le mieux etudie. Il sert d'introduction a toutes les notions du cours : correction, terminaison, complexite, bornes inferieures, strategies algorithmiques (incremental, diviser pour regner). Il faut savoir ecrire, prouver et analyser les quatre tris du programme.
Etant donne une liste \(t = [t_0, \dots, t_{n-1}]\) munie d'une relation d'ordre total, produire une permutation de \(t\) qui soit croissante. Deux exigences :
- le resultat est trie : \(t'_0 \le t'_1 \le \cdots \le t'_{n-1}\) ;
- le resultat est une permutation de l'entree : aucun element perdu ni ajoute.
Oublier la seconde condition est une erreur classique : la fonction qui renvoie
[0]*n produit bien une liste triee.
- Un tri est en place s'il n'utilise qu'une memoire auxiliaire \(O(1)\).
- Un tri est stable s'il preserve l'ordre relatif des elements de meme cle. C'est indispensable pour les tris multicriteres successifs.
- Le nombre de comparaisons et le nombre d'echanges sont les deux mesures pertinentes ; selon le cout d'un echange (grosses structures), l'un ou l'autre domine.
Tri par selection
A l'etape \(i\), chercher le minimum de la partie non triee \(t[i..n-1]\) et l'echanger avec \(t[i]\). La partie \(t[0..i]\) est alors triee et definitive.
def tri_selection(t):
"""Trie t en place par selection. Theta(n^2) comparaisons, Theta(n) echanges."""
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
if imin != i:
t[i], t[imin] = t[imin], t[i]
Invariant : au debut de l'iteration \(i\), t[0..i-1] contient les
\(i\) plus petits elements de la liste initiale, tries.
Complexite : la boucle interne effectue \(n-1-i\) comparaisons, d'ou \[\sum_{i=0}^{n-2}(n-1-i) = \frac{n(n-1)}{2} = \Theta(n^2)\] comparaisons, quelle que soit l'entree : le tri par selection ne profite jamais d'une liste deja triee. En revanche il ne fait que \(n-1\) echanges au plus, ce qui le rend interessant quand les echanges sont tres couteux.
Stabilite : non, a cause de l'echange a distance. Sur
[(2,'a'), (2,'b'), (1,'c')], le premier echange place (1,'c') devant et
inverse les deux 2.
Tri par insertion
C'est le tri du joueur de cartes : on prend les elements un a un et on insere chacun a sa place dans la partie deja triee, en decalant les plus grands vers la droite.
def tri_insertion(t):
"""Trie t en place par insertion. Theta(n) au meilleur cas, Theta(n^2) au pire."""
for i in range(1, len(t)):
x = t[i] # element a inserer
j = i - 1
while j >= 0 and t[j] > x: # decalage vers la droite
t[j + 1] = t[j]
j -= 1
t[j + 1] = x
Invariant : au debut de l'iteration \(i\), t[0..i-1] est triee et
contient exactement les \(i\) premiers elements initiaux.
| Cas | Entree | Comparaisons |
|---|---|---|
| meilleur | deja triee | \(n-1\), soit \(\Theta(n)\) |
| pire | triee a l'envers | \(\frac{n(n-1)}{2} = \Theta(n^2)\) |
| moyen | aleatoire | \(\approx \frac{n^2}{4} = \Theta(n^2)\) |
Le tri par insertion est stable, en place, adaptatif (lineaire sur une liste presque triee) et sa constante est tres faible. C'est pourquoi Timsort, le tri de Python, l'utilise pour les sous-listes de moins de 64 elements.
Precisement : si l'entree comporte \(k\) inversions (paires \(i<j\) avec \(t_i > t_j\)), le nombre de decalages vaut exactement \(k\), donc le tri est en \(\Theta(n+k)\).
Tri a bulles
Parcourir la liste en echangeant les couples voisins mal ordonnes. Apres un passage, le plus grand element a « remonte » en derniere position. Recommencer sur le prefixe restant.
def tri_bulles(t):
"""Trie t en place par propagation. Theta(n^2), Theta(n) si deja triee."""
n = len(t)
for i in range(n - 1, 0, -1):
echange = False
for j in range(i):
if t[j] > t[j + 1]:
t[j], t[j + 1] = t[j + 1], t[j]
echange = True
if not echange: # aucun echange : la liste est triee
return
Le drapeau echange rend le tri adaptatif : \(\Theta(n)\) sur une liste triee. Sans
lui, la complexite serait toujours \(\Theta(n^2)\). Le tri a bulles est stable (on n'echange que
des voisins strictement mal ordonnes) mais reste le moins efficace des trois : il effectue autant
d'echanges que d'inversions, soit jusqu'a \(n^2/2\).
2Tri fusion, diviser pour regner et borne inferieure
Tri fusion : diviser pour regner
Trois etapes :
- Diviser : couper la liste en deux moities ;
- Regner : trier recursivement chaque moitie ;
- Combiner : fusionner les deux moities triees en \(\Theta(n)\).
def fusion(g, d):
"""Fusionne deux listes triees en une liste triee. Theta(len(g) + len(d))."""
res = []
i = j = 0
while i < len(g) and j < len(d):
if g[i] <= d[j]: # <= et non < : c'est ce qui assure la STABILITE
res.append(g[i]); i += 1
else:
res.append(d[j]); j += 1
res.extend(g[i:])
res.extend(d[j:])
return res
def tri_fusion(t):
"""Renvoie une NOUVELLE liste triee. Theta(n log n) dans tous les cas."""
if len(t) <= 1:
return t[:] # cas de base
m = len(t) // 2
return fusion(tri_fusion(t[:m]), tri_fusion(t[m:]))
Notons \(C(n)\) le nombre de comparaisons. La fusion coute au plus \(n-1\) comparaisons, d'ou \[ C(n) = C\!\left(\left\lceil\tfrac n2\right\rceil\right) + C\!\left(\left\lfloor\tfrac n2\right\rfloor\right) + \Theta(n), \qquad C(1) = 0. \] Resolution. Pour \(n = 2^k\), on a \(C(n) = 2C(n/2) + n\). En divisant par \(n\) : \(\frac{C(n)}{n} = \frac{C(n/2)}{n/2} + 1\). La suite \(u_k = C(2^k)/2^k\) verifie \(u_k = u_{k-1} + 1\) avec \(u_0 = 0\), donc \(u_k = k\) et \[ C(n) = n\log_2 n = \Theta(n\log n). \] Interpretation par l'arbre des appels : il a \(\log_2 n\) niveaux, et chaque niveau traite au total \(n\) elements.
Ce resultat vaut dans tous les cas : le tri fusion n'a pas de mauvaise entree.
Le tri fusion n'est pas en place : il alloue \(\Theta(n)\) memoire supplementaire (les tranches et les listes fusionnees). C'est son principal defaut face au tri rapide. En contrepartie, il est stable et se generalise au tri de fichiers (chapitre 11).
Tri rapide (hors programme strict, mais classique)
def tri_rapide(t):
"""Tri rapide, version simple (non en place). Theta(n log n) en moyenne."""
if len(t) <= 1:
return t[:]
pivot = t[len(t) // 2]
petits = [x for x in t if x < pivot]
egaux = [x for x in t if x == pivot]
grands = [x for x in t if x > pivot]
return tri_rapide(petits) + egaux + tri_rapide(grands)
En moyenne \(\Theta(n\log n)\) avec une tres petite constante, mais \(\Theta(n^2)\) dans le pire cas (pivot systematiquement extremal, par exemple sur une liste deja triee si l'on prend toujours le premier element). Le choix aleatoire du pivot rend ce pire cas improbable.
Tout algorithme de tri n'accedant aux donnees que par des comparaisons effectue au moins \(\lceil\log_2(n!)\rceil = \Omega(n\log n)\) comparaisons dans le pire des cas.
Un tel algorithme est modelise par un arbre de decision binaire : chaque noeud interne est une comparaison, chaque branche l'un des deux resultats, chaque feuille une permutation produite en sortie.
Pour etre correct, l'algorithme doit pouvoir produire les \(n!\) permutations possibles : son arbre a donc au moins \(n!\) feuilles. Or un arbre binaire de hauteur \(h\) a au plus \(2^h\) feuilles. Donc \(2^h \ge n!\), soit \(h \ge \log_2(n!)\).
Par la formule de Stirling, \(n! \sim \sqrt{2\pi n}\left(\frac ne\right)^n\), donc \[ \log_2(n!) = n\log_2 n - n\log_2 e + O(\log n) = \Theta(n\log n). \] La hauteur de l'arbre est le nombre de comparaisons dans le pire cas : il est donc \(\Omega(n\log n)\).
Consequence. Le tri fusion est asymptotiquement optimal. Pour faire mieux, il faut sortir du modele : le tri par denombrement (chapitre 6) utilise les valeurs comme indices et atteint \(\Theta(n+k)\).
Tableau comparatif
| Tri | Meilleur | Moyen | Pire | Memoire | Stable | En place |
|---|---|---|---|---|---|---|
| Selection | \(\Theta(n^2)\) | \(\Theta(n^2)\) | \(\Theta(n^2)\) | \(\Theta(1)\) | non | oui |
| Insertion | \(\Theta(n)\) | \(\Theta(n^2)\) | \(\Theta(n^2)\) | \(\Theta(1)\) | oui | oui |
| Bulles | \(\Theta(n)\) | \(\Theta(n^2)\) | \(\Theta(n^2)\) | \(\Theta(1)\) | oui | oui |
| Fusion | \(\Theta(n\log n)\) | \(\Theta(n\log n)\) | \(\Theta(n\log n)\) | \(\Theta(n)\) | oui | non |
| Rapide | \(\Theta(n\log n)\) | \(\Theta(n\log n)\) | \(\Theta(n^2)\) | \(\Theta(\log n)\) | non | oui* |
| Denombrement | \(\Theta(n+k)\) | \(\Theta(n+k)\) | \(\Theta(n+k)\) | \(\Theta(k)\) | oui | non |
* dans sa version avec partition en place.
3Visualisation pas a pas et comparaison
- Sur une liste deja triee : le tri par selection fait toujours 28 comparaisons, le tri par insertion seulement 7. C'est la difference entre un algorithme adaptatif et un algorithme qui ne l'est pas.
- Sur une liste inversee : le tri par insertion atteint son pire cas, avec autant de decalages que d'inversions.
- Le tri par selection fait tres peu d'echanges (au plus \(n-1\)) mais beaucoup de comparaisons ; le tri a bulles, l'inverse.
L'essentiel du chapitre
- Selection, insertion, bulles : \(\Theta(n^2)\) ; fusion : \(\Theta(n\log n)\) garanti.
- Insertion et bulles sont adaptatifs (\(\Theta(n)\) sur une liste triee) ; la selection ne l'est jamais.
- Insertion, bulles et fusion sont stables ; selection et tri rapide ne le sont pas.
- Le tri fusion coute \(\Theta(n)\) memoire supplementaire.
- Tout tri par comparaison est en \(\Omega(n\log n)\) : preuve par arbre de decision et \(\log_2(n!)\).
- Chaque tri se prouve par un invariant portant sur le prefixe (ou suffixe) deja trie.