Aller au menu - Aller au contenu

Icône La puissance des algorithmes

Mise à jour : 27/05/2011
Difficulté : Intermédiaire Intermédiaire Durée d'étude : 1 jour Creative Commons BY-NC-SA
75 639 visites depuis 7 jours, dont 316 sur ce chapitre classé 5/786
Nous avons découvert les itérateurs qui nous permettent de parcourir des conteneurs, comme les vector. Dans ce chapitre, nous allons découvrir les algorithmes de la STL, des fonctions qui nous permettent d'effectuer des modifications sur les conteneurs.

Cela fait un moment que je vous parle de modifications, mais qu'est-ce que cela veut dire ? Eh bien, par exemple on peut trier un tableau, supprimer les doublons, inverser une sélection, chercher, remplacer ou supprimer des éléments, etc.
Certains de ces algorithmes sont simples à écrire et vous ne voyez peut-être pas l'intérêt d'utiliser des fonctions déjà faites. Après tout, vous savez déjà chercher vous-mêmes quelque chose dans un vector par exemple.

L'avantage d'utiliser les algorithmes de la STL est qu'il n'y a pas besoin de réfléchir pour écrire ces fonctions. Il n'y a qu'à utiliser ce qui existe déjà. De plus, ces fonctions sont extrêmement optimisées. En bref, ne réinventez pas la roue et utilisez les algorithmes de la STL que je vais vous présenter ! :)

Il est nécessaire d'avoir bien compris le chapitre précédent, notamment les itérateurs et les foncteurs. Nous allons en utiliser beaucoup dans ce qui suit. :pirate:
Sommaire du chapitre :
Icône du chapitre
Chapitre précédent Sommaire Chapitre suivant

Un premier exemple

Je vous préviens tout de suite, nous n'allons pas étudier tous les algorithmes proposés par la STL. Il y en a une soixantaine et ils ne sont pas tous très utiles. Et puis, quand vous aurez compris le principe, vous saurez vous débrouiller seuls. ;)

La première chose à faire est comme toujours l'inclusion du bon en-tête. Dans notre cas, il s'agit du fichier algorithm. Et croyez-moi, vous allez souvent en avoir besoin à partir de maintenant.

Un début en douceur



Dans le chapitre précédent, nous avions créé un foncteur nommé Remplir et nous l'avons appliqué à tous les éléments d'un vector. Cela se faisait via une boucle for qui parcourait les éléments du tableau de la position begin() à la position end().

Le plus simple des algorithmes s'appelle generate et fait exactement la même chose, mais de façon plus optimisée. Il appelle un foncteur sur tous les éléments situés entre deux itérateurs. Grâce à cet algorithme, notre code de remplissage de tableau devient beaucoup plus court :

Code : C++
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
#include <algorithm>
#include <vector>
using namespace std;

//Définition de Remplir...

int main()
{ 
    vector<int> tab(100,0); //Un tableau de 100 cases valant toutes 0

    Remplir f(0);       

    generate(tab.begin(), tab.end(), f); //On applique f à tout ce qui se trouve entre begin() et end()
    
    return 0;
}


Ce code a l'avantage d'être en plus très simple à comprendre. :euh: Si vous parlez la langue de Shakespeare, vous aurez compris que "to generate" signifie "générer". La ligne mise en évidence se lit donc de la manière suivante : Génère grâce au foncteur f tous les éléments situés entre tab.begin() et tab.end(). On peut difficilement faire plus clair !

Mais pourquoi doit-on utiliser des itérateurs ? Pourquoi la fonction generate() ne prend-elle pas comme premier argument le vector ?


Excellente question ! :) Je vois que vous suivez. Il serait bien plus simple de pouvoir écrire quelque chose comme generate(tab, f) à la place des itérateurs. On s'éviterait toute la théorie sur les itérateurs !
En fait c'est une fausse bonne idée que de faire comme ça. Imaginez que vous ne vouliez appliquer votre foncteur qu'aux dix premiers éléments du tableau et pas au tableau entier. Comment feriez-vous avec votre technique ? :euh: Ce ne serait tout simplement pas possible. L'avantage des itérateurs est clair dans ce cas : on peut se restreindre à une portion d'un conteneur. Tenez, pour remplir seulement les 10 premières cases, on ferait ceci :

Code : C++
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
int main()
{ 
    vector<int> tab(100,0); //Un tableau de 100 cases valant toutes 0

    Remplir f(0);       

    generate(tab.begin(), tab.begin()+10, f); //On applique f aux 10 premières cases
    generate(tab.end()-5, tab.end(), f);      //Et aux 5 dernières
    
    return 0;
}


Plutôt sympa non ?

En fait, c'est une propriété importante des algorithmes, ils s'utilisent toujours sur une plage d'éléments situés entre deux itérateurs.

Application aux autres conteneurs



Le deuxième avantage d'utiliser des itérateurs est qu'ils existent pour tous les conteneurs. On peut donc utiliser les algorithmes sur tous les types de conteneurs ou presque. Il existe quand même quelques restrictions selon que les itérateurs sont aléatoires bidirectionnels ou constants (pour les set notamment) comme on l'a vu dans le chapitre précédent.

Par exemple, on peut tout à fait utiliser notre foncteur sur un list<int>.

Code : C++
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
int main()
{ 
    list<int> tab; //Un ensemble d'entiers

    //Quelques manipulations pour créer des éléments...

    Remplir f(0);       

    generate(tab.begin(), tab.end(), f); //On applique f aux éléments de l'ensemble
    
    return 0;
}


La syntaxe est strictement identique ! Il suffit donc de comprendre une fois le fonctionnement de tout ceci pour pouvoir effectuer des manipulations complexes sur n'importe quel type de conteneur ! :magicien:

Il faut quand même que le foncteur corresponde au type contenu. On ne peut bien sûr pas utiliser un foncteur manipulant des string sur une deque de nombres à virgule. Il faut rester raisonnable. Le compilateur génère parfois des message d'erreur très difficiles à interpréter quand on se trompe avec la STL. Soyez donc vigilants.

Compter, chercher, trier

Bon, plongeons-nous un peu plus en avant dans la documentation de l'en-tête algorithm. Commençons par quelques fonctions de comptage.

Compter des éléments



Compter des éléments est une opération très facile à réaliser. Utiliser la STL peut à nouveau vous sembler superflu, moi je trouve que cela rend le code plus clair et peut-être même plus optimisé dans certains cas.

Pour compter le nombre d'éléments égaux à une valeur donnée, on utilise l'algorithme count. Oui, être anglophone aide beaucoup en programmation. Mais je crois que vous l'avez compris. ;)
Pour compter le nombre d'éléments égaux au nombre 2, c'est très simple :

Code : C++
1
int nombre = count(tab.begin(), tab.end(), 2);


Et bien sûr tab est le conteneur de votre choix. Et voilà, vous savez tout ! :-° En tout cas pour cet algorithme...

Avant d'aller plus loin, faisons un petit exercice pour récapituler tout ce que nous savons sur les foncteurs, generate() et count(). Essayez d'écrire un programme qui génère un tableau de 100 nombres aléatoires entre 0 et 9 puis qui compte le nombre de 5 générés. Tout ceci en utilisant au maximum la STL bien sûr ! A vos claviers !

Vous avez réussi ? Voici une solution possible :

Code : C++
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <iostream>
#include <cstdlib> //pour rand()                                                                                                                             
#include <ctime>   //pour time()                                                                                                                             
#include <vector>
#include <algorithm>
using namespace	std;

class Generer
{
public:
    int operator()() const
    {
        return rand() % 10;  //On renvoie un nombre entre 0 et 9                                                                                                 
    }
};

int main()
{
    srand(time(0));

    vector<int> tab(100,-1); //Un tableau de 100 cases                                                                                                         

    generate(tab.begin(), tab.end(), Generer());      	   //On génère les nombres aléatoires                                                                

    int const compteur = count(tab.begin(), tab.end(), 5);   //Et on compte les occurrences du 5                                                                

    cout << "Il y a " << compteur << " elements valant 5." << endl;

    return 0;
}


Personnellement, je trouve ce code très clair. On voit rapidement ce qui se passe. Toutes les boucles nécessaires sont cachées dans les fonctions de la STL. Pas besoin de s'ennuyer à devoir tout écrire soi-même.

Le retour des prédicats



Si vous pensiez que vous pourriez vous en sortir sans ces drôles de foncteurs, vous vous trompiez ! ;) Je vous avais dit dans le chapitre précédent que l'on utilisait des prédicats pour tester une propriété des éléments. On pourrait donc utiliser un prédicat pour ne compter que les éléments qui passent un certain test. Et si je vous en parle, c'est qu'un tel algorithme existe. Il s'appelle count_if(). La différence avec count() est que le troisième argument n'est pas une valeur mais un prédicat.

Dans le chapitre précédent, nous avions écrit un prédicat qui testait si une chaîne de caractères contenait des voyelles ou non. Essayons-le !

Code : C++
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include <algorithm>
#include <string>
#include <vector>
using namespace std;

class TestVoyelles
{
public:
    bool operator()(string const& chaine) const
    {
        for(int i(0); i<chaine.size(); ++i)
        {
            switch (chaine[i])   //On teste les lettres une-à-une
            {
                case 'a':        //Si c'est une voyelle
                case 'e':
                case 'i':
                case 'o':
                case 'u':
                case 'y':
                    return true;  //On renvoie 'true'
                default:
                    break;        //Sinon, on continue
            }
        }
        return false;   //Si on arrive là, c'est qu'il n'y avait pas de voyelles du tout
    }
};

int main()
{
    vector<string> tableau;

    //... On remplit le tableau en lisant un fichier par exemple.

    int const compteur = count_if(tableau.begin(), tableau.end(), TestVoyelles());

    //... Et on fait quelque chose avec 'compteur'

    return 0;
}


Voilà qui est vraiment puissant ! Le prédicat TestVoyelles s'active sur chacun des éléments du tableau et count_if indique combien de fois le prédicat a renvoyé "vrai". On sait ainsi combien il y a de chaînes contenant des voyelles dans le tableau. :)

Chercher



Chercher un élément dans un tableau est aussi très facile. On utilise l'algorithme find() ou find_if(). Ils s'utilisent exactement comme les algorithmes de comptage, la seule différence est leur type de retour : ils renvoient un itérateur sur l'élément trouvé ou sur end() si l'objet cherché n'a pas été trouvé.

Pour chercher la lettre a dans une deque de char, on fera quelque chose comme :

Code : C++
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
#include <deque>
#include <algorithm>
#include <iostream>
using namespace std;

int main()
{
    deque<char> lettres;

    //On remplit la deque... avec generate() par exemple !

    deque<char>::iterator trouve = find(lettres.begin(), lettres.end(), 'a');

    if(trouve == lettres.end())
        cout << "La lettre 'a' n'a pas ete trouvee" << endl;
    else
        cout << "La lettre 'a' a ete trouvee" << endl;

    return 0;
}


Et je ne vous fais pas l'affront de vous montrer la version qui utilise un prédicat. :soleil: Je suis convaincu que vous saurez vous débrouiller.

Puisque l'on parle de recherche d'éléments, je vous signale juste l'existence des fonctions min_element() et max_element() qui cherchent l'élément le plus petit ou le plus grand.

Trier !



Il arrive souvent que l'on doive trier une série d'éléments. Et ce n'est pas une mince affaire. En tout cas, c'est un problème avancé d'algorithmique (la science des algorithmes ;) ). Je vous assure qu'écrire une fonction de tri optimisée est une tâche qui n'est pas à la portée de beaucoup de monde. :'(

Heureusement, la STL propose une fonction pour cela, et je peux vous assurer qu'elle est très efficace et bien codée. Son nom est simplement sort(), ce qui signifie trier en anglais (au cas où je devrais le préciser).

On lui fournit deux itérateurs et la fonction va trier tout ce qui se trouve entre ces deux éléments dans l'ordre croissant. Trions donc le tableau de nombres aléatoires utilisé précédemment.

Code : C++
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
int main()
{
    srand(time(0));

    vector<int> tab(100,-1); //Un tableau de 100 cases                                                                                                         

    generate(tab.begin(), tab.end(), Generer());      	   //On génère les nombres aléatoires                                                                

    sort(tab.begin(), tab.end());                            //On trie le tableau                                

    for(vector<int>::iterator it=tab.begin(); it!=tab.end(); ++it)
        cout << *it << endl;                                     //On affiche le tableau trié

    return 0;
}


A nouveau, rien de bien sorcier. :soleil:

La fonction sort() ne peut être utilisée qu'avec des conteneurs proposant des random access iterators, c'est-à-dire les vector et les deque uniquement. De toute façon, trier une map a peu de sens puisque ces conteneurs stockent directement leurs éléments dans le bon ordre. ;)


Par défaut, la fonction sort() utilise l'opérateur < pour comparer les éléments avant de les trier. Mais il existe également une autre version de cette fonction qui prend un troisième argument : un foncteur comparant deux éléments. Nous avons déjà rencontré un tel foncteur dans le chapitre précédent pour changer le comportement d'une table associative. C'est exactement le même principe ici. Si l'on souhaite créer un tri spécifique, on doit fournir un foncteur expliquant à sort() comment trier.

Pour trier des chaînes de caractères selon leur longueur, nous pouvons ré-utiliser notre foncteur :

Code : C++
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
class ComparaisonLongueur
{
public:
    bool operator()(const string& a, const string& b)
    {
        return a.length() < b.length();
    }
};


int main()
{
    vector<string> tableau;

    //... On remplit le tableau en lisant un fichier par exemple.

    sort(tableau.begin(), tableau.end(), ComparaisonLongueur());

    //Le tableau est maintenant trié par longueur de chaîne

    return 0;
}


Puissant, simple et efficace. Que demander de mieux ?

Encore plus d'algos

Ne nous arrêtons pas en si bon chemin. On est encore loin d'avoir fait le tour de tout ce qui existe.

Dans l'exemple du tri, j'affichais le contenu du vector via une boucle for. Faire cela via un algorithme serait plus élégant. :) Concrètement, afficher les éléments revient à les passer en argument à une fonction (ou un foncteur) qui les affiche. Écrire un foncteur qui affiche l'argument reçu ne devrait pas vous poser de problèmes à ce stade du cours.

Code : C++
1
2
3
4
5
6
7
8
class Afficher
{
public:
    void operator()(int a) const
    {
        cout << a << endl;
    }
};


Il ne nous reste plus qu'à appliquer ce foncteur sur tous les éléments. L'algorithme pour faire ça s'appelle for_each(), ce qui signifie "pour tout".

Code : C++
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
int main()
{
    srand(time(0));
    vector<int> tab(100, -1);
    generate(tab.begin(), tab.end(), Generer());  //On génère des nombres aléatoires
    sort(tab.begin(), tab.end());

    for_each(tab.begin(), tab.end(), Afficher());   //Et on affiche les éléments
    
    return 0;
}


On a encore raccourci notre code. ;)

A partir de cet algorithme, on peut faire énormément de choses. Un des premiers cas qui me vient à l'esprit est le calcul de la somme des éléments d'un conteneur. Vous voyez comment ? Comme for_each() appelle le foncteur sur tous les éléments de la plage spécifiée, on peut demander au foncteur de sommer les éléments dans un de ses attributs.

Code : C++
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
class Sommer
{
public:
    Sommer()
        :m_somme(0)
    {}

    void operator()(int n)
    { 
        m_somme += n; 
    }

    int resultat() const
    {
        return m_somme;
    }

private:
    int m_somme;

};


L'opérateur () va simplement ajouter la valeur de l'élément courant à l'attribut m_somme. Après l'appel à l'algorithme, on peut consulter la valeur de m_somme en utilisant la méthode resultat().
Il faut cependant faire attention. La fonction for_each reçoit une copie du foncteur en argument et pas une référence. Cela veut dire que l'objet dont l'attribut m_somme est incrémenté n'est pas celui déclaré dans le main, mais une copie de celui-ci. :(
Heureusement pour nous, les concepteurs de la STL ont pensé à tout et la fonction for_each() renvoie le foncteur qu'elle a utilisé une fois qu'elle a terminé. On peut donc utiliser l'objet retourné pour connaître la somme.

Code : C++
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
int main()
{
    srand(time(0));
    vector<int> tab(100, -1);
    generate(tab.begin(), tab.end(), Generer());  //On génère des nombres aléatoires

    Sommer somme;

    somme = for_each(tab.begin(), tab.end(), somme);   //On somme les éléments et on récupère le foncteur utilisé
 
    cout << "La somme des elements generes est : " << somme.resultat() << endl;
   
    return 0;
}


Si vous voulez un exercice, je peux vous proposer de récrire la fonction qui calculait la moyenne d'un tableau de notes que nous avions vu au tout début de ce cours. Un petit foncteur pour le calcul de la moyenne, un for_each() et le tour est joué. ;)

Utiliser deux séries à la fois



Terminons cette courte présentation avec un dernier algorithme bien pratique pour traiter deux conteneurs à la fois. Imaginons que nous voulions calculer la somme des éléments de deux tableaux et stocker le résultat dans un troisième vector. Pour cela, il va nous falloir un foncteur qui effectue l'addition. Mais ça, on l'a déjà vu, ça existe dans l'en-tête functional. Pour le reste, il nous faut parcourir en parallèle deux tableaux et écrire les résultats dans un troisième. C'est ce que fait la fonction transform(). Elle prend 5 arguments. Le début et la fin du premier tableau, le début du deuxième, le début de celui où seront stockés les résultats et bien sûr le foncteur. :-°

Code : C++
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
#include <vector>
#include <algorithm>
#include <functional>
using namespace std;

int main()
{
    vector<double> a(50, 0.);    //Trois tableaux de 50 nombres à virgule
    vector<double> b(50, 0.);
    vector<double> c(50, 0.);
   
    //Remplissage des vectors 'a' et 'b'....

    transform(a.begin(), a.end(), b.begin(), c.begin(), plus<double>());

    //A partir d'ici les cases de 'c' contiennent la somme des cases de 'a' et 'b'

    return 0;
}


Il faut tout de même que les tableaux b et c soient assez grands. Si ils ont moins de 50 cases (la taille de a), ce code va planter lors de l'exécution puisque l'algorithme va tenter de remplir des cases inexistantes.


Arrêtons-nous là pour ce chapitre. Je vous ai parlé des algorithmes les plus utilisés et je pense que vous avez compris comment tout cela fonctionnait. Vous commencez à avoir une bonne expérience du langage. ;)
Je n'ai bien sûr pas pu vous présenter tous les algorithmes. Au travers de quelques exemples vous avez vu comment combiner les itérateurs, les foncteurs et les prédicats pour réaliser des opérations plutôt compliquées... et tout ça en gardant un code source très lisible et facile à comprendre.

Il est rare de se trouver dans un cas où la STL ne propose pas d'algorithme adapté. Je vous propose d'ailleurs d'aller faire un tour dans votre documentation favorite pour consulter la longue liste des fonctions proposées. Pour le site cplusplus.com, vous devriez arriver sur cette page des algorithmes.

Avec tout ça, vous allez pouvoir devenir des champions du C++. Savoir utiliser la STL à bon escient est ce qui fait la différence entre un bon programmeur et un excellent programmeur. ;)
Chapitre précédent Sommaire Chapitre suivant

Partager

15 commentaires pour "La puissance des algorithmes"
Note moyenne : 3.85 / 4 (1749 votes)
Pseudo Commentaire
Hors ligne *Nat # Posté le 30/10/2011 à 00:49:11

Le tuto est pas mal mais les codes sont décevants: donner comme exemple un code erroné... bof.
Je parle du code

Secret (cliquez pour afficher)
Code : C++
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
class Sommer
{
public:
    Sommer()
        :m_somme(0)
    {}

    void operator()(int n)
    { 
        m_somme += n; 
    }

    int resultat() const
    {
        return m_somme;
    }

private:
    int m_somme;

};


Qu'on peut donc si l'on suit le tuto compléter ainsi:

Secret (cliquez pour afficher)
Code : C++
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
#include <iostream>
#include <deque>
#include <algorithm>
#include <vector>
#include <cstdlib>
#include <ctime>

using namespace std;

class Generer
{
public:
    int operator()() const
    {
        return rand() % 10;  //On renvoie un nombre entre 0 et 9
    }
};

class Sommer
{
public:
    Sommer()
        :m_somme(0)
    {}

    void operator()(int n)
    { 
        m_somme += n; 
    }

    int resultat() const
    {
        return m_somme;
    }

private:
    int m_somme;

};

int main()
{
    srand(time(0));
    vector<int> tab(100, -1);
    generate(tab.begin(), tab.end(), Generer());  //On génère des nombres aléatoires

    Sommer somme;

    for_each(tab.begin(), tab.end(), somme);   //Et on somme les éléments
 
    cout << "La somme des elements generes est : " << somme.resultat() << endl;
   
    return 0;
}


Code : Console
La somme des elements generes est : 0


Et pourquoi ? Parce que si on se penche sur for_each, son prototype est:
Code : C++
1
Function for_each(InputIterator first, InputIterator last, Function f)


Function f. Pas Function& f ou Function* f. Par conséquent l'objet est modifié seulement en local: ce code est inadapté.

Et franchement voir qu'un code est publié sans avoir été testé est très décevant.

Comprendre notre logique, notre raisonnement permet d'améliorer le fonctionnement de nos ordinateurs.
Existe-t-il une forme de logique absolue ? Peut-on l'appliquer à un ordinateur ?
Peut-être un jour nous aurons tellement amélioré les capacités de nos machines que nous devrons apprendre à raisonner de leur manière -si performante.
 
Hors ligne tobast # Posté le 30/10/2011 à 00:58:58
if(!geek) exit(EXIT_FAILURE);
Avatar

Avis : Très bon

J'approuve l'avis de *Nat. J'ai regardé ce code aussi, et le fait que l'auteur 1/ ne teste pas son code, 2/ ne regarde pas bien le prototype d'une fonction qu'il nous fait découvrir, n'est pas très satisfaisant.
Avant le remaniement, bien que très incomplet, le tuto était clair. Ce n'est plus le cas. Du tout. Dommage.
 
Connecté Nanoc # Posté le 22/01/2012 à 10:53:35
Aimez-vous le C++ ?
Avatar
Validateurs

Ville : Durham
Pays : Royaume-Uni
Études : EPFL

Ca a été corrigé.
 
Hors ligne alpfrance # Posté le 15/02/2012 à 13:30:30

Euuuh je viens de tester.
Quelqu'un pourrait me dire pourquoi la fonction resultat() me renvoie toujours 0???
Et pourquoi on ne pourrait pas utiliser quelque chose de plus simple du type: std::accumulate
Merci par avance.
Connecté Nanoc # Posté le 15/02/2012 à 15:44:33
Aimez-vous le C++ ?
Avatar
Validateurs

Ville : Durham
Pays : Royaume-Uni
Études : EPFL

Pour les questions, il vaut mieux passer par le forum en présentant le morceau de code incriminé.
 

Voir tous les commentaires