1
Les algorithmes de tri
En passant à la machine à café, vous avez sans doute déjà croisé des développeurs. Vous avez sans...
2
dans une démarche qualité. Pour cela, nous allons employer la méthode 3T
(Tests en Trois Tempsiii
) qui s’inspire du TDD...
3
Les premières manières qui nous viennent à l'esprit
Tri par sélection
Sans le savoir, où sans s’en rendre compte, on est...
4
Pour programmer ce tri sur un tableau, il suffit donc de le parcourir à la
recherche du plus petit élément (ou du plus g...
5
Fig. 3 : Tri par insertion
Si on accepte de chercher la bonne position en partant de la queue, on peut en
profiter pour ...
6
Complexité
Comme vous l’avez surement déjà deviné, le nombre total d’opérations à
effectuer pour « dérouler » un algorit...
7
Pour un milliard d’éléments, ce qui reste raisonnable pour un programme
informatique, notamment dans le domaine de la fi...
8
Fig. 4 : Tri à bulle
Le nom du « tri à bulle » vient du fait que les plus gros éléments remontent
comme des bulles dans ...
9
en « O(n²) », le multiplicateur « ½ » ne changeant rien à l’histoire pour les
grandes valeurs, comme on l’a déjà vu.
« a...
10
public class RapideViaListeTri implements Tri {
private List<Integer> trier(final List<Integer> liste) {
if (liste == n...
11
trier(tab, 0, tab.length - 1);
}
private void trier(final int[] tab, final int gauche, final int
droite) {
if (droite <...
12
int g = gauche;
int g2 = g;
int d = gauche + taille - 1;
int d2 = d;
while (true) {
while (g2 <= d && tab[g2] <= pivot)...
13
Fig. 6 : Tri rapide (pivot en tête) sur liste quasi triée
Lorsqu’on soupçonne que les données sont déjà à peu près trié...
14
Fig. 8 : Tri rapide (pivot aléatoire)
Comme son nom l’indique, le « Quick Sort » a la réputation d’être rapide, ce qui
...
15
Si on raisonne de nouveau en terme de durée, on se rend compte qu’il y a une
véritable différence entre un algorithme d...
16
Le coût des divisions récursives est quasi gratuit. Il est systématique et ne
demande de réaliser aucune comparaison en...
17
debut++;
g++;
d++;
}
}
}
Tri par interclassement monotone
À l’époque où on utilisait encore des bandes magnétiques, le ...
18
Fig. 13 : Tri par interclassement monotone (liste quasi triée)
Je vous invite à lire mon blogvii
pour en savoir un peu ...
19
Fig. 14 : Distribution croissante sauf en queue
Notez que la bonne stratégie à appliquer dans cet exemple aurait été de...
20
sérieux. Dans certaines situations, on n’a pourtant pas réellement besoin d’un
résultat parfait.
Fig. 16 : Distribution...
21
Quand on est développeur, il est indispensable de savoir comment le langage
(Java, Lisp…), la base de données (Oracle, ...
Prochain SlideShare
Chargement dans…5
×

Les algorithmes de tri

1 254 vues

Publié le

En passant à la machine à café, vous avez sans doute déjà croisé des développeurs. Vous avez sans doute constaté qu’ils ont l'air passionnés par leur métier, ce qui rend leurs discussions animées. Et vous les avez sans doute entendu prononcer des mots comme « Bubble », « Quicksort », « logarithme » ou encore « complexité », qui semblent provenir d'une autre langue. Ce sont pourtant des notions primordiales en programmation. Dans cet article, nous allons tenter de démystifier ce charabia.

Publié dans : Technologie
2 commentaires
1 j’aime
Statistiques
Remarques
Aucun téléchargement
Vues
Nombre de vues
1 254
Sur SlideShare
0
Issues des intégrations
0
Intégrations
11
Actions
Partages
0
Téléchargements
29
Commentaires
2
J’aime
1
Intégrations 0
Aucune incorporation

Aucune remarque pour cette diapositive

Les algorithmes de tri

  1. 1. 1 Les algorithmes de tri En passant à la machine à café, vous avez sans doute déjà croisé des développeurs. Vous avez sans doute constaté qu’ils ont l'air passionnés par leur métier, ce qui rend leurs discussions animées. Et vous les avez sans doute entendu prononcer des mots comme « Bubble », « Quicksort », « logarithme » ou encore « complexité », qui semblent provenir d'une autre langue. Ce sont pourtant des notions primordiales en programmation. Dans cet article, nous allons tenter de démystifier ce charabia. « Bubble », « Quicksort », « Insertion » ou encore « Fusion » (pour ne citer qu’eux) sont les noms donnés à des algorithmes de tri célèbres. Wikipedia indiquei qu’un algorithme est « une suite finie et non ambigüe d’opérations ou d'instructions permettant de résoudre un problème ». Un algorithme de tri est donc la « recette de cuisine » permettant à un logiciel de ranger les éléments d’une liste. On veut trier une liste lorsqu’on pense que ses éléments sont dans le désordre, ou plus précisément dans un ordre qui ne nous convient pas. L’objectif du tri, en tant qu’algorithme, est de mettre les éléments dans le bon ordre. Trier une liste suppose donc qu’on est capable d’établir une relation d’ordre entre ses éléments (ce qui n’est pas toujours simple et/ou possible). Par exemple, on peut dire si Nicolas est (objectivement) plus petit que David. Quand je parle de « plus petit », je fais instinctivement référence à la taille des personnes, mais on aurait tout aussi bien pu comparer leurs poids, leurs quantités de cheveux, leurs notes au baccalauréat, etc. Mais pour simplifier, disons juste qu’on se base sur le critère de la taille. Dans cet article, nous n’allons traiter que des entiers pour simplifier la discussion. La relation d’ordre sera donc établie. « il faut choisir l’algorithme adapté au contexte… » Si ce sujet revient souvent chez les développeurs, c’est parce que le besoin de trier des données est omniprésent dans les programmes informatiques. Par habitude ou par inattention, on ne réalise pas qu’on manipule sans arrêt des données triées. Par exemple, disons qu’on s’intéresse à votre compte bancaire. Sur votre relevé, les opérations sont triées par date. Quand vous souscrivez à l’option Web, vous avez également la possibilité de trier selon les entêtes des colonnes (montant, date, numéro d’opération, bénéficiaire, type, etc.), mais vous ne vous êtes probablement jamais dit qu’il y avait un algorithme efficace caché derrière cette fonctionnalité. En Javaii , on dispose de deux structures de données intéressantes à trier : les listes et les tableaux. Les algorithmes à base de listes sont plus faciles à programmer, mais sont généralement moins performants. Les tableaux demandent parfois de se retourner le cerveau pour coller à l’esprit de l’algorithme, mais le jeu en vaut la chandelle. C’est donc à ces derniers que nous allons nous intéresser. Dans cet article, nous allons discuter d’une poignée d’algorithmes. Pour que l’ensemble soit cohérent, je vous propose de créer l’interface « Tri » qui définit la méthode « trier() ». Cette méthode ne renvoie rien. Elle prend un tableau en paramètre et le tri « sur place ». public interface Tri { void trier(final int[] tab); ... Il faut préciser qu’il existe des bons et des mauvais algorithmes de tri. Quand on tri une petite liste et qu’on le fait rarement, même le plus mauvais des algorithmes fera l’affaire. Par contre, quand on manipule des listes conséquentes et/ou qu’on les trie souvent, il faut choisir avec attention l’algorithme qui sera le plus adapté au contexte. On teste en trois temps Avant d’entrer dans le vif du sujet, nous allons écrire des tests simples, à l’aide de la bibliothèque JUnit, qui nous permettront de vérifier de manière automatique que les programmes fonctionnent correctement. Cela s’inscrit
  2. 2. 2 dans une démarche qualité. Pour cela, nous allons employer la méthode 3T (Tests en Trois Tempsiii ) qui s’inspire du TDD (Test Driven Development). Durant l’écriture de cet article, cette démarche a permis d’identifier des erreurs qui seraient très certainement passées inaperçues sans. Tous les algorithmes doivent passer par la même moulinette de tri. On peut donc en écrire une version « abstract », comme suit, dont devront hériter toutes les classes de test : public abstract class AbstractTriTest { protected Tri tri; @Test public void testTriTabVide() { final int[] tab = {}; doTestTri(tab); } @Test public void testTriTabUnSeulElement() { final int[] tab = { 1 }; doTestTri(tab); } @Test public void testTriTabDeuxElements() { final int[] tab = { 2, 1 }; doTestTri(tab); } ... @Test public void testTriTabMelange() { final int[] tab = { 8, 6, 3, 9, 2, 1, 4, 5, 7 }; doTestTri(tab); } @Test public void testTriTabQuasiTrie() { final int[] tab = { 1, 2, 3, 4, 5, 6, 9, 8, 7 }; doTestTri(tab); } ... protected void doTestTri(final int[] tab) { tri.trier(tab); int temp = Integer.MIN_VALUE; for (final int elt : tab) { Assert.assertTrue(temp <= elt); temp = elt; } } Ici, l’idée est de multiplier les cas de tests. Vous trouverez de nombreux autres tests dans le code source proposé avec cet article sur le site Web de Programmez. Un des tests (non présenté ici) a consisté à générer, selon diverses distributions (aléatoire, croissante, décroissante, quasi croissante, gaussienne, sinusoïdale, etc.), des fichiers (des petits et des gros, voire très gros) contenant des listes à trier. En entreprise, on entend souvent dire (à tort ou à raison) qu’il faut utiliser les fonctionnalités du langage pour trier des tableaux. Cela va donc nous fournir un premier algorithme de référence. On commence bien entendu par la classe de test qui sera relativement simple : public class JavaTriTest extends AbstractTriTest { @Before public void doBefore() { tri = new JavaTri(); } Dans la suite de cet article, les classes de test des autres algorithmes seront toujours calquées sur ce modèle. Elles ne seront donc pas présentées. « il faut utiliser les fonctionnalités du langage… » La classe qui met ça en pratique sera également très simple puisqu’il suffit de faire appel aux méthodes déjà existantes : public class JavaTri implements Tri { @Override public void trier(final int[] tab) { Arrays.sort(tab); }
  3. 3. 3 Les premières manières qui nous viennent à l'esprit Tri par sélection Sans le savoir, où sans s’en rendre compte, on est confronté très jeune à des algorithmes de tri. Je dirais que cela se produit au plus tard en petite section de maternelle, vers l’âge de trois ans, à l’occasion du passage du photographe scolaire. Pour bien composer sa photo, le photographe doit placer les enfants sur le banc en fonction de leurs tailles. Pour cela, le photographe passe en revue les enfants à la recherche du plus petit. Une fois trouvé, il le place sur le banc. Il réitère cette opération sur les enfants restant en plaçant à chaque fois l’enfant sélectionné à la prochaine place disponible sur le banc. L’algorithme se termine lorsqu’il ne reste plus d’enfant. Si vous avez bien suivi, vous aurez compris que le dernier enfant à être sélectionné, et donc à être placé sur le banc, est mécaniquement le plus grand. Cet algorithme aurait pu s’appeler le « tri du photographe », mais on le trouve dans la littérature sous le nom de « tri par sélection ». Fig. 1 : Photo de classe. Crédit : Puyo / Podcast Science Fig. 2 : Tri par sélection
  4. 4. 4 Pour programmer ce tri sur un tableau, il suffit donc de le parcourir à la recherche du plus petit élément (ou du plus grand, ce qui revient au même) et de le positionner au prochain emplacement disponible : public class SelectionTri implements Tri { @Override public void trier(final int[] tab) { for (int i = 0; i < tab.length; i++) { // Chercher la position (pos) du plus petit dans la zone restante int pos = i; for (int j = i; j < tab.length; j++) { if (tab[j] < tab[pos]) { pos = j; } } final int petit = tab[pos]; // Delaler de i a pos for (int j = pos; i < j; j--) { tab[j] = tab[j - 1]; } // mettre le plus petit trouve a la fin tab[i] = petit; } } Dans la suite, on notera « n » le nombre d’éléments dans la liste à classer. Dans l’exemple, « n » correspond donc au nombre d’enfants dans la classe. À titre d’illustration, disons qu’il y en a dix-huit (cf. dessins). Pour sélectionner le plus petit, le photographe doit donc passer en revue les 18 élèves, ce qui nécessite de faire 17 comparaisons. Pour le deuxième, il reste 17 élèves à comparer, nécessitant 16 comparaisons. Pour le troisième il faudra faire 15 comparaisons, et ainsi de suite… Au final, le photographe va donc faire « 17+16+15+…+1 » comparaisons, ce qui est un peu long à calculer. Heureusement, on fait un peu de math au lycée. Ça peut se factoriser à l’aide de la formule « n*(n-1) /2 », ce qui donne « 153 » comparaisons pour une classe de dix-huit bambins. À la fin de la journée, le photographe doit donc avoir un sacré mal de tête. Et encore, il s’agit d’une classe relativement peu chargée. En école d’ingénieur, nous étions 150 dans ma promotion. Le photographe devrait donc faire plus de onze mille (11175) comparaisons pour préparer sa photo à l’aide du tri par sélection : autant dire qu’il y passerait la journée. Sans vendre la mèche, on devine déjà que le « tri par sélection » n’est pas réputé pour être très efficace. Tri par insertion Lorsqu’on est enfant, il y a un autre algorithme de tri qu’on apprend (ou découvre) assez vite. C’est celui que les joueurs de cartes utilisent pour organiser leurs mains. Le joueur pioche la carte qui est en haut du tas et la place (l’insère) à la bonne position dans la main. Cet algorithme se nomme le « tri par insertion ». En première approche, le « tri par insertion » est donc l’inverse du « tri par sélection ». Il est toutefois un peu plus efficace. Et, sans surprise, le code du programme est très proche de celui qu’on a déjà vu : public class InsertionTri implements Tri { @Override public void trier(final int[] tab) { int pos; // On peut commencer a 1 directement... for (int i = 1; i < tab.length; i++) { final int temp = tab[i]; // recherche position for (pos = 0; pos < i; pos++) { if (temp < tab[pos]) { break; } } // Decaler for (int j = i; pos < j; j--) { tab[j] = tab[j - 1]; } // Insertion tab[pos] = temp; } }
  5. 5. 5 Fig. 3 : Tri par insertion Si on accepte de chercher la bonne position en partant de la queue, on peut en profiter pour réaliser le décalage du même coup. for (int i = 1; i < tab.length; i++) { final int temp = tab[i]; // Recherche et decalage d'un seul coup for (pos = i; 0 < pos && temp < tab[pos - 1]; pos--) { tab[pos] = tab[pos - 1]; } // Insertion tab[pos] = temp; } Décider si on lance la recherche à partie de la tête ou de la queue n’est pas anodin. Selon que la liste initiale est plutôt croissante ou décroissante, l’une ou l’autre des recherches sera plus efficace. Et puisqu'on en est à se demander dans quel sens parcourir la liste, on pourrait aussi démarrer depuis la dernière position utilisée. Il suffit ensuite de monter ou de descendre le curseur selon le résultat des comparaisons et de s'arrêter lorsque ça s'inverse. Cela permet d'optimiser en moyenne le traitement des sous-séquences. Dans le pire des cas, cela sera équivalent à la recherche par une extrémité. À titre d’illustration, prenons de jeu de la Beloteiv dans lequel chaque joueur reçoit huit cartes. Ici « n » vaudra donc « 8 ». Pour la première carte, il n’y a rien à faire puisque la main est vide. Pour la deuxième carte, il y a une comparaison à faire avec l’unique carte déjà en main pour savoir si elle doit aller à gauche ou à droite. Pour la troisième carte, il y aura « au maximum » deux comparaisons à effectuer. Il est important de comprendre qu’on arrête de faire des comparaisons dès que la bonne position est identifiée. On fait ainsi tant qu’il reste des cartes à piocher. Au final, et dans le « pire des cas », on fera donc « 1+2+…+7 » comparaisons, soit « 28 » au total. C’est la même formule (à l’envers) de calcul que pour le « tri par sélection » et ce n’est donc pas mieux. En revanche, dans le « meilleur des cas » où le joueur pioche les cartes dans l’ordre idéal (ie. triées dans l’ordre croissant ou décroissant selon qu’on insère par la tête ou par la queue), il n’y aura que 7 comparaisons. Et bien entendu, plus le nombre « n » est grand et plus la différence est sensible.
  6. 6. 6 Complexité Comme vous l’avez surement déjà deviné, le nombre total d’opérations à effectuer pour « dérouler » un algorithme est un critère majeur pour dire si l’algorithme est efficace. Cela permet surtout de comparer l’efficacité de deux algorithmes et de choisir le plus adapté. Il est relativement « simple » de dénombrer « précisément » le nombre d’opérations nécessaires pour « dérouler » un algorithme de tri lorsque le nombre d’éléments « n » dans la liste est petit. Par exemple, on a vu qu’il faudra réaliser « 153 » comparaisons pour trier « 18 » enfants à l’aide du « tri par sélection ». Le même algorithme nécessite de réaliser « 11.175 » comparaisons pour trier une classe de « 150 » élèves, et « 499.500 » pour trier les mille employés d’une PME. Et si vous vouliez trier les « 81.338 » spectateurs d’un match de foot au Stade de France, il faudrait « 3.307.894.453 » comparaisons. Ces exemples mettent deux choses en évidence. Premièrement, la quantité d’opérations nécessaire au déroulement du « tri par sélection » croît rapidement lorsque le nombre « n » d’éléments dans la liste augmente. Deuxièmement, il n’est pas nécessaire d’avoir une grande précision sur des chiffres aussi importants. Quand on réalise « 3.307.894.453 » comparaisons, on n’est plus à une comparaison près, ni à dix, ou cent, ou même cent millions. D’ailleurs on aurait pu se contenter de dire qu’il fallait « environ trois milliards » d’opérations. Ce qui compte, avec des valeurs si conséquentes, c’est d’avoir un « ordre de grandeur », même vague. C’est ce qu’on va appeler la « complexité » d’un algorithme. En fait, il n’est pas possible de discuter des tris sans parler de « complexité ». Ce mot à lui seul est synonyme de cauchemars pour bien des étudiants en école d’info. Et pour ma part, je dois avouer que j’ai mis longtemps à en comprendre les tenants et les aboutissants. Je me propose donc de vous l’expliquer simplement, en prenant quelques raccourcis. Ne vous inquiétez pas si tout n’est pas clair, ça va se clarifier au fur et à mesure. « discuter des tris impose d’aborder la complexité… » La complexité d’un tri de « n » éléments se note avec un « omicron » : dites « grand O ». Par exemple, la complexité du « tri par sélection » sera en « O(n*(n- 1) /2 ) », où « n*(n-1) /2 » est la formule qu’on avait utilisée un peu plus tôt. Comme on s’intéresse à des valeurs importantes et qu’on ne veut qu’un « ordre de grandeur », on considèrera que cette « complexité » peut se « simplifier » en « O(n*(n-1)) », qui peut elle-même se simplifier en « O(n²) ». On dira que l’algorithme du « tri par sélection » est de « complexité quadratique » en « O(n²) ». Pour faire simple, quand vous multipliez par « 10 » le nombre « n » d’éléments, vous multipliez par « 100 » le nombre d’opérations à réaliser lorsque vous utilisez un algorithme de tri en « O(n²) ». Dit comme ça, ce n’est pas très impressionnant alors essayons d’associer cela avec des durées pour voir à quel point c’est mauvais. Disons qu’une opération prenne une nanoseconde (ns) en moyenne (ce qui est très optimiste). Nombre d’éléments « n » Nombre d’opérations pour un tri en « O(n²) » Durée pour un tri en « O(n²) » 10 100 100 ns 100 10 000 10 us 1 000 1 000 000 1 ms 10 000 100 000 000 100 ms 100 000 10 000 000 000 10 s 1 000 000 1 000 000 000 000 16 min 40 s 10 000 000 100 000 000 000 000 27 heures 100 000 000 10 000 000 000 000 000 115 jours 1 000 000 000 1 000 000 000 000 000 000 31 ans Alors que le tri de « 10 » éléments prendra seulement « 100 » nanosecondes à l’aide d’un algorithme en « O(n²) », il faudra « 10 » microsecondes pour une centaine d’éléments et carrément une milliseconde pour mille. Et ces temps augmentent encore quand le nombre d’éléments augmente. Ca augmente même très vite.
  7. 7. 7 Pour un milliard d’éléments, ce qui reste raisonnable pour un programme informatique, notamment dans le domaine de la finance, on aura besoin d’un tiers de siècle. Autant dire que les données seront périmées depuis longtemps quand l’algorithme aura fini son travail. L’ordinateur sera peut-être même déjà passé à la poubelle. Et je ne vous parle même pas de la facture d’électricité qu’on aurait tendance à oublier (coût en énergie, en ressource naturelle, en entretien et en Euro). Pour bien réaliser ce que ça représente, prenons un exemple plus concret. Pour trier la population mondialev , soit un peu plus de sept milliards de personnes (au début de l’été 2014), il faudrait plus d’un millier et demi d’années (1655 ans environ). Pour que le tri soit fini au moment où vous lisez cet article, il aurait donc fallu le commencer en l’an 350 de notre ère, soit plus d’un siècle avant le début du moyen âge (476 à 1453). On parle de complexité en se plaçant dans la situation la plus défavorable pour l’algorithme. Ça permet en quelque sorte de majorer. On peut aussi se placer dans le cas le plus favorable ou dans le cas moyen. Par exemple et sans plus d’explications, le « tri par insertion » sera de « complexité linéaire » en « O(n) » dans le cas le plus favorable et en « O(n²) » dans les cas moyens et défavorables. Bon, je ne vais pas vous embêter plus que ça avec cette histoire de complexité. J’ai passé sous silence certains aspects (gestion de la mémoire, déplacements, permutations, création d’objets, flux, etc.) qui peuvent avoir un impact en terme de performance, en fonction de la plateforme et du langage utilisé, mais je pense sincèrement que l’essentiel y est… pour l’instant… En plus de la question des performances, on a pris l’habitude de classer (cf. tableau récapitulatif en fin d’article) les algorithmes de tri selon des caractéristiques importantes, comme la stabilité ou le fait de pouvoir trier sur place, c’est-à-dire qu’on réarrange les éléments directement dans la structure initiale. Un algorithme est dit « stable » lorsqu’il ne modifie pas l’ordre des éléments de même valeur. Enfin, une des principales contraintes concerne l’espace mémoire. Plus spécifiquement, on se demande si on est capable de trier l’ensemble des données en mémoire centrale. On dira que le tri est « interne » si c’est possible, et « externe » sinon. Comment faire mieux ? On devine qu’il va falloir trouver comment faire mieux, notamment en utilisant des algorithmes beaucoup plus rapides. Rassurez-vous, je ne vais pas vous présenter tous les algorithmes de tri qui existent. Je vais me limiter aux plus connus en insistant sur leurs points forts et leurs faiblesses. Tri à bulle (Bubble Sort) Le tri à bulle est certainement celui qu’on apprend à programmer en premier en école d’informatique, avant même le « tri par sélection » et le « tri par insertion ». Pour effectuer un « tri à bulle », il faut parcourir la liste en permutant les éléments contigus qui ne sont pas dans le bon ordre. Par exemple, si je trouve « 9 » dans la case « 4 » et seulement « 2 » dans la case « 5 », alors j’échange leurs positions. Quand c’est fini, l’élément le plus grand se retrouve donc en queue de liste. Cet élément est arrivé à sa bonne position, mais le reste de la liste est encore potentiellement en désordre. On recommence autant de fois qu’il y a d’éléments dans la liste, ce qui fait donc à chaque fois remonter le plus grand élément restant.
  8. 8. 8 Fig. 4 : Tri à bulle Le nom du « tri à bulle » vient du fait que les plus gros éléments remontent comme des bulles dans une flute de champagne. D’ailleurs, on n’utilise jamais le « tri à bulle », car les bulles remontent très lentement. public class BulleTri implements Tri { @Override public void trier(final int[] tab) { for (int i = 0; i < tab.length - 1; i++) { for (int j = 1; j < tab.length; j++) { if (tab[j] < tab[j - 1]) { // Permutation permuter(j, j - 1, tab); } } } } public static void permuter(final int indexA, final int indexB, final int[] tab) { final int temp = tab[indexA]; tab[indexA] = tab[indexB]; tab[indexB] = temp; } Pour une liste dans laquelle le nombre d’éléments « n » est de « 9 », on fera « 8 » (ie. « n-1 ») comparaisons pour mettre le plus grand élément à sa bonne position. Et on va répéter cela « 8 » fois également, ce qui nécessitera donc « 64 » comparaisons. La complexité associée au « tri à bulle » sera donc en « O((n-1)²) » qu’on simplifiera en « O(n²) ». On peut améliorer l’algorithme de base. En effet, à chaque passage, un élément supplémentaire se retrouve à sa place définitive. Il n’est donc plus nécessaire de l’inclure dans les comparaisons suivantes. for (int i = 0; i < tab.length - 1; i++) { for (int j = 1; j < tab.length - i; j++) { if (tab[j] < tab[j - 1]) { permuter(j, j - 1, tab); On n’aura alors besoin que de « (n-1)*n/2 » comparaisons, soit « 36 » comparaisons pour une liste de « 9 » éléments. La complexité restera toutefois
  9. 9. 9 en « O(n²) », le multiplicateur « ½ » ne changeant rien à l’histoire pour les grandes valeurs, comme on l’a déjà vu. « accusé d’être lent, le tri à bulle cache bien son jeu… » En outre, on peut stopper le déroulement de l’algorithme dès lors qu’on détecte qu’aucune permutation n’a été réalisée durant une boucle. Si la liste s’y prête, cela peut devenir une sérieuse optimisation. public class RapideDemiBulleTri implements Tri { @Override public void trier(int[] tab) { for (int i = 0; i < tab.length; i++) { boolean permutation = false; for (int j = 1; j < tab.length - i; j++) { if (tab[j] < tab[j - 1]) { // Permutation permuter(j, j - 1, tab); permutation = true; } } if (!permutation) { break; } } } Le « tri à bulle » est souvent accusé d’être lent, mais comme vous pouvez le voir, il cache bien son jeu. À titre personnel, je dois avouer que j’ai beaucoup d’affection pour cet algorithme, même en sachant que ce n’est pas le meilleur. Tri rapide (Quick Sort) S’il y a un algorithme de tri dont vous avez forcément entendu parler à la machine à café, c’est bien le « Quick sort ». C’est l’un des algorithmes les plus utilisés et certainement celui qui présente le plus de variantes. Le « Quick Sort », aussi appelé « tri rapide », fait partie de la famille d’algorithme dont le fonctionnement repose sur le principe « diviser pour régner ». Pour réaliser un « tri rapide », on doit choisir un élément dans la liste, qu’on appelle « pivot ». On divise ensuite la liste en deux sous-listes. La première, à gauche, contient les éléments inférieurs au pivot. La seconde, à droite, contient les éléments supérieurs au pivot. « vous avez forcément entendu parler du Quick Sort… » On reproduit alors récursivement ce choix du pivot et la division sur les listes de gauche et de droite précédemment construites jusqu’à n’avoir que des sous- listes de zéro ou un élément. Pour finir, il suffit de rassembler les éléments de toutes les sous-listes dans l’ordre gauche-droite. Fig. 5 : Tri rapide (pivot en tête) Le principe du « tri rapide » est donc très simple : tout se passe pendant la construction de l’arbre. En revanche, il est réellement (très) difficile de programmer cet algorithme dans la version utilisant des tableaux. Plus précisément, c’est la séparation en fonction du pivot qui pose problème à de nombreux développeurs. On trouve d’ailleurs de nombreux codes faux (qui ne passent pas mes tests) sur Internet. Je vous propose donc de commencer en douceur avec une version employant des listes.
  10. 10. 10 public class RapideViaListeTri implements Tri { private List<Integer> trier(final List<Integer> liste) { if (liste == null || liste.isEmpty()) { return new ArrayList<>(); } // Choix du pivot a gauche final int pivot = liste.get(0); // pour ne pas traiter le pivot dans la boucle liste.remove(0); final List<Integer> listeGauche = new ArrayList<>(); final List<Integer> listeDroite = new ArrayList<>(); // Separation en deux sous listes for (final int elt : liste) { if (elt < pivot) { listeGauche.add(elt); } else { listeDroite.add(elt); } } final List<Integer> result = new ArrayList<>(); result.addAll(trier(listeGauche)); result.add(pivot); result.addAll(trier(listeDroite)); return result; } À la lecture de ce code, vous avez sans doute repéré plusieurs problèmes techniques. La méthode fonctionne correctement (elle passe tous mes tests), mais elle est lente. Il y a fondamentalement trois « erreurs ». D’abord de nombreuses listes sont créées et manipulées durant le traitement, ce qui coute cher. Ensuite, l’auto-unboxing réalisé lors de l’itération sur la (sous) liste à trier ajoute encore un peu à l’addition. Et l’auto-boxing lors de l’ajout aux sous-listes finit de « plomber » la note. Cela peut se résoudre en travaillant directement sur des objets, et donc sur des références, en les comparant à l’aide de « compareTo() » en lieu et place de l’opérateur de comparaison. Enfin, l’algorithme ne prend pas en compte la présence (éventuelle) de plusieurs valeurs égales au pivot. Quitte à avoir deux sous-listes, on peut bien en gérer une troisième pour ce cas spécifique. Le nombre total d’éléments restera inchangé et le coût du test supplémentaire sera largement compensé par le gain en nombre de récursions. public class RapideViaListe2Tri implements Tri { private List<Integer> trier(final List<Integer> liste) { if (liste == null || liste.isEmpty()) { return new ArrayList<>(); } // Pivot a gauche final Integer pivot = liste.get(0); final List<Integer> listeGauche = new ArrayList<>(); final List<Integer> listeCentre = new ArrayList<>(); final List<Integer> listeDroite = new ArrayList<>(); // Separation en deux sous listes for (final Integer elt : liste) { final int comp = elt.compareTo(pivot); if (comp < 0) { listeGauche.add(elt); } else if (comp == 0) { listeCentre.add(elt); } else { listeDroite.add(elt); } } liste.clear(); liste.addAll(trier(listeGauche)); liste.addAll(listeCentre); liste.addAll(trier(listeDroite)); return liste; } Voici maintenant une première version utilisant des tableaux. L’intérêt de cette structure est de pouvoir effectuer le tri « sur place », en manipulant des primitifs. public class RapideTri implements Tri { @Override public void trier(final int[] tab) {
  11. 11. 11 trier(tab, 0, tab.length - 1); } private void trier(final int[] tab, final int gauche, final int droite) { if (droite <= gauche) { return; } // Pivot à gauche int positionPivot = gauche; permuter(positionPivot, droite, tab); for (int i = gauche; i < droite; i++) { if (tab[i] <= tab[droite]) { permuter(i, positionPivot++, tab); } } permuter(droite, positionPivot, tab); // tri recursif des sous-listes // Bien entendu on ne tri pas le pivot trier(tab, gauche, positionPivot - 1); trier(tab, positionPivot + 1, droite); } Comme vous le constatez, cette première version utilisant des tableaux est bien plus complexe. On arrive toutefois encore à distinguer la forme de base de l’algorithme. Un des gros défauts, difficiles à corriger, de cette version est de devoir gérer le décalage d’un grand nombre de cases. Pour gagner encore un peu en vitesse, on va devoir passer par des fonctions de « System », qui font des manipulations de la mémoire de façon native. public class RapideTri2 implements Tri { @Override public void trier(final int[] tab) { if (tab.length < 2) { return; } int[] tab2 = new int[tab.length]; trier(tab, tab2, 0, tab.length); System.arraycopy(tab2, 0, tab, 0, tab.length); } private void trier(final int[] tab, final int[] tab2, final int gauche, final int droite) { // Choix du pivot a gauche final int pivot = tab[gauche]; int posGauche = gauche; int posDroite = droite; // Separation en deux sous listes for (int index = gauche + 1; index < droite; index++) { int elt = tab[index]; if (elt < pivot) { tab2[posGauche++] = elt; } else if (elt > pivot) { tab2[--posDroite] = elt; } } Arrays.fill(tab2, posGauche, posDroite, pivot); if (posGauche > gauche) { trier(tab2, tab, gauche, posGauche); System.arraycopy(tab, gauche, tab2, gauche, posGauche - gauche); } if (posDroite < droite) { trier(tab2, tab, posDroite, droite); System.arraycopy(tab, posDroite, tab2, posDroite, droite - posDroite); } } Pour en finir avec le « tri rapide », je vous propose une version simplifiée de ce qu’on peut lire dans les sources du JDK 6. Dans cette version, il faut regarder avec attention pour bien distinguer la structure de l’algorithme de base. public class RapideCopyJdkTri implements Tri { @Override public void trier(final int[] tab) { if (tab.length == 0) { return; } trier(tab, 0, tab.length); } private static void trier(final int tab[], final int gauche, final int taille) { final int pivot = tab[gauche];
  12. 12. 12 int g = gauche; int g2 = g; int d = gauche + taille - 1; int d2 = d; while (true) { while (g2 <= d && tab[g2] <= pivot) { if (tab[g2] == pivot) { permuter(g++, g2, tab); } g2++; } while (d >= g2 && pivot <= tab[d]) { if (tab[d] == pivot) { permuter(d, d2--, tab); } d--; } if (g2 > d) { break; } permuter(g2++, d--, tab); } // Swap partition elements back to middle int s, n = gauche + taille; s = Math.min(g - gauche, g2 - g); decaler(tab, gauche, g2 - s, s); s = Math.min(d2 - d, n - d2 - 1); decaler(tab, g2, n - s, s); // Recursively sort non-partition-elements if (1 < (s = g2 - g)) { trier(tab, gauche, s); } if (1 < (s = d2 - d)) { trier(tab, n - s, s); } } public static void decaler(final int tab[], int a, int b, int n) { for (int i = 0; i < n; i++, a++, b++) { permuter(a, b, tab); } } Les gains de chaque version pourraient sembler minimes, en comparaison de l’investissement, mais ils sont réellement importants. Pour bien se rendre compte des différences de performance, voici les temps de traitement moyens observés sur mon ordinateur portable, équipé d’un JDK 7. Algo 1 000 d’éléments 1 000 000 d’éléments RapideViaListeTri 15 ms 8 s RapideViaListe2Tri 13 ms 829 ms RapideTri 1 ms 909 ms Rapide2Tri - 52 ms RapideCopyJdkTri - 61 ms JavaTri (JDK 7) - 48 ms On constate que la seconde version à base de liste (RapideViaListe2Tri) est dix fois plus rapide que la première (RapideViaListeTri) pour un million d’éléments à trier, ce qui est loin d’être négligeable. La première version à base de tableau (RapideTri) est presque aussi rapide que la seconde version à base de liste (RapideViaListe2Tri). La seconde version à base de tableaux (Rapide2Tri) est, quant à elle, dix-sept fois plus rapide que la première. Seule la méthode de Java 7 (JavaTriTest), qui est hybride, parvient à faire mieux. Ici, on voit aussi que mon ordinateur est loin de pouvoir effectuer les opérations élémentaires en une nanoseconde puisqu’on se serait alors attendu à une durée de l’ordre de « 20 ms » pour un million d’éléments. Cela est dû en partie à la puissance de la machine et en partie au fait qu’on a du mal à coller au plus près de l’algorithme théorique optimal. « le choix du bon pivot dépend de la distribution attendue… » Une des grosses difficultés du « Quick Sort » réside dans le choix du bon pivot, le risque étant de déséquilibrer les sous-listes qu’on va construire. Quand on n’a pas de meilleure idée et faute de mieux, on peut prendre le premier élément (fig. 5).
  13. 13. 13 Fig. 6 : Tri rapide (pivot en tête) sur liste quasi triée Lorsqu’on soupçonne que les données sont déjà à peu près triées et que le choix du pivot en première position provoquera un déséquilibre (fig. 6), on peut aussi préférer choisir le pivot au milieu (fig. 7). Et quand on n’a aucune information sur les données, on peut également choisir le pivot de manière aléatoire (fig. 7). Un mathématicien vous dirait que ça permet de maximiser « l’espérance » que ça se passe bien. Personnellement, je n’aime pas trop cette solution. Elle donne souvent de bons résultats, mais le déroulement de l’algorithme est alors aléatoire. Or, en bon informaticien, je n’aime pas trop quand c’est non reproductible. En outre, ça laisse la porte ouverte pour toutes les combinaisons où ça peut mal tourner. Or, en informatique, comme l’énonce la loi de Murphyvi , tout ce qui peut mal tourner va mal tourner. Fig. 7 : Tri rapide (pivot au centre)
  14. 14. 14 Fig. 8 : Tri rapide (pivot aléatoire) Comme son nom l’indique, le « Quick Sort » a la réputation d’être rapide, ce qui est mérité, mais parfois un peu usurpé. Pour le comprendre, il va encore falloir parler de complexité. Fig. 9 : Distribution aléatoire Dans le cas favorable où les éléments sont uniformément distribués, c’est-à-dire bien mélangées (fig. 9), l’algorithme produit un arbre équilibré avec « 2 » sous- listes de taille « n/2 » à chaque appel récursif, soit « n » opérations à chaque étape (une étape est un nœud de l’arbre ou une feuille). À l’étape « 1 », on aura donc « 2 » sous-listes. À l’étape « 2 », on aura « 2*2 = 22 » sous listes. À l’étape « 3 », on aura « 2*2*2=23 » sous listes, et ainsi de suite. À l’étape « p », on aura « 2*2*2*…*2=2p » sous listes. L’algorithme récursif s’arrête lorsqu’on arrive à un seul élément. S’il faut « p » étapes pour cela, on pourra donc dire que « n=2p ». La fonction mathématique qui permet de calculer la valeur de « p » quand on connait « n » est le « logarithme de n en base 2 » qu’on note « log2(n) » ou « lg(n) » en raccourci. Au final, la complexité du « tri rapide » sera donc en « O(n lg n) ». Fig. 10 : Distribution croissante Dans le cas défavorable où les éléments sont déjà triés (fig. 10), l’algorithme produit un arbre complètement déséquilibré, ressemblant à une longue tige tordue et ne permettant de trouver la position que d’un seul élément à chaque étape. On aura donc une complexité équivalente à celle du « tri par sélection » en « O(n²) ».
  15. 15. 15 Si on raisonne de nouveau en terme de durée, on se rend compte qu’il y a une véritable différence entre un algorithme de tri en « O(n lg n) » et un autre en « O(n²) ». Nombre d’éléments « n » Durée pour un tri en « O(n lg n) » Durée pour un tri en « O(n²) » 10 33 ns 100 ns 100 664 ns 10 us 1 000 10 us 1 ms 10 000 132 us 100 ms 100 000 1,6 ms 10 s 1 000 000 20 ms 16 min 40 s 10 000 000 233 ms 27 heures 100 000 000 2,5 s 115 jours 1 000 000 000 29 s 31 ans Population mondiale 4 min 1 655 ans Souvenez-vous. Un peu plus tôt, je vous ai dit qu’il fallait « 100 ns » pour trier une liste de « 10 » éléments à l’aide d’un algorithme en « O(n²) » et d’un bon ordinateur. Si on se place dans un cas en « O(n lg n) », il ne faudra plus que « 33 ns ». Bon, la différence ne saute pas aux yeux. Pour un million d’éléments, on passe de « 16 minutes » à « 20 ms », ce qui reste encore dans les limites du temps réel. Et pour trier la population mondiale, on passe de « 1655 ans » à seulement « 4 minutes ». Notez qu’il existe une optimisation du « Quick sort » assez contre-intuitive. Elle préconise de mélanger la liste avant de la trier. L’idée est de se rapprocher de la complexité en « O(n lg n) » du cas favorable où la liste est complètement mélangée, quitte à dépenser un « O(n) » à préparer la liste. Tri fusion (Merge Sort) Dans la grande famille des tris reposant sur le principe « diviser pour régner », on trouve également le « tri Fusion ». À mon sens, le « tri Fusion » est au « tri rapide » ce que le « tri par insertion » est au « tri par sélection ». Dans cette famille de tri, on travaille toujours en trois phases. D’abord on divise. Ensuite on règne. Et pour finir, on réconcilie (fusionne). Alors que, pour le « Quick Sort », tout se fait à la construction de l’arbre, pour le « tri fusion », la partie intéressante se situe lors de la phase de réconciliation. Pour réaliser un « tri fusion », on commence par diviser la liste à trier en deux sous-listes de même taille. On réitère récursivement cette opération jusqu’à n’avoir que des listes d’un seul élément. Cela produit donc un arbre à peu près équilibré. On remonte ensuite dans l’arbre en fusionnant les sous-listes à chaque étape. Pour cela, on prend le plus petit élément qui se présente en tête des deux sous-listes à fusionner et on recommence tant qu’il reste des éléments. Quand on revient à la « racine » de l’arbre, la liste est triée. Fig. 11 : Tri fusion
  16. 16. 16 Le coût des divisions récursives est quasi gratuit. Il est systématique et ne demande de réaliser aucune comparaison entre les éléments. Si vous avez compris ce qu’on avait dit pour la complexité du « quick Sort », vous avez certainement déjà deviné que la complexité du « tri fusion » sera en « O(n lg n) » dans tous les cas puisqu’on force la production d’un arbre équilibré. « le tri fusion force la production d’un arbre équilibré… » Comme pour le « Quick sort », il est plus simple de programmer l’algorithme du « tri Fusion » en commençant par une liste, pour bien le prendre en main. public class FusionViaListeTri implements Tri { private List<Integer> trier(final List<Integer> liste) { if (liste.size() <= 1) { return liste; } // Separation en deux sous listes final int posCentre = liste.size() / 2; List<Integer> listeGauche = liste.subList(0, posCentre); List<Integer> listeDroite = liste.subList(posCentre, liste.size()); // Tri des deux sous liste listeGauche = trier(listeGauche); listeDroite = trier(listeDroite); // Fusion final List<Integer> result = new ArrayList<>(liste.size()); final Iterator<Integer> iterGauche = listeGauche.iterator(); final Iterator<Integer> iterDroite = listeDroite.iterator(); Integer g = next(iterGauche); Integer d = next(iterDroite); while (g != null || d != null) { if (d == null || g != null && g.compareTo(d) < 0) { result.add(g); g = next(iterGauche); } else { result.add(d); d = next(iterDroite); } } return result; } private static Integer next(final Iterator<Integer> iter) { return (iter.hasNext()) ? iter.next() : null; } Pour réaliser le même traitement sur un tableau, il faudra décaler les éléments lors de la phase de fusion des zones triées de gauche et de droite. public class FusionTri implements Tri { @Override public void trier(int[] tab) { trier(tab, 0, tab.length - 1); } private void trier(int[] tab, int debut, int fin) { if (fin <= debut) { return; } // appels recursifs final int centre = (debut + fin) / 2; trier(tab, debut, centre); trier(tab, centre + 1, fin); // Fusion fusionner(tab, debut, centre, fin); } private void fusionner(int[] tab, int debut, int centre, int fin) { int g = centre; int d = centre + 1; while (debut <= g && d <= fin) { if (tab[debut] < tab[d]) { debut++; } else { // Decallage int temp = tab[d]; for (int i = d - 1; debut <= i; i--) { tab[i + 1] = tab[i]; } tab[debut] = temp;
  17. 17. 17 debut++; g++; d++; } } } Tri par interclassement monotone À l’époque où on utilisait encore des bandes magnétiques, le « tri par interclassement monotone » avait ses adeptes. Comme son nom l’indique, ce tri se base sur la « monotonie » dans l’ordonnancement des éléments d’une liste. Pour dérouler cet algorithme, on répète deux phases jusqu’à ce que ce soit trié, en utilisant des bandes magnétiques ou des zones mémoire temporaires « A » et « B ». Durant la phase « 1 », on recopie les éléments de la bande magnétique initiale « I » vers la bande « A » tant que les éléments sont croissants. On copie vers la bande « B » dès qu’ils sont décroissants. On continue sur la bande « B » jusqu’à ce qu’il y ait une décroissance en reprenant alors sur la bande « A ». Pour simplifier, on recopie les éléments sur une bande et on change de bande à chaque décroissance. Durant la phase « 2 », on fusionne les bandes « A » et « B » sur la bande « I » en copiant toujours le plus petit élément en tête des bandes. Je vous vois venir ; on n’en est plus à l’époque des bandes magnétiques. Cela dit, le temps de transfert d’un tableau de la mémoire centrale aux caches CPU, comparé à celui d’une liste chaînée, dont les éléments sont dispersés dans la mémoire, se pose exactement dans les mêmes termes. Et puis, on peut utiliser ce type de tri pour trier des gros fichiers dans un environnement contraint en mémoire puisqu’il n’y a besoin d’ouvrir que deux flux de sortie et un d’entré, ainsi qu’une paire de variables. Ce tri fonctionne relativement bien sur des listes mélangées. Son fonctionnement est presque magique quand on s’amuse à le dérouler sur papier. Mais c’est avec les listes en partie triées qu’il dévoile tout son potentiel. La connaissance, même faible, qu’on peut avoir à l’avance sur les données est donc très importante. Fig. 12 : Tri par interclassement monotone Pour la complexité, très sommairement, il y a donc « n » lectures et « n » écritures par phases. La longueur des sous-suites monotones est TRES grossièrement « 2 » puis « 4 » puis « 8 », bref, on va avoir « lg(n) » étapes (même raisonnement que pour un arbre). C’est assez approximatif, mais disons que dans le pire des cas, c’est en « O(n lg n) » et que, dans le cas de listes partiellement triées, c’est quasi linéaire en « O(n) ».
  18. 18. 18 Fig. 13 : Tri par interclassement monotone (liste quasi triée) Je vous invite à lire mon blogvii pour en savoir un peu plus sur le « tri par interclassement monotone » et découvrir quelques unes de ses variantes. Positionnement direct (tri sans comparaison) Parfois, on connait à l’avance la composition de la liste. Imaginons par exemple que la liste soit composée des membres de la Suite de Fibonacci : 1, 1, 2, 3, 5, 8, 13, 21… Avec cette information en poche, on ne va pas perdre notre temps à trier la liste puisque chaque élément porte intrinsèquement sa position. Par exemple, on sait que l’élément « 13 » doit aller à la position « 6 ». La puissance des machines là-dedans D’après la loi de Mooreviii , et en simplifiant, la puissance des processeurs double tous les 18 mois. Les traitements doivent donc aller deux fois plus vite. C’est vrai, mais c’est sans compter que quantité de données augmente également. Imaginons qu’elle ne fait que doubler sur la même période. Instinctivement, on se dit que ça double la quantité de calcul et que sera compensé par l’augmentation de puissance, mais c’est trompeur. Pour le comprendre, je vais devoir vous infliger encore un petit peu de math. Prenons un algorithme en « O(n²) » comme le « tri par sélection ». On double la quantité « n » de données et on divise par deux le temps de calcul pour que ce soit cohérent avec l’augmentation de puissance. On aura donc « (2n)²/2 = 4n²/2 = 2n² », ce qui est évidement plus grand que « n² ». Doubler la puissance ne suffit donc pas à compenser l’augmentation du volume de données et on ne peut donc pas se reposer sur l’amélioration des matériels. Il faut donc choisir avec attention son algorithme et l’adapter à son contexte. D’ailleurs, les constructeurs de processeurs ne font plus la course à la vitesse ; on n’essaie plus d’avoir des Méga Hertz, pour plein de bonnes raisons technologiques. À la place, on préfère multiplier le nombre de cœurs. L’avenir des tris complexes passe donc sans aucun doute par le parallélisme offert dans les processeurs multicœurs. Des algorithmes comme le « Quick Sort » seront les premiers à en profiter. Conclusion Alors, c’est quoi, le meilleur tri ? En fin d’année, Benny Scetbun répondait à une interviewix à propos de l’école « 42 ». Dans cette interview, il explique que le « Quick Sort » était autrefois considéré (c’est malheureusement encore enseigné à l’école) comme le meilleur algorithme de tri. Il précise toutefois que cela dépend du contexte. En effet, les listes qu’on doit manipuler sont statistiquement déjà ordonnées, tout simplement parce qu’on les a déjà triées la veille, l’avant-veille, etc. Les seuls éléments mal ordonnés sont ceux en queue (fig. 14), qu’on a ajoutés depuis le dernier tri. Du coup, le « Quick sort » est un bon algorithme en théorie, mais un mauvais dans la pratique. L’appliquer sur une liste déjà en partie triée le place dans son pire cas de complexité en « O(n²) ».
  19. 19. 19 Fig. 14 : Distribution croissante sauf en queue Notez que la bonne stratégie à appliquer dans cet exemple aurait été de ne trier que les données ajoutées récemment puis d’employer un algorithme similaire au « tri par insertion », que je vous ai présenté au début de cet article, pour les positionner correctement. Combinaisons de tri Les études des complexités servent d’une part à martyriser les étudiants en école d’info, et d’autre part à savoir quand et comment utiliser les algorithmes. Une des conséquences est qu’il peut être intéressant de combiner plusieurs algorithmes. Par exemple, la méthode de tri incluse dans le langage en Java (mon langage de prédilection) garantit un tri stable avec des performances en « O(n lg n) », ce qui est relativement honnête. Java utilise une combinaison d’algorithmes en fonction de la taille des listes. En dessous de « 7 » éléments, ça utilise un « tri par insertion ». Entre « 7 » et « 40 » éléments, ça emploie un « Quick Sort avec une médiane de trois ». Pour les listes plus grosses, ça utilise un « Quick Sort avec une médiane de neuf ». Les « médianes » désignent des variantes classiques sur le choix du pivot. Notez que les langages évoluent. Ainsi, depuis la version 7, Java utilise un « Quick Sort avec double pivot » pour les listes de plus de « 7 » éléments. Fig. 15 : Évolution du nombre d'opérations Après avoir autant discuté de complexité durant cet article, on pourrait s’étonner de ce choix dans le JDK. En effet, le « tri rapide » est en « O(n lg n) » alors que le « tri par insertion » est en « O(n²) ». Or, quand « n » vaut « 6 », le premier vaut « 15 » et le second « 36 », ce qui est bien supérieur. Mais raisonner de cette façon est en réalité une erreur. D’abord, il ne faut pas oublier que, même s’il est vrai que la complexité du « Quick Sort » est en « O(n lg n) » dans le cas favorable, elle tombe en « O(n²) » dans le pire des cas (lorsque la liste ou la sous-liste est triée). Ensuite, la complexité doit être considérée uniquement pour des grandes valeurs de « n ». Lorsqu’on manipule des listes petites, il faut dénombrer le nombre réel d’opérations. Pour le « tri par insertion », la formule est « n*(n- 1)/2 » dans le pire des cas, comme on l’avait calculé un peu plus tôt. Et il se trouve justement que ça donne « 15 » lorsque « n » vaut « 6 »… Sur le graphe (fig. 15), on observe que les deux courbes se croisent entre les positions d’abscisses « 6 » et « 7 ». Enfin, il faudra noter qu’on retrouve statistiquement de nombreuses sous-séquences déjà ordonnées dans les grandes listes. Tri ou presque La course au meilleur algorithme est un sujet vraiment important pour certaines entreprises. Un bon tri peut même s’apparenter à un avantage concurrentiel
  20. 20. 20 sérieux. Dans certaines situations, on n’a pourtant pas réellement besoin d’un résultat parfait. Fig. 16 : Distribution quasi croissante Reprenons l’exemple du photographe avec lequel on a commencé cet article. Il doit trier les enfants selon leurs tailles pour bien composer la photo de classe. Mais en réalité, il ne va pas s’amuser à mesurer chaque élève. Il fait ça au jugé. Et s’il s’est un peu trompé dans l’ordre final (fig. 15), on pourrait parier que ça ne se verra pas. Tri du dormeur Pour finir, et parce que je devine que vos yeux se ferment, je voudrais vous présenter un algorithme de circonstance puisqu’il s’agit du « tri du dormeur ». Ce tri a un nom qui me fait rigoler, d’autant qu’il le porte bien, car son principe de fonctionnement consiste précisément à dormir… Pour chaque élément de la liste, on lance un processus indépendant (un thread), qui se met en sommeil (pause) pour une durée égale à la valeur de l’élément. Par exemple, pour la valeur « 13 », le processus dort durant « 13 » millisecondes. Lorsque le processus se réveille, on ajoute directement son élément (« 13 » dans l’exemple) en queue d’une seconde liste. L’air de rien, ça marche super bien (mille éléments traités en une seconde) pour des listes dont les éléments n’ont pas des valeurs trop élevées. Le « tri du dormeur » nécessite toutefois une quantité astronomique de mémoire et une expérience forte en synchronisation multithread. Finalement Bref. On arrive à la fin de cet article, pour de bon. On aurait aussi pu discuter d’autres algorithmes de tri célèbres comme le « tri par base » (« Radix sort »), le « tri par tas » (« Heap sort »), le « tri par dénombrement », le « tri par paquets » (« Bucket sort ») ou encore le « tri shell » dont le principe repose sur des séquences (Pratt, Papernov-Stasevich, Sedgewick) et qui est monstrueusement efficace sur des tableaux presque triés. En guise de conclusion, je vous invite à les découvrir sur le Web, car ils valent le coup d’œil. Algorithme Complexité Caractéristique Sélection O(n²) Interne, stable, sur place Insertion +O(n) / -O(n²) Interne, stable, sur place Bulle O(n²) Interne, non stable, sur place Rapide +O(n lg n) / - O(n²) Interne, non stable, sur place Fusion O(n lg n) Interne/externe, stable, pas sur place Interclassement monotone O(n lg n) Dormeur O(a n) Base O(2c (n+k)) Interne, stable, pas sur place Tas O(n lg n) Interne, non stable, sur place Dénombrement O(2 (n+k)) Interne, stable, pas sur place Paquets O(a n) Interne, stable, pas sur place Shell Sedgewick O(n4/3 ) Interne, stable, sur place En fait, il n’existe pas d’algorithme de tri qu’on puisse considérer comme le meilleur. On pourrait généraliser cela en informatique par le fait qu’il n’y a pas de solution magique qui marche dans tous les cas. Par contre, on connait des réponses qui fonctionnent relativement bien dans des situations spécifiques.
  21. 21. 21 Quand on est développeur, il est indispensable de savoir comment le langage (Java, Lisp…), la base de données (Oracle, MySql, Mongo…) ou encore le progiciel (Kyriba, Excel…) trie les données. Il faut surtout savoir quand utiliser les mécanismes fournis et quand les fuir. Thierry Leriche-Dessirier Architecte JEE freelance / Team leader / Professeur à l’ESIEA http://www.icauda.com Merci à Étienne Neveu, Fabien Marsaud, Nicolas Tupegabet (Podcast Sciencex ) et Olivier Durin pour avoir participé à cet article. i Algorithme sur Wikipedia : http://fr.wikipedia.org/wiki/Algorithme ii On aurait pu écrire le même article dans un autre langage avec finalement assez peu de retouches. iii 3T : http://icauda.com/articles.php#3t iv La belote se joue à 4 joueurs avec un paquet de 32 cartes, soit 8 pour chaque joueur. v Population mondiale estimée au 1 er juillet 2014 : 7 226 376 025 personnes. vi http://fr.wikipedia.org/wiki/Loi_de_Murphy vii http://blog.developpez.com/todaystip/p11899/dev/tri-par-insertion-monotonie viii http://fr.wikipedia.org/wiki/Loi_de_Moore ix Interview de Benny Scetbun : http://nicotupe.fr/Blog/2013/09/42-interview-de-benny- scetbun/ x Podcast Science : http://www.podcastscience.fm PUBLICITE Test DISC Essentiel gratuit « Comportement et de Communication » http://www.profil4.com/disc-essentiel.php

×