La bibliothèque standard fournit plusieurs algorithmes relatifs à l'ordonnancement des éléments dans les conteneurs. Grâce à ces algorithmes, il est possible de réorganiser la séquence de ces éléments de manière à obtenir certaines propriétés basées sur la relation d'ordre. Ces réorganisations ont généralement pour but soit de trier complètement ces séquences, soit d'effectuer des tris partiels à partir desquels il est possible d'obtenir des informations relatives à l'ordre des éléments de manière très efficace.
La plupart des algorithmes de tri et d'ordonnancement se basent sur une structure de données très performante : les « tas ». Les algorithmes de manipulation de ces structures de données seront donc décrits en premier. Les sections qui suivront traiteront ensuite des algorithmes de tri et de recherches binaires dans un ensemble d'éléments déjà trié.
Un tas (« heap » en anglais) est une structure de données récursive dans laquelle le premier élément est toujours le plus grand élément et qui dispose d'une opération de suppression du premier élément ainsi que d'une opération d'ajout d'un nouvel élément extrêmement performantes. Plus précisément, les propriétés fondamentales des tas sont les suivantes :
le premier élément du tas est toujours le plus grand de tous les éléments contenus ;
il est possible de supprimer ce premier élément et cette opération de suppression a une complexité logarithmique en fonction du nombre d'éléments dans le tas ;
il est possible d'ajouter un nouvel élément dans le tas avec une complexité également logarithmique en fonction du nombre d'éléments déjà présents.
Note : En pratique, un tas est une forme d'arbre binaire équilibré dont la propriété récursive est que la racine de l'arbre est l'élément de plus grande valeur et que les deux branches de l'arbre sont eux-même des tas. La suppression de la racine, ainsi que l'ajout d'un nouvel élément, nécessite une réorganisation de l'arbre binaire, ce qui ne peut dépasser ln(n) opérations en raison de son aspect équilibré.
Notez que les tas ne garantissent pas, contrairement aux B-arbres et aux arbres rouges et noirs, que tous les éléments situés à la gauche d'un noeud sont plus grands que le noeud lui-même et que tous les éléments situés à la droite sont plus petits. C'est pour cette raison qu'un tas n'est justement pas complètement trié, et que les algorithmes de gestion des tas ne font que conserver cet ordre partiel.
La représentation des tas en mémoire peut être relativement difficile à comprendre. En général, il est d'usage de les stocker dans des tableaux, car les opérations de gestion des tas requièrent des itérateurs à accès aléatoires sur le conteneur sur lequel elles travaillent. Dans ce cas, les premiers éléments du tableau stockent les noeuds de l'arbre binaire du tas, et les feuilles sont placées dans la seconde moitié du tableau. Ainsi, un élément d'indice i a comme feuilles les éléments d'indice 2×i et 2×i+1 (pour tout i < n/2). Reportez-vous à la bibliographie pour plus de renseignements sur les structures de données et les notions algorithmiques associées.
Les algorithmes de manipulation des tas sont déclarés comme suit dans l'en-tête algorithm :
template <class RandomAccessIterator> void make_heap(RandomAccessIterator premier, RandomAccessIterator dernier); template <class RandomAccessIterator, class Compare> void make_heap(RandomAccessIterator premier, RandomAccessIterator dernier, Compare c); template <class RandomAccessIterator> void pop_heap(RandomAccessIterator premier, RandomAccessIterator dernier); template <class RandomAccessIterator, class Compare> void pop_heap(RandomAccessIterator premier, RandomAccessIterator dernier, Compare c); template <class RandomAccessIterator> void push_heap(RandomAccessIterator premier, RandomAccessIterator dernier); template <class RandomAccessIterator, class Compare> void push_heap(RandomAccessIterator premier, RandomAccessIterator dernier, Compare c); template <class RandomAccessIterator> void sort_heap(RandomAccessIterator premier, RandomAccessIterator dernier); template <class RandomAccessIterator, class Compare> void sort_heap(RandomAccessIterator premier, RandomAccessIterator dernier, Compare c);
L'algorithme make_heap
permet de construire
un nouveau tas à partir d'une séquence d'éléments quelconque. Il prend simplement en paramètre
les itérateurs de début et de fin de cette séquence, et ne retourne rien. Sa complexité est
une fonction linéaire du nombre d'éléments référencés par ces deux itérateurs.
Les algorithmes pop_heap
et
push_heap
permettent respectivement de supprimer la tête d'un tas existant
et d'ajouter un nouvel élément dans un tas. pop_heap
prend en paramètre
deux itérateurs référençant le premier et le dernier élément du tas. Il place le premier élément
du tas en dernière position et réorganise les éléments restants de telle sorte que les
dernier-premier-1 éléments constituent un nouveau tas. L'algorithme
push_heap
en revanche effectue le travaille inverse : il prend en paramètre
deux itérateurs référençant une séquence dont les premiers éléments sauf le dernier constituent un tas
et y ajoute l'élément référencé par l'itérateur dernier-1. Ces deux opérations
effectuent leur travail avec une complexité logarithmique.
Enfin, l'algorithme sort_heap
permet simplement
de trier une séquence ayant la structure de tas. Sa complexité est n×ln(n),
où n est le nombre d'éléments de la séquence.
Exemple 18-19. Algorithmes de manipulation des tas
#include <iostream> #include <algorithm> using namespace std; int main(void) { int t[10] = {5, 8, 1, 6, 7, 9, 4, 3, 0, 2}; // Construit un tas à partir de ce tableau : make_heap(t, t+10); // Affiche le tas : int i; for (i=0; i<10; ++i) cout << t[i] << " "; cout << endl; // Supprime l'élément de tête : pop_heap(t, t+10); // L'élément de tête est en position 9 : cout << "Max = " << t[9] << endl; // Affiche le nouveau tas : for (i=0; i<9; ++i) cout << t[i] << " "; cout << endl; // Ajoute un élément : t[9] = 6; push_heap(t, t+10); // Affiche le nouveau tas : for (i=0; i<10; ++i) cout << t[i] << " "; cout << endl; // Tri le tas : sort_heap(t, t+10); // Affiche le tableau ainsi trié : for (i=0; i<10; ++i) cout << t[i] << " "; cout << endl; return 0; }
Les opérations de tri de la bibliothèque standard s'appuient sur les algorithmes de manipulation des tas que l'on vient de voir. Ces méthodes permettent d'effectuer un tri total des éléments d'une séquence, un tri stable, légèrement moins performant que le précédent mais permettant de conserver l'ordre relatif des éléments équivalents, et un tri partiel.
Les algorithmes de tri sont déclarés comme suit dans l'en-tête algorithm :
template <class RandomAccessIterator> void sort(RandomAccessIterator premier, RandomAccessIterator dernier); template <class RandomAccessIterator, class Compare> void sort(RandomAccessIterator premier, RandomAccessIterator dernier, Compare c); template <class RandomAccessIterator> void stable_sort(RandomAccessIterator premier, RandomAccessIterator dernier); template <class RandomAccessIterator, class Compare> void stable_sort(RandomAccessIterator premier, RandomAccessIterator dernier, Compare c);
Les algorithmes sort
et
stable_sort
s'utilisent de la même manière et permettent de trier complètement
la séquence qui leur est spécifiée à l'aide des deux itérateurs premier et
dernier. Ces deux algorithmes effectuent un tri par ordre croissant en utilisant
l'opérateur d'infériorité du type des éléments de la séquence à trier. Cependant, il est également
possible d'utiliser un autre critère, en spécifiant un foncteur binaire en troisième paramètre.
Ce foncteur doit être capable de comparer deux éléments de la séquence à trier et d'indiquer si
le premier est ou non le plus petit au sens de la relation d'ordre qu'il utilise.
Exemple 18-20. Algorithme de tri
#include <iostream> #include <algorithm> using namespace std; int main(void) { int t[10] = {2, 3, 7, 5, 4, 1, 8, 0, 9, 6}; // Trie le tableau : sort(t, t+10); // Affiche le résultat : int i; for (i=0; i<10; ++i) cout << t[i] << " "; cout << endl; return 0; }
Il se peut que plusieurs éléments de la séquence soient considérés
comme équivalents par la relation d'ordre utilisée. Par exemple, il est possible de trier des structures
selon l'un de leurs champs, et plusieurs éléments peuvent avoir la même valeur dans ce champ sans pour
autant être strictement égaux. Dans ce cas, il peut être nécessaire de conserver l'ordre relatif initial
de ces éléments dans la séquence à trier. L'algorithme sort
ne permet pas de le faire,
cependant, l'algorithme stable_sort
garantit la conservation de cet ordre relatif,
au prix d'une complexité algorithmique légèrement supérieure. En effet, la complexité de
stable_sort
est n×ln²(n) (où n est
le nombre d'éléments à trier), alors que celle de l'algorithme sort
n'est que de
n×ln(n). Hormis cette petite différence, les deux algorithmes sont strictement
équivalents.
Dans certaines situations, il n'est pas nécessaire d'effectuer un tri total des éléments. En effet, le tri des premiers éléments d'une séquence seulement ou bien seule la détermination du nième élément d'un ensemble peuvent être désirés. À cet effet, la bibliothèque standard fournit les algorithmes suivants :
template <class RandomAccessIterator> void partial_sort(RandomAccessIterator premier, RandomAccessIterator pivot, RandomAccessIterator dernier); template <class InputIterator, class RandomAccessIterator> RandomAccessIterator partial_sort_copy( InputIterator premier, InputIterator dernier, RandomAccessIterator debut_resultat, RandomAccessIterator fin_resultat); template <class RandomAccessIterator, class Compare> void partial_sort( RandomAccessIterator premier, RandomAccessIterator fin_tri, RandomAccessIterator dernier, Compare c); template <class InputIterator, class RandomAccessIterator, class Compare> RandomAccessIterator partial_sort_copy( InputIterator premier, InputIterator dernier, RandomAccessIterator debut_resultat, RandomAccessIterator fin_resultat, Compare c); template <class RandomAccessIterator> void nth_element(RandomAccessIterator premier, RandomAccessIterator position, RandomAccessIterator dernier); template <class RandomAccessIterator, class Compare> void nth_element(RandomAccessIterator premier, RandomAccessIterator position, RandomAccessIterator dernier, Compare c);
L'algorithme partial_sort
permet de n'effectuer
qu'un tri partiel d'une séquence. Cet algorithme peut être utilisé lorsqu'on désire n'obtenir que
les premiers éléments de la séquence triée. Cet algorithme existe en deux versions. La première version
prend en paramètre l'itérateur de début de la séquence, l'itérateur de la position du dernier élément
de la séquence qui sera triée à la fin de l'exécution de l'algorithme, et l'itérateur de fin
de la séquence. La deuxième version, nommée partial_sort_copy
, permet de copier
le résultat du tri partiel à un autre emplacement que celui de la séquence initiale. Cette version
de l'algorithme de tri partiel prend alors deux couples d'itérateurs en paramètre, le premier spécifiant
la séquence sur laquelle l'algorithme doit travailler et le deuxième l'emplacement destination dans lequel
le résultat doit être stocké. Enfin, comme pour tous les autres algorithmes, il est possible de spécifier
un autre opérateur de comparaison que l'opérateur d'infériorité utilisé par défaut en fournissant un foncteur
binaire en dernier paramètre.
Exemple 18-21. Algorithme de tri partiel
#include <iostream> #include <algorithm> using namespace std; int main(void) { int t[10] = {2, 3, 7, 5, 4, 1, 8, 0, 9, 6}; // Trie les 5 premiers éléments du tableau : partial_sort(t, t+5, t+10); // Affiche le résultat : int i; for (i=0; i<10; ++i) cout << t[i] << " "; cout << endl; return 0; }
La complexité de l'algorithme partial_sort est n×ln(m), où n est la taille de la séquence sur laquelle l'algorithme travaille et m est le nombre d'éléments triés à obtenir.
L'algorithme nth_element
permet quant à lui
de calculer la valeur d'un élément de rang donné dans le conteneur si celui-ci était complètement trié.
Cet algorithme prend en paramètre l'itérateur de début de la séquence à traiter, l'itérateur référençant
l'emplacement qui recevra l'élément qui sera placé à sa position à la fin de l'opération de tri partiel et
l'itérateur de fin de la séquence. Il est également possible, comme pour les autres algorithmes,
de spécifier un foncteur à utiliser pour tester l'infériorité des éléments de la séquence. À l'issue
de l'appel, le n-ième élément de la séquence sera le même élément que celui qui se trouverait à
cette position si la séquence était complètement triée selon la relation d'ordre induite par l'opérateur
d'infériorité ou par le foncteur fourni en paramètre. La complexité de l'algorithme
nth_element
est linéaire en fonction du nombre d'éléments de la séquence à traiter.
Exemple 18-22. Algorithme de positionnement du nième élément
#include <iostream> #include <algorithm> using namespace std; int main(void) { int t[10] = {2, 3, 9, 6, 7, 5, 4, 0, 1, 8}; // Trie tous les éléments un à un : int i; for (i=0; i<10; ++i) { nth_element(t, t+i, t+10); cout << "L'élément " << i << " a pour valeur " << t[i] << endl; } return 0; }
Enfin, et bien que ces algorithmes ne fassent pas à proprement parler des opérations de tri, la bibliothèque standard fournit deux algorithmes permettant d'obtenir le plus petit et le plus grand des éléments d'une séquence. Ces algorithmes sont déclarés de la manière suivante dans l'en-tête algorithm :
template <class ForwardIterator> ForwardIterator min_element(ForwardIterator premier, ForwardIterator dernier); template <class ForwardIterator, class Compare> ForwardIterator min_element(ForwardIterator premier, ForwardIterator dernier, Compare c); template <class ForwardIterator> ForwardIterator max_element(ForwardIterator premier, ForwardIterator dernier); template <class ForwardIterator, class Compare> ForwardIterator max_element(ForwardIterator premier, ForwardIterator dernier, Compare c);
Ces deux algorithmes prennent en paramètre deux itérateurs permettant de définir la séquence des éléments dont le minimum et le maximum doivent être déterminés. Ils retournent un itérateur référençant respectivement le plus petit et le plus grand des éléments de cette séquence. La complexité de ces algorithmes est proportionnelle à la taille de la séquence fournie en paramètre.
Exemple 18-23. Algorithmes de détermination du maximum et du minimum
#include <iostream> #include <algorithm> using namespace std; int main(void) { int t[10] = {5, 2, 4, 6, 3, 7, 9, 1, 0, 8}; // Affiche le minimum et le maximum : cout << *min_element(t, t+10) << endl; cout << *max_element(t, t+10) << endl; return 0; }
Les opérations de recherche binaire de la bibliothèque standard sont des opérations qui permettent de manipuler des séquences d'éléments déjà triées en se basant sur cet ordre. Les principales fonctionnalités de ces algorithmes sont de rechercher les positions des éléments dans ces séquences en fonction de leur valeur.
Les principaux algorithmes de recherche binaire sont les algorithmes
lower_bound
et upper_bound
. Ces algorithmes sont déclarés
comme suit dans l'en-tête algorithm :
template <class ForwardIterator, class T> ForwardIterator lower_bound(ForwardIterator premier, ForwardIterator dernier, const T &valeur); template <class ForwardIterator, class T, class Compare> ForwardIterator lower_bound(ForwardIterator premier, ForwardIterator dernier, const T &valeur, Compare c); template <class ForwardIterator, class T> ForwardIterator upper_bound(ForwardIterator premier, ForwardIterator dernier, const T &valeur); template <class ForwardIterator, class T, class Compare> ForwardIterator upper_bound(ForwardIterator premier, ForwardIterator dernier, const T &valeur, Compare c);
L'algorithme lower_bound
détermine la première
position à laquelle la valeur valeur peut être insérée dans la séquence ordonnée
spécifiée par les itérateurs premier et dernier sans en briser
l'ordre. De même, l'algorithme upper_bound
détermine la dernière position à
laquelle la valeur valeur peut être insérée sans casser l'ordre de la séquence
sur laquelle il travaille. Il est supposé ici que l'insertion se ferait avant les éléments indiqués
par ces itérateurs, comme c'est généralement le cas pour tous les conteneurs.
Si le programmeur veut déterminer simultanément les deux itérateurs
renvoyés par les algorithmes lower_bound
et upper_bound
,
il peut utiliser l'algorithme equal_range
suivant :
template <class ForwardIterator, class T> pair<ForwardIterator, ForwardIterator> equal_range(ForwardIterator premier, ForwardIterator dernier, const T &valeur); template <class ForwardIterator, class T, class Compare> pair<ForwardIterator, ForwardIterator> equal_range(ForwardIterator premier, ForwardIterator dernier, const T &valeur, Compare comp);
Cet algorithme renvoie une paire d'itérateurs contenant respectivement la première et la dernière des positions auxquelles la valeur valeur peut être insérée sans perturber l'ordre de la séquence identifiée par les itérateurs premier et dernier.
Exemple 18-24. Algorithmes de détermination des bornes inférieures et supérieures
#include <iostream> #include <algorithm> using namespace std; int main(void) { int t[10] = {1, 2, 4, 4, 4, 5, 8, 9, 15, 20}; // Détermine les positions possibles d'insertion // d'un 4 : cout << "4 peut être inséré de " << lower_bound(t, t+10, 4) - t << " à " << upper_bound(t, t+10, 4) - t << endl; // Récupère ces positions directement // avec equal_range : pair<int *, int *> p = equal_range(t, t+10, 4); cout << "Equal range donne l'intervalle [" << p.first-t << ", " << p.second-t << "]"; cout << endl; return 0; }
Comme pour la plupart des algorithmes de la bibliothèque standard, il est possible de spécifier un foncteur qui devra être utilisé par les algorithmes de recherche binaire dans les comparaisons d'infériorité des éléments de la séquence.
Enfin, l'algorithme binary_search
permet de déterminer
si un élément d'un conteneur au moins est équivalent à une valeur donnée au sens de l'opérateur
d'infériorité ou au sens d'un foncteur fourni en paramètre. Cet algorithme est déclaré de la manière
suivante dans l'en-tête algorithm :
template <class ForwardIterator, class T> bool binary_search(ForwardIterator premier, ForwardIterator dernier, const T &valeur); template <class ForwardIterator, class T, class Compare> bool binary_search(ForwardIterator premier, ForwardIterator dernier, const T &valeur, Compare c);
Cet algorithme prend en paramètre les deux itérateurs définissant la séquence d'éléments à tester, la valeur avec laquelle ses éléments doivent être testés, et éventuellement un foncteur permettant de réaliser une opération de comparaison autre que celle de l'opérateur d'infériorité. Il renvoie un booléen indiquant si un des éléments au moins du conteneur est équivalent à la valeur fournie en paramètre.
Note : La relation d'équivalence utilisée par cet algorithme n'est pas celle induite par l'opérateur d'égalité des éléments. En réalité, deux éléments x et y sont considérés comme équivalents si et seulement si les deux inéquations x<y et y<x sont fausses. C'est la raison pour laquelle le foncteur fourni en paramètre ne doit pas définir la relation d'égalité, mais la relation d'infériorité.
Cette distinction a son importance si certains éléments de la séquence ne sont pas comparables ou si l'opérateur d'égalité définit une autre relation que l'opérateur d'infériorité. Bien entendu, en pratique, ces deux inéquations signifie souvent que les valeurs x et y sont égales.
Exemple 18-25. Algorithme de recherche binaire
#include <iostream> #include <string> #include <algorithm> using namespace std; struct A { int numero; // Numéro unique de l'élément string nom; // Nom de l'élément A(const char *s) : nom(s) { // Affecte un nouveau numéro : static int i=0; numero = ++i; } // Opérateur de classement : bool operator<(const A &a) const { return (numero < a.numero); } // Opérateur d'égalité (jamais utilisé) : bool operator==(const A &a) const { return (nom == a.nom); } }; int main(void) { // Construit un tableau d'éléments triés // (par construction, puisque le numéro est incrémenté // à chaque nouvel objet) : A t[5] = {"Jean", "Marc", "Alain", "Ariane", "Sophie"}; // Cette instance a le même nom que t[1] // mais ne sera pas trouvé car son numéro est différent : A test("Marc"); // Effectue la recherche de test dans le tableau : if (binary_search(t, t+5, test)) { cout << "(" << test.numero << ", " << test.nom << ") a été trouvé" << endl; } else { cout << "(" << test.numero << ", " << test.nom << ") n'a pas été trouvé" << endl; } return 0; }
La complexité algorithmique de tous ces algorithmes est logarithmique en fonction du nombre d'éléments de la séquence sur laquelle ils travaillent. Ils s'appuient sur le fait que cette séquence est déjà triée pour atteindre cet objectif.
Précédent | Sommaire | Suivant |
Opérations de recherche | Niveau supérieur | Opérations de comparaison |