Chapitre 1 Algorithmes de tri élémentaires Le tri de données constitue un des problèmes fondamentaux de l’algorithmique. On considère une liste de données, auxquelles sont attachées des clés. On cherche à trier ces données en fonction des clés (par exemple, trier une liste d’élèves par ordre alphabétique, ou par ordre de moyennes...). Nous nous placerons pour simplifier dans le cas où la clé est la donnée elle-même. 1.1 1.1.1 Tri par sélection Principe du tri par sélection Considérons une liste ` de longueur n. Supposons que la plage `[0..i[ – soit triée – contienne les i plus petits éléments de la liste (on initialise avec i = 0). On sélectionne, dans la plage `[i..n[, le plus petit élément, que l’on permute avec `i partie triée `0 ··· `i−1 ··· `i `∗ ··· `n−1 permutation Figure 1.1 – Tri par sélection : les i premiers éléments sont déjà placés 1.1.2 Programmation Commençons par écrire une fonction qui détermine l’indice du plus petit élément d’une liste entre les positions i et j : 1 2 3 4 5 6 7 1 2 3 def cherche_ind_min (l ,i , j ): """ renvoie l ’ indice du plus petit é l é ment de la plage l [ i .. j [ """ imin = i for k in range ( i +1 , j ): if l [ k ] < l [ imin ]: imin = k return ( imin ) Nous pouvons maintenant écrire la fonction de tri : def tri_selection ( l ): """ trie la liste l par l ’ algorithme du tri s é lection . La fonction modifie la liste l et ne renvoie rien """ 1 Chapitre 1. Algorithmes de tri élémentaires 2 4 5 6 7 n = len ( l ) for i in range (n -1): imin = cherche_ind_min (l ,i , n ) l [ i ] , l [ imin ] = l [ imin ] , l [ i ] Rappelons que, lors de l’écriture d’un algorithme, on doit être en mesure du justifier sa terminaison et sa correction. La terminaison est ici évidente car on utilise une boucle inconditionnelle (boucle for), qui se termine toujours (contrairement au cas d’une boucle while). Pour justifier la correction de l’algorithme (il fait bien ce pour quoi il a été conçu), démontrons que la propriété suivante est vraie : «À l’issue du passage d’indice i dans la boucle, la plage `[0..i + 1[ est triée et elle contient les i + 1 plus petits éléments.» – Lors du premier passage dans la boucle, on identifie le plus petit élément du tableau, que l’on place en tête (par permutation de deux éléments). À l’issue de ce passage d’indice i = 0, la plage `[0..1[ est triée (elle contient un unique élément) et elle contient les i + 1 = 1 plus petits éléments. – Supposons que la propriété soit vraie après le passage d’indice i. Lors du passage d’indice i + 1, on détermine le plus petit élément de la plage `[i + 1..n[ (qui est par hypothèse supérieur à tous les `k pour k 6 i), que l’on échange avec celui d’indice i + 1. À l’issue de ce passage d’indice i + 1, la plage `[0..i + 2[ est encore triée et elle contient les i + 2 plus petits éléments. Par récurrence finie, la propriété est encore vérifiée après le dernier passage, i.e. celui d’indice i = n − 2. À l’issue de ce passage, la plage `[0..n − 1[ est donc triée, et elle contient les n − 1 plus petits éléments de la liste. Par suite, le dernier élément `n−1 est supérieur à tous les précédents, donc la totalité de la liste est triée. Remarque 1. Une telle propriété (que nous avons démontrée par récurrence) est appelée un invariant de boucle. C’est en général en exhibant un tel invariant que l’on prouve la correction des algorithmes. 1.1.3 Stabilité Lorsque les données à trier sont distinctes des clés utilisées pour trier la liste (par exemple, un élève n’est pas égal à sa moyenne), on peut s’intéresser à ce qui arrive à deux éléments ayant la même clé (deux élèves ayant la même moyenne) : après le tri, ces deux éléments sont-ils dans la même position relative l’un par rapport à l’autre qu’avant le tri ? Lorsque c’est le cas, on dit que le tri est stable. La stabilité d’un tri est un élément intéressant lorsque l’on veut trier des données suivant un critère prioritaire, puis un critère secondaire. Par exemple, si les données sont des couples (nom, prénom), on peut chercher à trier par nom (critère prioritaire) puis, en cas d’égalité des noms, par prénom (critère secondaire). Si l’on dispose d’un tri stable, on peut réaliser ce tri de la façon suivante : – commencer par trier selon le critère secondaire ; – trier ensuite selon le critère prioritaire. Les données seront alors triées selon le critère prioritaire. En cas d’égalité des clés associées (le nom dans notre exemple), les données seront rangées, après ce second tri, dans le même ordre qu’après le premier tri, i.e. classées selon le critère secondaire (le prénom). Le tri par sélection n’est pas stable : par exemple, si les données sont A, B et C, et que les clés attachées sont 1, 1 et 0 (ce que l’on notera pas A1 , B1 , C0 ), le tri par sélection transforme la liste [A1 , B1 , C0 ] en [C0 , B1 , A1 ] (les éléments A et B, de même clé, ont été permutés). 1.1.4 Complexité Lorsque l’on cherche à évaluer la complexité d’un algorithme, on distingue en général la complexité – spatiale (l’espace mémoire qui est nécessaire pour exécuter l’algorithme) ; – temporelle (le temps nécessaire à son exécution). Nous n’étudierons ici que la complexité temporelle des algorithmes. Il est évidemment impossible de dire quelle durée prendra l’exécution d’un algorithme : cela dépend en particulier de la vitesse de la machine sur lequel il est exécuté. Pour évaluer cette complexité temporelle, nous chercherons à compter le nombre d’opérations élémentaires effectuées par l’algorithme. Ceci est en général impossible à faire de façon exacte, aussi simplifions-nous le problème en choisissant de compter uniquement les opérations «significatives». On cherche par ailleurs à obtenir une estimation asymptotique 1.2. Tri par insertion 3 de ce nombre plutôt qu’un décompte exact. Ainsi, on parlera d’algorithme linéaire (resp. quadratique, exponentiel) lorsque le nombre de ces opérations significatives varie linéairement (resp. comme le carré, l’exponentielle) de la taille n de la donnée à traiter. Dans le cas du tri, on évalue la complexité d’un algorithme en comptant le nombre de comparaisons effectuées entre les éléments de la liste. – La fonction cherche_ind_min, appelée avec les arguments i et j, effectue j − i − 1 comparaisons. Appelée avec les arguments i et n, elle en effectue n − i − 1. – Chaque passage dans la boucle indexée par i de la fonction tri_selection effectue donc n − i − 1 comparaisons. Comme i varie de 0 à n − 2, il y a au total C(n) = n−2 X (n − i − 1) = i=0 n−1 X k= k=1 n(n − 1) comparaisons. 2 Nous avons donc démontré le Théorème La complexité du tri par sélection est quadratique : C(n) = O(n2 ). Un tel algorithme est en général considéré comme peu efficace. Si la taille de la liste est multipliée pas deux, le temps nécessaire à son tri est lui multiplié par quatre. De plus, si la liste est déjà triée (ce que l’on peut tester par un simple parcours de liste, qui ne nécessite que n − 1 comparaisons), il n’y a rien de plus à faire, alors que le coût de cet algorithme est quand même, dans ce cas particulier, quadratique. Remarque 1. Même si la complexité quadratique est considérée comme mauvaise, il existe des problèmes pour lesquels il n’existe pas d’algorithme performant. Par exemple, pour un algorithme de complexité équivalente à 2n (complexité exponentielle), multiplier la taille des données pas 2 revient à élever aux carré le nombre d’opérations à effectuer ! De tels algorithmes sont totalement inefficaces, sauf pour de très petites valeurs de n (le temps de calcul nécessaire à traiter un problème dépasse rapidement l’âge de l’univers...). En ce qui concerne le tri, nous verrons d’autres algorithmes plus performants que celui du tri par sélection. 1.2 1.2.1 Tri par insertion Principe du tri par insertion C’est le tri du joueur de cartes qui reçoit ses cartes une à une. Le premier élément constitue une liste triée à lui tout seul. On compare ensuite le deuxième élément au premier pour savoir si on l’insère avant ou après le premier. À la k ème itération, on insère le k + 1ème élément dans la liste, déjà triée, des k premiers éléments. `0 `i−1 `i `k−1 `k `n−1 Figure 1.2 – Tri par insertion : insérer `k à sa place et décaler les éléments 1.2.2 1 2 Programmation def tri_insertion ( l ): """ trie la liste l par l ’ algorithme du tri insertion . Chapitre 1. Algorithmes de tri élémentaires 4 3 4 5 6 7 8 9 10 11 La fonction modifie la liste l et ne renvoie rien """ n = len ( l ) for k in range (1 , n ): a_placer = l [ k ] i = k -1 while i >= 0 and a_placer < l [ i ]: l [ i +1] = l [ i ] i = i - 1 l [ i +1] = a_placer Terminaison du programme : – la boucle for se termine ; – la boucle while aussi : même si on ne trouve aucun indice i tel que `i soit strictement supérieur à l’élément à placer, i diminue strictement à chaque étape donc finira par devenir négatif, ce qui terminera la boucle. Remarquons d’ailleurs que l’ordre dans lequel les conditions sont écrites n’est pas anodin : on vérifie d’abord que i > 0, puis on lit l’élément dans la case d’indice i (si on faisait le contraire, on risquerait de tenter de lire le contenu de la case d’indice −1, ce qui provoquerait une erreur). Démontrons maintenant la correction, en démontrant que la propriété «après le passage d’indice k dans la boucle for, la sous-liste `[0..k + 1[ est triée». – C’est vrai avant le premier passage (ce qui correspond à «après le passage d’indice k = 0) car la liste `[0..1[ comporte un seul élément donc est triée. – Supposons la propriété vraie après le passage d’indice k − 1 et vérifions qu’elle l’est encore après le passage d’indice k. Pour cela, il faut vérifier que notre mécanisme d’insertion fonctionne bien. Il est basé sur une boucle while, qui a deux causes possibles d’arrêt : – soit on trouve un indice i > 0 tel que a_placer < `i . À cet instant, l’élément en position k − 1 a été reculé à la position k, . . . et l’élément en position i + 1 a été reculé en position i + 2. La position i + 1 est donc libre, et c’est celle dans laquelle il faut insérer l’élément. – soit on n’a trouvé aucun tel élément et on s’arrête parce que i = −1. Lors du passage précédent dans la boucle, l’élément situé en position 0 a été reculé en position 1 ; il faut insérer l’élément en position 0. Dans tous les cas, en sortie de la boucle while, c’est bien en position i + 1 qu’il faut insérer l’élément. À l’issue du passage d’indice k, la propriété est encore vraie. Par récurrence, elle l’est encore à l’issue du passage d’indice n − 1 : la liste est alors triée. Remarque 1. À la différence du tri par sélection, le tri par insertion est stable : un élément de clé c ne peut passer devant un autre élément que si cet autre est de clé c0 > c. Deux éléments de même clé ne sont jamais permutés. 1.2.3 Complexité À la différence du tri par sélection, la complexité n’est pas la même pour toutes les listes de taille n. Nous allons étudier la complexité Cmin (n) dans le meilleur des cas et la complexité Cmax (n) dans le pire des cas (toujours en nous intéressant au nombre de comparaisons effectuées entre éléments du tableau). Dans le meilleur des cas, chaque élément `k (k > 1) est comparé à son prédécesseur et reste à sa place car `k > `k−1 . Ce cas se produit lorsque le tableau est déjà trié ; la complexité est alors linéaire : Cmin (n) = n − 1. Dans le pire des cas, chaque élément `k (k > 1) est comparé à ses k prédécesseurs et est finalement inséré en tête de liste. Ce cas se produit lorsque la liste est initialement triée à l’envers. La complexité est alors quadratique : n−1 X n(n − 1) . Cmax (n) = k= 2 k=1 Retenons : 1.3. Complexité minimale d’un algorithme de tri 5 Théorème Les complexités du tri par insertion sont, dans le meilleur et dans le pire des cas : Cmin (n) = O(n) (linéaire) 1.3 Cmax (n) = O(n2 ) (quadratique). et Complexité minimale d’un algorithme de tri Remarque 1. Ce dernier paragraphe n’est pas à votre programme ; il est inséré à titre d’information. Les deux algorithmes de tri que nous avons étudiés ont une complexité Cmax (n) en O(n2 ). Peut-on faire mieux ? Oui : nous étudierons dans le prochain chapitre un algorithme dont la complexité maximale vérifie Cmax (n) ∈ O(n log n). Cet algorithme est optimal au sens suivant : Théorème Un algorithme de tri procédant par comparaisons entre les clés des éléments à trier a, au mieux, une complexité Cmax (n) ∈ O(n log n). La preuve utilise la notion d’arbre de décision. 1.3.1 Arbres binaires Un arbre binaire est un ensemble de nœuds, organisés de la façon suivante : – un nœud et un seul n’est pointé par aucune flèche : c’est la racine de l’arbre ; – de certains nœuds (appelés nœuds internes) partent une flèche gauche et une flèche droite, pointant chacune sur un nœud (ce sont les fils du nœud interne) ; – des autres nœuds (appelés feuilles) ne part aucune flèche. Cette structure est utilisée pour représenter des données. Elle contient de l’information, stockée dans les feuilles et les nœuds internes (ce sont les étiquettes de l’arbre) et dans les flèches (ce sont les labels de l’arbre). Exemple 1. Voici un exemple d’arbre binaire. A 1 2 B C 3 D 4 E Figure 1.3 – Un exemple d’arbre binaire Cet arbre est formé de deux nœuds internes, étiquetés A et C, et de trois feuilles (étiquetées B, D et E). Le nœud A est la racine de l’arbre ; le sous-arbre pointé par la flèche de label 1 est le fils gauche du nœud A ; le sous-arbre pointé par la flèche de label 2 est son fils droit. Ces deux sous-arbres ont pour père le nœud A. La hauteur d’un arbre est la longueur (i.e. nombre de flèches) maximale d’un chemin allant de la racine à une feuille. Exemple 2. L’arbre représenté sur la figure 1 a une hauteur égale à 2. Remarque 1. Un arbre de hauteur n a donc au moins n + 1 feuilles (cas où chaque fils gauche est une feuille par exemple) et au plus 2n feuilles (cas où tous les nœuds, sauf ceux du « bas », ont 2 fils). Chapitre 1. Algorithmes de tri élémentaires 6 24 feuilles 4 + 1 feuilles Figure 1.4 – Nombre de feuilles d’un arbre de hauteur n 1.3.2 Arbres de décision On désire faire un choix dans un ensemble E de cardinal n 6= 0. Un arbre de décision associé à ce choix est un arbre binaire tel que : – la racine est étiquetée par l’ensemble E – chaque feuille est étiquetée par un singleton inclus dans E – chaque nœud qui n’est pas une feuille est étiqueté par un sous-ensemble E1 de E et ses deux fils par des sous ensembles stricts E2 et E3 de E1 tels que E1 = E2 ∪ E3 . De plus, on adjoint à chaque nœud un test booléen. Les deux flèches issues de ce nœud portent les labels V et F (pour « vrai » et « faux »). {a, b, c} V F a < b? {a, c} V {b, c} F a < c? V {a} {c} b < c? {b} F {c} Figure 1.5 – Arbre de décision pour la recherche de min(a, b, c) L’instruction définie par un nœud est la suivante : on sait que le choix à effectuer se trouve dans l’ensemble E1 . On effectue le test correspondant : si le résultat est V , alors le choix à effectuer se trouve dans le sous-ensemble issu de la flèche V ; sinon, dans l’autre sous-ensemble. La figure 1.5 représente un arbre de décision associé au choix du plus petit parmi trois nombres a, b et c. Remarque 1. Un arbre de décision associé à un ensemble de cardinal n doit posséder n feuilles au moins ; sa hauteur h doit donc vérifier 2h > n, donc h > dlog2 ne. Utilisons un arbre de décision pour le problème du tri d’une liste ` de taille n. En supposant les éléments deux à deux distincts, il y a n! listes différentes contenant ces éléments. Chacune de ces listes est une permutation de la liste `. Trier la liste revient à trouver, parmi les n! permutations de `, quelle est celle qui est triée. Notre arbre de décision a pour ensemble de référence l’ensemble E des permutations de ` ; les tests associés à chaque nœud sont les tests comparant deux éléments Pn du tableau. La hauteur minimale de l’arbre de décision est donc supérieure ou égale à dlog2 (n!)e = d k=2 log2 ke. Or la croissance de la fonction ln permet d’écrire Z k k+1 Z ln t dt 6 ln k 6 k−1 ln t dt k pour tout entier k > 2, d’où, par somme : Z n Z ln t dt 6 ln(n!) 6 1 n+1 ln t dt, 2 1.3. Complexité minimale d’un algorithme de tri 7 soit encore n ln n − n + 1 6 ln(n!) 6 (n + 1) ln(n + 1) − 2(ln 2 − 1), puis l’équivalent ln(n!) ∼ n ln n, d’où enfin dlog2 (n!)e ∼ n log2 n. Comme le nombre de comparaisons dans le pire des cas est égal à la hauteur de l’arbre de décision, nous avons démontré le théorème.