Notes de cours INFO510 / INFO511, L3 IUP TR Algorithmique et

Transcription

Notes de cours INFO510 / INFO511, L3 IUP TR Algorithmique et
Notes de cours INFO510 / INFO511, L3 IUP TR
Algorithmique et structures de données
Jacques-Olivier Lachaud
LAMA, Université de Savoie
11 octobre 2011
1
Introduction
Ce document trace les grandes lignes d’un cours de niveau introductif à l’algorithmique et aux
structures de données. Le langage algorithmique choisi est un pseudo-Pascal. Le langage de mise en
œuvre est le langage C. Ce cours s’adresse à des étudiants relativement débutants en informatique,
mais ayant néanmoins quelques notions de base. L’objectif est de connaı̂tre les TAD (types abstraits
de données) classiques (listes, arbres) et leurs utilisations courantes, et de savoir les mettre en œuvre.
Pour la syntaxe du pseudo-langage algorithmique, on peut se référer au document d’Eric Sopena
(LaBRI, IUT Bordeaux 1). Les livres sur les structures de données sont pléthores. Ici, quelques exemples sont pris de Data Structures and Algorithms, Aho/Hopcroft/Ullman, Ed. Addison-Wesley, 1987.
Un livre très complet sur l’algorithmique est Introduction à l’algorithmique, Cormen/Leiserson/Rivest/Stein,
Dunod, 2004. Pour le langage C, The C programming Langage, Kernighan/Ritchie est très bon, mais
n’intègre pas les modifications de la dernière version du C. On peut conseiller aussi le Méthodologie
de la programmation en C, Braquelaire, Ed. Dunod, 2005, qui intègre la norme C99.
Plan
1. Généralités
(a) Qu’est-ce qu’un algorithme ?
(b) Quelques problèmes classiques
(c) Rappels d’algorithmique élémentaire
(d) Actions, Fonctions
(e) Taxinomie des Types Abstraits de Données (TAD)
2. Quelques algorithmes sur les tableaux
(a) Le tableau
(b) Algos de base : parcours, min, max, argmin, argmax, recherche
(c) n-ième plus grand d’une collection ? Vers le tri
(d) Les tris internes/externes
(e) Autres tris : bin-sort
(f) Bornes inférieures d’un tri
3. Structures séquentielles
(a) Listes et cas particuliers Piles et Files
(b) Exemple de mise en œuvre d’une Pile
(c) Mise en œuvre des files
(d) Utilisation des files
(e) Algorithmes classiques utilisant les listes
(f) Mise en œuvre des listes (contiguë ou non)
(g) La file à double entrée et le calcul de l’enveloppe convexe
4. Structures arborescentes
(a) Arbres, arbres binaires, arbres n-aires
(b) Arbres partiellement ordonnés, file à priorité et tas
(c) Arbre binaire de recherche et applications
(d) Arbre suffixe et palindrome
(e) Tas et tri
(f) Arbre quaternaire et plus proche point
5. Complexité des algorithmes
1
(a) Un cadre de comparaison vitesse/taille
(b) Notations O, Ω, Θ
(c) Complexité des algorithmes présentés
6. Structures relationnelles Graphes, digraphes
1
Généralités
1.1
Qu’est-ce qu’un algorithme ?
Des problèmes aux programmes.
1. Identifier le problème à résoudre. Ce n’est toujours évident, surtout quand la personne soumettant le problème n’est pas informaticienne. Il faut aussi vérifier que le problème peut être résolu
à l’aide d’un ordinateur.
Exemples : Savoir dans un groupe qui est le “meilleur” 6= Savoir dans un groupe qui a en moyenne les
meilleures notes.
Quelle est la meilleure formation 6= Quelle est la formation avec le meilleur pourcentage de diplômé 6=
Quelle est la formation avec la meilleure insertion professionnelle.
2. Formaliser le problème. Une fois le problème bien identifié, on utilise des modèles formels (en
général bien étudiés) pour exprimer le problème sous une forme bien définie et bien spécifiée.
Exemples : Les systèmes d’équations linéaires (circuits, résistance des matériaux, géométrie)
Les équations aux dérivées partielles (EDP) (mécanique des fluides, électromagnétisme, etc)
Les grammaires et autres outils de mathématiques discrètes (reconnaissance de texte, logique et satisfaction de contraintes)
Les systèmes d’information, les IHM sont représentées sous forme relationnelle et les processus avec des
diagrammes adaptés (MERISE, UML)
3. Trouver une solution dans le cadre du modèle choisi, à l’aide d’une succession d’opérations
élémentaires. On parle d’algorithme.
Définition 1 (Algorithme (inspiré de Wikipedia)) Un algorithme est un énoncé d’une suite
d’opérations permettant de donner la réponse à un problème (énoncé en terme de : j’ai telles
données en entrées, je veux telles données en sortie). Si ces opérations s’exécutent en séquence,
on parle d’algorithme séquentiel. Si les opérations s’exécutent sur plusieurs processeurs en parallèle, on parle d’algorithme parallèle.
Exemples : Pour les EDP, il existent des schémas classiques pour transformer toute EDP en une succession de calculs qui approchent la solution réelle de plus en plus précisément.
Pour les équations linéaires, elles sont souvent mises sous forme matricielles, puis résolues avec des
algorithmes d’inversion de matrices.
Pour la reconnaissance de texte, on utilisera souvent des structures de données auxiliaires dans lesquelles
on réduit considérablement l’espace de recherche.
Un algorithme est donc un moyen de calculer une solution au problème posé, le calcul étant
décomposé en un nombre fini d’instructions et devant être exécutable en un temps fini, quelle
que soit la donnée en entrée (problème dit de la terminaison).
On décrit un algorithme souvent à l’aide d’un pseudo-langage naturel très simplifié, à la fois
lisible et formel. Les données, résultats, et variables intermédiaires sont clairement déclarés,
chaque calcul est précisé de façon complète, etc.
4. L’algorithme est enfin traduit sous forme d’un langage de programmation adapté, parfois dépendant
aussi du système d’exploitation.
Il est clair que les ordinateurs ne savent faire que des opérations extrêmement élémentaires :
accès mémoire et arithmétique essentiellement. C’est donc le travail de l’informaticien d’écrire
une solution à un problème complexe sous forme d’une suite d’instructions élémentaires.
2
Quelques questions :
(a) Quels langages de programmation connaissez-vous ?
(b) Quelles sont leurs principales différences ?
(c) Quel est l’influence d’un langage particulier pour résoudre un problème donné ?
(d) D’où vient le mot algorithme ?
5. Les langages de programmation visent d’une part à fournir des opérations un peu moins élémentaires
pour faciliter le travail de l’informaticien et d’autre part à rester assez fidèle au code du processeur pour ne pas introduire des instructions dont le temps d’exécution serait important ou
non prédictible (très important notamment dans le cas de la programmation système). A ce
jeu, le langage C, créé aux débuts des années 1970, est très proche de la machine. Toutes les
instructions C sont ainsi élémentaires (exécution en un temps constant) et un programme C se
traduit en langage assembleur de façon courte et directe (ou langage machine).
Les fondations théoriques de l’algorithmique datent du milieu du XXème siècle, avec notamment les
travaux d’Alan Turing, célèbre mathématicien anglais, qui participa au décryptage des communications
allemandes pendant la seconde guerre mondiale.
Exemple : [Routage dans un réseau]
Comment acheminer des données d’une machine A à une machine B dans le monde ? Comme il y a des
milliards de machines (plus de 1,2 Milliards d’abonnés à Internet), on ne peut pas tester tous les réseaux
possibles. De même on ne peut pas précalculer tous les chemins possibles (2e19 routes différentes avec
IPv4).
Pour les mobiles, même problématique mais avec 2,5 Milliards d’appareils.
Exemple : [Feux d’intersection]
On cherche à trouver le meilleur paramétrage des feux à une intersection un peu complexe.
D
E
C
A
B
Trouver le nombre minimum de phases d’intersection est équivalent à maximiser les traversées simultanées.
Ce problème se traduit sous forme d’un graphe : sommet (flux de déplacement AB, AC, . . . ), arête (si
présente, indique que les flux ne sont pas compatibles)
Trouver les paquets simultanés revient à colorier le graphe avec un nombre minimum de couleur. On
sait que c’est en fait un problème difficile (NP-difficile).
1.2
Quelques problèmes algorithmiques classiques
– Le tri d’une collection d’éléments selon une relation d’ordre. Par exemple, on veut remettre dans
l’ordre croissant de leurs dates des transactions bancaires. Il existe des solutions algorithmiques
efficaces pour le tri. Il est ainsi possible de trier un milliard d’éléments en un temps raisonnable
(minutes à qqs heures).
– Rechercher les textes comportant un ensemble de mots-clés dans une base de données très importante de textes. Difficile si reconnaissance exact, efficace si reconnaissance approchée. Google
en est le produit phare : plus de mille milliards de pages référencées en juillet 2008, plusieurs
centaines de milliers de teraoctets.
3
– Le voyageur de commerce. Problème très difficile dans le cas général. Calculables si le nombre
de villes est inférieur à 30. Algorithmes très rapides si les positions des villes sont connues et que
les distances donnent le coût du billet d’avion.
– L’optimisation combinatoire. Trouver les meilleurs paramètres qui minimisent une fonction coût.
Efficace en pratique, difficile dans des cas tordus.
– Calculer l’enveloppe convexe d’un nuage de points
– Résoudre le Rubik’s cube. Théoriquement, toute configuration est à 20 mouvements au plus de la
solution (borne atteinte, prouvé depuis juillet 2010). En pratique, les meilleures algorithmes font
une cinquantaine de mouvements. Les humains resolvent plutôt en 80-100 mouvements. Tout
peut se modéliser sous forme de plus court chemin dans un graphe, mais malheureusement le
graphe ne tient dans la mémoire d’aucun ordinateur (4.3e19 sommets !).
– Proposer un emploi du temps qui satisfasse toutes les contraintes est aussi une tâche difficile.
Savoir même si c’est possible ou non est difficile.
– ...
1.3
Rappels d’algorithmique élémentaire
Tout ordinateur est utilisé pour faire des calculs. Les calculs effectués dépendent de données fournies
par l’utilisateur. Le résultat des calculs est aussi stocké par l’ordinateur sous une forme qui sera ensuite
lisible par l’utilisateur. Par essence, les données peuvent varier mais dans un domaine bien défini. On
parle de type de la donnée. Pour désigner la donnée, on lui donne un nom spécifique (le plus explicite
possible). La donnée est alors appelée une variable. De façon concrète, il s’agit d’une zone de la
mémoire qui est réservée exclusivement à la mémorisation de la donnée. Pour se référer à la valeur de
la donnée, on utilise simplement le nom de la variable en algorithmie. Sur un ordinateur, au niveau
électronique, c’est beaucoup plus compliqué : le processeur en fait interroge sa mémoire pour récupérer
la valeur de la variable (sous forme de bits).
On utilisera en algorithmique des types supposés prédéfinis (dans le langage de mise en œuvre) :
entier, réel, booléen, chaı̂ne de caractères, intervalle de valeurs.
On définira des variables avec :
Var : i : entier ;
x,y : réel
On supposera les types booléen (vrai,faux), entier, réel, caractères, chaı̂ne de caractères, prédéfinis.
On pourra définir de nouveaux en les groupant sous forme de tableaux, d’entités, ou en les pointant
à l’aide de pointeurs.
Quelques instructions et structures de contrôles classiques :
– L’affectation à une variable :
A ← <expression>
L’expression doit avoir une valeur du type de A
– Opérations d’entrées sorties :
– Saisie au clavier (lecture sur l’entrée standard du programme)
Lire(<liste d’identifiants de variables>)
– Affichage à l’écran (écriture sur la sortie standard du programme)
Ecrire(<liste d’expressions>)
– Le bloc d’instruction Début-Fin
Début
instruction 1 . . .
Fin
Permet de regrouper une séquence d’instructions comme si c’était une seule instruction. Notam4
ment utile avec les structures de contrôles conditionnelles ou répétitives.
– Structures conditionnelles
1. Si-Alors-Sinon
si <condition> alors <action-si> [sinon <action-sinon>]
Calcule la valeur de condition (expression booléenne). Si vraie, exécute <action-si>. Si
fausse, exécute <action-sinon>.
2. Selon-Que (cf. fiche)
– Structures répétitives
Elles permettent d’exécuter plusieurs fois une même séquence d’instructions. Elles se distinguent
par la manière dont la répétition est organisée.
1. La structure Tant-Que
tant que <condition> faire <action>
Tant que <condition> s’évalue à vrai, l’instruction <action> est exécutée. Pour éviter une
répétition infinie, il faut nécessairement que <action> contienne au moins une instruction
qui modifie une variable utilisée dans l’expression conditionnelle.
2. La structure Répéter-Jusqu’à
répéter <action> jusqu’à <condition>
Ressemble à tant que mais <action> est exécutée au moins une fois. De plus <condition>
est une condition de sortie et non une condition de répétition.
3. La structure Pour
pour <variable> de <v1> à <v2> [par pas de <p>] faire <action>
Cette structure modifie la valeur d’une variable <variable> en lui faisant prendre les valeurs
successives v1, v1+p, v1+2*p, . . . jusqu’à dépasser v2. A chaque fois, <action> est exécutée.
– expressions : une expression représente une séquence de calculs successifs constitués à partir
d’opérations arithmétiques et de fonctions (souvent mathématiques) dont le résultat est une
valeur d’un type donné.
Exemple : [Exemples d’expressions entières]
– (3 ∗ 7)/(2 + 2) s’évalue en 5 à l’exécution. (/ division entière)
– 2 ∗ i + 1 s’évalue en 7 à l’exécution si la variable i de type entier valait 3.
– a mod b s’évalue en 1 si a et b étaient des variables entières par exemple de valeurs respectives 7 et
2 (reste de la division euclidienne de a par b)
Exemple : [Exemples d’expressions réelles]
– 3.5 ∗ 2.0 + 1.4 s’évalue en 8.4 à l’exécution.
– x/100.0 s’évalue en 0.71 à l’exécution si la variable x de type entier valait 71.
– cos(0.0) + sin(0.0) s’évalue en 1.0 (de façon générale, vous pouvez utiliser toutes les fonctions
mathématiques standard : cos, sin, tan, acos, exp, log)
Exemple : [Exemples d’expressions booléennes]
– x > 0.0 ET x < 1.0 vaut vrai si la variable réelle x est compris entre 0 et 1.
– a = 0 OU a = b vaut vrai si la variable entière a vaut l’entier 0 ou l’entier qui est la valeur actuelle
de la variable entière b.
– a mod 2 = 0 vaut vrai si la variable entière a est paire.
1.4
Construction de types complexes
En langage algorithmique, nous disposons de tous les types simples usuels : booléen, caractère,
entier, réel. Néanmoins, on a souvent besoin de définir des variables dont le type est beaucoup plus
complexe, par exemple pour disposer de variables représentant des objets complexes (un point, un
vecteur, un nombre complexe, une personne avec nom, prénom, age et autres, un groupe d’étudiants,
etc). On dispose de deux moyens essentiels pour fabriquer des types plus complexes :
– les tableaux
– les entités (ou structure, enregistrement, record)
5
1.4.1
Le tableau
Le tableau est un agrégat d’éléments du même type. Ces éléments sont rangés consécutivement et
numérotés, en général de 0 à N-1, où N est le nombre d’éléments. Un tableau est de taille donnée et
n’est pas extensible. Le tableau offre peu d’opérations. Etant donné un numéro, il peut renvoyer la
valeur de l’élément du tableau qui a ce numéro ou il peut lui affecter une nouvelle valeur.
Type : TabEntier = Tableau[0..MAX-1] d’entier
Var : T : TabEntier ;
i : entier
début
/* Affecte les carrés des entiers */;
pour i de 0 à MAX-1 faire
T[i] ←i*i
fin
Les chaı̂nes de caractères forment des tableaux particuliers, où on range dans chaque case du
tableau un caractère (type ’Chaı̂ne de caractères’). Comme on va suivre la convention du C, la fin
d’une chaı̂ne de caractères est indiquée par le caractère ’\0’. On aura le droit d’affecter à l’initialisation
d’une variable de type ’Chaı̂ne de caractères’ n’importe quel texte entre guillemets.
Var : S : Chaı̂ne de caractères ←”Bonjour Toto !” ;
i : entier
début
i ←0 ;
tant que S[i]! =′ \0′ faire
i ←i + 1 ;
Affiche( ”La chaı̂ne ”, S, ” fait ”, i, ” caractères.” );
fin
1.4.2
Les entités
Les entités permettent de définir des types qui sont l’agrégat d’autres types. Chaque élément agrégé
s’appelle un champ et est caractérisé par un nom. On accède à un champ en utilisant la notation pointée
suivie d’un nom.
1.5
Actions, Fonctions
Une action permet de regrouper un ensemble d’instructions sous un certain nom, en identifiant
un sens global à ce groupe d’instructions. L’action pourra être paramétrée avec des données en
entrée, auxquelles on donnera un nom formel. Elle pourra parfois modifier ces paramètres (paramètres
d’entrée-sortie) et retourner des paramètres (paramètres de sortie). Une fonction est une action particulière qui ne prend que des paramètres d’entrée et qui retourne une valeur en sortie. A ce titre, une
fonction pourra être utilisée dans une expression (correctement typée bien sûr). Enfin, on distingue
une action principale (nommée ainsi ou main) qui est l’action appelée au lancement du programme.
On pourra aussi lui spécifier des paramètres d’entrée.
Il est important de comprendre qu’un appel d’Action est une instruction, alors qu’un appel
de Fonction est une expression dont le type est la valeur de retour de la Fonction appelée.
Quelques questions :
1. Parmi les lignes de l’Algorithme 3, quelles sont celles qui n’ont pas de sens ?
2. L’ordinateur exécutera-t-il plus rapidement Carre( log(4) / log(3) - exp(2) ) que ( log(4) / log(3)
- exp(2) )*( log(4) / log(3) - exp(2) ) ?
6
Type : Etudiant : Entité : nom : chaı̂ne de caractères ;
prénom : chaı̂ne de caractères ;
groupe : chaı̂ne de caractères ;
numéro : entier
Type : Complexe = Entité : re : réel ;
im : réel
Type : TabComplexe = Tableau[ 0..99 ] de Complexe
Var : john : Etudiant
Var : z1, z2 : Complexe
début
john.nom ←”Smith” ;
john.prénom ←”John” ;
john.groupe ←”A2” ;
john.numéro ←”I345629” ;
z1.re ←0,0 ;
z1.im ←1,0 ;
/* z1 vaut le nombre imaginaire pur “i”. */
fin
1.5.1
Paramètres des actions/fonctions
Quoiqu’on puisse définir des actions ou fonctions qui ne prennent pas de paramètres, cela a souvent
peu d’intérêt. En effet, dans une machine déterministe, cela indique un programme qui annone toujours
le même résultat.
On peut donc passer des valeurs à un sous-programme (autre nom pour action et fonction). On
procède de la façon suivante :
1. Il faut que la fonction ou l’action appelée indique qu’elle accepte des valeurs en entrée, ou
paramètres formels. Elle donnera donc des noms à ces valeurs, noms dont l’ordre est déterminé
à l’avance. On la spécifie donc sous la forme suivante (appelée prototype de la fonction) :
Fonction Ecart( E x : réel, E y : réel ) : réel
Ainsi, la fonction Ecart attend deux valeurs en entrée, la première est de type réel et cette valeur
sera appelée x dans le corps de la fonction Ecart, la seconde est aussi de type réel mais cette
valeur sera appelée y dans le corps de la fonction Ecart.
On note que la fonction Ecart retourne une valeur réelle dans tous les cas, cette valeur de retour
n’a pas besoin de porter de nom.
2. Les paramètres formels sont ensuite utilisés comme des variables normales au sein du sousprogramme. En fait, leur portée est limitée à ce sous-programme. Voilà comment pourrait être
écrit la fonction Ecart :
3. Une fois l’action/fonction déclarée (par son prototype), on peut l’appeler à partir d’une autre action/fonction du moment qu’on lui donne effectivement des valeurs pour chacun de ses paramètres.
On parle d’arguments d’appel ou de paramètres effectifs. On pourrait ainsi appeler Ecart sous la
forme :
Les deux paramètres effectifs sont ici la valeur de la variable a (saisie à la ligne 1 par l’utilisateur)
et la valeur de la constante 3,14159. A l’exécution de l’action LoinDePi, au moment de l’appel
de la fonction Ecart, les paramètres formels x et y sont respectivement affectés de la valeur de
a et la valeur 3,14159. A la fin de l’exécution de Ecart, la valeur retournée est replacée dans
l’expression de la ligne 2, et donc affectée à la variable b.
Quelques questions :
1. Le programme LoinDePi donne-t-il la même valeur si on modifie la ligne 2 ainsi :
b ←Ecart( 3,14159, a ) ;
7
Action Echange( ES i : entier, ES j : entier) ;
Var : t : entier
début
t←i;
i←j;
j←t;
fin
Action Main ;
Var : a,b : entier
début
lire(a,b) ;
si a < b alors
Echange( a, b ) ;
affiche( ”La plus grande valeur est ”, a ) ; affiche( ”La plus petite valeur est ”, b ) ; ;
fin
Algorithme 1 – Action très classique qui échange la valeur de deux variables entières. Le
programme principal montre un exemple d’utilisation.
Fonction Carre( E x : réel ) : réel ;
début
Retourner x*x ;
fin
Algorithme 2 – Fonction calculant le carré du réel x passé en entrée.
1.5.2
Catégories de paramètres
Les paramètres formels sont des variables du type spécifié. On distingue de plus trois catégories de
paramètres formels :
– les paramètres d’entrée (notés E ). Ces paramètres récupèrent la valeur de l’argument d’appel. Même s’ils sont modifiés dans le sous-programme, le paramètre effectif correspondant reste
inchangé.
– les paramètres de sortie (notés S ). Ces paramètres ne sont pas initialisés à l’entrée du sousprogramme. En revanche, le paramètre effectif correspondant prend sa valeur à la sortie du
sous-programme. Un paramètre en sortie correspond obligatoirement à un paramètre effectif qui
est une variable.
– les paramètres d’entrée/sortie (notés ES ). Ils ont les propriétés combinées des deux précédents.
Algorithme 3 : Paramètres d’actions et de fonctions.
Var : i,j : entier ;
y : reel;
début
Lire(i,j);
y ←1.4 ;
1
Affiche(Carre(y));
2
Affiche(Carre(3.4));
3
Echange(i,12);
4
Echange(j,i);
fin
8
Fonction Ecart( E x : réel, E y : réel ) : réel début
Si x ≤ y Alors
Retourner y − x
Retourner x − y
fin
1
2
3
Action LoinDePi ;
Var : a, b : réel
début
Lire( a ) ;
b ←Ecart( a, 3,14159 ) ;
Ecrire( b ) ;
fin
1.5.3
Récursivité
On appelle graphe d’appel des fonctions, le graphe orienté dont les sommets sont les fonctions et
dont chaque arc de A vers B indique que la fonction A appelle la fonction B dans son corps. Une
fonction A (ou informellement un programme) est dite récursive ssi le sous-graphe issu de A contient
un cycle. On parle souvent de récursivité simple lorsque le cycle est de longueur 1 (i.e. la fonction
s’appelle elle-même).
La récursivité permet souvent d’écrire de façon simple des algorithmes qui aurait une écriture plus
complexe sans récursivité. En effet, elle permet d’exprimer très simplement une résolution du même
problème mais sur une partie réduite des données. Notons néanmoins que toute fonction récursive
peut s’écrire sous une forme non récursive en utilisant une structure de données annexe de type Pile.
Exemples :
1. La recherche dichotomique s’écrit simplement sous forme récursive. Son écriture itérative ne
nécessite même pas de pile.
2. Le problème dit des tours de Hanoı̈ se résoud simplement avec un programme récursif. Si on veut
afficher les déplacements de la tour i vers la tour j, (avec trois tours), on écrirait l’Algorithme 4.
3. Le problème dit du sac-à-dos ou de l’appoint a lui aussi une écriture récursive simple. La récursivité
permet ici d’explorer très simplement les possibilités.
Algorithme 4 : Tours de Hanoı̈, version récursive. On l’appelle ainsi Hanoi(n, 1, 3, 2) pour dire
qu’on veut déplacer n palets de la tour 1 vers la tour 3 en utilisant une 3ème tour intermédiaire,
la 2.
Action Hanoi(E n : entier, E i,j,k : entier);
/* i,j,k est un triplet choisi parmi toutes les permutations de 1,2,3. */ ;
début
Si n ≥ 1 Alors
Hanoi(n − 1, i, k, j );
Ecrire( ”Déplacer de ”, i, ” vers ”, j );
Hanoi(n − 1, k, j, i );
fin
Quelques questions :
1. Soit l’Algorithme 5. Tracer son graphe d’appel. Qu’affiche ce programme si l’utilisateur saisit le
nombre 3, s’il saisit le nombre 4.
2. De façon générale combien de lignes vont s’afficher en fonction du nombre saisi ? Autrement dit,
combien de déplacements d’un palet d’une tour à une autre sont nécessaires pour achever le
déplacement légal de tous les palets d’une tour à l’autre ?
9
Algorithme 5 : Test du programme de la tour de Hanoı̈.
Action TestHanoi ;
Var : m : entier
début
Lire(m) ;
Si m ≥ 1 Alors
Hanoi(m, 1, 3, 2);
fin
1.6
Types abstraits de données
Le mot type abstrait de données (TAD) date un peu mais désigne de façon générale des types
pouvant désigner des collections d’éléments, ces éléments ayant parfois des relations particulières très
souvent rencontrées lors de la résolution de problèmes complexes. Pour chacun des types, on spécifie
un ensemble de fonctions et d’actions qui les manipulent effectivement et on ne se préoccupe plus
de la façon particulière dont on les programme. D’où l’adjectif abstrait dans un TAD. On pourrait
maintenant arguer qu’avec la programmation orientée objet, toute classe est un TAD, mais ce débat
n’est pas l’objectif de ce cours.
On distingue en général quatre types principaux de TAD :
séquentiels Les éléments sont rangés en séquence dans le TAD et ont en général un suivant et un
précédent. La liste est l’exemple typique.
arborescents Les éléments sont structurés de manière hiérarchique, avec un parent et éventuellement
des fils. C’est la notion d’arbre enraciné (avec un élément distingué comme racine).
relationnels Les éléments ont des liens avec potentiellement tous les autres éléments. Les graphes et
les bases de données en sont les principaux représentants.
tables Les éléments sont accessibles directement étant donné un numéro ou clé qui les désigne de
façon unique. Un tableau est un exemple où les éléments sont numérotés consécutivement. Les
tableaux associatifs offrent quant à eux la possibilité d’utiliser des clés quelconques.
Pour chaque TAD, il existe souvent de multiples façons de le mettre en œuvre effectivement. En
général, ces mises en œuvre ne sont pas équivalentes, et sont plus ou moins performantes sur certaines
opérations. C’est pourquoi il faut bien les connaı̂tre pour savoir les utiliser à bon escient.
La suite du cours se propose d’examiner les différents TAD, leurs principales utilisations, et leurs
différentes mises en œuvre. Pour comparer les qualités respectives de ces structures, on introduira
aussi la notion de complexité (en temps et en mémoire), qui fournira un cadre (assez) objectif de
comparaison.
2
Quelques algorithmes sur les tableaux
Il s’agit ici de présenter quelques algorithmes élémentaires sur des collections d’éléments. La collection sera ici modélisée par un simple tableau dont on connait le nombre d’éléments pertinents.
2.1
Algos de base : parcours, min, max, argmin, argmax, recherche
NB : A faire en exercice. Pour la recherche, parler de la dichotomie.
La fonction suivante retourne l’indice de l’élément du tableau T de valeur minimale parmi tous les
éléments entre les indices i et j inclus.
On peut immédiatement déduire la valeur minimale : T [ ArgMin( T , i, j ) ].
10
/* ArgMin ou argument minimum : indice de l’élément dont la valeur est plus petite que les
valeurs de tous les autres. */
Fonction ArgMin(E T : TabEntier, E i, j : entier) : entier ;
Var : k,m : entier ;
début
m ←i ;
Pour k de i + 1 à j Faire
si T [k] < T [m] alors m ←k ;
Retourner m;
fin
Le principe de la recherche dichotomique est de couper en deux l’intervalle de recherche d’un
élément à chaque itération, de façon à réduire progressivement l’espace de recherche. Pour l’utiliser,
il est nécessaire que le tableau d’entrée soit trié. Nous donnons ci-dessous une version récursive.
Algorithme 6 : Algorithme de recherche d’un élément par dichotomie. Pour rechercher la valeur
w dans un tableau U : TabEntier, on l’appelerait ainsi : RechercheDico(U,0,MAX,w).
Fonction RechercheDico(E T : TabEntier, E i, j, v : entier) : entier ;
Données : T est supposé trié dans l’ordre croissant. ;
i indique l’indice de la première case à regarder. ;
j indique l’indice après celui de la dernière case. ;
v est la valeur recherchée.
Résultat : l’indice d’une case de valeur m, -1 si il n’y en a pas.
Var : m : entier ;
r : entier ;
début
r ← −1 ;
Si i < j Alors
début
m ← (i + j)div2 ;
si T [m] < v alors
r ← RechercheDico(T, m + 1, j, v);
sinon si T [m] > v alors
r ← RechercheDico(T, i, m, v);
sinon r ← m ;
fin
Retourner r;
fin
Exercice : Dichotomie non récursive Comment transformer l’Algorithme 6 sous une forme non-récursive ?
2.2
n-ième plus grand d’une collection ? Vers le tri
Le minimum et le maximum d’un ensemble d’éléments se calculent aisément, par un simple parcours
du tableau. Si le tableau est trié, ils se calculent directement.
Dans un ensemble, on cherche souvent à savoir quelle est la médiane de l’ensemble, c’est-à-dire un
élément tel que pas plus de la moitié des éléments lui sont strictement inférieurs et pas plus de la
moitié des éléments lui sont strictement supérieurs. Si jamais les éléments étaient triés, on est sûr que
⌋ satisfait ce critère.
l’élément en position ⌊ MAX−1
2
Plus généralement, on peut chercher à savoir quel est l’élément en n-ème position dans l’ensemble.
Ce problème est appelé problème de la sélection. On voit une solutions pour calculer le 2ème plus
11
petit élément : en mémorisant les deux plus petits. Plus généralement, en gardant les n-plus petits,
on peut répondre à ce problème, mais c’est une solution coûteuse en temps.
On entrevoit aussi une solution qui résoud toutes les instances du problème. Il suffit de trouver
un moyen de trier tous les éléments, et ensuite la sélection est triviale. On verra plus tard qu’il est
possible de ne pas trier tous les éléments pour répondre à une requête de sélection.
2.3
Quelques tris internes
Les tris internes sont les tris qui travaillent directement sur les tableaux d’éléments à trier. Tous les
éléments doivent pouvoir être accessibles directement via un indice dans le tableau. Les tris externes
peuvent travailler sur des fichiers où les éléments sont lus séquentiellement.
Le tri à bulle (bubble sort est le plus intuitif sans doute. Il se base sur des séries de permutations
de cases contiguës, l’idée étant de monter les éléments les plus petits, comme une bulle chercherait à
atteindre la surface.
Action TriBulle(ES T : TabEntier);
Var : i,j : entier ;
début
Pour i de 0 à M AX − 2 par pas de 1 Faire
Pour j de M AX − 2 à i par pas de −1 Faire
si T [j] > T [j + 1] alors Echange(T [j], T [j + 1]);
fin
Le tri par insertion part du principe qu’une partie du tableau est déjà triée. Le nouvel élément est
alors inséré à la bonne place, en décalant d’autres éléments si nécessaire.
Action TriInsertion(ES T : TabEntier);
Var : i,j : entier ;
début
Pour i de 1 à M AX − 1 par pas de 1 Faire
/* les éléments de 0 à i-1 sont triés. */;
j ←i − 1 ;
Tant Que j >= 0 et T [j] < T [j + 1] Faire
début
Echange(T [j], T [j + 1]);
j ←j − 1;
fin
fin
Le tri par sélection calcule en séquence les minimums. Il commence par extraire le minimum de
tous les éléments, le met en première case, puis calcule le minimum des éléments restants, le met en
deuxième case, etc.
Les algorithmes précédents sont assez comparables du point de vue temps de calcul. Quoiqu’ils
n’aient pas exactement les mêmes comportements, leurs temps de calcul sont de l’ordre du carré de
la taille des données. Cela se voit assez facilement pour les pires cas. Les preuves des cas moyens (au
sens toute permutation initiale des éléments est équiprobable) sont plus délicates.
On peut montrer à l’aide d’arbres de décisions qu’on ne peut pas construire un algorithme de
tri basé sur la comparaison qui ferait moins de Ω(N log N ) comparaisons dans le pire cas, N étant
le nombre d’éléments à trier. On prouve le même résultat en moyenne. On a donc une marge de
manœuvre pour développer des algorithmes de tri meilleurs.
12
Fonction ArgMin(E T : TabEntier, E i, j : entier) : entier ;
Var : k,m : entier ;
début
m ←i ;
Pour k de i + 1 à j Faire
si T [k] < T [m] alors m ←k ;
Retourner m;
fin
Action TriSelection(ES T : TabEntier);
Var : i,j : entier ;
début
Pour i de 0 à M AX − 2 par pas de 1 Faire
début
/* les éléments de 0 à i-1 sont triés. */;
j ←ArgMin(T , i, M AX − 1) ;
Echange( T [j], T [i] ) ;
fin
fin
Un meilleur tri en moyenne que les précédents est le shellsort (ou tri
√ coquille, Algorithme 7), une
sorte de variante du tri à bulle qui a une complexité de l’ordre de N N . Son principe est de trier
n/2 paires d’éléments (les T [i] et T [i + N/2]) dans une première passe, puis de trier n/4 quadruplets
d’éléments (les T [i], T [i + N/4], T [i + 2N/4], T [i + 3N/4]) dans une deuxième passe, puis n/8 octuplets
d’éléments, etc.
Algorithme 7 : Tri coquille ou shellsort. Le plus rapide connu avant quicksort
Action TriCoquille(ES T : TabEntier, N : entier);
Var : i,j, incr : entier ;
début
incr ←N div 2 ;
Tant Que incr > 0 Faire
Pour i de incr + 1 à N Faire
j ←i − incr;
// Tri par insertion;
Tant Que j > 0 Faire
Si T [j] > T [j + incr] Alors
Echange(T [j], T [j + incr]);
j ←j − incr;
sinon j ←0
fin
incr ←incr div 2;
Il existe de meilleurs algorithmes. Le plus connu est du à Hoare (1962), et s’appelle tri rapide ou
quicksort. Même s’il peut être aussi lent que les autres dans le pire cas, son comportement moyen est
prouvé optimal (N log N ). De plus, en pratique, c’est vraiment le plus rapide.
Le principe du quicksort est de découper (rapidement) le tableau en deux parties, une partie où
tous les éléments sont inférieurs à une valeur donnée (le pivot) et l’autre partie où tous les éléments
sont supérieurs ou égaux au pivot. Ensuite on appelle récursivement quicksort indépendamment sur
chaque partie. Il est facile de voir qu’un tel processus garantit que le tableau résultant est trié, du
moment que les parties diminuent en taille.
Cela se fait en définissant trois fonctions : la fonction TrouvePivot (Algorithme 8) qui recherche
un pivot valide, la fonction Partition (Algorithme 9) qui découpe le tableau, et l’action Quicksort
13
(Algorithme 10) qui appelle les deux autres et s’appelle récursivement.
Algorithme 8 : Fonction TrouvePivot.
Fonction TrouvePivot(E T : TabEntier, i, j : entier) : entier ;
/* Retourne l’indice d’un pivot si les cases T[i],...,T[j] contiennent au moins deux valeurs
distinctes, sinon retourne -1.*/
Var : k : entier ;
début
k ←i + 1 ;
Tant Que k <= j Faire
si T [k] > T [i] alors Retourner k ;
si T [k] < T [i] alors Retourner i;
k ←k + 1 ;
Retourner -1 ;
fin
Algorithme 9 : Fonction Partition.
Fonction Partition(ES T : TabEntier, i, j : entier, p : entier) : entier ;
/* Découpe T en deux parties : de i à k-1 les éléments sont inférieurs à p, de k à j les éléments
sont supérieurs ou égaux à p. Enfin, retourne cet indice k qui sépare ces deux parties. */
Var : k,l : entier ;
Début
k ←i ;
l ←j ;
Répéter
Echange( T[ k ], T[ l ] );
tant que T [k] < p faire k ←k + 1;
tant que T [l] ≥ p faire l ←l − 1;
Jusqu’à k > l ;
Retourner k ;
Fin
Nous verrons d’autres algorithmes de tri basés sur les arbres plus loin (dans la section sur les
structures arborescentes). Ils auront la propriété intéressante d’être de complexité N log N en moyenne
et en pire cas.
Enfin, nous verrons une autre forme de tri, pas seulement basée sur un opérateur de comparaison,
qui peut trier en temps de l’ordre de N . Ces tris, appelés bin-sort ou radix-sort seront vus comme
applications des listes.
2.4
tris spécifiques : bin-sort
(ultérieurement)
2.5
Bornes inférieures d’un tri
(ultérieurement)
3
Structures séquentielles
Une structure séquentielle matérialise les collections d’éléments avec la propriété que tout élément
a un successeur (ou est le dernier de la collection). Eventuellement, la notion de prédécesseur existe
14
Algorithme 10 : Algorithme Quicksort.
Action Quicksort(ES T : TabEntier, i, j : entier);
/* Trie le tableau T entre les indices i et j. */ Var : idxp, k : entier ;
Début
idxp ←TrouvePivot( T, i, j );
Si idxp 6= -1 Alors
k ←Partition( T, i, j, T[ idxp ] );
Quicksort( T, i, k-1 );
Quicksort( T, k, j );
Fin
et est simplement définie comme le successeur d’un prédécesseur est l’identité.
Pour simplifier légèrement les fonctions de manipulations des listes, nous nous intéressons plus
spécifiquement aux listes parcourables dans les deux sens, appelées listes doublement chaı̂nées. Elles
permettent notamment l’insertion avant l’élément pointé et la suppression de l’élément pointé.
3.1
Listes
Une liste est une séquence de zéro ou plus d’éléments d’un type donné (TElem ici), souvent
représentée sous le forme :
a1 , a2 , . . . , an
où n ≥ 0 et chaque ai est de type TElem.
Une définition récursive de liste est : T est une liste ssi T est l’ensemble vide ou T est un couple
(E, T’) avec E un élément et T’ une liste. Cette définition (plus correcte d’un point de vue théorie des
types) définit la liste précédente comme
(a1 , (a2 , (. . . , (an , ∅) · · · )
ce qui est équivalent à la notation précédente.
Chaque ai , 1 ≤ i < n, précède l’élément ai+1 dans la liste, tandis que chaque ai+1 succède à ai . En
comptant le nombre de prédécesseurs d’un élément ai , on obtient la position de cet élément (ici i car
on les a numéroté convenablement).
Pour manipuler les listes indifféremment de leur codage sous un langage de programmation, on se
donne des primitives (i.e. des fonctions) naturelles pour les créer, modifier, parcourier.
On voit que la notion de position est rendue abstraite par l’utilisation d’une adresse. Pour le
moment, rien ne dit ce qu’est une adresse Adr, mais l’intuition de l’adresse physique mémoire est
bonne, même si ce n’est pas sa seule implémentation possible.
On utilise cette astuce car la position (en tant que numéro) d’un élément n’est pas caractéristique
de l’élément et peut varier si des éléments sont insérés ou supprimés à des positions inférieures.
Les listes sont omniprésentes en algorithmie. Elles peuvent servir à stocker des collections de taille
variable, à trier les éléments, à parcourir des graphes, à modéliser des files d’attente, etc.
15
Type : Liste = Liste séquentielle de Elem;
Type : Adr = Adresse physique d’un élément Elem d’une liste;
Action Initialise(S L : Liste ) ;
/* L est une liste vide. */ ;
Action Termine(ES L : Liste ) ;
/* L n’est plus une liste valide. */ ;
Fonction Debut(E L : Liste ) : Adr ;
/* Retourne l’adresse du premier élément de L ou Fin(L) si L est vide. */ ;
Fonction Fin(E L : Liste ) : Adr ;
/* Retourne l’adresse après le dernier élément de L (i.e. ce n’est pas un élément dont la
valeur est valide). */ ;
Fonction Suivant(E L : Liste, E A : Adr ) : Adr ;
/* Retourne l’adresse du successeur de l’élément d’adresse A ou Fin( L ) si c’était le dernier.
*/ ;
Fonction Précédent(E L : Liste, E A : Adr ) : Adr ;
/* Retourne l’adresse du prédécesseur de l’élément d’adresse A ou Fin( L ) si c’était le
premier. */ ;
Fonction Valeur(E L : Liste, E A : Adr ) : Elem ;
/* Retourne l’élément à l’adresse A. */ ;
Action Modifie(E L : Liste, E A : Adr, E v : TElem ) ;
/* v devient la valeur de l’élément à l’adresse A. */ ;
Fonction Insére(ES L : Liste, E A : Adr, E v : TElem ) : Adr;
/* L’élément v devient un nouvel élément de la liste L, placé avant celui d’adresse A. Son
adresse dans la liste est retournée. */ ;
Action Supprime(ES L : Liste, E A : Adr ) ;
/* Supprime l’élément d’adresse A de la liste L. Son prédécesseur a maintenant le successeur
de A comme successeur. L’adresse A n’est plus valide. */ ;
16
La liste 12, 99, 37
Ajout d’un élément à une liste
Figure 1 – Liste chaı̂née (Wikipédia). Principe de mise en œuvre et exemple d’ajout d’élément.
Quelques questions :
1. Ecrire un algorithme pour afficher les éléments d’une liste.
2. Ecrire un algorithme qui retourne vrai si une liste est triée par ordre croissant (pour une fonction
Inf sur TElem)
3. Ecrire la fonction Localise( E L : Liste, E p : entier ) qui retourne l’adresse de l’élément à la
position p.
4. Ecrire l’action Supprime( E L : Liste, E p : entier ) qui supprime le p-ème élément de la liste L.
5. Ecrire l’action Purge( ES L : Liste) qui élimine les doublons d’une liste. La liste est quelconque.
6. Ecrire la fonction Fusion( E L1, L2 : Liste ) : Liste qui fusionne deux listes L1 et L2 triée en une
liste L3 triée.
7. En déduire l’algorithme de tri fusion.
3.2
Mise en œuvre des listes (contiguë ou non)
Il y a plusieurs façons de mettre en œuvre les listes. Une première façon, dite à cellules contiguës,
est de placer les éléments dans un tableau. La notion de successeur se confond avec la notion d’indice
supérieur dans le tableau. Les défauts sont la taille fixe du tableau (sauf réallocation) et le coût des
insertions/suppressions.
La deuxième façon, dite à curseur, est de placer les éléments dans un tableau mais d’avoir un autre
tableau qui donne l’indice de l’élément suivant. On a aussi l’indice du premier élément, comme l’indice
de la première case vide.
La troisième façon, dite chaı̂née, est d’utiliser les pointeurs. Chaque élément sera placé dans une
cellule, qui est un couple (TElem, pointeur vers suivant). On utilisera alors l’allocation dynamique
pour créer de nouvelles cellules. On veillera à bien libérer la mémoire lors de la destruction de la liste.
Quelques questions :
1. Ecrire les implémentations des primitives précédentes pour chaque variante de liste. On supposera
que l’on a une fonction Allouer( E T : TQcq ) : Pointeur de TQcq, qui alloue une variable de type
TQcq en mémoire sur le tas. Symétriquement Désallouer( E T : Pointeur de TQcq ) désalloue la
mémoire.
On donne ci-dessous une implémentation possible en C des listes doublement chaı̂nées. On note
que la liste est une cellule particulière, qui désigne en fait la fin de la liste (cellule invalide).
17
Liste.h
#ifndef _LISTE_H_
#define _LISTE_H_
Liste.c
#include <stdlib.h>
#include "Liste.h"
typedef double Elem;
struct SCellule {
Elem val;
struct SCellule* pred;
struct SCellule* succ;
};
typedef struct SCellule Cellule;
typedef Cellule* Adr;
typedef Cellule Liste;
Liste* Liste_creer()
{
Liste* L = (Liste*) malloc( sizeof( Liste ) );
Liste_init( L );
return L;
}
void Liste_init( Liste* L )
{
L->succ = L;
L->pred = L;
extern Liste* Liste_creer();
}
extern void Liste_init( Liste* L );
void Liste_termine( Liste* L )
extern void Liste_termine( Liste* L );
{
extern void Liste_detruire( Liste* L );
while ( Liste_debut( L ) != Liste_fin( L ) )
extern Adr Liste_debut( Liste* L );
Liste_supprime( L, Liste_debut( L ) );
extern Adr Liste_fin( Liste* L );
}
extern Adr Liste_suivant( Liste* L, Adr A );
void Liste_detruire( Liste* L )
extern Adr Liste_precedent( Liste* L, Adr A );
{
extern Adr Liste_insere( Liste* L, Adr A, Elem v );
Liste_termine( L );
extern void Liste_supprime( Liste* L, Adr A );
free( L );
extern Elem Liste_valeur( Liste* L, Adr A );
}
extern void Liste_modifie( Liste* L, Adr A, Elem v );
Adr Liste_debut( Liste* L )
{
#endif
return L->succ;
}
test-Liste.c
Adr Liste_fin( Liste* L )
#include <stdio.h>
{
#include "Liste.h"
return L;
}
void affiche( Liste* L )
Adr Liste_suivant( Liste* L, Adr A )
{
{
Adr A;
return A->succ;
for ( A = Liste_debut( L ); A != Liste_fin( L );
}
A = Liste_suivant( L, A ) )
Adr Liste_precedent( Liste* L, Adr A )
printf( "%f ", Liste_valeur( L, A ) );
{
printf( "\n" );
return A->pred;
}
}
Adr Liste_insere( Liste* L, Adr A, Elem v )
int main( void )
{
{
Adr ncell = (Adr) malloc( sizeof( Cellule ) );
Liste* L = Liste_creer( );
ncell->val = v;
Adr A = Liste_debut( L );
ncell->succ = A;
double x = 1.0;
ncell->pred = A->pred;
while ( x < 100000.0 )
A->pred = ncell;
{
ncell->pred->succ = ncell;
A = Liste_insere( L, A, x );
return ncell;
A = Liste_suivant( L, A );
}
x = 1.5*x;
void Liste_supprime( Liste* L, Adr A )
}
{
affiche( L );
A->pred->succ = A->succ;
A->succ->pred = A->pred;
Liste_detruire( L );
free( A );
return 0;
}
}
Elem Liste_valeur( Liste* L, Adr A )
{
Execution
return A->val;
1.000000 1.500000 2.250000 3.375000 5.062500 7.593750
}
11.390625 17.085938 25.628906 38.443359 57.665039 86.497559void Liste_modifie( Liste* L, Adr A, Elem v )
129.746338 194.619507 291.929260 437.893890 656.840836
{
985.261253 1477.891880 2216.837820 3325.256730 4987.885095
A->val = v;
7481.827643 11222.741464 16834.112196 25251.168294
}
37876.752441 56815.128662 85222.692992
3.3
3.3.1
Piles et Files
Définitions
Parfois, on n’a pas besoin de toutes les primitives des listes, alors même que la structure de données
manipulées est bien une liste avec une notion de successeur et de prédécesseur.
C’est le cas par exemple lorsqu’on insére, supprime, ou accéde à, des éléments d’un seul côté de la
liste. On parle alors de pile ou de structure LIFO (Last In, First Out). Le côté concerné s’appelle le
sommet de la pile. C’est aussi le cas lorsqu’on insère des éléments d’un seul côté et que l’on supprime
des éléments seulement de l’autre côté. On parle alors de file ou de structure FIFO (First In, First
18
Out). Le côté on l’on insère s’appelle la queue, l’autre la tête. Enfin, lorsque l’on insère ou supprime
des deux côtés, sans jamais faire des éditions ou des parcours en milieu de liste, on parle de file à
double entrée ou deque en anglais. On garde les noms précédents pour cette file aussi.
Les primitives se simplifient alors en :
Pile InitPile, TerminePile, EstVide ?, Empile, Dépile, ValeurSommet.
File InitFile, TermineFile, EstVide ?, Enfile, Défile, ValeurTête.
Deque InitDeque, TermineDeque, EstVide ?, InsereEnTete, InsereEnQueue, SupprimeEnTete, SupprimeEnQueue, ValeurTete, ValeurQueue.
On note que ces primitives n’ont jamais besoin d’un indicateur de position de type Adr.
3.3.2
Les piles
Les piles ont un fonctionnement similaire aux piles d’assiette, où seule l’assiette supérieure est
accessible à un moment donné. Les piles sont utilisées dans énormément d’algorithmes. L’exemple le
plus connu est la pile d’exécution du processus/fil d’exécution. En effet, un appel à une fonction/action
entraı̂ne la sauvegarde du contexte courant, des arguments et de l’adresse de retour, dans une pile
associée au processus. La fin d’une action/fonction provoque le dépilement de la pile pour restaurer
le contexte d’appel.
Une pile peut donc être utilisée pour transformer un programme récursif en un programme itératif.
La pile joue alors un rôle similaire à la pile d’exécution, en mémorisant en fait les arguments d’appels
intermédiaires. L’Algorithme 11 illustre l’utilisation d’une pile en lieu et place de la récursion. Ce
procédé s’appelle la dérécursification. Dans les langages impératifs, certains sous-programmes récursifs
sont dérécursifiés systématiquement pour gagner du temps ou de la place. Dans les langages dits
fonctionnels, le compilateur gère lui-même la récursion au mieux.
On utilise aussi beaucoup les piles pour évaluer les expressions arithmétiques. En effet, on verra plus
tard qu’une expression se modélise sous forme d’un arbre, et qu’un parcours de cet arbre correspond
à stocker les fils dans une pile.
Quelques questions :
1. Peut-on mettre au point une primitive ValeurDeuxième pour une pile, sans rien savoir de sa
représentation interne ? Peut-on faire la même chose pour une file ?
2. Utilisez une pile pour calculer la factorielle sans récursivité. Pourquoi n’est-ce pas nécessaire dans
ce cas d’utiliser un TAD annexe ?
3. Calculer la suite de Fibonacci sans récursivité.
4. Estimez si certains calculs sont redondants dans le triangle de Pascal ou Fibonacci.
5. Pensez-vous qu’un programme non récursif soit plus lisible que son homologue récursif ?
6. Comment parcourir une liste de la fin vers le début en utilisant une Pile ? Et sans Pile ?
3.3.3
Mise en œuvre des Piles
Les piles sont souvent mises en œuvre à l’aide d’un tableau, doublé de l’indice du sommet actuel de
la pile. Pour autoriser une pile à dépasser une taille initiale, on peut utiliser l’allocation dynamique.
Lorsque la pile est pleine, on alloue un tableau de taille double où on copie tous les éléments. L’ancien
tableau est ensuite désalloué.
Quelques questions : On peut aussi implémenter une Pile telle que lorsqu’elle est pleine, il n’est pas
nécessaire de désallouer l’ancien tableau. Toutes les primitives restent ainsi en temps constant. Comment
faire ? Indice : mélanger liste et pile.
3.3.4
Un exemple d’utilisation de pile : le calcul de l’enveloppe convexe
Nous proposons ici l’algorithme dit “Graham-scan” pour calculer l’enveloppe convexe (Algorithme 12).
Le principe est de trouver un point initial P, appelé pivot, qui est sur l’enveloppe convexe. On prend
19
Algorithme 11 : Calcul du coefficient binomial sans récursivité, en utilisant le triangle de Pascal
et une pile.
Fonction Binomial( E n,p : entier ) : entier;
Type : PaireNP = Entité : début
n : entier ;
p : entier ;
fin
Var : v : PaireNp ;
P : Pile de PaireNP ;
b : entier ;
début
b ←0 ;
CréerPile( P );
v.n ←n;
v.p ←p;
Empiler( P, v );
Tant Que non EstVide( P ) Faire
v ←ValeurSommet( P );
Dépiler( P );
si v.p = 0 ou v.p = v.n alors b ←b + 1;
Sinon
v.n ←v.n - 1;
Empiler( P, v );
v.p ←v.p - 1;
Empiler( P, v );
Retourner b;
fin
en général le point de coordonnées minimales dans l’ordre lexicographique. Ensuite on trie tous les
autres points Pi dans l’ordre croissant des angles ∠(0x, P~Pi ). Puis les deux éléments en sommet de pile
représente un vecteur auquel on va comparé chaque nouveau point. On décidera alors si un nouveau
point est à gauche ou à droite de ce vecteur. Si il est à gauche, son produit extérieur est positif, sinon
négatif. Un point à gauche, peut faire partie de l’enveloppe convexe. Si il est à droite, le sommet de
pile ne peut faire partie de l’enveloppe convexe. On l’enlève donc de la pile. On procède ainsi jusqu’à
épuisement des points. Le résultat de cet algorithme est illustré sur la Figure 2.
Quelques questions : Quelle est la complexité de l’algorithme précédent ?
3.3.5
Les files
La file a un fonctionnement similaire aux files d’attente, d’où leur appellation. Elles sont utilisées
dans beaucoup de processus système où il y a des accès concurrents à une ou plusieurs ressources,
lorsque le principe du premier arrivé, premier servi peut s’appliquer. Par exemple, on parle de file
d’impression (ou print spool) pour l’accès aux imprimantes, de file de courrier (ou mail spool) pour
l’envoi de courrier.
3.3.6
Mise en œuvre des files
La mise en œuvre classique des files est un tableau circulaire, avec deux indices : l’un qui pointe
vers la tête, l’autre vers la queue. On peut agrandir le tableau si nécessaire en le réallouant. Attention,
pour distinguer si une file est vide ou complètement pleine, deux solutions :
– soit on ne met jamais plus de N-1 éléments dans le tableau de taille N,
– soit on rajoute un champ dans la structure pour compter le nombre d’éléments.
20
Algorithme 12 : Enveloppe convexe par Parcours de Graham.
Action ConvexHull(E T : Tableau[1..MAX] de Point, S P : Pile de Point);
début
i ←TrouverPointMin( T );
Echange( T[1], T[i] ) /* T[1] est le pivot */ ;
TrierSelonT1( T ) /* On trie les points 2 à N suivant l’angle T[1]T[i]. */ ;
CréerPile( P );
Empiler( P, T[ 1 ] ) ;
Empiler( P, T[ 2 ] ) ;
pour i de 3 à MAX faire
Tant Que non EstAGauche( ValeurDeuxième( P ), ValeurSommet( P ), T[ i ] ) Faire
Dépiler( P );
Empiler( P, T[ i ] ) ;
/* P contient la liste des sommets de l’enveloppe convexe. */
fin
Fonction EstAGauche(E p1, p2, p3 : Point ) : booléen ;
/* Retourne vrai si p3 est à gauche (strictement) de la demi-droite [p1,p2). */ ;
Retourner (p2.x - p1.x)*(p3.y - p1.y) - (p3.x - p1.x)*(p2.y - p1.y) ¿ 0.0 ;
Figure 2 – Calcul de l’enveloppe convexe par l’algorithme de Graham. A gauche, résultat du tri des
points suivant l’angle. A droite, enveloppe convexe induite.
21
3.3.7
Un exemple d’utilisation des files à double entrée : le calcul de l’enveloppe convexe
d’un polygone
Il existe un algorithme très connu pour calculer l’enveloppe convexe d’un polygone P, qui ne se
base que sur l’utilisation d’une file à double entrée. C’est l’algorithme de Melkman. L’algorithme est
incrémental et la file représente l’état actuel de l’enveloppe convexe et sa tête désigne le point de
l’enveloppe convexe le plus proche du dernier point du polygone rajouté.
3.4
Autres structures séquentielles
Il existe d’autres types de listes. Les listes doublement chaı̂nées offre une fonction Précédent et
stocke en fait un pointeur vers l’élément précédent. Elles permettent le parcours de la liste dans les
deux sens.
Une autre variante est la liste circulaire, où le dernier élément pointe vers le premier. On y ajoute
en général une primitive Dernier() pour savoir quand on boucle.
Il existe enfin des variantes des listes pour représenter des ensembles. La skip-liste en est un exemple
intéressant (cf. examen INFO511 2007-2008).
4
Structures arborescentes
Il est souvent fréquent de devoir hiérarchiser les données. Par exemple, un livre est découpé en
chapitres. Chaque chapitre est découpé en sections. Chaque section est découpé en sous-sections, etc.
On constate que ces données ne sont pas ordonnées linéairement (successeur/prédécesseur), mais que
ce qui les caractérise est une relation de parenté. Chaque donnée X a un parent. Ce parent peut avoir
plusieurs enfants dont X. On se donne en général un point d’entrée dans ces données, que l’on appelle
la racine, et qui est la seule donnée qui n’a pas de parents. Pour ces structures de données, l’analogie
avec les ramifications d’un arbre est immédiate, et on parle de structures arborescentes ou d’arbre. Un
arbre se compose de nœuds. Les nœuds sans enfants ou terminaux sont aussi appelés des feuilles. Les
nœuds non terminaux sont dits internes. A chaque nœud est associé une étiquette, qui est finalement
sa valeur. En gros le nœud désigne la position logique de la donnée et son étiquette est la valeur de la
donnée.
Quelques utilisations courantes des arbres :
– Une utilisation très commune des arbres est de modéliser des ensembles, avec des requêtes efficaces
pour rechercher un élément.
– Dans les applications, les composants d’une interface graphique forment un arbre.
– Les systèmes de fichiers sont arborescents (dossiers sont des noeuds internes, les fichiers sont des
feuilles).
– Les scènes graphiques 3D sont des arbres, afin d’avoir des représentations du grossier aux détails
et d’accélérer les affichages.
– Les arbres servent aussi de structures intermédiaires à beaucoup d’algorithmes, par exemple le
tri ou les parcours en largeur sur les graphes.
– L’ensemble des fonctions appelées au cours d’une exécution forme un arbre (l’arbre d’exécution).
Un moment donné dans une exécution est une branche de cet arbre. Le parcours de cet arbre est
en un sens infixe dans l’exécution.
– Du coup, beaucoup d’algorithmes récursifs s’apparentent à des explorations d’arbre de possibilités.
4.1
Définition
On dit que T est un arbre ssi
– soit T est vide,
22
Figure 3 – Exemple d’arbre (source Wikipedia).
– soit T est un couple (v, T1 , . . . , Tn ) où v est un élément et (Ti ) est une liste éventuellement vide
d’arbres (qualifié de sous-arbres de T ).
Par exemple, l’arbre de la Figure 3 s’écrit sous la forme :
(A, (B, (D, ()), (E, ())), (C, (F, ()), (G, ())))
, voire même (en éliminant les listes vides) :
(A, (B(D), (E)), (C, (F ), (G)))
Exemple : On note que la représentation précédente est presque l’écriture parenthésée de ces données
en langage LISP. Seules les virgules sont enlevées car elles sont redondantes avec les espaces.
(A(B(D())(E()))(C(F ())(G())))
On note que les enfants de chaque nœud sont ordonnés (normalement, sur un dessin, ils se lisent
de gauche à droite).
On appelle hauteur d’un arbre ou d’un sous-arbre la longueur du plus long chemin qui part de la
racine pour toucher une feuille. Un arbre réduit à un élément a une hauteur de 1.
4.2
Construction
Pour construire un arbre à partir de cases ne contenant que des informations, on peut procéder de
l’une des trois façons suivantes :
1. Créer une structure de données composée de :
(a) l’étiquette (la valeur contenue dans le nœud),
(b) un lien vers chaque nœud fils,
(c) un arbre particulier, l’arbre vide, qui permettra de caractériser les feuilles. Une feuille a
pour fils des arbres vides uniquement.
2. Créer une structure de données composée de :
(a) l’étiquette (la valeur contenue dans le nœud),
(b) un lien vers le ≪ premier ≫ nœud fils (nœud fils gauche le cas échéant),
(c) un autre lien vers le nœud frère (le ≪ premier ≫ nœud frère sur la droite le cas échéant).
C’est la structure la plus adéquate lorsque le nombre de fils d’un noeud n’est pas connu ou borné.
3. Créer une structure de données composée de :
(a) l’étiquette (la valeur contenue dans le nœud),
(b) un lien vers le nœud père.
On note qu’il existe d’autres types de représentation propres à des cas particuliers d’arbres. Par
exemple, le tas (vu après) est représenté par un tableau d’étiquettes.
23
4.3
Parcours
Au contraire des structures séquentielles, il existe plusieurs moyens pour définir un ordre total sur
les éléments d’un arbre, et donc plusieurs moyens pour parcourir tous les éléments d’un arbre.
Le parcours en profondeur est un parcours récursif sur un arbre. Il existe trois ordres pour cette
méthode de parcours.
Parcours en profondeur préfixé. Dans ce mode de parcours, le nœud courant est traité avant le
traitement des nœuds gauches et droits. Ainsi, si l’arbre précédent est utilisé, le parcours sera
A, B, D, E, C, F puis G.
Parcours en profondeur infixé. Dans ce mode de parcours, le nœud courant est traité entre le
traitement des nœuds gauches et droits. Ainsi, si l’arbre précédent est utilisé, le parcours sera
D, B, E, A, F, C puis G.
Parcours en profondeur suffixé. Dans ce mode de parcours, le nœud courant est traité après le
traitement des nœuds gauches et droits. Ainsi, si l’arbre précédent est utilisé, le parcours sera
D, E, B, F, G, C puis A. Ce mode de parcours correspond à une notation polonaise inversée.
Le parcours en largeur correspond à un parcours par niveau de nœuds de l’arbre. Un niveau est un
ensemble de nœuds internes ou de feuilles situés à la même ≪ distance ≫ du nœud racine — on parle
aussi de nœud ou de feuille de même hauteur dans l’arbre considéré. L’ordre de parcours d’un niveau
donné est habituellement conféré, de manière récursive, par l’ordre de parcours des nœuds parents —
nœuds du niveau immédiatement supérieur.
Ainsi, si l’arbre précédent est utilisé, le parcours sera A, B, C, D, E, F puis G.
4.4
Primitives
Dans la suite, on utilisera les primitives données dans l’Algorithme 13 pour créer et/ou parcourir
un arbre.
Algorithme 13 : Primitives pour manipuler des arbres (ordonnés).
Type : TElem = type de l’étiquette;
Type : TArbre = arbre de TElem;
Type : Noeud = Adresse ou position d’un noeud dans un arbre;
Action Créer(S A : TArbre ) ;
/* A est une arbre vide. */ ;
Fonction Racine(E A : TArbre ) : Noeud;
/* Retourne NULL si A est l’arbre vide, sinon retourne le noeud racine de A. */ ;
Action Détruire(ES A : TArbre ) ;
/* A n’est plus un arbre valide. */ ;
Action Créeri( S A : TArbre, E e : TElem, E T1 : TArbre, . . . , E Ti : TArbre ) ;
/* Pour tout i ≥ 0 ; Créer un nouvel arbre A dont la racine est d’étiquette e et de premières
branches T1, . . . , Ti. Construit donc un arbre des feuilles vers la racine par fusion de
sous-arbres. */ ;
Fonction PremierFils(E A : TArbre, E n : Noeud ) : Noeud ;
/* Si n est un noeud valide, retourne le noeud de son premier fils (si il existe) et NULL
sinon. */ ;
Fonction Frere(E A : TArbre, E n : Noeud ) : Noeud ;
/* Si n est un noeud valide, retourne le noeud de son premier frère à sa droite (si il existe) et
NULL sinon. */ ;
Fonction Valeur(E L : TArbre, E n : Noeud ) : TElem ;
/* Retourne l’étiquette associée au noeud n. */ ;
Fonction Pere(E A : TArbre, E n : Noeud ) : Noeud ;
/* Si n est un noeud valide, retourne le noeud de son père (si il existe) et NULL sinon. */ ;
24
4.5
Ecriture des parcours
L’Algorithme 14 donne une méthode pour parcourir un arbre en profondeur, en utilisant la récursivité.
Algorithme 14 : Parcours en profondeur de l’arbre A à partir du noeud n. On note que POSTFIXE/INFIXE/PREFIXE correspond à un choix de l’utilisateur pour l’ordre des éléments en
profondeur.
Action ParcoursProf( E A : TArbre, E N : Noeud ) ;
/* Parcours le sous-arbre de A enraciné en N en profondeur. */ ;
Var : e : TElem
Var : M : Noeud
début
Si N != NULL Alors
e ←Valeur( A, N ) );
si PREFIXE alors Ecrire( e );
M ←PremierFils( A, N );
ParcoursProf( A, M ) ;
si INFIXE alors Ecrire( e );
M ←Frere( A, M ) ;
Tant Que M != NULL Faire
ParcoursProf( A, M ) ;
M ←Frere( A, M ) ;
si POSTFIXE alors Ecrire( e );
fin
Le parcours en largeur requiert en revanche une structure de données supplémentaire, une file. Il
est décrit sur l’Algorithme 15.
Algorithme 15 : Parcours en largeur d’un arbre A à partir d’un de ses noeuds n.
Action ParcoursLargeur( E A : TArbre, E n : Noeud ) ;
/* Parcours le sous-arbre enraciné en n en largeur, n est un noeud valide. */ ;
Var : F : File de Noeud;
début
CreerFile( F ) ;
Enfiler( F, n );
Tant Que non FileVide ?( F ) Faire
/* Récupère et affiche l’élément courant. */ ;
n ←ValeurTete( F );
Defiler( F ) ;
Ecrire( Valeur( A, n ) ) );
/* Enfile les fils directs dans la file. */;
n ←PremierFils( A, n );
Tant Que n != NULL Faire
Enfiler( F, n ) ;
n ←Frere( A, n ) ;
fin
25
Quelques questions :
1. Ecrire un algorithme pour calculer le nombre de descendants d’un noeud n donné. En déduire un
algorithme pour calculer le nombre d’éléments stockés dans un arbre.
2. Ecrire la fonction EstDescendant ?( n, m ) qui retourne vrai si le noeud n est un descendant du
noeud m.
3. Calculer la hauteur du sous-arbre de A enraciné en le noeud n.
4. Ecrire une fonction PositionPostfixe qui calcule la position d’un noeud n dans le parcours postfixé.
En déduire de la fonction qui calcule le nombre de descendant et de cette fonction PositionPostfixe
un autre moyen pour déterminer si un noeud n est un descendant du noeud m.
NB : On note que postfixe(m)-desc(m) ≤ postfixe(n) < postfixe(m).
5. Ecrire une fonction qui calcule la profondeur d’un noeud donné dans l’arbre (la profondeur de la
racine est 1, celle de ses fils est 2, etc).
6. Ecrire une fonction pour trouver le premier ancêtre commun à deux noeuds n et m. On pourra
utiliser la fonction précédente pour le faire efficacement.
4.6
Arbres binaires et autres arbres
On appelle arbre binaire un arbre soit vide, soit un arbre où chaque élément a aucun fils, un fils
gauche, un fils droit, ou un fils gauche et un fils droit. Ces arbres sont donc légèrement différents des
arbres précédents, puisqu’un nœud qui a un seul fils, l’a soit à gauche soit à droite. On note qu’on
distingue alors ces deux arbres.
On appelle arbre équilibré un arbre dont les feuilles sont toutes à la même profondeur. Un arbre
binaire aussi équilibré que possible est donc un arbre binaire dont les feuilles ont une profondeur m
ou m + 1, où m est l’entier qui précède le logarithme de base 2 du nombre d’éléments dans l’arbre.
En vrac, quelques autres définitions :
– Arbre partiellement ordonné : arbre binaire aussi équilibré que possible, tel que la valeur d’un
nœud est inférieure aux valeurs de ses fils. Ces arbres sont souvent représentés à l’aide d’un tas,
qui code un arbre binaire dans un tableau.
– Arbre binaire de recherche : arbre binaire tel que la valeur du nœud est supérieure à celles de
son sous-arbre gauche et inférieure à celles de son sous-arbre droit.
– Arbre préfixe (ou Trie) : structure de données arborescente pour représenter un grand ensemble
de mots. C’est un arbre dont chaque chemin de la racine a une feuille représente un mot, chaque
noeud représente une lettre.
4.7
Mise en œuvre des arbres
Nous en détaillons deux, l’une assez générale pour représenter des arbres quelconques, l’autre appelé tas, réservée à des arbres binaires sur lesquelles on fait peu d’opérations (insertion, minimum,
suppressionMinimum).
4.7.1
Arbres par fils gauche et frère droit
Une représentation efficace des arbres ordonnés dont le nombre de fils par noeud n’est pas borné
est celle-ci :
Tout noeud N a un lien vers son fils le plus à gauche ainsi qu’un lien vers son premier frère à sa
droite (i.e. un noeud de même père). La Figure 4 illustre cette structure de données sur l’arbre de
l’exemple précédent.
Cette représentation correspond à une multiliste, c’est-à-dire des ensembles qui mélangent plusieurs
relations d’ordre partiel.
26
A
B
D
C
E
F
G
Figure 4 – Représentation d’un arbre par fils gauche et frère droit.
4.7.2
Représentation par tas
Un tas est une représentation à l’aide d’un tableau d’un arbre partiellement ordonné. Cette structure
n’est donc pas aussi générale que la structure précédente, mais elle est plus efficace pour ces arbres-ci.
L’idée est d’utiliser les n premières positions d’un tableau A pour représenter n noeuds. Ainsi la case
A[1] représente la racine, et le fils gauche du noeud A[i] est la case A[2 ∗ i] et son fils droit est la case
A[2 ∗ i + 1].
On observe que pour les arbres partiellement ordonnés, les n premières cases du tableau (comptées
à partir de l’indice 1) sont bien occupées et représentent tous les noeuds de l’arbre. En général, une
priorité p est associée à chaque élément, ce qui induit l’ordre sur l’arbre. On a la propriété pour tout
noeud que sa priorité est inférieure ou égale à celle de ses fils.
On note que cette structure est très utilisée pour représenter les files à priorité, pour représenter
les zones mémoires disponibles, ou pour faire un tri appelé tri par tas ou heapsort.
Un tas présente donc les primitives données dans l’Algorithme 16.
On note que le tri par tas s’écrit de manière extrêmement simple, sous la forme de l’Algorithme 17.
4.8
Arbre binaire de recherche
On rappelle qu’un arbre binaire de recherche (ABR) est un arbre binaire tel que la valeur du nœud
est supérieure strictement à celles de son sous-arbre gauche et inférieure à celles de son sous-arbre
droit.
Ces arbres sont très souvent utilisés (cf. TP) et permettent par exemple l’écriture d’algorithmes de
tri efficace et de complexité en pire cas bornée.
L’insertion dans un tel arbre est très simple. On part de la racine, et on descend à gauche ou à
droite selon que l’élément inséré est inférieur à l’élément courant ou supérieur ou égal à ce même
élément. On s’arrête dès que l’on arrive à une place vide où mettre l’élément.
La recherche est elle-aussi très simple. Il est clair que la complexité de l’insertion comme de la
recherche est donnée par la longueur du parcours dans l’arbre, qui est bornée par la profondeur de
l’arbre. La fonction de suppression est un peu plus délicate, mais se fait aussi en temps proportionnel
à cette profondeur.
Il est facile de voir que tel quel il existe des séquences d’éléments dont l’insertion provoque un ABR
en forme de peigne déséquilibré (par exemple une suite croissante). La profondeur dans le pire cas est
donc de n et la complexité dans le pire cas de l’insertion, recherche, et suppression est O(n).
Néanmoins, dans le cas général, la profondeur moyenne est beaucoup plus petite, ce qui induit
de bien meilleure performances (cf. sous-section suivante). On note aussi qu’il existe des variantes
d’arbres de recherche qui ont réellement une profondeur dans le pire cas bornée par un O(log n). On
peut citer :
– Arbres binaires de recherche équilibrés (arbres AVL pour Adelson-Velsky et Landis, 1962) : la
hauteur des feuilles est de h ou h − 1.
– Arbres rouge et noir : la hauteur des feuilles est entre l et 2l (NB : c’est le choix de la Standard
27
Algorithme 16 : Primitives de manipulation des tas.
Type : TAS = Entité : elems : Tableau [1..MAX] de Element;
dernier : entier;
/* Crée un tas vide */;
Action Creer( S T : Tas );
début
T.dernier ←0 ;
fin
/* Retourne vrai si tas vide */;
Fonction EstVide ?( E T : Tas ) :booléen;
début
Retourner T.dernier = 0 ;
fin
/* Insère un élément dans une position valide. */;
Action Inserer( ES T : Tas, E e : Element);
Var : i : entier;
début
si T.dernier >= MAX alors Erreur(”Tas plein.”);
T.dernier ←T.dernier+1;
T.elems[ T.dernier ] ←e;
i ←T.dernier /* i est la position courante de e */ ;
Tant Que (i > 1) and p(T.elems[i]) < p(T.elems[i div 2]) Faire
/* On le remonte dans le tas en l’échangeant avec son père.*/ Echange( T.elems[i],
T.elems[ i div 2 ] );
i ←i div 2;
fin
/* Supprime et retourne l’élément de priorité minimale. */;
/* Enlève donc la racine et laisse le tas en position valide.*/;
Fonction SupprimerMin( ES T : Tas ) : Element;
Var : i, j : entier;
min : Element;
début
si T.dernier = 0 alors Erreur(”Tas vide.”);
min ←T[ 1 ];
T[ 1 ] ←T[ T.dernier ];
T.dernier ←T.dernier−1;
i ←1;
Tant Que i <= T.dernier div 2 Faire
si ( p( T.elems[ 2*i ] ) < p( T.elems[ 2*i+1 ] ) )
or ( 2*i = T.dernier ) alors j ←2*i;
sinon j ←2*i+1;
Si p( T.elems[ i ] ) > p( T.elems[ j ] ) Alors
Echange( T.elems[ i ], T.elems[ j ] ) ;
i ←j;
sinon Retourner min;
Retourner min;
fin
28
Algorithme 17 : Algorithme de tri par tas. La fonction de priorité p est ici simplement la
fonction identité.
Action TriParTas( ES A : TabEntier );
Var : T : Tas d’entiers;
début
Creer( T );
pour i de 0 à MAX-1 faire Inserer( T, A[ i ] );
pour i de 0 à MAX-1 faire A[ i ] ←SupprimerMin( T );
fin
Template Library C++ pour les représentations d’ensemble et de tableaux associatifs).
– Arbres B : arbres équilibrés où chaque noeud a entre L fils et U fils, L ≤ U . Souvent, 2 ∗ L = U
ou 2 ∗ L − 1 = U .
4.8.1
Profondeur moyenne d’un ABR
Nous montrons ici que la profondeur moyenne d’un ABR, dans le cas où les données sont insérées
de façon aléatoire uniforme, est inférieure à 2 log n.
Nous allons calculer la profondeur moyenne d’un ABR, avec l’hypothèse que les arbres ont été créé
par seulement des insertions, et que tous les ordres des n éléments insérés ont même probabilité.
Soit P (n) la longueur moyenne d’un chemin de la racine à un noeud quelconque (pas nécessairement
une feuille). Cela correspond à la notion intuitive de profondeur moyenne. L’arbre a été créé par
l’insertion de n éléments dans un ABR initialement vide. Il est évident que P (0) = 0 et P (1) = 1.
Qu’en est-il de P (n), n ≥ 2 ?
Le premier élément inséré, disons a, a autant de chance d’être le premier, deuxième, . . . , ou n-ième
élément lorsqu’on les ordonne. Si i sont inférieurs à a, n − 1 − i son supérieurs à a. Dans l’ABR (de
racine a), le sous-arbre gauche a donc i éléments et le sous-arbre droit n − 1 − i. Sur chaque sous
arbre, comme tous les ordres ont la même chance d’apparaı̂tre, la profondeur moyenne suit aussi P ,
i.e. P (i) à gauche et P (n − 1 − i) à droite. La profondeur moyenne P (n) est obtenue en moyennant
pour tout i les profondeurs de tous les noeuds de chaque côté, ce qui donne pour un arbre :
(n − 1 − i)
1
i
(P (i) + 1) +
(P (n − 1 − i) + 1) +
n
n
n
En moyennant sur tout i, cela donne
∀n ≥ 2, P (n) = 1 +
n−1
1 X
(iP (i) + (n − 1 − i)P (n − 1 − i))
n2 i=0
(1)
(2)
On s’aperçoit que le deuxième terme est égal au premier, ce qui donne la relation de récurrence suivante
n−1
2 X
∀n ≥ 2, P (n) = 1 + 2
iP (i)
n i=0
(3)
En faisant la différence P (n) − P (n − 1), on arrive à faire réapparaı̂tre P (n − 1), ce qui donne
P (n) − P (n − 1) =
⇔ P (n) =
2n − 1
1
P (n − 1) +
n2
n2
2
n −1
2n − 1
P (n − 1) +
n2
n2
−
(4)
(5)
On pourrait faire une analyse fine. Nous nous contentons ici de remarquer que comme les P (k)
sont positifs, la suite P (n) est bornée par la suite Q(n) définie par Q(n) = Q(n − 1) + 2n−1
n2 , Q(0) = 0,
Q(1) = 1.
Pn
Pn
On obtient Q(n) = i=1 n2 − i=1 n12 ≤ 1 + 2 log n.
29
4.9
Structure et algorithme Union-Find
Étant donné un ensemble d’éléments, il est souvent utile de le partitionner en un certain nombre de
classes disjointes. Une structure de données pour le problème des classes disjointes est une structure
de données qui maintient une telle partition. Un algorithme union-find est un algorithme qui fournit
deux opérations essentielles sur une telle structure :
– Find : détermine la classe d’un élément. Notamment utile pour déterminer si deux éléments
appartiennent à la même classe.
– Union : réunit deux classes en une seule.
Parce qu’elle fournit ces deux opérations, une structure de données pour le problème des classes
disjointes est souvent appelée structure union-find. L’autre opération importante, MakeSet, construit
une classe contenant un unique élément (un singleton). À l’aide de ses trois opérations, beaucoup de
problèmes de partitionnement peuvent être résolus (voir la section Applications).
Afin de définir ces opérations plus précisément, il faut choisir un moyen de représenter les classes.
L’approche classique consiste à sélectionner un élément particulier de chaque classe, appelé le représentant,
pour représenter la classe toute entière. Dès lors, Find(x) renvoie le représentant de la classe de x.
Il est assez naturel d’utiliser un arbre pour représenter l’appartenance d’un élément à une classe
donnée. L’élément choisi comme représentant n’a pas de père, tandis que les autres éléments de la
même classe ont un parent dans cette classe. Deux éléments sont donc dans la même classe s’ils ont le
même ancêtre commun. Dès lors qu’on fusionne deux classes il suffit que le représentant d’une classe
devienne le père de l’autre représentant.
Pour optimiser au mieux la mise en œuvre de cette structure, il faut limiter au maximum la
profondeur de l’arbre. Deux moyens sont employés :
– lorsqu’on fusionne deux classes, chaque classe a un représentant qui est la racine d’un arbre d’une
certaine profondeur. C’est le représentant dont l’arbre est le plus profond qui devient le père de
l’autre.
– à chaque fois qu’on fait une requête pour savoir qu’elle est le représentant d’une classe, on
modifie les relations de parenté des éléments parcourus pour qu’ils pointent directement sur le
représentant.
Mis ensembles, ces deux optimisations rendent les structures union-find extrêmement efficaces, avec
des requêtes en temps amorti quasi-constant (inverse de la fonction de Ackerman).
5
Complexité des algorithmes
Il y a souvent deux buts contradictoires lorsque l’on cherche à mettre au point un algorithme pour
résoudre un problème donné :
1. L’algorithme doit être facile à comprendre, coder, maintenir, mais aussi facile à vérifier.
2. L’algorithme doit utiliser efficacement les ressources de l’ordinateur, c’est-à-dire s’exécuter rapidement mais aussi prendre une place raisonnable en mémoire.
Si un algorithme doit être utilisé très souvent, il est alors intéressant de mettre en œuvre une solution
complexe mais efficace en temps et/ou en espace mémoire. Il est alors utile de pouvoir comparer
objectivement les complexités relatives.
5.1
Mesure du temps d’exécution d’un programme
Le temps d’exécution d’un programme dépend :
1. des données en entrée,
2. de la qualité du code généré par le compilateur,
3. de la nature et de la rapidité des instructions de la machine d’exécution du programme,
4. de l’algorithme utilisé pour résoudre le problème.
30
Algorithme 18 : Primitives pour les structures Union-Find.
Type : Element : Entité : valeur : comme vous voulez ;
pere : Pointeur de Element ;
rang : entier
Type : PElement : Pointeur de Element
Action MakeSet( ES e : PElement ) ;
début
e.pere ←null ;
e.rang ←0 ;
fin
Action Union( ES x, y : PElement ) ;
Var : rx, ry : PElement
début
rx ←Find( x ) ;
ry ←Find( y ) ;
si rx.rang > ry.rang alors
ry.parent ←rx ;
sinon
si rx.rang < ry.rang alors
rx.parent ←ry ;
sinon
yr.pere ←rx ;
rx.rang ←rx.rang + 1 ;
fin
Fonction Find( ES x : PElement ) : PElement ;
début
si x.pere = null alors
Retourner x ;
x.pere ←Find( x.pere ) ;
return x.pere ;
fin
31
D’après le premier point, il est clair que le temps d’exécution n’est pas juste une valeur, mais une
fonction des données. Très souvent, la valeur des données n’est pas significative, mais seul compte le
nombre de données, mettons n. Le temps d’exécution d’un programme sera donc une fonction T (n),
qui est le temps d’exécution de ce programme pour n données en entrée. Par exemple, il est clair qu’un
programme de tri sera de plus en plus lent si on augmente le nombre de données à trier.
Maintenant l’unité de temps de T (n) ne peut être précisée du fait des points (2) et (3). L’unité ne
sera donc que relative. Un même programme P aura peut-être un temps d’exécution T1 (n) = c1 n2
sur une machine M 1 et temps d’exécution T2 (n) = c2 n2 sur une machine M 2. Si les constantes c1
et c2 peuvent être distinctes (et très variables), il est en revanche peu probable que la partie n2 du
temps d’exécution se transforme d’une machine à une autre. En effet, un processeur peut être cadencé
plus rapidement qu’un autre, mais globalement, s’il doit faire K opérations, cela lui prendra un temps
proportionnel à K.
On dira donc souvent que le programme P s’exécute en un temps proportionnel à n2 , et non
s’exécute en c1 n2 sur la machine M 1, car cela ne présente pas toujours un intérêt majeur.
Parfois, le temps d’exécution d’un programme peut être rapide sur n données mais lent sur n autres
données. Un exemple typique est le tri insertion avec des données déjà triées, qui est rapide, mais qui
est lent sur la plupart des autres données. Dans ces cas-là, T (n) désignera le temps d’exécution dans
le pire cas, car c’est celui qui est problématique.
Une autre façon est de définir le temps d’exécution moyen T̂ (n), qui est la moyenne des temps
d’exécution de toutes les données de taille n. Si cette mesure peut paraı̂tre plus utile ou objective,
il faut néanmoins garder à l’esprit que les ensemble de n données sont rarement équiprobables dans
les applications réelles. Dans le cas du tri, on a souvent des données quasi-triées en entrée, du fait
des processus de saisie ou d’acquisition. Néanmoins, on montrera dans certains cas comment calculer
T̂ (n), et sous quelles hypothèses ce temps est valide.
Exemples :
1. L’algorithme de calcul du plus grand élément d’un tableau à n éléments nécessite de regarder
toutes les cases du tableau une fois exactement. Le temps d’exécution dans le pire cas est donc
proportionnel à n. Comme dans le meilleur cas il est aussi proportionnel à n, il est clair que le
temps d’exécution moyen est proportionnel à n lui-aussi.
2. Un algorithme de recherche dichotomique dans un tableau trié est beaucoup plus rapide. On
montre que son temps d’exécution est proportionnel à log2 n, dans le pire cas et dans le cas
moyen aussi.
3. L’algorithme de tri insertion a un temps d’exécution dans le pire cas proportionnel à n2 , mais son
temps d’exécution moyen est moins clair. Si on suppose que tous les ordres sont équiprobables, on
peut montrer que le temps d’exécution moyen est aussi proportionnel à n2 (avec une constante
inférieure).
5.2
Notations O, Θ, Ω
Lorsque l’on veut comparer les vitesses d’accroissement de fonction sans se préoccuper des constantes mises en jeu, il est pratique d’utiliser une notation concise pour exprimer la notion de proportionnalité, où le fait qu’une fonction grandit plus vite ou moins vite qu’une autre “à l’infini”. On
dispose pour cela de trois notations classiques : O = “grand O”, Θ = “Téta”, Ω = “grand Oméga”.
Dans la suite T et f sont deux fonctions de n.
– T (n) = O(f (n)) ssi il existe deux constantes c et n0 telles que ∀n ≥ n0 , T (n) ≤ cf (n). Cette
notation indique que T croı̂t moins vite que f .
– T (n) = Ω(f (n)) ssi il existe deux constantes c et n0 telles que ∀n ≥ n0 , T (n) ≥ cf (n). 1 Cette
notation indique que T croı̂t plus vite que f .
– T (n) = Θ(f (n)) ssi il existe trois constantes c1 , c2 et n0 telles que ∀n ≥ n0 , c1 f (n) ≤ T (n) ≤
c2 f (n). Cette notation indique que T et f croissent aussi vite.
1. Une définition non symétrique parfois utilisée est de dire qu’il existe une infinité de n ≥ n0 pour lesquels T (n) ≥
cf (n), mais pas forcément tous.
32
Une notation importante est O(1) qui exprime la croissance de toute fonction constante. Ainsi, on
dira qu’un ensemble d’instructions dont le temps d’exécution ne dépend pas de la taille des données
en entrée et est borné par une constante a une complexité O(1).
Exemples :
– Il est clair que n = O(n), n = Ω(n), et n = Θ(n).
– Plus généralement, f (n) = O(αf (n)), f (n) = Ω(αf (n)) et f (n) = Θ(αf (n)).
– On a aussi que n = O(n2 ), n2 = O(n3 ) et plus généralement na = O(nb ) ssi 0 ≤ a ≤ b.
– On a bien sûr n = O(n log n) et n log n = O(n2 )
Exercice : (Notations O, Θ, Ω)
1. Montrez que T (n) = Θ(f (n)) ssi T (n) = O(f (n)) et T (n) = Ω(f (n)).
2. Montrez que T (n) = O(f (n)) ssi f (n) = Ω(T (n)).
3. Montrez que si T (n) = O(f (n)) et f (n) = O(g(n)) alors T (n) = O(g(n)).
4. Montrez que si T (n) = Θ(f (n)) et f (n) = Θ(g(n)) alors T (n) = Θ(g(n)).
On dispose de règles d’addition et de multiplication de ces notations, notamment :
Addition de O. Si T1 (n) = O(f (n)) et T2 (n) = O(g(n)), alors T1 (n) + T2 (n) = O(max(f (n), g(n))).
C’est notamment utile lorsque vous avez mesuré la complexité de deux parties successives de
votre programme et que vous cherchez à déterminer la complexité du programme tout entier. Il
s’agit bien de l’addition de deux temps.
Produit de O. Si T1 (n) = O(f (n)) et T2 (n) = O(g(n)), alors T1 (n)T2 (n) = O(f (n)g(n)).
Cela montre par exemple que O(n2 /2) = O(n2 ). La règle des produits est utilisée pour mesurer
le temps d’exécution de programme contenant des boucles ou des appels répétitifs à un même
sous-programme de complexité connue.
Quelques questions :
1. Comment montrer que log n = O(n) ?
2. Montrez que si f (n) = O(g(n)) alors h(n)f (n) = O(h(n)g(n)).
3. En déduire que n log n = O(n2 ).
5.3
Complexité et temps d’exécution asymptotique
Il n’est donc pas facile de comparer les efficacités respectives d’algorithmes, sachant que leur vitesse
d’exécution dépend de beaucoup de paramètres, dont la machine. On va voir néanmoins que l’on
dispose d’un moyen pour le faire qui est assez objectif.
Supposons par exemple que l’on dispose de quatre programmes (Pi )i=1..4 qui résolvent le même
problème. Chaque programme Pi s’exécute sur une machine Mi . On note Ti (n) leurs temps d’exécution
respectifs, que l’on peut observer sur la Figure 5.
Lequel est le meilleur ? Cela dépend de la taille des données à traiter et du temps que l’on peut y
consacrer. Si on suppose que l’on ne dispose que de 103 secondes, ces quatre programmes/machines
sont quasiment aussi efficaces les uns que les autres. Si maintenant on dispose de 104 secondes, on
s’aperçoit que c’est le programme/machine avec le taux d’accroissement le plus faible qui devient vite
le plus efficace. Ainsi, pour un algorithme en O(n), le gain réalisé est identique au temps rajouté, ce
qui n’est pas le cas pour les autres.
T (n)
100n
5n2
n3 /2
2n
Taille max pour 103 s
10
14
12
10
Taille max pour 104 s
100
45
27
13
Gain
10
3,2
2,3
1,3
Un façon complètement symétrique de voir les choses est de supposer que l’on garde les mêmes
programmes compilés de la même façon, mais qu’on puisse cadencer les processeurs dix fois plus vite.
Le gain observé pour le même temps sera alors complètement similaire au fait de se donner dix fois
plus de temps.
33
3000
2^n
n^3/2
5n^2
100n
2500
2000
1500
1000
500
0
0
5
10
15
20
25
Figure 5 – Temps d’exécution de quatre programmes différents, de temps d’exécution respectifs 2n ,
n3 /2, 5n2 , 100n. L’unité de temps est sans importance, mettons des secondes.
On en conclut que lorsqu’on veut traiter des données de plus en plus grandes, il est intéressant
de comparer les temps d’exécution en terme d’accroissement O, c’est-à-dire de manière asymptotique,
en négligeant les constantes qui ne sont pertinentes que pour des petites données. La complexité en
temps d’un programme est donc son temps d’exécution mesuré en terme d’accroissement de la taille
des données en entrée.
Exemples :
1. Sur l’exemple précédent, la meilleure complexité est celle du programme de temps 100n, même
si ce n’est pas le programme le plus efficace pour de petites valeurs de n.
2. Dans certains cas, la constante est importante. Il existe un problème d’optimisation classique
(programmation linéaire) dont l’algorithme classique dit du simplexe est efficace en pratique, mais
peut avoir une complexité exponentielle dans certains cas. Il existe un algorithme de complexité
polynomiale qui résoud le même problème, mais la constante est très importante et sur toutes les
données que l’on peut traiter le rend inutilisable.
5.4
Calcul de la complexité d’un algorithme
On peut maintenant déterminer (à des constantes près) la complexité d’un algorithme donné.
Attention, on est souvent obligé de donner une complexité dans le pire cas, notamment lorsque le
programme a des morceaux d’instructions qui sont conditionnés.
Les règles sont les suivantes :
– Le temps d’exécution de chaque affectation, lecture, écriture en mémoire est supposé être constant
ou en O(1).
– De même, on suppose souvent (mais pas toujours) que le temps d’exécution de l’addition, soustraction, multiplication, division est constant. Cela est faux en général, mais assez vrai lorsqu’on
limite la taille des données à des valeurs codées sur moins de 32 ou 64 bits.
– Si T1 (n) et T2 (n) sont les temps d’exécution de deux fragments de programme, le temps d’exécution
de leur succession est T1 (n) + T2 (n). Si T1 (n) = O(f (n)) et T2 (n) = O(g(n)) alors la règle des
sommes donne T (n) = O(max(f (n), g(n))).
En particulier, une succession d’instructions élémentaires prend O(max(1, 1, . . . , 1)), soit O(1).
– Le temps d’exécution d’un “Si” est le temps d’exécution de la condition (souvent O(1)) plus le
temps d’exécution le plus large entre la partie “alors” et la partie “sinon”. On note que le temps
d’exécution devient un temps dans le pire cas.
On peut utiliser la notation Ω pour le meilleur cas.
– Le temps d’exécution d’une boucle est la somme de tous les temps d’exécution du bloc interne
plus les temps d’exécution de la condition de terminaison. Si le nombre d’itération maximal
O(f (n)) est connu et que le temps d’exécution du bloc interne est borné par O(g(n)), alors le
34
temps d’exécution de la boucle est O(f (n)g(n)) (règle des produits).
– Pour les appels de fonction/procédure, il faut bien sûr sommer leur temps d’exécution. Si l’appel
est récursif, il est en général sur une partie plus petite des données. On obtient donc une relation
de récurrence sur les temps d’exécution, et il existe des techniques classiques pour trouver la
forme close qui correspond à la récurrence.
Nb : exemple de calcul de la factorielle : T (n) = c + T (n − 1). On en déduit T (n) = cn = O(n).
5.5
Complexité de quelques algorithmes classiques
Quelques questions :
– Montrez que la complexité d’un algorithme de sommation conditionnelle est O(n). Exemple la
moyenne des notes différentes de 0.
– Montrez que le tri à bulle est un O(n2 ).
– Montrez que le pire cas de quicksort est un O(n2 ).
– Quelle est la complexité de la recherche dichotomique ?
– Quelle est la complexité des calculs récursif/itératif de la factorielle ?
– Quelle est la complexité de l’algorithme du sac-à-dos ?
– Quelle est la complexité de calcul de la version récursive du binomial Cnp ? (Remarquez que la somme
des binomiaux fait 2n ).
– Quelle est la complexité du calcul de l’enveloppe convexe par l’algorithme de Graham ? Par Melkman ?
– Quelle est la complexité des algorithmes Insérer et SupprimerMin des tas ? En déduire la complexité
du tri par tas ?
On note que l’on a bien montré la complexité en pire cas d’un algorithme en O(f (n)) lorsqu’on
peut exhiber un exemple d’exécution où le temps d’exécution atteint bien asymptotiquement ce f (n).
C’est la même chose pour le meilleur cas. Ainsi O(n3 ) est une borne supérieure de la complexité dans
le pire cas du tri à bulle, mais n’est jamais atteinte. De même Ω(n) est une borne inférieure de la
complexité dans le meilleur cas ce même algorithme, mais n’est jamais atteinte non plus.
5.6
Complexité moyenne en temps
Il est souvent plus difficile de calculer la complexité moyenne d’un algorithme. Il faut en effet
calculer le temps d’exécution de l’algorithme considéré sur toutes les données possibles, en normalisant
la probabilité d’apparition de chaque ensemble de données selon l’application visée. Très souvent,
pour des raisons de simplicité, on supposera que toutes les données ont des probabilités identiques
d’apparition. Evidemment, lorsque les temps d’exécution en pire cas et meilleur cas coı̈ncident en
ordre de grandeur, le temps moyen est du même ordre. Ce n’est que lorsqu’ils diffèrent qu’une analyse
en moyenne devient nécessaire.
L’analyse en moyenne peut être très délicate dans certains cas, et nécessiter une connaissance
poussée d’outils probabilistes (voir par exemple le calcul de la complexité moyenne des Bogosort et
Bozosort, cf Wikipédia). Sur certains algorithmes, elle est plus facile moyennant des connaissances sur
les séries.
5.6.1
Complexité moyenne d’une recherche dans un tableau
Il faut distinguer deux cas, selon que la donnée recherchée est dans le tableau ou non.
Si oui, on suppose qu’elle peut être dans n’importe quelle case de manière équiprobable. Dans ce
cas, si elle est dans la case d’indice i, le temps de recherche de l’élément est proportionnel à i. On a
donc
35
n−1
1X
i+1
n i=0
T̂ (n) =
n+1
2
=
Si l’élement n’est pas dans le tableau, le temps de recherche est invariablement n, le temps moyen
dans ce cas est donc n. Si on se donne maintenant p comme étant la probabilité a priori que l’élément
appartienne au tableau, le temps moyen d’exécution est donc proportionnel à
T̂ (n) = p n+1
2 + (1 − p)n =
(2−p)n+p
2
= (1 − p/2)O(n)
Quelques questions :
1. est-il légitime d’ignorer la constante de proportionnalité devant le temps de recherche ? Mettre à
jour si nécessaire ce calcul. Comment calculer de manière effective cette/ces constante(s) pour
un exécutable donné ?
2. Qu’en est-il de la recherche dans une liste, triée ou non ?
5.6.2
Complexité moyenne d’une recherche dans un ABR
Nous avons montré dans la Section 4.8.1 que la profondeur moyenne d’un ABR était inférieure à
1 + 2 log n, où log désigne le logarithme naturel, et en faisant certaines hypothèses sur la construction
de l’ABR et sur les données insérées. Au vu des algorithmes d’insertion, de recherche et de suppression,
leur complexité moyenne dépend de cette profondeur moyenne et on en déduit qu’ils sont en O(log n).
En fait, il est clair qu’une recherche d’un élément existant est en O(log n). Pour un élément non
existant, il faudrait plutôt calculer la longueur moyenne d’un chemin de la racine à une feuille ou à un
nœud qui n’a qu’un descendant. Pour l’insertion, c’est plutôt aussi cette quantité qu’il faut examiner.
5.6.3
Complexité moyenne du quicksort
On peut procéder d’une manière similaire au calcul de la profondeur moyenne d’un arbre binaire
pour déterminer la complexité moyenne du quicksort. Il faut faire l’hypothèse que tous les ordres
sont équiprobables et qu’à chaque étape de partitionnement la position du pivot peut être n’importe
laquelle des cases étudiées avec même probabilité.
Le temps d’exécution T (n) d’une étape de quicksort est donc de la forme :
T (n) =
n (RecherchePivot)
+ T (i) (Quicksort sur les i premiers éléments)
+ T (n − 1 − i) (Quicksort sur les n-1-i derniers éléments)
Le temps moyen T̂ (n) d’une étape est donc la moyenne des temps possibles d’exécution. Or le pivot
peut se retrouver à une position i quelconque de façon équiprobable. Ceci induit, pour n ≥ 2 :
T̂ (n) =
n−1
1X
n + T̂ (i) + T̂ (n − 1 − i)
n i=0
avec les temps moyens T̂ (1) = 1 et T̂ (0) = 0. Le premier terme sort de la somme. Les deux autres
termes sont symétriques. Cela donne
36
T̂ (n) = n +
n−1
2X
T̂ (i)
n i=0
On calcule maintenant la quantité suivante :
nT̂ (n) − (n − 1)T̂ (n − 1) =
⇔ nT̂ (n) =
⇔ T̂ (n) =
n2 − (n − 1)2 + 2T̂ (n − 1)
2n − 1 + (n + 1)T̂ (n − 1)
1
(n + 1)
2− +
T̂ (n − 1)
n
n
En développant le terme de droite
T̂ (n)
1
1
(n + 1)
n
= 2− +
2−
T̂ (n − 2)
+
n
n
n − 1 (n − 1)
n+1
n+1
n+1
n+1 n+1
T̂ (1)
+
+ ··· −
+
+ ··· +
= 2
n+1
n
(n + 1)n n(n − 1)
2
= 2(n + 1)
n+1
X
i=1
n
X
1
1
n+1
− (n + 1)
+
i
(i)(i + 1)
2
i=1
Le premier terme est de l’ordre de 2(n + 1) log(n + 1), le deuxième terme comme le troisième est
un O(n). Cela nous donne la complexité en moyenne du quicksort en O(n log n).
5.7
5.7.1
Quelques exercices détaillés
Complexité de calcul de la suite de Fibonacci
La version itérative du calcul de cette suite, définie par un+2 = un+1 + un , u0 = 0, u1 = 1, est
clairement en Θ(n). Le temps d’exécution T (n) de sa version récursive donne :
T (n) =
=
=
=
=
1 + T (n − 1) + T (n − 2)
1 + 1 + 2T (n − 2) + T (n − 3)
1 + 1 + 2 + 3T (n − 3) + 2T (n − 4)
1 + 1 + 2 + 3 + 5T (n − 4) + 3T (n − 5)
1 + . . . + ui + ui+1 T (n − i) + ui T (n − i − 1)
On montre facilement que 1 + . . . + ui = ui+2 − 1. D’où
T (n) =
=
=
Sachant que un ≈
assez coûteux !
5.8
√1
5
√ n
1+ 5
,
2
ui+2 − 1 + ui+1 T (n − i) + ui T (n − i − 1)
un+1 − 1 + un T (1) + un−1 T (0)
un+2 − 1
√ n+2
on en déduit que T (n) = Θ( 1+2 5
), ce qui est quand même
Complexité en espace
A faire.
37
4
3
2
1
Figure 6 – Un exemple de graphe orienté (ou digraphe).
5.9
Théorie de la complexité des algorithmes
A faire.
6
Structures relationnelles, Graphes
Les graphes servent à représenter les relations entre des éléments.
6.1
Définition d’un graphe
Un graphe simple orienté G est un couple (V, A) où :
– V est appelé l’ensemble des sommets de G, et
– A ⊆ V × V est un ensemble de couples d’éléments de V appelé l’ensemble des arcs de G.
Un graphe simple non-orienté G est un couple (V, E) où :
– V est appelé l’ensemble des sommets de G, et
– E ⊆ P2 (V ) est un ensemble de paires d’éléments de V appelé l’ensemble des arêtes de G.
(Ici P2 (V ) désigne l’ensemble des parties de cardinalité 2 de V .)
Il est plus facile de se représenter un graphe sous la forme d’un dessin (Figure 6).
Ces graphes sont dits simples car on n’autorise pas les multiples liens entre de mêmes extrémités.
Il est clair que tout arbre ou toute liste est un graphe. Les graphes sont omniprésents en informatique, et apparaissent dans de nombreux problèmes. Ils servent à modéliser des relations dans les
bases de données, à modéliser les réseaux, les formes géométriques, etc.
Dans un graphe non orienté, le degré d’un sommet est le nombre d’arêtes auxquelles ce sommet
appartient. La somme des degrés de chaque sommet est égale au double du nombre total d’arêtes.
Dans un graphe orienté, on distingue pour un sommet s le degré entrant et le degré sortant. Le
premier correspond au nombre d’arcs dont l’extrémité finale est s. Le second est le nombre d’arcs
dont l’extrémité initiale est s. Le degré d’un sommet s dans un graphe orienté est la somme du degré
entrant et sortant de s.
Un graphe est valué si à tout arc (resp. arête) est associée une valeur (par exemple : un poids, un
coût, une distance, ...). On parle de fonction de valuation définie de V × V dans R.
38
n0
n1
n4
n3
n3
n0
n1
planaire
n4
n1
n0
n2
n3
n2
n0
n2
n3
non planaire ?
et si
n1
n2
non planaire
Figure 7 – Propriété de planarité des graphes. Le graphe de droite est le graphe complet à cinq
sommets, ou K5 .
6.2
Classes de graphes notables
On dit qu’un graphe est connexe ssi pour tout couple de sommets u, v, il existe une séquence d’arcs
allant de u à v telle que l’extrémité finale de l’arc précédent est l’extrémité initiale de l’arc suivant.
On dit qu’un graphe est planaire s’il peut se dessiner dans le plan sans que deux arêtes ne s’intersectent. Par exemple le graphe d’adjacence entre régions dans une carte est toujours un graphe
planaire.
Un graphe est dit complet si tout sommet est relié à tous les autres. Le graphe complet à n sommets
est noté : Kn (en référence à Kuratowski).
Un graphe est biparti s’il existe une partition des sommets du graphe en deux sous-ensembles A et
B telle que toutes les arêtes du graphe ont un sommet dans A et un sommet dans B. Par exemple, le
graphe d’adjacence des cases d’un jeu d’échec est biparti.
Une k-coloration d’un graphe G=(S,A) est une application c de S dans 1, 2, ..., k (avec k entier
naturel non nul) telle que, pour tout couple (a, b) de sommets adjacents dans G, les couleurs c(a) et
c(b) respectivement de a et b sont distinctes. Il est clair qu’un graphe biparti admet une 2-coloration
(d’où l’échiquier noir et blanc). On sait aussi que tout graphe planaire est 4-colorable (Théorème des
quatre couleurs), d’où le résultat bien connu qu’une carte des pays peut être colorée avec 4 couleurs.
Une chaı̂ne est un graphe non orienté connexe de degré maximum inférieur ou égal à 2 et de degré
minimum 1. Un chemin est un graphe orienté connexe, tel que chaque sommet est de degré entrant
maximum 1, de degré sortant maximum 1 et de degré minimum 1. Une chaı̂ne est donc une version
non orientée d’un chemin.
On a des définitions équivalentes lorsqu’on ferme les chaı̂nes et chemins sur eux-mêmes. Un graphe
G non orienté (resp. orienté) et connexe est un cycle (resp. circuit) si et seulement si tous les sommets
sont de degré 2 (resp. de degré entrant 1 et de degré sortant 1).
6.3
Quelques problèmes classiques
Un cycle eulérien est un cycle d’arêtes telle que chaque paire d’arête successive est incidente à
au moins un même sommet. Euler a montré qu’un graphe connexe possède un cycle eulérien si et
seulement si tous ses sommets sont de degré pair.
Soient G un graphe et C un sous-graphe de G : C est un cycle hamiltonien de G si C est un cycle
qui a le même nombre de sommets que G.
39
En gros, dans un cycle eulérien, on ne passe qu’une fois et une seule par une arête et dans un cycle
hamiltonien, on ne passe qu’une fois et une seule par un sommet (sauf pour fermé la boucle).
Or, il est très facile de vérifier si un graphe contient un cycle eulérien, et très difficile de vérifier si
un graphe contient un cycle hamiltonien.
Un arbre couvrant d’un graphe G non orienté est un graphe T tel que : T couvre G ; T est un arbre.
Tout graphe connexe admet un arbre couvrant. Lorsqu’on affecte des poids aux arêtes, il est courant
de se poser la question de savoir quel est l’arbre couvrant de poids minimal.
Il existe des algorithmes efficaces pour le faire. L’algorithme de Prim (1957) est assez facile à
implémenter et de complexité O(mn), où m est le nombre d’arêtes et n le nombre de sommets.
L’algorithme de Kruskal est quant à lui de complexité m log n. Chazelle (2000) a publié un algorithme
de complexité mα(m, n), où α(m, n) est l’inverse de la fonction de Ackerman, c’est-à-dire une fonction
qui quoique tendant vers l’infini, est quasi-constante pour toute valeur pratique.
6.4
Primitives
On va se donner quelques primitives pour écrire des algorithmes sur les graphes. D’abord pour leur
création :
– Creer(S G : Graphe). Crée le graphe vide
– AjouterSommet(ES G : Graphe) : entier. Ajoute un nouveau sommet au graphe G et retourne
son indice.
– AjouterArc(ES G : Graphe, E i, j : entier). Ajoute un nouvel arc du sommet i vers le sommet j.
– AjouterArete(ES G : Graphe, E i, j : entier). Ajoute l’arête du sommet i vers le sommet j.
– Detruire(ES G : Graphe). Détruit le graphe. N’est plus valide.
– Ordre(E G : Graphe). Renvoie le nombre de sommets de G
– Sommets(E G : Graphe) : Liste de Sommets. Retourne la liste des sommets de G.
Ensuite, pour leur parcours :
– Premier(E G : Graphe, E s : Sommet) : entier. Retourne l’indice du premier sommet voisin de s
dans G, ou 0 s’il n’en a pas.
– Suivant(E G : Graphe, E s : Sommet, E i : entier) : entier. Retourne l’indice du sommet voisin
suivant de s dans G, ou 0 s’il n’en a pas.
– Voisin(E G : Graphe, E s : Sommet, i : entier) : Sommet. Retourne le sommet d’indice i dans le
voisinage de S.
L’Algorithme 19 montre comment compter le nombre d’arêtes d’un graphe non orienté avec les
primitives précitées.
Lorsqu’il n’y aura pas d’ambiguı̈té, on écrira plus volontiers l’algorithme sous la forme de l’Algorithme 20.
On se donnera de plus quelques fonctions pratiques pour indiquer si on est déjà passé sur un
sommet.
– CréerMarqueur( E G : Graphe, S M : Marqueur ). Retourne un nouveau marqueur associé aux
sommets du graphe G. Tous les sommets de G sont alors non marqués dans M.
– EstMarque ?( E M : Marqueur, E s : Sommet ) : booléen. Retourne vrai si le sommet s est marqué
dans M, faux sinon.
– Marquer( ES M : Marqueur, E s : Sommet). Marque le sommet s dans M.
– Demarquer( ES M : Marqueur, E s : Sommet). Démarque le sommet s dans M.
6.5
Algorithmes de parcours
Un algorithme naturel est le parcours en largeur, qui parcourt les sommets d’un graphe connexe à
partir d’une source, en triant les éléments suivant leur distance à la source. Il est décrit sur l’Algorithme 21. On note que si le graphe n’est pas connexe, il faut ensuite parcourir la liste des sommets
pour trouver un autre sommet non marqué, et refaire un parcours en largeur à partir de ce sommet,
40
Algorithme 19 : Comptage du nombre d’arêtes d’un graphe, écriture formelle.
Fonction NbAretes(E G : Graphe) : entier;
Var : L : Liste de Sommet;
Adr : Adresse;
a, i : entier;
s : Sommet;
début
a ←0;
L ←Sommets( G );
Adr ←Premier( L );
Tant Que Adr 6= NULL Faire
s ←Valeur( L, Adr ) ;
i ←Premier(G, s);
Tant Que i 6= 0 Faire
a ←a+1;
i ←Suivant(G, s, i );
Adr ←Suivant( L, Adr );
Retourner a/2 ;
fin
Algorithme 20 : Comptage du nombre d’arêtes d’un graphe, écriture informelle.
Fonction NbAretes(E G : Graphe) : entier;
Var : a : entier;
s : Sommet;
début
a ←0;
Pour tout Sommet s de G Faire
Pour tout Sommet t tq (s,t) dans G Faire
a ←a+1;
Retourner a/2 ;
fin
41
et ainsi de suite.
Algorithme 21 : Parcours en largeur d’un graphe.
Fonction ParcoursLargeur(E G : Graphe, E s : sommet);
Var : F : File de Sommet;
s : Sommet;
M : Marqueur;
début
CréerMarqueur( G, M );
CreerFile( F );
Enfiler( F, s );
Marquer( M, s );
Tant Que non FileVide ?( F ) Faire
s ←Tete( F );
Defiler( F );
/* Ici, faites ce que vous voulez. */;
Pour tout Sommet t tq (s,t) dans G Faire
Si non EstMarque ?( M, t ) Alors
Marquer( M, t );
Enfiler( F, t );
fin
Il existe aussi un parcours en profondeur des graphes, qui peut être récursif ou itératif, et qui
coı̈ncide avec le parcours en profondeur des arbres lorsque le graphe est un arbre.
Quelques questions :
1. Ecrire les versions récursives et itératives du parcours en profondeur.
2. En utilisant le parcours en largeur ou le parcours en profondeur, déduisez un algorithme pour
compter le nombre de composantes connexes d’un graphe.
3. Ecrire l’algorithme qui décide si un graphe non orienté est eulérien.
4. Ecrire un algorithme qui détecte si un graphe orienté contient un cycle. Comment l’adapter pour
détecter des cycles de longueur ≥ k ?
5. En adaptant le parcours en largeur, déduire un algorithme pour tester si un graphe est biparti.
La distance topologique entre deux sommets d’un graphe est le nombre minimum d’arcs à traverser
pour aller de l’un à l’autre. On appelle excentricité d’un sommet sa distance topologique au sommet
le plus distant. On peut déduire trois algorithmes du parcours en largeur :
– comment calculer le diamètre d’un graphe (égal à la plus grande excentricité possible)
– comment calculer le rayon d’un graphe (plus petite excentricité possible)
– comment calculer le centre d’un graphe, qui est l’ensemble des sommets de plus petite excentricité.
6.6
Poids et valuation d’un graphe
On associe souvent une valeur à chaque arc/arête d’un graphe (son poids) et parfois aussi une valeur
à un sommet. Il y a plusieurs façons de mettre en œuvre ces fonctions, l’une étant de stocker dans
le type Sommet ou le type Arc/Arête un champ pour cette donnée. On peut aussi utiliser un TAD
de type tableau associatif pour mémoriser ces valeurs. Sans présumer de la mise en œuvre choisie, on
utilisera les primitives suivantes :
– Valuer(E G : Graphe, E s : Sommet, E v : X). Donne une valeur de type X à un sommet s (on
substitue le bon type à X).
– Valuer(E G : Graphe, E s,t : Sommet, E v : X). Donne une valeur à l’arc (s,t) (ou l’arête {s,t}
si le graphe est non orienté).
42
– Poids(E G : Graphe, E s,t : Sommet) : X. Retourne la valeur (le poids) associée à l’arc (s,t) (ou
l’arête {s,t} si le graphe est non orienté).
– Poids(E G : Graphe, E s : Sommet) : X. Retourne la valeur associée au sommet s.
6.7
Algorithmes de plus court chemin
Le poids d’un chemin est la somme des poids de ses arcs. Un problème classique est de déterminer
étant donné deux points un chemin qui les relie tel qu’il n’existe aucun autre chemin de poids inférieur.
On parle de plus court chemin car on identifie souvent le poids d’un arc à une distance. Ainsi, on
cherche souvent le plus court chemin entre deux villes et le poids d’un arc est souvent la longueur de
la section de route. On peut aussi rajouter une notion de vitesse, mais le principe reste le même. On
parle aussi de distance topologique lorsque le poids de tout arc est 1.
L’algorithme de plus court chemin le plus connu est celui de E. Dijkstra (1959), qui calcule en fait
les plus courts chemins de tous les sommets vers le sommet source. Il suffit de l’arrêter dès qu’on a
trouvé le sommet cible. On note que cet algorithme n’est valable que pour des poids positifs.
Le principe de l’algorithme est de construire un arbre couvrant à partir de la source, l’arbre codant
en fait les plus courts chemins jusqu’à sa racine. On retire les arêtes qui bordent l’arbre déjà extrait
dans l’ordre croissant de leur poids, ce qui garantit la validité de l’algorithme (Algorithme 22 et
Algorithme 23).
Algorithme 22 : Principe de l’algorithme de calcul de plus courtes distances à une source de
Dijkstra.
/* Calcule l’arbre des distances vers u dans G. */;
Action DijkstraSimple( E G : Graphe, E u : Sommet, S d : TabAssoc de Sommet vers réel );
/* G est un graphe (V,E) */;
Var : S : ensemble de sommets;
w, v : Sommet;
début
/* Les distances sont invalides au début. */;
S ← ∅;
pour tout Sommet v 6= u de G faire d[v] ← +∞;
d[u] ← 0;
Tant Que V − S non vide Faire
Choisir le sommet w de V − S tq d[w] est minimum;
S ← S ∪ {w};
Pour tout sommet v de V − S Faire
d[v] ← min(d[v], d[w] + P oids(w, v));
fin
Une implémentation naı̈ve de l’algorithme donne une complexité en O(n2 ). On observe que le choix
d’un sommet prend O(Card(V − S)), tout comme la deuxième boucle. Sachant que ce cardinal vaut
n au début et diminue de 1 à chaque fois, on obtient bien O(n) + O(n − 1) + · · · + O(1) = O(n2 ).
La complexité de l’algorithme est de O((m + n) log n) si on utilise un tas binaire pour la file à
priorité. On peut faire encore mieux avec un tas de Fibonacci ( O(m + n log n) ).
Lorsqu’il faut calculer un plus court chemin avec des poids négatifs, on préferera l’algorithme de
Dantzig-Ford ou l’algorithme de Bellman-Ford.
Lorsque l’on veut calculer tous les plus courts chemins (par exemple pour déterminer les meilleures
routes dans un réseau local une bonne fois pour toute), on utilise l’algorithme de Floyd-Warshall (qui
fonctionne avec des poids négatifs du moment qu’il n’existe pas un cycle de poids négatif). Celui-ci
s’écrit assez simplement, si on suppose que l’on se donne en entrée une matrice d’adjacence M qui
représente le graphe G, telle que Mij vaut le poids de cet arc si l’arc existe et +∞ sinon.
L’algorithme se réduit alors à Algorithme 24.
43
Algorithme 23 : Algorithme de calcul du plus court chemin de Dijkstra.
/* Calcule l’arbre des plus courts chemin vers u dans G. */;
Action ArbreDijkstra( E G : Graphe, E u : Sommet, S A : TabAssoc de Sommet vers Sommet
) Var : d : TabAssoc de Sommet vers réel;
V : Marqueur;
P : File à priorité de Sommet;
u, s1, s2 : Sommet;
début
/* Les distances sont invalides au début. */;
pour tout Sommet v de G faire d[ v ] ←-1;
/* A stocke le parent de chaque sommet dans l’arbre couvrant. */;
A[ u ] ←u;
d[ u ] ←0;
Insérer( P, u, 0 );
/* Marque les sommets visités. */;
CréerMarqueur( G, V );
/* Début de la boucle principale. */;
Tant Que non EstVide ?( P ) Faire
/* Extrait le plus proche de A de ceux adjacents. */;
/* On connait déjà son plus court chemin vers u. */;
s1 ←SupprimerMin( P );
Marquer( V, s1 );
Pour tout Sommet s2, tq (s1,s2) dans G Faire
Si non EstMarque ?( V, s2 ) Alors
/* On regarde s’il faut mettre à jour les plus courts chemins.*/;
Si d[ s2 ] == -1 ou ( d[ s2 ] > d[ s1 ] + Poids( s1, s2 ) ) Alors
d[ s2 ] ←d[ s1 ] + Poids( s1, s2 );
/* on fait passer le chemin par s1 */;
A[ s2 ] ←s1 ;
Inserer( P, s2, d[ s2 ] );
fin
/* A partir de l’arbre précédent, renvoie le plus court chemin de v vers u. */ ;
Action PlusCourtCheminDijkstra( E A : TabAssoc de Sommet vers Sommet, E v : Sommet,
S F : File de Sommet );
début
CreerFile( F );
Tant Que v 6= A[ v ] Faire
Enfiler( F, v );
v ←A[ v ];
Enfiler( F, v );
fin
Algorithme 24 : Calcul des plus courtes distances (Floyd-Warshall).
Action FloydWarshall(ES M : matrice d’adjacence n × n valuée d’un graphe);
/* La matrice M est modifiée telle qu’à la sortie Mij vaut la distance minimale de i à j. */;
Pour k de 1 à n Faire
Pour i de 1 à n Faire
Pour j de 1 à n Faire
Mij = min(Mij , Mik + Mkj );
44
On peut calculer ensuite aussi les plus courts chemins en modifiant légèrement cet algorithme. On
rajoute une matrice d’entiers P, initialisée à 0. On la met à jour au point (i, j) avec la valeur k si
le chemin de i à j via k est plus court. L’affichage du plus court chemin est une simple procédure
récursive (Algorithme 25).
Algorithme 25 : Calcul des plus courts chemins à partir de (Floyd-Warshall).
Action CheminFW(ES P : matrice n × n d’entiers);
Var : k : entier;
début
k ←P(i,j);
Si k 6= 0 Alors
CheminFW( i, k );
Ecrire( k );
CheminFW( k, j );
fin
Quelques questions :
1. Pourquoi l’algorithme de Dijkstra fonctionne ? Montrez qu’un sommet sorti de la file a une distance
à u inférieure à des sommets qui sont encore dans la file. Un sommet de la file touche aussi
forcément un sommet de l’arbre couvrant courant.
2. Si on veut chercher le plus court chemin entre deux sommets u et v, il est “souvent” plus
efficace de lancer deux Dijkstra en parallèle sur chaque sommet et de s’arrêter lorsque les deux
propagations se rencontrent. Pourquoi ? Regardez l’effet de Dijkstra sur une simple grille du plan.
3. Trouvez des graphes où l’approche précédente est pire.
4. Remarquez que chaque colonne j de la matrice de Floyd-Warshall contient les distances de tous les
sommets à j. Comment calculer l’excentricité, puis les centres du graphe à partir de la matrice ?
Complexité ?
6.8
Arbre couvrant de poids minimal
On rappelle qu’un arbre couvrant d’un graphe connexe G est un sous-graphe connexe de G comportant les mêmes sommets et ne comportant pas de cycles.
L’objectif est ici de construire un arbre couvrant d’un graphe valué non orienté, tel que le poids de
cet arbre (somme des poids de ses arêtes) est inférieur ou égal au poids des autres arbres couvrants.
L’algorithme de Kruskal (Algorithme 26) consiste à d’abord ranger par ordre de poids croissant les
arêtes d’un graphe, puis à retirer une à une les arêtes selon cet ordre et à les ajouter à l’ACM cherché
tant que cet ajout ne fait pas apparaı̂tre un cycle dans l’ACM.
6.9
Graphes planaires
On rappelle qu’un graphe planaire peut se dessiner sur le plan sans auto-intersection. Il dessine
donc des faces (ou régions connexes) délimitées par les arêtes, ainsi qu’une face dite infinie. Si on note
f le nombre de faces, m le nombre d’arêtes, n le nombre de sommets, on observe (Formule d’Euler)
n−m+f =2
(6)
Cela veut dire qu’un graphe sera toujours dessiné avec le même nombre de faces, indépendamment
de la manière de le dessiner (car m et n sont fixés et f vaut alors 2 − n + m =constante.
Dessinons maintenant le graphe sur un tore. Qu’observez-vous ?
45
Algorithme 26 : Algorithme de Kruskal de calcul d’arbre couvrant minimal. Ici, le tri est le tri
par tas et on utilise une structure Union-Find pour fusionner les ensembles disjoints.
Fonction Kruskal(E G : Graphe ) : Graphe;
Var : A : Graphe /* l’arbre couvrant */;
u, v : Sommet;
UF : Structure Union-Find;
T : Tas;
F : File de (Sommet, Sommet);
début
CreerGraphe( A );
Pour tout sommet u de G Faire
AjouterSommet( A, u );
CreerSingleton( UF, u );
/* Trier les arêtes de G par ordre croissant de poids */;
/* Par exemple avec un tas. */;
Pour tout arete (u,v) de G Faire
Inserer( T, (u,v), Poids(u,v) );
CreerFile( F );
Tant Que non EstVide( T ) Faire
Enfiler( F, SupprimerMin( T ) );
/* On lit les arêtes dans l’ordre croissant de leur poids. */;
Tant Que non EstVide( F ) Faire
(u, v) ← T ete(F ) ;
Defiler( F );
Si Find( UF, u ) 6= Find( UF, v ) Alors
/* L’ajout de cet arc ne formera donc pas un cycle. */;
AjouterArc( A, u, v );
Union( UF, u, v );
Retourner A;
fin
46