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