Généralités
Nous allons revisiter les trois algorithmes de tri empiriques abordés en première année, à savoir le tri par sélection, le tri par propagation (plus communément appelé tri à bulles) et le tri par insertion. Ces trois algorithmes trient des listes statiques (ou tableaux). Autrement dit, ils se contentent de modifier le contenu des cellules de ces listes pour y parvenir. A contrario, d'autres algorithmes de tri opèrent sur des listes dynamiques, c'est-à-dire qu'ils sont susceptibles d'ajouter ou de retirer des termes, en tête ou en queue de liste ou encore à des positions arbitraires. Ces déclinaisons du modèle de données liste sont liées à des structures de données différentes dans les langages de programmation qui ont un impact majeur dans la complexité des algorithmes réalisant les opérations usuelles sur les listes, insertion, suppression, appartenance, etc. Ces questions seront abordées plus en détail dans le chapitre Listes, files, piles.
Les algorithmes de tri peuvent posséder des propriétés remarquables :
En d'autres termes, si l'on distingue les paramètres de l'algorithme des autres variables qui lui sont nécessaires pour réaliser le tri, la taille mémoire occupée par ces dernières est constant quelle que soit la taille de la liste à trier.
Pour mieux comprendre cette propriété, il suffit d'imaginer que les différentes occurrences d'une même valeur sont numérotées (virtuellement) dans l'ordre de leur apparition dans la liste et de s'assurer qu'après le tri la suite de ces numéros est toujours croissante. Par exemple pour la valeur \(2\) qui apparaît trois fois dans la liste \[({\color{yellow}2_1},{\color{#88F}3_1},4,1,5,{\color{yellow}2_2},6,{\color{#88F}3_2},{\color{yellow}2_3}),\] un tri stable pour l'ordre naturel renverrait la liste \[(1,{\color{yellow}2_1},{\color{yellow}2_2},{\color{yellow}2_3},{\color{#88F}3_1},{\color{#88F}3_2},4,5,6),\] alors qu'un tri instable pourrait renvoyer, par exemple, la liste \[(1,{\color{yellow}2_1},\underbrace{{\color{yellow}2_3},{\color{yellow}2_2}}_{\rightleftharpoons},\underbrace{{\color{#88F}3_2},{\color{#88F}3_1}}_{\rightleftharpoons},4,5,6).\]
Cette caractéristique peut s'avérer importante lorsque l'on effectue plusieurs tris successifs à partir d'une liste initiale où chaque nouveau tri s'effectue suivant une clé de tri différente. Un tri stable permet de trier une liste déjà triée en conservant le classement précédent dans chaque sous-groupe d'éléments de même valeur pour la nouvelle clé de tri. Par exemple, supposons que l'on dispose d'une liste d'individus triés dans l'ordre croissant de leur taille que l'on trie une seconde fois, mais selon l'ordre croissant de leur poids cette fois. Avec un tri stable, les groupes d'individus de même poids, contigus dans la liste nouvellement triée, restent triés dans l'ordre croissant de leur taille (un couple (taille, poids) code un individu dans la liste) : \begin{align*} &\ \ \big((175,65),(160,75),(175,45),(165,50),(150,45),(160,45),(150,50)\big)\\ \text{Tri #1: clé taille}\ \mapsto&\ \ \big((150,45),(150,50),(160,75),(165,45),(165,50),(175,65),(175,45)\big)\\ \text{Tri #2: clé poids}\ \mapsto&\ \ \big((\underline{150},{\color{yellow}\underset{=}{45}}),(\underline{160},{\color{yellow}\underset{=}{45}}),(\underline{175},{\color{yellow}\underset{=}{45}}),(\underline{150},{\color{#88F}\underset{=}{50}}),(\underline{165},{\color{#88F}\underset{=}{50}}),(175,65),(160,75)\big)\\ \end{align*}
Les trois algorithmes empiriques se font in situ, le tri par propagation et le tri par insertion sont stable alors que le tri sélection ne l'est pas.
C'est l'étude de leurs fonctions de complexité qui justifie que l'on revienne sur ces trois algorithmes, en particulier la complexité moyenne relativement difficile à établir et hors programme en première année. Dans toute la suite, la liste à trier est notée \(L\) et elle est indexée de \(1\) à \(n\) où \(n\) est un entier naturel qui désigne sa taille, i.e. le nombre de termes qu'elle contient. Nous désignons par \(L[i\colon j]\) la sous-liste constituée des éléments de la liste \(L\) dont les indices sont dans l'intervalle \[ \ab{i}{j}=\{k\in{\mathbf N}\mid i\leq k\leq j\}. \] Attention donc à ne pas faire la confusion avec les intervalles semi-ouverts à droite \([i\colon j]\) du langage Python. Comme ces trois tris s'appuient fondamentalement sur des comparaisons entre les termes de la liste, nous évaluerons parfois leurs complexités en nous concentrant sur ce nombre de comparaisons en négligeant les autres pour ne pas compliquer inutilement l'exposé.
Les illustrations interactives des algorithmes utilisent des listes d'entiers relatifs (donc muni de l'ordre naturel), mais il faut garder à l'esprit que ces algorithmes s'appliquent à n'importe quelles données d'un ensemble totalement ordonné, le symbole \(\leq\) désignant génériquement la relation d'ordre considérée. Même dans le cas où les données sont des \(k\)-uplets composites et où la relation d'ordre n'est pas totale, les différentes composantes sont généralement munies d'une relation d'ordre totale.
Ces trois algorithmes opèrent en échangeant le contenu de deux cellules dans le tableau, nous ferons donc appel à l'algorithme Echanger qui nécessite une variable auxiliaire pour conserver temporairement le contenu d'une des deux cellules et dont la fonction de complexité notée \(T_{\leftrightarrows}\) est constante \(\Theta(1)\) :
Echanger(L,i,j) données L: liste de valeurs i,j: entiers variables aux: entier debut aux ← L[i] L[i] ← L[j] L[j] ← aux FIN
Dans les calculs de complexité des tris, nous supposerons souvent que la liste \(L\) à trier contient les \(n\) premiers entiers naturels, autrement dit que l'application sous-jacente \(L:\ab{1}{n}\rightarrow \ab{1}{n}\) est une permutation du groupe symétrique \({S}_n\). Cette hypothèse ne restreint pas la généralité comme nous le verrons dans le chapitre suivant. L'algorithme Echanger(L,i,j) qui échange les valeurs de la liste aux positions \(i\) et \(j\) définit dont la transposition \((L[i],L[j])\).
On rappelle qu'une inversion d'une permutation \(\sigma\in{S}_n\) est un couple \((i,j)\in\ab{1}{n}^2\) tel que \(i < j\) et \(\sigma(i) > \sigma(j)\). Notons \({\mathscr I}(\sigma)\) l'ensemble des inversions d'une permutation \(\sigma\) de \({\frak S}_n\), i.e. \begin{equation}\label{eq:ensinversions} {\mathscr I}(\sigma):=\big\{(i,j)\in\ab{1}{n}^2\ \mid\ (i < j)\ \wedge\ (\sigma(i) > \sigma(j))\big\} \end{equation}
Le résultat suivant nous sera utile pour le tri à bulles et le tri par insertion. Si, pour ordonner une liste, on échange deux termes contigus dont les indices forment une inversion, alors on diminue exactement le nombre d'inversions de la liste par 1.
Dans le premier cas, le couple \((k,k+1)\) qui définissait une inversion pour la permutation \(\sigma\) n'est plus une inversion pour la permutation \(\sigma'\) par construction, il y a donc à ce stade une inversion de moins dans \({\mathscr I}(\sigma')\). Les deux derniers cas étant symétriques, nous ne traitons que le cas 2. Pour tout \(i\in[1,k[\), si \((i,k)\) (resp. \((i,k+1)\) change de statut alors \((i,k+1)\) (resp. \((i,k+1)\) également puisque \(\sigma'(k)=\sigma(k+1)\) et \(\sigma'(k+1)=\sigma(k)\), rétablissant ainsi le nombre d'inversions.
Le corollaire suivant est direct, si \(\#{\mathscr I}(\sigma\circ\tau)=\#{\mathscr I}(\sigma)-1\) où \(\sigma\in S_n\) et \(\tau\) est une transposition de type \((\sigma(k),\sigma(k+1))\), il faut au moins appliquer \(\#{\mathscr I}(\sigma)\) échanges pour obtenir la permutation identité qui n'a aucune inversion.
Le tri par sélection
Présentation de l'algorithme
Le tri par sélection est l'un des tris les plus simples. Il s'appuie sur un algorithme auxiliaire de recherche du minimum (ou symétriquement du maximum) dans une liste \(L\) (supposée non-vide). Pour cette recherche, on initialise toujours le minimum (ou le maximum) au premier terme de la liste puis on parcourt le reste des termes de la liste en mettant à jour la valeur minimale (ou maximale) si le terme courant est plus petit (ou plus grand) :
Min(L):valeur données L: liste de valeurs variables i: entier min: valeur debut min ← L[1] i ← 2 TQ (i ≤ #L) FAIRE SI (L[i] < min) ALORS min ← L[i] FSI i ← i + 1 FTQ RENVOYER min FIN
La condition de la boucle #10 ne dépend que de la variable \(i\) qui n'est modifiée que par l'incrémentation de l'instruction #14. Si la liste ne contient qu'un seul élément, on n'entre pas dans la boule et l'algorithme s'arrête en renvoyant le minimum qui a été initialisé à la seule valeur de la liste. Sinon, la valeur initiale de \(i\) est inférieure ou égale à la taille de la liste \(L\) et croit strictement à chaque passage dans la boucle ce qui assure que la condition \(i \leq \#L\) échouera pour la valeur \(i=n+1\). L'assertion la variable \(min\) contient la valeur minimale de la liste \(L[1,i-1]\) est vraie à l'entrée de la boucle et il est facile de montrer qu'elle le reste après chaque itération à la ligne #14, la variable contient donc bien la valeur minimale de la liste à la sortie de la boucle puisque \(i=n\), ce qui prouve la validité de l'algorithme.
D'après l'exercice ci-dessus, on sait calculer une position \(i_{\text{min}}\) d'un minimum dans la liste \(L[1:n]\). Pour trier la liste, il suffit de l'échanger avec le premier élément de la liste (\(L[1] \leftrightarrows L[i_{\text{min}}]\)) et de recommencer le processus avec la sous-liste \(L[2:n]\)) et ainsi de suite jusqu'à ce que la sous-liste à parcourir soit réduite à un élément. Il faut noter la symétrie de ce tri, on peut également chercher une plus grande valeur dans la liste et la ranger en dernière position.
L'écriture de l'algorithme coule de source, il suffit de répéter la recherche de l'indice du minimum (instruction #10), de faire l'échange (instruction #11) et de recommencer un cran plus loin dans la liste (incrément de l'indice de départ dans la liste, instruction #12) :
TriSelection(@L) données L: liste de valeurs variables i,imin,n: entiers debut i ← 1 n ← #L TQ (i < #L) FAIRE imin ← IdxMin(L,i,#L) Echanger(L,i,imin) i ← i + 1 FTQ FIN
Complexité du tri par sélection
Nous allons estimer la complexité de l'algorithme TriSelection en évaluant tout d'abord celle de l'algorithme IdxMin. Elle dépend des indices \(g\) et \(d\), le nombre d'opérations est proportionnel au nombre de cellules à parcourir dans le liste \(L\), soit \(d-g+1\). Le nombre réel d'opérations à réaliser dépend bien entendu du nombre de mises à jour de l'indice du minimum fonction de l'instance considérée, mais le coût unitaire est borné par deux constantes donc en \(\Theta(1)\). Comme l'appel à cet algorithme se fait toujours pour la valeur \(d=n\), on a
\[T_{\small\text{IdxMin}}(n,d)=\Theta(n-d+1)=\Theta(n-d).\]Les deux algorithmes Echanger et IdxMin ont tous deux un coût qui ne dépend que de la taille des données à traiter et pas de la nature des instances de taille \(n\), par conséquent les trois complexités dans le meilleur des cas, le pire des cas et le cas moyen se confondent.
Toutes les instructions hors de la boucle tant que de l'algorithme TriSelection ont un coût constant \(\Theta(1)\) et les trois instructions dans la boucle ont pour coûts respectifs \(\Theta(n-d)\), \(\Theta(1)\) et \(\Theta(1)\), on en déduit la somme : \begin{align*} T(n)&=\Theta(1)+\sum_{d=1}^{n-1}(\Theta(n-d) + \Theta(1) + \Theta(1))\\ &=\Theta(1)+2(n-1)\Theta(1)+\sum_{d=1}^{n-1} \Theta(n-d)\\ &=\Theta(n)+\sum_{k=1}^{n-1} \Theta(k)\\ &=\Theta(n)+\Theta\left(\frac{n(n-1)}{2}\right)\\ &=\Theta(n^2) \end{align*}
Le tri par propagation (tri à bulles)
Présentation de l'algorithme
Le principe du tri par propagation, généralement connu comme le tri à bulles est simple lui aussi. La métaphore est que le début de la liste symbolise le fond de l'eau et la fin de la liste la surface. Chaque nombre représente le diamètre d'une bulle et la bulle la plus grosse est celle qui remonte le plus vite à la surface (ce qui est conforme à la réalité physique dans une certaine mesure). Plus formellement, on parcourt la liste de la gauche vers la droite en échangeant les termes contigus \(L[i]\) et \(L[i+1]\) s'ils sont mal rangés, c'est-à-dire si \(L[i+1] < L[i]\) (on propage la bulle \(L[i]\)). À ce stade, la bulle la plus grosse (la plus grande valeur) est au bout de la liste en position n.
Le terme le plus grand étant à présent placé au bout de la liste, il suffit de recommencer le processus de propagation, mais cette fois sur la sous-liste \(L[1:n-1]\) contenant les \(n-1\) premiers termes de la liste et ainsi de suite en éliminant le dernier terme de la liste à chaque passe. L'algorithme est constitué d'une boucle principale (ligne #8) qui fait varier la taille de l'intervalle \(\ab{1}{d}\) à parcourir dans la liste \(L\) en commençant par la liste entière avec \(d:=\#L\) en appelant à chaque étape l'algorithme de propagation.
TriBulles(@L) données L: liste de valeurs variables d: entier debut d ← #L TQ (d > 1) FAIRE Propager(L,1,d) d ← d - 1 FTQ FIN
On peut améliorer immédiatement cet algorithme en remarquant que si aucun échange n'a lieu lors d'une passe sur la liste, c'est qu'elle est triée et qu'il est alors inutile de balayer la liste à nouveau.
L'algorithme peut alors exploiter cette optimisation à l'aide de la variable booléenne continuer initialisée à VRAI au départ et mise à jour par l'algorithme Propager.
TriBulles(@L) données L: liste de valeurs variables d: entier EX: booléen debut EX ← VRAI d ← #L TQ (EX ET (d > 1)) FAIRE EX ← Propager(L,1,d) d ← d - 1 FTQ FIN
Prouvons la validité de cet algorithme en oubliant pour le moment la version optimisée. La variable \(d\) est initialisée à la taille de la liste et n'est modifiée que par une décrémentation dans la boucle principale, la condition \(d > 1\) sera donc nécessairement fausse pour la valeur \(d=1\) si la boucle interne est valide et ne modifie pas la variable \(d\), ce qui est le cas (cf. exercice). Ainsi, l'algorithme s'arrête. La propriété \(L[d:n]\) est triée est un invariant de la boucle, en effet elle est vraie en entrant la première fois dans la boucle pour \(d=n\) puisqu'elle est réduite à un seul terme et nous savons que l'algorithme Propager place le plus grand élément de la liste \(L[1:d]\) à la position \(d\) ce qui ne modifie pas l'ordre de la liste \(L[d:n]\). Dans la version optimisée, nous savons déjà que l'algorithme s'arrête, il reste à s'assurer qu'il est toujours valide. La seule modification entre les deux versions est que l'algorithme peut sortir de la boucle avant que \(d\) ne soit égal à 1, i.e. quand la variable continuer est fausse, mais dans ce cas cela suppose que la liste est triée d'après le lemme précédent.
Complexité du tri à bulles
Dans sa version initiale, sans optimisation, la complexité du tri à bulle ne dépend que de la taille de l'instance, le \(i\)-ème passage sur la liste réalise exactement \(n-i\) comparaisons entre les termes contigus d'indices \(j\) et \(j+1\) pour \(j\in\ab{i}{n-1}\), autrement dit, il y a \begin{equation} \label{eq:sumcompbulle} \sum_{i=1}^{n-1}{\color{orange}\sum_{j=i}^{n-1}1}=\sum_{i=1}^{n-1}{\color{orange}(n-i+1)}=\sum_{i=1}^{n-1}i=\frac{n(n-1)}{2} \end{equation} comparaisons et finalement \(T(n)=\frac{n(n-1)}{2}=\Theta(n^2)\).
Dans sa version optimisée, le nombre de comparaisons dépend également de la nature de l'instance. Dans le meilleur des cas, la liste est triée et le premier passage suffit à achever la procédure puisque la variable continuer reste fausse, aucun échange n'ayant eu lieu. On a donc réalisé exactement \(n-1\) comparaisons entre termes contigus de la liste :
\[\check T(n)=n-1=\Theta(n).\]Dans le pire des cas, les valeurs sont triées dans l'ordre inverse et il y a systématiquement une transposition après chaque comparaison entre deux termes contigus. On retrouve le même calcul qu'en \((\ref{eq:sumcompbulle})\), soit exactement \(\frac{n(n-1)}{2}\) comparaisons : \[\hat T(n)=\Theta(n^2).\]
Le nombre moyen de comparaisons est très difficile à obtenir. Il est tentant d'estimer la complexité d'un algorithme de tri à travers le nombre de transpositions (échanges) nécessaires pour réordonner les termes de la liste \(L\). C'est probablement l'analogie avec le coût physique d'une transposition — intervertir deux cartes dans une main par exemple —, qui est plus important qu'une simple comparaison (contrôle visuel) qui nous le suggère. Le fonctionnement du tri à bulles optimisé permet de se convaincre que ce serait une erreur. En effet, dans le cas où la liste est déjà triée, aucune transposition n'est effectuée et il aura pourtant fallu balayer l'intégralité des \(n\) termes de la liste pour s'en assurer, le coût est donc bien linéaire. Cependant, on peut exploiter le nombre moyen de transpositions pour minorer le nombre moyen de comparaisons, puisqu'une transposition n'est réalisée qu'après une comparaison, il y a au moins autant de comparaisons qu'il y a de transpositions.
Rappelons que les listes considérées sont des permutations du groupe symétrique \(S_n\). Estimons le nombre de transpositions moyens nécessaires pour trier une liste/permutation. D'après le lemme, chaque échange entre termes contigus de la liste diminuant le nombre d'inversions de la liste d'une unité, le nombre total d'inversions des \(n!\) listes/permutations de taille \(n\) est égal au nombre d'échanges total nécessaires à trier ces listes. Le résultat est fourni par ce résultat de combinatoire : \[\sum_{L\in S_n}\#{\mathscr I}(L)=n!\ \frac{n(n-1)}{4}.\] Il suffit de diviser par \(n!\), pour obtenir une minoration du nombre de comparaisons moyen du tri à bulles : \[\overline{T}(n)=\Omega\left(\frac{n(n-1)}{4}\right)=\Omega(n^2).\]
Le tri par insertion
Présentation de l'algorithme
Le tri par insertion est le plus difficile des trois. C'est celui que nous utilisons quand nous jouons aux cartes, plus précisément pendant la phase où l'on range ses cartes dans l'ordre croissant dans sa main. Chaque nouvelle carte est insérée à la bonne position par rapport aux cartes déjà rangées. L'insertion se fait en comparant la nouvelle carte avec celles déjà en main de la gauche vers la droite (ou le contraire) jusqu'à ce que la bonne position soit trouvée.
Il faut néanmoins adapter ce que nous faisons physiquement avec les modèles de données que nous manipulons et respecter les contraintes fixées. Nous voulons réaliser le tri in situ, on ne dispose donc que de la liste d'origine \(L\) et d'un nombre de variables indépendant de sa taille \(n\). Il faut donc imaginer que l'on va devoir ranger la \(i\)-ème carte \(L[i]\) dans la sous-liste \(L[1:i-1]\) qui est donc supposée triée (on rappelle que l'intervalle entier \([a:b]\) contient toutes les valeurs entre \(a\) et \(b\) incluses contrairement au Python qui exclut \(b\)).
L'insertion d'une carte \(L[i]\) dans la main/liste \(L[1:i-1]\) se fait indifféremment en parcourant les cartes dans la main de la gauche vers la droite ou de la droite vers la gauche. L'exercice suivant précise pourquoi il est préférable de faire la comparaison de la droite vers la gauche.
L'exercice met en lumière une erreur extrêmement courante, il est donc nécessaire de réaliser les décalages \(L[k+1]\leftarrow L[k]\) en commençant par \(k=i-1\) et finir avec \(k=j\). Mais plutôt que de réaliser les décalages et ensuite insérer la carte à la position \(k\), on peut ramener la carte à la bonne position en l'échangeant avec les cartes de la main de la même manière que pour l'algorithme du tri par propagation mais en propageant la bulle de la droite vers la gauche.
Inserer(L,i) DONNÉES L: liste de valeurs DEBUT TQ ((i > 1) ET (L[i] < L[i - 1])) FAIRE Echanger(L,i,i - 1) i ← i - 1 FTQ FIN
Comme pour le tri à bulle, on arrête la propagation dès que la carte/bulle est bien placée. Il suffit alors d'insérer toutes les cartes dans la main de la deuxième carte (la première l'est déjà) jusqu'à la dernière carte pour réaliser ce tri.
TriInsertion(@L) données L: liste de valeurs variables i: entier debut i ← 2 TQ (i ≤ #L) FAIRE Inserer(L,i) i ← i + 1 FTQ FIN
Complexité du tri par insertion
Comme toujours avec les tris comparatifs, nous allons évaluer la complexité en estimant le nombre de comparaisons effectuées et ici pour ne pas compliquer inutilement l'exposé, nous ne compterons que celles entre les valeurs dans la liste. Dans le meilleur des cas, les cartes arrivent triées dans l'ordre croissant, la carte à insérer n'est comparée qu'une seule fois avec la carte la plus à droite dans la main et ne nécessite aucun décalage des cartes déjà placées. Il y a au total \(n-1\) comparaisons (il n'y a rien à comparer pour la première carte), la complexité dans le meilleur des cas est donc
\[\check T(n)=n-1=\Theta(n).\]Dans le pire des cas, le calcul est tout aussi simple, on reçoit les cartes dans l'ordre décroissant et il faut donc décaler toutes les cartes déjà en main. L'insertion de la \(i\)-ème carte nécessite \(i-1\) comparaisons, on retrouve donc très classiquement la somme des \(n-1\) premiers entiers soit \(\frac{n(n-1)}{2}\) :
\[\hat T(n)=\frac{n(n-1)}{2}=\Theta(n^2).\]Pour le cas moyen, nous supposons encore que la liste \(L\) est une permutation de \(S_n\). Nous allons reprendre le corollaire de la première section pour évaluer la complexité moyenne. À la différence du tri à bulles, le nombre de comparaisons est ici directement lié aux nombre d'échanges de valeurs contiguës. À chaque insertion d'une nouvelle carte, il y a exactement une comparaison de plus que d'échanges effectués.
Quand on insère la \(i\)-ème carte, les \(i-1\) premières sont supposées triées. Ainsi, si l'on tient compte de la comparaison qui termine la boucle, le nombre total de comparaisons \(c_i\) réalisées pour cette insertion est donné par \begin{equation}\label{eq:nbinv} c_i:=1+{\#\big\{j\in\ab{1}{i-1}\ \mid\ L[j]>L[i]\big\}}. \end{equation}
Notons \(c_L\) le nombre total de comparaisons réalisées pour trier la liste \(L\). Si l'on ne tient pas compte de l'élémentaire optimisation de l'algorithme qui consiste à commencer par l'insertion de la deuxième carte, on a \begin{align*} c_L&=\sum_{i=1}^{n}c_i\\ &=\sum_{i=1}^{n}\left(1+{\#\big\{(i,j)\ \such\ (j < i)\ \wedge\ (L[j]>L[i])\big\}}\right)\\ &=n+\#\big\{(i,j)\in\ab{1}{n}^2\ \such\ (j < i)\ \wedge\ (L[j]>L[i])\big\}\\ &=n+{\color{#FF0}\#{\mathscr I}(L)}. \end{align*}
Il suffit à présent de calculer la moyenne du nombre de comparaisons pour toutes les permutations du groupe symétrique \({S}_n\) : \begin{align*} \bar T(n) &=\frac{1}{n!}\sum_{L\in{S}_n}c_L\\ &=\frac{1}{n!}\sum_{L\in{S}_n}(n+\#{\mathscr I}(L))\\ &=n+\frac{1}{n!}{\color{orange}\sum_{L\in{S}_n}\#{\mathscr I}(L)}\\ \end{align*} Or le nombre d'inversions total pour toutes les permutations du groupe \({S}_n\) est égal à \(\color{orange}n!\ \frac{n(n-1)}{4}\), donc \begin{align*} \bar T(n)&=n+\frac{n(n-1)}{4}\\ &=\Theta(n^2). \end{align*}
Notons que la constante cachée est ici égale à \(\frac{1}{4}\), ce tri est donc censé être en moyenne deux fois plus rapide que tri par sélection dont la constante cachée est \(\frac{1}{2}\) (idem pour le tri à bulles, même si n'avons pas pu l'établir).
Travaux pratiques
Toutes les fonctions et programmes sont à écrire en langage \(C\). L'objectif de ce tp est de comparer la complexité moyenne des trois tris empiriques. Les tris opéreront sur des permutations du groupe \({S}_n\), i.e. des tableaux \(T\) de taille \(n\) contenant chacun des \(n\) premiers entiers et renverront le nombre de comparaisons effectuées pour réaliser le tri. Remarque récurrente : la notation \([a:b]\), qui désignent les indices d'une sous-liste, contiennent les valeurs \(a\) et \(b\), contrairement au Python.
Indication : partir de la permutation identité \(S=[1,2,3,\ldots,n]\) puis, pour toutes les valeurs de \(i\) allant de \(1\) à \(n-1\), transposer (échanger) le \(i\)-ème terme avec le \(j\)-ème terme où la valeur de \(j\) est choisie au hasard dans l'intervalle fermé \(\ab{i}{n}\) (donc \(i\) et \(n\) sont inclus).
(2) Écrivez la fonction ullong TriSelection(int *T, uint n) qui reçoit en entrée un tableau \(T\) de \(n\) entiers et qui trie ce tableau à l'aide de l'algorithme du tri par sélection en vous appuyant sur la fonction IdxMin. La fonction renvoie le nombre de comparaisons réalisées.
(2) Écrivez la fonction ullong TriBulles(int *T, uint n, ullong *nbcomp) qui reçoit en entrée un tableau T de n entiers et qui trie cette liste à l'aide de l'algorithme du tri à bulles. La fonction renvoie le nombre d'échanges effectués. Le nombre de comparaisons réalisées est renvoyé via la variable *nbcomp.
(3) Écrivez la fonction ullong TriCocktail(int *T, uint n, ullong *nbcomp) qui reçoit en entrée un tableau T de n entiers et qui trie cette liste à l'aide de l'algorithme du tri cocktail : même principe que le tri par propagation, mais on propage alternativement de gauche à droite et de droite à gauche. La fonction renvoie le nombre d'échanges effectués. Le nombre de comparaisons réalisées est renvoyé via la variable *nbcomp.
(4) Comparez le nombre de comparaisons et le nombre d'échanges réalisés par le tri par propagation et le tri cocktail pour un même tableau à trier.
(5) Implantez l'algorithme du tri à peigne étudié en travaux dirigés sous la forme d'une fonction ullong TriPeigne(int *T, uint n, float coef, ullong *nbcomp). La fonction renvoie le nombre d'échanges effectués. Le nombre de comparaisons réalisées est renvoyé via la variable *nbcomp.
(6) Comparez les performances du tri peigne en fonction du coefficient de réduction coef. Vous testerez avec les coefficients suivants : \(1.1\) à \(2.0\) par pas de \(0.1\).
Tracez la courbe de complexité moyenne des quatre tris avec Gnuplot.