lims Hubbard
22/06/06
12:33
Page 1
STRUCTURES DE DONNÉES EN JAVA J. R. HUBBARD Professeur de mathématiques et d’informatique à l’université de Richmond (Virginie, USA)
Traduit de l’américain par Virginie Maréchal
lims Hubbard
22/06/06
12:33
Page 2
Ce pictogramme mérite une explication. Son objet est d’alerter le lecteur sur la menace que représente pour l’avenir de l’écrit, particulièrement dans le domaine de l’édition technique et universitaire, le développement massif du photocopillage. Le Code de la propriété intellectuelle du 1er juillet 1992 interdit en effet expressément la photocopie à usage collectif sans autorisation des ayants droit. Or, cette pratique s’est généralisée dans les
établissements d’enseignement supérieur, provoquant une baisse brutale des achats de livres et de revues, au point que la possibilité même pour les auteurs de créer des œuvres nouvelles et de les faire éditer correctement est aujourd’hui menacée. Nous rappelons donc que toute reproduction, partielle ou totale, de la présente publication est interdite sans autorisation du Centre français d’exploitation du droit de copie (CFC, 20 rue des GrandsAugustins, 75006 Paris).
Original edition copyright © 2001 by The McGraw-Hill Companies, Inc. All rights reserved. L’édition originale de cet ouvrage est parue sous le titre : Schaum’s Outline of Data Structures with Java.
© Dunod, Paris, 2003, pour l’édition française. Tous droits réservés. ISBN : 2-10-0006937-3
Toute représentation ou reproduction intégrale ou partielle faite sans le consentement de l’auteur ou de ses ayants droit ou ayants cause est illicite selon le Code de la propriété intellectuelle (Art L 122-4) et constitue une contrefaçon réprimée par le Code pénal. • Seules sont autorisées (Art L 122-5) les copies ou reproductions strictement réservées à l’usage privé du copiste et non destinées à une utilisation collective, ainsi que les analyses et courtes citations justifiées par le caractère critique, pédagogique ou d’information de l’œuvre à laquelle elles sont incorporées, sous réserve, toutefois, du respect des dispositions des articles L 122-10 à L 122-12 du même Code, relatives à la reproduction par reprographie.
Sommaire
Avant-propos Chapitre 1
IX Caractéristiques de base du langage java 1.1 Programmation orientée objet 1.2 Langage de programmation Java 1.3 Variables et objets 1.4 Types primitifs 1.5 Contrôle du flux 1.6 Classes 1.7 Modificateurs 1.8 Classe String 1.9 Classe Math Questions de révision Réponses Exercices d’entraînement Solutions
Chapitre 2
Caractéristiques de base des tableaux 2.1 Propriétés des tableaux 2.2 Copier un tableau 2.3 Classe Arrays 2.4 Algorithme de recherche séquentielle 2.5 Algorithme de recherche binaire 2.6 Classe Vector Questions de révision Réponses Exercices d’entraînement Solutions
Chapitre 3
Java avancé 3.1 3.2 3.3 3.4 3.5
Héritage Polymorphisme Conversion des types Classe Object Classes abstraites
1 1 1 2 3 5 7 9 10 13 16 17 18 20 25 25 27 28 31 33 35 38 39 39 44 57 57 58 60 62 64
IV
Chapitre 4
Structures de données en Java
3.6 Interfaces 3.7 Paquetages 3.8 Gérer les exceptions Questions de révision Réponses Exercices d’entraînement Solutions
67 70 70 72 72 73 74
Récursivité
77 78 79 80 82 83 83 84 85 85 87 88 89 89 92
4.1 La base et la partie récursive de la récursivité 4.2 Tracer un appel récursif 4.3 Algorithme récursif de recherche binaire 4.4 Coefficients binomiaux 4.5 Algorithme d’Euclide 4.6 Preuve inductive de correction 4.7 Analyse de la complexité des algorithmes récursifs 4.8 Programmation dynamique 4.9 Les tours de Hanoi 4.10 Récursivité mutuelle Questions de révision Réponses Exercices d’entraînement Solutions
Chapitre 5
Collections 5.1 Framework de collections Java 5.2 Interface Collection 5.3 Classe AbstractCollection 5.4 Classe Bag 5.5 Interface Iterator Questions de révision Réponses Exercices d’entraînement Solutions
Chapitre 6
Piles 6.1 Classe Java Stack 6.2 Applications des piles 6.3 Supprimer la récursivité Questions de révision Réponses Exercices d’entraînement Solutions
99 99 100 101 102 109 109 110 110 111 115 115 118 121 122 122 123 125
V
Sommaire
Chapitre 7
Files 7.1 Framework des files 7.2 Implémentation contiguë 7.3 Implémentation chaînée 7.4 Applications utilisant les files Questions de révision Réponses Exercices d’entraînement Solutions
Chapitre 8
Listes 8.1 Interface java.util.List 8.2 Implémenter l’interface java.util.List 8.3 Classes AbstractList et AbstractSequentialList 8.4 Itérateurs de listes 8.5 Classe ArrayList 8.6 Classe LinkedList 8.7 Itérateurs de listes indépendants Questions de révision Réponses Exercices d’entraînement Solutions
Chapitre 9
Arbres 9.1 Terminologie des arbres 9.2 Arbres décisionnels et diagrammes de transition 9.3 Arbres ordonnés 9.4 Algorithmes de parcours des arbres ordonnés Questions de révision Réponses Exercices d’entraînement Solutions
Chapitre 10
Arbres binaires 10.1 10.2 10.3 10.4 10.5 10.6 10.7 10.8 10.9
Terminologie Compter les arbres binaires Arbres binaires complets Identité, égalité et isomorphisme Arbres binaires parfaits Algorithmes de parcours des arbres binaires Arbres d’expression Classe BinaryTree Implémenter les algorithmes de parcours
129 129 132 134 136 142 142 142 144 149 149 151 151 153 154 155 164 165 165 166 167 171 172 174 177 178 180 181 183 183 187 187 188 189 190 192 194 196 198 203
VI
Chapitre 11
Structures de données en Java
10.10 Forêts Questions de révision Réponses Exercices d’entraînement Solutions
205 206 207 208 210
Arbres de recherche
217 217 219 222
11.1 11.2 11.3 11.4
Arbres de recherche multidirectionnels Arbres équilibrés ou arbres-B Arbres binaires de recherche Caractéristiques des arbres binaires de recherche en matière de performances 223 11.4 Arbres AVL 11.6 Classe AVLTree Questions de révision Réponses Exercices d’entraînement Solutions
Chapitre 12
Tas et files de priorité 12.1 Tas 12.2 Mappage naturel 12.3 Insérer des éléments dans un tas 12.4 Supprimer un élément d’un tas 12.5 Classe PriorityQueue 12.6 Interface Java Comparator 12.7 Implémentation directe Questions de révision Réponses Exercices d’entraînement Solutions
Chapitre 13
Algorithmes de tri 13.1 13.2 13.3 13.4 13.5 13.6 13.7 13.8 13.9 13.10 13.11
Méthode Java Arrays.sort() Tri par permutation Tri par sélection Tri par insertion Tri Shell Tri par fusion Tri par segmentation Tri vertical Rapidité des algorithmes de tri par comparaison Tri digital Tri panier
224 225 227 228 228 229
233 233 233 234 235 236 237 239 243 243 244 245 251 252 252 254 255 256 258 260 263 268 268 270
VII
Sommaire
Chapitre 14
Questions de révision Réponses Exercices d’entraînement Solutions
272 274 275 277
Tables
287 287 288 289 290 292 293 295 296 298 300 300 301 302
14.1 Interface Java Map 14.2 Classe HashMap 14.3 Codes de hachage Java 14.4 Tables de hachage 14.5 Performances des tables de hachage 14.6 Algorithmes de résolution des collisions 14.7 Chaînage séparé 14.8 Applications 14.9 Classe TreeMap Questions de révision Réponses Exercices d’entraînement Solutions
Chapitre 15
Ensembles 15.1 Ensembles mathématiques 15.2 Interface Java Set 15.3 Classe Java AbstractSet 15.4 Classe Java HashSet 15.5 Classe Java TreeSet Questions de révision Réponses Exercices d’entraînement Solutions
Chapitre 16
Graphes 16.1 16.2 16.3 16.4 16.5 16.6 16.7 16.8 16.9 16.10 16.11 16.12
Graphes simples Terminologie des graphes Chemins et cycles Graphes isomorphes Matrice d’adjacence d’un graphe Matrice d’incidence d’un graphe Liste d’adjacences d’un graphe Digraphes Chemins d’un digraphe Digraphes pondérés et graphes Chemins et cycles eulériens et hamiltoniens Algorithme de Dijkstra
305 305 306 306 307 309 311 311 311 312 313 313 313 314 316 318 319 319 320 321 322 323 324
VIII
Annexes
Index
Structures de données en Java
16.13 Algorithmes de parcours des graphes Questions de révision Réponses Exercices d’entraînement Solutions
328 332 332 333 338
A. Mathématiques de base
345
B. De C++ à Java
366
C. Environnements de développement Java
368 371
Avant-propos
Comme tous les livres de notions fondamentales proposés dans la série Schaum’s, celui-ci est en premier lieu destiné aux personnes qui souhaitent étudier seules, de préférence en complément d’un cours sur les structures de données. Il constitue également une excellente référence pour les programmeurs Java plus expérimentés. Cet ouvrage comprend plus de 260 exemples et exercices d’entraînement. L’auteur est convaincu que les principes des structures de données peuvent être acquis plus aisément grâce à un large éventail d’exemples bien structurés et accompagnés d’explications précises. Ce livre est conçu de façon à apporter cette aide au lecteur. Le code source des exemples et les exercices peuvent être téléchargés depuis le site web de l’auteur http://www.mathcs.richmond.edu/~hubbard/Books.html. Toutes les corrections et les addenda sont également disponibles sur ce site. Je souhaiterais remercier tous mes amis, collègues, étudiants dont les critiques constructives m’ont été d’une aide précieuse. Je tiens tout particulièrement à remercier ma femme, Anita Hubbard, pour ses conseils, ses encouragements et les exemples créatifs qu’elle a apportés à ce livre. La plupart des idées originales que vous y trouverez sont d’elle. JOHN R. HUBBARD Richmond, Virginie
[email protected]
Chapitre 1
Caractéristiques de base du langage java Ce chapitre est consacré aux fonctionnalités de base du langage de programmation Java nécessaires à la conception et l’implémentation des structures de données.
1.1 PROGRAMMATION ORIENTÉE OBJET Java est un langage de programmation orienté objet parce qu’il présente les caractéristiques suivantes : • • • •
Chaque élément de donnée est encapsulé dans un objet. Chaque instruction exécutable est effectuée par un objet donné. Chaque objet est l’instanciation d’une classe ou est un tableau. Chaque classe est définie dans le cadre d’une hiérarchie d’héritage unique. La hiérarchie d’héritage unique de Java est une structure arborescente dont la racine est la classe
Object (reportez-vous à la section 3.4). Même si cette structure peut vous faire penser que Java est un
langage simple, ne vous fiez pas aux apparences car elle comprend les bibliothèques de classes Java 1.3 qui sont composées de 2 130 classes et de 23 901 membres. Ce sont ces caractéristiques orientées objet qui font de Java un langage particulièrement adapté à la conception et à l’implémentation des structures de données. L’architecture de ses collections (reportezvous au chapitre 4) offre une plate-forme idéale de construction des structures de données.
1.2 LANGAGE DE PROGRAMMATION JAVA Un programme Java est une collection d’un ou de plusieurs fichiers texte. Au moins l’un de ces fichiers contient une classe publique unique avec une méthode unique dont la signature est la suivante : public static void main(String[] args)
Chaque fichier du programme doit comprendre une classe ou bien une interface publique et est appelé X.java, X correspondant au nom de la classe ou de l’interface publique unique. Chaque fichier X.java est compilé dans un fichier classe appelé X.class. Le programme est ensuite lancé en exécutant le fichier X.class qui contient la méthode main(String[]) comprenant les instructions à exécuter.
2
Caractéristiques de base du langage java
Cette programmation peut être effectuée à partir de la ligne de commande ou bien d’un environnement IDE (reportez-vous à l’annexe C).
1.3 VARIABLES ET OBJETS Dans le cadre de tous les langages de programmation, l’accès aux données est effectué via les variables. Avec Java, une variable est soit une référence à un objet, soit l’un des huit types primitifs, comme illustré dans le diagramme. S’il s’agit d’une référence, sa valeur est null ou bien elle contient l’adresse d’un objet instancié. Chaque objet appartient à une classe unique qui définit les propriétés et les opérations de ses objets de la même manière qu’un type primitif définit les propriétés et les opérations de ses variables. Une variable est créée par une déclaration spécifiant son type et sa valeur initiale facultative. Un objet est créé par l’opérateur new qui appelle son constructeur de classe.
Types Java Types primitifs
boolean Types numériques Types entiers
byte short int long char Types à virgule flottante
float double Types de références type Tableau type Classe type Interface
Exemple 1.1 Créer des variables et des objets public class Ex0101 { public static void main(String[] args) { boolean flag=true; char ch=’B’; short m; int n=22; float x=3.14159F; double y; String str; String nada=null; String pays = new String("France"); System.out.println("flag = " + flag); System.out.println("ch = " + ch); System.out.println("n = " + n); System.out.println("x = " + x); System.out.println("nada = " + nada); System.out.println("pays = " + pays); } } flag = true ch = B n = 22 x = 3,14159 nada = null pays = France
Ce programme déclare neuf variables, puis il imprime les six qui ont été initialisées. Les six premières variables sont de type primitif et les trois dernières sont des références aux objets String. Il contient un seul objet qui est de type String et s’appelle pays (techniquement, pays est le nom de la variable faisant référence à l’objet String).
3
Types primitifs
La figure présentée contient les neuf variables et le seul objet. Chacune de ces variables a un nom et un type. Si le type est une classe, la variable est une référence à une instance (c’est-à-dire à un objet) de cette classe. Les variables non initialisées sont vides, tandis que les autres contiennent une valeur. Une variable de référence ne peut contenir qu’une seule valeur, c’est-à-dire la référence dessinée sous forme d’un point noir. Si cette référence n’est pas null, le point est composé d’une flèche pointant vers l’objet auquel il fait référence. En Java, un objet ne peut pas exister si une variable de référence ne pointe pas vers lui.
flag
true
ch
"B"
boolean
m
char
n
22
short
x
3.14159
int
y
float
str String
pays
double
nada String
"France"
String
1.4 TYPES PRIMITIFS Le langage Java définit les huit types primitifs suivants : • • • • • • • •
boolean false ou true
caractère Unicode 6 bits char, soit ’B’ ou ’π’ entier 8 bits byte : –128 à 127 entier 16 bits short : –32768 à 32767 entier 32 bits int : –2147483648 à 2147483647 entier 64 bits long : –9223372036854775808 à 9223372036854775807 nombre décimal à virgule flottante 32 bits float : (plus ou moins) 1.4E-45F à 3.4E38F nombre décimal à virgule flottante 64 bits double : (plus ou moins) 4.9E-324 à 1.8E308
Remarquez que les littéraux de type float doivent être précédés du suffixe F qui les différencie des valeurs de type double. Chaque littéral Unicode peut être exprimé sous la forme '\uxxxx', x correspondant à un chiffre hexadécimal. Par exemple, 'B' correspond à '\u0042' et 'π' à '\u03C0'. Outre leur valeur numérique, les variables à virgule flottante peuvent également contenir l’une des trois valeurs spéciales suivantes : NEGATIVE_INFINITY, POSITIVE_INFINITY et NaN (Not a Number, c’est-à-dire « n’est pas un nombre »). Ces valeurs spéciales sont obtenues à la suite de mauvaises opérations arithmétiques.
Exemple 1.2 Valeurs spéciales des types à virgule flottante public class Ex0102 { public static void main(String[] args) { double x=1E200; // x=10000...0 (1 suivi de 200 zéros) System.out.println("x = " + x); System.out.println("x*x = " + x*x); System.out.println("(x*x)/x = " + (x*x)/x); System.out.println("(x*x)/(x*x) = " + (x*x)/(x*x)); } } x = 1.0E200 x*x = Infinity (x*x)/x = Infinity (x*x)/(x*x) = NaN
4
Caractéristiques de base du langage java
La valeur de x*x est Infinity parce que (10200)(10200) == 10400, soit une valeur supérieure au maximum de 1.3(10308) pour le type double. D’un point de vue algébrique, (xx)/x = x, sauf lorsque x est fini. En effet, Infinity divisé par une valeur finie non négative sera toujours Infinity. En dernier lieu, la valeur Infinity divisée par Infinity ne produit pas de valeur finie, mais une valeur indéterminée sur le plan algébrique, d’où la valeur Java NaN. Pour chacun des huit types primitifs, Java définit une classe enveloppe qui fournit les services orientés objets nécessaires à la manipulation des types primitifs. Par exemple, la classe enveloppe du type double est Double, et celle du type int est Integer.
Exemple 1.3 Utiliser une classe enveloppe public class Ex0103 { public static void main(String[] args) { String s = "2.7182818284590"; System.out.println("s = " + s); Double x = new Double(s); System.out.println("x = " + x); double y = x.doubleValue(); System.out.println("y = " + y); s += 45; System.out.println("s = " + s); y += 45; System.out.println("y = " + y); int n = x.intValue(); System.out.println("n = " + n); n = Integer.parseInt("3A9",16); System.out.println("n = " + n); } } s x y s y n n
= = = = = = =
2.7182818284590 2.718281828459 2.718281828459 2.718281828459045 47.718281828459 2 937
Le constructeur Double(s) crée l’objet x de type Double à partir de l’objet s de type String et il stocke la valeur numérique 2.7182818284590 dans l’objet x. La deuxième instruction println() appelle implicitement la méthode Double.toString() qui reconvertit cette valeur numérique en chaîne à imprimer. L’appel x.doubleValue() renvoie la valeur numérique stockée affectée à y. L’opérateur += est ensuite appliqué à l’objet s de type String et à la variable y de type double, créant ainsi des résultats complètement différents. Dans le cas des chaînes, += signifie « insérer » alors qu’il signifie « additionner » lorsqu’il s’agit de numéros. Puis, la méthode Double.intValue() tronque la valeur numérique stockée de l’entier 2 et l’utilise pour initialiser n. En dernier lieu, Integer.parseInt("3A9",16) renvoie la valeur int (au format décimal) pour l’entier dont la représentation hexadécimale est 3A9, soit 3(162) + 10(16) + 9 = 937. Remarquez que l’opérateur + convertit automatiquement le nombre y en son équivalent de type chaîne lorsqu’il est combiné à une chaîne, comme c’est le cas pour l’appel System.out .println("y = " + y).
Contrôle du flux
5
1.5 CONTRÔLE DU FLUX Le langage Java supporte les instructions if, if..else et switch, ainsi que l’opérateur d’expression conditionnelle ?..:.
Exemple 1.4 Utiliser les instructions conditionnelles Le programme suivant génère des entiers aléatoires dans un intervalle de 0 à 99, puis il utilise les instructions conditionnelles afin de les classer : public class Ex0104 { public static void main(String[] args) { int n = (int)Math.round(100*Math.random()); System.out.println("n = " + n); if (n>25 && n<75) System.out.println(n + " est compris entre 25 et 75"); else System.out.println(n + " n’est pas compris entre 25 et 75"); switch ((int)n/20) { case 0: System.out.println(n + " < 20"); case 1: System.out.println(n + " < 40"); case 2: System.out.println(n + " < 60"); break; case 3: System.out.println(n + " < 80"); break; default: System.out.println(n + " >= 80"); } System.out.println(n + (n%2>0 ? " est impair" : " est pair")); } } n = 19 19 n’est pas compris entre 25 et 75 19 < 20 19 < 40 19 < 60 19 est impair
Remarquez que l’absence d’instruction break entre case 0 et case 1 permet au contrôle de traverser chacun de ces cas jusqu’à la case 2 et d’exécuter les trois instructions println(). L’expression (n%2>0 ? " est impair" : " est pair") est évaluée à la chaîne " est impair" si la condition n%2>0 est vraie (c’est-à-dire si n n’est pas divisible par 2). Dans le cas contraire, elle est évaluée à la chaîne " est pair" (lorsque n est divisible par 2). Java supporte les instructions while, do..while et for.
Exemple 1.5 Utiliser les boucles Le programme teste empiriquement le théorème des nombres premiers de Gauss : si p(n) est le nombre de nombres premiers inférieurs à n, le rapport p(n)(ln n)/n se rapproche de 1.0 lorsque n devient illimité. Il compte le nombre p(n) de nombres premiers pour chaque n impair situé dans l’intervalle 3 à 1 000 000. Au fur et à mesure de l’augmentation de p(n), chaque fois qu’un multiple de 5 000 est passé, le programme imprime les valeurs de n, p(n), lnn (le logarithme naturel) et le rapport p(n)(ln n)/n, soit un nombre proche de 1.0. public class Ex0105 { public static void main(String[] args) { System.out.println("n\tp(n)\tln(n)\t\t\tp(n)*ln(n)/n"); final String DASHES18="\t------------------"; System.out.println("------\t-----" + DASHES18 + DASHES18);
6
Caractéristiques de base du langage java
int p=1; // p = nombre de nombres premiers qui sont <= n for (int n=3; n<1000000; n += 2) { int d=3; while (d<=Math.sqrt(n) && n%d>0) d += 2; if (n%d==0) continue; ++p; if (p%5000>0) continue; double ln=Math.log(n); System.out.println(n + "\t" + p + "\t" + ln + "\t" + p*ln/n); } System.out.println("------\t-----" + DASHES18 + DASHES18); } } n -----48619 104743 163847 224743 287137 350381 414991 479939 545749 611957 679279 746777 814309 882389 951193 ------
c(n) -----5000 10000 15000 20000 25000 30000 35000 40000 45000 50000 55000 60000 65000 70000 75000 ------
ln(n) -----------------10.79176967999097 11.559265009775785 12.006688344529985 12.322712806131365 12.567714732761953 12.766776412829921 12.936012112230687 13.08141429147497 13.209914442069696 13.324417297588104 13.428787220525182 13.52352189210366 13.610095179831822 13.690388280841916 13.765472265206315 -------------------
c(n)*ln(n)/n -------------------1.109830486023054 1.1035835339617717 1.0991981859170432 1.0966048158235286 1.0942263390613152 1.093105198012728 1.091012633835611 1.0902564110418174 1.0892299388420983 1.0886726761511107 1.087304770394617 1.0865510232990836 1.0863888114819662 1.0860597533048737 1.085384795609801 ------------------
La boucle for est itérée 499 999 fois, soit pour chaque valeur de n = 3, 5, 7, 9, …, 999999. Le bloc de la boucle teste si n est un nombre premier en le divisant par d = 3, 5, 7, 9, etc. S’il trouve un nombre d qui divise n de façon à ce que le reste n%d soit égal à zéro, la première instruction continue est exécutée, en commençant par l’itération suivante de la boucle for. En revanche, si aucun diviseur de n n’est trouvé, cela signifie qu’il s’agit d’un nombre premier et le compteur p est alors incrémenté. Java supporte l’utilisation de sous-programmes appelés méthodes.
Exemple 1.6 Utiliser des méthodes Le programme suivant crée la même sortie que celui de l’exemple précédent même s’il utilise une méthode séparée pour déterminer si chaque entier n est un nombre premier. public class Ex0106 { public static void main(String[] args) { System.out.println("n\tp(n)\tln(n)\t\t\tp(n)*ln(n)/n"); final String DASHES18="\t------------------"; System.out.println("------\t-----" + DASHES18 + DASHES18); int p=1; // p = nombre de nombres premiers <= n for (int n=3; n<1000000; n += 2) if (isPrime(n)) { ++p; if (p%5000>0) continue;
7
Classes
double ln=Math.log(n); System.out.println(n + "\t" + p + "\t" + ln + "\t" + p*ln/n); } System.out.println("------\t-----" + DASHES18 + DASHES18); } private static boolean isPrime(int n) { int d=3; while (d<=Math.sqrt(n) && n%d>0) d += 2; if (n%d==0) return false; return true; } }
La méthode isPrime(s) encapsule le code qui teste si n est un nombre premier. Elle renvoie false si un diviseur d de n est trouvé, et true dans le cas contraire. Remarquez que cette méthode est déclarée comme étant privée et statique. Elle doit être privée parce qu’elle n’est pas destinée à être utilisée hors de sa classe, et statique parce qu’elle doit être appelée depuis la méthode statique main() (reportez-vous à la section 1.7).
1.6 CLASSES Une classe est une implémentation d’un type de données abstrait. Les objets sont créés grâce à l’instanciation des classes. La définition d’une classe spécifie le type de données géré par ses objets et les opérations que ces derniers peuvent effectuer. Ces spécifications correspondent aux membres de la classe. Comme illustré dans la figure, quatre types de membres peuvent commembre poser une classe. champ Un champ est une variable qui contient des données. Une méthode est méthode et une fonction qui effectue des opérations. À l’intérieur d’elle-même, une constructeur ns r classe peut également définir des classes et des interfaces locales internes. ce accesseur En outre, la figure précédente contient quatre types courants de méthot mutateur des spécialisées : les constructeurs, les accesseurs, les mutateurs et les utit utilitaire litaires. Un constructeur est une fonction qui crée les objets de la classe. Ce classe processus est qualifié d’instanciation et les objets obtenus d’instances de la interface classe. Le constructeur est également chargé d’initialiser les champs des objets qu’il crée. Un accesseur est une fonction en lecture seule qui peut renvoyer la valeur d’un champ de classe sans permettre sa modification externe. Dans le cadre de Java, un accesseur qui accède à un champ X est généralement appelé getX(). C’est la raison pour laquelle les accesseurs sont souvent qualifiés de getters. Un mutateur est en lecture-écriture et permet aux appeleurs externes de modifier la valeur d’un champ. Dans le cadre de Java, un mutateur qui modifie un champ X est généralement appelé setX(). C’est la raison pour laquelle les mutateurs sont souvent qualifiés de setters. Un utilitaire est une fonction privée utilisée de façon interne par d’autres méthodes de la classe.
Exemple 1.7 Classe Point Le programme suivant définit une classe dont les instances représentent des points d’un plan euclidien : public class Point { protected double x, y; public Point(double x, double y)
8
Caractéristiques de base du langage java
{ this.x = x; this.y = y; } public double getX() { return x; } public double getY() { return y; } public Point getLocation() { return new Point(x,y); } public void setLocation(double x, double y) { this.x = x; this.y = y; } public void translate(double dx, double dy) { x += dx; y += dy; } public boolean equals(Object object) { if (object == this) return true; if (object.getClass() != this.getClass()) return false; Point point = (Point)object; return (x == point.x && y == point.y); } public int hashCode() { return (new Double(x)).hashCode() + (new Double(y)).hashCode(); } public String toString() { return new String("(" + (float)x + "," + (float)y + ")"); } }
Cette classe a trois champs (x, y et origin), un constructeur et cinq accesseurs (getX(), getY(), getLocation(), hashCode() et toString()), et un mutateur (translate(double, double)). Le pilote test de cette classe est le suivant : public class Ex0107 { public static void main(String[] args) { Point p = new Point(2,3); System.out.println("p = " + p); System.out.println("p.hashCode() = " + p.hashCode()); Point q = p.getLocation(); compare(p,q); q.translate(5,-1); compare(p,q); q = p; compare(p,q); } private static void compare(Point p, Point q) { System.out.println("q = " + q);
Modificateurs
9
System.out.println("q.hashCode() = " + q.hashCode()); if (q.equals(p)) System.out.println("q est égal à p"); else System.out.println("q n’est pas égal à p"); if (q == p) System.out.println("q == p"); else System.out.println("q != p"); } } p = (2.0,3.0) p.hashCode() = -2146959360 q = (2.0,3.0) q.hashCode() = -2146959360 q est égal à p q != p q = (7.0,2.0) q.hashCode() = -2145648640 q n’est pas égal à p q != p q = (2.0,3.0) q.hashCode() = -2146959360 q est égal à p q == p
Ce pilote test comprend la méthode utilitaire compare(Point,Point) qui facilite la vérification. Le point p a la valeur hashCode() –2 146 959 360. Ce nombre n’a aucune signification intrinsèque, il x 2.0 y 3.0 p est simplement utilisé comme numéro d’identificadouble double Point tion et est calculé à partir des valeurs hashcode() correspondantes des deux objets de coordonnées du y 3.0 x 2.0 q point (x et y). double double Point Le point q est défini comme la copie de p. Il a donc la même valeur hashCode() et la méthode equals(Object) renvoie true. Cependant, le test q == p est évalué à false parce que p et q sont des x 7.0 y 2.0 q objets différents. En fait, l’opérateur d’égalité == double double Point teste plus l’identité que l’égalité, ce qui est vrai uniquement lorsque vous avez deux références différentes au même objet. x 2.0 y 3.0 Une fois que q est traduit à l’emplacement (7,2), la p double double Point méthode equals(Object) renvoie false. En dernier lieu, lorsque la référence p est affectée à q la référence q, l’égalité q == p est évaluée à true Point parce qu’il ne reste plus qu’un seul objet Point, soit le point d’origine (2,3). L’autre point (7,2) a été récupéré par le ramasse-miettes, c’est-à-dire qu’il a été détruit au moment où il a perdu sa variable de référence.
1.7 MODIFICATEURS Les classes, les interfaces et leurs membres peuvent être déclarés à l’aide des modificateurs public, protected, private, package, abstract, static et final. Le tableau suivant résume leur signification.
10
Caractéristiques de base du langage java
Modificateur Interface public
Classe
Classe imbriquée
Champ
Méthode
Accessible depuis n’importe quelle classe.
protected Accessible uniquement depuis cette classe et ses sous-classes. private
Accessible uniquement depuis cette classe.
abstract
Contient au moins une Sans objet méthode abstract.
final
Sans objet
static
Sans objet Sans objet
Ne peut pas être décomposée en sous-classes. N’est pas une classe interne.
Sans objet
Son implémentation n’est pas définie ; seuls sa signature et son type de renvoi sont déclarés.
Sa valeur ne peut pas être modifiée.
Ne peut pas être remplacée par une sous-classe.
Une seule instance existe pour tous les objets de la classe.
Une seule instance existe pour tous les objets de la classe.
Les modificateurs public, protected et private sont qualifiés de modificateurs d’accès parce qu’ils déterminent l’emplacement à partir duquel il est possible d’accéder à la classe ou au membre. Généralement, public signifie accessible depuis n’importe quel emplacement, protected signifie accessible uniquement depuis la classe et ses sous-classes et private signifie accessible uniquement depuis la classe elle-même. Un membre est déclaré comme abstract lorsqu’il est incomplet. C’est pourquoi une méthode abstract est une méthode dont l’implémentation n’est pas incluse. Une classe abstract comporte au moins une méthode abstract. Les interfaces sont abstract par défaut, c’est pourquoi elles n’utilisent pas de modificateur. Les champs ne peuvent pas être abstract. Une classe final ne peut pas être divisée en sous-classes (reportez-vous à la section 3.1). Un champ final est simplement une constante. Une méthode final ne peut pas être remplacée dans une sous-classe. Un champ static appartient à la classe elle-même ; il ne génère pas de copie séparée de chacune de ses instances. De la même façon, une méthode static est liée à la classe et non à ses objets. Si X est une classe avec un champ statique x et une méthode statique y(), X.x et X.y() permettent d’y accéder et sont indépendants des objets. La classe java.lang.Math (reportez-vous à la section 1.9) illustre l’utilisation des méthodes static.
1.8 CLASSE String Une chaîne (string) est un objet qui contient une séquence de caractères généralement utilisée pour traiter du texte. Java fournit une classe String qui permet la création et le traitement des chaînes. Cette classe contient plus de 50 méthodes, dont plus de 10 constructeurs. Vous trouverez les méthodes les plus couramment utilisées dans la définition de classe suivante. Bien que les chaînes soient des objets et non des types primitifs, elles sont similaires à ces derniers sur certains points. Le système reconnaît les littéraux de chaînes tels que "bleu" et "automne", de la même façon qu’il reconnaît des littéraux numériques tels que 8388608 et 3,14159. En outre, les références de chaîne peuvent être affectées à ces littéraux de la façon suivante : String couleur="bleu";
Classe String
11
Étant donné que les littéraux sont uniques, toute autre référence à "bleu" sera évaluée comme égale à la référence couleur. À l’instar des types numériques, les chaînes sont les seules classes d’objets susceptibles d’être manipulées par des opérateurs (reportez-vous à l’exemple 1.8). public final class String { public char charAt() public boolean endsWith(String suffix) public boolean equals(Object object) public int indexOf(char ch) public int indexOf(char ch, int start) public int indexOf(String str) public int indexOf(String str, int start) public int lastIndexOf(char ch) public int lastIndexOf(char ch, int start) public int lastIndexOf(String str) public int lastIndexOf(String str, int start) public int length() public String replace(char ch, char ch2) public boolean startsWith(String prefix) public boolean startsWith(String prefix, int start) public String() public String(char[] chars) public String(char[] chars, int start, int len) public String substring(int start) public String substring(int start, int stop) public char[] toCharArray() public String toLowerCase() public String toUpperCase() public String trim() }
Par ailleurs, la classe String définit les deux opérateurs de concaténation + et += illustrés dans l’exemple suivant.
Exemple 1.8 Tester la classe String public class Ex0108 { public static void main(String[] args) { String s="ABCDEFG"; System.out.println("s = \"" + s + "\""); s = s + "HIJK"; System.out.println("s = \"" + s + "\""); s += "LMNOP"; System.out.println("s = \"" + s + "\""); System.out.println("s.length() = " + s.length()); System.out.println("s.charAt(6) = " + s.charAt(6)); System.out.println("s.indexOf(’G’) = " + s.indexOf(’G’)); System.out.println("s.indexOf(’Z’) = " + s.indexOf(’Z’)); System.out.println("s.indexOf(’G’,8) = " + s.indexOf(’G’,8)); System.out.println("s.indexOf(\"GHIJ\") = " + s.indexOf("GHIJ")); if (s.startsWith("DE")) System.out.println("s.startsWith(\"DE\")"); else System.out.println("s ne commence pas par \"DE\""); if (s.startsWith("DE",3)) System.out.println("s.startsWith(\"DE\",3)"); else System.out.println("s ne commence pas par \"DE\" après 3 chars");
12
Caractéristiques de base du langage java
if (s.endsWith("IJK")) System.out.println("s.endsWith(\"IJK\")"); else System.out.println("s ne finit pas par \"IJK\""); if (s.endsWith("NOP")) System.out.println("s.endsWith(\"NOP\")"); else System.out.println("s ne finit pas par \"NOP\""); s += "DABBADABBADO"; System.out.println("s = \"" + s + "\""); s = s.replace(’B’,’T’); System.out.println("s = \"" + s + "\""); s = s.substring(7,10); System.out.println("s = \"" + s + "\""); s = s.toLowerCase(); System.out.println("s = \"" + s + "\""); s = " W XY Z "; System.out.println("s = \"" + s + "\""); System.out.println("s.length() = " + s.length()); s = s.trim(); System.out.println("s = \"" + s + "\""); System.out.println("s.length() = " + s.length()); } } s = "ABCDEFGHIJK" s = "ABCDEFGHIJKLMNOP" s.length() = 16 s.charAt(6) = G s.indexOf(’G’) = 6 s.indexOf(’Z’) = -1 s.indexOf(’G’,8) = -1 s.indexOf("GHIJ") = 6 s ne commence pas par "DE" s.startsWith("DE",3) s ne finit pas par "IJK" s.endsWith("NOP") s = "ABCDEFGHIJKLMNOPDABBADABBADO" s = "ATCDEFGHIJKLMNOPDATTADATTADO" s = "HIJ" s = "hij" s = " W XY Z " s.length() = 12 s = "W XY Z" s.length() = 7
Remarquez que les objets String sont immuables. C’est pourquoi la seule méthode de modification d’une chaîne consiste à lui affecter la valeur de renvoi d’une méthode, par exemple replace (char,char) qui permet de renvoyer une nouvelle chaîne. Avec Java, le système d’exploitation de l’ordinateur doit gérer un pool de littéraux de chaînes et s’assurer qu’il n’existe qu’une seule occurrence de tout littéral de chaîne utilisé, quel que soit l’environnement d’exécution. Ce procédé est similaire à celui qui s’applique aux littéraux numériques, à savoir qu’il n’existe qu’un seul nombre 27. C’est la raison pour laquelle l’opérateur d’égalité == fonctionne toujours « correctement » pour les chaînes.
Exemple 1.9 Tester si les littéraux String sont uniques public class Ex0109 { public static void main(String[] args)
Classe Math
13
{ String s1="ABCDEFG"; System.out.println("s1 = \"" + s1 + "\""); System.out.println("(s1 == \"ABCDEFG\") = " + (s1 == "ABCDEFG")); System.out.println("(s1 == \"ABCD\"+\"EFG\") = " + (s1 == "ABCD"+"EFG")); String s2="ABCDEFG"; // fait de s2 un synonyme de s1 System.out.println("s2 = \"" + s2 + "\""); System.out.println("(s1 == s2) = " + (s1 == s2)); s2 = new String("ABCDEFG"); // s2 fait maintenant référence à // un objet séparé System.out.println("s2 = \"" + s2 + "\""); System.out.println("(s1 == s2) = " + (s1 == s2)); System.out.println("s1.equals(s2) = " + s1.equals(s2)); } } s1 = "ABCDEFG" (s1 == "ABCDEFG") = true (s1 == "ABCD"+"EFG") = true s2 = "ABCDEFG" (s1 == s2) = true s2 = "ABCDEFG" (s1 == s2) = false s1.equals(s2) = true
Dans ce programme, vous pouvez constater qu’il n’existe qu’un seul littéral de chaîne "ABCDEFG" quel que soit son format. C’est pourquoi si deux références différentes (c’est-à-dire des références avec des noms différents) sont affectées au littéral, elles doivent être égales, c’est-à-dire que l’opérateur d’égalité == est évalué à true. Remarquez cependant que deux chaînes différentes peuvent avoir la même valeur de littéral. Le cas échéant, l’opérateur d’égalité est évalué à false, mais la méthode equals() renvoie tout de même true. Comparez ce résultat à celui de l’exemple 1.7. Les propriétés spéciales suivantes permettent de différencier la classe String de toutes les autres classes : • • • •
Les objets String sont immuables (en lecture seule) ; leur valeur ne peut pas être modifiée. Les littéraux String sont gérés dans un pool de chaînes par le système d’exploitation. La classe String définit des opérateurs spéciaux + et +=. Il est possible d’accéder à la longueur grâce à la méthode length() au lieu du champ length, comme dans le cas des tableaux, ou bien grâce à la méthode size() utilisée par les objets Collection.
1.9 CLASSE Math La classe Java Math définit les constantes et les méthodes mathématiques qui implémentent les fonctions mathématiques courantes. Sa définition dans le paquetage java.util est la suivante : public final class Math { public static final double E=2.7182818284590452354; public static final double PI=3.14159265358979323846; public static double abs(double x) // valeur absolue public static native double atan(double x) // arctangent public static native double ceil(double x) // plafond public static native double cos(double x) public static native double exp(double x) // base e
14
Caractéristiques de base du langage java
public public public public public public public public public public
static static static static static static static static static static
native double floor(double x) native double log(double x) // base e native double max(double x, double y) native double min(double x, double y) native double pow(double x, double y) // puissance synchronized double random() long round(double x) native double sin(double x) native double sqrt(double x) // racine carrée native double tan(double x)
}
Remarquez que tous ces membres sont static. Cela signifie qu’ils sont appelés à l’aide du préfixe Math au lieu de l’objet Math.
Exemple 1.10 Tester la classe Math public class Ex0110 { public static void main(String[] args) { final double PI=Math.PI; final double E=Math.E; System.out.println("E = " + E); System.out.println("Math.exp(1.0) = " + Math.exp(1.0)); System.out.println("PI = " + PI); System.out.println("4*Math.atan(1.0) = " + 4*Math.atan(1.0)); System.out.println("Math.cos(2*PI) = " + Math.cos(2*PI)); System.out.println("Math.sin(PI/2) = " + Math.sin(PI/2)); System.out.println("Math.tan(PI/4) = " + Math.tan(PI/4)); System.out.println("Math.log(E) = " + Math.log(E)); System.out.println("Math.abs(-13.579) = " + Math.abs (-1.579)); System.out.println("Math.floor(13.579) = " + Math.floor(13.579)); System.out.println("Math.ceil(13.579) = " + Math.ceil(13.579)); System.out.println("Math.round(13.579) = " + Math.round(13.579)); System.out.println("Math.pow(25.0,0.5) = " + Math.pow(25.0,0.5)); System.out.println("Math.sqrt(25.0) = " + Math.sqrt(25.0)); System.out.println("Math.random() = " + Math.random()); System.out.println("Math.random() = " + Math.random()); } } E = 2.718281828459045 Math.exp(1.0) = 2.7182818284590455 PI = 3.141592653589793 4*Math.atan(1.0) = 3.141592653589793 Math.cos(2*PI) = 1.0 Math.sin(PI/2) = 1.0 Math.tan(PI/4) = 0.9999999999999999 Math.log(E) = 1.0 Math.abs(-13.579) = 13.579 Math.floor(13.579) = 13.0 Math.ceil(13.579) = 14.0 Math.round(13.579) = 14 Math.pow(25.0,0.5) = 5.0 Math.sqrt(25.0) = 5.0 Math.random() = 0.9279776738566742 Math.random() = 0.4493770111566855
Notez l’erreur d’arrondi dans le calcul de Math.tan(PI/4) ; la valeur correcte serait très exactement 1.0.
15
Classe Math
Notez également que la méthode Math.round(double) renvoie un entier long au lieu d’un nombre décimal à virgule flottante double comme toutes les autres méthodes Math. La méthode Math.random() renvoie des nombres décimaux à virgule flottante de type double générés de façon aléatoire et distribués uniformément dans un intervalle de 0.0 à 1.0. L’exemple suivant permet de tester cette distribution.
Exemple 1.11 Tester la méthode Math.random() Ce programme génère 50 000 nombres aléatoires dans un intervalle de 0.0 à 1.0, puis il compte combien d’entre eux entrent dans les 100 sous-intervalles divisés de façon égale selon une longueur de 0.01. Il utilise un tableau frequency[] pour cumuler ces comptes. Par exemple, le nombre aléatoire x= 0.7294416115902632 est situé dans l’intervalle 0.72 x < 0.73, c’est pourquoi il est compté en incrémentant frequency[72]. public class Ex0111 { public static void main(String[] args) { final int SUBINTERVALS=100; final int TOTAL=50000; int[] frequency = new int[SUBINTERVALS]; for (int k=0; k<SUBINTERVALS; k++) frequency[k] = 0; for (int j=0; j
506 529 499 480 527 503 533 504 489 501
515 483 477 566 482 497 499 507 511 522
454 504 498 487 506 483 488 478 512 468
495 476 500 512 498 522 511 512 464 524
512 496 498 485 475 468 468 521 487 505
502 471 517 489 519 505 506 484 499 508
542 524 482 461 517 510 476 479 506 533
506 508 438 524 466 488 510 480 497 530
514 489 492 504 509 532 498 487 526 504
Si les 50 000 nombres étaient distribués uniformément et exactement, nous devrions en avoir 500 dans chaque sous-intervalle. Étant donné que ces 100 comptes de fréquence sont proches de 500, nous obtenons la preuve empirique que la méthode Math.random() crée effectivement une distribution uniforme.
Exemple 1.12 Tester la méthode Math.sqrt() Ce programme teste la méthode Math.sqrt() en comparant ses résultats à une méthode de racine carrée définie localement et en mettant au carré sa sortie. public class Ex0112 { public static void main(String[] args)
16
Caractéristiques de base du langage java
{ for (double x=50.0; x<60.0; x++) { double y=Math.sqrt(x); double z=sqrt(x); System.out.println(y + "\t" + y*y); System.out.println(z); } } private static double sqrt(double x) { final double EPSILON=1E-14; if (x <= 0) return 0.0; double y=1.0; while (Math.abs(y*y-x) > EPSILON) y = (y+x/y)/2; return y; } } 7.0710678118654755 7.0710678118654755 7.14142842854285 7.14142842854285 7.211102550927978 7.211102550927979 7.280109889280518 7.280109889280518 7.3484692283495345 7.3484692283495345 7.416198487095663 7.416198487095663 7.483314773547883 7.483314773547883 7.54983443527075 7.54983443527075 7.615773105863909 7.615773105863909 7.681145747868608 7.681145747868609
?
50.00000000000001 51.00000000000001 51.99999999999999 53.0 54.0 55.0 56.0 57.0 58.00000000000001 58.99999999999999
QUESTIONS DE RÉVISION
QUESTIONS DE RÉVISION
1.1
Pourquoi le langage de programmation Java est-il qualifié d’orienté objet ?
1.2
Quels sont les éléments nécessaires à l’écriture d’un programme Java ?
1.3
Qu’est-ce qu’un type primitif ?
1.4
Qu’est-ce qu’un littéral ?
1.5
Quels sont les trois littéraux non numériques des deux types à virgule flottante ?
1.6
Qu’est-ce qu’une référence d’objet ?
1.7
Qu’est-ce qu’un objet ?
1.8
Qu’est-ce qu’une classe ?
1.9
Qu’est-ce qu’une instance ?
Révision et entraînement
17
1.10 Qu’est-ce qu’un champ ? 1.11 Qu’est-ce qu’une méthode ? 1.12 Qu’est-ce que la signature d’une méthode ? 1.13 Qu’est-ce qu’un constructeur ? 1.14 Qu’est-ce qu’un membre static ? 1.15 Qu’est-ce qu’un modificateur d’accès ? 1.16 Que signifie private ? 1.17 Que signifie protected ? 1.18 Qu’est-ce qu’une classe enveloppe ? 1.19 Qu’est-ce qui différencie la classe String de toutes les autres classes de la bibliothèque Java
standard ? 1.20 À quoi sert la méthode Math.random() ?
¿
RÉPONSES
RÉPONSES
1.1
Java est qualifié de langage de programmation orienté objet parce que toutes les données et les opérations sont encapsulées dans des objets. En outre, les classes des objets sont définies dans une hiérarchie d’héritage unique.
1.2
Un programme Java doit contenir une classe public définie dans un fichier appelé X.java, X correspondant au nom de la classe. Cette classe doit comprendre une méthode déclarée de la façon suivante : • public static void main(String[] args)
1.3
Les types primitifs sont les suivants : boolean, char, byte, short, int, long, float ou double.
1.4
Un littéral est une constante anonyme ; un symbole qui représente une valeur constante du type. Par exemple, 4, 3.14 et "France" sont des littéraux.
1.5
Les trois littéraux non numériques des deux types à virgule flottante sont NEGATIVE_INFINITY, POSITIVE_INFINITY et NaN.
1.6
Une référence d’objet est une variable dont la valeur est null ou correspond à l’adresse d’un objet.
1.7
Un objet est un bloc contigu de stockage mémoire typé par une classe et auquel vous accédez via une variable de référence pour cette classe. Il peut être composé de plusieurs classes qui contiennent des données et peut avoir des méthodes qui effectuent des opérations.
1.8
Une classe est une méthodologie de création d’objets. La définition d’une classe spécifie les champs et les méthodes de chacune de ses instances.
1.9
Une instance de classe est un objet du type de cette classe. Le processus de création d’une instance est qualifié d’instanciation de la classe.
1.10 Un champ est une variable membre d’une classe. Lorsque la classe est instanciée, les champs
obtenus pour l’objet contiennent les données de ce dernier. 1.11 Une méthode est une fonction membre d’une classe. Lorsque cette dernière est instanciée, les
méthodes de l’objet obtenu effectuent certaines opérations pour ce dernier.
18
Caractéristiques de base du langage java
1.12 La signature d’une méthode est la partie de sa définition nécessaire à la compilation des instructions qui l’appellent. Par exemple, la signature de la méthode main() est la suivante : • main(String[])
1.13 Un constructeur est une méthode qui instancie sa classe. Il porte le même nom que la classe ellemême et n’a aucun type de renvoi. Il est appelé par l’opérateur new. 1.14 Un membre static s’applique à toute la classe et non à une instance. Il est appelé à l’aide du nom de la classe au lieu de celui de l’instance. Par exemple, la méthode sqrt(double) de la classe Math est statique. Elle est appelée sous la forme Math.sqrt(). Un champ static n’a
qu’une valeur de donnée quel que soit le nombre d’objets existant dans la classe, même s’il n’y en a aucun. 1.15 Un modificateur d’accès est l’un des trois mots-clés Java public, protected ou private qui
permettent de modifier une classe, un champ, une méthode ou une interface lorsque ces éléments sont définis. 1.16 Le modificateur d’accès private signifie que l’entité définie sera accessible uniquement à par-
tir de la classe dans laquelle elle est définie. 1.17 Le modificateur d’accès protected signifie que l’entité définie sera accessible uniquement
depuis la classe dans laquelle elle est définie ou depuis les sous-classes qui en découlent. 1.18 Une classe enveloppe a pour but de fournir des constantes et des méthodes destinées au traitement de l’un des huit types primitifs. Par exemple, la classe Double est une classe enveloppe du type primitif double. 1.19 La classe String se distingue des autres classes parce que ses instances sont immuables (en lecture seule), que ses littéraux sont uniques et qu’elle a deux opérateurs + et += destinés à la
concaténation. 1.20 La méthode Math.random() renvoie des nombres aléatoires à virgule flottante de type double
qui sont distribués uniformément dans un intervalle de 0.0 à 1.0.
?
EXERCICES D’ENTRAÎNEMENT
EXERCICES D’ENTRAÎNEMENT
1.1
Écrivez et testez la méthode suivante : • public static String monthName(int month) • // condition préalable : 0 < n et n < 13 • // exemple : monthName(3) renvoie "Mars"
1.2
Écrivez et testez la méthode suivante : • public static int daysInMonth(int month, int year) • // condition préalable : 0 < n et n < 13 • // exemple : daysInMonth(2,2000) renvoie 29
1.3
Écrivez et testez la méthode suivante : • public static int numberOfDigits(int n) • // exemple : numDigits(-8036800) renvoie 7
1.4
Écrivez et testez la méthode suivante : • public static int sumOfDigits(int n) • // exemple : sumOfDigits(-8036800) renvoie 25
Révision et entraînement
1.5
19
Écrivez et testez la méthode suivante : • public static int reverseDigits(int n) • // exemple : reverseDigits(-8036800) renvoie 572038
1.6
Écrivez et testez la méthode suivante : • public static double round(double x, int precision) • // exemple : round(803.505692,4) renvoie 803.1057
1.7
Écrivez et testez la méthode suivante : • public static String signedToBinary(int n) • // examples : signedToBinary( 1289) renvoie "010100001001" • // signedToBinary(-1289) renvoie "101011110111"
1.8
Écrivez et testez la méthode suivante : • public static String unsignedToBinary(int n) • // condition préalable : n >= 0 • // exemple : unsignedToBinary(1289) renvoie "10100001001"
1.9
Écrivez et testez la méthode suivante : • public static int binaryToSigned(String code) • // conditions préalables : chaque caractère du code est ’0’ ou ’1’; • code.length <= 32 • // exemple : binaryToSigned("010100001001") renvoie 1289 • // binaryToSigned("101011110111") renvoie -1289
1.10 Écrivez et testez la méthode suivante : • public static int binaryToUnsigned(String code) • // conditions préalables : chaque caractère du code est ’0’ ou ’1’; • code.length <= 32 • // exemple : binaryToUnsigned("010100001001") renvoie 1289 • // binaryToUnsigned("101011110111") renvoie 2807
1.11 Écrivez et testez la méthode suivante : • public static String format(String s, int len, int d) • // exemples : format("tomate",9,d) pour d = -1,0, 1 renvoie • // respectivement : • // "tomate " , " tomate " , " tomate"
1.12 Écrivez et testez la méthode suivante : • public static int randomInt(int start, int stop) • // conditions préalables : début < arrêt; • // renvoie des entiers distribués uniformément • // dans l’intervalle début à fin -1 (inclus)
1.13 Implémentez ce constructeur par défaut pour la classe Point (reportez-vous à l’exemple 1.7) : • public Point() • // construit un point représentant l’origine (0,0)
1.14 Ajoutez un objet public static final à la classe Point afin de représenter l’origine (0,0). 1.15 Implémentez le constructeur de copie suivant pour la classe Point (reportez-vous à l’exem-
ple 1.7) : • public Point(Point point) • // construit un point ayant les mêmes coordonnées que • // le point donné
20
Caractéristiques de base du langage java
1.16 Implémentez la méthode suivante pour la classe Point (reportez-vous à l’exemple 1.7) : • public double distance(Point point) • // renvoie la distance euclidienne de ce point au point donné
1.17 Implémentez la méthode suivante pour la classe Point (reportez-vous à l’exemple 1.7) : • public double magnitude() • // renvoie la distance euclidienne de ce point à l’origine
1.18 Implémentez la méthode suivante pour la classe Point (reportez-vous à l’exemple 1.7) : • public double amplitude() • // renvoie la mesure en radians de l’angle polaire du point
1.19 Implémentez le constructeur suivant pour la classe Point (reportez-vous à l’exemple 1.7) : • public void setPolar(double r, double theta) • // déplace ce point vers les coordonnées polaires données (r,theta) 1.20 Implémentez la méthode suivante pour la classe Point (reportez-vous à l’exemple 1.7) : • public static Point polar(double r, double theta) • // renvoie le point dont les coordonnées polaires sont (r,theta)
1.21 Implémentez la méthode suivante pour la classe Point (reportez-vous à l’exemple 1.7) : • public void expand(double dr) • // développe ce point par le facteur dr
1.22 Implémentez la méthode suivante pour la classe Point (reportez-vous à l’exemple 1.7) : • public void rotate(double theta) • // fait pivoter le point dans le sens des aiguilles d’une montre • // par theta radians
¿
SOLUTIONS
SOLUTIONS
1.1
• public static String monthName(int month) • { switch (month) • { case 1: return "Janvier"; • case 2: return "Février"; • case 3: return "Mars"; • case 4: return "Avril"; • case 5: return "Mai"; • case 6: return "Juin"; • case 7: return "Juillet"; • case 8: return "Août"; • case 9: return "Septembre"; • case 10: return "Octobre"; • case 11: return "Novembre"; • case 12: return "Décembre"; • } • return ""; •}
1.2
• public static int daysInMonth(int month, int year) • { if (month==4 || month==6 || month==9 || month==11) return 30; • if (month==2)
Révision et entraînement • if (year%400==0 || year%100!=0 && year%4==0) return 29; • else return 28; • return 31; •}
1.3
• public static int numberOfDigits(int n) • { if (n<0) n = -n; • int count=0; • while (n>0) • { n /= 10; • ++count; • } • return count; •}
1.4
• public static int sumOfDigits(int n) • { if (n<0) n = -n; • int sum=0; • while (n>0) • { sum += n%10; • n /= 10; • } • return sum; •}
1.5
• public static int reverseDigits(int n) • { if (n==0) return 0; • int sign = (n<0?-1:1); • if (n<0) n = -n; • int reverse=0; • while (n>0) • { reverse = 10*reverse + n%10; • n /= 10; • } • return sign*reverse; •}
1.6
• public static double round(double x, int precision) • { double pow10 = Math.pow(10,precision); • return Math.round(x*pow10)/pow10; •}
1.7
• public static String signedToBinary(int n) • { if (n==0) return "0"; • if (n>0) return "0" + unsignedToBinary(n); // exercice 1.8 • int mod=1; • while(mod+2*n<0) • mod *= 2; • return unsignedToBinary(mod+n); •}
1.8
• public static String unsignedToBinary(int n) • { // Condition préalable : n > 0 • String code=""; • while (n > 0) • { code = "" + (n%2) + code; // ajouter le bit suivant • // à gauche du code • n /= 2; // supprimer le bit courant le moins significatif • } • return code ; •}
21
22 1.9
Caractéristiques de base du langage java • public static int binaryToSigned(String code) • { int len = code.length(); • int unsigned = binaryToUnsigned(code); // exercice 1.10 • if (code.charAt(0) == ’0’) return unsigned; • return unsigned - (int)Math.pow(2,len); •}
1.10 • public static int binaryToUnsigned(String code) • { int n = code.length(); • int answer = 0; • for (int i=0; i
0)? spaces : spaces/2); • int rightSpaces = spaces - leftSpaces; • for (int i=0; i
1.14 Voici un exemple de champ public static final pour la classe Point : •public static final Point ORIGIN = new Point();
1.15 Voici un exemple de constructeur de copie pour la classe Point : • public Point(Point q) • { this.x = q.x; • this.y = q.y; }
1.16 Voici un exemple de méthode de distance pour la classe Point : • public double distance(Point point) • { double dx = this.x - point.x; • double dy = this.y - point.y; • return Math.sqrt(dx*dx+dy*dy); •}
1.17 Voici un exemple de méthode de grandeur pour la classe Point : • public double magnitude() • { return distance(ORIGIN); •}
Révision et entraînement
1.18 Voici un exemple de méthode d’amplitude pour la classe Point : • public double amplitude() • { return Math.atan(y/x); •}
1.19 Voici un exemple de méthode de la classe Point pour les coordonnées polaires : • public void setPolar(double r, double theta) • { this.x = r*Math.cos(theta); • this.y = r*Math.sin(theta); •}
1.20 Voici un autre exemple de méthode de la classe Point pour les coordonnées polaires : • public static Point polar(double r, double theta) • { double x = r*Math.cos(theta); • double y = r*Math.sin(theta); • return new Point(x,y); •}
1.21 Voici un exemple de méthode de développement pour la classe Point : • public void expand(double dr) • { x *= dr; • y *= dr; •}
1.22 Voici un exemple de méthode de rotation pour la classe Point : • public void rotate(double theta) • { double xx = x; • double yy = y; • double sin = Math.sin(theta); • double cos = Math.cos(theta); • x = xx*cos - yy*sin; • y = xx*sin + yy*cos; •}
23
Chapitre 2
Caractéristiques de base des tableaux Un tableau est un objet composé d’une séquence d’éléments numérotés de même type. Ces éléments sont numérotés à partir de 0 et peuvent être référencés par leur numéro grâce à l’opérateur d’index []. Les tableaux sont beaucoup utilisés en raison de leur efficacité.
2.1 PROPRIÉTÉS DES TABLEAUX Dans le cadre du langage de programmation Java, les tableaux présentent les propriétés suivantes : 1. 2. 3. 4. 5. 6. 7. 8. 9. 10. 11. 12. 13. 14. 15. 16. 17. 18.
Les tableaux sont des objets. Ils sont créés dynamiquement (au moment de l’exécution). Ils peuvent être affectés à des variables de type Object. Toutes les méthodes de la classe Object peuvent être appelées sur un tableau. Un objet tableau contient une séquence de variables qui sont toutes du même type. Les variables sont qualifiées de composants du tableau. Si le type de composant est T, le tableau a également le type T[]. Une variable de type tableau contient une référence à l’objet tableau. Le type de composant peut également avoir le type tableau. Un élément de tableau est un composant dont le type n’est pas un tableau. Un type d’élément peut être primitif ou de référence. La longueur d’un tableau correspond à son nombre de composants. La longueur d’un tableau est paramétrée lors de sa création et ne peut pas être modifiée par la suite. Pour être accessible, la longueur d’un tableau doit être définie comme une variable d’instance public final. Les tableaux doivent être indexés par des valeurs intégrales dans un intervalle de 0..length –1. Une exception ArrayIndexOutOfBoundsException est lancée si la propriété 15 n’est pas respectée. Les variables de type short, byte ou char peuvent être utilisées comme index. Les tableaux peuvent être copiés à l’aide de la méthode Object.clone().
26
Caractéristiques de base des tableaux
19. Il est possible de tester l’égalité des tableaux à l’aide de la méthode Arrays.equals(). 20. Les objets tableau implémentent Cloneable et java.io.Serializable. La propriété 3 découle de la propriété 1. Bien que le type tableau ne soit pas une classe, il se comporte comme une extension de la classe Object. La propriété 7 démontre que le type tableau est différent du type classe (reportez-vous à la première figure du chapitre 1). En fait, il s’agit d’un type dérivé : pour chaque type de classe T, il existe un type de tableau correspondant T[]. En outre, pour chacun des huit types primitifs, il existe un type de tableau correspondant. La propriété 9 permet la création de tableaux de tableaux. Techniquement parlant, Java autorise les tableaux multidimensionnels uniquement pour les types primitifs. Cependant, dans le cas des objets, il n’y a pas énormément de différence entre des objets et un tableau de tableaux. En effet, les tableaux étant eux-mêmes des objets, un tableau de tableaux est, par définition, un tableau d’objets. En outre, certains objets composants peuvent également être différents d’un tableau (reportez-vous à l’exemple 2.1). En raison de la propriété 13, l’affectation de null à une valeur de composant de référence n’affecte en rien la longueur du tableau ; null reste une valeur valide pour le composant de référence.
Exemple 2.1 Quelques définitions de tableaux Vous trouverez ci-après des exemples de définitions de tableaux : public class Ex0201 { public static void main(String[] args) { float x[]; x = new float[100]; args = new String[10]; boolean[] isPrime = new boolean[1000]; int fib[] = { 0, 1, 1, 2, 3, 5, 8, 13 }; short[][][] b = new short[3][8][5]; double a[][] = { {1.1,2.2}, {3.3,4.4}, null, {5.5,6.6}, null }; a[4] = new double[66]; a[4][65] = 3.14; Object[] objects = { x, args, isPrime, fib, b, a }; } }
La première ligne déclare x[] comme tableau de floats mais n’alloue aucun de ces derniers. La deuxième ligne définit x[] comme ayant 100 composants de type float. La troisième ligne déclare args[] comme tableau d’objets String. Remarquez les deux méthodes (équivalentes) de déclaration d’un tableau : les crochets peuvent être le suffixe de l’identificateur de type ou bien de celui du tableau. La quatrième ligne définit args[] avec 10 composants String. La cinquième ligne définit isPrime[] comme tableau de 1 000 variables boolean. La sixième ligne définit fib[] comme tableau de 8 int initialisés aux 8 valeurs listées. Ainsi, fib[4] a la valeur 3 et fib[7] a la valeur 13. La huitième ligne définit a[][] comme tableau de cinq composants, chacun d’entre eux étant un tableau d’éléments de type double. Seuls trois des cinq tableaux composants sont alloués. La ligne suivante alloue un tableau de 66 éléments de type double à a[4] et la dernière ligne affecte 3.14 à son dernier élément. La dernière ligne définit le tableau objects avec six composants, chacun d’entre eux étant un tableau. Les composants des quatre premiers tableaux composants sont des éléments, c’est-à-dire qu’ils ne sont pas des tableaux. Mais les composants b et a ne sont pas des éléments parce qu’ils sont également des tableaux. Les éléments du tableau objects comprennent 2, 5 et 13 (composants du composant fib), null (composant du composant a), ainsi que 2.2 et 3.14 (composants des composants du composant a).
Copier un tableau
27
Le tableau a[][] défini dans l’exemple 2.1 est qualifié de tableau extensif parce qu’il s’agit d’un tableau bidimensionnel composé de lignes de différentes longueurs. Dans le cadre de Java, l’élément d’un tableau peut être de type primitif, ou bien de type tableau ou référence. Les tableaux les plus simples sont bien évidemment ceux qui sont composés d’éléments de type primitif, par exemple x[], isPrime[] et fib[] dans l’exemple 2.1. Ces tableaux présentent l’avantage de pouvoir être triés.
2.2 COPIER UN TABLEAU Étant donné qu’un tableau est un objet, il peut être copié en appelant la méthode Object.clone(), comme illustré dans l’exemple suivant.
Exemple 2.2 Copier un tableau public class Ex0202 { public static void main(String[] args) { int[] a = { 22, 44, 66, 88 }; print(a); int[] b = (int[])a.clone(); // copier a[] dans b[] print(b); String[] c = { "AB", "CD", "EF" }; print(c); String[] d = (String[])c.clone(); // copier c[] dans d[] print(d); c[1] = "XYZ"; // modifier c[], mais pas d[] print(c); print(d); } public static void print(int[] a) { for (int i=0; i
44 66 88 44 66 88 CD EF CD EF XYZ EF CD EF
Le tableau a[] contient quatre éléments int. Le tableau b[] est une copie de a[]. De la même façon, le tableau d[] est une copie du tableau c[], chacun d’entre eux contenant trois éléments String. Dans les deux cas, la copie est obtenue en appelant la méthode clone(). Étant donné que cette méthode renvoie une référence à un Object, le transtypage doit être effectué vers le type du tableau copié, c’est-à-dire int[] ou String[].
28
Caractéristiques de base des tableaux
La dernière partie de l’exemple illustre le fait que le tableau cloné d[] soit une copie séparée de c[] : la modification de c[1] en "XYZ" n’affecte en aucun cas la valeur "CD" de d[1].
2.3 CLASSE Arrays La classe java.util.Arrays définit les méthodes suivantes : public public public public public
static static static static static
List asList(Object[]) int binarySearch(...) boolean equals(...) void fill(...) void sort(...)
Dans le cas présent, les points de suspension entre parenthèses indiquent que la méthode est surchargée pour différents types de paramètre, à la fois primitifs et de référence. Par exemple, il existe neuf versions de la méthode binarySearch(…) : une pour le tableau de chaque type primitif à l’exception du type boolean, et deux pour les tableaux de type Object[]. Notez que toutes les méthodes de la classe Arrays sont static et sont donc appelées à l’aide du préfixe Arrays au lieu du nom de l’instance de classe. En fait, il n’est pas possible d’instancier la classe Arrays parce que son constructeur est déclaré comme private.
Exemple 2.3 Tester les méthodes de la classe Arrays import java.util.Arrays; public class Ex0203 { public static void main(String[] args) { char[] a = new char[64]; Arrays.fill(a,’H’); String s = new String(a); System.out.println("s = \"" + s + "\""); Object[] objects = new Object[8]; Arrays.fill(objects,2,5,"Java"); System.out.println("objects = " + Arrays.asList(objects)); int[] x = { 77, 44, 99, 88, 22, 33, 66, 55 }; int[] y = (int[])x.clone(); System.out.print("x = "); print(x); System.out.print("y = "); print(y); System.out.println("Arrays.equals(x,y) = " + Arrays.equals(x,y)); System.out.println("y.equals(x) = " + y.equals(x)); y[4] = 0; System.out.print("y = "); print(y); System.out.println("Arrays.equals(x,y) = " + Arrays.equals(x,y)); System.out.print("x = "); print(x); Arrays.sort(x); System.out.print("x = "); print(x); int i = Arrays.binarySearch(x,44); System.out.println("Arrays.binarySearch(x,44) = " + i); i = Arrays.binarySearch(x,47); System.out.println("Arrays.binarySearch(x,47) = " + i); } private static void print(int[] a) { System.out.print("{ " + a[0]); for (int i=1; i
Classe Arrays
29
s = "HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH" objects = [null, null, Java, Java, Java, null, null, null] x = { 77, 44, 99, 88, 22, 33, 66, 55 } y = { 77, 44, 99, 88, 22, 33, 66, 55 } Arrays.equals(x,y) = true y.equals(x) = false y = { 77, 44, 99, 88, 0, 33, 66, 55 } Arrays.equals(x,y) = false x = { 77, 44, 99, 88, 22, 33, 66, 55 } x = { 22, 33, 44, 55, 66, 77, 88, 99 } Arrays.binarySearch(x,44) = 2 Arrays.binarySearch(x,47) = -4
Les trois premières lignes construisent un objet String avec plusieurs instances d’un même caractère. Nous définissons d’abord a comme tableau composé de 64 char. Nous utilisons ensuite la méthode Arrays.fill() pour le remplir à l’aide du caractère voulu, ’H’ dans le cas présent. Nous utilisons ensuite le constructeur String approprié de façon à créer un objet String avec le même contenu. Puis, nous construisons un tableau appelé Object et composé de huit références. Nous entrons les éléments indexés de 2 à 4 avec l’objet "Java" de type String. Remarquez que les paramètres entiers 2 et 5 sont utilisés pour indiquer le sous-intervalle de valeurs à remplir. Le premier entier, 2, est l’index de départ, et le second est l’index du premier élément suivant qui ne sera pas modifié. Ce protocole de définition des sous-intervalles est souvent utilisé dans les bibliothèques standard. C’est pourquoi le nombre d’éléments du sous-intervalle est toujours égal à la différence entre les deux paramètres, dans le cas présent 5 – 2 = 3 éléments ont été modifiés. Il n’existe pas de méthode toString() pour les tableaux. Contrairement à d’autres objets, ils peuvent donc être imprimés directement. Cependant, la méthode Arrays.toList() crée un objet List qui peut être passé à la méthode System.out.println() parce qu’il a une méthode toString(). Il s’agit là d’un procédé simple permettant d’imprimer un tableau d’objets. La partie suivante de l’exemple crée deux tableaux int, x et y, en utilisant la méthode Object .clone() pour copier x dans y (reportez-vous à la section 2.2). Les méthodes Arrays.equals() et Object.equals() sont ensuite appelées afin de vérifier l’égalité. Remarquez que seule la première de ces méthodes renvoie la bonne réponse. Nous modifions ensuite y[4] de façon à vérifier si la méthode Arrays.equals() fonctionne correctement lorsque les deux tableaux ne sont pas égaux. La méthode Arrays.sort() permet de trier x. Nous pouvons ensuite utiliser la méthode Arrays .binarySearch() pour rechercher un int donné dans x. Si cet élément se trouve dans le tableau, la méthode renvoie son index, c’est-à-dire 2 dans notre exemple. Dans le cas contraire, la méthode renvoie un entier négatif afin de signaler que l’élément ne se trouve pas dans le tableau. L’algorithme de recherche binaire générique est défini dans la section 2.5. Remarquez que la méthode Arrays.binarySearch() ne fonctionnera pas correctement si le tableau n’a pas été trié auparavant. La méthode Arrays.equals() est destinée à être utilisée à la place de la méthode Object .equals() pour les tableaux. Cette deuxième méthode renvoie true uniquement lorsque les deux références concernent le même objet, c’est-à-dire que l’identité est testée au lieu de l’égalité. Afin d’illustrer plus en détail les algorithmes de tableaux présentés dans ce livre, nous allons maintenant définir une autre classe Arrays. Pour éviter un conflit de nom, cette classe sera définie dans un paquetage séparé appelé schaums.dswj.
30
Caractéristiques de base des tableaux
Exemple 2.4 Classe utilitaire Arrays package schaums.dswj; import java.util.Random; public class Arrays { private static Random random = new Random(); public static int load(int start, int range) { return random.nextInt(range) + start; } public static void load(int[] a, int start, int range) { int n=a.length; for (int i=0; i9?"":" ") + i); System.out.print("\n{ " + a[0]); for (int i=1; i
Initialement, cette classe test n’a que trois méthodes : deux méthodes load() permettant d’initialiser les variables et les tableaux à l’aide d’entiers aléatoires et une méthode print() permettant d’imprimer des tableaux d’entiers. Les méthodes load() limitent les valeurs entières du début de l’intervalle à load –1. Cela facilite la création des valeurs copiées. La méthode print() annote la liste du tableau à l’aide d’une ligne des numéros d’index afin de faciliter la recherche de chaque élément indexé. Le pilote test suivant illustre le fonctionnement de cette classe : import schaums.dswj.Arrays; public class Testing { private static final private static final private static final private static int[]
int int int a =
SIZE = 16; START = 40; RANGE = 20; new int[SIZE];
public static void main(String[] args) { Arrays.load(a,START,RANGE); Arrays.print(a); Arrays.load(a,START,RANGE); Arrays.print(a); } } 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 { 49, 56, 46, 43, 40, 57, 47, 43, 43, 43, 46, 57, 47, 53, 44, 46 } 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 { 58, 55, 40, 45, 56, 46, 59, 59, 57, 45, 46, 42, 52, 47, 42, 54 }
31
Algorithme de recherche séquentielle
Ce programme génère deux tableaux aléatoires composés de 16 éléments chacun. Ces éléments se trouvent dans l’intervalle 40 à 59 et sont donc susceptibles d’avoir des copies. Par exemple, dans le premier tableau, a[2] = a[10] = 46.
2.4 ALGORITHME DE RECHERCHE SÉQUENTIELLE La recherche séquentielle (également appelée recherche linéaire) est l’algorithme de recherche le plus simple, mais également le moins efficace. Cet algorithme étudie simplement chaque élément de façon séquentielle. Il commence donc par le premier élément et continue jusqu’à ce qu’il trouve l’élément clé ou qu’il atteigne la fin du tableau. Par exemple, si vous cherchez quelqu’un dans un train en marche, vous utilisez une recherche séquentielle, comme illustré dans la figure suivante : ? 0
? 1
? 3
? 4
5
6
7
8
9
Algorithme 2.1 Recherche séquentielle (Condition préalable : s = {s0, s1, s2, . . ., sn – 1} est une séquence de n valeurs ordinales du même type que x.) (Condition postérieure : l’index i est renvoyé quand si = x, ou –1 est renvoyé.) 1. 2. 3. 4.
Effectuez les étapes 2 et 3 n fois pour i = 0 jusqu’à n – 1. (Invariant : aucun des éléments de la séquence suivante {s0..si – 1} n’est égal à x.) Si si = x, renvoyer i. Renvoyer –1.
Dans l’algorithme 2.1 (et dans tous les algorithmes de ce livre), nous préciserons les conditions préalables et postérieures afin d’indiquer très exactement ce que fait l’algorithme. En outre, nous utiliserons des invariants de boucle afin de démontrer l’exactitude de l’algorithme. Nous proposerons également un exemple Java de mise en pratique de chaque algorithme.
Exemple 2.5 Recherche séquentielle public static int sequentialSearch(int[] a, int x) { // Conditions postérieures : renvoie i; si i >= 0, alors a[i] == x; // sinon i == -1; for (int i=0; i
✽ Théorème 2.1 : la recherche séquentielle est correcte. Démonstration : si n = 0, la séquence est vide et la boucle n’est pas du tout exécutée. Seule l’étape 4 est exécutée et elle renvoie –1 immédiatement. Cela respecte les conditions postérieures puisque x ne peut pas être égal à l’un des éléments parce qu’il n’y en a aucun. Si n = 1, la boucle n’est itérée qu’une seule fois, avec i = 0. Lors de cette itération, s0 = x ou s0 ≠ x. Si s0 = x, 0 est renvoyé et la condition postérieure est respectée. Si s0 ≠ x, la boucle s’arrête, l’étape 4 est exécutée et –1 est renvoyé, ce qui respecte la condition postérieure puisque le seul élément de la séquence n’est pas égal à x.
32
Caractéristiques de base des tableaux
Supposons que n > 1. Lors de la première itération de la boucle, i = 0 et l’invariant de boucle de l’étape 2 est vide et donc true parce que la sous-séquence {s0..si – 1} est vide. Ensuite, au cours de l’étape 3, s0 = x ou s0 ≠ x. Si s0 = x, 0 est renvoyé et la condition postérieure est respectée. Si s0 ≠ x, la boucle continue. S’il y a une deuxième itération (c’est-à-dire, si s0 ≠ x), i = 1 et l’invariant de boucle de l’étape 2 est encore true parce que la sous-séquence {s0..si – 1} = {s0} et que s0 ≠ x. Supposons que, lors de la kième itération de la boucle, l’invariant de cette dernière soit true, c’està-dire qu’aucun des éléments de la sous-séquence {s0..sk – 1} soit égal à x. Au cours de cette itération, à l’étape 3, si = x ou si ≠ x. Si si = x, k est renvoyé et la condition postérieure est respectée. Si si ≠ x, la boucle continue. D’après le principe d’induction mathématique (reportez-vous à la section A.4), après chaque itération de boucle, l’algorithme se termine par une condition postérieure true ou bien c’est l’invariant de boucle de l’itération suivante qui sera true. Ainsi, si l’algorithme ne se termine au cours d’aucune itération, l’invariant de boucle du cas i = n sera true après la dernière itération, lorsque i = n –1. En effet, aucun élément de la sous-séquence {s0..sn – 1} n’est égal à x. À ce stade, –1 est renvoyé et la condition postérieure est respectée. Le théorème suivant utilise la notation O(). Reportez-vous à la section A.3 pour revoir ces bases mathématiques. ✽ Théorème 2.2 : la recherche séquentielle est exécutée en une durée O(n). Démonstration : si x se trouve dans la séquence, disons à x = si avec i < n, la boucle est itérée i fois. Dans ce cas, la durée d’exécution est proportionnelle à i, soit O(n) puisque i < n. En revanche, si x ne se trouve pas dans la séquence, la boucle est itérée n fois, d’où une durée d’exécution proportionnelle à n, soit O(n).
Exemple 2.6 Tester la recherche séquentielle import schaums.dswj.Arrays; public class Testing { private static final private static final private static final private static int[]
int int int a =
SIZE = 16; START = 40; RANGE = 20; new int[SIZE];
public static void main(String[] args) { Arrays.load(a,START,RANGE); Arrays.print(a); test(); test(); test(); test(); } public static void test() { int x = Arrays.load(START,RANGE); System.out.print("Recherche de x = " + x + ":\t"); int i = Arrays.sequentialSearch(a,x); if (i >= 0) System.out.println("a[" + i + "] = " + a[i]); else System.out.println("i = " + i + " ==> x introuvable"); } }
33
Algorithme de recherche binaire
0 1 { 49, 44, Recherche Recherche Recherche Recherche
2 3 4 5 6 7 8 9 10 11 12 13 14 15 49, 41, 50, 59, 51, 48, 48, 41, 50, 47, 41, 58, 46, 48 } de x = 51: a[6] = 51 de x = 47: a[11] = 47 de x = 56: i = -1 ==> x introuvable de x = 50: a[4] = 50
Ce programme teste l’algorithme de recherche séquentielle quatre fois sur le tableau généré par load(). Le premier test recherche x = 51 et le trouve à a[6]. Le deuxième recherche x = 47 et le trouve à a[11]. Le troisième recherche x = 56 et échoue, c’est pourquoi il renvoie –1. Le quatrième recherche x = 50 et le trouve à a[4]. Remarquez que cette valeur se trouve également à a[10]. La recherche séquentielle s’arrête dès qu’elle trouve l’élément recherché et elle ignore le reste du tableau. Chaque algorithme de tableau est ajouté à la classe schaums.dswj.Arrays afin d’en faciliter l’accès. Ainsi, pour tester la recherche séquentielle, nous importons cette classe, puis nous appelons la méthode de recherche Arrays.sequentialSearch().
2.5 ALGORITHME DE RECHERCHE BINAIRE La recherche binaire est la procédure standard de recherche dans une séquence triée. Elle est beaucoup plus efficace que la recherche séquentielle, mais les éléments doivent être triés pour qu’elle soit utilisable. Elle divise à plusieurs reprises la séquence en deux, puis limite à chaque fois la recherche à la moitié contenant l’élément. Ce type de recherche est notamment utile lorsque vous souhaitez rechercher un mot dans le dictionnaire.
Algorithme 2.2 Recherche binaire
?
?
?
?
(Condition préalable : s = {s0, s1, s2, …, sn – 1} est une séquence triée de valeurs ordinales n du même type que x.) (Condition postérieure : l’index i est renvoyé si si = x ou bien –1 est renvoyé.) 1. Supposons que ss soit une sous-séquence de s, initialement paramétrée de façon à être égale à s. 2. Si la sous-séquence ss est vide, renvoyer –1. 3. (Invariant : si x se trouve dans la séquence initiale s, il doit se trouver dans la sous-séquence ss.) 4. Supposons que si soit l’élément central de ss. 5. Si si = x, renvoyer son index i. 6. Si si < x, répétez les étapes 2 à 7 sur la sous-séquence située au-dessus de si. 7. Répétez les étapes 2 à 7 sur la sous-séquence située sous si. Notez que la condition préalable de l’algorithme 2.2 ne peut être appliquée qu’à une séquence triée. La recherche binaire est implémentée dans la classe java.util.Arrays (reportez-vous à la section 2.3). Vous en trouverez ci-après une version annotée :
Exemple 2.7 Recherche binaire public static int binarySearch(int[] a, int x) { // Condition préalable : a[0] <= a[1] <= ... <= a[a.length-1]; // Conditions postérieures : renvoie i; si i >= 0, alors a[i] == x; // sinon i == -1;
34
Caractéristiques de base des tableaux
int lo=0, hi=a.length-1; while (lo <= hi) // étape 1 { // INVARIANT : si a[j]==x alors lo <= j <= hi; // étape 3 int i = (hi + lo)/2; // étape 4 if (a[i] == x) return i; // étape 5 else if (a[i] < x) lo = i+1; // étape 6 else hi = i-1; // étape 7 } return -1; // étape 2 }
✽ Théorème 2.3 : la recherche binaire est correcte. Démonstration : l’invariant de boucle est true à la première itération parce que la sous-séquence courante est identique à la séquence initiale. À chaque autre itération, la sous-séquence courante a été définie dans l’itération précédente comme la moitié de la sous-séquence précédente qui restait après l’omission de la moitié qui ne contenait pas x. Ainsi, si x se trouvait dans la séquence initiale, il doit se trouver dans la sous-séquence courante. L’invariant de boucle est par conséquent true à chaque itération. Pour chaque itération, i est renvoyé lorsque si = x ou bien la sous-séquence est réduite de plus de 50 %. Étant donné que la séquence initiale ne contient qu’un nombre fini d’éléments, la boucle ne peut pas continuer indéfiniment. C’est pourquoi l’algorithme s’arrête soit en renvoyant i depuis la boucle, soit à l’étape 6 ou 7 lorsque –1 est renvoyé. Si i est renvoyé depuis la boucle, si = x. Si tel n’est pas le cas, la boucle se termine lorsque grand (hi) < petit (lo), c’est-à-dire lorsque la sous-séquence est vide. Dans ce cas, nous savons par l’invariant de boucle que si ne se trouve pas dans la séquence initiale. ✽ Théorème 2.4 : la recherche binaire est exécutée en une durée O(lgn). Démonstration : dans la démonstration du théorème 2.3, nous avons vu que le nombre d’itérations est au maximum égal au nombre de fois (plus 1) où n peut être divisé par deux. Ce nombre correspond au logarithme binaire intégral lgn (reportez-vous à l’annexe A pour plus d’informations).
Exemple 2.8 Tester la recherche binaire import schaums.dswj.Arrays; public class Ex0208 { private static final private static final private static final private static int[]
int int int a =
SIZE = 16; START = 40; RANGE = 20; new int[SIZE];
public static void main(String[] args) { Arrays.load(a,START,RANGE); Arrays.print(a); test(); java.util.Arrays.sort(a); Arrays.print(a); test(); test(); test(); } public static void test() { int x = Arrays.load(START,RANGE); System.out.print("Recherche de x = " + x + ":\t"); int i = Arrays.binarySearch(a,x); if (i >= 0) System.out.println("a[" + i + "] = " + a[i]); else System.out.println("i = " + i + " ==> x introuvable"); } }
35
Classe Vector
0 1 { 48, 57, Recherche 0 1 { 42, 45, Recherche Recherche Recherche
2 3 4 5 6 7 46, 51, 55, 51, 55, 45, de x = 50: i = -1 ==> x 2 3 4 5 6 7 45, 46, 47, 47, 48, 50, de x = 50: a[7] = 50 de x = 47: a[5] = 47 de x = 58: i = -1 ==> x
8 9 10 47, 52, 57, introuvable 8 9 10 51, 51, 52,
11 12 13 14 15 47, 50, 42, 59, 45 } 11 12 13 14 15 55, 55, 57, 57, 59 }
introuvable
Ce programme teste l’algorithme de recherche binaire quatre fois sur le tableau généré par load(). Le premier test recherche x = 50 et échoue bien que a[7] = 50 parce que le tableau n’est pas encore trié. Une fois que java.util.Arrays.sort(a) a trié le tableau, le deuxième test trouve x = 50 à a[7]. Le troisième test recherche x = 57 et le trouve à a[5]. Remarquez qu’il ne s’agit pas de l’occurrence la plus à gauche de cette valeur qui apparaît aussi à a[4]. En fait, la recherche binaire n’est pas séquentielle, c’est pourquoi il n’est pas facile de savoir quel index sera renvoyé par l’algorithme lorsqu’une valeur apparaît plusieurs fois (reportez-vous à la question de révision 2.9). Le quatrième test recherche x = 58 et échoue, c’est pourquoi il renvoie –1.
2.6 CLASSE Vector Mathématiquement, un vecteur est simplement une séquence finie de nombres. Géométriquement, nous pensons généralement à un vecteur bidimensionnel (x, y) comme représentant un point dans un plan et à un vecteur tridimensionnel (x, y, z) comme représentant un point dans l’espace. Les vecteurs composés d’un nombre n fini d’éléments sont courants en algèbre linéaire : (x1, x2, …, xn) . Ce procédé requiert l’utilisation des index. Dans le cadre de Java, un vecteur est identique à un tableau, à l’exception des points suivants : • Un vecteur est une instance de la classe java.util.Vector. • Il est possible de modifier la longueur d’un vecteur. La classe Vector a été largement réécrite dans Java 1.2. Elle est définie de la façon suivante dans le paquetage java.util : public class Vector extends AbstractList implements List { public boolean add(Object object) public void add(int index, Object object) public boolean addAll(Collection collection) public boolean addAll(int index, Collection collection) public void addElement(Object object) public void clear() public Object clone() public boolean contains(Object object) public boolean containsAll(Collection collection) public void copyInto(Object[] objects) public Object elementAt(int index) public boolean equals(Object object) public Object firstElement() public Object get(int index) public int hashCode() public int indexOf(Object object) public int indexOf(Object object, int index) public void insertElementAt(Object object, int index)
36
Caractéristiques de base des tableaux
public public public public public public public public public public public public public public public public public public public public
boolean isEmpty() Object lastElement() int lastIndexOf(Object object) int lastIndexOf(Object object, int index) Object remove(int index) boolean remove(Object object) boolean removeAll(Collection collection) void removeAllElements() boolean removeElement(Object object) void removeElementAt(int index) boolean retainAll(Collection collection) Object set(int index, Object object) void setElementAt(Object object, int index) int size() List subList(int start, int stop) Object[] toArray() Object[] toArray(Object[] objects) String toString() Vector() Vector(Collection collection)
}
Les méthodes add permettent d’insérer de nouveaux éléments dans le vecteur. Les méthodes add(object) et addElement(object) ont le même effet, mais la première renvoie true (ou false en cas d’échec). Si l’emplacement du ou des nouveaux éléments n’est pas spécifié, la fin du vecteur est utilisée par défaut. La méthode clear() réduit le vecteur de façon à ce que sa taille soit égale à zéro, c’est-à-dire qu’il soit vide. Remarquez qu’un vecteur vide est différent d’une référence null puisque l’objet vecteur existe encore. La méthode clone() copie tout le vecteur, et donc l’objet qu’il contient. La méthode contains détermine si les objets donnés sont des éléments du vecteur. La méthode copyInto copie les éléments du vecteur dans le tableau donné. Les méthodes elementAt() et get() sont semblables à l’opérateur d’index pour les tableaux puisqu’elles renvoient l’élément vecteur à l’index donné. La méthode equals() teste l’égalité d’un autre objet et renvoie true si et uniquement si l’objet donné est un vecteur dont les éléments sont égaux au vecteur appelé. Les méthodes firstElement() et lastElement() renvoient simplement le premier et le dernier élément du vecteur. La méthode hashCode() renvoie un numéro d’identification pour l’objet vecteur. Les méthodes indexOf utilisent la recherche séquentielle pour retrouver et renvoyer l’index d’un objet donné du vecteur. Elles renvoient –1 si l’objet n’est pas trouvé. La version à deux paramètres commence sa recherche à l’index donné. La méthode insertElementAt() insère l’objet donné dans le vecteur à l’index donné. Tous les éléments existants sont alors déplacés vers l’avant à partir de cet index. Les méthodes lastIndexOf fonctionnent comme indexOf(), mais elles exécutent la recherche séquentielle à l’envers. Les méthodes remove suppriment du vecteur un segment composé d’un ou de plusieurs éléments. Les éléments existants qui se trouvaient au-delà du segment supprimé sont alors déplacés vers l’arrière, ce qui provoque un résultat inverse à celui de la méthode insertElementAt(). Remarquez le point suivant : bien qu’il existe une seule méthode insert, six méthodes remove (public) sont à votre disposition. Les méthodes remove(index) et removeElementAt(index) ont les mêmes conséquences, mais la première renvoie l’objet supprimé (ou null en cas d’échec), tandis que la dernière renvoie true (ou false en cas d’échec).
Classe Vector
37
Les méthodes set(index,object) et setElementAt(object,index) remplacent toutes les deux l’élément à l’index donné par l’objet donné. Elles ont le même effet, sauf que la première renvoie l’objet supprimé (ou null en cas d’échec). La méthode subList() renvoie un objet List contenant les objets du vecteur de l’index start à l’index stop -1. C’est pourquoi la taille de l’objet List obtenu est stop – start. Les méthodes toArray() renvoient un tableau Object[] qui contient les mêmes éléments que le vecteur. La version sans paramètres crée le tableau renvoyé et a donc la même taille que le vecteur. La version avec le paramètre Object[] effectue la même opération si la taille du tableau donné est inférieure à la taille du vecteur. Si tel n’est pas le cas, elle copie les éléments du vecteur au début du tableau donné, puis elle attribue la valeur null aux composants restants. La méthode toString() renvoie une chaîne qui représente le contenu du vecteur.
Exemple 2.9 Tester la classe java.util.Vector import java.util.*; public class Ex0209 { private static Vector v = new Vector(); private static Vector w = new Vector(); public static void main(String[] args) { String[] villes = { "Austin", "Boston", "Milan", "Moscou" }; v.addAll(Arrays.asList(villes)); System.out.println("v = " + v); v.add("Paris"); System.out.println("v = " + v); w = (Vector)v.clone(); System.out.println("w = " + w); System.out.println("w.equals(v) = " + w.equals(v)); v.set(3,"Ottawa"); System.out.println("v = " + v); System.out.println("w = " + w); System.out.println("w.equals(v) = " + w.equals(v)); v.insertElementAt("Londres",3); System.out.println("v = " + v); System.out.println("w = " + w); System.out.println("w.equals(v) = " + w.equals(v)); w.removeElementAt(1); w.removeElementAt(3); w.remove("Milan"); System.out.println("w = " + w); v.addAll(5,w); System.out.println("v = " + v); System.out.println("v.indexOf(\"Austin\") = " + v.indexOf("Austin")); System.out.println("v.indexOf(\"Austin\",2) = " + v.indexOf("Austin",2)); System.out.println("v.indexOf(\"Dublin\") = " + v.indexOf("Dublin")); } }
38
Caractéristiques de base des tableaux
v = [Austin, Boston, Milan, v = [Austin, Boston, Milan, w = [Austin, Boston, Milan, w.equals(v) = true v = [Austin, Boston, Milan, w = [Austin, Boston, Milan, w.equals(v) = false v = [Austin, Boston, Milan, w = [Austin, Boston, Milan, w.equals(v) = false w = [Austin, Moscou] v = [Austin, Boston, Milan, v.indexOf("Austin") = 0 v.indexOf("Austin",3) = 5 v.indexOf("Dublin") = -1
Moscou] Moscou, Paris] Moscou, Paris] Ottawa, Paris] Moscou, Paris] Londres, Ottawa, Paris] Moscou, Paris] Londres, Ottawa, Austin, Moscou, Paris]
Le tableau villes permet de créer le vecteur v en passant l’objet List créé par la méthode Arrays.asList() au constructeur Vector qui prend l’objet Collection comme paramètre (une List est une Collection). La méthode System.out.println() appelle la méthode toString() du vecteur pour l’imprimer. L’appel v.add("Paris") ajoute la nouvelle chaîne à la fin du vecteur et augmente ainsi sa taille. L’appel v.clone() renvoie une copie du vecteur v, mais comme type Object. C’est pourquoi il doit être transtypé en Vector afin d’être affecté à la référence w. La méthode equals() montre que v et w sont égaux. L’appel v.set(3,"Ottawa") modifie v, mais pas w et vérifie si les deux vecteurs sont différents, mais égaux. Après avoir supprimé les trois éléments de w, l’appel v.addAll(5,w) insère des copies de tous les éléments de w dans v en commençant à la cinquième position (c’est-à-dire juste après les cinq premiers éléments). En dernier lieu, la méthode indexOf() permet de rechercher les éléments de v.
?
QUESTIONS DE RÉVISION
QUESTIONS DE RÉVISION
2.1
Quelle est la différence entre un composant et un élément de tableau ?
2.2
Que signifie exactement le fait que Java n’autorise pas les tableaux multidimensionnels ?
2.3
Qu’est-ce qu’une exception ArrayIndexOutOfBoundsException et en quoi son utilisation différencie-t-elle Java d’autres langages de programmation tels que le C et le C++ ?
2.4
Quels sont les types valides pour les index de tableaux ?
2.5
En quoi cette définition est-elle erronée ? •Arrays arrays = new Arrays();
2.6
Quelle est la méthode d’impression d’un tableau d’objets la plus simple ?
2.7
Si la recherche binaire est nettement plus rapide que la recherche séquentielle, quel est l’intérêt d’utiliser cette dernière ?
2.8
Que se passe-t-il si la recherche séquentielle est appliquée à un élément qui apparaît plusieurs fois dans le tableau ?
2.9
Que se passe-t-il si la recherche binaire est appliquée à un élément qui apparaît plusieurs fois dans le tableau ?
Révision et entraînement
39
2.10 Quelle est la différence entre l’appel de la méthode clear() sur un objet Vector et l’affectation de la valeur null à ce dernier ?
¿
RÉPONSES
RÉPONSES
2.1
Un composant de tableau peut être de type primitif, ou bien de type référence ou tableau. Un élément de tableau est un composant qui n’est pas un type de tableau. C’est pourquoi, dans le langage Java, les composants de a[] sont les lignes des tableaux et les éléments de a[][] sont des variables de type double.
2.2
Un tableau multidimensionnel comporte plusieurs index. Un tableau Java n’a qu’une seule variable d’index. Cependant, étant donné qu’un composant indexé par la variable peut être un tableau (avec un index), le tableau initial semble avoir plusieurs index.
2.3
Un objet ArrayIndexOutOfBoundsException est une exception qui est lancée dès que vous essayez d’utiliser une valeur inférieure à zéro ou bien supérieure ou égale à la longueur du tableau comme index sur le tableau. Grâce à ce procédé, le programmeur contrôle mieux les conséquences d’une telle erreur d’exécution. Dans des langages tels que le C++, ce type d’erreur provoque généralement le plantage du programme.
2.4
Un index de tableau peut être de type byte, char, short ou int.
2.5
La classe Array ne peut pas être instanciée parce que son constructeur est déclaré comme private.
2.6
La méthode la plus simple pour imprimer un tableau d’objets consiste à le passer à la méthode Arrays.toList() qui crée un objet List pouvant être imprimé directement à l’aide de la méthode System.out.println().
2.7
La recherche binaire ne peut pas fonctionner si le tableau n’a pas été trié au préalable.
2.8
Si la recherche séquentielle est appliquée à un élément qui apparaît plusieurs fois dans le tableau, elle renvoie l’index le plus proche du début du tableau.
2.9
Si la recherche binaire est appliquée à un élément qui apparaît plusieurs fois dans le tableau, elle est susceptible de renvoyer n’importe quel index ; cela dépend de l’emplacement des index par rapport aux multiples du point médian des sous-intervalles. Par exemple, si la recherche binaire est appliquée à un tableau de 10 000 éléments et que vous recherchez un élément répété aux emplacements 0-99, elle renverra l’index 77 à la 77e itération.
2.10 L’appel v.clear() fait de v un vecteur vide qui reste un objet non null. L’affectation v = null détruit l’objet. Dans le premier cas, l’expression v.size() serait évaluée à 0 ; dans le deuxième,
elle lancerait une exception.
?
EXERCICES D’ENTRAÎNEMENT
EXERCICES D’ENTRAÎNEMENT
2.1
Exécutez un programme test afin d’illustrer comment la méthode Arrays.fill() gère un tableau d’objets.
2.2
Exécutez un programme test afin d’illustrer comment la méthode Arrays.equals() gère un tableau d’objets.
40
Caractéristiques de base des tableaux
2.3
Exécutez un programme test afin d’illustrer comment la méthode Arrays.equals() gère un tableau de tableaux.
2.4
S’il faut 50 minutes à une recherche séquentielle pour parcourir un tableau de 10 000 éléments, combien de temps mettrait-elle pour effectuer la même opération sur un tableau de 20 000 éléments en utilisant le même ordinateur ?
2.5
S’il faut 5 minutes à une recherche binaire pour parcourir un tableau de 1 000 éléments, combien de temps mettrait-elle pour effectuer la même opération sur un tableau de 1 000 éléments en utilisant le même ordinateur ?
2.6
La recherche par interpolation est similaire à la recherche binaire. Cependant, contrairement à cette dernière, elle choisit si au cours de l’étape 4 de façon à ce que la proportion des éléments inférieurs à si dans la sous-séquence ss corresponde à une répartition uniforme. Par exemple, si vous recherchez le nom Byrd dans un annuaire de 2600 pages, vous ouvrirez d’abord celui-ci aux alentours de la page 200 en supposant logiquement qu’1/13e des noms précède celui que vous recherchez. La recherche par interpolation peut être exécutée en O(lglgn). En tenant compte de ces informations, s’il vous a fallu 5 minutes pour exécuter votre recherche sur un tableau de 1 000 éléments, combien de temps mettrez-vous pour un tableau de 1 000 000 éléments en utilisant le même ordinateur ?
2.7
Exécutez un pilote test pour la méthode de recherche binaire présentée dans l’exemple 2.7 sur un tableau de 10 000 éléments et comptez le nombre d’itérations.
2.8
Écrivez et testez la méthode suivante : • private static boolean isSorted(int[] a) • // renvoie true si a[0] <= a[1] <= ... <= a[a.length-1]
2.9
Écrivez et testez la méthode suivante : • private static int minimum(int[] a) • // renvoie l’élément minimum de a[]
2.10 Écrivez et testez la méthode suivante : • private static double mean(double[] a) • // renvoie la valeur moyenne de tous les éléments de a[]
2.11 Écrivez et testez la méthode suivante : • private static int[] withoutDuplicates(int[] a) • // renvoie un tableau avec les mêmes éléments que ceux de a[], • // mais sans copies
2.12 Écrivez et testez la méthode suivante : • private static Object[] withoutDuplicates(Object[] a) • // renvoie un tableau avec les mêmes composants que ceux de a[], • // mais sans copies
2.13 Écrivez et testez la méthode suivante : • private static void reverse(int[] a) • // inverse les éléments de a[]
2.14 Écrivez et testez la méthode suivante : • private static Object[] concatenate(Object[] a, Object[] b) • // renvoie un tableau contenant tous les éléments de a[] • // suivis de tous les éléments de b[]
41
Révision et entraînement
2.15 Écrivez et testez la méthode suivante : • private static void shuffle(Object[] a) • // permute de façon aléatoire les éléments de a[]
2.16 Écrivez et testez la méthode suivante : • private static int[] tally(String string) • // renvoie un tableau a[] de 26 entiers qui compte les fréquences • // des lettres (insensibles à la casse) de la chaîne donnée
2.17 Écrivez et testez la méthode suivante : • private static double innerProduct(double[] x, double[] y) • // renvoie le produit interne algébrique (la sommes des produits • // des composants) des deux tableaux donnés sous forme de • // vecteurs (algébriques)
2.18 Écrivez et testez la méthode suivante : • private static double[][] outerProduct(double[] x, double[] y) • // renvoie le produit externe algébrique des deux tableaux donnés • // sous forme de vecteurs (algébriques) : p[i][j] = a[i]*b[j]
2.19 Écrivez et testez la méthode suivante : • private static double[][] product(double[][] a, double[][] b) • // renvoie le produit matrice des deux tableaux donnés : • // p[i][j] = Sum(a[i][k]*b[k][j]:k)
2.20 Écrivez et testez la méthode suivante : • private static void transpose(double[][] a) • // transpose le tableau donné sous forme de matrice : • // a[i][j] <-- a[j][i]
2.21 Écrivez et testez la méthode suivante : • private static int[][] pascal(int size) • // renvoie le triangle de Pascal avec une taille donnée
2.22 Le crible d’Ératosthène est un tableau d’éléments boolean dont le iième élément est true si et
seulement si i est un nombre premier. Utilisez l’algorithme suivant pour calculer et imprimer un crible de taille 1 000 :
Algorithme 2.3 Crible d’Ératosthène (Condition préalable : p est un tableau de n bits.) (Condition postérieure : p[i] est true si et seulement si i est un nombre premier.) 1. Initialiser p[0] et p[1] à false et tous les autres p[i] à true. 2. Répéter l’étape 3 pour chaque i de 3 à n en incrémentant par 2. 3. S’il existe un nombre premier ≤ à la racine carrée de i qui divise i, paramétrer p[i] à false. 2.23 Répétez l’exercice 2.22 en utilisant l’objet java.util.Vector. 2.24 Répétez l’exercice 2.22 en utilisant l’objet java.util.BitSet. 2.25 Définissez et testez la classe Primes avec les méthodes suivantes : • private Primes() • public static void setLast(int last) • public static void setLast()
// définit le dernier // définit dernier=1
42
Caractéristiques de base des tableaux • public • • public • public • • public • • public
static void sizeSize(int size) // définit la taille // de bitset static void sizeSize() // définit la taille de bitset=1000 static boolean isPrime(int n) // true si n est un // nombre premier static int next() // nombre premier suivant // le dernier static void printPrimes() // imprime le crible
Utilisez l’implémentation BitSet du crible d’Ératosthène de l’exercice 2.24 et les définitions suivantes : • public class Primes • { private static final int SIZE = 1000; • private static int size = SIZE; • private static BitSet sieve = new BitSet(size); • private static int last = 1;
en incluant cet initialisateur statique qui implémente le crible d’Ératosthène : • static • { for (int i=2; i<SIZE; i++) • sieve.set(i); • for (int n=2; 2*n<SIZE; n++) • if (sieve.get(n)) • for (int m=n; m*n<SIZE; m++) • sieve.clear(m*n); •}
2.26 Ajoutez la méthode suivante à la classe Primes, puis testez-la : • public static String factor(int n) • // condition préalable : n > 1 • // renvoie la factorisation des nombres premiers de n; • // exemple : facteur(4840) renvoie "2*2*2*5*11*11"
2.27 Christian Goldbach (1690–1764) a supposé en 1742 que chaque nombre pair supérieur à 2 est la
somme de deux nombres premiers. Écrivez un programme qui teste la conjecture de Goldbach pour tous les nombres pairs inférieurs à 100. Utilisez la classe Primes de l’exercice 2.25. Les 10 premières lignes de la sortie obtenue devraient ressembler aux lignes suivantes : • • • • • • • • • • •
4 = 2+2 6 = 3+3 8 = 3+5 10 = 3+7 = 5+5 12 = 5+7 14 = 3+11 = 7+7 16 = 3+13 = 5+11 18 = 5+13 = 7+11 20 = 3+17 = 7+13 22 = 3+19 = 5+17 = 11+11
2.28 D’après les travaux de Pierre de Fermat (1601–1665), il existe un nombre infini de nombres prep 2
miers de forme n = 2 + 1 pour un entier p. Ces nombres sont qualifiés de nombres premiers 1 2 de Fermat. Par exemple, 5 est un nombre premier de Fermat parce qu’il a la forme 2 + 1 . Écrivez un programme capable de rechercher tous les nombres premiers de Fermat se trouvant dans l’intervalle du type int. Utilisez la classe Primes de l’exercice 2.25 et la méthode Math.pow.
43
Révision et entraînement
Les cinq premières lignes de votre sortie devraient ressembler aux lignes suivantes : • • • • • •
2^2^0 2^2^1 2^2^2 2^2^3 2^2^4
+ + + + +
1 1 1 1 1
= = = = =
3 5 17 257 65537
2.29 Charles Babbage (1792–1871) a obtenu la première bourse jamais accordée en 1823 lorsqu’il a
persuadé le gouvernement britannique de lui accorder 1 000 livres pour qu’il puisse construire sa machine de calcul mécanique. Dans sa demande de bourse, Babbage donnait la formule x2 + x + 41 comme exemple d’une fonction que sa machine serait en mesure de calculer. Cette fonction présentait un intérêt particulier pour les mathématiciens parce qu’elle crée un nombre inhabituel de nombres premiers. Les nombres premiers de forme n = x2 + x + 41 pour un entier x sont qualifiés de nombres premiers de Babbage. Écrivez un programme capable de retrouver tous les nombres premiers de Babbage inférieurs à 10 000. Utilisez la classe Primes de l’exercice 2.25. Les cinq premières lignes de la sortie obtenue devraient ressembler aux lignes suivantes : • • • • • •
0 1 2 3 4
41 43 47 53 61
est est est est est
un un un un un
nombre nombre nombre nombre nombre
premier premier premier premier premier
2.30 Deux entiers impairs consécutifs qui sont tous les deux premiers sont qualifiés de nombres pre-
miers jumeaux. La conjecture des nombres premiers jumeaux consiste à dire qu’il existe un nombre infini de nombres premiers jumeaux. Écrivez un programme capable de rechercher tous les nombres premiers jumeaux inférieurs à 1 000. Utilisez la classe Primes de l’exercice 2.25. Les cinq premières lignes de la sortie obtenue devraient ressembler aux lignes suivantes : • • • • • •
3 5 5 7 11 13 17 19 29 31
2.31 Testez la supposition selon laquelle il existe au moins un nombre premier entre chaque paire de
nombres aux carrés consécutifs (les nombres au carré sont 1, 4, 9, 16, 25, …). Utilisez la classe Primes de l’exercice 2.25. Les cinq premières lignes de la sortie obtenue devraient ressembler aux lignes suivantes : • • • • • •
1 < 2 < 4 4 < 5 < 9 9 < 11 < 16 16 < 17 < 25 25 < 29 < 36
2.32 Le moine minime Marin Mersenne (1588–1648) entreprit en 1644 l’étude de nombres sous la
forme n = 2p – 1, p étant un nombre premier. Il était convaincu que la plupart de ces nombres n étaient premiers. Ces nombres sont maintenant appelés Nombres premiers de Mersenne. Écrivez un programme capable de rechercher tous les nombres premiers de Mersenne pour p < 30.
44
Caractéristiques de base des tableaux
Utilisez la classe Primes de l’exercice 2.25. Les cinq premières lignes de la sortie obtenue devraient ressembler aux lignes suivantes : • • • • • •
2 2^2-1 = 3 2^3-1 = 5 2^5-1 = 7 2^7-1 = 11 2^11-1
3 est un nombre premier 7 est un nombre premier 31 est un nombre premier 127 est un nombre premier = 2047 est un nombre premier
2.33 Un nombre est dit palindromique s’il ne varie pas lorsqu’il est inversé, c’est-à-dire s’il reste le
même lorsque les chiffres qui le composent sont inversés. Par exemple, 3456543 est palindromique. Écrivez un programme capable de vérifier chacun des 10 000 premiers nombres premiers et d’imprimer ceux qui sont palindromiques. Utilisez la classe Primes de l’exercice 2.25.
¿
SOLUTIONS
SOLUTIONS
2.1
Le programme test de la méthode java.util.Arrays.fill() appliquée aux tableaux d’objets est le suivant : • import java.util.Arrays; • public class Pr0201 • { public static void main(String[] args) • { Object[] a = new Object[4]; • Double x = new Double(Math.PI); • Arrays.fill(a,x); • for (int i=0; i
2.2
Le programme test de la méthode java.util.Arrays.equals() appliquée aux tableaux d’objets est le suivant : • import java.util.Arrays; • public class Pr0202 • { public static void main(String[] args) • { Double x = new Double(Math.PI); • Object[] a = new Object[4]; • Arrays.fill(a,x); • Object[] b = new Object[4]; • Arrays.fill(b,x); • System.out.println("b.equals(a) = " + b.equals(a)); • System.out.println("Arrays.equals(a,b) = " • + Arrays.equals(a,b)); • Arrays.fill(a,"Eclair au chocolat !"); • Arrays.fill(b,"Eclair au chocolat !"); • System.out.println("b.equals(a) = " + b.equals(a)); • System.out.println("Arrays.equals(a,b) = " • + Arrays.equals(a,b)); • } •}
Révision et entraînement
2.3
45
Le programme test de la méthode java.util.Arrays.equals() appliquée à des tableaux de tableaux est le suivant : • import java.util.Arrays; • public class Pr0203 • { public static void main(String[] args) • { double[] x = { Math.E, Math.PI }; • String[] s = { "Nord", "Est", "Sud", "Ouest" }; • Vector[] y = new Vector[0]; • Object[] a = { x, s, null, y }; • Object[] b = { x, s, null, y }; • System.out.println("b.equals(a) = " + b.equals(a)); • System.out.println("Arrays.equals(a,b) = " • + Arrays.equals(a,b)); • } •}
2.4
2.5
2.6
2.7
La recherche séquentielle est exécutée de façon linéaire, ce qui signifie que sa durée est proportionnelle au nombre d’éléments. Par conséquent, le traitement d’un tableau deux fois plus grand prendrait deux fois plus de temps, soit 20 minutes. La recherche binaire est exécutée de façon logarithmique, c’est pourquoi la mise au carré de la taille du tableau ne fait que doubler la durée du traitement. Ainsi, un tableau composé de 1 0002 éléments sera traité en deux fois plus de temps, soit 10 minutes. La recherche par interpolation a une durée d’exécution hyperlogarithmique. Par conséquent, la mise au carré de la taille du tableau n’affecte en aucun cas cette durée d’exécution de la recherche qui sera d’environ 2 minutes. Le programme test de l’algorithme de recherche séquentielle est le suivant : • public class Pr0207 • { private static final int N=16; • private static int[] a = new int[N]; • private static final int RANGE=2*N; • private static final int START=10; • private static Random random = new Random(); • public static void main(String[] args) • { load(a); • print(a); • int x = random.nextInt(RANGE) + START; • System.out.println("x = " + x); • int i = sequentialSearch(a,x); • System.out.println("recherche(a,x) = " + i); • if (i >= 0) System.out.println("a[" + i + "] = " + a[i]); • } • private static void load(int[] a) • { for (int i=0; i9?"":" ") + i); • System.out.print("\n{ " + a[0]); • for (int i=1; i
46
Caractéristiques de base des tableaux • if (a[i]==x) return i; • return -1; • } •}
2.8
Le programme test de la méthode isSorted(int[]) est le suivant : • public class Pr0208 • { private static final int SIZE = 16; • private static int[] a = new int[SIZE]; • public static void main(String[] args) • { schaums.dswj.Arrays.load(a,40,20); • schaums.dswj.Arrays.print(a); • System.out.println("isSorted(a) = " + isSorted(a)); • java.util.Arrays.sort(a); • schaums.dswj.Arrays.print(a); • System.out.println("isSorted(a) = " + isSorted(a)); • } • private static boolean isSorted(int[] a) • { if (a.length<2) return true; • for (int i=1; i
2.9
Le programme test de la méthode minimum(int[]) est le suivant : • public class Pr0209 • { private static final int SIZE = 8; • private static int[] a = new int[SIZE]; • public static void main(String[] args) • { schaums.dswj.Arrays.load(a,20,80); • schaums.dswj.Arrays.print(a); • System.out.println("minimum(a) = " + minimum(a)); • } • private static int minimum(int[] a) • { int min = a[0]; • for (int i=1; i
2.10 Le programme test de la méthode mean(double[]) est le suivant : • public class Pr0210 • { private static final int SIZE = 4; • private static double[] a = new double[SIZE]; • public static void main(String[] args) • { schaums.dswj.Arrays.load(a,20,10); • schaums.dswj.Arrays.print(a); • System.out.println("mean(a) = " + mean(a)); • } • private static double mean(double[] a) • { double sum=0.0; • for (int i=0; i
Révision et entraînement
2.11 Le programme test de la méthode withoutDuplicates(int[]) est le suivant : • public class Pr0211 • { private static final int SIZE = 16; • private static int[] a = new int[SIZE]; • public static void main(String[] args) • { schaums.dswj.Arrays.load(a,10,8); • schaums.dswj.Arrays.print(a); • int[] b = withoutDuplicates(a); • schaums.dswj.Arrays.print(b); • } • private static int[] withoutDuplicates(int[] a) • { if (a.length<2) return a; // il n’y a aucune copie • int x = a[0]; // utiliser cette valeur comme marqueur factice • for (int i=1; i0 && a[i] != x) b[i-shift] = a[i]; • else if (a[i]==x) ++shift; • else b[i] = a[i]; • return b; • } •}
2.12 Le programme test de la méthode withoutDuplicates(Object[]) est le suivant : • public class Pr0212 • { private static final int SIZE = 16; • private static Object[] a = new Object[SIZE]; • public static void main(String[] args) • { schaums.dswj.Arrays.load(a,10,8); • schaums.dswj.Arrays.print(a); • Object[] b = withoutDuplicates(a); • schaums.dswj.Arrays.print(b); • } • private static Object[] withoutDuplicates(Object[] a) • { if (a.length<2) return a; // il n’y a aucune copie • Object x = a[0]; // utiliser cet objet comme marqueur factice • for (int i=1; i0 && !a[i].equals(x)) b[i-shift] = a[i]; • else if (a[i].equals(x)) ++shift; • else b[i] = a[i];
47
48
Caractéristiques de base des tableaux • return b; • } •}
2.13 Le programme test de la méthode reverse(int[]) est le suivant : • public class Pr0213 • { private static final int SIZE = 16; • private static int[] a = new int[SIZE]; • public static void main(String[] args) • { schaums.dswj.Arrays.load(a,60,40); • schaums.dswj.Arrays.print(a); • reverse(a); • schaums.dswj.Arrays.print(a); • } • private static void reverse(int[] a) • { if (a.length<2) return; • for (int i=0; i
2.14 Le programme test de la méthode concatenate(Object[],Object[]) est le suivant : • public class Pr0214 • { private static final int SIZE = 8; • private static Object[] a = new Object[SIZE]; • private static Object[] b = new Object[SIZE]; • public static void main(String[] args) • { schaums.dswj.Arrays.load(a,10,40); • schaums.dswj.Arrays.load(b,60,40); • schaums.dswj.Arrays.print("a",a); • schaums.dswj.Arrays.print("b",b); • Object[] c = concatenate(a,b); • schaums.dswj.Arrays.print("a",a); • schaums.dswj.Arrays.print("b",b); • schaums.dswj.Arrays.print("c",c); • } • private static Object[] concatenate(Object[] a, Object[] b) • { Object[] c = new Object[a.length+b.length]; • for (int i=0; i
2.15 Le programme test de la méthode shuffle(Object[]) est le suivant : • public class Pr0215 • { private static final int SIZE = 16; • private static Object[] a = new Object[SIZE]; • • public static void main(String[] args) • { schaums.dswj.Arrays.load(a,10,90); • schaums.dswj.Arrays.print(a); • shuffle(a); • schaums.dswj.Arrays.print(a); • } • private static void shuffle(Object[] a)
Révision et entraînement • { Random random = new Random(); • for (int i=0; i
2.16 Le programme test de la méthode tally(String) est le suivant : • public class Pr0216 • { public static void main(String[] args) • { String string="Bienvenue dans le nouveau millénaire"; • System.out.println(string); • int[] t = tally(string); • for (int i=0; i<26; i++) • System.out.println("Fréquence de " + (char)(’A’+i) • + " = " + t[i]); • } • private static int[] tally(String s) • { int[] frequency = new int[26]; • for (int i=0; i<s.length(); i++) • { char ch = Character.toUpperCase(s.charAt(i)); • if (Character.isLetter(ch)) • ++frequency[(int)ch - (int)’A’]; // compter ch • } • return frequency; • } •}
2.17 Le programme test de la méthode innerProduct(double[],double[]) est le suivant : • public class Pr0217 • { public static void main(String[] args) • { double[] x = { 1.1, 2.2, 3.3, 4.4 }; • double[] y = { 2.0, 0.0, 1.0, -1.0 }; • System.out.println("innerProduct(x,y) = " • + innerProduct(x,y)); • } • private static double innerProduct(double[] x, double[] y) • { double sum=0.0; • for (int i=0; i<x.length && i
2.18 Le programme test de la méthode outerProduct(double[],double[]) est le suivant : • public class Pr0218 • { public static void main(String[] args) • { double[] x = { 1.1, 2.2, 3.3, 4.4 }; • double[] y = { 2.0, 0.0, -1.0 }; • double[][] z = outerProduct(x,y); • for (int i=0; i<x.length; i++) • { for (int j=0; j
49
50
Caractéristiques de base des tableaux • for (int j=0; j
2.19 Le programme test de la méthode product(double[][],double[][]) est le suivant : • public class Pr0219 • { public static void main(String[] args) • { double[][] x = { { 1.0, 2.0 }, • { 3.0, 4.0 } }; • double[][] y = { { 20.0, -10.0 }, • { 10.0, 20.0 } }; • double[][] z = product(x,y); • for (int i=0; i<x.length; i++) • { for (int j=0; j
2.20 Le programme test de la méthode transpose(double[][]) est le suivant : • public class Pr0220 • { public static void main(String[] args) • { double[][] x = { { 1.0, 2.0, 3.0 }, { 4.0, 5.0, 6.0 } }; • double[][] y = transpose(x); • for (int i=0; i
2.21 Le programme test qui renvoie le triangle de Pascal est le suivant : • public class Pr0221 • { private static final int N=9; • public static void main(String[] args) • { int[][] p = pascal(N);
Révision et entraînement • • • • • • • • • • • • • • • •}
for (int i=0; i
2.22 Le crible d’Ératosthène est le suivant : • public class Pr0222 • { private static final int SIZE=1000; • private static boolean[] sieve = new boolean[SIZE]; • public static void main(String[] args) • { initializeSieve(); • printSieve(); • } • private static void initializeSieve() • { for (int i=2; i<SIZE; i++) • sieve[i] = true; • for (int n=2; 2*n<SIZE; n++) • if (sieve[n]) • for (int m=n; m*n<SIZE; m++) • sieve[m*n] = false; • } • private static void printSieve() • { int n=0; • for (int i=0; i<SIZE; i++) • if (sieve[i]) System.out.print((n++%10==0?"\n":"\t")+i); • System.out.println("\n" + n • + " nombres premiers inférieurs à "+ SIZE); • } •}
2.23 Le crible d’Ératosthène avec un objet java.util.Vector est le suivant : • import java.util.Vector; • public class Pr0223 • { private static final int SIZE=1000; • private static Vector sieve = new Vector(SIZE); • public static void main(String[] args) • { initializeSieve(); • printSieve(); • } • private static void initializeSieve() • { sieve.add(Boolean.FALSE); • sieve.add(Boolean.FALSE); • for (int i=2; i<SIZE; i++) • sieve.add(Boolean.TRUE); • for (int n=2; 2*n<SIZE; n++) • if (((Boolean)sieve.get(n)).booleanValue()) • for (int m=n; m*n<SIZE; m++)
51
52
Caractéristiques de base des tableaux • • • • • • • • • • • •}
sieve.set(m*n,Boolean.FALSE); } private static void printSieve() { int n=0; for (int i=0; i<SIZE; i++) if (((Boolean)sieve.get(i)).booleanValue()) System.out.print((n++%10==0?"\n":"\t")+i); System.out.println("\n" + n + " nombres premiers inférieurs à " + SIZE); }
2.24 Le crible d’Ératosthène avec un objet java.util.BitSet : • import java.util.BitSet; • public class Pr0224 • { private static final int SIZE=1000; • private static BitSet sieve = new BitSet(SIZE); • public static void main(String[] args) • { initializeSieve(); • printSieve(); • } • private static void initializeSieve() • { for (int i=2; i<SIZE; i++) • sieve.set(i); • for (int n=2; 2*n<SIZE; n++) • if (sieve.get(n)) • for (int m=n; m*n<SIZE; m++) • sieve.clear(m*n); • } • private static void printSieve() • { int n=0; • for (int i=0; i<SIZE; i++) • if (sieve.get(i)) • System.out.print((n++%10==0?"\n":"\t")+i); • System.out.println("\n" + n • + " nombres premiers inférieurs à " + SIZE); • } •}
2.25 La classe Primes est définie de la façon suivante : • package schaums.dswj; • import java.util.*; • public class Primes • { private static final int SIZE = 1000; • private static int size = SIZE; • private static BitSet sieve = new BitSet(size); • private static int last = 1; • static • { for (int i=2; i<SIZE; i++) • sieve.set(i); • for (int n=2; 2*n<SIZE; n++) • if (sieve.get(n)) • for (int m=n; m*n<SIZE; m++) • sieve.clear(m*n); • } • private Primes() • { • }
Révision et entraînement • public static void setLast(int n) • { last = n; • } • public static void setLast() • { last = 1; • } • public static void setSize(int n) • { size = n; • } • public static void setSize() • { size = 1000; • } • public static boolean isPrime(int n) • { return sieve.get(n); • } • public static int next() • { while (++last<size) • if (sieve.get(last)) return last; • return -1; • } • public static void printPrimes() • { int n=0; • for (int i=0; i<SIZE; i++) • if (sieve.get(i)) • System.out.print((n++%10==0?"\n":"\t")+i); • System.out.println("\n" + n • + " nombres premiers inférieurs à " + SIZE); • } •} • • import schaums.dswj.Primes; • public class Pr0225 • { public static void main(String[] args) • { Primes.printPrimes(); • for (int n=1; n<=10; n++) • System.out.println(n + ".\t" + Primes.next()); • } •}
2.26 Le programme test de la méthode de factorisation des nombres premiers est le suivant : • import schaums.dswj.Primes; • import java.util.Random; • public class Pr0226 • { private static final int N=10; • private static final int RANGE=2000; • private static Random random = new Random(); • public static void main(String[] args) • { for (int i=0; i
La méthode est ajoutée de la façon suivante à la classe Primes : • public static String factor(int n) • { String primes=""; • int p = next(); • while (n>1)
53
54
Caractéristiques de base des tableaux • • • • • • • • • • • • •}
{ if (n%p==0) { primes += (primes.length()==0?"":"*") + p; n /= p; } else p = next(); if (p == -1) { primes += " DEBORDEMENT"; break; } } setLast(); return primes;
2.27 Le programme test de la conjecture de Goldbach est le suivant : • public class Pr0227 • { public static void main(String[] args) • { final int N=10000; • Primes.setSize(N); • System.out.println("4 = 2+2"); • for (int n=6; n<100; n += 2) • { System.out.print(n); • for (int p=3; p<=n/2; p += 2) • if (Primes.isPrime(p) && Primes.isPrime(n-p)) • System.out.print(" = "+p+"+"+(n-p)); • System.out.println(); • } • } •}
2.28 Le programme de recherche des nombres premiers de Fermat est le suivant : • public class Pr0228 • { public static void main(String[] args) • { final int N=10000; • Primes.setSize(N); • for (int p=0; p<5; p++) • { int n = (int)Math.pow(2,Math.pow(2,p)) + 1; • if (Primes.isPrime(n)) • System.out.println("p = "+p+", n = 2^2^p = "+n); • } • } •}
2.29 Le programme de recherche des nombres premiers de Babbage est le suivant : • public class Pr0229 • { public static void main(String[] args) • { final int N=10000; • Primes.setSize(N); • for (int x=0; x<50; x++) • { System.out.print(x); • int n = x*x + x + 41; • if (Primes.isPrime(n)) • System.out.println("\t"+n+" is prime"); • else System.out.println(); • } • } •}
Révision et entraînement
2.30 Le programme de recherche des nombres premiers jumeaux est le suivant : • public class Pr0230 • { public static void main(String[] args) • { final int N=1000; • Primes.setSize(N); • int n=Primes.next(); • while (n<0.9*N) • { if (Primes.isPrime(n+2)) • System.out.println(n + "\t" + (n+2)); • n = primes.next(); • } • } •}
2.31 Le programme de recherche des nombres premiers entre les racines carrées est le suivant : • public class Pr0231 • { public static void main(String[] args) • { final int N=10000; • Primes.setSize(N); • for (int n=1; n<100; n++) • for (int i=n*n+1; i<(n+1)*(n+1); i++) • if (Primes.isPrime(i)) • { System.out.println(n*n + " < " + i + " < " + (n+1)*(n+1)); • break; • } • } •}
2.32 Le programme de recherche des nombres premiers de Mersenne est le suivant : • public class Pr0232 • { public static void main(String[] args) • { final int N=10000; • Primes.setSize(N); • for (int p = Primes.next(); p<30; p = Primes.next()) • { int n = (int)Math.round(Math.pow(2,p)) - 1; • System.out.print(p + "\t2^" + p + "-1 = " + n); • if (Primes.isPrime(n)) • System.out.println(" est un nombre premier "); • else System.out.println(" n’est pas un nombre premier "); • } • } •}
2.33 Le programme de recherche des nombres premiers palindromiques est le suivant : • public class Pr0233 • { public static void main(String[] args) • { final int N=10000; • Primes.setSize(N); • for (int i=0; i
55
56
Caractéristiques de base des tableaux • • • • • • • • • • • • • } •}
// faire de p10 la puissance 10 la plus importante < n while (p109) { if (n/p10 != n%10) return false; n /= 10; // supprimer de n le chiffre le plus à droite p10 /= 10; n %= p10; // supprimer de n le chiffre le plus à gauche } return true; // les entiers composés d’un chiffre sont // palindromiques
Chapitre 3
Java avancé 3.1 HÉRITAGE L’héritage des classes représente l’un des concepts centraux de la programmation orientée objet. Nous disons que la classe Y hérite de la classe X si elle étend cette dernière en utilisant ses champs et ses méthodes en plus de la définition de ses propres champs et méthodes. Dans ce cas, nous disons également que Y est une sous-classe de X ou bien une classe enfant de X qui est alors qualifiée de classe parent ou superclasse. D’un point de vue conceptuel, vous obtenez ainsi des instances de la classe Y qui sont plus spécialisées et des instances de la classe X qui sont plus générales.
Exemple 3.1 Étendre la classe Point Le programme suivant définit PointColore comme sous-classe de la classe Point déjà vue dans l’exemple 1.7 : public class PointColore extends Point { private String couleur="noir"; public PointColore(double x, double y, String couleur) { super(x,y); // appelle le constructeur parent this.couleur = couleur; } public String getCouleur() { return couleur; } public void setCouleur(String couleur) { this.couleur = couleur; } public boolean equals(Object object) { if (object == this) return true; if (object.getClass() != this.getClass()) return false; if (object.hashCode() != this.hashCode()) return false; PointColore point = (PointColore)object; return (x == point.x && y == point.y && couleur == point.couleur); }
58
Java avancé
public int hashCode() { return (new Double(x)).hashCode() + (new Double(y)).hashCode() + couleur.hashCode(); } public String toString() { return new String("(" + x + "," + y + ", " + couleur + ")"); } }
Cette sous-classe définit son propre constructeur, un nouvel accesseur getCouleur() et un nouveau mutateur setCouleur(String). Elle remplace les méthodes equals(Object), hashCode() et toString() définies dans la classe parent. Remarquez que les champs x et y de la classe Point doivent être déclarés comme protected au lieu de private afin d’être accessibles depuis la sous-classe PointColore. Voici le pilote test de cette sous-classe : public class Ex0301 { public static void main(String[] args) { PointColore p = new PointColore(2,3,"vert"); System.out.println("p = " + p); PointColore q = new PointColore(2,3,"vert"); System.out.println("q = " + q); if (q == p) System.out.println("q == p"); else System.out.println("q != p"); if (q.equals(p)) System.out.println("q est égal à p"); else System.out.println("q n’est pas égal à p"); q.setCouleur("rouge"); System.out.println("q = " + q); if (q.equals(p)) System.out.println("q est égal à p"); else System.out.println("q n’est pas égal à p"); } } p q q q q q
= (2.0,3.0, vert) = (2.0,3.0, vert) != p est égal à p = (2.0,3.0, rouge) n’est pas égal à p
3.2 POLYMORPHISME Grâce à l’héritage, l’instance d’une sous-classe peut-être considérée comme une instance de sa classe parent lorsqu’elle est passée sous forme d’un argument à une méthode. Cette capacité de prendre en apparence plusieurs formes est qualifiée de polymorphisme.
Exemple 3.2 Utiliser le polymorphisme public class Ex302 { public static void main(String[] args) { PointColore p = new PointColore(2,3,"vert"); System.out.println("p = " + p); Point q = new Point(-2,0); System.out.println("q = " + q); System.out.println("distance(p,q) = " + distance(p,q)); }
59
Polymorphisme
private static double distance(Point p, Point q) { double dx = p.getX() - q.getX(); double dy = p.getY() - q.getY(); return Math.sqrt(dx*dx + dy*dy); } } p = (2.0,3.0, vert) q = (-2.0,0.0) distance(p,q) = 5.0
L’objet p est une instance de la classe PointColore. Mais la méthode distance(Point,Point) permet son passage au premier paramètre sous forme d’une instance de la classe parent Point. C’est pourquoi p semble avoir plusieurs formes (PointColore et Point), c’est-à-dire qu’il semble être polymorphique. Toutes les classes Java, qu’elles fassent partie de la bibliothèque standard ou qu’elles soient créées par le programmeur, résident dans une hiérarchie d’héritage de grande taille. Cette hiérarchie se présente sous la forme d’une structure arborescente avec la classe java.lang.Object à sa racine. Chaque autre classe se trouve sous une classe parent unique. Ainsi, chaque classe Java est directement ou indirectement une sous-classe de la classe Object. L’arbre ci-contre illustre l’organisation de 40 des 1 462 classes qui constituent la bibliothèque standard Java 1.2 (soit moins de 3 % d’entre elles). Vous pouvez notamment constater que Integer est une sous-classe de Number, qui est une sousclasse de la classe Object. Cet arbre qui illustre la hiérarchie Java vous permet également de comprendre comment fonctionne le polymorphisme. Étant donné que les instances de chaque classe enfant peuvent être passées à un paramètre dont le type est une classe ancêtre, il suffit simplement de suivre les chemins qui vont des feuilles à la racine Object pour voir à quoi ressemble un objet polymorphique. Par exemple, un objet Frame peut être passé à un paramètre de type Frame, Window, Container, Component ou Object. En raison de cette hiérarchie Java, le polymorphisme signifie que tout objet peut être passé à un paramètre de type Object et que tout objet peut appeler n’importe quelle méthode définie dans la classe Object (puisqu’aucune d’entre elles n’est private).
Object AbstractCollection AbstractList AbstractSequentialList LinkedList ArrayList Vector Stack AbstractSet HashSet TreeSet AbstractMap HashMap TreeMap WeakHashMap Arrays BitSet Collections Component Button Container Panel Window Frame Date Dictionary Hashtable Properties File Math Number Integer Double Random Reader InputStreamReader FileReader String StringTokenizer System
60
Java avancé
3.3 CONVERSION DES TYPES Dans le cadre de Java, chaque expression a un type qui peut être converti à l’aide de différents mécanismes. Conversions primitives élargissantes
Conversions primitives rétrécissantes double
float
float
long
long
int
int
char
short byte
short char
byte
L’opérateur de concaténation + de type String convertit automatiquement les autres types en String, comme dans l’exemple suivant : int n = 44; String s = "n = " + n;
Dans l’expression "n = " + n, l’opérateur + convertit int n en objet String "44". Ce procédé est qualifié de conversion de chaîne. Si le type de la classe est différent de String, la méthode toString() de cette classe est appelée pour effectuer la conversion. Cette opération peut être héritée de la classe Object. Lorsque plusieurs types numériques sont utilisés dans une expression numérique, les valeurs de type les plus petites sont automatiquement converties en valeurs de type plus grandes, par exemple : double x = 3.14159/4;
L’expression 3.14159/4 contient la valeur double 3.14159 et la valeur 4 de type int ; c’est pourquoi la valeur int est convertie en type double avant l’opération de division. Il s’agit d’une promotion numérique. Le même processus de conversion a lieu lorsqu’un type plus petit est affecté à un type plus grand, comme dans l’exemple suivant : double x = 44;
Cette initialisation affecte la valeur 44 de type int à la variable x de type double. Avant l’affectation, la valeur int est convertie dans son équivalent de type double. Il s’agit de la conversion de l’affectation. Cette technique peut également être utilisée entre des types de référence :
Exemple 3.3 Conversion de l’affectation class X { int a; } class Y extends X { int b; } public class Ex0303 { public static void main(String[] args) { X x = new X(); System.out.println("x.getClass() = "+ x.getClass()); Y y = new Y(); System.out.println("y.getClass() = "+ y.getClass()); x = y; // conversion de l’affectation de x en type Y
Conversion des types
61
System.out.println("x.getClass() = "+ x.getClass()); } } x.getClass() = class X y.getClass() = class Y x.getClass() = class Y
L’affectation x = y attribue le type le plus petit Y au type le plus grand X et convertit ainsi x en type Y. Le polymorphisme décrit dans la section 2.3 est qualifié de conversion de l’appel de méthode parce qu’un type est converti dans un autre lorsqu’il est passé à une méthode appelée. Ce type de conversion a également lieu avec les types primitifs. Par exemple : int n = 44; double y = Math.sqrt(n); // conversion de l’appel de méthode
Dans le cas présent, la valeur 44 de type int est convertie en double lorsque la méthode Math.sqrt(double) est appelée parce que son paramètre est de type double. Pour finir, il reste à préciser que les types peuvent être convertis explicitement à l’aide de l’opérateur de conversion de type.
Exemple 3.4 Transtyper les types de référence class X { public String toString() { return "Je suis un X."; } } class Y extends X { public String toString() { return "Je suis un Y."; } } public class Ex0304 { public static void main(String[] args) { Y y = new Y(); System.out.println("y: " + y); X x = y; System.out.println("x: " + x); Y yy = (Y)x; // transtype x en type Y System.out.println("yy: " + yy); X xx = new X(); System.out.println("xx: " + xx); yy = (Y)xx; // Erreur d’exécution : xx ne peut pas être // transtypé en y System.out.println("yy: " + yy); } } y: Je suis un Y. x: Je suis un Y. yy: Je suis un Y. xx: Je suis un X. Exception in thread "main" java.lang.ClassCastException: X at Testing.main(Testing.java)
La conversion yy = (Y)x convertit x en type Y. Cette opération fonctionne parce que la valeur de x peut être interprétée pour le type Y étant donné qu’elle provient initialement de y. La conversion yy = (Y)xx convertit xx en type Y. La compilation a lieu mais, au moment de l’exécution, l’exception ClassCastException est lancée parce que la valeur de xx ne peut pas être interprétée pour le type Y.
62
Java avancé
Le transtypage de l’exemple 3.4 illustre des conversions rétrécissantes de types de référence se soldant par une réussite ou par un échec. L’exemple suivant vous permettra de constater qu’il est également possible d’effectuer des conversions rétrécissantes de types primitifs réussies ou non.
Exemple 3.5 Transtyper les types primitifs public class Ex0305 { public static void main(String[] args) { double x = 44.0; System.out.println("x = " + x); float y = (float)x; // transtypage rétrécissant de double en float System.out.println("y = " + y); int n = (int)y; // transtypage rétrécissant de float en int System.out.println("n = " + n); short m = (short)n; // transtypage rétrécissant de int en short System.out.println("m = " + m); n = 65536 + 4444; System.out.println("n = " + n); m = (short)n; // transtypage rétrécissant de int en short System.out.println("m = " + m); y = (float)m; // transtypage élargissant de short en float System.out.println("y = " + y); y = (float)n; // transtypage élargissant de int en float System.out.println("y = " + y); } } x y n m n m y y
= = = = = = = =
44.0 44.0 44 44 69980 4444 4444.0 69980.0
Les trois premiers transtypages rétrécissants sont réussis parce que la valeur 44 est appropriée pour les types les plus petits. Cependant, la valeur int 69980 (65536 + 4444) n’est pas transtypée en short : la valeur erronée 4444 est affectée à m. En effet, le rétrécissement de int en short crée toujours le reste issu de la division de la valeur int par 65 536, soit 216. De la même façon, le transtypage élargissant y = (float)m de short en float est réussi tandis que le transtypage élargissant y = (float)n de int en float échoue parce que la valeur int 69980 comprend trop de chiffres significatifs pour le type float. Remarquez que cette valeur numérique est identique à celle obtenue en rétrécissant int en short.
3.4 CLASSE Object La classe Object est la classe ancêtre dominante en Java. Toutes les autres classes en sont dérivées. Chaque méthode de la classe Object est héritée par toutes les autres classes. Vous trouverez ci-après la liste des méthodes les plus importantes : public class Object { protected Object clone() public boolean equals(Object) public final Class getClass()
63
Classe Object
protective int hashCode() public Object() public String toString() }
La plupart de ces méthodes seront remplacées par des sous-classes. La méthode clone() renvoie une copie de l’objet. Si la sous-classe ne remplace pas cette méthode, elle se contente d’effectuer une copie champ par champ.
Exemple 3.6 Clonage incorrect class Widget implements Cloneable { int n; Widget w; Widget(int n) { this.n = n; } public Object clone() throws CloneNotSupportedException { return super.clone(); } public String toString() { return "(" + n + "," + w.n + ")"; } } public class Ex0206 { public static void main(String[] args) throws CloneNotSupportedException { Widget x = new Widget(44); x.w = new Widget(66); System.out.println("x = " + x); Widget y = (Widget)x.clone(); System.out.println("y = " + y); x.n = 55; x.w.n = 77; System.out.println("x = " + x); System.out.println("y = " + y); } } xx yy xx yy r
= = = =
(44,66) (44,66) (55,77) (44,77)
n
44 int
x Widget
w Widget
n
66 int
Le widget x est construit avec le champ n w n 44 initialisé à 44. Puis le champ w est instanWidget int y cié directement dans la deuxième ligne, Widget ce qui permet d’initialiser x.w.n à 66. w Le widget y est construit comme clone Widget de x. La méthode Object.clone() copie simplement les champs n et w de x, sans instancier d’autre widget pour y.w. C’est la raison pour laquelle le widget référencé par x.w n’est pas copié. Lorsque x.n prend la valeur 55, cela n’affecte en aucun cas y.n qui conserve sa valeur 44. Mais lorsque x.w.n prend la valeur 77, il s’agit également d’une valeur de y.w.n, c’est pourquoi y n’est pas complètement indépendant de x. Le clonage est donc incomplet.
64
Java avancé
Exemple 3.7 Clonage correct Essayons maintenant de remplacer la méthode clone() de l’exemple 3.6 par le programme suivant : public Object clone() throws CloneNotSupportedException { Widget newWidget = new Widget(this.n); Widget y = newWidget; Widget t = this.w; while (t != null) { y.w = new Widget(t.n); y = y.w; t = t.w; } return newWidget; } xx yy xx yy
= = = =
(44,66) (44,66) (55,77) (44,66)
Ce programme ne copie pas uniquement le widget, mais également chaque widget associé au widget this. C’est la raison pour laquelle aucun widget cloné n’est complètement indépendant de ce widget. Par conséquent, la modification d’un widget n’a aucun effet sur l’autre.
n
55
n
77
int
x
int
Widget
w
w
Widget
Widget
n
44
n
66
int
y
int
Widget
w
w
Widget
Widget
La méthode equals() doit être remplacée de la même façon que la méthode clone(). Tous les éléments liés correspondants doivent être comparés (reportez-vous à l’exemple 1.7 et à l’exercice d’entraînement 3.4). La méthode getClass() renvoie un objet Class qui représente la classe instanciée par l’objet this. Elle est particulièrement utile lorsque vous souhaitez tester l’égalité des objets (reportez-vous à l’exemple 1.7 et à l’exercice d’entraînement 3.4). La méthode hashCode() renvoie un int qui fonctionne comme un code d’identification de l’objet. Les hash codes de différents objets sont susceptibles de varier. Ceux des objets composés doivent être calculés à partir des hash codes de leurs composants (reportez-vous à l’exemple 1.7 et à l’exercice d’entraînement 3.5). La méthode toString() renvoie un type String qui affiche le contenu de l’objet (reportez-vous aux exemples 1.7 et 3.6).
3.5 CLASSES ABSTRAITES Une classe abstraite comprend au moins une méthode abstraite. Une méthode abstraite ne comporte pas d’implémentation, mais uniquement une déclaration d’en-tête. C’est le mot-clé abstract permet d’identifier les classes et les méthodes abstraites.
Exemple 3.8 Classe Sequence déclarée comme abstract public abstract class Sequence { protected int length=0; public int getLength()
Classes abstraites
65
{ return length; } public abstract void append(Object object); // ajoute l’objet donné à la fin de la séquence public int count(Object object) { int c=0; for (int i=0; i= longueur (length); public abstract boolean remove(Object object); // supprime la première occurrence de l’objet; // renvoie thrue si l’opération est réussie; public abstract Object set(int index, Object object); // renvoie l’objet à l’index donné après l’avoir remplacé par // l’objet donné, ou null l’opération est réussie public String toString() { if (length==0) return "()"; String s = "(" + get(0); for (int i=1; i
Cette classe contient cinq méthodes abstraites et quatre méthodes concrètes, c’est-à-dire non abstraites. Elle comporte également un champ. Remarquez que chaque déclaration de méthode abstraite se termine par un point-virgule. Le fait de définir une classe abstraite vous permet d’avoir recours à différentes implémentations de certaines méthodes qui sont terminées dans les sous-classes.
Exemple 3.9 Sous-classe ArraySequence de la classe Sequence public class ArraySequence extends Sequence { protected Object[] a; protected int capacity=16; // INVARIANT : a[i] != null pour 0 <= i < length; public ArraySequence()
66
Java avancé
{ a = new Object[capacity]; } public void append(Object object) { if (length==capacity) // doubler la capacité { Object[] tmp = a; capacity *= 2; a = new Object[capacity]; for (int i=0; i= length) return null; return a[index]; } public Object remove(int index) { if (index >= length) return null; Object x = a[index]; for (int i=index; i= length) return null; Object x = a[index]; a[index] = object; return x; } }
Cette sous-classe implémente la structure de la séquence comme s’il s’agissait d’un tableau d’Objects. Elle fournit un constructeur par défaut et implémente les cinq méthodes abstraites de la classe Sequence. En voici le pilote test : public class Ex0309 { public static void main(String[] args) { ArraySequence s = new ArraySequence(); System.out.println("s = " + s); s.append("Chili"); s.append("Chine"); s.append("Congo"); s.append("Egypte"); s.append("Chine"); s.append("Inde"); s.append("Italie"); s.append("Chine");
Interfaces
67
System.out.println("s = " + s); System.out.println("s.getLength() = " + s.getLength()); System.out.println("s.count(\"Chine\") = " + s.count("Chine")); System.out.println("s.get(5) = " + s.get(5)); System.out.println("s.indexOf(\"Chine\") = " + s.indexOf("Chine")); System.out.println("s.remove(5) = " + s.remove(5)); System.out.println("s.remove(\"Chine\") = " + s.remove("Chine")); System.out.println("s.indexOf(\"Chine\") = " + s.indexOf("Chine")); System.out.println("s.set(2,\"Japon\") = " + s.set(2,"Japon")); System.out.println("s = " + s); System.out.println("s.getLength() = " + s.getLength()); } } s = () s = (Chili,Chine,Congo,Egypte,Chine,Inde,Italie,Chine) s.getLength() = 8 s.count("Chine") = 3 s.get(5) = Inde s.indexOf("Chine") = 1 s.remove(5) = Inde s.remove("Chine") = true s.indexOf("Chine") = 3 s.set(2,"Japon") = Egypte s = (Chili,Congo,Japon,Chine,Italie,Chine) s.getLength() = 6
Cet exemple utilise un tabeau pour implémenter la classe Sequence définie dans l’exemple 3.8, mais d’autres implémentations sont possibles. Par exemple, nous aurions pu utiliser une structure chaînée similaire à celle de la classe Widget définie dans l’exemple 3.6. Vous pouvez choisir ultérieurement la méthode d’implémentation.
3.6 INTERFACES Un type de données abstrait est une description du comportement du type de données sans détails sur son mode d’implémentation. Dans le cadre de Java, un type de données est une classe et un type de données abstrait est une interface. Une interface est similaire à une classe abstraite, sauf qu’elle n’a pas d’implémentations de méthodes, ni de champs qui ne soient pas final. Elle sert de projet ou de contrat ; tout objet de classe qui implémente une interface est capable de faire ce que l’interface spécifie via ses méthodes. Par conséquent, les autres méthodes peuvent déclarer leurs paramètres de façon à avoir des types d’interface, des types de classe et des types primitifs. Le compilateur autorise le passage d’un objet à ce genre de paramètre tant qu’il s’agit d’une classe qui implémente l’interface, même dans le cas d’une classe abstraite.
Exemple 3.10 Interface Sequence Le programme suivant illustre le cas d’une interface qui pourrait être utilisée à la place de la classe abstraite définie dans l’exemple 3.8 : public interface Sequential { public abstract void append(Object object); // ajoute l’objet donné à la fin de la séquence public int count(Object object); // renvoie le nombre d’occurrences de l’objet // donné dans la séquence
68
Java avancé
public abstract Object get(int index); // renvoie l’objet à l’index donné, // ou null s’il n’est pas dans la séquence public int getLength(); // renvoie le nombre d’éléments de la séquence public int indexOf(Object object); // renvoie le nombre d’éléments précédant // la première occurrence de l’objet donné dans la séquence, // ou -1 si l’objet donné n’est pas dans la séquence public abstract Object remove(int index); // supprime et renvoie l’objet à l’index donné, // ou renvoie null si l’index >= longueur (length); public abstract boolean remove(Object object); // supprime la première occurrence de l’objet; // renvoie thrue si l’opération est réussie; public abstract Object set(int index, Object object); // renvoie l’objet à l’index donné après l’avoir remplacé par // l’objet donné, ou null si l’opération échoue public String toString(); // renvoie une chaîne qui affiche le contenu de la séquence }
Remarquez que les interfaces ne contiennent pas de code exécutable. Par définition, elles ne peuvent contenir que des déclarations de méthodes et des définitions de constantes. Remarquez également que, à l’instar de la bibliothèque Java standard, nous utilisons des formes nominatives pour les noms de classes et des adjectifs pour ceux des interfaces. Il est ainsi plus aisé de se rappeler qu’une classe est un type, tandis qu’une interface est une description de comportement. Par exemple, l’interface Sequential spécifie comment les instances de types qui l’implémentent doivent se comporter, c’est-à-dire comme des structures de données séquentielles. Les interfaces peuvent être utilisées comme les classes pour déclarer des variables et des paramètres, de la façon suivante : public static void print(Sequential sequence) { for (int i=0; i<sequence.getLength(); i++) System.out.println(i + ": " + sequence.get(i)); }
Ce code est compilé et aucune implémentation de l’interface Sequential n’est nécessaire. Cependant, pour être utile, une interface doit être implémentée. Pour cela, il suffit de définir une classe comprenant la définition de toutes les déclarations de méthodes spécifiées dans l’interface et d’ajouter la clause implements à la déclaration de classe.
Exemple 3.11 Implémenter l’interface Sequential à l’aide de la classe ArraySequence public class ArraySequence implements Sequential { protected int length=0; protected Object[] a; protected int capacity=16; // INVARIANT : a[i] != null pour 0 <= i < length; public ArraySequence() { a = new Object[capacity]; }
Interfaces
public void append(Object object) { if (length==capacity) // doubler la capacité { Object[] tmp = a; capacity *= 2; a = new Object[capacity]; for (int i=0; i= length) return null; return a[index]; } public int getLength() { return length; } public int indexOf(Object object) { for (int i=0; i= length) return null; Object x = a[index]; for (int i=index; i= length) return null; Object x = a[index]; a[index] = object; return x; } public String toString() { if (length==0) return "()"; String s = "(" + get(0); for (int i=1; i
69
70
Java avancé
Remarquez que l’exemple 3.11 utilise le même code que celui des exemples 3.8 et 3.9. Cela vous permet de constater que la classe abstraite et l’interface se différencient essentiellement par l’emplacement du code : dans une interface, tout le code exécutable doit se trouver dans l’implémentation, tandis que la classe abstraite peut avoir une partie du code exécutable dans l’implémentation et le reste dans la sous-classe. Les interfaces sont moins souples que les classes abstraites parce qu’elles ne peuvent pas inclure de code exécutable, mais elles sont également plus souples qu’elles car plusieurs interfaces peuvent être implémentées par une seule classe. Java ne permet pas l’héritage multiple, c’est-à-dire une classe qui étend plusieurs autres classes.
3.7 PAQUETAGES Un paquetage est une collection de classes et d’interfaces. Chaque nom de classe ou d’interface doit être unique dans un paquetage, mais le même nom peut être utilisé pour différentes classes ou interfaces de paquetages différents. Par exemple, il existe deux classes Object dans la bibliothèque standard Java : la première se trouve dans le paquetage java.lang et l’autre dans java.org.omg.CORBA. D’un point de vue technique, ces deux noms sont différents parce qu’ils s’écrivent ainsi lorsqu’ils sont complets : java.lang.Object et java.org.omg.CORBA.Object. En d’autres termes, le nom du paquetage auquel la classe appartient est en fait le préfixe du nom de la classe. Les paquetages sont organisés en hiérarchies arborescentes, exactement java applet comme les classes, les interfaces et les répertoires de fichiers (dossiers). awt En fait, les programmeurs Java mappent généralement chaque paquetage color dans un répertoire unique avec la hiérarchie de paquetage correspondant à event celle du répertoire. Ainsi, par exemple, tous les fichiers de classe du paqueimage tage java.awt.event seraient stockés dans le répertoire java/awt/ renderable event/ (ou dossier java\awt\event\ sous Windows). La hiérarchie beans de certains sous-paquetages Java est illustrée dans l’arbre ci-contre. beancontext io La plupart des environnements de développement Java (reportez-vous lang à l’annexe C) conseillent l’utilisation des paquetages. Ils mappent les ref champs du nom de paquetage au chemin du sous-répertoire vers le réperreflect toire dans lequel le paquetage est stocké. Par exemple, un paquetage math appelé books.schaums.dswj.util aurait un chemin d’accès Winutil dows appelé books\schaums\dswj\util.java. jar zip
3.8 GÉRER LES EXCEPTIONS Lorsque vous développez vos programmes, n’oubliez pas que le moindre détail peut être source d’erreur. Une exception est une erreur d’exécution qui provoque le transfert de l’exécution du programme de la source de la condition vers un gestionnaire d’exceptions. La source de la condition est qualifiée de lanceur d’exception et le gestionnaire d’attrapeur. Les erreurs d’exécution sont généralement encapsulées sous forme d’instances de la classe Throwable et de ses sous-classes. Les deux sous-classes qui en dérivent immédiatement sont les classes Error et Exception. La hiérarchie d’héritage suivante illustre en détail la classification des exceptions en fonction des conditions qui les provoquent.
Object Throwable Error LinkageError ThreadDeath VirtualMachineError Exception ClassNotFoundException IOException EOFException FileNotFoundException NoSuchFieldException NoSuchMethodException RuntimeException ArithmeticException IllegalArgumentException IndexOutOfBoundsException NullPointerException
Gérer les exceptions
71
Les deux exemples suivants vous indiquent deux méthodes de gestion d’une exception : en l’attrapant à l’aide de l’instruction try..catch, ou en la passant à l’appelant de l’environnement courant grâce à l’insertion de la clause throw dans la méthode courante.
Exemple 3.12 Une exception attrapée public class Ex0312 { public static void main(String[] args) { try { int n = Integer.parseInt("Abacadabra !"); System.out.println("n = " + n); } catch (Exception e) { System.out.println("e = " + e); } } } e = java.lang.NumberFormatException: Abacadabra !
L’appel Integer.parseInt("Abacadabra!") échoue parce que la chaîne passée ne représente pas un entier. La méthode lance un objet NumberFormatException qui est attrapé par la méthode main(). Les instructions exécutables du bloc catch sont exécutées lorsque l’exception est attrapée.
Exemple 3.13 Une exception non attrapée public class Ex0313 { public static void main(String[] args) { System.out.println("Essayez ça..."); int n = Integer.parseInt("Abacadabra !"); System.out.println("n = " + n); } } Essayez ça... Exception in thread "main" java.lang.NumberFormatException: Abacadabra ! at java.lang.Integer.parseInt(Integer.java:409) at java.lang.Integer.parseInt(Integer.java:458) at Testing.main(Testing.java:12)
Ce programme essaie d’effectuer le même appel de méthode que celui de l’exemple 3.12, mais hors d’un bloc try. C’est la raison pour laquelle le programmeur perd tout contrôle de l’exécution une fois l’exception lancée. En d’autres termes, le système plante et imprime un message d’erreur. La classe NumberFormatException est un descendant de la classe RuntimeException. Seules les extensions de cette dernière peuvent être gérées aisément, comme c’est le cas dans l’exemple 3.13. Toutes les autres exceptions (ou tout au moins la plupart d’entre elles) doivent être attrapées par la méthode à l’origine de l’erreur ou bien par une méthode déjà appelée sur la pile appelante. Les exceptions qui doivent être attrapées (c’est-à-dire qui ne sont pas des extensions de la classe RuntimeException) sont qualifiées d’exceptions vérifiées.
72
Java avancé
?
QUESTIONS DE RÉVISION
QUESTIONS DE RÉVISION
3.1
Qu’est-ce que l’héritage ?
3.2
Qu’est-ce que le polymorphisme ?
3.3
Qu’est-ce que le transtypage ?
3.4
Qu’est-ce que la promotion ?
3.5
Pourquoi la classe Object est-elle qualifiée de « mère de toutes les classes » en Java ?
3.6
Qu’est-ce qu’une méthode abstract ?
3.7
Qu’est-ce qu’une classe abstract ?
3.8
Quelle est la différence entre une classe abstract et une interface ?
3.9
Quelle est la différence entre une sous-classe et une extension de classe ?
3.10 Quelle est la différence entre une classe abstract et une classe concrète ? 3.11 Quelle est la différence entre une classe qui étend une autre classe et une classe qui implémente
une interface ? 3.12 Qu’est-ce qu’un bloc try ? 3.13 Quelle est la différence entre une exception vérifiée et une exception non vérifiée ?
¿
RÉPONSES
RÉPONSES
3.1
L’héritage décrit les relations entre une classe et une autre classe qui l’étend. Cette extension consiste à ajouter des champs et/ou des méthodes supplémentaires, voire d’autres méthodes qui remplacent celles définies dans la première classe. Cette dernière est appelée classe parent ou superclasse et la classe qui en est dérivée est appelée classe enfant ou sous-classe.
3.2
Le polymorphisme fait référence à un objet qui semble se comporter comme un autre. Cet objet similaire est en fait le même objet qui est considéré comme un membre de sa classe parent ou ancêtre.
3.3
Le transtypage correspond à l’utilisation de la valeur d’un type comme s’il s’agissait d’un autre type. Les objets ne peuvent pas modifier leurs types, mais leurs valeurs peuvent être affectées à des objets d’un autre type tant que deux types se trouvent sur le même chemin racine-vers-feuille dans l’arbre d’héritage. Par exemple : • String string1 = "ABCDE"; • Object object = (Object)string1; • • String string2 = (String)object; •
3.4
// // // //
transtypage d’une chaîne comme objet transtypage d’un objet comme chaîne
La promotion est le transtypage appliqué aux types primitifs. Les valeurs des types numériques peuvent être attribuées aux variables de type numérique supérieur. Par exemple : • int n = 44; // promotion d’un int en float • float x = n; // promotion d’un float en double • double y = x;
Révision et entraînement
73
3.5
Dans le cadre du langage Java, la classe Object est la seule sans classe parent. Elle se trouve à la racine de l’arbre d’héritage.
3.6
Une méthode abstract est simplement la déclaration de la fonction, c’est-à-dire les modificateurs, le type de renvoi et la signature de la méthode
3.7
Une classe abstract a au moins une méthode abstract.
3.8
Une classe abstract fonctionne comme une interface, mais elle peut en outre avoir des définitions de champs et des méthodes implémentées. Les méthodes d’une classe abstract qui ne sont pas implémentées doivent être déclarées à l’aide du mot-clé abstract. En revanche, une interface est une classe sans implémentation, c’est-à-dire qu’elle contient uniquement un listing des signatures de méthodes, sans instructions exécutables ni définitions de champs.
3.9
Il n’y a pas de différence entre une sous-classe et une extension de classe, ces deux expressions sont synonymes.
3.10 Une classe abstract est composée d’au moins une méthode abstract (c’est-à-dire implé-
mentée) alors que toutes les méthodes d’une classe concrète doivent être implémentées. 3.11 Lorsqu’une classe en étend une autre, elle hérite des méthodes de champs de cette dernière, à
l’exception des méthodes qu’elle remplace. Lorsqu’une classe implémente une interface, elle doit définir toutes les méthodes dont les signatures sont spécifiées dans l’interface. 3.12 Le bloc try est le bloc d’instructions qui suit le mot-clé try d’une instruction try..catch.
C’est à cet endroit que les méthodes qui lancent les exceptions vérifiées doivent être appelées à moins que la méthode appelante ne lance elle-même l’exception plus haut dans l’arbre appelant. 3.13 Une exception vérifiée est une exception qui doit être appelée par la méthode où elle a eu lieu ou
par une autre méthode située plus haut dans l’arbre appelant. Les méthodes qui lancent les exceptions vérifiées peuvent être appelées uniquement depuis les blocs try ou depuis d’autres méthodes lançant les exceptions. Les exceptions non vérifiées sont uniquement celles qui sont dérivées de la classe RuntimeException.
?
EXERCICES D’ENTRAÎNEMENT
EXERCICES D’ENTRAÎNEMENT
3.1
Étendez la classe Point (reportez-vous à l’exemple 1.7) afin de créer une classe Point3D en ajoutant une troisième coordonnée. Remplacez les méthodes suivantes : • public boolean equals(Object object); • public int hashCode(); • public String toString();
et ajoutez les méthodes suivantes : • private double z; • public Point3D(double x, double y); • public Point3D(double x, double y, double z); • public Point3D(Point3D q); • public double getZ(); • public void setLocation(double x, double y, double z); • public void translate(double dx, double dy, double dz);
74
Java avancé
3.2
Modifiez la classe Point3D de l’exercice précédent afin d’intégrer les ajouts effectués précédemment dans la classe Point. Remplacez les méthodes suivantes : • public double magnitude(); • public void expand(double dr);
et ajoutez les membres suivants : • public static final Point3D ORIGIN; • public Point3D(); • public double distance(Point3D point);
3.3
Essayez de deviner ce que le programme suivant imprimera. Exécutez-le afin de vérifier si vous aviez raison. • class X • { protected int a; • protected int b=22; • public X() • { System.out.print("X(): " + this); • System.out.print(" a = " + a + ", b = " + b); • a = 33; • b = 44; • System.out.println(" a = " + a + ", b = " + b); • } • public String toString() { return "Je suis un X."; } •} • class Y extends X • { protected int c=55; • public Y() • { System.out.print("Y(): " + this); • System.out.print(" a = " + a + ", b = " + b + ", c = " + c); • a = 66; • b = 77; • c = 88; • System.out.println(" a = " + a + ", b = " + b + ", c = " + c); • } • public String toString() { return "Je suis un Y."; } •} • public class Testing • { public static void main(String[] args) • { Y y = new Y(); • } •}
3.4 3.5
¿
Implémentez la méthode equals(Object) pour la classe Widget de l’exemple 3.6. Implémentez la méthode hashCode() pour la classe Widget de l’exemple 3.6.
SOLUTIONS
SOLUTIONS
3.1
Vous obtenez la classe Point3D suivante en étendant la classe Point : • public class Point3D extends Point • { protected double z; • public Point3D(double x, double y) • { this.x = x;
Révision et entraînement • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • •}
3.2
75
this.y = y; this.z = 0; } public Point3D(double x, double y, double z) { this.x = x; this.y = y; this.z = z; } public Point3D(Point3D q) { this.x = q.x; this.y = q.y; this.z = q.z; } public double getZ() { return z; } public void setLocation(double x, double y, double z) { this.x = x; this.y = y; this.z = z; } public void translate(double dx, double dy, double dz) { x += dx; y += dy; z += dz; } public boolean equals(Object object) { if (object == this) return true; if (object.getClass() != this.getClass()) return false; if (object.hashCode() != this.hashCode()) return false; Point3D point = (Point3D)object; return (x == point.x && y == point.y && z == point.z); } public int hashCode() { return (new Double(x)).hashCode() + (new Double(y)).hashCode() + (new Double(z)).hashCode(); } public String toString() { return new String("(" + (float)x + "," + (float)y + "," + (float)z + ")"); }
Vous ajoutez les membres à la classe Point3D de la façon suivante : • public static final Point3D ORIGIN = new Point3D(); • public Point3D() • { this.x = 0; • this.y = 0; • this.z = 0; •} • public double distance(Point3D point) • { double dx = this.x - point.x; • double dy = this.y - point.y; • double dz = this.z - point.z; • return Math.sqrt(dx*dx+dy*dy+dz*dz); •} • public double magnitude() • { return distance(ORIGIN); •}
76
Java avancé • public • { x *= • y *= • z *= •}
3.3
void expand(double dr) dr; dr; dr;
L’ordre des événements est le suivant : 1. Paramétrage des champs à l’aide des valeurs par défaut. 2. Appel du constructeur Y(). 3. Appel du constructeur X(). 4. Appel du constructeur Object. 5. Initialisation des champs X. 6. Exécution du constructeur X(). 7. Initialisation des champs Y. 8. Exécution du constructeur Y(). Vous obtenez alors la sortie suivante : • • X(): Je suis un Y. a = 0, b = 22 a = 33, b = 44 • Y(): Je suis un Y. a = 33, b = 44, c = 55 a = 66, b = 77, c = 88
3.4
La méthode equals(Object) est implémentée de la façon suivante pour la classe Widget : • public boolean equals(Object object) • { if (object == this) return true; • if (object.getClass() != this.getClass()) return false; • Widget y = (Widget)object; • Widget t = this; • while (y != null || t != null) • { if (y == null || t == null) return false; • if (y.n != t.n) return false; • y = y.w; • t = t.w; • } • return true; •}
3.5
La méthode hashCode() est implémentée de la façon suivante pour la classe Widget : • public int hashCode() • { int hc = (new Integer(n)).hashCode(); • Widget tw = this.w; • while (tw != null) • { hc += (new Integer(tw.n)).hashCode(); • tw = tw.w; • } • return hc; •}
Chapitre 4
Récursivité Une fonction est dite récursive lorsqu’elle s’appelle elle-même. Cette puissante technique de programmation crée des répétitions sans utiliser de boucles while, do..while ou for. C’est pourquoi elle est capable de produire des résultats substantiels à partir de très peu de code. En outre, la récursivité vous permettra de trouver des solutions d’une grande élégance à un certain nombre de problèmes. Attention cependant, lorsqu’elle est mal utilisée, cette subtilité informatique peut créer un code totalement inefficace. Le code récursif provient généralement des algorithmes récursifs.
Exemple 4.1 Fonction factorielle La fonction factorielle est définie mathématiquement de la façon suivante : n! = 1, if n = 0 n ( n – 1 )!, if n > 0
Cette définition est récursive parce que la factorielle revient à droite de l’équation. La fonction est définie par elle-même. Le tableau ci-contre contient les dix premières valeurs de la fonction factorielle. La première, 0! est définie par la moitié supérieure de la définition 0! = 1 (pour n = 0). Toutes les autres valeurs sont définies par la moitié inférieure de la définition, c’est-à-dire : • Pour n = 1, 1! = n! = n(n – 1)! = 1(1 – 1)! = 1(0)! = 1(1) = 1. • Pour n = 2, 2! = n! = n(n – 1)! = 2(2 – 1)! = 2(1)! = 2(1) = 2. • Pour n = 3, 3! = n! = n(n – 1)! = 3(3 – 1)! = 3(2)! = 3(2) = 6. • Pour n = 4, 4! = n! = n(n – 1)! = 4(4 – 1)! = 4(3)! = 4(6) = 24. • Pour n = 5, 5! = n! = n(n – 1)! = 5(5 – 1)! = 5(4)! = 5(24) = 120.
n
n!
0
1
1
1
2
2
3
6
4
24
5
120
6
720
7
5 040
8
40 310
9 362 880
Comme vous pouvez le constater, cette fonction croît rapidement.
Exemple 4.2 Implémenter récursivement la fonction factorielle Lorsqu’une fonction est définie récursivement, son implémentation est généralement une traduction directe de sa définition récursive. Les deux parties qui constituent la définition récursive de la fonction factorielle sont directement traduites en deux instructions Java : public static int f(int n) { if (n==0) return 1; // base return n*f(n-1); // partie récursive }
78
Récursivité
Vous trouverez ci-après un pilote test simple pour la fonction factorielle : public static void main(String[] args) { for (int n=0; n<10; n++) System.out.println("f("+n+") = "+f(n)); }
Ce programme est censé imprimer les mêmes valeurs que celles du tableau précédent.
Exemple 4.3 Implémenter itérativement la fonction factorielle Il est également facile d’implémenter la fonction factorielle itérativement : public static int f(int n) { int f=1; for (int i=2; i<=n; i++) f *= i; return f; }
Remarquez que l’en-tête de la fonction est identique à celui de l’exemple 4.2, seul le corps diffère. Cela nous permet d’utiliser le même pilote test pour les deux implémentations. Le résultat obtenu doit être identique dans les deux cas.
4.1 LA BASE ET LA PARTIE RÉCURSIVE DE LA RÉCURSIVITÉ Pour fonctionner correctement, chaque fonction récursive doit être composée d’une base et d’une partie récursive. La base arrête la récursivité et c’est dans la partie récursive que la fonction s’appelle elle-même.
Exemple 4.4 Base et partie récursive de la fonction factorielle Dans la méthode Java qui implémente la fonction factorielle de l’exemple 4.2, la base et la partie récursive sont associées à des commentaires. La partie récursive appelle la méthode et passe une valeur plus petite de n. Ainsi, si vous commencez par la valeur positive 5, les valeurs des appels suivants seront 4, 3, 2, 1 et 0. Lorsque 0 est passé, la base est exécutée, ce qui met un terme à la récursivité et provoque le début de la chaîne des renvois, soit 1, 1, 2, 6, 24 et 120. n Fn
Exemple 4.5 Nombres de Fibonacci Les nombres de Fibonacci sont les suivants : 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, …. Chaque nombre suivant le deuxième est la somme des deux nombres précédents. Il s’agit d’une définition récursive naturellement : 0, if n = 0 F n = 1, if n = 1 F n – 1 + F n – 2 , if n > 1
Les 15 premières valeurs de la séquence de Fibonacci sont présentées dans le tableau ci-contre. Les deux premières valeurs F0 et F1 sont définies par les deux premières parties de la définition : F0 = 0 (pour n= 0) et F1 = 1 (pour n = 1). Ces deux parties constituent la base de la récursivité.
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14
0 1 1 2 3 5 8 13 21 34 55 89 144 233 377
79
Tracer un appel récursif
Toutes les autres valeurs sont définies par la partie récursive de la définition : • • • • • •
Pour n = 2, F2 = Fn = Fn – 1 + Fn – 2 = F(2) – 1 + F(2) – 2 = F1 + F0 = 1 + 0 = 1. Pour n = 3, F3 = Fn = Fn – 1 + Fn – 2 = F(3) – 1 + F(3) – 2 = F2 + F1 = 1 + 1 = 2. Pour n = 4, F4 = Fn = Fn – 1 + Fn – 2 = F(4) – 1 + F(4) – 2 = F3 + F2 = 2 + 1 = 3. Pour n = 5, F5 = Fn = Fn – 1 + Fn – 2 = F(5) – 1 + F(5) – 2 = F4 + F3 = 3 + 2 = 5. Pour n = 6, F6 = Fn = Fn – 1 + Fn – 2 = F(6) – 1 + F(6) – 2 = F5 + F4 = 5 + 3 = 8. Pour n = 7, F7 = Fn = Fn – 1 + Fn – 2 = F(7) – 1 + F(7) – 2 = F6 + F5 = 8 + 5 = 13.
Exemple 4.6 Implémenter récursivement la fonction Fibonacci public static int fib(int n) { if (n < 2) return n; // base return fib(n-1) + fib(n-2); // partie récursive }
Vous trouverez ci-après un pilote test simple de la méthode de Fibonacci : public static void main(String[] args) { for (int n=0; n<16; n++) System.out.println("fib(" + n + ") = " + fib(n)); }
Les valeurs obtenues devraient être identiques à celles du tableau de l’exemple 4.5.
4.2 TRACER UN APPEL RÉCURSIF Le traçage de l’exécution d’une méthode permet généralement de le rendre plus clair.
Exemple 4.7 Tracer la fonction factorielle récursive La figure suivante illustre comment vous pouvez tracer l’appel f(5) de la fonction factorielle récursive définie dans l’exemple 4.2 : main() n 5
f(5) 5 120
n 5
f(4) 4 24
n 4
f(3) 3
n 3
6
f(2) 2 2
n 2
f(1) 1
n 1
1
L’appel commence dans la fonction main() et la valeur 5 est passée à la fonction f(). La valeur du paramètre n est alors 5, f(4) est appelée et la valeur 4 est passée à la fonction f(). La valeur du paramètre n est alors 4, f(3) est appelée et la valeur 3 est passée à la fonction f(). Le processus continue (récursivement) jusqu’à ce que l’appel f(1) soit effectué depuis l’appel f(2). La valeur du paramètre n est alors 1. Cette valeur est renvoyée immédiatement sans qu’aucun autre appel ne soit effectué. L’appel f(2) renvoie ensuite 2*1=2 à l’appel f(3). L’appel f(3) renvoie à son tour 3*2=6 à l’appel f(4) qui renvoie 4*6=24 à l’appel f(5). En dernier lieu, l’appel f(5) renvoie la valeur 120 à main(). Le traçage de cet exemple indique que l’appel f(n) de l’implémentation récursive de la fonction factorielle générera n – 1 appels récursifs. Cette technique est clairement inefficace comparée à l’implémentation itérative de l’exemple 4.3.
Exemple 4.8 Tracer la fonction Fibonacci récursive La fonction Fibonacci (voir l’exemple 4.6) est plus récursive que la fonction factorielle de l’exemple 4.2 dans la mesure où elle inclut deux appels récursifs. Vous pouvez en constater les conséquences
80
Récursivité
dans la trace de l’appel fib(5) illustrée ci-après. L’appel commence dans la fonction main() et la valeur 5 est passée à la fonction fib(). La valeur du paramètre n est alors 5, fib(4) et fib(3) sont ensuite appelées et les valeurs 3 et 4 sont respectivement passées. Chacun de ces appels effectue ensuite deux appels récursifs jusqu’aux appels de base f(1) et f(0) qui renvoient tous les deux 1. Les appels récursifs renvoient ensuite la somme des deux valeurs qui leur ont été renvoyées pour que la valeur 8 soit finalement renvoyée à main(). main()
fib(5)
5
n 5
n 5
8
3
4 fib(4)
5
fib(3)
3
n 3
n 4 3
2
2
fib(3) 3
n 3 2 fib(2)
1 2
1
n 2 1 fib(1) n 1
2
fib(2)
fib(2)
n 2
n 2
1 fib(1)
fib(1)
n 1
n 1
0 1
1
fib(1)
n 0
n 1
2
1
fib(1) n 1
1 fib(0)
1
0 1
1
fib(0) n 0
0 1
1
fib(0) n 0
4.3 ALGORITHME RÉCURSIF DE RECHERCHE BINAIRE Nous avons déjà vu l’algorithme de recherche binaire non récursif dans la section 2.5. Vous vous rappelez certainement qu’il utilise la stratégie « Diviser pour mieux régner » en fractionnant la séquence en deux et en poursuivant l’analyse dans la moitié contenant l’élément recherché. Ce procédé est naturellement récursif.
Algorithme 4.1 Recherche binaire récursive (Condition préalable : s = {s0, s1, …, sn – 1} est une séquence triée de n valeurs ordinales du même type que x.) (Condition postérieure : l’index i est renvoyé lorsque si = x ; dans le cas contraire, –1 est renvoyé.) 1. Si la séquence est vide, renvoyer –1. 2. Supposons que si soit l’élément central de la séquence. 3. Si si = x, renvoyer son index i. 4. Si si < x, appliquer l’algorithme sur la sous-séquence se trouvant au-dessus de si. 5. Appliquer l’algorithme à la sous-séquence de ss se trouvant au-dessus de si. D’après la condition préalable de l’algorithme 4.1, la séquence doit être triée.
Exemple 4.9 Recherche binaire récursive public static int search(int[] a, int lo, int hi, int x) { // Condition préalable : a[0] <= a[1] <= ... <= a[a.length-1]; // Conditions postérieures : renvoie i; si i >= 0, alors a[i] == x; // sinon i == -1;
Algorithme récursif de recherche binaire
81
{if (lo>hi) return -1; // étape 1 int i = (lo+hi)/2; // étape 2 if (a[i] == x) return i; // étape 3 else if (a[i] < x) return search(a,i+1,hi,x); // étape 4 else return search(a,lo,i-1,x); // étape 5 } }
✽ Théorème 4.1 : la durée d’exécution de la recherche binaire récursive est égale à O(lgn). Démonstration : l’argument qui sous-tend ce théorème est globalement identique à celui du théorème 2.4. La durée d’exécution est proportionnelle au nombre d’appels récursifs effectués. Chaque appel traite une sous-séquence correspondant à la moitié de la précédente. C’est la raison pour laquelle le nombre d’appels récursifs est égal au nombre de fois où n peut être divisé par 2, soit lg n.
Exemple 4.10 Tester la recherche binaire récursive import schaums.dswj.Arrays; public class Ex0410 { private static final int SIZE = 16; private static final int START = 40; private static final int RANGE = 20; private static int[] a = new int[SIZE]; public static void main(String[] args) { Arrays.load(a,START,RANGE); java.util.Arrays.sort(a); Arrays.print(a); test(); test(); test(); test(); } public static void test() { int x = Arrays.load(START,RANGE); System.out.print("Recherche de x = " + x + ":\t"); int i = search(a,x); if (i >= 0) System.out.println("a[" + i + "] = " + a[i]); else System.out.println("i = " + i + " ==> x introuvable"); } public static int search(int[] a, int x) { return search(a,0,a.length-1,x); } public static int search(int[] a, int lo, int hi, int x) { if (lo>hi) return -1; // base int i = (lo+hi)/2; if (a[i] == x) return i; else if (a[i] < x) return search(a,i+1,hi,x); else return search(a,lo,i-1,x); } } 0 1 { 45, 45, Recherche Recherche Recherche Recherche
2 3 4 5 6 7 8 9 10 11 12 13 14 15 45, 45, 48, 48, 49, 50, 51, 52, 53, 54, 54, 58, 58, 59 } de x = 46: i = -1 ==> x introuvable de x = 48: a[5] = 48 de x = 54: a[11] = 54 de x = 57: i = -1 ==> x introuvable
82
Récursivité
Ce test est similaire à celui de l’exemple 2.8. Vous constaterez que, pour avoir la signature de méthode search(int[],int) avec uniquement deux paramètres, vous devez « envelopper » la méthode récursive dans une méthode pilote non récursive afin de la démarrer.
4.4 COEFFICIENTS BINOMIAUX Les coefficients binomiaux sont le résultat de l’expansion d’une expression binomiale de la forme (x + 1)n. Par exemple, (x + 1)6 =x6 + 6x5 + 15x4 = 20x3 + 15x2 + 6x +1 Les 7 coefficients générés ici sont 1, 6, 15, 20, 15, 6 et 1. Le mathématicien Pascal (1623-1662) a découvert une relation récursive dans les coefficients binomiaux. En les organisant dans un triangle, il a trouvé que chaque nombre interne est la somme des deux nombres se trouvant directement au-dessus de lui : 1 1 1 1 1
Ligne 6
1 1 1
8
4
6
6
15
1 1 4
10 20
35 56
Colonne 2
1 3
10
21 28
2
3
5
7
1
15 35
70
1 5
1 6
21 56
1 7
28
1 8
1
Par exemple, 15 = 5 + 10. Supposons que c(n,k) indique le coefficient du numéro de ligne n et du numéro de colonne k (en comptant à partir de 0). Si nous prenons l’exemple de c(6,2) = 15, la relation de récurrence de Pascal peut être exprimée de la façon suivante : c(n, k) = c(n–1, k–1) + c(n–1, k), for 0 < k < n soit, lorsque n = 6 et k = 2, c(6,2) = c(5,1) + c(5,2).
Exemple 4.11 Implémenter récursivement la fonction des coefficients binomiaux public static int c(int n, int k) { if (k==0 || k==n) return 1; // base return c(n-1,k-1) + c(n-1,k); // récursivité }
La base de la récursivité couvre les parties gauche et droite du triangle, avec k = 0 et k = n. Les coefficients binomiaux sont identiques aux nombres de combinaison utilisés en mathématiques combinatoires et calculés explicitement par la formule suivante : n n–1 n–2 n–k+1 n! c ( n, k ) = ----------------------- = --- ------------ ------------ … --------------------- 1 2 3 k k! ( n – k )!
Dans ce contexte, la combinaison est souvent écrite sous la forme suivante : c ( n, k ) = n k En outre, vous la lirez en disant que « n choisit k ». Par exemple, si n = 8 et k = 3, vous obtenez la combinaison suivante : 8 = ( 8 ⁄ 1 ) ( 7 ⁄ 2 ) ( 6 ⁄ 3 ) = 56 3
83
Algorithme d’Euclide
Exemple 4.12 Implémenter itérativement la fonction des coefficients binomiaux Cette version implémente la formule explicite donnée précédemment. L’expression de droite est composée de coefficients k, c’est pourquoi le calcul est effectué par une boucle répétée un nombre k de fois : public static int c(int n, int k) { if (n<2 || k==0 || k==n) return 1; int c=1; for (int j=1; j <= k; j++) c = c*(n-j+1)/j; return c; }
4.5 ALGORITHME D’EUCLIDE L’algorithme d’Euclide calcule le plus grand diviseur commun de deux entiers positifs. Il apparaît comme Proposition 2 du livre VII composant les Éléments d’Euclide (v. 300 av. J.-C.) et est probablement l’algorithme récursif le plus ancien. Tel qu’il a été initialement formulé par Euclide, il consiste à soustraire plusieurs fois le nombre n le plus petit du plus grand nombre m jusqu’à ce que la différence d obtenue soit plus petite que n. Répétez ensuite les mêmes étapes en remplaçant n par d et m par n. Continuez jusqu’à ce que les deux nombres soient égaux. Le nombre obtenu est alors le plus grand diviseur commun des deux nombres initiaux. Cet exemple applique l’algorithme d’Euclide pour rechercher le plus grand diviseur commun de 494 et 130, c’est-à-dire 26. Ceci est correct puisque 494 = 26⋅19 et 130 = 26⋅5.
494 –130 364 –130 234 –130 130 104 –104 26
104 –26 78 –26 52 –26 26
Exemple 4.13 Implémenter récursivement l’algorithme d’Euclide Chaque étape de cet algorithme ne fait que soustraire le plus petit nombre du plus grand en appelant récursivement gcd(m,n-n) ou gcd(m-n,n) : public static int gcd(int m, int n) { if (m==n) return n; // base else if (m
Par exemple, l’appel gcd(494,130) appelle récursivement gcd(364,130), qui appelle récursivement gcd(234,130), qui appelle récursivement gcd(104,130), qui appelle récursivement gcd(104,26), qui appelle récursivement gcd(78,26), qui appelle récursivement gcd(52,26), qui appelle récursivement gcd(26,26), qui renvoie 26. Cette valeur est ensuite renvoyée successivement jusqu’à l’appel initial gcd(494,130) qui la renvoie à sa routine d’appel.
4.6 PREUVE INDUCTIVE DE CORRECTION Les fonctions récursives sont généralement démontrées à l’aide du principe d’induction mathématique (voir l’annexe B). Selon ce principe, il est possible de prouver qu’une séquence infinie d’instructions est vraie en vérifiant (i) si la première instruction est vraie, et (ii) en en déduisant que toutes les autres propositions de la séquence sont vraies. L’étape (i) est qualifiée d’étape de base et (ii) d’étape inductive. La supposition selon laquelle les propositions précédentes sont vraies est qualifiée d’hypothèse inductive.
84
Récursivité
✽ Théorème 4.2 : la fonction factorielle récursive est correcte. Démonstration : pour prouver que l’implémentation récursive de la fonction factorielle est correcte (voir l’exemple 4.1), nous devons tout d’abord vérifier la base. L’appel f(0) renvoie la valeur correcte 1 en raison de la première ligne : if (n < 2) return 1;
Nous supposons ensuite que la fonction renvoie la valeur correcte pour tous les entiers inférieurs à un nombre n > 0. Puis la deuxième ligne : return n*f(n-1);
renverra la valeur n! correcte parce que, si nous nous en tenons à l’hypothèse inductive, l’appel f(n-1) renverra (n – 1)! et n! = n (n – 1). Remarquez que nous utilisons ici le principe fort d’induction mathématique (également qualifié de deuxième principe). Dans cette version, l’hypothèse inductive nous permet de supposer que toutes les propositions précédentes sont vraies. Dans le cadre du principe faible (ou premier principe), nous pouvons uniquement supposer que seule la proposition précédente est vraie. Cependant, étant donné que ces deux principes sont équivalents (c’est-à-dire qu’ils permettent tous les deux d’établir la preuve d’une proposition générale), il est généralement préférable d’appliquer la méthode d’induction forte. ✽ Théorème 4.3 : l’algorithme d’Euclide est correct. Démonstration : Pour prouver que l’algorithme d’Euclide (voir l’exemple 4.13) est correct, nous pouvons utiliser l’induction forte. Si m et n sont égaux, ce nombre est leur plus grand diviseur commun. La fonction renvoie donc la valeur correcte dans ce cas en raison de la ligne de code suivante : if (m == n) return n;
Si m et n ne sont pas égaux, la fonction renvoie gcd(m,n-m) ou gcd(m-n,n). Pour constater que cette valeur est également correcte, nous avons simplement besoin de voir que les trois paires (m,n), (m,n-m) et (m-n,n) auront toujours le même plus grand diviseur commun. Il s’agit là d’un théorème de la théorie des nombres présenté dans l’annexe B.
4.7 ANALYSE DE LA COMPLEXITÉ DES ALGORITHMES RÉCURSIFS L’analyse de la complexité d’un algorithme récursif dépend de sa relation de récurrence. Généralement, la meilleure technique consiste à utiliser T(n) comme nombre d’étapes nécessaires à l’application d’un algorithme pour un problème de taille n. La partie récursive de l’algorithme se traduit en relation de récurrence sur T(n). Sa solution est ensuite la fonction de complexité de l’algorithme. ✽ Théorème 4.4 : la fonction factorielle récursive a une durée d’exécution égale à O(n). Démonstration : supposons que T(n) soit le nombre d’appels récursifs effectués à partir de l’appel initial f(n) de la fonction (voir l’exemple 4.2). T(0) = T(1) = 0 parce que si n < 2, aucun appel récursif n’a lieu. Si n > 1, la ligne return n*f(n-1);
est exécutée et effectue l’appel récursif f(n - 1). Le nombre total d’appels récursifs est 1 plus le nombre d’appels effectués depuis f(n - 1), ce qui se traduit de la façon suivante dans la relation de récurrence : T(n) = 1 + T(n – 1) La solution de cette récurrence est la suivante : T(n) = n – 1, pour n > 0
85
Programmation dynamique
Nous arrivons à cette conclusion en deux étapes : tout d’abord, nous trouvons la solution, puis nous utilisons l’induction pour prouver qu’elle est correcte. La technique la plus simple de recherche d’une solution pour la relation de récurrence est de créer un tableau de valeurs et de rechercher un schéma. Dans le cas présent, la relation de récurrence nous indique que chaque valeur de T(n) est supérieure de 1 à la valeur précédente. La solution f(n) = n – 1 est donc plutôt évidente. Maintenant, pour prouver que T(n) = n – 1 chaque fois que n > 0, nous supposons que f(n) = n – 1 et nous appliquons le principe faible de l’induction mathématique. La situation de base est lorsque n = 1. Dans ce cas, T(n) = T(1) = 0 et f(n) = f(1) = (1) – 1 = 0. Pour l’étape inductive, nous supposons que T(n) = f(n) pour n > 0, puis nous en déduisons que T(n + 1) = f(n + 1) :
n
T (n)
0
0
1
0
2
1
3
2
4
3
5
4
6
5
T(n + 1) = 1 + T(n) = 1 + f(n) = 1 + (n – 1) = n f(n + 1) = (n + 1) – 1 = n Voilà qui termine notre démonstration. Maintenant que nous avons déterminé que la fonction de complexité pour l’implémentation récursive de la fonction factorielle T(n) = n – 1, nous pouvons en déduire que la durée d’exécution de cette implémentation sera proportionnelle à la taille de son argument n. S’il faut 3 millisecondes pour calculer 8!, il faudra environ 6 millisecondes pour calculer 16!.
4.8 PROGRAMMATION DYNAMIQUE Dans la plupart des cas, la récursivité est rendue inefficace par ses nombreux appels de fonctions. C’est la raison pour laquelle il est souvent préférable d’avoir recours à une implémentation itérative lorsqu’elle n’est pas trop complexe. Une troisième possibilité s’offre également à vous, elle consiste à implémenter la relation de récurrence en stockant les valeurs calculées précédemment dans un tableau au lieu de les recalculer grâce aux appels récursifs de fonctions. Cette méthode est qualifiée de programmation dynamique.
Exemple 4.14 Implémenter la fonction Fibonacci à l’aide de la programmation dynamique public static int fib(int n) { if (n<2) return n; int[] f = new int[n]; f[0] = 0; f[1] = 1; for (int i=2; i
Cette implémentation utilise un tableau dynamique f[] d’entiers longs n pour stocker les premiers nombres n de Fibonacci.
4.9 LES TOURS DE HANOI Nous venons de voir des exemples importants de fonctions qui sont définies plus naturellement et qui sont plus aisément compréhensibles si vous utilisez la récursivité. Dans certains cas, la récursivité est d’ailleurs la seule méthode acceptable, comme nous allons le voir pour le jeu des tours de Hanoi.
86
Récursivité
Ce jeu est composé d’un plateau sur lequel se trouA B C vent trois tours (appelées A, B et C) et une séquence de n disques avec un trou au centre. Les disques sont empilés par taille décroissante (c’est-à-dire d’abord le rayon de 8 cm, puis celui de 7 cm, 6 cm, 5 cm, etc.) sur la tour A. La règle est simple : aucun disque ne peut être placé sur un disque plus petit de la même tour. L’objectif est de déplacer tous les disques de la tour A vers la tour C, un disque à la fois, en respectant la règle énoncée précédemment. La solution générale est naturellement récursive : • Partie I : déplacer les disques n – 1 les plus petits de la tour A vers la tour B. • Partie II : déplacer le disque restant de la tour A vers la tour C. • Partie III : déplacer les disques n – 1 les plus petits de la tour B vers la tour C. Les étapes 1 et 3 sont récursives puisque vous appliquez la solution complète aux disques n – 1. La base de cette solution récursive est le cas n = 0, c’est-à-dire, ne faites rien. La solution du cas du disque n = 1 est la suivante : 1. Déplacer le disque de la tour A vers la tour C. La solution du cas des disques n = 2 est la suivante : 1. Déplacer le disque supérieur de la tour A vers la tour B. 2. Déplacer le deuxième disque de la tour A vers la tour C. 3. Déplacer le disque supérieur de la tour B vers la tour C. La solution du cas des disques n = 3 est la suivante : 1. 2. 3. 4. 5. 6. 7.
Déplacer le disque supérieur de la tour A vers la tour C. Déplacer le deuxième disque de la tour A vers la tour B. Déplacer le disque supérieur de la tour C vers la tour B. Déplacer le disque restant de la tour A vers la tour C. Déplacer le disque supérieur de la tour B vers la tour A. Déplacer le deuxième disque de la tour B vers la tour C. Déplacer le disque supérieur de la tour A vers la tour C.
Dans cet exemple, les étapes 1 à 3 constituent la première partie de la solution générale, l’étape 4 la deuxième partie et les étapes 5 à 7 la troisième partie. Étant donné que la solution générale récursive requiert la substitution de différents noms de tours, il est préférable d’utiliser des variables. Ainsi, si nous appelons cet algorithme en trois étapes hanoi(n,x,y,z), nous obtenons la solution suivante : • Partie I : déplacer les disques n –1 les plus petits de la tour x vers la tour z. • Partie II : déplacer le disque restant de la tour x vers la tour y. • Partie III : déplacer les disques n – 1 les plus petits de la tour z vers la tour y. Vous trouverez l’implémentation complète de cette solution générale dans l’exemple suivant.
Exemple 4.15 Les tours de Hanoi public class Ex0415 { public static void main(String[] args) { runHanoi(4,’A’,’B’,’C’); } public static void runHanoi(int n, char x, char y, char z) { if (n==1) // base
87
Récursivité mutuelle
System.out.println("Déplacer le disque supérieur de la tour " + x + " vers la tour " + z); else // récursivité { runHanoi(n-1,x,z,y); runHanoi(1,x,y,z); runHanoi(n-1,y,x,z); } } }
Dans le cas de quatre disques, la solution est créée par l’appel : runHanoi(4,’A’,’B’,’C’);
Vous obtenez alors la sortie suivante : Déplacer Déplacer Déplacer Déplacer Déplacer Déplacer Déplacer Déplacer Déplacer Déplacer Déplacer Déplacer Déplacer Déplacer Déplacer
le le le le le le le le le le le le le le le
disque disque disque disque disque disque disque disque disque disque disque disque disque disque disque
supérieur supérieur supérieur supérieur supérieur supérieur supérieur supérieur supérieur supérieur supérieur supérieur supérieur supérieur supérieur
de de de de de de de de de de de de de de de
la la la la la la la la la la la la la la la
tour tour tour tour tour tour tour tour tour tour tour tour tour tour tour
A A B A C C A A B B C B A A B
vers vers vers vers vers vers vers vers vers vers vers vers vers vers vers
la la la la la la la la la la la la la la la
tour tour tour tour tour tour tour tour tour tour tour tour tour tour tour
B C C B A B B C C A A C B C C
4.10 RÉCURSIVITÉ MUTUELLE Lorsqu’une fonction s’appelle elle-même, il s’agit d’une récursivité directe. Mais il existe aussi une récursivité indirecte, quand une fonction appelle d’autres fonctions, qui appellent d’autres fonctions, qui finissent par appeler la fonction initiale. La récursivité mutuelle est la forme la plus courante de ce type de récursivité ; elle a lieu lorsque deux fonctions s’appellent mutuellement.
g()
f() f() f()
g()
h()
Exemple 4.16 Fonctions sinus et cosinus calculées par récursivité mutuelle Les fonctions trigonométriques sinus et cosinus peuvent être définies de plusieurs façons et divers algorithmes vous permettent de calculer leurs valeurs. Bien qu’elle ne soit pas la plus efficace, la méthode la plus simple consiste à utiliser la récursivité mutuelle. Elle est basée sur les identités suivantes : sin2θ = 2sincosθ cos2θ = 1 – 2(sinθ)2 et sur les deux polynômes de Taylor : sinx ≈ x – x3/6 cosx ≈ 1 – x2/2 qui sont des approximations de petites valeurs de x.
88
Récursivité
public class Ex0416 { public static void main(String[] args) { for (double x=0. ; x<1.0; x += 0.1) System.out.println(s(x) + "\t" + Math.sin(x)); for (double x=0. ; x<1.0; x += 0.1) System.out.println(c(x) + "\t" + Math.cos(x)); } public static double s(double x) { if (-0.005 < x && x < 0.005) return x - x*x*x/6; // base return 2*s(x/2)*c(x/2); // récursivité } public static double c(double x) { if (-0.005 < x && x < 0.005) return 1.0 - x*x/2; // base return 1 - 2*s(x/2)*s(x/2); // récursivité } }
0.0 0.09983341664635367 0.1986693307941265 0.29552020665442036 0.3894183423068936 0.4794255385990945 0.5646424733830799 0.6442176872361944 0.7173560908968648 0.7833269096232099 0.8414709848016061 1.0 0.9950041652780733 0.9800665778414311 0.9553364891277464 0.9210609940036278 0.8775825618931635 0.8253356149178575 0.7648421872857489 0.6967067093499022 0.6216099682760496 0.5403023058779364
0.0 0.09983341664682815 0.19866933079506122 0.2955202066613396 0.3894183423086505 0.479425538604203 0.5646424733950354 0.644217687237691 0.7173560908995227 0.7833269096274833 0.8414709848078964 1.0 0.9950041652780258 0.9800665778412416 0.955336489125606 0.9210609940028851 0.8775825618903728 0.8253356149096783 0.7648421872844885 0.6967067093471655 0.6216099682706645 0.5403023058681398
Ce programme fonctionne parce que x est divisé par 2 à chaque appel récursif. Il finit ainsi par atteindre le critère de base (-0.005 < x && x < 0.005) qui arrête la récursivité. Lorsque x est très petit, les polynômes de Taylor donnent des résultats précis à 15 décimales près.
?
QUESTIONS DE RÉVISION
QUESTIONS DE RÉVISION
4.1 4.2
Une fonction récursive doit être composée d’une étape de base et d’une étape récursive. Expliquez en quoi consistent ces étapes et leur rôle essentiel dans la récursivité. Combien d’appels récursifs de la fonction factorielle récursive seront-ils générés par l’appel f(10) (voir l’exemple 4.2) ?
Révision et entraînement
89
4.3
Combien d’appels récursifs de la fonction récursive Fibonacci seront-ils générés par l’appel fib(6) (voir l’exemple 4.6) ?
4.4
Quels sont les avantages et les inconvénients de l’implémentation d’une solution récursive au lieu d’une solution itérative ?
4.5
Quelle est la différence entre la récursivité directe et la récursivité indirecte ?
¿
RÉPONSES
RÉPONSES
4.1
La base d’une fonction récursive est son point de départ lors de sa définition et son étape finale lorsqu’elle est appelée récursivement ; c’est ce qui termine la récursivité. Quant à la partie récursive, il s’agit de l’affectation qui comprend la fonction à droite de l’opérateur d’affectation et qui provoque l’appel de la fonction par elle-même ; c’est ce qui crée les répétitions. Par exemple, dans le cas de la fonction factorielle, la base est n! = 1 si n = 0 et la partie récursive est n! = n(n – 1) si n > 0.
4.2
L’appel factorial(10) générera 10 appels récursifs.
4.3
L’appel f(6) de la fonction Fibonacci générera 14 + 8 = 22 appels récursifs parce qu’il appelle f(5) et f(4) qui génèrent respectivement 14 et 8 appels récursifs.
4.4
Une solution récursive est souvent plus facile à comprendre que son équivalent itératif. Cependant, la récursivité s’exécute beaucoup plus lentement que l’itération.
4.5
La récursivité directe a lieu quand une fonction s’appelle elle-même. La récursivité indirecte a lieu lorsque des fonctions s’appellent entre elles.
?
EXERCICES D’ENTRAÎNEMENT
EXERCICES D’ENTRAÎNEMENT
4.1
Écrivez et testez une fonction récursive qui renvoie la somme des carrés des premiers entiers positifs n.
4.2
Écrivez et testez une fonction récursive qui renvoie la somme des premières puissances n d’une base b.
4.3
Écrivez et testez une fonction récursive qui renvoie la somme des premiers éléments n d’un tableau.
4.4
Écrivez et testez une fonction récursive qui renvoie le maximum des premiers éléments n d’un tableau.
4.5
Écrivez et testez une fonction récursive qui renvoie le maximum des premiers éléments n d’un tableau en n’utilisant pas plus de n lg appels récursifs.
4.6
Écrivez et testez une fonction récursive qui renvoie la puissance xn.
4.7
Écrivez et testez une fonction récursive qui renvoie la puissance xn, en n’utilisant pas plus de 2 n lg appels récursifs.
4.8
Écrivez et testez une fonction récursive qui renvoie le logarithme binaire entier d’un entier n (c’est-à-dire le nombre de fois où n peut être divisé par 2).
90 4.9
Récursivité
Écrivez et testez une fonction booléenne récursive qui détermine si une chaîne est un palindrome (c’est-à-dire une chaîne de caractères qui reste identique lorsque vous en inversez les lettres).
4.10 Écrivez et testez une fonction récursive qui renvoie une chaîne contenant la représentation
binaire d’un entier positif. 4.11 Écrivez et testez une fonction récursive qui renvoie une chaîne contenant la représentation hexa-
décimale d’un entier positif. 4.12 Écrivez et testez une fonction récursive qui imprime toutes les permutations des n premiers caractères d’une chaîne. Par exemple, l’appel print("ABC",3) imprimerait • ABC • ACB • BAC • BCA • CBA • CABr
4.13 Implémentez itérativement la fonction Fibonacci (sans utiliser de tableau). 4.14 Implémentez la fonction d’Ackermann récursive :
a(0, n) a(1, 0) a(m, 0) a(m, n)
=1 =2 = m + 2, if m >1 = a(a(m – 1, n), n – 1), if m > 0 et n > 0
4.15 Prouvez la relation de récurrence de Pascal. 4.16 Tracez l’implémentation récursive de l’algorithme d’Euclide (voir l’exemple 4.6) pour l’appel gcd(385,231). 4.17 Implémentez l’algorithme d’Euclide itérativement. 4.18 Implémentez l’algorithme d’Euclide récursivement en utilisant l’opérateur de reste entier % au
lieu de la soustraction répétée. 4.19 Implémentez l’algorithme d’Euclide itérativement en utilisant l’opérateur de reste entier % au
lieu de la soustraction répétée. 4.20 Utilisez l’induction mathématique pour prouver que l’implémentation récursive de la fonction
Fibonacci est correcte (voir l’exemple 4.6). 4.21 Utilisez l’induction mathématique pour prouver que la fonction récursive de l’exercice 4.4 est
correcte. 4.22 Utilisez l’induction mathématique pour prouver que la fonction récursive de l’exercice 4.5 est
correcte. 4.23 Utilisez l’induction mathématique pour prouver que la fonction récursive de l’exercice 4.8 est
correcte. 4.24 Utilisez l’induction mathématique pour prouver que la fonction récursive de l’exercice 4.12 est
correcte. 4.25 Le domaine calculable d’une fonction est l’ensemble des entrées pour lesquelles cette fonction
peut créer des résultats corrects. Déterminez empiriquement le domaine calculable de la fonction factorielle implémentée dans l’exemple 4.2. 4.26 Déterminez empiriquement le domaine calculable de la fonction sum(b,n) implémentée dans l’exercice 4.2 en utilisant b = 2.
91
Révision et entraînement
4.27 Déterminez empiriquement le domaine calculable de la fonction Fibonacci implémentée dans
l’exemple 4.3. 4.28 Déterminez empiriquement le domaine calculable de la fonction récursive des coefficients bino-
miaux de l’exemple 4.11. 4.29 Le programme des tours de Hanoi réalise 7 déplacements de disques pour 3 disques. Combien de
déplacements doivent être effectués si vous disposez de : a. 5 disques ? b. 6 disques ? c. n disques ? 4.30 Démontrez la formule dérivée de l’exercice précédent. 4.31 Déterminez empiriquement le domaine calculable de la fonction d’Ackermann de l’exer-
cice 4.14. 4.32 Créez l’arbre d’appels récursifs pour l’appel hanoi(4,'A', 'B', 'C') de l’exemple 4.15. 4.33 Modifiez le programme de l’exemple 4.16 afin d’obtenir des résultats plus précis en réduisant les
bases de façon à ce que la récursivité se poursuive jusqu’à ce que |x| < 0,00005. 4.34 Modifiez le programme de l’exemple 4.16 afin d’obtenir des résultats plus précis en utilisant des
approximations plus précises : sin x ≈ x – x3/6 + x5/120 = x(1 – x2(1 – x2/20)) cos x ≈ 1 – x2/2 + x4/24 = 1 – x2/2⋅(1 – x2/12) 4.35 Utilisez la récursivité mutuelle pour implémenter les fonctions de sinus et de cosinus hyperboli-
ques. Utilisez les formules suivantes : sinh 2θ = 2 sinh θ cosh θ cosh 2θ = 1 + 2 ( sinh θ )
2
3
sinh x ≈ x + x / 6 2
cosh x ≈ 1 + x / 2
Comparez vos résultats aux valeurs correspondantes des fonctions sinh et cosh définies en termes de fonction exponentielle par les formules suivantes : e x – e –x ) sinh x = (---------------------2
( ex
+e ) cosh x = ----------------------–x
2
4.36 Implémentez la fonction tangente récursivement en utilisant les formules suivantes :
2 tan x tan 2x = ---------------------------2 1 – ( tan x ) 1 3 tan x ≈ x + --- x 3 4.37 Implémentez une fonction récursive qui évalue un polynôme a0 +a1x + a2x2 +… + anxn, les coef-
ficients n + 1 ai étant passés à la fonction dans un tableau en même temps que le degré n.
92
Récursivité
¿
SOLUTIONS
SOLUTIONS
4.1
Cette fonction récursive renvoie la somme des premiers carrés n : • public static int sum(int n) • { if (n==0) return 0; // base • return sum(n-1) + n*n; // récursivité •}
4.2
Cette fonction récursive renvoie la somme des premières puissances n d’une base b : • public static double sum(double b, int n) • { if (n==0) return 1; // base • return 1 + b*sum(b,n-1); // récursivité •}
4.3
Remarquez que cette solution implémente la méthode de Horner : 1 + b*(1 + b*(1 + … + b)). Cette fonction récursive renvoie la somme des premiers éléments n d’un tableau : • public static double sum(double[] a, int n) • { if (n==0) return 0.0; // base • return sum(a,n-1) + a[n-1]; // récursivité •}
4.4
Cette fonction récursive renvoie le maximum des premiers éléments n d’un tableau : • public static double max(double[] a, int n) • { if (n==1) return a[0]; // base • double m = max(a,n-1); // récursivité • if (a[n-1] > m) return a[n-1]; • else return m; •}
4.5
Cette fonction récursive renvoie le maximum des premiers éléments n d’un tableau et n’effectue pas plus de n lg appels récursifs : • public static double max(double[] a, int lo, int hi) • { if (lo>=hi) return a[lo]; • int mid=(lo+hi)/2; // index central • double m1=max(a,lo,mid); // récursivité sur a[lo..mid] • double m2=max(a,mid+1,hi); // récursivité sur a[mid+1..hi] • return (m1>m2?m1:m2); // maximum de {m1,m2} •}
4.6
Cette fonction récursive renvoie la puissance xn : • public static double pow(double x, int n) • { if (n==0) return 1.0; // base • return x*pow(x,n-1); // récursivité •}
4.7
Cette fonction récursive renvoie la puissance xn et n’effectue pas plus de n lg appels récursifs : • public static double pow(double x, int n) • { if (n==0) return 1.0; // base • double p = pow(x,n/2); • if (n%2==0) return p*p; // récursivité (n pair) • else return x*p*p; // récursivité (n impair) •}
Révision et entraînement
4.8
Cette fonction récursive renvoie le logarithme binaire entier de n : • public static int lg(int n) • { if (n==1) return 0; // base • return 1 + lg(n/2); // récursivité •}
4.9
Cette fonction récursive détermine si une chaîne est un palindrome : • public static boolean isPalindrome(String s) • { int len = s.length(); • if (len<2) return true; • if (s.charAt(0) != s.charAt(len-1)) return false; • if (len==2) return true; • return isPalindrome(s.substring(1,len-1)); // récursivité •}
4.10 Cette fonction récursive convertit du décimal en binaire : • public static String binary(int n) • { String s; • if (n%2 == 0) s = "0"; • else s = "1"; • if (n < 2) return s; // base • return binary(n/2) + s; // récursivité •}
4.11 Cette fonction récursive convertit du décimal en hexadécimal : • public static String hexadecimal(int n) • { String s = hex(n%16); • if (n<16) return s; // base • return hexadecimal(n/16) + s; // récursivité •} • String hex(int n) • { if (n==0) return "0"; • if (n==1) return "1"; • if (n==2) return "2"; • if (n==3) return "3"; • if (n==4) return "4"; • if (n==5) return "5"; • if (n==6) return "6"; • if (n==7) return "7"; • if (n==8) return "8"; • if (n==9) return "9"; • if (n==10) return "A"; • if (n==11) return "B"; • if (n==12) return "C"; • if (n==13) return "D"; • if (n==14) return "E"; • else return "F"; •}
4.12 Cette fonction récursive imprime des permutations : • public static void print(String str) • { print("",str); •} • • public static void print(String left, String right) • { int n=right.length(); • if (n==0) return;
93
94
Récursivité • • • • • • • • • • • •}
if (n==1) { System.out.println(left+right); return; } StringBuffer s = new StringBuffer(right); for (int i=0; i
4.13 L’implémentation itérative de la fonction Fibonacci est la suivante : • public static int fib(int n) • { if (n<2) return n; • int f0=0, f1=1, f=f0+f1; • for (int i=2; i
4.14 Et voici la fonction d’Ackermann : • public static int ackermann(int m, int n) • { if (m==0) return 1; // base • if (n==0) • if (m==1) return 2; // base • else return m + 2; // base • return ackermann(ackermann(m-1,n), n-1); // récursivité •}
4.16 Considérez la relation c(8,3 = 56 = 35 + 21 = c(7,3) + c(7,2) issue de l’expansion de (x + 1)8 :
(x + 1)8 = (x + 1)(x + 1)7 = (x + 1)(x7 + 7x6 + 21x5 + 35x4 + 35x3 + 21x2 + 7x + 1) = x8 + 7x7 + 21x6 + 35x5 + 35x4 + 21x3 + 7x2 + x + x7 + 7x6 + 21x5 + 35x4 + 35x3 + 21x2 + 7x + 1 = x8 + 8x7 + 28x6 + 56x5 + 70x4 + 56x3 + 28x2 + 7x + 1 Le coefficient c(8,3) est pour le terme x5 qui est 35x5 +21x5 + 56x5. La somme 35x5 +21x5 est issue de x(35x4) et 1(21x5). Ces coefficients sont donc 35 = c(7,3) et 21 = c(7,2). La preuve générale est basée sur le même argument : c(n,k) est le coefficient du terme xk dans l’expansion de (x + 1)n. Étant donné que (x + 1)n = (x + 1)(x + 1)n – 1, ce terme vient de la somme (x)(c(n – 1, k – 1) xk – 1) + (1)(c(n – 1, k)xk) = (c(n – 1, k – 1) + c(n – 1, k)) xk Par conséquent, c(n, k) = c(n – 1, k – 1) + c(n – 1, k). 4.16 La trace de l’appel gcd(616,231) est la suivante : main()
gcd(385,231)
m 385 n 231
385,231 77
m 385 n 231
gcd(154,231)
154,231 77
m 154 n 231
gcd(154,77)
154,77 77
m 154 n 77
gcd(77,77)
77,77 77
m 77 n 77
Révision et entraînement
95
4.17 L’implémentation itérative de l’algorithme d’Euclide est la suivante : • public static int gcd(int m, int n) • { while (m != n) // INVARIANT : gcd(m,n) • if (m < n) n -= m; • else m -= n; • return n; •}
4.18 L’implémentation récursive de l’algorithme d’Euclide utilisant l’opérateur de reste est la sui-
vante : • public static int gcd(int m, int n) • { if (m==0) return n; // basis • if (n==0) return m; // basis • else if (m
4.19 L’implémentation itérative de l’algorithme d’Euclide utilisant l’opérateur de reste est la sui-
vante : • public static int gcd(int m, int n) • { while (n>0) // INVARIANT : gcd(m,n) • { int r = m%n; • m = n; • n = r; • } • return m; •}
4.20 Pour prouver que l’implémentation récursive de la fonction Fibonacci est correcte, vérifiez d’abord la base. Les appels fib(0) et fib(1) renvoient les valeurs correctes 0 et 1 en raison
de la première ligne • if (n < 2) return n;
Nous supposons ensuite que la fonction renvoie les valeurs correctes pour tous les entiers inférieurs à n > 1. La deuxième ligne • return fib(n-1) + fib(n-2);
renverra la valeur correcte n! parce que, si nous suivons l’hypothèse inductive, les appels fib(n-1) et fib(n-2) renvoient les valeurs correctes pour Fn – 1 et Fn – 2 respectivement, et Fn = Fn – 1 + Fn – 2 par définition. Remarquez que, dans le cas présent, la base requiert la vérification des deux premières étapes de la séquence parce que la relation de récurrence Fn = Fn – 1 + Fn – 2 s’applique uniquement pour n > 1. 4.21 Si n = 1, la base est exécutée et renvoie a[0] qui est l’élément maximum parce qu’il s’agit du
seul élément. Si n > 1, la fonction calcule correctement le maximum m des premiers éléments n–1 (selon l’hypothèse inductive). Si (a[m – 1] > m) est vraie, a[m – 1] est renvoyé. Cet élément est le plus grand puisqu’il est plus grand que le plus grand de tous les autres éléments. Dans le cas contraire, si la condition (a[n-1] >m) est fausse, m est renvoyé et il s’agit de l’élément le plus grand puisque a[m – 1] n’est pas plus grand. 4.22 Si n = 1, la base et exécutée et renvoie a[0] qui est l’élément maximum parce qu’il s’agit du seul élément. Si n > 1, la fonction calcule correctement les valeurs maximum m1 et m2 des première
et deuxième parties du tableau (selon l’hypothèse inductive). L’un de ces deux nombres est correct pour tout le tableau et le plus grand d’entre eux est renvoyé.
96
Récursivité
4.23 Si n = 1, la base est exécutée et renvoie 0 qui est le nombre de fois où n peut être divisé par 2. Si
n > 1, la fonction calcule correctement le nombre de fois où n/2 peut être divisé par 2 (selon l’hypothèse inductive). Ce nombre est inférieur de 1 au nombre de fois où n peut être divisé par 2. C’est pourquoi la valeur renvoyée, 1 + lg(n/2) est correcte. 4.24 Nous démontrons d’abord la conjecture selon laquelle l’appel print(left,right) imprimera n! chaînes distinctes ayant toutes le même préfixe left, avec n = right.length(). Si n = 1, la méthode imprime left+right et renvoie les données, soit 1! chaîne (distincte). Supposons que, lorsque right.length() = n – 1, l’appel print(left,right) imprime (n – 1)! Chaînes distinctes ayant toutes la chaîne de préfixe left. Ensuite, lorsque right.length() = n, la boucle for effectue n appels du type print(left+temp,ss), avec temp comme caractère distinct et ss = s.substring(1,n). Étant donné que la longueur de s.substring(1,n)
est égale à n – 1, chacun de ces appels imprime (n – 1)! chaînes distinctes ayant toutes la même chaîne de préfixe left+temp. C’est pourquoi la boucle imprime (n)(n – 1)! chaînes distinctes ayant toutes la même chaîne de préfixe left. Notre supposition est donc démontrée grâce à l’induction mathématique. Nous pouvons en déduire que l’appel print(str) imprimera n! permutations distinctes des caractères de la chaîne str, n étant sa longueur. Puisqu’il s’agit très exactement du nombre total de permutations de la chaîne, nous pouvons dire que la méthode est correcte. 4.25 En ce qui concerne la fonction factorielle implémentée dans l’exemple 4.2, le dépassement d’entier a lieu pour le type de renvoi long avec n = 13 sur l’ordinateur de l’auteur. C’est pour-
quoi le domaine calculable de cette fonction est 0 ≤ n ≤ 12. 4.26 En ce qui concerne la fonction sum(b,n) implémentée dans l’exercice 4.2 avec b = 2, le dépassement des nombres à virgule flottante a lieu pour le type de renvoi double avec n = 1,023 sur
l’ordinateur de l’auteur. Le domaine calculable de cette fonction est donc 0 ≤ n ≤ 1,022. 4.27 En ce qui concerne la fonction Fibonacci implémentée dans l’exemple 4.4, le temps système des
appels récursifs nuit considérablement aux performances d’exécution après n = 36 sur l’ordinateur de l’auteur. Le domaine calculable de cette fonction est donc environ 0 ≤ n ≤ 40. 4.28 En ce qui concerne la fonction des coefficients binomiaux de l’exemple 4.7, le temps système des
appels récursifs nuit considérablement aux performances d’exécution après n = 25 sur l’ordinateur de l’auteur. Le domaine calculable de cette fonction est donc environ 0 ≤ n ≤ 30. 4.29 Le programme des tours de Hanoi effectue :
a. 31 déplacements pour 5 disques ; b. 63 déplacements pour 6 disques ; c. 2n – 1 déplacements pour n disques. 4.30 Nous utiliserons l’induction mathématique pour démontrer que le programme des tours de Hanoi
effectue 2n – 1 déplacements de disques. La base est établie dans l’exemple 4.15. Pour déplacer n + 1 disques, vous devez effectuer 2n – 1 déplacements qui vous permettront de transférer tous les disques sauf le dernier dans la tour B (d’après l’hypothèse inductive). Vous aurez ensuite besoin d’effectuer un déplacement pour transférer le dernier disque dans la tour C et de 2n – 1 déplacements supplémentaires pour transférer les disques restants de la tour B à la tour C au-dessus du dernier disque dont nous venons de parler. Le total est donc de (2n – 1) + 1 + (2n – 1) = 2n + 1 – 1. 4.31 En ce qui concerne la fonction d’Ackermann de l’exercice 4.14, des exceptions sont lancées pour
m = 17 lorsque n = 2, pour m = 5 lorsque n = 3, pour m = 4 lorsque n = 4 et pour m = 3 lorsque n = 5. Le domaine calculable de cette fonction est donc limité à 0 ≤ m ≤ 16 lorsque n = 2, 0 ≤ m ≤ 4 lorsque n = 3, 0 ≤ m ≤ 3 lorsque n = 4 et à 0 ≤ m ≤ 2 lorsque n = 5.
97
Révision et entraînement
4.32 L’arbre d’appels de l’exemple 4.15 est le suivant :
h(2,A,B,C) h(3,A,C,B)
h(4,A,B,C)
h(1,A,C,B) h(1,A,B,C) h(1,B,A,C)
h(1,A,C,B) h(2,C,A,B)
h(1,C,B,A) h(1,C,A,B) h(1,A,C,B)
h(2,B,C,A)
h(1,B,A,C) h(1,B,C,A) h(1,C,B,A)
h(1,A,B,C)
h(3,B,A,C)
h(1,B,A,C) h(2,A,B,C)
h(1,A,C,B) h(1,A,B,C) h(1,B,A,C)
4.33 Voici un exemple d’implémentation récursive plus précise des fonctions sinus et cosinus : • public class Pr0433 • { public static void main(String[] args) • { for (double x=0. ; x<1.0; x += 0.1) • System.out.println(s(x) + "\t" + Math.sin(x)); • for (double x=0. ; x<1.0; x += 0.1) • System.out.println(c(x) + "\t" + Math.cos(x)); • } • public static double s(double x) • { if (Math.abs(x) < 0.00005) return x - x*x*x/6; • return 2*s(x/2)*c(x/2); • } • public static double c(double x) • { if (Math.abs(x) < 0.00005) return 1.0 - x*x/2; • return 1 - 2*s(x/2)*s(x/2); • } •}
4.34 Voici un autre exemple d’implémentation récursive plus précise des fonctions sinus et cosinus : • public class Pr0434 • { public static void main(String[] args) • { for (double x=0. ; x<1.0; x += 0.1) • System.out.println(s(x) + "\t" + Math.sin(x)); • for (double x=0. ; x<1.0; x += 0.1) • System.out.println(c(x) + "\t" + Math.cos(x)); • } • public static double s(double x) • { if (Math.abs(x) < 0.005) return x*(1 - x*x/6*(1-x*x/20)); • return 2*s(x/2)*c(x/2); • } • • public static double c(double x) • { if (Math.abs(x) < 0.005) return 1.0 - x*x/2*(1-x*x/12); • return 1 - 2*s(x/2)*s(x/2); • } •}
98
Récursivité
4.35 L’implémentation récursive mutuelle des fonctions sinus et cosinus hyperboliques est la suivante : • public class Pr0435 • { public static void main(String[] args) • { for (double x=0. ; x<1.0; x += 0.1) • System.out.println(s(x) + "\t" + sinh(x)); • for (double x=0. ; x<1.0; x += 0.1) • System.out.println(c(x) + "\t" + cosh(x)); • } • • public static double s(double x) • { if (Math.abs(x) < 0.005) return x + x*x*x/6; • return 2*s(x/2)*c(x/2); • } • • public static double c(double x) • { if (Math.abs(x) < 0.005) return 1.0 + x*x/2; • return 1 + 2*s(x/2)*s(x/2); • } • • public static double sinh(double x) • { return (Math.exp(x) - Math.exp(-x))/2.0; • } • • public static double cosh(double x) • { return (Math.exp(x) + Math.exp(-x))/2.0; • } •}
4.36 L’implémentation récursive de la fonction tangente est la suivante : • public static double t(double x) • { if (-0.005 < x && x < 0.005) return x + x*x*x/3; // base • return 2*t(x/2)/(1 - t(x/2)*t(x/2)); // récursivité •}
4.37 L’évaluation récursive d’une fonction polynomiale est la suivante : • public static double p(double[] a, double x) • { // renvoie a[0] + a[1]*x + a[2]*x*x + ... • return p(a,x,0); •} • • private static double p(double[] a, double x, int k) • { // renvoie a[k] + a[k+1]*x + a[k+2]*x*x + ... • if (k == a.length) return 0; // base • return a[k] + x*p(a,x,k+1); // récursivité •}
Chapitre 5
Collections Une collection est un conteneur d’objets. Java 1.2 a formalisé ce concept avec son framework de collections composées d’interfaces et de classes.
5.1 FRAMEWORK DE COLLECTIONS JAVA Le paquetage java.util définit le framework suivant composé de huit interfaces pour les collections : Interface
Description
Collection
Collection d’éléments.
List
Séquence d’éléments.
Set
Collection d’éléments uniques.
SortedSet
Collection triée d’éléments uniques.
Map
Collection de paires (clé, valeur) avec des clés obligatoirement uniques.
SortedMap
Collection triée de paires (clé, valeur) avec des clés obligatoirement uniques.
Iterator
Objet capable de parcourir une collection.
ListIterator
Objet capable de parcourir une séquence.
100
Collections
Le graphique suivant illustre les relations entre les interfaces et les classes qui les implémentent : Object AbstractCollection
Collection
AbstractList
List
AbstractSequentialList LinkedList ArrayList Vector Stack AbstractSet
Set
HashSet TreeSet AbstractMap
SortedSet Map
HashMap SortedMap
TreeMap WeakHashMap
Iterator
Arrays BitSet
ListIterator
Collections Dictionary Hashtable
Map
Properties
Les lignes en pointillés connectent les interfaces situées à droite du graphique aux classes de la partie gauche qui les implémentent directement. Par exemple, la classe Hashtable implémente l’interface Map. Dans les classes comme dans les interfaces, l’héritage est indiqué par des lignes continues. Par exemple, la classe Stack étend la classe Vector et l’interface SortedSet étend l’interface Set. Remarquez que chacune des quatre interfaces principales (Collection, List, Set et Map) est implémentée par une classe abstract correspondante (AbstractCollection, AbstractList, AbstractSet et AbstractMap). Remarquez également que le nom de la plupart des classes de collection concrètes est composé de la structure de données utilisée et de l’interface implémentée. Ainsi, la classe ArrayList utilise un tableau pour implémenter l’interface List, la classe HashSet utilise une table de hachage pour implémenter l’interface Set et la classe TreeMap utilise un arbre pour implémenter l’interface Map. Ce chapitre est consacré aux implémentations directes des interfaces Collection et Iterator. L’implémentation des six autres interfaces sera abordée dans les chapitres 7, 13 et 14.
5.2 INTERFACE Collection Comme vous avez pu le constater dans le graphique précédent, l’interface Collection définit le framework de toutes les classes conteneurs Java. Elle est définie de la façon suivante dans le paquetage java.util : public interface Collection { public boolean add(Object object); public boolean addAll(Collection collection); public void clear(); public boolean contains(Object object); public boolean containsAll(Collection collection); public boolean equals(Object object); public int hashCode();
101
Classe AbstractCollection
public public public public public public public public
boolean Iterator boolean boolean boolean int Object[] Object[]
isEmpty(); iterator(); remove(Object object); removeAll(Collection collection); retainAll(Collection collection); size(); toArray(); toArray(Object[] objects);
}
5.3 CLASSE AbstractCollection La classe AbstractCollection est une implémentation partielle de l’interface Collection. Elle implémente autant d’éléments que possible sans spécifier la structure de stockage utilisée par la collection. Elle est définie de la façon suivante dans le paquetage java.util : public class AbstractCollection implements Collection { public boolean add(Object object) public boolean addAll(Collection collection) public void clear() public boolean contains(Object object) public boolean containsAll(Collection collection) public boolean isEmpty() abstract public Iterator iterator() public boolean remove(Object object) public boolean removeAll(Collection collection) public boolean retainAll(Collection collection) abstract public int size() public Object[] toArray() public Object[] toArray(Object[] objects) public String toString() }
Notez que cette classe implémente les 15 méthodes spécifiées par l’interface Collection, à l’exception de equals() et hashCode() qui sont définies dans la classe Object (reportez-vous à la section 3.4) et qui seront remplacées par des extensions de la classe AbstractCollection. En outre, la classe AbstractCollection remplace la méthode toString() de la classe Object. Si AbstractCollection ne remplace pas les méthodes equals() et hashCode() de la classe Object, c’est parce que celles-ci ne peuvent pas être complètement définies si vous ignorez quelle structure de données implémentera le stockage des éléments du conteneur. En revanche, AbstractCollection est en mesure de remplacer la méthode toString() de la classe Object, comme illustré dans l’exemple suivant.
Exemple 5.1 Méthode toString() définie dans la classe AbstractCollection public String toString() { if (isEmpty()) return "[]"; Iterator it = iterator(); String str = "[" + it.next(); while (it.hasNext()) str += ", " + it.next(); return str + "]"; }
Cette méthode utilise l’itérateur renvoyé par la méthode iterator() de la classe afin de parcourir la collection et d’obtenir une image chaîne de tous ses éléments. Pour travailler sur l’instance d’une sous-classe concrète de la classe AbstractCollection, il suffit de se baser sur cette implémenta-
102
Collections
tion correcte de la méthode iterator(). C’est la raison pour laquelle AbstractCollection définit une méthode iterator() abstraite : de cette façon, elle sera toujours implémentée par une sous-classe concrète. La méthode size() de la classe AbstractCollection est définie comme étant abstraite pour la même raison. Cette classe est utilisée par d’autres méthodes implémentées dans le code que nous venons de voir (exemple 5.2).
Exemple 5.2 Méthode isEmpty() définie dans la classe AbstractCollection public boolean isEmpty() { return size() == 0; }
5.4 CLASSE Bag Un sac (bag), également qualifié de multi-ensemble, est une collection d’éléments susceptibles de contenir des copies. En effet, les ensembles standard n’autorisent pas les copies. Dans l’exemple suivant, vous verrez comment utiliser un tableau d’Objects pour implémenter une classe Bag.
Exemple 5.3 Classe Bag public class Bag extends AbstractCollection { private Object[] objects; private int size=0; // nombre d’objets dans le sac private static final int CAPACITY=16; // capacité par défaut private void resize(int capacity) // augmente la taille de objects[] jusqu’à une capacité donnée // reportez-vous à l’exemple 5.4 public Bag() { // construit un sac vide avec la capacité par défaut objects = new Object[CAPACITY]; } public Bag(int capacity) // construit un sac vide avec la capacité donnée // reportez-vous à l’exercice d’entraînement 5.1 public Bag(Collection collection) // construit un sac contenant les objets de // la collection donnée en doublant sa capacité // reportez-vous à l’exemple 5.5 public Bag(Object[] objects) // construit un sac contenant les objets du // tableau donné en doublant sa capacité // reportez-vous à l’exercice d’entraînement 5.2 public boolean add(Object object) // ajoute l’objet donné au sac // reportez-vous à l’exemple 5.6 public boolean addAll(Collection collection) // ajoute tous les objets de la collection donnée dans le sac // reportez-vous à l’exercice d’entraînement 5.3
Classe Bag
103
public void clear() // supprime tous les objets du sac // reportez-vous à l’exercice d’entraînement 5.4 public boolean contains(Object object) // renvoie true si l’objet donné // est égal à un objet du sac // reportez-vous à l’exemple 5.7 public boolean containsAll(Collection collection) // renvoie true si chaque objet de la collection donnée // est égal à un objet du sac, c’est-à-dire // si la collection donnée est un sous-ensemble du sac; // reportez-vous à l’exercice d’entraînement 5.5 private static int frequency(Collection x, Object object) // renvoie le nombre d’objets de la collection donnée // qui sont égaux à l’objet donné // reportez-vous à l’exercice d’entraînement 5.6 public boolean equals(Object object) // renvoie true si l’objet donné est un sac // et a le même contenu que le sac donné // reportez-vous à l’exemple 5.8 public int hashCode() // renvoie le code de hachage du sac, c’est-à-dire la somme // de tous les codes de hachage de ses éléments // reportez-vous à l’exercice d’entraînement 5.7 public boolean isEmpty() { // renvoie true si le sac est vide return size == 0; } public Iterator iterator() // renvoie un itérateur sur le sac // reportez-vous à l’example 5.12 public boolean remove(Object object) // supprime l’un des objets donnés du sac; // renvoie true si le sac a été modifié; // reportez-vous à l’exercice d’entraînement 5.8 public boolean removeAll(Object object) // supprime la totalité de l’objet donné du sac; // renvoie true si le sac a été modifié; // reportez-vous à l’exemple 5.9 public boolean removeAll(Collection collection) // supprime du sac tous les objets se trouvant également dans // la collection donnée, réduisant ainsi ce sac à son // intersection théorique avec cette collection; // renvoie true si le sac a été modifié; // reportez-vous à l’exercice d’entraînement 5.9 public boolean retainAll(Collection collection) // supprime du sac tous les objets ne se trouvant pas dans la // collection donnée, réduisant ainsi le sac au complément théorique // de cette collection;
104
Collections
// renvoie true si le sac a été modifié; // reportez-vous à l’exercice d’entraînement 5.10 public int size() { // renvoie le nombre d’objets contenus dans le sac; return size; } public Object[] toArray() // renvoie un tableau dont les éléments se trouvent dans le sac; // reportez-vous à l’exemple 5.10 public Object[] toArray(Object[] objects) // renvoie un tableau dont les éléments se trouvent dans le sac // s’il est plus grand que le tableau object[] donné; // sinon, le tableau donné est renvoyé après suppression // de tous ses éléments, puis il est chargé avec les éléments du sac // et null pour combler les vides; // reportez-vous à l’exercice d’entraînement 5.11 public String toString() // renvoie une chaîne avec le contenu du sac // reportez-vous à l’exercice d’entraînement 5.12 }
Exemple 5.4 Implémenter la méthode Bag.resize(int) Cette méthode utilitaire privée est intégrée afin de faciliter l’expansion du tableau objects[] lorsque de nouveaux éléments sont ajoutés au sac. private void resize(int capacity) { // augmente la taille du tableau objects[] jusqu’à la // capacité donnée if (capacity <= this.capacity) return; Object[] temp = objects; objects = new Object[capacity]; for (int i=0; i<size; i++) objects[i] = temp[i]; }
Si la capacity donnée n’est pas plus importante que la capacity courante, la méthode renvoie le tableau sans rien changer. Dans le cas contraire, elle copie tout le tableau objects dans un tableau temp, réaffecte la référence objects à un nouveau tableau de taille équivalente à la capacity donnée, tableau dans lequel elle copie le tableau temp.
Exemple 5.5 Implémenter un constructeur Bag(Collection) public Bag(Collection collection) { // construit un sac contenant les objets de // la collection donnée en doublant sa capacité objects = new Object[2*collection.size()]; for (Iterator it = collection.iterator(); it.hasNext(); ) objects[size++] = it.next(); }
Pour créer un Bag contenant les éléments de la collection donnée, cette méthode alloue d’abord le tableau objects avec le double des composants de cette collection (afin de permettre l’augmentation ultérieure du tableau).
Classe Bag
105
Elle utilise ensuite un itérateur pour parcourir la collection et copie chaque variable de référence de ses objets dans le nouveau tableau objects du sac. Notez que les objets qui composent la collection ne sont pas copiés dans notre exemple qui ne propose qu’une nouvelle structure de données de références à ces objets. Étant donné que la collection donnée implémente l’interface Collection, vous aurez nécessairement une méthode iterator() qui renverra un objet itérateur capable de parcourir cette collection. En outre, puisque cet objet itérateur implémente l’interface java.util.Iterator (reportezvous à la section 5.5), vous retrouverez nécessairement les méthodes next() et hasNext() utilisées dans notre exemple. Elles permettent l’utilisation d’une boucle for afin de parcourir la collection et d’accéder à chacun de ses éléments via la méthode next().
Exemple 5.6 Implémenter la méthode Bag.add(Object) public boolean add(Object object) { // ajoute l’objet donné au sac if (size == objects.length) resize(2*objects.length); objects[size++] = object; return true; }
Si le sac est plein, la méthode private resize() est appelée en premier lieu afin de doubler la capacité du tableau objects[]. L’objet donné est ensuite ajouté à la séquence d’éléments se trouvant déjà dans le tableau. Lorsque vous utilisez l’interface Collection, n’oubliez pas que la méthode add(Object) doit renvoyer une valeur boolean afin d’indiquer si l’opération d’ajout est réussie ou non. Avec cette implémentation, l’ajout sera toujours réussi (à moins que l’ordinateur ne se retrouve à cours de mémoire), c’est pourquoi true sera automatiquement renvoyé. Nous verrons dans d’autres applications que la méthode add(Object) peut parfois renvoyer false. Voici un pilote test de la méthode add(Object) : public class Testing { public static void main(String[] args) { String[] food = { "fromage", "jambon", "rhum", "thé" }; Bag foodBag = new Bag(food); System.out.println(foodBag); foodBag.add("figue"); System.out.println(foodBag); } } { fromage, jambon, rhum, thé } { fromage, jambon, rhum, thé, figue }
Notez que ce programme teste également le constructeur Bag(Object[]).
Exemple 5.7 Implémenter une méthode Bag.contains(Object) Cette méthode implémente l’algorithme de recherche séquentielle (reportez-vous à la section 2.4) afin de retrouver l’object donné dans le sac. Elle effectue une recherche dans le tableau objects[] et renvoie true dès que l’un de ses éléments est égal à l’object donné. Si la boucle se termine une fois que tous les éléments ont été vérifiés, la méthode renvoie false : public boolean contains(Object object) { // renvoie true si l’objet donné // est égal à un objet du sac
106
Collections
for (int i=0; i<size; i++) if (object.equals(objects[i])) return true; return false; }
Remarquez que cette méthode utilise la méthode Object.equals() afin de tester l’égalité. Cet appel renvoie true uniquement si les deux références object et objects[i] concernent toutes les deux le même objet. Voici le pilote test de la méthode contains(Object) : public class Testing { public static void main(String[] args) { String[] food = { "fromage", "jambon", "rhum", "thé" }; Bag foodBag = new Bag(food); System.out.println(foodBag); if (foodBag.contains("figue")) System.out.println("figue"); else System.out.println("pas de figue"); if (foodBag.contains("jambon")) System.out.println("jambon"); else System.out.println("pas de jambon"); } } { fromage, jambon, rhum, thé } pas de figue jambon
Notez que les deux occurrences du littéral chaîne doivent faire référence au même objet se trouvant en mémoire.
Exemple 5.8 Implémenter une méthode Bag.equals(Object) public boolean equals(Object object) { // renvoie true si l’objet donné est un sac // et a le même contenu que le sac donné if (object == this) return true; if (object.getClass() != this.getClass()) return false; if (object.hashCode() != this.hashCode()) return false; Collection collection = (Collection)object; if (collection.size() != this.size()) return false; if (!collection.containsAll(this)) return false; if (!this.containsAll(collection)) return false; for (int i=0; i<size; i++) { Object x = objects[i]; if (frequency(collection,x) != frequency(this,x)) return false; } return true; }
Exemple 5.9 Implémenter une méthode Bag.removeAll(Object) public boolean removeAll(Object object) { // supprime tous les objets donnés du sac; // renvoie true si le sac a été modifié; boolean modified=false; for (int i=0; i<size; i++) if (object.equals(objects[i])) { objects[i] = objects[--size];
Classe Bag
modified = true; } return modified; }
Exemple 5.10 Implémenter une méthode Bag.toArray() public Object[] toArray() { // renvoie un tableau dont les éléments sont dans le sac; Object[] objects = new Object[size]; for (int i=0; i<size; i++) objects[i] = this.objects[i]; return objects; }
Exemple 5.11 Tester la classe Bag Voici un pilote test de la classe Bag définie dans l’exemple 5.3 : public class Ex0511 { public static void main(String[] args) { String[] etats = { "Maine", "Idaho", "Iowa", "Ohio", "Utah" }; Bag myCollection = new Bag(etats); print(myCollection); myCollection.add("Alaska"); print(myCollection); if (myCollection.remove("Ohio")) print(myCollection); else System.out.println("Object \"Ohio\" introuvable."); Iterator it = myCollection.iterator(); while (it.hasNext()) { String s = (String)it.next(); System.out.println("\ts = \""+s+"\""); if (s.charAt(0) == ’I’) { it.remove(); System.out.println("\tObjet \""+s+"\" a été supprimé."); } } print(myCollection); } private static void print(Bag collection) { System.out.println("size() = " + collection.size()); Object[] objects = collection.toArray(); for (int i=0; i
107
108
Collections
else System.out.println("\tnewC n’est pas égal à myC"); } }
size() = 5 objects[0] = Maine objects[1] = Idaho objects[2] = Iowa objects[3] = Ohio objects[4] = Utah Contains "Iowa" newC contient myC myC contient newC myC est égal à newC newC est égal à myC size() = 6 objects[0] = Maine objects[1] = Idaho objects[2] = Iowa objects[3] = Ohio objects[4] = Utah objects[5] = Alaska Contient "Iowa" newC ne contient pas myC myC contient newC myC n’est pas égal à newC newC n’est pas égal à myC size() = 5 objects[0] = Maine objects[1] = Idaho objects[2] = Iowa objects[3] = Alaska objects[4] = Utah Contient "Iowa" newC ne contient pas myC myC ne contient pas newC myC n’est pas égal à newC newC n’est pas égal à myC s = "Maine" s = "Idaho" Objet "Idaho" supprimé. s = "Utah" s = "Iowa" Objet "Iowa" supprimé. s = "Alaska" size() = 3 objects[0] = Maine objects[1] = Utah objects[2] = Alaska Ne contient pas "Iowa" newC ne contient pas myC myC ne contient pas newC myC n’est pas égal à newC newC n’est pas égal à myC
Interface Iterator
109
5.5 INTERFACE Iterator Un itérateur est un objet qui parcourt une structure de données et en vérifie chaque élément une seule fois. Étant donné que le temps est linéaire, un itérateur linéarise la structure qu’il parcourt. Plusieurs itérateurs peuvent vérifier une structure simultanément, chacun d’entre eux étant en mesure d’identifier un élément de la structure et d’en permettre l’accès. Voici l’interface Iterator telle qu’elle est définie dans le paquetage java.util : public interface Iterator { public boolean hasNext(); public Object next(); public void remove(); }
Les itérateurs sont généralement implémentés comme des classes internes anonymes définies dans la méthode iterator() de la classe de collection associée, comme illustré dans l’exemple suivant.
Exemple 5.12 Implémenter la méthode Iterator dans la classe Bag Voici maintenant l’implémentation de la méthode iterator() pour la classe Bag définie dans l’exemple 5.3 : public Iterator iterator() { // renvoie un itérateur sur le sac return new Iterator() // définition du constructeur incorporée : { private int cursor=0; public boolean hasNext() { return cursor<size; } public Object next() { if (cursor>=size) return null; return objects[cursor++]; } public void remove() { objects[--cursor] = objects[--size]; objects[size] = null; } }; // notez le point-virgule obligatoire }
?
QUESTIONS DE RÉVISION
QUESTIONS DE RÉVISION
5.1 5.2 5.3
Qu’est-ce que le framework de collections Java ? Pourquoi les méthodes iterator() et size() sont-elles déclarées comme abstract dans la définition de la classe AbstractCollection ? Qu’est-ce qu’un itérateur ?
110
Collections
¿
RÉPONSES
RÉPONSES
5.1 5.2
5.3
?
Le framework de collections Java est un groupe d’interfaces et de classes défini dans le paquetage java.util. Il facilite la définition des classes de structure de données via l’héritage. Les méthodes iterator() et size() sont déclarées comme abstract dans la classe AbstractCollection parce qu’elles sont utilisées par d’autres méthodes telles que toString() et isEmpty() implémentées dans cette classe. Ces autres méthodes peuvent ainsi être utilisées dans des sous-classes concrètes. Un itérateur est un objet qui passe séquentiellement d’un composant à l’autre via une structure de collection et qui fournit l’accès à ces éléments.
EXERCICES D’ENTRAÎNEMENT
EXERCICES D’ENTRAÎNEMENT
5.1
Implémentez le constructeur suivant pour la classe Bag (reportez-vous à l’exemple 5.3) : • public Bag(int capacity) • // construit un sac vide avec la capacité donnée
5.2
Implémentez le constructeur suivant pour la classe Bag (reportez-vous à l’exemple 5.3) : • public Bag(Object[] objects) • // construit un sac contenant les objets du • // tableau donné en doublant sa capacité
5.3
Implémentez le constructeur suivant pour la classe Bag (reportez-vous à l’exemple 5.3) : • public boolean addAll(Collection collection) • // ajoute tous les objets de la collection donnée • // dans le sac
5.4
Implémentez la méthode suivante pour la classe Bag (reportez-vous à l’exemple 5.3) : • public void clear() • // supprime tous les objets du sac
5.5
Implémentez la méthode suivante pour la classe Bag (reportez-vous à l’exemple 5.3) : • public boolean containsAll(Collection collection) • // renvoie true si chaque objet de la collection donnée • // est égal à un objet du sac ; c’est-à-dire, • // si la collection donnée est un sous-ensemble du sac ;
5.6
Implémentez la méthode suivante pour la classe Bag (reportez-vous à l’exemple 5.3) : • private static int frequency(Collection x, Object object) • // renvoie le nombre d’objets de la collection donnée • // qui sont égaux à l’objet donné
5.7
Implémentez la méthode suivante pour la classe Bag (reportez-vous à l’exemple 5.3) : • public int hashCode() • // renvoie un code de hachage pour le sac qui est la somme • // des codes de hachage de ses éléments
Révision et entraînement
5.8
Implémentez la méthode suivante pour la classe Bag (reportez-vous à l’exemple 5.3) : • public boolean remove(Object object) • // supprime l’un des objets donné du sac; • // renvoie true si le sac a été modifié;
5.9
Implémentez la méthode suivante pour la classe Bag (reportez-vous à l’exemple 5.3) : • public boolean removeAll(Collection collection) • // supprime du sac tous les objets se trouvant également dans la • // collection donnée, réduisant ainsi le sac à son • // intersection théorique avec cette collection; • // renvoie true si le sac a été modifié;
5.10 Implémentez la méthode suivante pour la classe Bag (reportez-vous à l’exemple 5.3) : • public boolean retainAll(Collection collection) • // supprime du sac tous les objets ne se trouvant pas dans la • // collection donnée, réduisant ainsi le sac • // à son complément théorique par rapport à la collection; • // renvoie true si le sac a été modifié;
5.11 Implémentez la méthode suivante pour la classe Bag (reportez-vous à l’exemple 5.3) : • public Object[] toArray(Object[] objects) • // renvoie un tableau dont les éléments se trouvent dans le sac • // s’il est plus long que le tableau object[] donné; • // sinon, le tableau donné est renvoyé après suppression de • // tous ses éléments, puis il est chargé avec les éléments du sac • // avec null pour combler les vides;
5.12 Implémentez la méthode suivante pour la classe Bag (reportez-vous à l’exemple 5.3) : • public String toString() • // renvoie une chaîne avec le contenu du sac
¿
SOLUTIONS
SOLUTIONS
5.1
L’implémentation du constructeur (int) pour la classe Bag est la suivante : • public Bag(int capacity) • { // construit un sac vide avec la capacité donnée • objects = new Object[capacity]; •}
5.2
L’implémentation du constructeur Bag(Object[]) pour la classe Bag est la suivante : • public Bag(Object[] objects) • { // construit un sac contenant les objets du • // tableau donné en doublant sa capacité • this.objects = new Object[2*objects.length]; • for (int i=0; i
5.3
L’implémentation de la méthode addAll(Collection) pour la classe Bag : • public boolean addAll(Collection collection) • { // ajoute tous les objets de la collection donnée dans le sac
111
112
Collections • resize(2*collection.size()); • for (Iterator it = collection.iterator(); it.hasNext(); ) • objects[size++] = it.next(); • return true; •}
5.4
L’implémentation de la méthode clear() pour la classe Bag est la suivante : • public void clear() • { // supprime tous les objets du sac • for (int i=0; i<size; i++) • objects[i] = null; • size = 0; •}
5.5
L’implémentation de la méthode containsAll(Collection) pour la classe Bag est la suivante : • public boolean containsAll(Collection collection) • { // renvoie true si chaque objet de la collection donnée • // est égal à un objet du sac, c’est-à-dire • // si la collection donnée est un sous-ensemble du sac; • for (Iterator it = collection.iterator(); it.hasNext(); ) • if (!this.contains(it.next())) return false; • return true; •}
5.6
L’implémentation de la méthode frequency(Collection,Object) pour la classe Bag est la suivante : • private static int frequency(Collection x, Object object) • { // renvoie le nombre d’objets de la collection donnée • // étant égaux à l’objet donné • int count=0; • for (Iterator it = x.iterator(); it.hasNext(); ) • if (object.equals(it.next())) ++count; • return count; •}
5.7
L’implémentation de la méthode hashCode() pour la classe Bag est la suivante : • public int hashCode() • { // renvoie un code de hachage pour le sac égal à la somme • // de tous les codes de hachage de ses éléments • int code=0; • for (int i=0; i<size; i++) • code += objects[i].hashCode(); • return code; •}
5.8
L’implémentation de la méthode remove(Object) pour la classe Bag est la suivante : • public boolean remove(Object object) • { // supprime l’un des objets donnés du sac; • // renvoie true si le sac a été modifié; • for (int i=0; i<size; i++) • if (object.equals(objects[i])) • { objects[i] = objects[--size]; • return true; • } • return false; •}
Révision et entraînement
5.9
113
L’implémentation de la méthode removeAll(Collection) pour la classe Bag est la suivante : • public boolean removeAll(Collection collection) • { // supprime du sac tous les objets se trouvant également • // dans la collection donnée, réduisant ainsi l’intersection • // théorique avec cette collection; • // renvoie true si le sac a été modifié; • boolean modified=false; • • for (Iterator it = collection.iterator(); it.hasNext(); ) • if (this.remove(it.next())) modified = true; • return modified; •}
5.10 L’implémentation de la méthode retainAll(Collection) pour la classe Bag est la sui-
vante : • public boolean retainAll(Collection collection) • { // supprime du sac tous les objets ne se trouvant pas dans • // la collection donnée, réduisant ainsi le sac • // à son complément théorique par rapport à la collection; • // renvoie true si le sac a été modifié; • boolean modified=false; • • for (int i=0; i<size; i++) • if (!collection.contains(objects[i])) • { remove(objects[i]); • modified = true; • } • return modified; •}
5.11 L’implémentation de la méthode toArray(Object[]) pour la classe Bag est la suivante : • public Object[] toArray(Object[] objects) • { // renvoie un tableau dont les éléments sont dans le sac • // s’il est plus grand que le tableau object[] donné; • // sinon, le tableau donné est renvoyé après suppression • // de tous ses éléments, puis il est chargé avec les éléments • // du sac et null pour combler les vides; • if (size > objects.length) objects = this.toArray(); • • { for (int i=0; i<size; i++) • objects[i] = this.objects[i]; • for (int i=size; i
5.12 L’implémentation de la méthode toString() pour la classe Bag est la suivante : • public String toString() • { // renvoie une chaîne avec le contenu du sac • String s="{ "; • if (size>0) s += this.objects[0]; • for (int i=1; i<size; i++) • s += ", " + this.objects[i]; • return s + " }"; •}
Chapitre 6
Piles Une pile est un conteneur qui implémente le protocole dernier entré, premier sorti (LIFO, Last-in-First-Out). Cela signifie que le seul objet accessible dans le conteneur est le dernier à avoir été entré. Pour comprendre de quoi il retourne, imaginez que vous souhaitiez lire le dernier livre d’une pile. Avant de l’atteindre, vous devrez retirer tous les livres qui se trouvent au-dessus.
Canada Suède Russie Mexique France Canada Brésil
6.1 CLASSE JAVA Stack Comme nous l’avons déjà vu dans le diagramme de la section 5.1, le framework de collections Java comprend une classe Stack qui est définie de la façon suivante dans le paquetage java.util :
Object AbstractCollection
public class Stack extends Vector { public boolean empty() { return size()==0; } public Object peek() { if (size()==0) throw new EmptyStackException(); return elementAt(size()-1); } public Object pop() { Object object = peek(); removeElementAt(size()-1); return object; } public Object push(Object object) { addElement(object); return object; } public int search(Object object) { int i=lastIndexOf(object); if (i<0) return -1; // l’objet n’est pas dans la pile return size() - i; }
AbstractList Vector Stack
116
Piles
public Stack() { } }
La classe Stack est une sous-classe de Vector (reportez-vous au chapitre 2). Elle implémente une pile sous forme de vecteur, le dernier élément de ce dernier se trouvant en haut de la pile. La méthode empty() renvoie true si et seulement si le nombre d’éléments de la pile est égal à zéro. La méthode peek() renvoie l’objet situé en haut de la pile sans le supprimer. Elle utilise la méthode Vector.elementAt(int) pour accéder au dernier élément du vecteur. Si la pile est vide, l’exception EmptyStackException est lancée. La méthode pop() renvoie l’objet du haut de la pile après l’avoir supprimé. Elle utilise la méthode Vector.removeElementAt(int). Étant donné que cette méthode appelle la méthode peek(), elle lance également une exception EmptyStackException si la pile est vide. La méthode push(Object) insère l’objet donné en haut de la pile. Elle utilise la méthode Vector.addElementAt(Object) qui ajoute l’élément à la fin du vecteur. La méthode search(Object) renvoie la position de l’objet donné dans la pile ou –1 si l’objet n’est pas dans la pile. Elle appelle la méthode Vector.lastIndexOf(Object) qui utilise la méthode equals() pour comparer l’objet donné à ceux du vecteur. Si plusieurs objets sont égaux à l’objet donné, c’est la position du dernier objet de la pile qui est renvoyée, c’est-à-dire celle de l’objet le plus près du haut de la pile. Les numéros des positions sont calculés à l’aide de l’indexation de base 1, c’est pourquoi l’élément supérieur de la pile est à la position 1.
Exemple 6.1 Visualiser une pile vide public class Ex0601 { public static void main(String[] args) { java.util.Stack stack = new java.util.Stack(); System.out.println("stack.size() = " + stack.size()); System.out.println("stack.peek() = " + stack.peek()); } } stack.size() = 0 java.util.EmptyStackException at java.util.Stack.peek(Stack.java:86) at Ex0601.main(Ex0601.java:10)
Ce programme illustre le lancement d’une exception EmptyStackException si la méthode peek() est appelée sur une pile vide.
Exemple 6.2 Gérer une exception EmptyStackException import java.util.Stack; public class Test { public static void main(String[] args) { Stack stack = new Stack(); print(stack); } private static void print(Stack stack) { System.out.println("stack.size() = " + stack.size()); try { System.out.println("stack.peek() = " + stack.peek()); }
117
Classe Java Stack
catch(java.util.EmptyStackException e) { System.out.println(e + " : La pile est vide."); } } } stack.size() = 0 java.util.EmptyStackException : La pile est vide.
Ce programme illustre un procédé simple de gestion d’une exception EmptyStackException.
Exemple 6.3 Tester les méthodes push() et pop() import java.util.Stack; public class Ex0603 { public static void main(String[] args) { Stack stack = new Stack(); stack.push("Angleterre"); stack.push("Canada"); stack.push("France"); stack.push("Mexique"); stack.push("Russie"); stack.push("Danemark"); stack.push("Angleterre"); stack.push("Turquie"); print(stack); System.out.println("stack.search(\"Angleterre\") = " + stack.search("Angleterre")); System.out.println("stack.pop() = " + stack.pop()); System.out.println("stack.pop() = " + stack.pop()); print(stack); System.out.println("stack.search(\"Angleterre\") = " + stack.search("Angleterre")); } private static void print(Stack stack) { System.out.println(stack); System.out.println("stack.size() = " + stack.size()); try { System.out.println("stack.peek() = " + stack.peek()); } catch(java.util.EmptyStackException e) { System.out.println(e + " : La pile est vide."); } } } [Angleterre, Canada, France, ➥ Turquie] stack.size() = 8 stack.peek() = Turquie stack.search("Angleterre") = stack.pop() = Turquie stack.pop() = Angleterre [Angleterre, Canada, France, stack.size() = 6 stack.peek() = Danemark stack.search("Angleterre") =
Mexique, Russie, Danemark, Angleterre,
2 Mexique, Russie, Danemark] 6
118
Piles
Après avoir poussé les huit objets (toutes les chaînes) dans la pile, ce programme appelle sa méthode locale print(Stack) afin d’afficher les données relatives à la pile. Il recherche ensuite "Angleterre", retire "Turquie" du sommet de la pile, puis appelle à nouveau print(Stack). Notez que search(Object) renvoie 2, ce qui signifie qu’il existe une copie de "Angleterre" en deuxième position à partir du haut de la pile (une autre copie se trouve en bas de la pile). La méthode pop() est ensuite appelée deux fois, ce qui permet de supprimer la première occurrence de "Turquie", puis "Angleterre" de la pile. L’appel final de print(Stack) indique que la pile est composée de six éléments, avec "Danemark" en haut et "Angleterre" en sixième position à partir du haut. Notez que l’appel System.out.println(stack) invoque la méthode Vector.toString() qui renvoie une chaîne affichant une liste d’éléments délimités par des crochets [].
6.2 APPLICATIONS DES PILES Bien que la structure de données stack soit l’une des plus simples, elle est fondamentale dans certaines applications importantes, notamment dans les exemples qui vont suivre. Une expression arithmétique est qualifiée de notation suffixée (ou de notation polonaise inversée, RPN) si chaque opérateur est placé après ses opérandes. Par exemple, l’expression suffixée de 3*(4 + 5) est la suivante : 3 4 5 + * (l’expression 3*(4 + 5) est qualifiée d’expression infixée). Les expressions suffixées sont plus faciles à traiter par une machine que les expressions infixées et ce sont les calculatrices RPN qui peuvent effectuer ce type de traitement.
Exemple 6.4 Utiliser une calculatrice RPN Ce programme analyse les expressions suffixées et effectue les calculs indiqués. Il a recours à deux piles, la première pour cumuler les opérateurs, et la seconde pour cumuler les opérandes : import java.util.Stack; import java.io.*; public class Ex0604 { public static void main(String[] args) { boolean quit=false; String input; double x, y, z; Stack operands = new Stack(); while (!quit) { input = getString("RPN> "); switch (input.charAt(0)) { case ’Q’: quit = true; break; case ’+’: y = Double.parseDouble((String)operands.peek()); operands.pop(); x = Double.parseDouble((String)operands.peek()); operands.pop(); z = x + y; System.out.println("\t" + x + "+" + y + " = " + z); operands.push(new Double(z).toString()); break; case ’-’: y = Double.parseDouble((String)operands.peek());
Applications des piles
119
operands.pop(); x = Double.parseDouble((String)operands.peek()); operands.pop(); z = x - y; System.out.println("\t" + x + "-" + y + " = " + z); operands.push(new Double(z).toString()); break; case ’*’: y = Double.parseDouble((String)operands.peek()); operands.pop(); x = Double.parseDouble((String)operands.peek()); operands.pop(); z = x * y; System.out.println("\t" + x + "*" + y + " = " + z); operands.push(new Double(z).toString()); break; case ’/’: y = Double.parseDouble((String)operands.peek()); operands.pop(); x = Double.parseDouble((String)operands.peek()); operands.pop(); z = x / y; System.out.println("\t" + x + "/" + y + " = " + z); operands.push(new Double(z).toString()); break; default: operands.push(input); } } }
private static String getString(String prompt) { System.out.print(prompt); InputStreamReader iSReader = new InputStreamReader(System.in); BufferedReader bReader = new BufferedReader(iSReader); String input=""; try { input = bReader.readLine(); } catch(IOException e) { System.out.println(e); } return input; }
Ce programme traite l’expression suffixée 3 4 5 + * 10 / 1 – qui représente l’expression infixée 3*(4 + 5)/10 - 1. Chaque calcul intermédiaire est imprimé, c’est-à-dire : 4 + 5 = 9, 3*9 = 27, 27/10 = 2,7 et 2,7 - 1 = 1,7. À chaque itération de la boucle while, le programme imprime l’invite RPN>, puis il lit une chaîne d’entrée. Il utilise le premier caractère de cette chaîne afin de décider quelle opération effectuer. S’il s’agit d’un Q, le programme s’arrête. En revanche, s’il s’agit d’un opérateur +, -, * ou /, il procède à l’opération correspondante. S’il s’agit d’une autre valeur, le programme suppose que l’entrée est un opérande numérique. Dans ce cas, il utilise stringstream pour envoyer la valeur numérique dans la variable x, puis il la pousse dans la pile d’opérandes. Lorsque l’un des quatre opérateurs est entré, il dépile les deux derniers nombres de la pile d’opérandes, il effectue l’opération arithmétique demandée, il imprime le résultat, puis il le pousse dans la pile d’opérandes.
120
Piles
RPN> RPN> RPN> RPN>
3 4 5 + 4.0+5.0 = 9.0
RPN> * 3.0*9.0 = 27.0 RPN> 10 RPN> / 27.0/10.0 = 2.7 RPN> 1 RPN> 2.7–1 = 1.7000000000000002 RPN> Q
Généralement, les êtres humains préfèrent les notations infixées à celles suffixées pour les opérations arithmétiques. C’est pourquoi nous vous proposons l’exemple suivant qui convertit une expression infixée donnée en une expression suffixée.
Exemple 6.5 Convertir une expression infixée en expression suffixée import java.util.Stack; import java.io.*; public class Ex0605 { public static void main(String[] args) { try { Stack stack = new Stack(); InputStreamReader reader = new InputStreamReader(System.in); StreamTokenizer tokens = new StreamTokenizer(reader); tokens.ordinaryChar(’/’); // sinon ce serait un commentaire tokens.eolIsSignificant(true); // false est la valeur par défaut int tokenType; System.out.print("Entrez une expression infixée : "); while ((tokenType=tokens.nextToken()) != StreamTokenizer.TT_EOL) { char ch = (char)tokenType; if (tokenType==StreamTokenizer.TT_NUMBER) System.out.print(tokens.nval + " "); else if (ch==’+’ || ch==’-’ || ch==’*’ || ch==’/’) stack.push(new Character(ch)); else if (ch==’)’) System.out.print((Character)stack.pop()+" "); } while (!stack.empty()) System.out.print((Character)stack.pop()+" "); } catch (Exception e) { System.out.println(e); } } } Entrer une expression infixée : (80 - 30)*(40 + 50/10) 80.0 30.0 - 40.0 50.0 10.0 / + *
Supprimer la récursivité
121
L’entrée est analysée par un objet tokens de type StreamTokenizer associé à l’objet InputStreamReader. ordinaryChar(’/’) est appelée et reconnaît l’opérateur de division « / » comme caractère. La méthode eolIsSignificant(true) est ensuite appelée de façon à ce que la constante de classe TT_EOL puisse contrôler la boucle d’analyse et la terminer lorsque le caractère de fin de ligne est détecté. À chaque itération de la boucle while, l’objet tokens obtient l’unité suivante du flux d’entrée, extrait sa représentation char et l’insère dans la variable ch. Les actions suivantes de cet objet dépendent de sa nature : un opérande numérique, l’un des quatre opérateurs arithmétiques ou le caractère parenthèse droite ’)’. S’il s’agit d’un opérande numérique, il est imprimé immédiatement parce que les opérandes figurent devant les opérateurs dans les notations suffixées. S’il s’agit d’un opérateur arithmétique, il est poussé dans la pile. Et enfin, s’il s’agit du caractère parenthèse droite, l’opérateur situé en haut de la pile est extrait est imprimé. Attention, dans le cas présent, l’expression d’entrée doit être mise entre parenthèses. Pensez également à insérer un espace devant l’opérateur de soustraction pour qu’il soit reconnu comme opérateur de soustraction binaire et non comme opérateur de négation unaire.
6.3 SUPPRIMER LA RÉCURSIVITÉ Le système d’exploitation d’un ordinateur exécute une fonction récursive grâce à une pile qui lui permet de stocker l’état d’exécution courant chaque fois qu’il effectue un appel récursif. Par la suite, chaque fois que l’appel récursif provoque le renvoi d’une valeur, il dépile l’état de la pile d’exécution afin de pouvoir recommencer là où il en était. Étant donné que les piles sont utilisées par le système d’exploitation pour exécuter une fonction récursive, il est évident que le programmeur doit être capable de réécrire une fonction récursive de façon à utiliser une pile explicite au lieu d’effectuer des appels récursifs.
Exemple 6.6 Implémenter itérativement les tours de Hanoi Ce programme équivaut au programme de récursivité présenté dans l’exemple 4.15 : public class Ex0606 { public static void main(String[] args) { hanoi(3,’A’,’B’,’C’); // jouer avec 3 disques } private static void hanoi(int n, char x, char y, char z) { java.util.Stack stack = new java.util.Stack(); stack.push(new Quad(n,x,y,z)); while (!stack.empty()) { Quad quad = (Quad)stack.pop(); n = quad.n; x = quad.a; y = quad.b; z = quad.c; if (n == 1) System.out.println ("Déplacer le disque supérieur de la tour " + quad.a + " vers la tour " + quad.c); else { stack.push(new Quad(n-1,y,x,z)); stack.push(new Quad(1,x,y,z)); stack.push(new Quad(n-1,x,z,y)); } } } }
122
Piles
class Quad { public int n; public char a, b, c; public Quad(int n, char a, char b, char c) { this.n = n; this.a = a; this.b = b; this.c = c; } } Déplacer Déplacer Déplacer Déplacer Déplacer Déplacer Déplacer
le le le le le le le
disque disque disque disque disque disque disque
supérieur supérieur supérieur supérieur supérieur supérieur supérieur
de de de de de de de
la la la la la la la
tour tour tour tour tour tour tour
A A C A B B A
vers vers vers vers vers vers vers
la la la la la la la
tour tour tour tour tour tour tour
C B B C A C C
Chaque appel récursif intégré à la version récursive de la méthode hanoi(int, char, char, char) est remplacé par un appel stack.push(Quad) et chaque retour d’un appel récursif est remplacé par un appel stack.pop(Quad). La classe Quad contient un quadruple composé d’un entier et de trois caractères.
?
QUESTIONS DE RÉVISION
QUESTIONS DE RÉVISION
6.1 6.2
6.3
6.4
¿
Pourquoi les piles sont-elles qualifiées de structures LIFO ? Peut-on qualifier les piles de structures : a. LILO b. FILO Définissez les expressions suivantes : a. notation préfixée ; b. notation infixée ; c. notation suffixée. Dans le cadre des notations suffixées, quelle expression suivante peut être considérée comme vraie : a. x y + z + = x y z + + b. x y + z - = x y z – + c. x y – z + = x y z + – d. x y – z – = x y z – –
RÉPONSES
RÉPONSES
6.1
Les piles sont qualifiées de structures LIFO parce que le dernier élément inséré dans la pile est toujours le premier à en être supprimé. LIFO est l’acronyme de l’expression anglaise Last-InFirst-Out, c’est-à-dire dernier entré, premier sorti.
Révision et entraînement
123
6.2
a. Une pile ne peut pas être une structure LILO (Last-In-Last-Out, Dernier entré, dernier sorti), c’est-à-dire le contraire du protocole Dernier entré, premier sorti. b. Une pile peut être une structure FILO (First-In-Last-Out, Premier entré, dernier sorti) qui est identique au protocole Dernier entré, premier sorti.
6.3
a. La notation préfixée des expressions arithmétiques place les opérateurs binaires devant leurs deux opérandes. Par exemple, « x + 2 » s’écrit sous la forme « + x 2 » en notation préfixée. En mathématiques, la notation fonctionnelle standard utilise ce format préfixé, par exemple pour f(x), sin x, etc. b. La notation infixée des expressions arithmétiques place les opérateurs binaires entre leurs opérandes. Il s’agit du format standard des expressions arithmétiques telles que « x + 2 ». c. La notation suffixée des expressions arithmétiques place les opérateurs binaires après leurs deux opérandes. Par exemple, l’expression « x + 2 » s’écrit « x 2 + » en notation suffixée. En mathématiques, la fonction factorielle utilise la notation suffixée : n!.
6.4
a. b. c. d.
?
Vraie parce que (x + y) + z = x + (y + z). Vraie parce que (x + y) – z = x + (y – z). Fausse parce que (x – y) + z x - (y + z). Fausse parce que (x – y) – z x – (y – z).
EXERCICES D’ENTRAÎNEMENT
EXERCICES D’ENTRAÎNEMENT
6.1
Tracez le code suivant en faisant apparaître le contenu de la pile après chaque appel : • Stack stack = new Stack(); • stack.push(new Character(’A’)); • stack.push(new Character(’B’)); • stack.push(new Character(’C’)); • stack.pop(); • stack.pop(); • stack.push(new Character(’D’)); • stack.push(new Character(’E’)); • stack.push(new Character(’F’)); • stack.pop(); • stack.push(new Character(’G’)); • stack.pop(); • stack.pop(); • stack.pop();
6.2
Convertissez les expressions préfixées suivantes en expressions infixées : a. – / + * a b c d e b. / – a b * c + d e c. / a + b * c – d e
6.3
Convertissez les expressions préfixées de l’exercice 6.2 en expressions suffixées.
6.4
Convertissez les expressions infixées suivantes en expressions préfixées : a. (a + b) – (c / (d + e)) b. a / ((b / c) * (d – e)) c. (a / (b / c)) * (d – e)
124
Piles
6.5
Convertissez les expressions infixées de l’exercice 6.4 en expressions suffixées.
6.6
Convertissez les expressions suffixées suivantes en expressions préfixées : a. a b + c d – / e + b. a b c + d e – * – c. a b c d e / / / /
6.7
Convertissez les expressions suffixées de l’exercice 6.6 en expressions infixées.
6.8
Écrivez la méthode suivante en utilisant uniquement le constructeur et les méthodes push(), peek(), pop() et empty() de la classe Stack : • private static void reverse(Stack stack); • // inverse le contenu de la pile donnée
6.9
Écrivez la méthode suivante en utilisant uniquement le constructeur et les méthodes push(), peek(), pop() et empty() de la classe Stack : • private static Stack reversed(Stack stack); • // renvoie une nouvelle pile contenant les mêmes éléments • // que ceux de la pile donnée, mais dans l’ordre inverse
6.10 Écrivez la méthode suivante en utilisant uniquement le constructeur et les méthodes push(), peek(), pop() et empty() de la classe Stack : • public Object penultimate(Stack stack); • // renvoie le deuxième élément de la pile donnée en partant du haut
6.11 Écrivez la méthode suivante en utilisant uniquement le constructeur et les méthodes push(), peek(), pop() et empty() de la classe Stack : • public Object bottom(); • // renvoie l’élément inférieur de la pile
6.12 Écrivez la méthode suivante en utilisant uniquement le constructeur et les méthodes push(), peek(), pop() et empty() de la classe Stack : • public Object popBottom(); • // supprime et renvoie l’élément situé en bas de la pile
6.13 Écrivez la méthode reverse() de l’exercice 6.8 en utilisant les méthodes Vector héritées. 6.14 Écrivez la méthode reverse() de l’exercice 6.9 en utilisant les méthodes Vector héritées. 6.15 Écrivez la méthode penultimate() de l’exercice 6.10 en utilisant les méthodes Vector héri-
tées. 6.16 Écrivez la méthode bottom() de l’exercice 6.11 en utilisant les méthodes Vector héritées. 6.17 Écrivez la méthode popBottom() de l’exercice 6.12 en utilisant les méthodes Vector héritées. 6.18 Modifiez l’exemple 6.5 de façon à ce qu’il utilise une pile de valeurs primitives char des références Object et implémentez votre propre classe Stack dans ce but. Utilisez un tableau pour stocker les valeurs char.
125
Révision et entraînement
¿
SOLUTIONS
SOLUTIONS
6.1
La trace est la suivante :
s.push('A')
s.push('B') A
s.pop()
s.push('E')
s.push('G')
B A
E D A
G E D A
s.pop()
s.push('C')
s.push('D') A
s.push('F')
s.pop()
6.2
a. (a * b + c) / (d – e) b. (a – b) / (c * ( d + e )) c. a / (b + (c * (d – e)))
6.3
a. a b * c + d / e – b. a b – c d e + * / c. a b c d e – * + /
6.4
a. (a + b) – (c / (d + e)) = – + a b / c + d e b. a / ((b / c) * (d – e)) = / a * / b c – d e c. (a / (b / c)) * (d – e) = * / a / b c – d e
6.5
a. (a + b) – (c / (d + e)) = a b + c d e + / – b. a / ((b / c) * (d – e)) = a b c / d e – * / c. (a / (b / c)) * (d – e) = a b c / / * d e – *
6.6
a. (a + b) / (c – d) + e b. a – (b + c) * (d – e) c. a / (b / (c / (d / e)))
6.7
a. + / + a b – c d e b. – a * + b c – d e c. / a / b / c / d e La méthode suivante inverse le contenu d’une pile :
6.8
B A
F E D A
E D A
• private static void reverse(Stack stack) • { Stack tempStack1 = new Stack();
s.pop()
s.pop()
C B A
D A
E D A
D A
126
Piles • • • • • • • •}
6.9
while(!stack.empty()) tempStack1.push(stack.pop()); Stack tempStack2 = new Stack(); while(!tempStack1.empty()) tempStack2.push(tempStack1.pop()); while(!tempStack2.empty()) stack.push(tempStack2.pop());
La méthode suivante inverse le contenu d’une pile : • private static Stack reversed(Stack stack) • { Stack tempStack = new Stack(); • Stack newStack = new Stack(); • while(!stack.empty()) • { Object x = stack.pop(); • tempStack.push(x); • newStack.push(x); • } • while(!tempStack.empty()) • stack.push(tempStack.pop()); • return newStack; •}
6.10 La méthode suivante renvoie le deuxième élément d’une pile en partant du haut : • private static Object penultimate(Stack stack) • { Object x1 = stack.pop(); • Object x2 = stack.pop(); • stack.push(x2); • stack.push(x1); • return x2; •}
6.11 La méthode suivante renvoie l’élément inférieur d’une pile : • private static Object bottom(Stack stack) • { Object x = null; • Stack tempStack = new Stack(); • while(!stack.empty()) • { x = stack.pop(); • tempStack.push(x); • } • while(!tempStack.empty()) • stack.push(tempStack.pop()); • return x; •}
6.12 La méthode suivante supprime et renvoie l’élément inférieur d’une pile : • private static Object popBottom(Stack stack) • { Stack tempStack = new Stack(); • Object x = null; • if (!stack.empty()) x = stack.pop(); • while(!stack.empty()) • { tempStack.push(x); • x = stack.pop(); • } • while(!tempStack.empty()) • stack.push(tempStack.pop()); • return x; •}
Révision et entraînement
127
6.13 La méthode suivante utilise les méthodes Vector pour inverser le contenu d’une pile : • private static void reverse(Stack stack) • { Stack copiedStack = (Stack)stack.clone(); • stack.clear(); • while(!copiedStack.empty()) • stack.push(copiedStack.pop()); •}
6.14 La méthode suivante utilise les méthodes Vector pour renvoyer le contenu inversé d’une pile : • private static Stack reversed(Stack stack) • { Stack copiedStack = (Stack)stack.clone(); • Stack newStack = new Stack(); • while(!copiedStack.empty()) • newStack.push(copiedStack.pop()); • return newStack; •}
6.15 La méthode suivante utilise les méthodes Vector pour renvoyer le deuxième élément à partir du
haut de la pile : • private static Object penultimate(Stack stack) • { if (stack.size()<2) return null; • return stack.elementAt(stack.size()-2); •}
6.16 La méthode suivante utilise les méthodes Vector pour renvoyer l’élément inférieur d’une pile : • private static Object bottom(Stack stack) • { return stack.firstElement(); •}
6.17 La méthode suivante utilise les méthodes Vector pour supprimer et renvoyer l’élément inférieur
d’une pile : • private static Object popBottom(Stack stack) • { return stack.remove(0); •}
6.18 L’exemple 6.5 modifié à l’aide d’une pile de valeurs primitives char est le suivant : • import java.io.*; • class CharStack • { private char[] s = new char[1000]; • private int top=-1; • public boolean empty() { return top<0; } • public char peek() { return s[top]; } • public char pop() { return s[top--]; } • public void push(char ch) { s[++top] = ch; } •} • • public class Tmp • { public static void main(String[] args) • { try • { CharStack stack = new CharStack(); • InputStreamReader reader = new InputStreamReader(System.in); • StreamTokenizer tokens = new StreamTokenizer(reader); • tokens.ordinaryChar(’/’); • tokens.eolIsSignificant(true); • int tokenType; • System.out.print("Enter an infix expression: ");
128
Piles • • • • • • • • • • • • • • • • • •}
while ((tokenType=tokens.nextToken()) != StreamTokenizer.TT_EOL) { char ch = (char)tokenType; if (tokenType==treamTokenizer.TT_NUMBER) System.out.print(tokens.nval + " "); else if (ch==’+’ || ch==’-’ || ch==’*’ || ch==’/’) stack.push(ch); else if (ch==’)’) System.out.print(stack.pop()+" "); } while (!stack.empty()) System.out.print(stack.pop()+" "); } catch (Exception e) { System.out.println(e); } }
Chapitre 7
Files Une file est un conteneur qui implémente le protocole Premier entré, premier sorti (FIFO, First-In-First-Out). Cela signifie que le seul objet accessible dans le conteneur est celui qui a été inséré en premier. Pour comprendre de quoi il retourne, imaginez un groupe de personnes qui font la queue pour aller voir un film : la prochaine personne à entrer dans la salle de cinéma sera celle qui est arrivée avant toutes les autres.
7.1 FRAMEWORK DES FILES Un framework est un jeu d’interfaces et de classes abstraites associées via l’extension et l’implémentation. En outre, il offre les diverses possibilités d’implémentations des types de données abstraites. Le framework de collections Java (reportez-vous à la section 5.1) est composé de listes, de jeux et de mappes. Si la bibliothèque standard Java ne propose pas de framework de files à proprement parler, le framework de collections vous indique tout de même au programmeur comment en construire un. À l’instar des interfaces List et Set, l’interface Queue Object AbstractCollection Collection est une sous-interface de ColQueue AbstractQueue lection (reportez-vous à la ArrayQueue section 5.2) et elle hérite donc LinkedQueue des 15 spécifications de méthodes de cette dernière : public interface Collection { public boolean add(Object); public boolean addAll(Collection); public void clear(); public boolean contains(Object); public boolean containsAll(Collection); public boolean equals(Object); public int hashCode(); public boolean isEmpty(); public Iterator iterator(); public boolean remove(Object); public boolean removeAll(Collection); public boolean retainAll(Collection); public int size();
130
Files
public Object[] toArray(); public Object[] toArray(Object[]); }
Exemple 7.1 Une interface queue L’interface suivante est destinée aux files qui ajoutent les quatre méthodes traditionnelles définissant leur comportement : public interface Queue extends Collection { public Object dequeue(); public Object enqueue(Object object); public Object getBack(); public Object getFront(); }
Dans le cas présent, dequeue signifie supprimer l’objet situé en tête de file, et enqueue insérer le nouvel objet en queue de la file.
Exemple 7.2 Utiliser une file Le diagramme ci-contre illustre l’évolution d’une file suite aux appels des méthodes enqueue (Object) et dequeue() dans l’ordre indiqué ci-après : q.enqueue("Amin"); q.enqueue("Bush"); q.enqueue("Chen"); q.enqueue("Diaz"); q.dequeue(); q.dequeue(); q.enqueue("Ford"); q.enqueue("Gore"); q.dequeue();
Dans le cas présent, les éléments situés en tête de file se trouvent dans la colonne de gauche, ceux situés en queue se trouvent dans la colonne de droite. C’est la raison pour laquelle, après le troisième appel de la méthode dequeue(), un appel de la méthode getFront() renverrait "Diaz" et un appel de la méthode getBack() renverrait "Gore". Sur le modèle du framework de collections Java (reportez-vous à la section 5.1), nous implémentons l’interface Queue comme sous-classe abstraite de la classe AbstractCollection, le tableau concerné et les implémentations liées devenant ensuite des sous-classes de cette classe de base :
q.enqueue("Amin")
Amin
q.enqueue("Bush")
Amin Bush
q.enqueue("Chen")
Amin Bush Chen
q.enqueue("Diaz")
Amin Bush Chen Diaz
q.dequeue()
Bush Chen Diaz
q.dequeue()
Chen Diaz
q.enqueue("Ford")
Chen Diaz Ford
q.enqueue("Gore")
Chen Diaz Ford Gore
q.dequeue()
Diaz Ford Gore
Exemple 7.3 Une classe AbstractQueue import java.util.*; public abstract class AbstractQueue extends AbstractCollection implements Queue
131
Framework des files
{ protected AbstractQueue() { } public abstract Object dequeue(); public abstract Object enqueue(Object object); public boolean equals(Object object) { if (object == this) return true; if (!(object instanceof AbstractQueue)) return false; AbstractQueue abstractQueue = (AbstractQueue) object; if (abstractQueue.size() != size()) return false; return containsAll(abstractQueue); } public abstract Object getBack(); public abstract Object getFront(); public int hashCode() { int n = 0; for (Iterator it = iterator(); it.hasNext(); ) { Object object = it.next(); if (object != null) n += object.hashCode(); } return n; } public abstract Iterator iterator(); public abstract int size(); }
Cette classe abstraite implémente le constructeur par défaut et remplace les méthodes equals (Object) et hashCode() de la classe Object. Les six autres méthodes sont déclarées comme abstraites ; c’est pourquoi les sous-classes concrètes sont chargées de leur implémentation. L’interface Queue requiert la présence des méthodes dequeue(), enqueue(Object), getBack() et getFront(), tandis que la classe de base AbstractCollections requiert l’implémentation des méthodes iterator() et size(). Étant une sous-classe de AbstractCollections, AbstractQueue hérite des méthodes concrètes suivantes (reportez-vous à la section 5.3) : public public public public public public public public public public public
boolean void boolean boolean boolean boolean boolean boolean Object[] Object[] String
addAll(Collection); clear(); contains(Object); containsAll(Collection); isEmpty(); remove(Object); removeAll(Collection); retainAll(Collection); toArray(); toArray(Object[]); toString();
La méthode isEmpty() utilise la méthode size(), contrairement aux dix autres méthodes concrètes qui utilisent la méthode iterator(). C’est la raison pour laquelle les sous-classes doivent implémenter les méthodes size() et iterator().
132
Files
7.2 IMPLÉMENTATION CONTIGUË Les tableaux, c’est-à-dire le procédé contigu, constituent la méthode la plus simple d’implémentation d’une file.
Exemple 7.4 Classe ArrayQueue import java.util.*; public class ArrayQueue extends AbstractQueue { protected Object[] objects; protected int front=0; protected int back=0; protected int capacity=16; // INVARIANTS : objects[i] == null pour 0 <= i < front; // objects[i] != null pour tête <= i < fin; // objects[i] == null pour fin <= i < capacité; public ArrayQueue() { objects = new Object[capacity]; } public ArrayQueue(int capacity) { this.capacity = capacity; objects = new Object[capacity]; } public Object dequeue() {if (isEmpty()) throw new NoSuchElementException("La file est vide"); Object object = objects[front++]; if (2*front>=capacity) // décaler vers la gauche { for (int i=0; i<size(); i++) objects[i] = objects[i+front]; back -= front; front = 0; } return object; } public Object enqueue(Object object) { if (back>=capacity) { Object[] temp = objects; capacity *= 2; // doubler la capacité objects = new Object[capacity]; for (int i=0; i
Implémentation contiguë
133
public Object getFront() {if (isEmpty()) throw new NoSuchElementException("La file est vide"); return objects[front]; } public Iterator iterator() { return new Iterator() // classe interne anonyme { private int cursor=front; public boolean hasNext() { return cursor=back) throw new NoSuchElementException(); return objects[cursor++]; } public void remove() { throw new UnsupportedOperationException(); } }; } public int size() { return back-front; } }
Le tableau objects est composé des éléments de la file. Comme l’indique le commentaire d’invariant de classe, seul le sous-tableau objects[front..(back-1)] est utilisé ; tous les autres éléments sont null. La classe contient deux constructeurs : le premier est un constructeur par défaut qui définit la capacité initiale du tableau à 16, tandis que le second laisse à l’utilisateur le soin de déterminer cette capacité initiale. Les méthodes getFront() et getBack() renvoient respectivement objects[front] et objects[back-1]. Quant à la méthode size(), elle renvoie back-front, soit le nombre d’éléments figurant dans le sous-tableau objects[front..(back-1)]. La méthode enqueue(Object) insère l’objet donné dans objects[back], puis elle incrémente l’index back. Si objects[back] n’existe pas, c’est-à-dire si back a atteint la fin du tableau, un nouveau tableau deux fois plus grand est créé avant l’insertion. Les éléments back-front sont ensuite copiés dans ce tableau, en commençant par l’index 0. La méthode dequeue() supprime l’objet situé au niveau de objects[front], puis incrémente l’index front. Suite à cette suppression, si l’index front se trouve dans la deuxième moitié finale du tableau (c’est-à-dire si au moins la moitié des éléments du tableau sont inutilisés), les éléments back-front sont tous décalés vers le début du tableau de façon à commencer au niveau de l’index 0. En dernier lieu, la méthode iterator() renvoie un itérateur qui vous permettra de parcourir la file. Étant donné que celle-ci est implémentée comme un sous-tableau, l’itérateur doit simplement suivre un index, nommé cursor, qui traverse ce sous-tableau. L’itérateur renvoyé est créé par le constructeur d’une classe interne anonyme qui définit les trois méthodes nécessaires à l’interface Iterator. Le curseur est initialisé au niveau de l’index front, puis il est incrémenté après chaque appel de la fonction next() qui renvoie objects[cursor]. Si la fonction next() est appelée alors que hasNext() est false (c’est-à-dire une fois que le curseur a atteint la fin de la file), une exception NoSuchElementException est lancée. La méthode remove() lance ensuite automatiquement une exception UnsupportedOperationException parce qu’une file interdit la suppression de ses éléments par une méthode autre que dequeue().
134
Files
Exemple 7.5 Tester la classe ArrayQueue Le pilote test suivant nous permettra d’implémenter notre file : public class TestQueue { public static void main(String[] args) { ArrayQueue q = new ArrayQueue(); System.out.println(q); q.enqueue("Amin"); System.out.println(q); q.enqueue("Bush"); System.out.println(q); q.enqueue("Chen"); System.out.println(q); q.enqueue("Diaz"); System.out.println(q); q.dequeue(); System.out.println(q); q.dequeue(); System.out.println(q); q.enqueue("Ford"); System.out.println(q); q.enqueue("Gore"); System.out.println(q); q.dequeue(); System.out.println(q); System.out.println("q.getFront() = " + q.getFront()); System.out.println("q.getBack() = " + q.getBack()); System.out.println("q.size() = " + q.size()); for (java.util.Iterator it=q.iterator(); it.hasNext(); ) System.out.println("\tit.next() = " + it.next()); for (;;) // forcer une exception { q.dequeue(); System.out.println(q); } } } [] [Amin] [Amin, Bush] [Amin, Bush, Chen] [Amin, Bush, Chen, Diaz] [Bush, Chen, Diaz] [Chen, Diaz] [Chen, Diaz, Ford] [Chen, Diaz, Ford, Gore] [Diaz, Ford, Gore] q.getFront() = Diaz q.getBack() = Gore q.size() = 3 it.next() = Diaz it.next() = Ford it.next() = Gore [Ford, Gore] [Gore] [] java.util.NoSuchElementException: la file est vide at LinkedQueue.dequeue(LinkedQueue.java:26) at TestQueue.main(TestQueue.java, Compiled Code) Exception in thread "main"
7.3 IMPLÉMENTATION CHAÎNÉE L’implémentation chaînée est très certainement plus efficace que l’implémentation contiguë parce qu’elle élimine principalement les risques de dépassement de capacité de la file. Cependant, étant donné que ce type d’implémentation a recours aux pointeurs, il reste plus complexe que l’implémentation contiguë.
Implémentation chaînée
135
Exemple 7.6 Classe LinkedQueue import java.util.*; import AbstractQueue; public class LinkedQueue extends AbstractQueue { private static class Node { Object object; Node next, previous; Node() { this.next = this.previous = this; } Node(Object object, Node next, Node previous) { this.object = object; this.next = next; this.previous = previous; } } private Node header = new Node(); private int size = 0; public Object dequeue() {if (isEmpty()) throw new NoSuchElementException("la file est vide"); Object object = header.next.object; header.next = header.next.next; header.next.previous = header; --size; return object; } public Object enqueue(Object object) { Node p = header.previous; // dernier élément de la file header.previous = p.next = new Node(object,header,p); ++size; return object; } public Object getBack() {if (isEmpty()) throw new NoSuchElementException("la file est vide"); return header.previous.object; } public Object getFront() {if (isEmpty()) throw new NoSuchElementException("la file est vide"); return header.next.object; } public Iterator iterator() { return new Iterator() // classe interne anonyme { private Node cursor=header; public boolean hasNext() { return cursor.next != header; } public Object next() { if (cursor.next==header) throw new NoSuchElementException(); cursor = cursor.next; return cursor.object; } public void remove() { throw new UnsupportedOperationException();
136
Files
} }; } public LinkedQueue() { } public int size() { return size; } }
7.4 APPLICATIONS UTILISANT LES FILES Les files sont naturellement utilisées lorsque la fréquence des demandes utilisateur pour certains services risque de dépasser celle à laquelle ces services peuvent être effectués. Imaginez, par exemple, des voitures qui font la queue à un péage, comme illustré dans le diagramme composé de quatre voitures et de trois cabines de péage. La situation est similaire avec un réseau local sur lequel de nombreux ordinateurs partagent uniquement quelques imprimantes : les travaux d’impression s’accumulent parfois dans la file d’attente. Il en va de même aux caisses d’un supermarché ou bien chez le coiffeur.
Exemple 7.7 Simulation d’un système client/serveur Le programme suivant vous propose une simulation d’un système général client/serveur, les clients pouvant être des voitures, des travaux d’impression ou bien des personnes, et les serveurs respectivement des cabines de péage, des imprimantes ou bien des coiffeurs. L’exécution de cette simulation créerait la sortie suivante :
Le travail #1 arrive au temps 2 avec 7 pages. La file contient maintenant 1 travail : [#1(7)] L’imprimante A(89%,84%) commence le travail #1 au temps 2. La file est maintenant vide. Le travail #2 arrive au temps 10 avec 39 pages. La file contient maintenant 1 travail : [#2(39)] L’imprimante B(97%,91%) commence le travail #2 au temps 10. La file est maintenant vide. L’imprimante A(89%,84%) termine le travail #1 au temps 11. Le travail #3 arrive au temps 18 avec 36 pages. La file contient maintenant 1 travail : [#3(36)] L’imprimante A(89%,87%) commence le travail #3 au temps 18. La file est maintenant vide. Le travail #4 arrive au temps 44 avec 126 pages. La file contient maintenant 1 travail : [#4(126)] L’imprimante C(106%,102%) commence le travail #4 au temps 44. La file est maintenant vide. L’imprimante B(97%,91%) termine le travail #2 au temps 53.
Applications utilisant les files
137
L’imprimante A(89%,87%) termine le travail #3 au temps 60. Le travail #5 arrive au temps 78 avec 170 pages. La file contient maintenant 1 travail : [#5(170)] L’imprimante A(89%,92%) commence le travail #5 au temps 78. La file est maintenant vide. Le travail #6 arrive au temps 113 avec 172 pages. La file contient maintenant 1 travail : [#6(172)] L’imprimante B(97%,95%) commence le travail #6 au temps 113. La file est maintenant vide. Le travail #7 arrive au temps 121 avec 40 pages. La file contient maintenant 1 travail : [#7(40)] L’imprimante D(128%,124%) commence le travail #7 au temps 121. La file est maintenant vide. Le travail #8 arrive au temps 127 avec 30 pages. La file contient maintenant 1 travail : [#8(30)] Le travail #9 arrive au temps 136 avec 41 pages. La file contient maintenant 2 travaux : [#8(30), #9(41)] Le travail #10 arrive au temps 140 avec 20 pages. La file contient maintenant 3 travaux : [#8(30), #9(41), #10(20)] Le travail #11 arrive au temps 147 avec 31 pages. La file contient maintenant 4 travaux : [#8(30), #9(41), #10(20), ➥ #11(31)] L’imprimante D(128%,124%) termine le travail #7 au temps 154. L’imprimante D(128%,126%) commence le travail #8 au temps 155. La file contient maintenant 3 travaux : [#9(41), #10(20), #11(31)] Le travail #12 arrive au temps 160 avec 63 pages. La file contient maintenant 4 travaux : [#9(41), #10(20), #11(31), ➥ #12(63)] L’imprimante C(106%,102%) termine le travail #4 au temps 168. L’imprimante C(106%,104%) commence le travail #9 au temps 169. La file contient maintenant 3 travaux : [#10(20), #11(31), #12(63)] L’imprimante D(128%,126%) termine le travail #8 au temps 179. L’imprimante D(128%,118%) commence le travail #10 au temps 180. La file contient maintenant 2 travaux : [#11(31), #12(63)] L’imprimante D(128%,118%) termine le travail #10 au temps 197. L’imprimante D(128%,138%) commence le travail #11 au temps 198. La file contient maintenant 1 travail : [#12(63)] L’imprimante C(106%,104%) termine le travail #9 au temps 209. L’imprimante C(106%,96%) commence le travail #12 au temps 210. La file est maintenant vide.
Dans cet exemple, les travaux d’impression correspondent aux clients, et les imprimantes aux serveurs. Ce cas de figure est composé de quatre serveurs, à savoir les imprimantes A, B, C et D, chacune d’entre elles se caractérisant par une vitesse d’impression différente exprimée sous forme d’un pourcentage. Ainsi, l’imprimante A a une vitesse d’impression de 89 %, soit environ 0,89 page par seconde. En outre, chaque travail d’impression est associé à une vitesse d’impression. Ainsi, l’imprimante A présente une vitesse d’impression égale à 84 % pour le travail #1, soit 0,84 page par seconde. La rapidité d’une même imprimante varie d’un travail à l’autre pour des raisons diverses, notamment en fonction du trafic sur le réseau. Ainsi, l’imprimante B, dont la vitesse d’impression est en moyenne égale à 0,97 page par seconde, imprime 0,91 page par seconde pour le travail #2, et 0,95 page par seconde pour le travail #6. D’autre part, les travaux d’impression arrivent dans la file de façon aléatoire. Dans le cas présent, le travail #1 a été inséré au temps 2 (soit 2 secondes après l’heure de début), le travail #2 au temps 10 et le travail #3 au temps 18. Si les imprimantes sont encore occupées lorsqu’un nouveau travail arrive, celui-ci est intégré à la file d’attente. C’est ce qui se produit pour le travail #8 qui arrive au temps 127, au moment où les quatre
138
Files
imprimantes sont utilisées. Avant cela, il restait toujours au moins une imprimante disponible qui pouvait commencer l’impression du travail dès son arrivée : • • • • • • •
L’imprimante A commence l’impression du travail #1 dès son arrivée au temps 2. L’imprimante B commence l’impression du travail #2 dès son arrivée au temps 10. L’imprimante A commence l’impression du travail #3 dès son arrivée au temps 18. L’imprimante C commence l’impression du travail #4 dès son arrivée au temps 44. L’imprimante A commence l’impression du travail #5 dès son arrivée au temps 78. L’imprimante B commence l’impression du travail #6 dès son arrivée au temps 113. L’imprimante D commence l’impression du travail #7 dès son arrivée au temps 121.
Le travail #8 doit donc attendre 28 secondes avant que l’imprimante D commence son impression au temps 155. Il est placé dans la file d’attente, comme tous les autres travaux qui se présentent au cours de ces 28 secondes. C’est pourquoi notre file contient les travaux #8, #9, #10 et #11 au temps 155. Dans l’exemple de sortie que nous venons de voir, chaque travail d’impression est identifié par son numéro ID et par sa taille. Ainsi, #1(7) signifie que le travail #1 est composé de 7 pages à imprimer. De la même manière, #8(30) indique que 30 pages doivent être imprimées pour le travail #8. #12
#11
#10
#9
#8
#7
D #4
C
#1
A 0
#10
#11
#9
#2
B
#8
#12
#6
#3
#5
100
100
200
300
Pour qu’une simulation soit efficace, vous devez utiliser des nombres générés de façon aléatoire. Dans le cas présent, nous avons eu recours à trois générateurs de nombres aléatoires : le premier a généré les vitesses d’impression moyennes de chaque imprimante, le second la vitesse d’impression réelle de chaque travail imprimé par une imprimante donnée, et le troisième l’intervalle de temps qui sépare l’arrivée des travaux. Ces générateurs sont des instances de l’extension suivante de la classe java.util.Random : public class Random extends java.util.Random { private double mean; private double standardDeviation; public Random(double mean) { this.mean = mean; this.standardDeviation = mean; }
Applications utilisant les files
139
public Random(double mean, double standardDeviation) { this.mean = mean; this.standardDeviation = standardDeviation; } public double nextGaussian() { double x = super.nextGaussian(); // x = normal(0.0, 1.0) return x*standardDeviation + mean; } public double nextExponential() { return -mean*Math.log(1.0 - nextDouble()); } public int intNextExponential() { return (int)Math.ceil(nextExponential()); } }
La méthode nextGaussian() renvoie des nombres aléatoires qui sont traditionnellement affectés avec la moyenne et la déviation standard données. Elle appelle et remplace la méthode synonyme dans la classe java.util.Random, ce qui renvoie des nombres aléatoires normalement attribués avec une moyenne égale à 0.0 et une déviation standard de 1.0. La méthode nextExponential() renvoie des nombres aléatoires qui sont attribués de façon exponentielle avec la moyenne donnée. Vous obtenez ainsi une répartition juste des intervalles qui séparent les arrivées. Cette méthode permet également de générer la taille des travaux qui détermine la durée de l’impression. Chaque travail d’impression est une instance de la classe suivante : public class Client { private static final int MEAN_JOB_SIZE = 100; private static Random randomJobSize = new Random(MEAN_JOB_SIZE); private static int nextId = 0; private int id, jobSize; private Server server; public Client(int time) { id = ++nextId; jobSize = randomJobSize.intNextExponential(); print(id,time,jobSize); } public double getJobSize() { return jobSize; } public void beginService(Server server, int time) { this.server = server; printBegins(server,id,time); } public void endService(int time) { printEnds(server,id,time); server = null; } public String toString() { return "#" + id + "(" + (int)Math.round(jobSize) + ")"; } private static void print(int job, int time, double size) { System.out.println("Le travail #" + job + " arrive au temps " + time + " avec " + (int)Math.round(size) + " pages."); } private static void printBegins(Server server, int job, int time)
140
Files
{ System.out.println("L’imprimante " + server + " commence le travail #" + job + " au temps " + time + "."); } private static void printEnds(Server server, int job, int time) { System.out.println("L’imprimante " + server + " termine le travail #" + job + " au temps " + time + "."); } }
Le générateur de nombres aléatoires randomJobSize génère la taille des travaux affectée de façon exponentielle avec une moyenne de 100 pages. Il est déclaré comme étant statique parce qu’une seule instance est nécessaire à la création de toutes les tailles de travaux. De la même façon, static int nextId permet de générer les numéros d’identification de tous les travaux. Le constructeur utilise le compteur nextId pour définir l’id du travail, puis le générateur randomJobSize afin de définir jobSize. Il imprime ensuite une ligne de sortie annonçant que le travail est arrivé. La méthode beginService() affecte la référence server à l’imprimante qui l’a appelée, puis elle imprime une ligne de sortie indiquant que l’impression a commencé. De la même façon, la méthode endService() annule la référence server après avoir imprimé une ligne de sortie signalant que l’impression est terminée. Chaque imprimante est représentée par une instance de la classe suivante : public class Server { private static Random randomMeanServiceRate = new Random(1.00,0.20); private static char nextId = ’A’; private Random randomServiceRate; private char id; private double meanServiceRate, serviceRate; private Client client; private int timeServiceEnds; public Server() { id = (char)nextId++; meanServiceRate = randomMeanServiceRate.nextGaussian(); randomServiceRate = new Random(meanServiceRate,0.10); } public void beginServing(Client client, int time) { this.client = client; serviceRate = randomServiceRate.nextGaussian(); client.beginService(this,time); int serviceTime = (int)Math.ceil(client.getJobSize()/serviceRate); timeServiceEnds = time + serviceTime; } public void endServing(int time) { client.endService(time); this.client = null; } public int getTimeServiceEnds() { return timeServiceEnds; } public boolean isFree() { return client == null; } public String toString() { int percentMeanServiceRate = (int)Math.round(100*meanServiceRate);
Applications utilisant les files
141
int percentServiceRate = (int)Math.round(100*serviceRate); return id + "(" + percentMeanServiceRate + "%," + percentServiceRate + "%)"; } }
Le générateur de nombres aléatoires randomMeanServiceRate génère normalement les vitesses avec une moyenne de 100.0 et une déviation standard de 20.0. Il crée la vitesse meanServiceRate pour chaque imprimante soit, dans notre exemple, une vitesse d’impression de 89 % pour l’imprimante A, de 97 % pour l’imprimante B, de 106 % pour l’imprimante C et de 128 % pour l’imprimante D. De la même façon, le générateur de nombres aléatoires randomServiceRate génère normalement les vitesses d’impression de chaque travail soit, dans notre exemple, 84 % pour le travail #1, 87 % pour le travail #3 et 92 % pour le travail #5. Ces vitesses ont été calculées à partir d’une affectation normale définie en fonction d’une moyenne égale à 89 % (pour l’imprimante A). La déviation standard est fixée à 10 % pour la répartition de chaque imprimante. La méthode beginServing() affecte la référence client au travail client en cours d’impression. Elle obtient la vitesse serviceRate extraite du générateur randomServiceRate, puis elle envoie le message beginService au travail d’impression client. Ensuite, l’affectation int serviceTime = (int)Math.ceil(client.getJobSize()/serviceRate); calcule la durée d’impression du travail (exprimée en secondes) en divisant la taille de ce travail (soit le nombre de pages) par la vitesse d’impression (soit le nombre de pages par seconde). Le plafond entier de cette division est ensuite ajouté à l’heure courante de façon à initialiser le champ timeServiceEnds de l’objet Server. La classe Main est la suivante : public class ClientServerSimulation { private static final int NUMBER_OF_SERVERS = 4; private static final double MEAN_INTERARRIVAL_TIME = 20.0; private static final int DURATION = 100; private static Server[] servers = new Server[NUMBER_OF_SERVERS]; private static Queue clients = new ArrayQueue(); private static Random random = new Random(MEAN_INTERARRIVAL_TIME); public static void main(String[] args) { for (int i=0; i
142
Files
else System.out.println("La file contient maintenant " + size + "job" + (size>1?"s: ":": ") + queue); }
La simulation de l’exemple 7.7 est qualifiée de simulation en temps réel parce que sa boucle principale est effectuée une fois pour chaque mouvement de l’horloge. Par opposition, une simulation événementielle a lieu lorsque la boucle principale est effectuée une fois pour chaque événement : à l’arrivée du travail, au début ou à la fin du service. Les simulations événementielles sont généralement plus simples, même si tous les serveurs doivent alors obligatoirement travailler à la même vitesse.
?
QUESTIONS DE RÉVISION
QUESTIONS DE RÉVISION
7.1 7.2
7.3
¿
Pourquoi les files sont-elles qualifiées de structures FIFO, c’est-à-dire Premier entré, premier sorti ? Peut-on qualifier les files de structures : a. LILO b. FILO Quels sont les avantages et les inconvénients de l’implémentation chaînée d’une file par rapport à une implémentation contiguë ?
RÉPONSES
RÉPONSES
7.1
Les files sont qualifiées de structures FIFO parce que le premier élément inséré dans une file est toujours le premier à en être supprimé.
7.2
a. Une file peut être une structure LILO (Last-In-Last-Out, Dernier entré, dernier sorti), c’est-àdire la même chose que le protocole Premier entré, premier sorti. b. Une file ne peut pas être une structure FILO (First-In-Last Out, Premier entré, dernier sorti) qui est le contraire du protocole Premier entré, premier sorti. L’implémentation chaînée présente l’avantage d’éliminer principalement les risques de dépassement de capacité de la file. En effet, le nombre d’appels de la méthode enqueue() est uniquement limité par la quantité de mémoire disponible pour l’opérateur new. En revanche, elle utilise des pointeurs qui la rendent plus complexe que l’implémentation contiguë.
7.3
?
EXERCICES D’ENTRAÎNEMENT
EXERCICES D’ENTRAÎNEMENT
7.1
Tracez le code suivant en indiquant le contenu de la file q après chaque appel : • ArrayQueue q; • q.enqueue("A"); • q.enqueue("B"); • q.enqueue("C"); • q.dequeue(); • q.dequeue();
Révision et entraînement
143
• q.enqueue("D"); • q.enqueue("E"); • q.enqueue("F"); • q.dequeue(); • q.enqueue("G"); • q.dequeue(); • q.dequeue(); • q.dequeue();
Implémentez la méthode des exercices 7.2 à 7.7 en utilisant uniquement le constructeur et les méthodes enqueue(), dequeue() et isEmpty() de la classe ArrayQueue : 7.2
• public static void reverse(ArrayQueue queue) • // inverse le contenu de la file donnée;
7.3
• private static ArrayQueue reversed(ArrayQueue queue); • // renvoie une nouvelle file qui contient les mêmes éléments • // que la file donnée, mais dans l’ordre inverse
7.4
• public static Object second(ArrayQueue queue) • // renvoie le deuxième élément à partir de la tête de file
7.5
• public static Object last(); • // renvoie le dernier élément de la file
7.6
• public static Object removeLast(); • // supprime et renvoie le dernier élément de la file
7.7
• public static ArrayQueue merge(ArrayQueue q1, ArrayQueue q2); • // fusionne les deux files données en les alternant • // tant que cela est possible, et renvoie la file combinée obtenue
7.8
Modifiez le programme de l’exemple 7.7 pour qu’il calcule et imprime les statistiques suivantes : a. le nombre moyen de travaux dans le système ; b. le nombre moyen de travaux dans la file ; c. la durée moyenne de présence d’un travail dans le système ; d. la durée moyenne d’attente d’un travail dans la file. Théoriquement, les réponses obtenues devraient être approximativement égales à ces résultats suivants : a. L = n/(st – n) b. (nL)/(st) c. tL d. (nL)/s avec n = taille moyenne du travail, s = nombre de serveurs et t = intervalle moyen entre les arrivées. Par exemple, si n = 100, s = 6 et t = 20, les réponses obtenues seront approximativement égales à : a. 5 travaux dans le système ; b. 4,17 travaux dans la file ; c. 100 secondes dans le système ; d. 83,3 secondes dans la file. Modifiez le programme de l’exercice 7.8 de façon à ce qu’il calcule et imprime également les statistiques suivantes : a. la durée de service moyenne de chaque serveur ; b. la durée de service moyenne de tous les travaux ; c. le pourcentage d’inactivité de chaque serveur.
7.9
144
Files
¿
SOLUTIONS
SOLUTIONS
7.1
La trace est la suivante :
A
7.2
q.enqueue('A')
A
q.enqueue('B')
A B
q.enqueue('C')
A B C
q.dequeue()
B C
q.dequeue()
C
q.enqueue('D')
C D
q.enqueue('E')
C D E
q.enqueue('F')
C D E F
q.dequeue()
D E F
q.enqueue('G')
D E F G
q.dequeue()
E F G
q.dequeue()
F G
q.dequeue()
G
La méthode suivante inverse le contenu de la file : • private static void reverse(ArrayQueue queue) • { Stack tempStack = new Stack(); • while(!queue.isEmpty()) • tempStack.push(queue.dequeue()); • while(!tempStack.empty()) • queue.enqueue(tempStack.pop()); •}
7.3
La méthode suivante renvoie le contenu inversé d’une file : • private static ArrayQueue reversed(ArrayQueue queue) • { ArrayQueue newQueue = new ArrayQueue(); • Stack stack = new Stack(); • while(!queue.isEmpty()) • { Object x = queue.dequeue(); • stack.push(x); • newQueue.enqueue(x); • } • while(!newQueue.isEmpty()) • queue.enqueue(newQueue.dequeue()); • while(!stack.empty())
Révision et entraînement • newQueue.enqueue(stack.pop()); • return newQueue; •}
7.4
La méthode suivante renvoie le deuxième élément à partir de la tête de file : • private static Object second(ArrayQueue queue) • { ArrayQueue tempQueue = new ArrayQueue(); • Object x = queue.dequeue(); • tempQueue.enqueue(x); • x = queue.dequeue(); • tempQueue.enqueue(x); • while(!queue.isEmpty()) • tempQueue.enqueue(queue.dequeue()); • while(!tempQueue.isEmpty()) • queue.enqueue(tempQueue.dequeue()); • return x; •}
7.5
La méthode suivante renvoie le dernier élément d’une file : • private static Object last(ArrayQueue queue) • { ArrayQueue tempQueue = new ArrayQueue(); • while(!queue.isEmpty()) • tempQueue.enqueue(queue.dequeue()); • Object x = null; • while(!tempQueue.isEmpty()) • { x = tempQueue.dequeue(); • queue.enqueue(x); • } • return x; •}
7.6
La méthode suivante supprime et renvoie le dernier élément d’une file : • private static Object removeLast(ArrayQueue queue) • { ArrayQueue tempQueue = new ArrayQueue(); • int count=0; • while(!queue.isEmpty()) • { tempQueue.enqueue(queue.dequeue()); • ++count; • } • Object x = null; • for (int i=0; i
7.7
La méthode suivante fusionne deux files : • public static ArrayQueue merged(ArrayQueue q1, ArrayQueue q2) • { ArrayQueue newQueue = new ArrayQueue(); • while(!q1.isEmpty() && !q2.isEmpty()) • { newQueue.enqueue(q1.dequeue()); • newQueue.enqueue(q2.dequeue()); • } • while(!q1.isEmpty()) • newQueue.enqueue(q1.dequeue()); • while(!q2.isEmpty()) • newQueue.enqueue(q2.dequeue()); • return newQueue; •}
145
146 7.8
Files
Le programme de l’exemple 7.7 peut être modifié de la façon suivante : Ajoutez tout d’abord ce code à la classe Client : • private int id, jobSize, tArrived, tBegan, tEnded; • • public Client(int time) • { id = ++nextId; • jobSize = randomJobSize.intNextExponential(); • tArrived = time; •} • • public double getJobSize() • { return jobSize; •} • • public int getWaitTime() • { return tBegan - tArrived; •} • • public int getServiceTime() • { return tEnded - tBegan; •} • • public void beginService(Server server, int time) • { this.server = server; • tBegan = time; •} • • public void endService(int time) • { tEnded = time; • server = null; •}
Ajoutez ensuite ce code à la classe Server : • public Client getClient() • { return client; •}
En dernier lieu, ajoutez ce code à la classe main : • private static int totalTime, queueTime, totalNumberOfJobs; • private static int[] numberOfJobs = new int[NUMBER_OF_SERVERS]; • public static void main(String[] args) • { for (int i=0; i
Révision et entraînement
147
• if (t == server.getTimeServiceEnds()) • { Client client = server.getClient(); • server.endServing(t); • int waitTime = client.getWaitTime(); • ++numberOfJobs[i]; // compter uniquement les • // projets terminés • } • } • } • totalTime += clients.size(); • queueTime += clients.size(); • } • for (int i=0; i
7.9
En ce qui concerne la seconde modification du programme de l’exemple 7.7, ajoutez le code suivant : • private static int totalTime, queueTime, totalNumberOfJobs, • totalServiceTime; • private static int[] serviceTime = new int[NUMBER_OF_SERVERS]; • private static int[] numberOfJobs = new int[NUMBER_OF_SERVERS]; • public static void main(String[] args) • { for (int i=0; i
148
Files • else // le serveur sert un client • { ++totalTime; // compter le temps dans le système • if (t == server.getTimeServiceEnds()) • { Client client = server.getClient(); • server.endServing(t); • int waitTime = client.getWaitTime(); • serviceTime[i] = client.getServiceTime(); • ++numberOfJobs[i]; // compter uniquement les • // travaux terminés • } • } • } • totalTime += clients.size(); • queueTime += clients.size(); • } • for (int i=0; i
Chapitre 8
Listes Une liste est un conteneur séquentiel capable d’insérer et de supprimer des éléments localement de façon constante, c’est-à-dire indépendamment de la taille du conteneur. Il est préférable d’utiliser cette structure de données pour les applications qui n’ont pas besoin d’un accès aléatoire. Pour comprendre de quoi il retourne, imaginez les wagons d’un train : pour en supprimer un, il suffit de le déconnecter des wagons le touchant, puis de reconnecter ces derniers entre eux. Dans le cadre de Java, l’interface List est une extension de l’interface Collection, c’est pourquoi elle fait partie, de même que son implémentation, du framework de collections Java 1.2 (reportez-vous à la section 5.1) défini dans le paquetage java.util. L’interface List fournit un type de données abstrait à la notion de séquence en termes mathématiques. Ainsi, les implémentations de List Java se comportent comme des conteneurs qui supportent un accès indexé à leurs éléments.
8.1 INTERFACE java.util.List L’interface List est définie de la façon suivante dans le paquetage java.util : public interface List extends Collection { public boolean add(Object object); public void add(int index, Object object); public boolean addAll(Collection collection); public boolean addAll(int index, Collection collection); public void clear(); public boolean contains(Object object); public boolean containsAll(Collection collection); public boolean equals(Object object); public Object get(int index); public int hashCode(); public int indexOf(Object object); public boolean isEmpty(); public Iterator iterator(); public int lastIndexOf(Object object); public ListIterator listIterator(); public ListIterator listIterator(int index); public Object remove(int index); public boolean remove(Object object); public boolean removeAll(Collection collection); public boolean retainAll(Collection collection);
150
Listes
public public public public public }
Object int List Object[] Object[]
set(int index, Object object); size(); subList(int start, int stop); toArray(); toArray(Object[] objects);
Vous remarquerez que ce code utilise un type d’itérateur spécial nommé ListIterator ; il s’agit d’un itérateur bidirectionnel.
Exemple 8.1 Utiliser l’interface List import java.util.*; public class Ex0801 { public static void main(String[] args) { String[] strings; strings = new String[]{"Adams","Tyler","Grant","Hayes","Nixon"}; List list = Arrays.asList(strings); System.out.println("list = " + list); System.out.println("list.size() = " + list.size()); System.out.println("list.getClass().getName() = " + list.getClass().getName()); System.out.println("list.get(1) = " + list.get(1)); System.out.println("list.contains(\"Tyler\") = " + list.contains("Tyler")); List sublist = list.subList(2,5); System.out.println("sublist = " + sublist); Object object = list.get(1); System.out.println("sublist.get(1) = " + sublist.get(1)); System.out.println("sublist.contains(\"Tyler\") = " + sublist.contains("Tyler")); list.remove(3); } } list = [Adams, Tyler, Grant, Hayes, Nixon] list.size() = 5 list.getClass().getName() = java.util.Arrays$ArrayList list.get(1) = Tyler list.contains("Tyler") = true sublist = [Grant, Hayes, Nixon] sublist.get(1) = Hayes sublist.contains("Tyler") = false Exception in thread "main" java.lang.UnsupportedOperationException at java.util.AbstractList.remove(AbstractList.java:174) at Testing.main(Testing.java:23)
Ce programme a recours à un tableau String pour construire l’objet List. La méthode getClass() indique que cet objet est une instance de la classe ArrayList. Les méthodes get()et sublist() utilisent les numéros d’index comme arguments. L’appel de list.sublist(2,5) renvoie un objet List qui contient les éléments des numéros 2 à 4 de la liste. L’appel list.remove(3) lance une exception UnsupportedOperationException parce que la classe ArrayList n’implémente pas la méthode remove(). L’exception lancée dans l’exemple 8.1 vous permet de constater que toutes les méthodes spécifiées dans l’interface List ne sont pas systématiquement implémentées. Lorsqu’elles ne le sont pas, elles doivent envoyer une exception UnsupportedOperationException.
151
Implémenter l’interface java.util.List
8.2 IMPLÉMENTER L’INTERFACE java.util.List Dans l’exemple 8.1, vous avez pu découvrir l’une des classes du framework de collections Java 1.2 qui implémente l’interface List : la classe ArrayList. Cette interface peut également être implémentée par les trois classes suivantes : AbstractList, LinkedList et Vector. Object AbstractCollection
Collection
AbstractList
List
AbstractSequentialList LinkedList Iterator
ArrayList Vector
ListIterator
La classe AbstractList joue, pour les listes, un rôle identique à celui de AbstractCollection pour les collections générales : étant une implémentation partielle de l’interface correspondante, elle limite la quantité de code nécessaire aux implémentations personnalisées. En outre, il est impossible de l’instancier directement parce qu’elle est définie comme abstraite. Nous reviendrons sur l’utilisation de cette classe dans la section 8.3. La classe LinkedList offre la structure et la fonctionnalité de toutes les classes de listes chaînées. Nous la détaillerons dans la section 8.6. La classe ArrayList implémente l’interface List à l’aide d’un tableau contigu. Nous l’aborderons plus en détail dans la section 8.5. Quant à la classe Vector, elle est similaire à la classe ArrayList, comme nous l’avons déjà vu dans la section 2.6.
8.3 CLASSES AbstractList ET Abstract-
SequentialList La classe AbstractList fournit l’ossature des implémentations complètes de la classe List qui ont recours à un tableau afin de stocker les éléments d’une liste. De la même façon, la classe AbstractSequentialList fournit l’ossature des implémentations complètes de la classe List qui ont recours à une structure chaînée non contiguë pour stocker les éléments d’une liste. Ces deux procédés de stockage sont fondamentalement opposés. En effet, la structure contiguë d’un tableau fournit un accès aléatoire immédiat, mais nécessite un certain nombre de transferts de données (insertions et suppressions), tandis que la structure chaînée est capable de gérer des insertions et des suppressions immédiates, ce qui signifie qu’elle ne permet pas l’utilisation des index et donc l’accès immédiat (accès aléatoire). Le tableau suivant résume les différences entre ces deux procédés :
Implémentation partielle
Implémentation complète
Structure de données
get(), set()
add(), remove()
AbstractList
ArrayList
tableau
O(1)
O(n)
AbstractSequentialList
LinkedList
liste chaînée
O(n)
O(1)
Si votre application requiert un accès rapide à une séquence statique, il est préférable d’utiliser la classe ArrayList, de l’étendre ou bien d’étendre sa classe parent AbstractList. En revanche, si votre applica-
tion requiert une séquence dynamique, c’est-à-dire avec des insertions et des suppressions fréquentes, pensez à utiliser la classe LinkedList, à l’étendre ou bien à étendre sa classe parent AbstractSequentialList.
152
Listes
Pour information, sachez que la classe LinkedList n’implémente pas la méthode get(int) directement. Vous trouverez ci-après une liste des méthodes de la classe AbstractList définies dans le paquetage java.util : public abstract class implements List { protected public boolean public void public boolean public boolean public void public boolean abstract public public int public int public Iterator public int public ListIterator public ListIterator public Object public void public Object public List }
AbstractList extends AbstractCollection AbstractList() add(Object object); add(int index, Object object); addAll(Collection collection); addAll(int index, Collection collection); clear(); equals(Object object); Object get(int index); hashCode(); indexOf(Object object); iterator(); lastIndexOf(Object object); listIterator(); listIterator(int index); remove(int index); removeRange(int start, int stop); set(int index, Object object); subList(int start, int stop);
La méthode get() étant déclarée comme abstraite, toutes les extensions concrètes de cette classe devront l’implémenter. L’implémentation des méthodes set(), add() et remove() est relativement aisée : { throw new UnsupportedOperationException(); }
Cette implémentation vous permet de comprendre pourquoi ces méthodes sont facultatives. En effet, elles peuvent éventuellement être remplacées par une extension selon l’efficacité de leur structure de données (reportez-vous au tableau de cette section). Une autre alternative est à votre disposition à ce stade : l’extension peut également laisser au moins l’une de ces implémentations. Dans ce cas, l’exception est lancée intentionnellement si une application utilise une méthode autre que celle qui lui était associée. Vous trouverez ci-après une liste des méthodes de la classe AbstractSequentialList définies dans le paquetage java.util : public abstract class AbstractSequentialList extends AbstractList { protected AbstractSequentialList() public void add(int index, Object object); public boolean addAll(Collection c); public boolean addAll(int index, Collection c); public Object get(int index); public Iterator iterator(); public int lastIndexOf(Object object); abstract public ListIterator listIterator(int index); public Object remove(int index); public Object set(int index, Object object); }
Les méthodes listIterator() et size() sont déclarées comme abstraites, c’est pourquoi chaque extension concrète de la classe devra les implémenter. N’oubliez pas que la méthode size() abstraite est héritée de la classe AbstractCollection (reportez-vous à la section 5.3).
Itérateurs de listes
153
8.4 ITÉRATEURS DE LISTES Un itérateur est un objet capable de se déplacer d’un élément à l’autre dans une collection. Il remplace l’indice des tableaux ou des vecteurs. De la même manière que vous utilisez naturellement les indices pour traiter les tableaux, vous utiliserez les itérateurs pour traiter les listes. Java 1.2 définit l’interface Iterator et la sous-interface ListIterator dans le paquetage java.util ; ces deux éléments font donc partie du framework de collections Java. L’interface Iterator spécifie le comportement d’un itérateur unidirectionnel dans une structure de données générale linéaire (reportez-vous à la section 5.5). En revanche, l’extension ListIterator spécifie le comportement d’un itérateur bidirectionnel dans une liste doublement chaînée. L’interface ListIterator, telle qu’elle est définie dans le paquetage java.util, est la suivante : public interface ListIterator extends Iterator { public void add(Object object); public boolean hasNext(); public boolean hasPrevious(); public Object next(); public int nextIndex(); public Object previous(); public int previousIndex(); public void remove(); public void set(Object object); }
Exemple 8.2 Utiliser un itérateur de liste import java.util.*; public class Ex0802 { public static void main(String[] args) { String[] planets = new String[]{"Neptune","Terre","Mars","Pluton"}; List list = Arrays.asList(planets); System.out.println("list = " + list); ListIterator it = list.listIterator(); System.out.println("it.next() = " + it.next()); System.out.println("it.next() = " + it.next()); System.out.println("it.next() = " + it.next()); System.out.println("it.next() = " + it.next()); System.out.println("it.previous() = " + it.previous()); System.out.println("it.previous() = " + it.previous()); it.add("Saturne"); } }
list = [Neptune, Terre, Mars, Pluton] it.next() = Neptune it.next() = Terre it.next() = Mars it.next() = Pluton it.previous() = Pluton it.previous() = Mars Exception in thread "main" java.lang.UnsupportedOperationException at java.util.AbstractList.add(AbstractList.java:153) at java.util.AbstractList$ListItr.add(AbstractList.java:503) at Testing.main(Testing.java:20)
154
Listes
Ce programme illustre le fonctionnement d’un itérateur bidirectionnel. Vous avez également pu constater que la méthode add() n’est pas implémentée pour les objets ListIterator qui itèrent sur les objets ArrayList. C’est pourquoi l’appel it.add("Saturne") déclenche le lancement de l’exception UnsupportedOperationException.
8.5 CLASSE ArrayList La classe ArrayList utilise un tableau pour implémenter la classe AbstractList. Elle est définie de la façon suivante dans le paquetage java.util : public class ArrayList extends AbstractList implements List { public boolean add(Object object) public void add(int index, Object object) public boolean addAll(Collection collection) public boolean addAll(int index, Collection collection) public ArrayList() public ArrayList(int initialCapacity) public ArrayList(Collection collection) public void clear() public Object clone() public boolean contains(Object object) public void ensureCapacity(int capacity) public Object get(int index) public int indexOf(Object object) public boolean isEmpty() public int lastIndexOf(Object object) public Object remove(int index) public Object set(int index, Object object) public int size() public Object[] toArray() public Object[] toArray(Object[] objects) public void trimToSize() }
Les deux méthodes ensureCapacity(int) et trimToSize() gèrent plus spécifiquement la capacité privée d’un objet ArrayList. L’entier obtenu correspond à la taille réelle du tableau qui stocke les éléments de l’objet ArrayList. Sur les 21 méthodes implémentées par cette classe, seules deux, ainsi que les constructeurs, ne constituent pas une personnalisation de la méthode correspondante dans l’une des classes ancêtres AbstractList, AbstractCollection ou Object.
Exemple 8.3 Utiliser ArrayList pour stocker les données classées Le fichier texte suivant nommé Villes.txt liste les neuf villes les plus importantes en 1990, par ordre décroissant de leur nombre d’habitants (Tokyo étant donc la ville la plus peuplée). Le programme qui suit est destiné à lire ces données dans ArrayList, puis à utiliser les méthodes indexOf(), remove() et add() afin de mettre à jour les données pour l’année 1995. import java.io.*; import java.util.*; public class Ex0803 { private static ArrayList list = new ArrayList(); public static void main(String[] args) { load("Villes.txt"); System.out.println(list); list.remove(list.indexOf("Seoul"));
Villes.txt
Tokyo Mexico Sao Paolo Seoul New York Osaka Bombay
Classe LinkedList
155
list.remove(list.indexOf("Osaka")); list.add(5,"Shanghai"); list.add(6,"Los Angeles"); System.out.println(list); } private static void load(String filename) { try { File file = new File(filename); FileReader reader = new FileReader(file); BufferedReader in = new BufferedReader(reader); String ville = in.readLine(); while (ville != null) { list.add(ville); ville = in.readLine(); } } catch(Exception e) { System.out.println(e); } } } [Tokyo, Mexico, Sao Paolo, Seoul, New York, Osaka, Bombay, ➥ Calcutta, Buenos Aires] [Tokyo, Mexico, Sao Paolo, New York, Bombay, Shanghai, Los Angeles, ➥ Calcutta, Buenos Aires]
8.6 CLASSE LinkedList La classe LinkedList utilise une liste chaînée afin d’implémenter la classe AbstractSequentialList. Elle est définie de la façon suivante dans le paquetage java.util : public class LinkedList extends AbstractSequentialList implements List { public boolean add(Object object) public void add(int index, Object object) public boolean addAll(Collection collection) public boolean addAll(int index, Collection collection) public void addFirst(Object object) public void addLast(Object object) public void clear() public Object clone() public boolean contains(Object object) public Object getFirst() public Object getLast() public int indexOf(Object object) public int lastIndexOf(Object object) public LinkedList() public LinkedList(Collection collection) public ListIterator listIterator(int index) public Object remove(int index) public boolean remove(Object object) public Object removeFirst() public Object removeLast() public Object set(int index, Object object) public int size() public Object[] toArray() public Object[] toArray(Object[] objects) }
156
Listes
Parmi les 24 méthodes définies dans la classe LinkedList, seules les neuf qui apparaissent ici en gras ne sont pas des redéfinitions de la méthode correspondante dans l’une des classes ancêtres AbstractSequentialList, AbstractList, AbstractCollection ou Object.
Exemple 8.4 Classe Ring Le programme suivant permet de définir une classe destinée aux listes circulaires doublement liées. Cette classe est similaire à java.util.LinkedList, à l’exception du fait que les méthodes next() et previous() sont capables de passer d’un bout à l’autre de la liste en cercle. package schaums.dswj; import java.util.*; public class Ring extends java.util.AbstractSequentialList { private Node header; private int size = 0; public Ring() { } public Ring(List list) { super(); addAll(list); } public ListIterator listIterator(int index) { return new RingIterator(index); } public int size() { return size; } private static class Node { Object object; Node previous, next; Node(Object object, Node previous, Node next) { this.object = object; this.previous = previous; this.next = next; } Node(Object object) { this.object = object; this.previous = this.next = this; } } private class RingIterator implements ListIterator { private Node next, lastReturned; private int nextIndex; RingIterator(int index) { if (index<0 || index>size) throw new IndexOutOfBoundsException("Index : " + index); next=(size==0?null:header); for (nextIndex=0; nextIndex
Classe LinkedList
public boolean hasNext() { return size > 0; } public boolean hasPrevious() { return size > 0; } public Object next() { if (size==0) throw new NoSuchElementException(); lastReturned = next; next = next.next; nextIndex = (nextIndex==size-1?0:nextIndex+1); return lastReturned.object; } public Object previous() { if (size==0) throw new NoSuchElementException(); next = lastReturned = next.previous; nextIndex = (nextIndex==0?size-1:nextIndex-1); return lastReturned.object; } public int nextIndex() { return nextIndex; } public int previousIndex() { return (nextIndex==0?size-1:nextIndex-1); } public void add(Object object) { if (size==0) { next = header = new Node(object); nextIndex = 0; } else { Node newNode = new Node(object,next.previous,next); newNode.previous.next = next.previous = newNode; } lastReturned = null; ++size; nextIndex = (nextIndex==size-1?0:nextIndex+1); } public void remove() { if (lastReturned==null) throw new IllegalStateException(); if (next==lastReturned) next = lastReturned.next; else nextIndex = (nextIndex==0?size-1:nextIndex-1); lastReturned.previous.next = lastReturned.next; lastReturned.next.previous = lastReturned.previous; lastReturned = null; --size; } public void set(Object object) { if (lastReturned==null) throw new IllegalStateException(); lastReturned.object = object; } }
157
158
Listes
Étant une sous-classe de la classe AbstractSequentialList, cette classe Ring doit obligatoirement implémenter les méthodes listIterator(int) et size(). En outre, puisque la méthode listIterator(int) doit renvoyer un objet ListIterator, notre classe doit également implémenter les neuf méthodes de l’interface (reportez-vous à la section 8.4) grâce à la classe interne RingIterator. La classe Ring définit deux champs privés (header et size), quatre méthodes size 8 publiques et deux classes privées (Node et RingIterator). header Une liste chaînée est une séquence de nœuds chaînés, c’est-à-dire d’objets distincts Ring qui contiennent les données de la liste, à raison d’un composant par nœud. C’est la raison pour laquelle toute implémentation d’une liste chaînée requiert l’utilisation d’une classe Node interne avec un champ destiné aux données et un autre destiné au chaînage. Dans le cas de notre implémentation, le champ de données du nœud est une réféprevious rence Object nommée object. Étant donné que ces listes seront doublement object 'B' chaînées, la classe Node est composée de deux champs de chaînage nommés prenext vious et next. Quelle que soit la structure chaînée utilisée (une liste, un arbre, un Node graphe, etc.), chaque champ doit être une référence au même type que la classe de nœud elle-même. Ainsi, dans cette implémentation, previous et next sont des références à Node. Le champ header de la classe Ring pointe vers le premier nœud de la liste. Vous remarquerez que cette implémentation n’utilise aucun nœud d’en-tête factice. C’est pourquoi le nombre de nœuds est toujours égal au nombre réel de composants de la liste stocké dans le champ size de cette classe. La classe interne RingIterator implémente l’interface java.util.Linnext kedList. Elle est composée de trois champs : next et lastReturned, qui lastReturned sont des références aux nœuds de listes, et l’entier nextIndex, qui assure le nextIndex 5 suivi du numéro d’index du nœud recherché par l’itérateur. Le champ next RingIterator pointe vers le nœud suivant de la liste, soit le prochain à être consulté si l’itérateur est avancé. Le champ lastReturned pointe vers le nœud qui a été renvoyé par l’appel précédent de la méthode next() ou previous(). Il recherche le nœud concerné par un appel de la méthode remove() ou set(). L’interface ListIterator spécifie si les méthodes hasNext() et hasPrevious() devraient renvoyer true lorsque la liste contient plus de composants dans la direction correspondante. En ce qui concerne la classe Ring, qui n’a ni début, ni fin, ces deux méthodes renverront toujours true, sauf si la liste est vide. Les méthodes next() et previous() avancent l’itérateur jusqu’au nœud suivant dans la direction indiquée. L’instruction next = next.next; est utilisée lorsqu’il s’agit d’avancer. Vous la comprendrez mieux si vous pensez qu’une référence à un objet est en fait l’adresse de cet objet dans la mémoire principale. Ainsi, cette instruction affecte l’adresse stockée dans next.next à la variable de référence next. Le diagramme suivant illustre l’exemple de 12 objets et de leur adresse mémoire réelle au cours de l’exécution du pilote test de l’exemple 8.5. 0x15c6d50
size 8 header 0x15c8298
0x15c8290
value 'B' Character
0x15c8540
value 'C' Character
0x15c8530
value 'D' Character
Ring 0x15c8298
previous object next
0x15c84e8 0x15c8820 0x15c8288
Node 0x15c8820
value 'A' Character
0x15c8288
previous object next
0x15c8298 0x15c8290 0x15c8538
Node 0x15c8640
next 0x15c8538 lastReturned 0x15c8288 nextIndex 2 RingIterator
0x15c8538
previous object next
0x15c8288 0x15c8540 0x15c8528
Node
0x15c8528
previous object next
0x15c8538 0x15c8530 0x15c8518
Node
159
Classe LinkedList
Dans le cas présent, l’adresse stockée dans next.next est 0x15c8528, et celle stockée dans next est 0x15c8538. Ainsi, l’affectation next = next.next; a modifié la valeur de la référence next de 0x15c8538 en 0x15c8528. En d’autres termes, le champ next de l’itérateur ne recherche plus le troisième nœud, mais le quatrième. Vous remarquerez que cette représentation des objets est plus simple que la suivante, dans laquelle chaque référence d’objet (adresse mémoire) est symbolisée par une flèche pointant vers le référent : size
8
header
value 'C'
value 'B' Character
Character
value 'D' Character
Ring
previous
previous
previous
previous
object
object
object
object
next
next
next
next
Node
value 'A' Character
Node
Node
Node
next lastReturned nextIndex
2
RingIterator
Prêtez attention à l’utilisation de l’opérateur d’expression conditionnelle pour incrémenter et de décrémenter le champ nextIndex, respectivement dans les méthodes next() et previous(). Par exemple, l’expression nextIndex = (nextIndex==size-1?0:nextIndex+1); attribue la valeur 0 au champ ou bien elle l’incrémente selon la valeur de sa taille. Lorsque celle-ci est égale à size-1, c’est que l’itérateur se trouve au niveau du dernier élément de la liste et qu’il devra passer au premier élément si vous l’avancez, c’est-à-dire à l’élément dont l’index est 0. Dans le cas contraire, le champ sera simplement incrémenté. Les méthodes nextIndex() et previousIndex() renvoient respectivement le dernier élément et l’élément précédent. Ce dernier élément est stocké dans le champ nextIndex de l’itérateur. L’élément précédent doit lui être inférieur de 1, sauf si la valeur de nextIndex est égale à 0, auquel cas l’index précédent doit être égal à size-1. Voilà qui illustre parfaitement le fait qu’une liste circulaire chaînée soit consultée en boucle. La méthode add() insère l’objet donné comme nouveau composant de la liste. Le diagramme suivant illustre le fonctionnement de ce code : Node newNode = new Node(object,next.previous,next); newNode.previous.next = next.previous = newNode;
Les pointeurs qui seront amenés à changer sont signalés en pointillés. Remarquez comment le pointeur lastReturned est invalidé (c’est-à-dire devient null) après l’insertion. Cela vous indique que la dernière opération effectuée par l’itérateur n’était pas un appel de next(), ni de previous(). En outre, vous pouvez en déduire que, ni la méthode add(), ni la méthode remove() ne peuvent être appelées si vous n’avez pas d’abord appelé une nouvelle fois la méthode next() ou previous(). Si vous oubliez cette étape, une exception UnsupportedOperationException sera lancée, comme nous l’avons déjà vu dans l’exemple 8.2.
160
Listes
Avant : previous object 'X' next Node
previous
previous
object 'C'
object 'D' next
next Node
Node
next lastReturned nextIndex
4
RingIterator
Après : previous object 'X' next Node
previous
previous
object 'C'
object 'D'
next Node
next lastReturned nextIndex
4
RingIterator
Exemple 8.5 Tester la classe Ring import java.util.*; import schaums.dswj.Ring; public class Ex0808 { private static final int SIZE=8; private static Ring ring = new Ring(); public static void main(String[] args) { ListIterator it = ring.listIterator(); for (int n=0; n<SIZE; n++) it.add(new Character((char)(’A’+n))); System.out.println(ring); it = ring.listIterator(); // l’initialiser à nouveau printNext(it,10); printPrevious(it,4);
next Node
161
Classe LinkedList
System.out.println(ring); it = ring.listIterator(); // l’initialiser à nouveau printNext(it,2); it.remove(); System.out.println(ring); printNext(it,3); it.remove(); System.out.println(ring); printPrevious(it,2); it.remove(); System.out.println(ring); printPrevious(it,3); it.remove(); System.out.println(ring); } private static void printNext(ListIterator it, int n) { for (int i=0; i
G, H] it.next() = A it.next() = B it.next() = C it.next() = D it.next() = E it.next() = F it.next() = G it.next() = H it.next() = A it.next() = B = 1 it.previous() = 0 it.previous() = 7 it.previous() = 6 it.previous() G, H] it.next() = A it.next() = B H] it.next() = C it.next() = D it.next() = E
= = = =
B A H G
= 2 it.previous() = D = 1 it.previous() = C = 0 it.previous() = A = 4 it.previous() = H = 3 it.previous() = G
Afin de vous faciliter la tâche, ce programme utilise deux méthodes d’impression : d’une part, printNext() appelle un nombre de fois donné nextIndex() et next(), et d’autre part, printPrevious() effectue la même opération pour previousIndex() et previous().
162
Listes
Tout d’abord, ce programme charge l’anneau à l’aide de huit objets Character :
ring
size
7
header Ring
B
H
A
G
C
F
D
next
E
it
lastReturned nextIndex
4
RingIterator
Il utilise ensuite ses méthodes d’impression afin de parcourir l’anneau dans le sens des aiguilles d’une montre et dans le sens inverse, ce qui vous permet de comprendre comment les numéros d’index sont reliés aux nœuds. Après avoir réinitialisé l’itérateur, ce code avance à deux reprises de façon à ce que le champ lastReturned pointe vers le nœud B. Ensuite, l’appel it.remove() supprime le nœud B, comme l’illustre la sortie de la méthode println(ring) suivante. De la même façon, si vous avancez encore trois fois, l’appel it.remove() supprime le nœud E. Vous avez probablement remarqué que, juste avant cet appel de it.remove(), l’appel précédent avait renvoyé l’index 3. Avant la première suppression, le nœud E était associé à l’index 4 mais, à ce stade (c’est-à-dire après la suppression du nœud B), il ne reste plus que trois nœuds le précédant (A, C et D) ; son index est donc désormais égal à 3. L’index d’un élément séquentiel est toujours égal au nombre d’éléments le précédant. L’appel suivant de it.remove() intervient après deux appels de it.previous(). À ce stade, le nœud supprimé (le nœud C) était associé à l’index 1. En dernier lieu, à la suite des trois appels supplémentaires de it.previous(), l’itérateur bidirectionnel parcourt la liste en boucle et c’est le nœud G qui se trouve supprimé.
Exemple 8.6 Problème de Josephus Ce problème est inspiré d’une histoire attribuée à Joseph ben Matthias (Josephus). Ce dernier avait conclu un pacte de suicide avec 40 soldats alors qu’ils étaient assiégés par les forces romaines bien supérieures (en 67 ap. J.-C.). Il avait proposé que chaque homme tue son voisin et s’était arrangé pour être le dernier. C’est ainsi qu’il put raconter cette histoire.
163
Classe LinkedList
Notre classe Ring va nous permettre de simuler une solution à ce problème : import java.util.*; import schaums.dswj.*; public class Ex0806 { public static void main(String[] args) { Ring ring = new Ring(); ListIterator it = ring.listIterator(); int N = get("Entrez le nombre de soldats"); for (int k=0; k 1) { Object killer = it.next(); System.out.println(killer + " tue " + it.next()); it.remove(); } System.out.println("Le seul survivant est " + it.next()); } } Entrez le nombre de soldats : 8 8 soldats : [A, B, C, D, E, F, G, H] A tue B C tue D E tue F G tue H A tue C E tue G A tue E Le seul survivant est A
A H
B
G
C
F
D E
Ce programme utilise la méthode suivante de saisie des entiers : public static int get(String prompt) { int n=0; try { InputStreamReader reader = new InputStreamReader(System.in); BufferedReader in = new BufferedReader(reader); System.out.print(prompt + ": "); String input = in.readLine(); n = Integer.parseInt(input); } catch(Exception e) { System.out.println(e); } return n; }
Il est défini dans la classe schaums.dswj.IO. Notez que la classe Ring ne peut stocker que des objets, au même titre que toutes les structures de données des collections Java. C’est pourquoi nous devons envelopper chaque entier dans un objet Integer afin de le stocker dans l’anneau.
164
Listes
8.7 ITÉRATEURS DE LISTES INDÉPENDANTS Il est possible d’utiliser plusieurs itérateurs sur une même liste chaînée tant qu’ils n’essaient pas de la modifier.
Exemple 8.7 Exemple de simulation de promenades aléatoires import java.util.*; public class Ex0807 { private static final int SIZE=26; private static LinkedList list = new LinkedList(); private static Random random = new Random(); public static void main(String[] args) { initializeList(); ListIterator it1 = list.listIterator(); ListIterator it2 = list.listIterator(); moveForward(it1); moveForward(it2); moveBackward(it1); moveBackward(it2); moveForward(it1); moveForward(it2); } private static void initializeList() { ListIterator it = list.listIterator(); for (int k=0; k<SIZE; k++) it.add(new Character((char)(’A’+k))); } private static void moveForward(ListIterator it) { int n = random.nextInt(SIZE-it.previousIndex()); for (int i=0; i
Ce programme vous permet de simuler deux promenades aléatoires sur une même liste chaînée. Il utilise les méthodes locales moveForward() et moveBackward() pour représenter les promenades. Chaque appel déplace un nombre aléatoire de pas dans la direction donnée. Les méthodes
165
Révision et entraînement
previousIndex() et nextIndex() de ListIterator évitent au programme d’atteindre la fin
de la liste et de la dépasser. Au cours de cette exécution, l’itérateur it1 se déplace d’abord vers l’avant de 18 éléments jusqu’à R. Ensuite, l’itérateur it2 se déplace indépendamment de 5 éléments vers l’avant jusqu’à E. Puis l’itérateur it1 se déplace vers l’arrière de 5 éléments, de R à N, et l’itérateur it2 se déplace indépendamment de 2 éléments vers l’arrière, soit de E à D. Attention, lorsqu’un itérateur inverse son sens de déplacement, il compte à nouveau l’élément courant. En dernier lieu, it1 se déplace vers l’avant de 3 éléments, de N à P, et l’itérateur it2 se déplace indépendamment de 12 éléments vers l’avant, soit de D à O. La figure suivante illustre la position des deux itérateurs après leur deuxième séquence de déplacements : it2
A
?
B
C
D
it1
E
F
G
H
I
J
K
L
M
N
O
P
QUESTIONS DE RÉVISION
QUESTIONS DE RÉVISION
8.1 8.2 8.3 8.4 8.5 8.6 8.7
¿
Qu’est-ce qui caractérise l’interface List par rapport à l’interface Collection ? En quoi les classes AbstractCollection et AbstractList se différencient-elles ? En quoi les classes AbstractList et AbstractSequentialList se différencient-elles ? En quoi les interfaces Iterator et ListIterator se différencient-elles ? En quoi les classes ArrayList et LinkedList se différencient-elles ? En quoi les objets ArrayList et Vector se différencient-ils ? Dans quelles conditions est-il préférable d’utiliser un objet ArrayList plutôt qu’un objet LinkedList, et inversement ?
RÉPONSES
RÉPONSES
8.1
L’interface List comprend les 10 méthodes suivantes qui fonctionnent avec des index : • public • public • public • public • public • public • public • public • public • public
8.2
void boolean Object int int ListIterator ListIterator Object Object List
add(int index, Object object); addAll(int index, Collection collection); get(int index); indexOf(Object object); lastIndexOf(Object object); listIterator(); listIterator(int index); remove(int index); set(int index, Object object); subList(int start, int stop);
La classe AbstractList implémente les méthodes de l’interface List, dont les 10 méthodes d’index listées dans la réponse 8.1 qui ne se trouvent pas dans la classe AbstractCollection.
166
Listes
8.3
8.4
8.5
8.6
8.7
?
La classe AbstractSequentialList est conçue de façon à être utilisée comme classe de base des classes de listes chaînées. Elle spécifie les méthodes abstraites listIterator() et size() qui doivent être implémentées par une sous-classe concrète quelle qu’elle soit. La classe ListIterator étend la classe Iterator sur le même modèle que la classe AbstractSequentialList avec AbstractList (reportez-vous à la réponse 8.2). Les objets Iterator standard sont des itérateurs unidirectionnels qui sont répétés sur des listes de tableaux, alors que les objets ListIterator sont des itérateurs bidirectionnels qui sont répétés sur des listes chaînées. Contrairement aux instances de la classe ArrayList qui utilisent un stockage contigu et indexé dont l’accès est direct (à l’aide des tableaux), les instances de la classe LinkedList utilisent un stockage d’accès chaîné (séquentiel). Pour résumer, les listes de tableaux vous offrent donc un accès plus rapide, tandis que les listes chaînées vous permettent d’effectuer plus rapidement vos modifications (insertions et suppressions) Les objets ArrayList et Vector ne présentent que peu de différences dans la mesure où ils permettent tous les deux un accès direct indexé. La classe ArrayList faisant partie du framework de collections Java, elle a fait son apparition récemment avec Java 1.2, c’est probablement pourquoi elle remporte la faveur des programmeurs. Quant à la classe Vector, elle a beau proposer deux fois plus de méthodes, celles-ci sont souvent redondantes et prêtent par conséquent à confusion. Il est préférable d’utiliser un objet ArrayList si vous prévoyez de nombreuses recherches, et un objet LinkedList si vous prévoyez des insertions et/ou des suppressions fréquentes (voir la réponse 8.6).
EXERCICES D’ENTRAÎNEMENT
EXERCICES D’ENTRAÎNEMENT
8.1
Implémentez la méthode suivante : • public static void loadRandomLetters(LinkedList list, int n) • // remplit la liste de n majuscules générées de façon aléatoire
8.2 8.3 8.4
Écrivez une méthode qui utilise un itérateur pour imprimer le contenu d’une liste chaînée à raison d’un objet par ligne. Écrivez une méthode qui utilise un itérateur pour imprimer le contenu d’une liste chaînée à l’envers, toujours à raison d’un objet par ligne. Écrivez la méthode suivante : • public static void exchange(LinkedList list, int i, int j) • // intervertit les éléments au niveau des index i et j
8.5
8.6
Modifiez la solution au problème de Josephus (reportez-vous à l’exemple 8.6) pour qu’elle utilise également un paramètre skip afin de générer la sortie. La valeur de skip est une constante entière non négative qui spécifie quelle personne doit être tuée par chaque soldat. Par exemple, si skip = 2, A tue D, ce qui signifie qu’il ignore B et C, puis E tue H, etc. La solution initiale correspondait au cas spécial où skip = 0. Modifiez le programme des promenades aléatoires présenté dans l’exemple 8.7 de façon à ce que les itérateurs se déplacent autour de l’objet Ring et n’utilisent plus une liste chaînée (voir l’exemple 8.5). Modifiez les méthodes pour que les déplacements soient effectués en boucle autour de l’anneau.
Révision et entraînement
8.7
¿
167
Modifiez le programme des promenades aléatoires présenté dans l’exemple 8.7 de façon à ce que les itérateurs se déplacent autour de l’objet Ring et n’utilisent plus une liste chaînée (voir l’exercice précédent). Faites également en sorte que la direction change de façon aléatoire.
SOLUTIONS
SOLUTIONS
8.1
• public static void loadRandomLetters( LinkedList list, int n ) • { list.clear(); • while (0 < n--) • list.add("" + (char)(’A’ + (int)(Math.random()*26))); •}
8.2
• public static void printForward(LinkedList list ) • { for (ListIterator itr=list.listIterator(); itr.hasNext(); ) • System.out.println(itr.next()); •}
8.3
• public static void printBackward(LinkedList list) • { ListIterator itr=list.listIterator(list.size()); • while (itr.hasPrevious()) • System.out.println(itr.previous()); •}
8.4
• public static void exchange(LinkedList list, int i, int j) • { Object ithObj = list.get(i); • Object jthObj = list.get(j) ; • list.set(i,jthObj); • list.set(j,ithObj); •}
8.5
La solution générale au problème de Josephus est la suivante : • public class Pr0805 • { public static void main(String[] args) • { Ring ring = new Ring(); • ListIterator it = ring.listIterator(); • int n = get("Entrez le nombre de soldats"); • int skip = get("Entrez le nombre de soldats à ignorer"); • for (int k=0; k 1) • { Object killer = it.next(); • for (int i=0; i<skip; i++) • it.next(); • System.out.println(killer + " tue " + it.next()); • it.remove(); • } • System.out.println("Le seul survivant est " + it.next()); • } •}
8.6
Le programme des promenades aléatoires peut-être modifié de la façon suivante pour que les itérateurs se déplacent autour d’un objet Ring :
168
Listes • import java.util.*; // définit la classe ListIterator • import schaums.dswj.*; // définit la classe Ring • • public class Pr0806 • { private static final int SIZE=10; • private static final int MAX_WALK=20; • private static Ring ring = new Ring(); • private static Random random = new Random(); • • public static void main(String[] args) • { initializeRing(); • ListIterator it1 = ring.listIterator(); • ListIterator it2 = ring.listIterator(); • moveForward(it1); • moveForward(it2); • moveBackward(it1); • moveBackward(it2); • moveForward(it1); • moveForward(it2); • } • • private static void initializeRing() • { ListIterator it = ring.listIterator(); • for (int k=0; k<SIZE; k++) • it.add(new Character((char)(’A’+k))); • } • • private static void moveForward(ListIterator it) • { int n = random.nextInt(MAX_WALK); • for (int i=0; i
8.7
Le programme des promenades aléatoires peut être modifié de la façon suivante pour que les itérateurs se déplacent autour de l’objet Ring dans des directions aléatoires : • import java.util.*; // définit la classe ListIterator • import schaums.dswj.*; // définit la classe Ring • • public class Pr0807 • { private static final int SIZE=10; • private static final int MAX_WALK=20; • private static Ring ring = new Ring(); • private static Random random = new Random(); • • public static void main(String[] args) • { initializeRing(); • ListIterator it1 = ring.listIterator(); • ListIterator it2 = ring.listIterator(); • move(it1); • move(it2);
Révision et entraînement • • • • • • • • • • • • • • • • • • • • • • • •}
move(it1); move(it2); move(it1); move(it2); } private static void initializeRing() { ListIterator it = ring.listIterator(); for (int k=0; k<SIZE; k++) it.add(new Character((char)(’A’+k))); } private static void move(ListIterator it) { int n = random.nextInt(MAX_WALK); boolean forward = (random.nextInt(2)==1); if (forward) for (int i=0; i
169
Chapitre 9
Arbres Un arbre est un conteneur non linéaire qui modélise une relation hiérarchique dans laquelle tous les éléments peuvent avoir plusieurs successeurs (qualifiés d’enfants) et un prédécesseur unique (le parent), sauf dans le cas de la racine qui est le seul élément sans parent. Les arbres sont couramment utilisés en informatique, notamment pour les structures des systèmes de fichiers. Dans le cadre de Java, vous y aurez souvent recours pour les structures d’héritage des classes, la durée d’exécution de l’appel des méthodes lors de l’exécution d’un programme, la classification des types et la définition syntaxique de ce langage de programmation lui-même.
Object AbstractCollection AbstractList AbstractSequentialList LinkedList ArrayList Vector Stack AbstractSet HashSet TreeSet AbstractMap HashMap TreeMap WeakHashMap Arrays BitSet Collections Dictionary Hashtable Properties Type Type primitif
boolean Type numérique Type entier
byte short int long char Type à virgule flottante
float double Type de référence Type Array (tableau) Type Class (classe) Type Interface
172
Arbres
9.1 TERMINOLOGIE DES ARBRES La définition récursive d’un arbre non ordonné est la suivante : ◆ Définition : Un arbre est soit un ensemble vide, soit une paire (r, S), r étant un nœud et S un ensemble d’arbres disjoints ne contenant pas r. Le nœud r est qualifié de racine de l’arbre T et les éléments de l’ensemble S sont ses sous-arbres. Cet ensemble S peut bien évidemment être vide. La restriction selon laquelle aucun sous-arbre ne peut contenir de racine s’applique récursivement, à savoir que r ne peut se trouver dans aucun sous-arbre, ni dans aucun sous-arbre d’un sous-arbre, et ainsi de suite. D’après cette définition, le deuxième composant d’un arbre peut être un ensemble de sous-arbres, ce qui signifie que l’ordre des sous-arbres n’a absolument aucune importance.
Exemple 9.1 Arbres non ordonnés égaux Les deux arbres suivants sont égaux : a
b
a
c
d
=
c
b
d
L’arbre de gauche a une racine a et deux sous-arbres B et C, avec B = (b, ), C = (c, {D}). D est le sous-arbre D = (d, ). L’arbre de droite a la même racine a et le même ensemble de sous-arbres {B, C} = {C, B}, c’est pourquoi (a, {B, C}) = (a, {C, B}). Les éléments qui composent un arbre sont qualifiés de nœuds. Techniquement, chaque nœud est le composant d’un seul sous-arbre, à savoir A l’arbre dont il est la racine. Cependant, un arbre est indirectement coma posé de sous-arbres imbriqués et chaque nœud est considéré comme un élément de tous les arbres dans lesquels il est imbriqué. C’est pourquoi a, B b, c et d sont tous considérés comme des nœuds de l’arbre A. De la même C c b façon, c et d sont tous les deux des nœuds de l’arbre C. La taille d’un arbre correspond au nombre de nœuds qu’il contient. D Ainsi, l’arbre A de la figure précédente a une taille égale à 4 et l’arbre C a d une taille égale à 2. Un arbre dont la taille est égale à 1 est qualifié de singleton, comme c’est le cas des arbres B et D de notre figure. Un arbre vide est un arbre de taille 0 ; il est signalé à l’aide du symbole ∅. Vous remarquerez que ce type d’arbre est unique puisque tous les arbres vides sont égaux. Si T = (x, S) est un arbre, x est la racine de T et S est son ensemble de sous-arbres S = {T1, T2, …, Tn}. Chaque sous-arbre Tj est lui-même un arbre ayant sa propre racine rj. Dans ce cas, le nœud r est qualifié de parent de chaque nœud rj qui est donc considéré comme l’enfant de r. En général, nous disons que deux nœuds sont adjacents si l’un est le parent de l’autre. Un nœud sans enfant est une feuille et un nœud composé d’au moins un enfant est qualifié de nœud interne.
173
Terminologie des arbres
Le chemin est une séquence de nœuds (x0, x1, x2…, xm) dans laquelle les nœuds de chaque paire ayant des indices adjacents (xi – 1, xi) sont des nœuds adjacents. Par exemple, (a, b, c, d) est le chemin de l’arbre que nous venons de voir, mais cela n’est pas le cas de (a, d, b, c). La longueur d’un chemin correspond au nombre m de ses paires adjacentes. Nous pouvons déduire de cette définition que les arbres sont acycliques, c’est-à-dire qu’aucun chemin ne peut contenir le même nœud plusieurs fois. Le chemin joignant la racine pour le nœud x0 d’un arbre est le chemin (x0, x1, x2…, xm) dans lequel xm est la racine de l’arbre. Le chemin joignant la racine à un nœud feuille est qualifié de chemin feuillevers-racine. ✽ Théorème 9.1 : chaque nœud d’un arbre comporte un seul chemin de racine. Pour consulter la démonstration de ce théorème, reportez-vous à l’exercice d’entraînement 9.1. La profondeur d’un nœud correspond à la longueur de son chemin jusqu’à la racine. Il est évident que la profondeur de la racine d’un arbre est, par définition, égale à 0. Nous pouvons également faire référence à la profondeur du sous-arbre d’un arbre en parlant de la profondeur de sa racine. Le niveau d’un arbre est l’ensemble de nœuds se trouvant à une profondeur donnée. La hauteur d’un arbre correspond à la profondeur la plus grande parmi tous les nœuds. Si nous reprenons l’arbre de répertoires Windows présenté en début de chapitre, nous pouvons constater que sa hauteur est égale à 4. Par définition, la hauteur d’un singleton est égale à 0 et celle d’un arbre vide à –1. Un nœud y est considéré comme l’ancêtre d’un nœud x s’il se trouve sur le chemin de x qui rejoint la racine. Vous remarquerez que la racine d’un arbre est donc l’ancêtre de chaque autre nœud du même arbre. De la même manière, un nœud x est considéré comme le descendant d’un autre nœud y si y est un ancêtre de x. Pour chaque nœud y d’un arbre, l’ensemble contenant y et tous ses descendants forment donc le sous-arbre de racine y. Si S est un sous-arbre de T, nous pouvons dire que T est le superarbre de S. La longueur de chemin d’un arbre correspond à la somme de la longueur de tous ses chemins partant de la racine. Il s’agit en fait d’une somme pondérée pour laquelle vous ajoutez chaque niveau multiplié par le nombre de nœuds qu’il contient. La longueur du chemin de l’arbre représenté ci-après est donc la suivante : 1⋅3 + 2⋅4 + 3⋅8 = 35.
Exemple 9.2 Propriétés d’un arbre La racine de l’arbre ci-contre est le nœud a. Les six nœuds a, b, c, e, f et h sont des nœuds internes., les neuf autres nœuds étant des feuilles. Le chemin (m, h, c, a) constitue le chemin feuille-vers-racine et il a une longueur égale à 3. Le nœud b a une profondeur de 1 et le nœud m de 3. Le niveau 2 est composé des nœuds e, f, g et h. La hauteur de cet arbre est égale à 3. Les nœuds a, c et h sont tous des ancêtres du nœud m et le nœud k est un descendant du nœud c, mais pas du nœud b. Le sous-arbre de racine b est composé des nœuds b, e, i et j. Le degré d’un nœud correspond à son nombre d’enfants. Dans l’exemple précédent, b a un degré égal à 1, d a un degré de 0 et h de 5. L’ordre d’un arbre est le degré maximum parmi tous ses nœuds. Un arbre est complet lorsque tous ses nœuds internes ont le même degré et que toutes ses feuilles se trouvent au même niveau. L’arbre suivant est complet. Il a un degré égal à3 et est composé de 40 nœuds au total.
a
b
c
e
i
j
d
f
g
h
k
l
m
n
o
174
Arbres
h+1
– 1- nœuds. ✽ Théorème 9.2 : l’arbre complet d’ordre d et de hauteur h comporte d-------------------d–1 Pour consulter la démonstration de ce théorème, reportez-vous à l’exercice d’entraînement 9.1. ■ Corollaire 9.1 : la hauteur d’un arbre complet d’ordre d et de taille n est h = logd (nd – n + 1) – 1. h+1
– 1- , ■ Corollaire 9.2 : le nombre de nœuds d’un arbre de hauteur h est au maximum égal à d-------------------d–1 d étant le degré maximum parmi tous les nœuds.
9.2 ARBRES DÉCISIONNELS ET DIAGRAMMES DE TRANSITION Un arbre décisionnel résume tous les résultats susceptibles d’être obtenus à la suite d’un traitement en plusieurs étapes. Chaque nœud interne de ce type d’arbre est associé à une question et les branches qui le connectent à ses enfants sont associées aux différentes réponses possibles à cette question.
Exemple 9.1 Retrouver la fausse pièce Supposons que vous deviez retrouver la fausse pièce parmi cinq pièces qui semblent identiques. Cette fausse pièce se distingue des autres uniquement parce qu’elle est plus légère. La seule solution qui s’offre à vous consiste donc à peser un sous-ensemble de pièces et à le comparer à un autre. Reste à savoir comment sélectionner les sous-ensembles de pièces. Dans l’arbre décisionnel ci-après, le symbole ~ signifie que le poids des deux opérandes est comparé. Ainsi, par exemple, {a, b} ~ {d, e} signifie que le poids des pièces a et b est comparé à celui des pièces d et e.
{a}<
{a} ≈ {b} b} {a,
{a, b} ≈ { d, e}
<{
} d, e
{a, b} = {d, e} {a,
b}
>{
{b}
{a}>{ b
a
}
b
c
d, e }
{d}<
{d} ≈ {e}
{e}
{d}>{ e
d
}
e
Un diagramme de transition est un arbre, ou un graphe (voir le chapitre 12), dont les nœuds internes représentent divers états (situations) pouvant être obtenus au cours d’un traitement en plusieurs étapes. Comme dans le cadre d’un arbre décisionnel, chaque feuille correspond à un résultat différent. Chaque branche indique la probabilité (condition) d’occurrence de l’événement enfant, si l’événement parent a eu lieu.
175
Arbres décisionnels et diagrammes de transition
Exemple 9.4 Jeu de craps Le craps se joue avec des dés et deux joueurs, X et Y. Tout d’abord, X lance la paire de dés. Si la somme de ces derniers est égale à 7 ou à 11, X gagne. En revanche, si elle est égale à 2, 3 ou 12, c’est Y qui gagne. Dans tous les autres cas, la somme est qualifiée de « point » qui doit être à nouveau atteint au cours du lancer de dés suivant. Donc, si aucun joueur ne sort gagnant du premier lancer, les dés sont jetés jusqu’à ce que la somme du point sorte à nouveau ou jusqu’à ce qu’un 7 sorte. Dans ce deuxième cas de figure, Y l’emporte. Dans le cas contraire, c’est X qui gagne lorsque la somme du point sort. Le diagramme de transition suivant modélise le fonctionnement du jeu de craps : 7 ou 11, donc X gagne
4 2/9
1/1
2
1/3 2/3
Y 5
2/5 3/5
6
5/11 6/11
X Y
5/36
8
5/11 6/11
X Y
1 /9
1/1 2
X Y
1 /9
5/36
X
9
2/5 3/5
X Y
1/9
10
1/3 2/3
X Y
2, 3, ou 12, donc Y gagne
Lorsque vous lancez une paire de dés, 36 résultats différents peuvent sortir (6 possibilités pour le premier dé multipliées par 6 autres pour le second lancé avec le premier). Parmi ces 36 résultats possibles, un seul lancer peut créer une somme égale à 2 (1 + 1), 2 lancers différents peuvent créer une somme égale à 3 (1 + 2 ou 2 + 1) et un seul lancer peut créer une somme égale à 12 (6 + 6). Il y a donc 4 chances sur 36 d’obtenir un 2, un 3 ou un 12, soit une probabilité de 4/36 = 1/9. De la même façon, il existe 6 possibilités d’obtenir un 7 et 2 possibilités d’obtenir un 11, soit une probabilité de 8/36 = 2/9. Les autres probabilités du premier niveau de l’arbre sont calculées de la même façon. Afin d’illustrer le calcul des probabilités au deuxième niveau de l’arbre, nous allons prendre l’exemple du point 4. Si le lancer suivant est égal à 4, X gagne. S’il est égal à 7, c’est Y qui l’emporte. Dans les autres cas, X doit continuer à lancer les dés. Le diagramme de transition ci-contre résume ces trois possibilités :
4
1/12 1/6
4 7
3/4
176
Arbres
Les probabilités 1/12, 1/6 et 3/4 sont calculées de la façon suivante : P(4) = 3/36 = 1/12 P(7) = 6/36 = 1/3 P(2, 3, 5, 6, 8, 9, 10, 11 ou 12) = 27/36 = 3/4 Une fois que le point 4 a été établi avec le premier lancer, la probabilité que X gagne au deuxième lancer est de 1/12, et la probabilité qu’il doive lancer les dés une troisième fois est de 3/4. La probabilité que X gagne au troisième lancer est donc de (3/4)(1/12) et celle qu’il aille au quatrième lancer de (3/4)(3/4). Toujours sur le même modèle, la probabilité que X gagne au quatrième lancer est de (3/4)(1/12) + (3/4)(3/4)(1/12), etc. Si nous additionnons ces probabilités partielles, nous pouvons conclure que la probabilité de voir X gagner à chaque lancer une fois le point 4 établi avec le premier lancer est la suivante : 3 51 3 41 3 31 3 21 3 1 1 P 4 = ------ + --- ------ + --- ------ + --- ------ + --- ------ + --- ------ + … 12 4 12 4 12 4 12 4 12 4 12 1 -----12
= -----------3 1 – --4 1 / 12 = ----------1 /4
1 = --3
Ce calcul applique la formule des séries géométriques (voir la section A.10 de l’annexe A). Si la probabilité de voir X gagner une fois le point 4 établi au premier lancer est de 1/3, la probabilité de voir Y l’emporter dans ce cas doit être de 2/3. Les autres probabilités du deuxième niveau sont calculées de la même façon. Nous pouvons maintenant calculer la probabilité de voir X gagner à partir du diagramme de transition principal : 1 5 5 1 1 2 1 P = --- + ------ ( P 4 ) + --- ( P 5 ) + ------ ( P 6 ) + ------ ( P 8 ) + --- ( P 9 ) + ------ ( P 10 ) 9 36 36 9 12 9 12 1 2 5 5 5 5 1 2 1 1 2 1 1 = --- + ------ --- + --- --- + ------ ------ + ------ ------ + --- --- + ------ --- 3 12 3 5 11 11 5 9 36 36 9 12 9 244 = --------495 = 0.4929
La probabilité de voir X gagner est donc de 49,29 % et celle de voir Y l’emporter de 50,71 %. La méthode stochastique peut donc être analysée par un diagramme de transition, c’est-à-dire qu’elle peut être décomposée en séquences d’événements dont les probabilités conditionnelles peuvent être calculées. Le jeu de craps illustre parfaitement le traitement stochastique puisque le nombre d’événements susceptibles de se produire est illimité. Comme dans l’analyse de l’exemple 9.2, la plupart des
177
Arbres ordonnés
traitements stochastiques peuvent être reformulés en un traitement fini équivalent applicable aux ordinateurs. Remarquez que, contrairement aux autres modèles d’arbres, les arbres de transition et décisionnels sont généralement dessinés de la gauche vers la droite de façon à suggérer le mouvement dépendant du temps d’un nœud à l’autre.
9.3 ARBRES ORDONNÉS La définition récursive d’un arbre ordonné est la suivante : Un arbre ordonné est soit vide, soit constitué d’une paire T(x, S), le premier composant x étant un nœud et le second composant S une séquence d’arbres disjoints ordonnés qui ne contiennent pas x. Le nœud r est qualifié de racine de l’arbre T et les éléments de l’ensemble S sont ses sous-arbres. Cet ensemble S peut bien évidemment être vide. La restriction selon laquelle aucun sous-arbre ne peut contenir de racine s’applique récursivement, à savoir que r ne peut se trouver dans aucun sous-arbre, ni dans aucun sous-arbre d’un sous-arbre, et ainsi de suite. Comme vous pouvez le constater, cette définition est identique à celle des arbres non ordonnés, à une exception près : les sous-arbres des arbres non ordonnés sont contenus dans une séquence et non dans un ensemble. Ainsi, si deux arbres sont composés des mêmes sous-ensembles, ils sont considérés comme étant égaux. En revanche, dans le cas des arbres ordonnés, ils ne peuvent être égaux que si leurs sousarbres égaux sont dans le même ordre
Exemple 9.5 Arbres ordonnés inégaux Les deux arbres ordonnés suivants ne sont pas égaux : a
b
a
c
d
≠
c
b
d
En effet, l’arbre de gauche a une racine a et deux sous-arbres B et C, avec B = (b, ∅) et C = (c, (D)). En outre, D est le sous-arbre D = (d, ∅). Quant à l’arbre de droite, il a la même racine a et les mêmes sous-arbres. Cependant, ces sous-arbres ne sont pas dans le même ordre, c’est pourquoi les séquences ne sont pas égales : (B, C) ≠ (C, B). Si vous respectez la définition au pied de la lettre, vous prendrez conscience d’une subtilité à laquelle peu de programmeurs prêtent attention, comme illustré dans l’exemple suivant :
Exemple 9.6 Arbres ordonnés inégaux, deuxième exemple Les deux arbres T1 = (a, (B, C)) et T2 = (a, (B, ∅, C)) sont des arbres différents, même s’ils seraient probablement dessinés tous les deux de la façon suivante : En fait, techniquement parlant, T1 est un arbre dont l’ordre est égal à 2, tandis que T2 a un ordre égal à 3.
a
b
c
La terminologie des arbres non ordonnés s’applique également aux arbres ordonnés. Deux termes supplémentaires vous seront cependant utiles : le premier enfant et le dernier enfant d’un nœud dans un arbre ordonné. Afin de vous faciliter la tâche, vous pouvez considérer que vous avez affaire à un arbre généalogique dans lequel les enfants sont classés par âge, du plus vieux au plus jeune.
178
Arbres
9.4 ALGORITHMES DE PARCOURS DES ARBRES ORDONNÉS Un algorithme de parcours est une méthode de traitement d’une structure de données qui applique une opération spécifique à chaque élément de cette structure. Par exemple, si l’opération consiste à imprimer le contenu de l’élément, le parcours imprime chaque élément de la structure. Le fait d’appliquer l’opération à un élément est qualifié de visite de cet élément. Lors de l’exécution de l’algorithme de parcours, chaque élément de la structure est donc visité dans un ordre qui dépend de l’algorithme sélectionné. Les algorithmes de parcours d’un arbre général les plus courants sont au nombre de trois. L’algorithme de parcours en largeur visite la racine, puis chaque élément du premier niveau, avant de visiter chaque élément du deuxième niveau, etc., en visitant systématiquement chaque élément d’un niveau avant de passer au niveau suivant. En fait, si l’arbre est composé de façon habituelle avec sa racine en haut et les feuilles près du bas, le parcours en largeur s’effectue de gauche à droite, puis de haut en bas, comme si vous lisiez un texte en français.
Exemple 9.7 Parcours en largeur Le parcours en largeur d’un arbre visite les nœuds dans l’ordre a, b, c, d, e, f, g, h, i, j, k, l, m, comme illustré dans la figure suivante : a
b
e
h
c
d
f
i
g
j
k
l
m
Algorithme 9.1 Parcours en largeur d’un arbre ordonné Pour parcourir un arbre ordonné non vide : 1. 2. 3. 4. 5. 6.
Initialisez une file d’attente. Mettez la racine en attente. Répétez les étapes 4 à 7 jusqu’à ce que la file d’attente soit vide. Retirez le nœud x de la queue. Visitez x. Mettez tous les enfants de x en attente dans l’ordre.
L’algorithme de parcours préfixe visite d’abord la racine, puis parcourt de façon préfixe chaque sous-arbre récursivement.
Exemple 9.8 Parcours préfixe Le parcours préfixe de l’arbre présenté dans l’exemple 9.8 visiterait les nœuds dans l’ordre suivant : a, b, e, h, i, f, c, d, g, j, k, l, m.
179
Algorithmes de parcours des arbres ordonnés
a
b
e
h
c
d
f
i
g
j
k
l
m
Vous remarquerez que le parcours préfixe d’un arbre peut être obtenu en effectuant des cercles, c’està-dire en commençant par la racine, puis en visitant chaque nœud la première fois que vous le rencontrez sur votre gauche.
Algorithme 9.2 Parcours préfixe d’un arbre ordonné Pour parcourir un arbre ordonné non vide : 1. Visitez la racine. 2. Effectuez un parcours préfixe de chaque sous-arbre dans l’ordre. L’algorithme de parcours postfixe parcourt de façon postfixe chaque arbre récursivement, puis il visite la racine.
Exemple 9.9 Parcours postfixe Le parcours postfixe de l’arbre présenté dans l’exemple 9.8 visiterait les nœuds dans l’ordre suivant : h, i, e, f, b, c, j, k, l, m, g, d, a.
Algorithme 9.3 Parcours postfixe d’un arbre ordonné Pour parcourir un arbre ordonné non vide : 1. Effectuez un parcours préfixe récursif de chaque sous-arbre dans l’ordre. 2. Visitez la racine. Remarquez que le parcours en largeur et le parcours préfixe visitent systématiquement la racine de chaque sous-arbre avant de visiter les autres nœuds. En revanche, le parcours postfixe visite toujours la racine de chaque sous-arbre après avoir visité tous ses autres nœuds. En outre, le parcours préfixe visite toujours le nœud le plus à droite en dernier tandis que le parcours postfixe visite toujours le nœud le plus à gauche en premier. Les parcours préfixe et postfixe sont récursifs et peuvent également être implémentés itérativement à l’aide d’une pile. Le parcours en largeur est implémenté itérativement à l’aide d’une file.
a
b
e
h
c
d
f
i
g
j
k
l
m
180
Arbres
?
QUESTIONS DE RÉVISION
QUESTIONS DE RÉVISION
9.1
Dans le cadre de Java, toutes les classes forment un seul arbre communément appelé arbre d’héritage Java. Mettez en pratique les notions que nous venons d’étudier et répondez aux questions suivantes concernant cet arbre : a. Quelle est la taille de l’arbre d’héritage Java dans Java 1.3 ? b. Quelle est la racine de cet arbre ? c. Quel type de nœud représente la classe final de cet arbre ?
9.2
Indiquez si les affirmations suivantes sont vraies ou fausses : a. La profondeur d’un nœud est égale au nombre de ses ancêtres. b. La taille d’un sous-arbre est égale au nombre de descendants de la racine du sous-arbre. c. Si x est un descendant de y, la profondeur de x est plus importante que celle de y. d. Si la profondeur de x est plus importante que celle de y, cela signifie que x est un descendant de y. e. Un arbre est un singleton si et seulement si sa racine est une feuille. f. Chaque feuille d’un sous-arbre est également la feuille de son superarbre. g. La racine d’un sous-arbre est également la racine de son superarbre. h. Le nombre d’ancêtres d’un nœud est égal à sa profondeur. i. Si R est un sous-arbre de S et que S est un sous-arbre de T, alors R est un sous-arbre de T. j. Un nœud est une feuille si et seulement si son degré est égal à 0. k. Dans tous les arbres, le nombre de nœuds internes doit être inférieur à celui des nœuds feuilles. l. Un arbre est complet si et seulement si toutes ses feuilles se trouvent au même niveau. m.Chaque sous-arbre d’un arbre binaire complet est complet. n. Chaque sous-arbre d’un arbre binaire parfait est parfait. Reportez-vous à la section 10.5 pour plus d’informations sur ce sujet.
9.3
Pour chacun des cinq arbres suivants, indiquez quels sont les nœuds des feuilles, les enfants du nœud C, la profondeur du nœud F, tous les nœuds du niveau 3, la hauteur de l’arbre et son ordre.
9.4
Pour l’arbre ci-contre, retrouvez les éléments suivants : a. tous les ancêtres du nœud F ; b. tous les descendants du nœud F ; c. tous les nœuds du sous-arbre dont la racine est F ; d. tous les nœuds feuilles.
9.5
9.6
Combien de nœuds y a-t-il dans un arbre complet avec : a. un ordre égal à 3 et une hauteur égale à 4 ? b. un ordre égal à 4 et une hauteur égale à 3 ? c. un ordre égal à 10 et une hauteur égale à 4 ? d. un ordre égal à 4 et une hauteur égale à 10 ? Donnez l’ordre de visite de l’arbre présenté dans l’exemple 9.2 si vous effectuez un parcours : a. en largeur ; b. préfixe ; c. postfixe.
A
C
B
D
E
F
G
H
I
J
K
L
181
Révision et entraînement
9.7
Quel parcours visite toujours : a. b. c. d.
la racine en premier ? le nœud le plus à gauche en premier ? la racine en dernier ? le nœud le plus à droite en dernier ? a.
b.
A
c.
A
A
B
C
D
E
B
C
D
E
B
C
D
F
G
H
J
K
F
G
H
J
E
F
G
L
M
N
O
P
L
M
N
O
H
J
K
P
Q
R
S
L
M
N
O
P
Q
F
R
S
J
T
U
W
X
d.
Q
A
B
e.
C
D
E
A
B
C
D
E
F
G
H
J
K
L
M
N
O
K
L
M
N
O
V
P
Q
R
S
T
P
Q
R
S
T
Y
G
H
Z
9.8
Quel ordre de parcours se comporte comme s’il s’agissait de lire un texte de gauche à droite, ligne par ligne ? Quel algorithme de parcours lit les colonnes verticalement de gauche à droite ?
9.9
Quel algorithme de parcours est utilisé dans l’arbre d’appel de l’exercice 4.31 ?
¿
RÉPONSES
RÉPONSES
9.1
Dans le cadre de l’arbre d’héritage Java : a. La taille de l’arbre de Java 1.3 est égale à 1 730. b. La classe Objet se trouve à la racine de l’arbre. c. La classe final est un nœud feuille.
182
Arbres
9.2
a. Vraie. b. Fausse. La taille contient un élément en plus parce que la racine du sous-arbre se trouve dans le sous-arbre, mais n’est pas un descendant d’elle-même. c. Vraie. d. Fausse. e. Vraie. f. Vraie. g. Fausse. h. Vraie. i. Vraie. j. Vraie. k. Fausse. l. Fausse. m.Vraie. n. Vraie.
9.3
a. Les nœuds feuilles sont L, M, N, H, O, P, Q, ; les enfants du nœud C sont G et H ; le nœud F a une profondeur de 2 ; les nœuds du niveau 3 sont L, M, N, O, P et Q ; la hauteur de l’arbre est de 3 ; l’ordre de l’arbre est égal à 4. b. Les nœuds feuilles sont C, E, G, O, P, Q, R et S ; le nœud C n’a aucun enfant ; le nœud F a une profondeur de 2 ; les nœuds du niveau 3 sont L, M, N et O ; la hauteur de l’arbre est de 4 ; l’ordre de l’arbre est égal à 4. c. Les nœuds feuilles sont C, E, G, J, L, N, O, P, W, Y et Z ; le nœud C n’a aucun enfant ; le nœud F a une profondeur de 2 ; les nœuds du niveau 3 sont H, J et K ; la hauteur de l’arbre est de 9 ; l’ordre de l’arbre est égal à 3. d. Les nœuds feuilles sont G, H, K, L, N, O, P, Q, R, S et T ; le seul nœud enfant de C est E ; le nœud F a une profondeur de 3 ; les nœuds du niveau 3 sont F, G, H et J ; la hauteur de l’arbre est de 5 ; l’ordre de l’arbre est égal à 5. e. Les nœuds feuilles sont D, E, L, N, P, Q, R, S et T ; le nœud C n’a aucun enfant ; le nœud F a une profondeur de 1 ; les nœuds du niveau 3 sont K, L, M, N et O ; la hauteur de l’arbre est de 4 ; l’ordre de l’arbre est égal à 5.
9.4
a. b. c. d.
Les ancêtres de F sont C et A. Les descendants de F sont I, K et L. Les nœuds du sous-arbre dont la racine est F sont F, I, K et L. Les nœuds feuilles sont D, H, J, K et L.
9.5
a. b. c. d.
(35 – 1)/2 = 121 nœuds (44 – 1)/3 = 85 nœuds (105 – 1)/9 = 11 111 nœuds (411 – 1)/3 = 1 398 101 nœuds
9.6
a. Parcours en largeur : A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P. b. Parcours préfixe : A, B, E, I, J, C, F, G, H, K, L, M, N, O, P, D. c. Parcours postfixe : I, J, E, B, F, G, K, L, M, N, O, P, H, C, D, A.
9.7
a. b. c. d.
Les parcours en largeur et préfixe visitent toujours la racine en premier. Le parcours postfixe visite toujours le nœud le plus à gauche en premier. Le parcours postfixe visite toujours la racine en dernier. Le parcours préfixe visite toujours le nœud le plus à droite en dernier.
Révision et entraînement
183
9.8
Le parcours en largeur traverse l’arbre de gauche à droite et de haut en bas. Le parcours infixe lit chaque colonne de gauche à droite (pour obtenir plus d’informations sur ce parcours, consultez l’exemple 10.6).
9.9
Le parcours infixe est utilisé dans l’exercice d’entraînement 4.31.
?
EXERCICES D’ENTRAÎNEMENT
EXERCICES D’ENTRAÎNEMENT
9.1
Démontrez le théorème 9.1.
9.2
Démontrez le théorème 9.2.
9.3
Démontrez le corollaire 9.1.
9.4
Démontrez le corollaire 9.2.
9.5
Déduisez la formule permettant de calculer la longueur du chemin d’un arbre complet d’ordre d et de hauteur h.
9.6
Supposons que deux joueurs de craps considèrent 3 comme valeur possible d’un point. Dans cette version, le joueur Y gagne si son premier lancer fait sortir 2 ou 12. Utilisez un diagramme de transition pour analyser cette version du jeu et calculez la probabilité de voir X l’emporter.
9.7
Le paradoxe de St-Pétersbourg est une stratégie de pari qui semble garantir la victoire. Il peut être appliqué à tous les jeux binomiaux pour lesquels vous avez autant de chances de gagner que de perdre à chaque tentative, et pour lesquels le montant parié peut varier. Prenons l’exemple du jeu « pile ou face » : supposons qu’un parieur parie la somme qu’il veut et qu’il gagne la somme pariée si c’est face qui sort ou qu’il perde cette somme si c’est pile qui sort. La stratégie de StPétersbourg consiste à continuer à jouer jusqu’à ce que face sorte et à doubler le pari chaque fois que ce côté ne sort pas. Ainsi, si la séquence de lancer de la pièce est {P, P, P, F} et que le pari se monte à 1 euro, que le parieur perd, qu’il parie encore 2 euros, qu’il perd, puis 4 euros et qu’il perd, et enfin 8 euros et qu’il gagne, il aura gagné –1 + –2 + –4 + 8 = 1 euro. Étant donné que face doit sortir à un moment ou à un autre, le parieur gagnera nécessairement 1 euro, quel que soit le nombre de fois où il devra lancer la pièce. Dessinez le diagramme de transition de cette stratégie en indiquant les gains du parieur à chaque étape du jeu. Expliquez ensuite le défaut de cette stratégie.
9.8
¿
Vous devez retrouver la fausse pièce parmi un ensemble de sept pièces apparemment identiques. La seule caractéristique qui les différencie est la légèreté de la fausse pièce par rapport aux autres. La seule solution consiste donc à tester le poids d’un sous-ensemble de pièces par rapport à un autre. Comment allez-vous sélectionner ces sous-ensembles (voir l’exemple 9.3) ?
SOLUTIONS
SOLUTIONS
9.1
S’il n’existe aucun chemin reliant le nœud donné x à la racine de l’arbre, la définition est fausse puisque x doit être la racine d’un sous-arbre pour constituer un élément de l’arbre. S’il existe plus d’un chemin de x à la racine, x est un élément de plusieurs sous-arbres distincts, ce qui va également à l’encontre de la définition de l’arbre selon laquelle les sous-arbres doivent être disjoints.
184 9.2
Arbres
Si l’arbre est vide, sa hauteur est h = –1 et son nombre de nœuds n = 0. Dans ce cas, la formule est correcte : n = (d(h) + 1 – 1)/(d – 1) = (d(–1) + 1 – 1)/(d – 1) = (d0 – 1)/(d – 1) = (1 – 1)/(d – 1) = 0 Si l’arbre est un singleton, sa hauteur est h = 0 et son nombre de nœuds n = 1. Dans ce cas, la formule suivante est toujours correcte : n = (d(h) + 1 – 1)/(d – 1) = (d(0) + 1 – 1)/(d – 1) = (d – 1)/(d – 1) = 1 Supposons maintenant que cette formule soit correcte pour tout arbre complet de hauteur h–1, avec h ≥ 0. Supposons que T soit un arbre complet de hauteur h. Par définition, il est composé d’un nœud racine et d’un ensemble de sous-arbres. En outre, étant donné que T est complet, il est en fait composé d’une nœud racine et d’un ensemble de d sous-arbres ayant chacun une hauteur h–1. Ainsi, par hypothèse inductive, nous pouvons déduire que le nombre de nœuds de chaque sous-arbre est : ns = (d(h – 1) + 1 – 1)/(d – 1) = (dh – 1)/(d – 1) Le nombre total de nœuds dans T est donc égal à : n = 1 + ( d )( nS ) dh – 1 = 1 + d -------------- d –1 d – 1 dh + 1 – d = ------------ + --------------------d–1 d–1 dh + 1 – 1 d–1
= -----------------------
D’après le principe d’induction mathématique (reportez-vous à la section A.4), nous pouvons en déduire que la formule doit être correcte pour tous les arbres complets quelle que soit leur hauteur. 9.3
Cette démonstration est purement algébrique : dh + 1 – 1 n = --------------------d–1 n(d – 1 ) = d h + 1 – 1 d h + 1 = n( d – 1) + 1 = nd – n + 1 h + 1 = log d ( nd – n + 1 ) h = log d ( nd – n + 1 ) – 1
9.4
Supposons que T soit un arbre d’ordre d et de hauteur h. T peut être intégré dans l’arbre complet h+1 – 1 de mêmes degré et hauteur. Cet arbre complet est composé très exactement de d-------------------- nœuds, d–1 c’est pourquoi son sous-arbre T est composé au plus de ce même nombre de nœuds.
9.5
La longueur du chemin d’un arbre complet de degré d et de hauteur h est calculée de la façon suivante : d -----------------[ h dh + 1 – ( h + 1 )d + 1 ] ( d – 1)2
185
Révision et entraînement
Par exemple, la longueur de chemin de l’arbre complet situé dans la section 9.2 est égale à 102. 9.6
La version du craps avec 3 comme point est la suivante : 7 ou 11, donc X gagne X
1/4 3/4
3
Y 18 1/ 8 1/1
1/1
X
1/3 2/3
4
Y X
2/5 3/5
2
5
Y
1 /9
5/11 6/11
6
5/36
X Y
5/36
5/11 6/11
8
X
1 /9
Y
1/1 2
X
2/5 3/5
9
Y
1/9
X
1/3 2/3
10
Y 2 ou 12, donc Y gagne
La probabilité de voir X gagner dans le cadre de cette version est de 0,5068 ou de 50,68 %. 9.7
L’analyse du diagramme arborescent du paradoxe de St-Pétersbourg est illustrée ci-après. Le défaut de cette stratégie est qu’il existe une possibilité distincte (c’est-à-dire une probabilité positive) que pile sorte suffisamment de fois à la suite pour que le montant parié dépasse l’enjeu du parieur. Après n piles successifs, le parieur doit miser 2n euros. Par exemple, si pile sort 20 fois de suite, le pari suivant doit être d’un million d’euros !
F Pari 1 €
Gain 1 € +1€
P
F
–1€
Pari 2 €
Gain 1 € –1€ + 2€ = +1€
P –1€ – 2€ = –3€
Pari 4 €
F
Gain 1 € –3 + 4€ = +1€
P –3€ – 4€ = –7€
Pari 8 €
F
Gain 1 € –7€ + 8€ = +1€
P –7€ – 8€ = –15€
186 9.8
Arbres
L’arbre décisionnel suivant illustre tous les résultats possibles après exécution de l’algorithme permettant de résoudre le problème des 7 pièces : X
X = {a} Y = {c} X<
X = {a, b, c} Y = {e, f, g}
b
X>Y
Y
X=Y X>
X=Y
a
c
d
Y X
X = {e} Y = {g}
X=Y
e f
X>Y
g
Chapitre 10
Arbres binaires 10.1 TERMINOLOGIE La définition récursive d’un arbre binaire est la suivante : ◆ Définition : Un arbre binaire est soit un ensemble vide, soit un triplet T = (x, L, R), x étant un nœud et L et R étant des arbres binaires disjoints ne contenant pas x. Le nœud x est la racine de l’arbre T, et les sous-arbres a L et R sont respectivement le sous-arbre de gauche et le sous-arbre de droite de T. Si vous comparez cette définib c tion à celle des arbres ordonnés présentée dans la section 9.3, vous constaterez rapidement qu’un arbre binaire est simplement un arbre ordonné dont l’ordre est égal à 2. d Attention cependant, un sous-arbre gauche vide est différent d’un sous-arbre droit vide (reportez-vous à l’exemple 9.6). C’est pourquoi les deux arbres binaires de la figure ci-dessus ne sont pas identiques. L’équivalent non récursif de notre première définition est le suivant :
a
≠
b
c
d
◆ Définition : Un arbre binaire est un arbre ordonné au sein duquel chaque nœud interne a un degré égal à 2. Dans cette définition plus simple, les nœuds feuilles sont considérés comme des nœuds factices dont l’unique but est de définir la structure de l’arbre. Dans le cadre d’applications réelles, les nœuds internes contiennent des données alors que les nœuds feuilles sont soit des nœuds vides identiques, soit un nœud vide seul, soit une simple référence null. Cette définition vous semblera peut-être inefficace et plus complexe de prime abord, mais elle est en fait beaucoup plus facile à implémenter.
a
b
a
c
d
b
= *
c
d
* *
*
*
188
Arbres binaires
Sauf indication contraire, nous nous référerons dans ce livre à la première définition des arbres binaires. C’est pourquoi certains nœuds internes pourront n’avoir qu’un seul enfant, à gauche ou bien à droite. La définition des termes taille, chemin, longueur de chemin, profondeur de nœud, niveau, hauteur, nœud interne, ancêtre, descendant, sous-arbre et superarbre est identique qu’il s’agisse d’un arbre binaire ou d’un arbre général. Vous pouvez donc vous reporter au chapitre précédent si vous souhaitez obtenir plus d’informations sur ces notions.
Exemple 10.1 Caractéristiques d’un arbre binaire
a
L’arbre binaire suivant a une taille égale à 10 et une hauteur égale à 3. Le nœud a est sa racine et le chemin qui connecte le nœud h au nœud b a une longueur égale à 2. Le nœud b se trouve au niveau 1, le nœud h au niveau 3. b est un ancêtre de h et h est un descendant de b. La partie en grisé constitue le sous-arbre de taille 6 et de hauteur 2. Sa racine est le nœud b.
b
c
d h
e i
j
f m
10.2 COMPTER LES ARBRES BINAIRES Exemple 10.2 Tous les arbres binaires de taille 3 Il existe 5 types différents d’arbres binaires de taille n = 3 :
Quatre d’entre eux ont une hauteur égale à 2 et le cinquième a une hauteur égale à 1.
Exemple 10.3 Tous les arbres binaires de taille 4 Il existe 14 types différents d’arbres binaires de taille n = 4 :
Dix d’entre eux ont une hauteur égale à 3 et les quatre restants ont une hauteur égale à 2.
Exemple 10.4 Tous les arbres binaires de taille 5 Pour trouver tous les arbres binaires de taille 5, il vous suffit d’appliquer leur définition. Si t est un arbre binaire de taille 5, il doit être composé d’un nœud racine et de deux sous-arbres dont la somme des tailles est égale à 4. Il existe 4 possibilités : le sous-arbre de gauche contient 4, 3, 2, 1 ou 0 nœuds. Comptez d’abord tous les arbres binaires de taille 5 dont le sous-arbre de gauche est de taille 4. Comme nous l’avons déjà vu dans l’exemple 10.4, il existe 14 possibilités différentes pour ce sousarbre gauche. Cependant, pour chacun de ces 14 choix, il n’y a aucune autre option étant donné que le sous-arbre droit doit être vide. Nous dénombrons donc 14 arbres binaires différents de taille 5 dont le sous-arbre gauche est de taille 4. Nous comptons ensuite tous les arbres binaires de taille 5 dont le sous-arbre gauche est de taille 3. Comme nous l’avons déjà vu dans l’exemple 10.3, il existe 5 possibilités différentes pour ce sous-arbre.
189
Arbres binaires complets
Cependant, pour chacun de ces 5 choix, il n’y a aucune autre option étant donné que le sous-arbre droit doit être un singleton. Nous dénombrons donc 5 arbres binaires différents de taille 5 dont le sous-arbre gauche est de taille 3. Puis, nous comptons tous les arbres binaires de taille 5 dont le sous-arbre gauche est de taille 2. Il n’existe que deux possibilités différentes pour ce sous-arbre. Cependant, pour chacun de ces deux choix, nous avons 2 fois 2 possibilités différentes pour le sous-arbre droit étant donné qu’il doit également être de taille 2. Nous obtenons donc 2 × 2 = 4 arbres binaires différents de taille 5 dont le sous-arbre gauche est de taille 2. En suivant le même raisonnement, nous pouvons déduire qu’il existe 5 arbres binaires différents de taille 5 dont le sous-arbre gauche est de taille 1, et qu’il existe 14 arbres binaires différents de taille 5 dont le sous-arbre gauche est de taille 0. Le nombre total d’arbres binaires de taille 5 est donc de : 14 + 5 + 4 + 5 + 14 = 42.
10.3 ARBRES BINAIRES COMPLETS Un arbre binaire est considéré comme complet si toutes ses feuilles sont au même niveau et que chacun de ses nœuds internes a deux enfants.
Exemple 10.5 Arbre binaire complet de hauteur 3 L’arbre illustré ci-contre est un arbre binaire complet de hauteur 3. Remarquez qu’il est composé de 15 nœuds, dont 7 nœuds internes et 8 feuilles.
a
b
c
d e f g ✽ Théorème 10.1 : l’arbre binaire complet de hauteur h a l = h h 2 feuilles et m = 2 – 1 nœuds internes. o h i j k l m n Démonstration : l’arbre binaire complet de hauteur h = 0 est un nœud feuille seul ; il a donc n = 1 nœud, qui est une feuille. Par conséquent, étant donné que 2h + 1 – 1 = 20 + 1 – 1 = 21 – 1 = 2 – 1 = 1,2h – 1 = 20 – 1 = 1 – 1 = 0 et 2h = 20 = 1, les formules sont correctes lorsque h = 0. De façon plus générale, supposons que h > 0 et que, hypothèse inductive, les formules soient vraies pour tous les arbres binaires complets de hauteur inférieure à h. Prenons ensuite un arbre binaire complet de hauteur h. Chaque sous-arbre a une hauteur h – 1 et nous lui appliquons les formules suivantes : lL = lR = 2h – 1 et mL = mR = 2h – 1 – 1. Il s’agit respectivement du nombre de feuilles du sous-arbre gauche, puis de celui du sous-arbre droit, du nombre de nœuds internes dans le sous-arbre gauche, puis dans le sous-arbre droit. Ensuite,
l = lL + lR = 2h – 1 + 2h – 1 = 2. 2h – 1 = 2h et m = mL + mR + 1 = (2h – 1 – 1) + (2h – 1 – 1) + 1 = 2 · 2h – 1 – 1 = 2h – 1. Par conséquent, d’après le deuxième principe d’induction mathématique, les formules doivent être vraies pour les arbres binaires complets de hauteur h ≥ 0. CQFD. En regroupant simplement les formules de m et l, nous obtenons le premier corollaire. ■ Corollaire 10.1 : l’arbre binaire complet de hauteur h comprend un total de n = 2h + 1 – 1 nœuds. En résolvant la formule n = 2h + 1 – 1 pour h, nous obtenons le corollaire suivant. ■ Corollaire 10.2 : l’arbre binaire complet composé de n nœuds a une hauteur h = lg(n + 1) – 1.
190
Arbres binaires
Notez que la formule de ce corollaire est correcte même lorsque n = 0. En effet, l’arbre binaire vide a une hauteur h = lg(n + 1) – 1 = lg(0 + 1) – 1 = lg(1) – 1 = 0 – 1 = -1. Le corollaire suivant applique le corollaire 10.1 et le fait qu’un arbre binaire complet de hauteur h ait plus de nœuds que n’importe quel autre arbre binaire de hauteur h. ■ Corollaire 10.3 : dans tout arbre binaire de hauteur h, h + 1 ≤ n ≤ 2h + 1 – 1
et
lg n ≤ h ≤ n – 1
correspondant au nombre de ses nœuds.
10.4 IDENTITÉ, ÉGALITÉ ET ISOMORPHISME Dans un ordinateur, deux objets sont identiques s’ils occupent le même espace mémoire et qu’ils ont par conséquent la même adresse. Dans le cadre du langage de programmation Java, cette approche de l’égalité est reflétée dans l’utilisation de l’opérateur d’égalité. Ainsi, si x et y sont des références aux objets, la condition (x == y) est vraie si, et seulement si, x et y font tous les deux références au même objet. Cependant, en mathématiques, deux objets sont généralement considérés comme égaux s’ils ont les mêmes valeurs. En java, cette distinction est gérée grâce à la méthode equals() définie dans la classe Object (reportez-vous à la section 3.4) et héritée par chaque classe. En général, les sous-classes la redéfinissent.
Exemple 10.6 Tester l’égalité des chaînes public class Ex1006 { static public void main(String[] args) { String x = new String("ABCDE"); String y = new String("ABCDE"); System.out.println("x = " + x); System.out.println("y = " + y); System.out.println("(x == y) = " + (x == y)); System.out.println("x.equals(y) = " + x.equals(y)); } } x = ABCDE y = ABCDE (x == y) = false x.equals(y) = true
Dans le cas présent, les deux objets x et y (ou, pour être plus précis, les deux objets qui sont référencés par les variables x et y) sont différents et occupent donc des espaces mémoire différents ; c’est pourquoi ils ne sont pas égaux dans le sens d’identiques. (x == y) est donc évaluée à false. Cependant, d’un point de vue mathématique, ils ont tous les deux la même valeur et sont par conséquent égaux : x.equals(y) est donc évaluée à true. Dans le contexte du langage de programmation Java, cette distinction entre l’égalité d’identité et l’égalité mathématique existe uniquement pour les variables de référence (c’est-à-dire pour les objets). Ainsi, pour toutes les variables de type primitif, l’opérateur d’égalité teste l’égalité mathématique. Les structures de données comportent à la fois un contenu et une structure. C’est pourquoi il arrive que deux structures de données aient un contenu égal, c’est-à-dire identique, mais organisé différemment. Par exemple, deux tableaux peuvent contenir les mêmes numéros 22, 44 et 88, mais dans un ordre différent.
191
Identité, égalité et isomorphisme
Exemple 10.7 Tester l’égalité des tableaux import java.util.Arrays; public class Testing { static public void main(String[] args) { int[] x = { 44, 22, 88 }; int[] y = { 88, 44, 22 }; System.out.println("Arrays.equals(x,y) = " + Arrays.equals(x,y)); Arrays.sort(x); Arrays.sort(y); System.out.println("Arrays.equals(x,y) = " + Arrays.equals(x,y)); } } Arrays.equals(x,y) = false Arrays.equals(x,y) = true
Deux structures de données sont dites isomorphes si elles présentent la même structure, c’est-à-dire si elles ont la même taille et qu’elles contiennent les mêmes données. Ces données peuvent ensuite être réorganisées de façon à rendre les deux structures égales. Cette opération est possible si et seulement si il existe une correspondance unilatérale entre les nœuds des deux structures pour préserver leur contiguïté. La définition exacte de l’isomorphisme est la suivante : ◆ Définition : Deux structures X et Y sont dites isomorphes s’il existe une fonction f qui fait correspondre chaque élément x de X à un élément unique y = f(x) de Y de façon à ce que chaque y de Y corresponde à un élément unique x de X, et que les deux éléments x1 et x2 soient adjacents dans X si et seulement si f(x) et f(x) sont adjacents dans Y. Une fonction f ayant les propriétés décrites dans cette définition est qualifiée d’isomorphisme entre les deux structures. Vous remarquerez que cette définition ne suppose pas que les deux structures contiennent les mêmes données. En effet, l’isomorphisme ne dépend pas du contenu, mais il décrit uniquement la structure sousjacente. Dans le cas des tableaux, des listes et des autres structures de données linéaires, les données peuvent être réorganisées (triées) pour que les deux structures soient égales d’un point de vue mathématique. En revanche, avec des structures de données non linéaires telles que les arbres et les graphes, cette réorganisation n’est pas toujours possible. Par exemple, si deux graphes ont la même taille et le même contenu, mais qu’ils sont structurés différemment, il est impossible de réorganiser leur contenu de façon à le rendre égal. Ils ne sont donc pas isomorphes.
Exemple 10.8 Arbres isomorphes a
b e
c f Arbre 1
p
d
q
g
t
a
r
s u
Arbre 2
v
b
c
d
e
f
g
Arbre 3
192
Arbres binaires
Les arbres 1 et 2 sont isomorphes, mais pas égaux. L’arbre 3 n’est pas isomorphe (et par conséquent pas égal) aux deux autres parce qu’il n’a que trois feuilles, tandis que les deux autres arbres sont composés de quatre feuilles. Cette distinction vous permet de déduire relativement aisément qu’il n’existe aucun isomorphisme entre l’arbre 1 et l’arbre 3. Étant donné qu’ils sont ordonnés les arbres 1 et 2 ne sont pas isomorphes parce que les sous-arbres les plus à gauche de leur racine ont une taille différente. En effet, le sous-arbre le plus à gauche de l’arbre 1 est composé de trois nœuds (b, e et f), tandis que celui de l’arbre 2 n’est composé que de deux nœuds (q et t). Là encore, cette distinction vous permet de déduire relativement aisément qu’il n’existe aucun isomorphisme entre l’arbre 1 et l’arbre 3. Les arbres binaires sont des arbres ordonnés, c’est-à-dire que l’ordre des deux enfants de chaque nœud fait partie de la structure de l’arbre binaire lui-même.
Exemple 10.9 Arbres binaires non isomorphes a
a
b d
c e
f
Arbre binaire 1
a
b
c d
e
b f
Arbre binaire 2
c d
e
Arbre 3
Dans le cas présent, l’arbre binaire 1 n’est pas isomorphe à l’arbre binaire 2, de la même façon que les arbres ordonnés de l’exemple 10.8 ne sont pas isomorphes. En effet, l’ordre de leurs sous-arbres n’est pas égal. Cependant, s’il s’agissait d’arbres non ordonnés, ils seraient isomorphes. Précisons que même dans un contexte d’arbres non ordonnés, aucun d’entre eux ne serait isomorphe à l’arbre 3 parce que celui-ci ne comporte que cinq nœuds tandis que les autres en ont six.
10.5 ARBRES BINAIRES PARFAITS Un arbre binaire parfait est soit un arbre binaire complet, soit un arbre complet comportant un segment de feuilles manquantes à droite du niveau inférieur.
Exemple 10.10 Arbre binaire parfait de hauteur 3
Le premier arbre est parfait et le deuxième est un arbre binaire complet qui a été obtenu en ajoutant 5 feuilles à droite du niveau 3. ✽ Théorème 10.2 : dans un arbre binaire parfait de hauteur h, h + 1 ≤ n ≤ 2h + 1 – 1 n correspondant au nombre de ses nœuds.
et
h = lg n
193
Arbres binaires parfaits
Exemple 10.11 Autres arbres binaires parfaits Voici trois autres exemples d’arbres binaires parfaits :
Les arbres binaires parfaits sont importants parce qu’ils peuvent être simplement et naturellement stockés sous forme de tableaux. Pour cela, il vous suffit de procéder à un mappage naturel (reportez-vous à la section 12.2) qui affectera les numéros d’index aux nœuds de l’arbre par niveau, comme illustré dans la figure ci-après. L’index 1 est affecté à la racine puis, pour chaque nœud i, 2i est affecté à son enfant gauche et 2i + 1 à son enfant de droite (le cas échéant). Vous pouvez ainsi affecter un entier positif unique à chaque nœud. L’intérêt de ce mappage naturel est qu’il permet aisément de calculer les index de tableau des enfants et du parent d’un nœud à partir de l’index de ce dernier. 1
a
3
2
b
c
d
e 9
8
h 0
6
5
4
i
10
11
j
7
f
g
12
k
l
1
2
3
4
5
6
7
8
9
10
11
12
a
b
c
d
e
f
g
h
i
j
k
l
Algorithme 10.1 Mappage naturel d’un arbre binaire parfait dans un tableau Pour parcourir un arbre binaire parfait stocké dans un tableau à la suite de son mappage naturel : 1. Le parent du nœud stocké à l’emplacement i est stocké à l’emplacement i/2 ; 2. L’enfant gauche du nœud stocké à l’emplacement i est stocké à l’emplacement 2i ; 3. L’enfant droit du nœud stocké à l’emplacement i est stocké à l’emplacement 2i + 1. Par exemple, le nœud e est stocké à l’index i = 5 dans le tableau ; son nœud parent b est stocké à l’index i/2 = 5/2 = 2 ; son nœud enfant gauche j est stocké à l’emplacement 2i = 25 = 10 ; et son nœud enfant droit k est stocké à l’index 2i + 1 = 25 + 1 = 11. La signification du terme « parfait » devrait maintenant être claire. La propriété qui définit la perfection est précisément la fonction qui garantit que le mappage naturel stockera ses nœuds dans un tableau sans trous.
Exemple 10.12 Arbre binaire incomplet L’arbre binaire de l’exemple 10.1 est incomplet. Le mappage naturel de ses nœuds dans un tableau laisse des trous, comme illustré dans la figure suivante :
194
Arbres binaires
a
b
c
d
g 0
e
h
f
i
j
1
2
3
4
5
6
a
b
c
d
e
f
7
8
9
10
g
h
i
11
12
13
j
Attention : certains auteurs utilisent l’expression « arbre binaire quasi-parfait » pour faire référence à un arbre binaire parfait et « arbre binaire parfait » pour faire référence à un arbre binaire complet.
10.6 ALGORITHMES DE PARCOURS DES ARBRES BINAIRES Les algorithmes de parcours utilisés pour les arbres généraux, c’est-à-dire le parcours préfixe, postfixe et en largeur (reportez-vous à la section 9.4), s’appliquent également aux arbres binaires. En outre, les arbres binaires supportent un quatrième algorithme, celui du parcours infixe. Ces quatre algorithmes sont développés ci-après.
Algorithme 10.2 Parcours en largeur d’un arbre binaire Pour parcourir un arbre binaire non vide : 1. Initialisez une file d’attente. 2. Mettez la racine en attente dans la file. 3. Répétez les étapes 4 à 7 jusqu’à ce que la file d’attente soit vide. 4. Retirez le nœud x de la file. 5. Visitez x. 6. Le cas échéant, mettez l’enfant gauche de x dans la file d’attente. 7. Le cas échéant, mettez l’enfant droit de x dans la file d’attente.
Exemple 10.13 Parcours en largeur d’un arbre binaire Le parcours en largeur d’un arbre binaire complet de hauteur 3 est le suivant : A B
C
D H
E I
J
G
F K
L
M
N
O
Les nœuds sont visités dans l’ordre suivant : A, B, C, D, E, F, G, H, I, J, K, L, M, N, O.
195
Algorithmes de parcours des arbres binaires
Algorithme 10.3 Parcours préfixe d’un arbre binaire Pour parcourir un arbre binaire non vide : 1. Visitez la racine. 2. Si le sous-arbre de gauche n’est pas vide, effectuez-en un parcours préfixe. 3. Si le sous-arbre de droite n’est pas vide, effectuez-en un parcours préfixe.
Exemple 10.14 Parcours préfixe d’un arbre binaire La figure ci-après illustre le parcours préfixe d’un arbre binaire complet de hauteur 3. A C
B D H
E I
J
G
F K
L
M
N
O
Les nœuds sont visités dans l’ordre A, B, D, H, I, E, J, K, C, F, L, M, G, N, O, C. Remarquez que le parcours préfixe d’un arbre binaire peut s’effectuer en cercles, en commençant par la racine et en visitant chaque nœud la première fois que vous le rencontrez sur votre gauche.
A B
C
D H
E I
J
G
F K
L
N
M
O
Algorithme 10.4 Parcours postfixe d’un arbre binaire Pour parcourir un arbre binaire non vide : 1. Si le sous-arbre de gauche n’est pas vide, effectuez-en un parcours postfixe. 2. Si le sous-arbre de droite n’est pas vide, effectuez-en un parcours postfixe. 3. Visitez la racine.
Exemple 10.15 Parcours postfixe d’un arbre binaire La figure ci-après illustre le parcours postfixe d’un arbre binaire complet de hauteur 3 : A B
C
D H
E I
J
F K
L
G M
N
O
196
Arbres binaires
Les nœuds sont visités dans l’ordre suivant : H, I, D, J, K, E, B, L, M, F, N, O, G, C, A. Le parcours préfixe visite la racine en premier lieu tandis que le parcours postfixe la visite en dernier. Il existe également une troisième possibilité qui consiste à visiter la racine entre le parcours des deux sous-arbres. Il s’agit du parcours infixe.
Algorithme 10.5 Parcours infixe d’un arbre binaire Pour parcourir un arbre binaire non vide : 1. Si le sous-arbre de gauche n’est pas vide, effectuez-en un parcours préfixe. 2. Visitez la racine. 3. Si le sous-arbre de droite n’est pas vide, effectuez-en un parcours préfixe.
Exemple 10.6 Parcours infixe d’un arbre binaire La figure ci-après illustre le parcours infixe d’un arbre binaire complet de hauteur 3 : A B
C
D I
H
F
E J
K
L
G M
N
O
Les nœuds sont visités dans l’ordre suivant : H, D, I, B, J, E, K, A, L, F, M, C, N, G, O.
10.7 ARBRES D’EXPRESSION Une expression arithmétique telle que (5 – x)*y + 6/(x + z) est une combinaison d’opérateurs arithmétiques (+, -, *, /, etc.), d’opérandes (5, x, y, 6, z, etc.), et de parenthèses permettant de remplacer la priorité des opérations. Chaque expression peut être représentée par un arbre binaire unique dont la structure est déterminée par la priorité des opérations dans l’expression. Ce type d’arbre est qualifié d’arbre d’expression.
Exemple 10.17 Un arbre d’expression Vous trouverez ci-après l’arbre représentant l’expression suivante : (5 – x)*y + 6/(x + z) + /
* – 5
y x
6
+ x
z
L’algorithme récursif de construction d’un arbre d’expression est le suivant :
Arbres d’expression
197
Algorithme 10.5 Construire un arbre d’expression L’arbre d’expression d’une expression donnée peut être construit récursivement à l’aide des règles suivantes : 1. L’arbre d’expression d’un seul opérande est un nœud racine unique le contenant. 2. Si E1 et E2 sont des expressions représentées par les arbres T1 et T2 et si op est un opérateur, alors l’arbre de l’expression E1 op E2 est composé d’un nœud racine contenant op et des deux sousarbres T1 et T2. Une expression peut être représentée de trois façons selon l’algorithme de parcours utilisé. Le parcours préfixe crée une représentation préfixe, le parcours infixe une représentation infixe et le parcours postfixe une représentation postfixe de l’expression. Cette dernière porte également le nom de notation polonaise inversée ou de notation RPN (pour Reverse Polish Notation), comme nous l’avons déjà vu dans la section 6.2.
Exemple 10.18 Les trois représentations d’une expression Les trois représentations de l’expression de l’exemple 10.17 sont les suivantes : • Préfixe : +*-5xy/6+xz • Infixe : 5-x*y+6/x+z • Postfixe : 5x-y*6xz+/+ Généralement, la syntaxe d’une fonction utilise la représentation préfixe. L’expression de l’exemple 10.17 pourrait ainsi être évaluée de la façon suivante : somme(produit(différence(5, x), y), division(6, somme(x, z)))
Certaines calculatrices scientifiques proposent la notation RPN. Pour utiliser cette notation, vous devez entrer les deux opérandes avant l’opérateur. Une expression est évaluée en appliquant l’algorithme suivant à sa représentation postfixe.
Algorithme 10.7 Évaluer une expression à partir de sa représentation postfixe Pour évaluer une expression dont la représentation est postfixe, vous devez analyser cette représentation de gauche à droite : 1. Créez une pile d’opérandes. 2. Répétez les étapes 3 à 9 jusqu’à ce que vous atteigniez la fin de la représentation. 3. Lisez le symbole t suivant de la représentation. 4. S’il s’agit d’un opérande, poussez sa valeur dans la pile. 5. Si tel n’est pas le cas, répétez les étapes 6 à 9 : 6. Dépilez a. 7. Dépilez b. 8. Évaluez c = a t b. 9. Poussez c dans la pile. 10. Renvoyez l’élément supérieur de la pile.
Exemple 10.9 Évaluer une expression à partir de sa représentation postfixe L’évaluation de l’expression de l’exemple 10.18 en remplaçant x par 2, y par 3 et z par 1 est la suivante :
198
Arbres binaires
x 2
5
x
–
y
*
6
x
z
+
/
5
+
x
–
y
*
6
x
z
+
/
+
y 3 z 1
b
5
op
x
–
b
5
x
–
x
–
x
y
*
6
x
y
x
–
y
6
x
b 6
–
y
+
*
6
x
z
+
6
x
op /
/
z
+
/
2 5
3 3
6 9
+
/
+
c
z
a 3
+
/
1 2 6 9
c 2
2 9
–
x
x
5
–
*
6
y
*
–
y
*
6
–
y
*
6
–
y
*
op +
+
x
z
x
z
6
x
z
+
x
z
a 2
+
/
+
/
/
/
2 6 9
+
c 3
+
9
+
c 3
+
3
+
c 9
a 1
6
/
c 3
a 2
op +
x
z
a 3
op –
x
x
5
c
a 3
op *
b 2
b 9
y
a
op –
b 5
5
+
x
b 3
5
+
op
b 5
5
+
c
z
5
+
c
a
*
/
c
a
op
x
z
a
*
b
c
a
op
b
5
6
op
b
5
*
op
b
5
y
a
3 6 9
+
c 11
11
10.8 CLASSE BinaryTree La classe BinaryTree est destinée aux arbres binaires et implémente directement la définition récursive de ces derniers. Étant donné qu’elle étend la classe AbstractCollection (reportez-vous à la section 5.3), elle demeure cohérente avec le framework de collections Java : import java.util.*; public class BinaryTree extends java.util.AbstractCollection { protected Object root; protected BinaryTree left, right, parent; protected int size; public BinaryTree() { }
Classe BinaryTree
199
public BinaryTree(Object root) { this.root = root; size = 1; } public BinaryTree(Object root, BinaryTree left, BinaryTree right) { this(root); if (left != null) { this.left = left; left.parent = this; size += left.size(); } if (right != null) { this.right = right; right.parent = this; size += right.size(); } } public boolean equals(Object object) { if (!(object instanceof BinaryTree)) return false; BinaryTree tree = (BinaryTree)object; return ( tree.root.equals(root) && tree.left.equals(left) && tree.right.equals(right) && tree.parent.equals(parent) && tree.size == size); } public int hashCode() {return root.hashCode() + left.hashCode() + right.hashCode() + size; } public Iterator iterator() { return new java.util.Iterator() // classe interne anonyme { private boolean rootDone; private java.util.Iterator lit, rit; // itérateurs public boolean hasNext() { return !rootDone || lit != null && lit.hasNext() || rit != null && rit.hasNext(); } public Object next() { if (rootDone) { if (lit != null && lit.hasNext()) return lit.next(); if (rit != null && rit.hasNext()) return rit.next(); return null; } if (left != null) lit = left.iterator(); if (right != null) rit = right.iterator(); rootDone = true; return root; } public void remove() { throw new UnsupportedOperationException(); } }; } public int size() { return size; } }
200
Arbres binaires
L’objet BinaryTree peut être représenté de la façon suivante : parent root "B" size 1 left right Il est composé de cinq champs : une référence Object nomBinaryTree mée root, un champ int nommé size et trois références BinaryTree nommées left, right et parent. La définition récursive des arbres binaires suppose uniquement l’existence des champs root, left et right, les deux autres champs étant simplement inclus afin de simplifier une partie du code. Le constructeur composé d’un paramètre crée un singleton, c’est-à-dire un arbre binaire avec un seul nœud. Le constructeur composé de trois paramètres crée un arbre binaire récursivement à partir des deux autres arbres binaires qui peuvent être null. La classe java.util.AbstractCollection requiert l’implémentation des quatre méthodes définies ici. La méthode equals(Object) remplace la version par défaut définie dans la classe Object (reportez-vous à la section 3.4). Pour qu’un Object soit égal à l’objet BinaryTree, il doit représenter une instance de la classe BinaryTree et tous ses champs doivent être égaux aux cinq champs correspondants de l’objet BinaryTree. La méthode hashCode() remplace également la version par défaut définie dans la classe Object. Elle renvoie simplement un entier calculé à partir des codes de hachage de ses quatre objets membres et son champ de taille. La méthode iterator() remplace la version par défaut (vide) définie dans la classe AbstractCollection. Son rôle est de construire un objet itérateur capable de parcourir l’objet BinaryTree. Pour cela, elle crée sa propre classe interne anonyme Iterator à l’aide de la construction Java return new : return new Iterator() // classe interne anonyme { private boolean rootDone; private Iterator lit, rit; //itérateurs public boolean hasNext() ... public Object next() ... public void remove() .. };
Le corps de cette classe anonyme est défini entre les accolades qui suivent l’appel du constructeur Iterator(). Comme vous pouvez le constater, ce bloc doit être suivi d’un point-virgule parce qu’il constitue en fait la fin d’une instruction return. Attention, bien que la construction complète ressemble
à une définition de méthode, elle n’en est pas une : il s’agit en fait d’une définition de classe complète insérée dans une instruction return. Pour renvoyer un objet Iterator, cette classe anonyme doit implémenter l’interface Iterator (reportez-vous à la section 5.5), d’où la nécessité de définir les trois méthodes suivantes : public boolean hasNext() ... public Object next() ... public void remove() ..
Cette implémentation est récursive. La méthode hasNext() appelle les méthodes hasNext() des itérateurs sur les deux sous-arbres, et la méthode next() appelle les méthodes next() de ces deux itérateurs nommés lit et rit. L’autre variable locale est en fait un indicateur nommé rootDone qui sait si l’objet racine a déjà été visité par l’itérateur. La méthode hasNext() renvoie true, sauf si les trois composants de l’arbre ont été visités, à savoir la racine, le sous-arbre de gauche et celui de droite. Pour cela, elle utilise les itérateurs lit et rit récursivement. La méthode next() utilise également les itérateurs lit et rit récursivement. Si la racine a déjà été visitée, l’itérateur visite le nœud suivant dans le sous-arbre de gauche s’il y en a un, ou bien le nœud suivant du sous-arbre de droite s’il existe. En revanche, si la racine n’a pas encore été visitée, il s’agit du premier appel de l’itérateur sur ce sous-arbre, c’est pourquoi la méthode initialise lit et rit, paramètre l’indicateur rootDone, puis renvoie la racine.
201
Classe BinaryTree
La méthode remove() n’est pas implémentée parce qu’aucune méthode ne permet de supprimer aisément un nœud interne d’un arbre binaire.
Exemple 10.20 Tester la classe BinaryTree Le pilote test de la classe BinaryTree définie précédemment est le suivant : public class Ex1020 { static public void main(String[] args) { BinaryTree e = new BinaryTree("E"); BinaryTree g = new BinaryTree("G"); BinaryTree h = new BinaryTree("H"); BinaryTree i = new BinaryTree("I"); BinaryTree d = new BinaryTree("D",null,g); BinaryTree f = new BinaryTree("F",h,i); BinaryTree b = new BinaryTree("B",d,e); BinaryTree c = new BinaryTree("C",f,null); BinaryTree tree = new BinaryTree("A",b,c); System.out.println("arbre = " + tree); } } arbre = [A, B, D, G, E, C, F, H, I]
Ce programme crée l’arbre binaire suivant, puis il appelle indirectement sa méthode toString() héritée de la classe AbstractCollection : A
B
C 9
D
E
F
G
H
I
4
2
"A"
"B"
"D"
1
4
1
"G"
"E"
1
3
"H"
"C"
"F"
1
"I"
202
Arbres binaires
Cette figure vous propose deux vues du même arbre, le plus grand étant plus détaillé et représentant chaque référence d’objet à l’aide d’une flèche. Étant donné qu’elle étend la classe AbstractCollection, la classe BinaryTree hérite automatiquement des méthodes suivantes qui sont définies à l’aide des méthodes iterator() et size() : public public public public public public public public public public public public
boolean boolean Object[] Object[] String boolean boolean void boolean boolean boolean boolean
isEmpty() contains(Object object) toArray() toArray(Object[] objects) toString() add(Object object) addAll(Collection collection) clear() containsAll(Collection collection) remove(Object object) removeAll(Collection collection) retainAll(Collection collection)
Cependant, les méthodes non constantes lanceront une exception UnsupportedOperationException parce qu’elles appellent d’autres méthodes qui ne sont pas implémentées, à savoir add() et Iterator.remove().
Exemple 10.21 Tester la méthode contains() sur un arbre binaire Cet exemple construit un arbre identique à celui de l’exemple 10.20, puis il teste la méthode contains() sur l’arbre et ses sous-arbres : public class Ex1020 { static public void main(String[] args) { BinaryTree e = new BinaryTree("E"); BinaryTree g = new BinaryTree("G"); BinaryTree h = new BinaryTree("H"); BinaryTree i = new BinaryTree("I"); BinaryTree d = new BinaryTree("D",null,g); BinaryTree f = new BinaryTree("F",h,i); BinaryTree b = new BinaryTree("B",d,e); BinaryTree c = new BinaryTree("C",f,null); BinaryTree a = new BinaryTree("A",b,c); System.out.println("a = " + a); System.out.println("a.contains(\"H\") = " + a.contains("H")); System.out.println("b = " + b); System.out.println("b.contains(\"H\") = " + b.contains("H")); System.out.println("c = " + c); System.out.println("c.contains(\"H\") = " + c.contains("H")); } } A
a = [A, B, D, G, E, C, F, H, I] a.contains("H") = true b = [B, D, G, E] b.contains("H") = false c = [C, F, H, I] c.contains("H") = true
Les sous-arbres b et c sont mis en évidence dans la figure ci-contre :
b
c
B
D
E
G
C
F
H
I
Implémenter les algorithmes de parcours
203
10.9 IMPLÉMENTER LES ALGORITHMES DE PARCOURS L’itérateur renvoyé par la méthode iterator() suit l’algorithme de parcours préfixe (voir l’algorithme 10.5) afin de parcourir l’arbre binaire. Dans le programme suivant, la classe BinaryTree a été modifiée de façon à implémenter les quatre algorithmes de parcours d’un arbre binaire : public class BinaryTree extends java.util.AbstractCollection { private Object root; private BinaryTree left, right, parent; private int size; // Inclure le même code que celui de la section 10.8 abstract public class Iterator { protected boolean rootDone; protected Iterator lit, rit; // itérateurs public boolean hasNext() { return !rootDone || lit != null && lit.hasNext() || rit != null && rit.hasNext(); } abstract public Object next(); public void remove() { throw new UnsupportedOperationException(); } } public class PreOrder extends Iterator { public PreOrder() { if (left != null) lit = left.new PreOrder(); if (right != null) rit = right.new PreOrder(); } public Object next() { if (!rootDone) { rootDone = true; return root; } if (lit != null && lit.hasNext()) return lit.next(); if (rit != null && rit.hasNext()) return rit.next(); return null; } } public class InOrder extends Iterator { public InOrder() { if (left != null) lit = left.new InOrder(); if (right != null) rit = right.new InOrder(); } public Object next() { if (lit != null && lit.hasNext()) return lit.next(); if (!rootDone) { rootDone = true; return root; } if (rit != null && rit.hasNext()) return rit.next(); return null; } }
204
Arbres binaires
public class PostOrder extends Iterator { public PostOrder() { if (left != null) lit = left.new PostOrder(); if (right != null) rit = right.new PostOrder(); } public Object next() { if (lit != null && lit.hasNext()) return lit.next(); if (rit != null && rit.hasNext()) return rit.next(); if (!rootDone) { rootDone = true; return root; } return null; } } public class LevelOrder extends Iterator { ArrayQueue queue = new ArrayQueue(); public boolean hasNext() { return (!rootDone || !queue.isEmpty()); } public Object next() { if (!rootDone) { if (left != null) queue.enqueue(left); if (right != null) queue.enqueue(right); rootDone = true; return root; } if (!queue.isEmpty()) { BinaryTree tree = (BinaryTree)queue.dequeue(); if (tree.left != null) queue.enqueue(tree.left); if (tree.right != null) queue.enqueue(tree.right); return tree.root; } return null; } } }
Tout d’abord, nous définissons une classe interne abstraite intitulée Iterator qui servira de classe de base aux quatre classes d’itérateurs concrètes. Cette classe déclare les trois mêmes champs (rootDone, rit et lit) que la classe d’itérateur anonyme définie précédemment. Pour information, il n’y aura aucun conflit de nom entre cette classe Iterator et la classe java.util.Iterator hors de la classe BinaryTree parce que vous y ferez référence sous la forme BinaryTree.Iterator (reportez-vous à l’exemple 10.22). D’ailleurs, nous avons déjà évité un conflit de nom au sein de la classe BinaryTree en incluant le nom du paquetage java.util comme composant de la classe java .util.Iterator. Les méthodes hasNext() et remove() sont implémentées dans la classe abstraite Iterator de la même façon que dans la classe d’itérateur anonyme. Cependant, la méthode next() est déclarée comme étant abstraite parce que chacun des algorithmes de parcours l’implémente différemment. La classe PreOrder définit les itérateurs lit et rit comme itérateurs PreOrder dans son constructeur afin de s’assurer que le parcours récursif suivra l’algorithme de parcours préfixe. En effet, cet algorithme (reportez-vous à la section Algorithme 10.3) spécifie que la racine doit être visitée en premier, puis que le même algorithme doit être appliqué récursivement au sous-arbre de gauche, puis au sous-arbre de droite. D’où l’utilité des trois instructions if :
205
Forêts
if (!rootDone) { rootDone = true; return root; } if (lit != null && lit.hasNext()) return lit.next(); if (rit != null && rit.hasNext()) return rit.next();
Les classes PreOrder, InOrder et PostOrder se différencient uniquement par leur définition des itérateurs récursifs rit et lit dans les constructeurs, ainsi que par l’ordre des trois instructions if dans la méthode next(). En ce qui concerne la classe InOrder, la racine est visitée entre les deux parcours récursifs. Dans la classe PostOrder, la racine est visitée après les deux parcours récursifs. Comme vous avez pu le deviner, « Pre » signifie « avant », « in » signifie « entre » et « post » signifie « après ». La classe de parcours en largeur LevelOrder est nettement différente des trois autres classes de parcours parce qu’elle utilise une file au lieu d’être récursive.
Exemple 10.22 Tester les algorithmes de parcours public class Ex1022 { static public void main(String[] args) { BinaryTree e = new BinaryTree("E"); BinaryTree g = new BinaryTree("G"); BinaryTree h = new BinaryTree("H"); BinaryTree i = new BinaryTree("I"); BinaryTree d = new BinaryTree("D",null,g); BinaryTree f = new BinaryTree("F",h,i); BinaryTree b = new BinaryTree("B",d,e); BinaryTree c = new BinaryTree("C",f,null); BinaryTree tree = new BinaryTree("A",b,c); System.out.println("arbre = " + tree); BinaryTree.Iterator it; System.out.print("Parcours préfixe : "); for (it = tree.new PreOrder(); it.hasNext(); ) System.out.print(it.next() + " "); System.out.print("\nParcours infixe : "); for (it = tree.new InOrder(); it.hasNext(); ) System.out.print(it.next() + " "); System.out.print("\nParcours posfixe : "); for (it = tree.new PostOrder(); it.hasNext(); ) System.out.print(it.next() + " "); System.out.print("\nParcours en largeur : "); for (it = tree.new LevelOrder(); it.hasNext(); ) System.out.print(it.next() + " "); System.out.println(); } } arbre = [A, B, D, G, E, C, F, H, I] Parcours préfixe : A B D G E C F H I Parcours infixe : D G B E A H F I C Parcours postfixe : G D E B H I F C A Parcours en largeur : A B C D E F G H I
10.10 FORÊTS Une forêt est une liste d’arbres.
206
Arbres binaires
Exemple 10.23 Une forêt La figure suivante illustre le cas d’une forêt constituée de trois arbres : N
A
B
E
C
D
F
G
H
K
L
M
Q
O
P
R
S
J
T
L’algorithme suivant indique comment une forêt peut être représentée par un seul arbre binaire.
Algorithme 10.7 Mappage naturel d’une forêt dans un arbre binaire 1. Mappez la racine du premier arbre dans celle de l’arbre binaire. 2. Si le nœud X est mappé dans le nœud X’ et que le nœud Y est le premier enfant de X, mappez Y dans l’enfant gauche de X’. 3. Si le nœud X est mappé dans X’ et que le nœud Z est le frère de X, mappez Z dans l’enfant droit de X’. Les racines des arbres sont considérées comme des frères.
Exemple 10.24 Mapper une forêt dans un arbre binaire Le mappage de la forêt illustrée dans l’exemple 10.23 est le suivant : A
B
E
N
D
C
F
G
H
K
L
M
J
O
A
Q
P
B
R
S
T
E
N
D
C
F
G
H
K
L
M
J
O
Q
P
R
S
T
Regardons plus précisément les nœuds C, F et D. Dans la forêt originale, l’enfant le plus ancien de C était F et son frère suivant était D ; dans l’arbre binaire obtenu, C a donc F pour enfant gauche et D pour enfant droit.
?
QUESTIONS DE RÉVISION
QUESTIONS DE RÉVISION
10.1
Combien de nœuds feuilles dénombre-t-on dans un arbre binaire complet de hauteur h = 3 ?
10.2
Combien de nœuds internes dénombre-t-on dans un arbre binaire complet de hauteur h = 3 ?
207
Révision et entraînement
10.3
Combien de nœuds dénombre-t-on dans un arbre binaire complet de hauteur h = 3 ?
10.4
Combien de nœuds feuilles dénombre-t-on dans un arbre binaire complet de hauteur h = 9 ?
10.5
Combien de nœuds internes dénombre-t-on dans un arbre binaire complet de hauteur h = 9 ?
10.6
Combien de nœuds dénombre-t-on dans un arbre binaire complet de hauteur h = 9 ?
10.7
Quel est l’intervalle de hauteurs possibles pour un arbre binaire avec n = 100 nœuds ?
10.8
Pourquoi est-il impossible d’effectuer un parcours infixe d’un arbre général ?
10.9
Indiquez si les affirmations suivantes sont vraies ou fausses : a. b. c. d.
¿
Si toutes les feuilles d’un arbre binaire se trouvent au même niveau, cet arbre est complet. Si un arbre binaire est composé de n nœuds et a une hauteur h, alors h ≥ lg n. Un arbre binaire ne peut pas avoir plus de 2d nœuds au niveau de la profondeur d. Si chaque sous-arbre correct d’un arbre binaire est complet, l’arbre lui-même doit également être complet.
RÉPONSES
RÉPONSES
10.1
Un arbre binaire complet de hauteur 3 a l = 23 = 8 feuilles.
10.2
Un arbre binaire complet de hauteur 3 a m = 23 – 1 = 7 nœuds internes.
10.3
Un arbre binaire complet de hauteur 3 a n = 23+1 – 1 = 24 – 1 = 16 – 1 = 15 nœuds.
10.4
Un arbre binaire complet de hauteur 9 a l = 29 = 512 feuilles.
10.5
Un arbre binaire complet de hauteur 9 a m = 29 – 1 = 512 – 1 = 511 nœuds internes.
10.6
Un arbre binaire complet de hauteur 9 a n = 29+1 – 1 = 210 – 1 = 1024 – 1 = 1023 nœuds.
10.7
D’après le corollaire 10.3, dans tout arbre binaire : lg n ≤ h ≤ n – 1 Ainsi, dans un arbre binaire composé de 100 nœuds : lg 100 ≤ h ≤ 100 – 1 = 99 Étant donné que : lg 100 = (log 100)/(log 2) = 6,6 = 6 la hauteur doit être située entre 6 et 99, 6 ≤ h ≤ 99.
10.8
L’algorithme de parcours infixe des arbres binaires visite récursivement la racine entre le parcours du sous-arbre gauche et celui du sous-arbre droit. Cela suppose donc l’existence de deux sous-arbres (même vides) à chaque nœud (non vide). Or, dans un arbre général, un nœud peut avoir un nombre illimité de sous-arbres ; il n’existe donc aucune méthode algorithmique qui permette de généraliser le parcours infixe.
10.9
a. b. c. d.
Vraie. Vraie. Vraie. Fausse.
208
Arbres binaires
?
EXERCICES D’ENTRAÎNEMENT
EXERCICES D’ENTRAÎNEMENT
10.1
Pour chacun des arbres binaires suivants, dessinez son équivalent en fonction de la deuxième définition selon laquelle chaque nœud interne doit avoir deux enfants : a
a.
a
b.
b
c
b
d
e
c d
a
c.
b
c
d
e i
10.2
a
d.
j
b
f
g n
c
d o
e
g
h
Donnez l’ordre de visite de cet arbre binaire selon les parcours suivants : a. parcours en largeur ; b. parcours préfixe ; c. parcours infixe ; d. parcours postfixe.
10.3
Donnez l’ordre de visite de l’arbre binaire de taille 10 figurant dans l’exemple 10.2 selon les parcours suivants : a. parcours en largeur ; b. parcours préfixe ; c. parcours infixe ; d. parcours postfixe.
10.4
Donnez l’ordre de visite de cet arbre binaire selon les parcours suivants : a. parcours en largeur ; b. parcours préfixe ; c. parcours infixe ; d. parcours postfixe.
10.5
Dessinez le tableau obtenu à la suite d’un mappage naturel destiné à stocker l’arbre binaire de l’exercice 10.1.
f
A C
B D
E H
F I
G K
J
A C
B D G
J
H M
F
E
N
L
K O
209
Révision et entraînement
10.6
Dessinez le tableau obtenu à la suite d’un mappage naturel destiné à stocker l’arbre binaire de l’exemple 10.1.
10.7
Dessinez le tableau obtenu à la suite d’un mappage naturel destiné à stocker l’arbre binaire de l’exercice 10.4.
10.8
Si les nœuds d’un arbre binaire sont numérotés en fonction de leur mappage naturel et que l’opération de visite imprime le numéro du nœud, quel algorithme de parcours imprimera les nombres dans l’ordre ?
10.9
Dessinez l’arbre d’expression de a * (b + c) * (d * e + f).
10.10 Écrivez les représentations préfixe et postfixe des expressions de l’exercice 10.8. 10.11 Dessinez l’arbre d’expression de chacune des expressions préfixes de l’exercice 6.2. 10.12 Dessinez l’arbre d’expression de chacune des expressions infixes de l’exercice 6.4. 10.13 Dessinez l’arbre d’expression de chacune des expressions postfixes de l’exercice 6.6. 10.14 Quelles sont les limites du nombre n de nœuds pour un arbre binaire de hauteur 4 ? 10.15 Quelles sont les limites de la hauteur h d’un arbre binaire composé de 7 nœuds ? 10.16 Quelle est la forme de l’arbre binaire le plus haut pour un nombre donné de nœuds ? 10.17 Quelle est la forme de l’arbre binaire le plus bas (c’est-à-dire avec la hauteur la plus petite) pour
un nombre donné de nœuds ? 10.18 Vérifiez la définition récursive des arbres binaires (expliquée en début de
A
chapitre) pour l’arbre binaire ci-contre : B
10.19 Dessinez les 42 arbres binaires de taille n = 5. 10.20 Combien y a-t-il d’arbres binaires différents de taille n = 6 ?
D C
E
F
10.21 Déduisez une relation de récurrence pour le nombre f(n) d’arbres binaires
de taille n. 10.22 Démontrez que, pour tout n ≤ 8, la fonction f(n) calculée dans l’exercice 10.21 crée la même
séquence que la formule explicite suivante : 2n n ( 2n )! ( 2n ) ( 2n – 1 ) ( 2n – 2 ) … ( 2n + 3 ) ( 2n + 2 ) f ( n ) = ------------ = ------------------------ --------------------------------------------------------------------------------------------------n+1 n! ( n + 1 )! (n)(n – 1)(n – 2)(n – 3)…(2)(1)
Par exemple, 8 4 8! (8)(7)(6) ( 8 )( 7 ) f ( 4 ) = -------- = ---------- = -------------------------------- = ---------------- = 14 4!5! (4)(3 )(2)(1) 4 5 10.23 Démontrez le corollaire 10.3. A
10.24 Démontrez le théorème 10.2. 10.25 Dessinez la forêt représentée par l’arbre binaire ci-contre :
B
C
10.26 Déduisez une formule explicite pour le nombre f(h) d’arbres D
binaires parfaits de hauteur h. 10.27 Déduisez une formule explicite pour le nombre f(h) d’arbres
F
E G
binaires complets de hauteur h. 10.28 Démontrez que chaque sous-arbre d’un arbre binaire complet est également complet. 10.29 Démontrez que chaque sous-arbre d’un arbre binaire parfait est également parfait.
210
Arbres binaires
Implémentez chacune des méthodes suivantes dans la classe BinaryTree : 10.30 • public int leaves(); • // renvoie le nombre de feuilles dans l’arbre 10.31 • public int height(); • // renvoie la hauteur de l’arbre 10.32 • public int level(Object object); • // renvoie -1 si l’objet donné n’est pas dans l’arbre; • // sinon, renvoie son niveau dans l’arbre; 10.33 • public void reflect(); • // intervertit les enfants de chaque nœud de l’arbre 10.34 • public void defoliate(); • // supprime toutes les feuilles de l’arbre
¿
SOLUTIONS
SOLUTIONS
10.1
Les arbres correspondant à la deuxième définition d’un arbre binaire sont les suivants : a.
b.
a
b
c
d
*
a
*
b
* e
*
*
*
c
*
* d
*
** c.
d.
a
b
c
d
*
e i
j
**** 10.2
a. b. c. d.
a
b
f
*
*
g
*
n
d o
****
g
**
Parcours en largeur : A, B, C, D, E, F, G, H, I, J, K. Parcours préfixe : A, B, D, E, H, I, C, F, J, G, K. Parcours infixe : D, B, H, E, I, A, F, J, C, G, K. Parcours postfixe : D, H, I, E, B, J, F, K, G, C, A.
c
e
* *
*
f h
**
*
*
211
Révision et entraînement
10.3
a. Parcours en largeur : A, B, C, D, E, F, H, I, J,M. b. Parcours préfixe : A, B, D, H, I, E, J, C, F, M. c. Parcours infixe : H, D, I, B, J, E, A, F, M, C. d. Parcours postfixe : H, I, D, J, E, B, M, F, C, A.
10.4
a. Parcours en largeur : A, B, C, D, E, F, G, H, J, K, L, M, N, O. b. Parcours préfixe : A, B, D, G, M, H, C, E, J, N, F, K, O, L. c. Parcours infixe : G, M, D, H, B, A, N, J, E, C, K, O, F, L. d. Parcours postfixe : M, G, H, D, B, N, J, E, O, K, L, F, C, A.
10.5
La figure suivante illustre le mappage naturel de l’arbre binaire donné : 0
1
2
3
4
5
6
7
8
9
10
A B C D E F G
10.6
12
13
14
J
15
K
La figure suivante illustre le mappage naturel de l’arbre binaire de l’exemple 10.1 : 0
1
2
3
4
5
6
7
A B C D E F
10.7
11
H I
9
10
H I
8
J
11
12
13
M
La figure suivante illustre le mappage naturel de l’arbre binaire donné : 0
1
2
3
4
A B C D
5
6
7
8
9
10
E F G H
11
12
13
J
14
15
K L
16
17
18
19
M
20
21
22
23
24
25
26
27
N
10.8
Le parcours en largeur imprime les nombres du mappage naturel dans l’ordre.
10.9
L’arbre d’expression est le suivant : *
a
*
+
+ b c
*
f
d e
10.10 La représentation préfixe est *a*+bc+*def, et la représentation postfixe est abc+de*f+**.
28
29
O
212
Arbres binaires
10.11 Les arbres d’expression des expressions préfixes de l’exercice 6.2 sont les suivants : a.
b.
-
*
e
/
c.
/
+ c
/
d
/
a
+
e
d
+
b
*
a b c
*
c
-
a b
d e
10.12 Les arbres d’expression des expressions infixes de l’exercice 6.4 sont les suivants : -
a. +
a
c
*
+
*
d e
*
c.
a
/
b
/
b.
/ a
-
/
b c d e
e
d
b c
10.13 Les arbres d’expression des expressions postfixes de l’exercice 6.6 sont les suivants : a.
e
/ +
b.
+
-
c.
-
a
* +
/
a
/
b
a b a b
/
b c d e c
/ d e
10.14 Dans un arbre binaire de hauteur h = 4, 5 n 31. 10.15 Dans un arbre binaire avec n = 7 nœuds, 2 h 6. 10.16 Pour un nombre donné de nœuds, l’arbre binaire le plus haut est une séquence linéaire. 10.17 Pour un nombre donné de nœuds, l’arbre binaire le plus bas est un arbre binaire parfait. 10.18 Pour vérifier la définition récursive d’un arbre donné, nous remarquons d’abord que les feuilles
C, E et F sont des arbres binaires parce que chaque singleton est conforme à la définition récursive des arbres binaires (ses sous-arbres de gauche et de droite sont tous les deux vides). Il s’ensuit que le sous-arbre de racine B est un arbre binaire parce qu’il s’agit d’un triplet (X, L, R), avec X = B, L = Ø et R = C. De la même façon, il s’ensuit que le sous-arbre de racine D est un arbre binaire parce qu’il s’agit d’un triplet (X, L, R), avec X = D, L = E et R = F. En dernier lieu,
213
Révision et entraînement
il s’ensuit que l’arbre entier est conforme à la définition récursive parce qu’il s’agit d’un triplet (X, L, R), avec X = A, L comme arbre binaire de racine B et L comme arbre binaire de racine D. 10.19 Les 42 arbres binaires de taille n = 5 sont les suivants :
10.20 Il existe 132 arbres binaires différents de taille 6 :
142 + 114 + 25 + 52 + 141 + 142 = 132 10.21 Un arbre binaire non vide est composé d’une racine X, d’un sous-arbre gauche L et d’un sous-
arbre droit R. Supposons que n soit la taille de l’arbre binaire, que nL = |L| = la taille de L et que nR = |R| = la taille de R. Ensuite, n = 1 + nL + nR. Il existe donc n valeurs différentes possibles pour la paire (nL, nR) : (0, n – 1), (1, n – 2), …, (n – 1,0). Par exemple, si n = 6 (comme dans l’exercice 10.16), les seules possibilités sont (0, 5), (1, 4), (2, 3), (3, 2), (4, 1) ou (5, 0). Dans le cas de (0, n – 1), L est vide et |R| = n – 1 ; il existe donc f(0) x f(n – 1) arbres binaires différents. Dans le cas de (1, n – 2), L est un singleton et |R| = n – 2 ; il existe donc f(1) x f(n – 2) arbres binaires différents. Le même principe peut être appliqué à chaque cas. Le nombre total d’arbres binaires différents de taille est n est donc le suivant : f(n) = 1f(n – 1) + 1f(n – 2) + 2f(n – 3) + 5f(n – 4) +…+ f(i – 1) f(n – i) +…+ f(n – 1)1 Cette formule condensée est la suivante : n
f (n) =
∑ f (i – 1) ⋅ f (n – 1)
i=1
214
Arbres binaires
10.22 Il s’agit des nombres catalans :
n
2n n
n + 1 2n ⁄ ( n + 1 ) n
∑ f (i – 1) ⋅ f (n – i)
0 1
1
1
1
1 2
2
1
1·1 = 1
2 6
3
2
1·1+1·1 = 2
3 20
4
5
1·2+1·1+2·1 = 5
4 70
5
14
1·5+1·2+2·1+5·1 = 14
5 252
6
42
1·14+1·5+2·2+5·1+14·1 = 42
6 924
7
132
1·42+1·14+2·5+5·2+14·1+42·1 = 32
7 3432
8
429
1·132+1·42+2·14+5·5+14·2+42·1+132·1 = 429
8 12 870
9
1430
1·149+1·132+2·42+5·14+14·5+42·2+132·1+429·1 = 1430
10.23 Pour une hauteur donnée h > 0, l’arbre binaire ayant le plus de nœuds est l’arbre binaire complet.
Le corollaire 10.2 établit que ce nombre est n = 2h + 1 – 1. Par conséquent, dans tout arbre binaire de hauteur h, le nombre n de nœuds doit être conforme à n 2h + 1 – 1. Toujours pour une hauteur h donnée, l’arbre binaire ayant le moins de nœuds est celui dans lequel chaque nœud interne a un seul enfant ; cet arbre linéaire a n = h + 1 nœuds parce que chaque nœud a très exactement un enfant, à l’exception de la feuille seule. Nous pouvons en déduire que, dans tout arbre binaire de hauteur h, le nombre n de nœuds doit être conforme à n ≥ h + 1. La deuxième paire d’inégalités suit la première en résolvant h. 10.24 Supposons que T soit un arbre parfait de hauteur h et de taille n. Supposons que T1 soit le sous-
arbre complet obtenu en supprimant le niveau le plus bas des feuilles de T, et que T2 soit le superarbre complet obtenu en remplissant le niveau le plus bas des feuilles de T. Ensuite, T1 a une hauteur h – 1 et T2 une hauteur h. D’après le corollaire 10.1, n1 = |T1| = 2h – 1 et n2 = |T2| = 2h + 1 – 1. Maintenant, n1 < n n2, donc 2h – 1 = n1 < n n2 = 2h + 1 – 1 et 2h = n1 + 1 n n2 < n2 + 1 = 2h + 1. Ainsi, h ≤ lg n < h + 1, et donc h = lg n. 10.25 La forêt créée par l’arbre binaire donné est obtenue en inversant la mappe naturelle de la façon
suivante : A
C
C
B
B D F
C
A
A
D
G
E
B
D
G
E G
F
F
10.26 f(h) = 1 10.27 f(h) = h 10.28 ✽ Théorème : chaque sous-arbre d’un arbre binaire complet est également complet.
E
Révision et entraînement
215
Démonstration : supposons que T soit un arbre de recherche binaire et que S soit un sous-arbre de T. Supposons également que x soit un élément de S et que L et R soient les sous-arbres gauche et droit de x dans S. Ensuite, étant donné que S est un sous-arbre de T, x est également un élément de T, et L et R sont les sous-arbres gauche et droit de x dans T. Donc, y ≤ x ≤ z pour chaque y ∈ L et chaque z ∈ R parce que T a la propriété des arbres binaires de recherche. Par conséquent, S a cette même propriété. 10.29 ✽ Théorème : chaque sous-arbre d’un arbre binaire parfait est également parfait. Démonstration : supposons que T soit un arbre de recherche binaire et que S soit un sous-arbre de T. Supposons également que x soit un élément de S et que L et R soient les sous-arbres gauche et droit de x dans S. Ensuite, étant donné que S est un sous-arbre de T, x est également un élément de T, et L et R sont les sous-arbres gauche et droit de x dans T. Donc, y ≤ x ≤ z pour chaque y ∈ L et chaque z ∈ R parce que T a la propriété des arbres binaires de recherche. Par conséquent, S a cette même propriété. 10.30 • public int leaves() • { if (this == null) return 0; • int leftLeaves = (left==null ? 0 : left.leaves()); • int rightLeaves = (right==null ? 0 : right.leaves()); • return leftLeaves + rightLeaves; •} 10.31 • public int height() • { if (this == null) return -1; • int leftHeight = (left==null ? -1 : left.height()); • int rightHeight = (right==null ? -1 : right.height()); • return 1 + (leftHeight
Chapitre 11
Arbres de recherche Les structures arborescentes sont utilisées pour stocker les données parce que leur organisation permet d’accéder plus efficacement à ces dernières. Quant aux arbres de recherche, ils gèrent les données qu’ils contiennent de façon ordonnée.
11.1 ARBRES DE RECHERCHE MULTIDIRECTIONNELS La définition récursive d’un arbre de recherche multidirectionnel est la suivante : ◆ Définition : Un arbre de recherche multidirectionnel d’ordre m est soit un ensemble vide, soit une paire (k, S) dans laquelle le premier composant est une séquence k = (k1, k2, …, kn – 1) de n – 1 clés et le deuxième composant une séquence S = (S0, S1, S2, …, Sn – 1) de n arbres de recherche multidirectionnels d’ordre m, avec 2 ≤ n ≤ m et s0 ≤ k1 ≤ s1 ≤ …≤ kn – 1 ≤ sn – 1 pour chaque si ∈ Si. Cette définition est similaire à celle de l’arbre générale que nous avons déjà vue dans la section 9.1. Un arbre de recherche multidirectionnel d’ordre m peut être considéré comme un arbre d’ordre m constitué de séquences de clés ayant la propriété d’ordre décrite ci-dessus.
Exemple 11.1 Arbre de recherche à 5 directions 57 72
27 33 38 47
13 19 23 25
39 45
59 60 70
48 50 55
61 64 65 67
77 85
73 75
78 81 82
87
218
Arbres de recherche
L’arbre multidirectionnel illustré ci-après est d’ordre m = 5. Il est composé de trois nœuds internes de degré 5 (chacun d’entre eux comportant quatre clés), de trois nœuds internes de degré 4 (chacun d’entre eux comportant 3 clés), de quatre nœuds internes de degré 3 (chacun d’entre eux comportant deux clés) et d’un nœud interne de degré 2 (comportant 1 clé). Le nœud racine se compose de deux clés et de trois enfants. Les quatre clés du premier enfant sont inférieures à k1 = 57 et les trois clés du deuxième enfant sont situées entre k1 = 57 et k2 = 72. Les deux clés du troisième enfant sont supérieures à k1 = 72. En fait, les 13 clés du premier sous-arbre sont inférieures à 57, les sept clés du deuxième sous-arbre sont situées entre 57 et 72 et les huit clés du troisième sous-arbre sont supérieures à 72. L’arbre multidirectionnel fait partie de la catégorie des arbres de recherche parce qu’il joue le rôle d’un index multiniveau permettant d’effectuer des recherches dans des listes de taille importante. Pour trouver une valeur de clé, commencez par la racine et descendez l’arbre jusqu’à ce que vous trouviez la clé ou que vous atteigniez une feuille. Effectuez une recherche binaire de la clé à chaque nœud. Si vous ne la trouvez pas dans un nœud, la recherche s’arrête entre deux valeurs de clés adjacentes (avec k0 = –∞ et kn = ∞). Vous devez alors suivre le lien reliant ces deux clés et le prochain nœud. Si vous atteignez une feuille, c’est que la clé ne se trouve pas dans l’arbre. Supposons que vous recherchiez la valeur de clé 66. Vous commencerez à la racine de l’arbre, puis vous suivrez le lien central (parce que 57 ≤ 66 < 72) jusqu’au nœud central composé de 3 clés. Vous suivrez ensuite le troisième lien (parce que 60 ≤ 66 < 70) jusqu’au nœud du bas composé de 4 clés. Vous suivrez ensuite le troisième lien (parce que 65 ≤ 66 < 67) jusqu’au nœud feuille. Vous serez alors en mesure de conclure que la clé 66 ne se trouve pas dans l’arbre. Pour insérer une clé dans un arbre de recherche multidirectionnel, vous devez d’abord appliquer l’algorithme de recherche. Si la recherche se termine au niveau d’un nœud feuille, les deux clés qui encadrent le nœud parent recherchent l’emplacement d’insertion de la nouvelle clé. C’est pourquoi vous devez insérer cette dernière dans le nœud interne, entre les deux clés qui l’encadrent. Si cette insertion porte à m le nombre de clés, c’est-à-dire si la limite de m – 1 clés par nœud est dépassée en raison de son ajout, fractionnez le nœud en deux parties après avoir remonté la clé centrale jusqu’à son nœud parent. Si ce déplacement porte à m le nombre de clés du nœud parent, répétez l’opération de fractionnement jusqu’à temps que vous atteigniez la racine si nécessaire. Le fractionnement de la racine crée une nouvelle racine, ce qui augmente la hauteur de l’arbre d’un niveau.
Exemple 11.2 Insérer des éléments dans un arbre à 5 directions Pour insérer 66 dans l’arbre de recherche de l’exemple 11.1, effectuez d’abord une recherche en suivant les étapes que nous venons de détailler. Vous atteindrez alors le nœud feuille marqué d’un X dans la figure suivante : 57 72
27 33 38 47
13 19 23 25
39 45
59 60 70
48 50 55
61 64 65 67 X
77 85
73 75
78 81 82
87
219
Arbres équilibrés ou arbres-B
Insérez la nouvelle clé 66 dans le dernier nœud parent entre les clés 65 et 67 qui l’encadrent, comme illustré ci-après : 57 72
27 33 38 47
13 19 23 25
39 45
59 60 70
48 50 55
61 64 65 66 67
77 85
73 75
78 81 82
87
Le nœud contient désormais 5 clés et dépasse donc la limite de 4 clés qui caractérise les arbres à 5 directions. Ce nœud est donc fractionné, ce qui fait remonter la clé centrale 65 jusqu’au nœud parent : 57 72
27 33 38 47
13 19 23 25
39 45
59 60 65 70
48 50 55
61 64
66 67
77 85
73 75
78 81 82
87
En général, le fractionnement d’un nœud n’est pas fréquent, en particulier si m est important. Par exemple, si m = 50, environ 2 % des nœuds dépasseront leur limite au cours des recherches. Cela signifie que, dans ce cas, un fractionnement du niveau inférieur se révélerait nécessaire uniquement pour 2 % des insertions. En outre, un fractionnement du deuxième niveau en partant du niveau inférieur (c’est-à-dire un fractionnement double) ne serait nécessaire que pour 2 % de 2 % des insertions, ce qui équivaut à une probabilité de 0,0004. Si nous poursuivons notre raisonnement, la probabilité d’un troisième fractionnement serait de 0,000008. Comme vous pouvez le constater, une racine a très peu de chances d’être fractionnée. Étant donné qu’il s’agit de la seule façon de faire croître l’arbre verticalement, celui-ci a tendance à rester très court et très large, ce qui réduit considérablement la durée des recherches.
11.2 ARBRES ÉQUILIBRÉS OU ARBRES-B Un arbre-B d’ordre m est en fait un arbre de recherche multidirectionnel qui présente les caractéristiques supplémentaires suivantes : 1. Sa racine est composée de deux enfants minimum. 2. Tous ses autres nœuds internes sont composés de m/2 enfants au minimum. 3. Tous les nœuds feuilles se trouvent au même niveau.
220
Arbres de recherche
Ces critères rendent l’arbre plus équilibré et, par conséquent, plus efficace. Ils simplifient également les algorithmes d’insertion et de suppression. Les arbres-B servent d’index aux ensembles de données de taille importante stockés sur disque. Dans une base de données relationnelle, les données sont organisées en séquences d’enregistrements distinctes qualifiées de tables. Chaque table est susceptible d’être stockée sous forme d’un fichier de données séquentiel dans lequel les enregistrements sont numérotés comme les éléments d’un tableau. D’autre part, le système de base de données a également la possibilité d’accéder aux enregistrements directement à l’aide de leur adresse sur le disque. Quelle que soit la solution choisie, chaque enregistrement est accessible directement sur le disque via une structure d’adressage. Il vous suffit donc d’avoir l’adresse de l’enregistrement sur le disque pour pouvoir y accéder directement (c’est-à-dire en lisant le disque une seule fois). Par conséquent, la « clé » qui est stockée dans un arbre-B est en fait une paire clé/adresse qui contient la valeur de clé réelle de l’enregistrement (par exemple, un numéro de sécurité sociale dans le cas d’enregistrements de données privées ou un ISBN dans le cas d’un livre), ainsi que son adresse sur le disque. Dans l’exemple suivant, seule la valeur de clé apparaîtra ; l’adresse sur le disque qui lui est normalement associée sera implicite.
Exemple 11.3 Arbre-B La figure suivante illustre le cas d’un arbre-B d’ordre 5. Chacun de ses nœuds internes est composé de 3, 4 ou 5 enfants et toutes ses feuilles se trouvent au niveau 3. 57 81
33 47
13 19 23 25
39 45
66 77
48 50 55
61 64 65
67 73 75
88 95
78 80
82 87
90 92
96 98
Algorithme 11.1 Rechercher un élément dans un arbre-B. Pour rechercher un enregistrement comportant une clé k à l’aide d’un arbre-B d’ordre m : 1. Si l’arbre est vide, renvoyez null. 2. Supposons que x soit la racine. 3. Répétez les étapes 4 à 6 jusqu’à ce que x soit un nœud feuille. 4. Appliquez la recherche binaire (algorithme 2.2) au nœud x pour la clé ki, avec ki – 1 < k ≤ ki (pour k0 = –∞ et km = ∞). 5. Si ki = k, procédez à l’extraction de l’enregistrement du disque et renvoyez-le. 6. Supposons que x soit la racine du sous-arbre Si. 7. Renvoyez null. Comme vous pouvez le constater, ce processus est similaire à la recherche d’un thème dans l’index d’un livre. Chaque page de l’index est associée à un mot ou à une lettre correspondant aux thèmes qu’elle contient. Ce mot ou cette lettre jouent le rôle des clés des nœuds internes dans un arbre de recherche. Quant au numéro de la page figurant en regard du thème, il joue le rôle de l’adresse du nom du fichier qui vous mènera aux données sur le disque. La dernière étape de notre processus consiste à rechercher la page dans le livre ou bien le fichier sur le disque. Nous pourrions pousser cette analogie encore plus loin si l’index du livre avait son propre index. En effet, chaque niveau interne d’un arbre multidirectionnel est similaire à un autre niveau d’index.
221
Arbres équilibrés ou arbres-B
Algorithme 11.2 Insérer des éléments dans un arbre-B Pour insérer un enregistrement de clé k à l’aide de l’index d’un arbre-B d’ordre m : 1. Si l’arbre est vide, créez un nœud racine composé de deux feuilles factices, insérez k à ce niveau et renvoyez true (afin d’indiquer que l’insertion est réussie). 2. Supposons que x soit la racine. 3. Répétez les étapes 4 à 6 jusqu’à ce que x soit un nœud feuille. 4. Appliquez l’algorithme 2.2 de recherche au nœud x pour la clé ki, avec ki – 1 < k ≤ ki (pour k0 = –∞ et km = ∞). 5. Si ki = k, renvoyez false (afin d’indiquer que l’insertion a échoué parce qu’un enregistrement avec la clé k existe déjà et que les clés doivent être uniques). 6. Supposons que x soit la racine du sous-arbre Si. 7. Ajoutez l’enregistrement sur le disque. 8. Insérez k (accompagnée de l’adresse de l’enregistrement sur le disque) dans x entre ki – 1 et ki. 9. Ajoutez un nœud feuille factice à x. 10. Si le degré (x) = m, répétez les étapes 11 à 13 jusqu’à ce que le degré (x) < m. 11. Supposons que kj soit la clé centrale du nœud x. 12. Supposons que u et v soient les moitiés droite et gauche de x après la suppression de kj de x. 13. Si x est la racine, créez un nouveau nœud racine comportant kj et les sous-arbres u et v. 14. Dans le cas contraire, insérez kj dans le nœud parent de x et attachez les sous-arbres u et v. 15. Renvoyez true.
kj
33 47
x 13 19 23 25 30
23
33 47
u
v
13 19
25 30
23 33 47
u
v
13 19
25 30
L’algorithme de suppression des arbres-B est similaire à leur algorithme d’insertion. Les trois algorithmes que nous venons d’étudier ont une durée d’exécution proportionnelle à la hauteur de l’arbre. D’après le corollaire 9.1, nous pouvons déduire que la hauteur de l’arbre est proportionnelle à logm(n). D’après le théorème A.6, nous pouvons déduire que cette hauteur est proportionnelle à lgn. Nous pouvons donc en déduire le théorème suivant : ✽ Théorème 11.1 : dans un arbre-B, la recherche, l’insertion et la suppression ont une durée d’exécution égale à O(lgn).
222
Arbres de recherche
11.3 ARBRES BINAIRES DE RECHERCHE Un arbre binaire de recherche est un arbre binaire dont les éléments comprennent un champ clé de type ordinal ayant la propriété suivante : si k est la valeur de clé d’un nœud, k ≥ x pour chaque clé x du sous-arbre situé à gauche du nœud, et k ≤ y pour chaque clé y du sous-arbre situé à droite du nœud. Grâce à cette propriété (qualifiée de propriété BST en anglais), le parcours symétrique d’un arbre binaire de recherche crée des éléments dans l’ordre croissant. Vous appliquerez cette propriété pour chaque insertion dans l’arbre.
K E
P
B A
G D
F
N
S Q
H
Algorithme 11.3 Insérer des éléments dans un arbre binaire de recherche Pour insérer un élément avec la valeur de clé k dans un arbre binaire de recherche, vous devez procéder de la façon suivante : 1. Si l’arbre est vide, insérez le nouvel élément à la racine. Le renvoyer. 2. p recherche la racine. 3. Si k est inférieure à la clé stockée au niveau indiqué par p et que le nœud signalé par p n’a pas d’enfant à gauche, insérez le nouvel élément comme enfant gauche de p. Le renvoyer. 4. Si k est inférieure à la clé stockée au niveau de p et que le nœud signalé par p a un enfant à gauche, p recherche son enfant gauche. Retournez ensuite à l’étape 3. 5. Si le nœud au niveau de p n’a pas d’enfant droit, insérez le nouvel élément comme enfant droit de p. Le renvoyer. 6. p recherche son enfant droit. Retournez ensuite à l’étape 3.
Exemple 11.4 Insérer des éléments dans un arbre binaire de recherche Appliquez l’algorithme 11.3 pour insérer un élément de clé M dans l’arbre binaire précédent. L’étape 1 lance l’itéK rateur p à la racine K. Étant donné que M est supérieur à K (c’est-à-dire que M suit K dans l’alphabet) et que le nœud E P p K a un enfant à droite, l’algorithme passe à l’étape 6 et B G N S réinitialise l’itérateur p au niveau du nœud P, puis retourne à l’étape 3. Ensuite, M étant inférieur à P (c’estA D F H M Q à-dire que M précède P dans l’alphabet) et le nœud P ayant un enfant à gauche, l’algorithme passe à l’étape 4 et réinitialise l’itérateur p au nœud N avant de retourner à l’étape 3. Puis, étant donné que M est également inférieur à N mais que le nœud N n’a pas d’enfant à gauche, l’algorithme passe à l’étape 5 et insère le nouvel élément comme enfant gauche du nœud N et le renvoie.
Exemple 11.5 Créer un arbre binaire de recherche La séquence d’arbres suivante illustre la création d’un arbre binaire de recherche en insérant la séquence d’entrée 44, 77, 55, 22, 99, 33, 88 :
Insérer 44
44
44
Insérer 77
22
44
Insérer 55
22
77
223
Caractéristiques des arbres binaires de recherche
44
Insérer 22
44
22
77
Insérer 99
22
77
55 44
Insérer 33
44
22
77
33
99
55
55
Insérer 88
22
99
77
33
55
99
88
Si un arbre binaire de recherche est équilibré, vos recherches seront plus efficaces. Comme dans le cas d’une recherche binaire, vous avez besoin de O(lg n) étapes pour trouver un élément dans un arbre binaire de recherche équilibré. Cependant, si vous ne spécifiez aucune autre restriction, vous risquez d’obtenir un arbre fortement déséquilibré, surtout lorsque les éléments sont insérés dans un ordre trié. Dans ce cas, l’arbre devient une liste linéaire et transforme l’algorithme de recherche en recherche séquentielle O(n).
Exemple 11.6 Arbre binaire de recherche déséquilibré Dans cet exemple, nous allons utiliser les mêmes données d’entrée que dans l’exemple 11.2, mais dans un ordre différent : 99, 22, 88, 33, 77, 55, 44. Nous obtenons alors l’arbre ci-contre :
99 22 88
11.4 CARACTÉRISTIQUES DES ARBRES BINAIRES DE RECHERCHE EN MATIÈRE DE PERFORMANCES Les fonctions insert() et search() commencent à la racine de l’arbre et descendent le long des feuilles en effectuant une comparaison à chaque niveau. Dans ces conditions, la durée d’exécution d’un algorithme est proportionnelle à h + 1, h correspondant à la hauteur de l’arbre. La fonction search() peut s’arrêter avant d’atteindre une feuille, mais elle ne peut pas effectuer plus de h + comparaisons.
33 77
55
44
✽ Théorème 11.2 : dans un arbre binaire de recherche de taille n, les fonctions insert() et search() ont besoin de O(lg n) comparaisons dans le meilleur des cas.
Démonstration : dans le meilleur des cas, l’arbre binaire est totalement équilibré et presque complet. C’est pourquoi, si l’on reprend le corollaire 10.2, h + 1 ≈ lg(n + 1) = O(lg n). ■ Corollaire 11.1 : Dans un arbre binaire de recherche de taille n, les fonctions insert() et search() ont besoin de O(n) comparaisons dans le pire des cas. Démonstration : Dans le pire des cas, l’arbre est linéaire et h + 1 = n = O(n). ✽ Théorème 11.3 : Dans un arbre binaire de recherche de taille n, les fonctions insert() et search() ont besoin de O(2lnn) ≈ O(1.39lg n) comparaisons dans la plupart des cas. La démonstration de ce résultat dépasse la portée de cet ouvrage.
224
Arbres de recherche
11.4 ARBRES AVL Le problème de déséquilibre illustré dans l’exemple 11.3 peut être évité en imposant certaines restrictions aux nœuds de l’arbre binaire de recherche. C’est là qu’intervient l’arbre AVL, un arbre binaire tel que les hauteurs des deux sous-arbres diffèrent de 1 au maximum. Dans le cadre des arbres AVL, l’algorithme d’insertion s’assure que chaque nœud est équilibré à l’aide d’un nombre d’équilibre défini comme la hauteur du sous-arbre de droite moins celle du sousarbre de gauche. Pour que l’arbre soit équilibré, chaque nombre d’équilibre doit être égal à 1, 0 ou –1. Ce type d’arbre a été nommé d’après ses inventeurs, Adelson-Velskii-Landis. La figure suivante illustre la différence entre un arbre AVL et un arbre non AVL. A
A
2
1
B
C
B
C
0
2
-1
1
D
E
F
G
D
E
F
G
0
0
0
1
1
0
-1
1
H
I
J
K
L
M
H
I
J
K
0
0
0
0
0
0
0
0
0
1
N
O
0
0
Ce n’est pas un arbre AVL
L 1
C’est un arbre AVL
Ainsi, l’arbre de gauche n’est pas un arbre AVL parce qu’il est déséquilibré au niveau du nœud C, son nombre d’équilibre étant égal à 2, soit une différence de profondeur qui ne se trouve pas dans la fourchette autorisée. Cet arbre est également déséquilibré au niveau du nœud G. En revanche, l’arbre de droite illustre parfaitement l’arbre AVL parce que chaque numéro d’équilibre est égal à –1, 0 ou 1. Les arbres AVL sont liés aux nombres de Fibonacci Fm (reportez-vous à la section A.10). ✽ Théorème 11.4 : si un arbre AVL a une taille n et une hauteur h, n ≥Fh + 2 – 1. Démonstration : supposons que T soit un arbre AVL de hauteur h et de taille n. Si h = –1, alors n = 0 (ce qui signifie que l’arbre est vide) et Fh + 2 – 1 = F(0) + 2 – 1 = F2 – 1 = 1 – 1 = 0 = n. Si h = 0, alors n = 1 (ce qui signifie que l’arbre est un singleton) et Fh + 2 – 1 = F(1) + 2 – 1 = F3 – 1 = 1 – 1 = 0 = n. Nous venons d’établir la base de notre démonstration récursive. Supposons maintenant que h > 1 et que, par hypothèse inductive, l’inégalité soit vraie pour tous les arbres AVL de hauteur < h. Supposons que T soit un arbre AVL de hauteur h et de taille n. Le nombre d’équilibre à la racine T doit alors être égal à –1, 0 ou 1. Supposons que TL et TR soient les sous-arbres gauche et droit de T, que hL et hR soient leurs hauteurs respectives et que nL et nR soient leurs tailles respectives. Il s’ensuit que soit hL = h –1, soit hR = h –1 et que hL ≥h – 2 et hR ≥h – 2. Ainsi, dans les deux cas, F hL + F hG ≥ F h – 1 + F h – 2 = F h Par conséquent, l’hypothèse inductive nous permet de déduire que n = 1 + n L + n R ≥ 1 + ( F hL + 2 – 1 ) + ( F hR + 2 – 1 ) ≥ F h + 2 – 1 ■ Corollaire 11.2 : la hauteur d’un arbre AVL est égale à O(1.44 lgn). Démonstration : d’après le corollaire A.3, Fh + 2 = Θ(φh + 2) = Ω(φh). Ainsi, n ≥ Fh + 2 – 1 = Ω(Fh + 2) = Ω(φh). Ainsi, φh = O(n), donc h = O(logφn) = O(1.44 lgn).
Classe AVLTree
225
■ Corollaire 11.3 : dans le cadre d’un arbre AVL, la durée d’exécution des recherches, des insertions et des suppressions est plus lente de 44 % au maximum par rapport à celle de ces mêmes opérations effectuées dans un arbre complètement équilibré.
11.6 CLASSE AVLTree Cette classe destinée aux arbres AVL étend la classe BinaryTree définie dans la section 10.8 : public class AVLTree extends BinaryTree { protected AVLTree left, right; protected int balance; protected java.util.Comparator comp; public AVLTree(java.util.Comparator comp){ this.comp = comp; } public AVLTree(Object root, java.util.Comparator comp) { this.root = root; this.comp = comp; } public boolean add(Object object) { AVLTree temp = attach(object); if (temp != this) { left = temp.left; right = temp.right; balance = temp.balance; } return true; } public AVLTree attach(Object object) { if (root == null) // l’arbre est vide { root = object; return this; } if (comp.compare(object,root)<0) // insérer dans le sous-arbre // gauche { if (left == null) { left = new AVLTree(object,comp); ++size; --balance; } else { int lb = left.balance; left = left.attach(object); if (left.balance != lb && left.balance != 0) --balance; } if (balance < -1) { if (left.balance > 0) left = left.rotateLeft(); return rotateRight(); } } else // insérer dans le sous-arbre droit { if (right == null) { right = new AVLTree(object,comp); ++size; ++balance; } else { int rb = right.balance;
226
Arbres de recherche
right = right.attach(object); if (right.balance != rb && right.balance != 0) ++balance; } if (balance > 1) { if (right.balance < 0) right = right.rotateRight(); return rotateLeft(); } } return this; } private AVLTree rotateRight() // reportez-vous à l’exercice 11.6 private AVLTree rotateLeft() { AVLTree x = this, y = right, z = y.left; x.right = z; y.left = x; int xb = x.balance, yb = y.balance; if (yb < 1) { --x.balance; y.balance = ( xb>0 ? yb-1 : xb+yb-2 ); } else if (yb < xb) { x.balance -= yb+1; --y.balance; } else y.balance = xb-2; return y; } }
Exemple 11.7 Créer un arbre AVL La figure suivante illustre l’insertion de G, M, T, D et P dans un arbre AVL vide : G
add(G)
G
add(M)
0
G
add(T)
X
2
1
M
M
0
1
T 0
M0
M -1
rotateLeft()
add(D)
G
T
G
T
0
0
-1
0
D 0
add(E)
D
M
M
-2
-2
G
T
-2
0
rotateLeft()
X
G
T
-2
0
E
X
1
-1
E
D
0
0
rotateRight()
M
M
1
0
E
T
0
0
add(P)
E
T
0
-1
D
G
D
G
0
0
0
00
P
227
Révision et entraînement
La première rotation se produit avec l’insertion de T. En effet, la racine est alors égale à 2, ce qui va à l’encontre de la propriété des arbres AVL. Avec la rotation à gauche autour de la racine x, M devient le parent de son parent précédent G. La rotation suivante a lieu après l’insertion de E. Cette rotation autour du parent D étend la partie G-D-E, mais laisse le nœud G déséquilibré puisqu’il est égal à –2. D’où la nécessité d’une autre rotation dans la direction opposée. Comme vous pouvez le constater, les rotations sont particulièrement efficaces : grâce à des modifications locales des références et des différences de profondeur, elles rétablissent un équilibre presque parfait pour l’arbre.
Exemple 11.8 Insérer d’autres éléments dans le même arbre AVL La figure suivante illustre le cas d’insertions supplémentaires dans le même arbre AVL. Nous allons donc intégrer W après U, V et Z. M
M
2
2
E
T
0
2
rotateRight()
E
T
0
2
G
P
V
D
G
P
V
0
0
1
0
0
0
2
0
U
Z
0
-1
X
X
U
W
0
1
W
Z
0
0
M 1
rotateLeft()
E
V
0
0
D
G
T
W
0
0
0
1
P
U
Z
0
0
0
Comme vous pouvez le constater, nous devons procéder à une double rotation au cours de laquelle un sous-arbre non trivial est déplacé. Le sous-arbre contenant U est décalé de son parent V vers son parent T. Vous remarquerez que la propriété des arbres binaires de recherche est conservée. Bien qu’il soit légèrement compliqué, l’algorithme d’insertion des arbres AVL est extrêmement efficace. Les rotations qui garantissent l’équilibre des arbres ne constituent que des modifications locales de certaines références et des nombres d’équilibre.
?
QUESTIONS DE RÉVISION
QUESTIONS DE RÉVISION
11.1
Quels sont les avantages et les inconvénients d’un arbre binaire de recherche ?
11.2
Quels sont les avantages et les inconvénients d’un arbre AVL ?
228
Arbres de recherche
¿
RÉPONSES
RÉPONSES
11.1
L’inconvénient d’un arbre binaire de recherche est qu’il est parfois complètement déséquilibré et transforme ainsi la recherche de cas en algorithme O(n). En revanche, il est très efficace pour les insertions et les suppressions.
11.2
Un arbre AVL présente l’avantage d’être toujours équilibré, ce qui garantit la rapidité d’exécution O(lg n) de l’algorithme binaire de recherche. Cependant, vous devrez effectuer des rotations complexes utilisées par les algorithmes d’insertion et de suppression nécessaires à l’équilibre de l’arbre.
?
EXERCICES D’ENTRAÎNEMENT
EXERCICES D’ENTRAÎNEMENT
11.1
Décrivez ce qui se passe lorsqu’un nouvel enregistrement comportant la clé 16 est inséré dans l’arbre à 5 directions décrit dans l’exemple 11.1.
11.2
Définissez deux autres méthodes d’organisation des 7 clés de l’exemple 11.5 qui vous permettront de créer le même arbre binaire de recherche.
11.3
Décrivez une méthode de tri des tableaux d’objets à l’aide d’un arbre binaire de recherche, puis déterminez la complexité de votre algorithme.
11.4
Parmi les arbres binaires suivants, lesquels sont des arbres binaires de recherche ? A
a.
C
B D H
E I
J
G M
L
N
B O
F
A
F C
D
G
K
I
N
G
N K
K
M
O
R
L O
N
O
H E
E
J
d.
C A
L
D
F K
c.
B
H
b.
Q M
P
S T
11.5
Vérifiez l’affirmation suivante du corollaire 11.2 : logφn = 1.44 lgn.
11.6
Implémentez la méthode rotateRight() dans la classe AVLTree.
11.7
Démontrez que chaque sous-arbre d’un arbre binaire est également un arbre binaire.
11.8
Démontrez que chaque sous-arbre AVL est également un arbre AVL.
11.9
Voici une liste d’abréviations postales des 10 premiers états américains à avoir ratifié leur constitution : DE, PA, NJ, GA, CT, MA, MD, SC, NH, VA. Créez un arbre AVL pour l’insertion de chacune de ces chaînes.
229
Révision et entraînement
¿
SOLUTIONS
SOLUTIONS
11.1
Pour insérer un nouvel enregistrement composé d’une clé 16 dans l’arbre, votre recherche initiale doit commencer par le premier nœud feuille, comme illustré dans la figure suivante : 57 72
27 33 38 47
13 16 19 23 25
39 45
59 60 70
48 50 55
61 64 65 67
77 85
73 75
78 81 82
87
Étant donné qu’il s’agit d’un arbre de recherche à 5 directions, ce premier nœud feuille a dépassé son nombre d’équilibre limite et a dû être fractionné en deux nœuds feuilles, ce qui a transféré la clé centrale 19 jusqu’au nœud parent : 57 72
19 27 33 38 47
13 16
23 25
39 45
59 60 70
48 50 55
61 64 65 67
77 85
73 75
78 81 82
87
Cependant, maintenant que le nœud parent a également dépassé la limite du nombre d’équilibre, il est fractionné, ce qui déplace sa clé centrale jusqu’à son nœud parent : 33 57 72
19 27
13 16
23 25
38 47
39 45
59 60 70
48 50 55
61 64 65 67
77 85
73 75
78 81 82
87
230 11.2
Arbres de recherche
Vous pourriez classer les 7 clés de l’exemple 11.5 de la façon suivante et obtenir le même arbre binaire de recherche que celui de cet exemple : a. 44, 22, 33, 77, 55, 99, 88 ; b. 44, 22, 77, 33, 55, 99, 88.
11.3
Un tableau d’objets peut être trié grâce à l’insertion d’objets dans un arbre binaire de recherche puis à l’utilisation d’un parcours infixe pour copier ces objets dans le tableau. La propriété des arbres binaires de recherche veut que le parcours infixe visite les éléments dans l’ordre. Ainsi, si vous avez recours à un arbre AVL, chaque insertion a une durée d’exécution égale à O(lgn), c’est pourquoi la création d’un arbre composé de n éléments sera exécutée en une durée de O(n lgn). Le parcours infixe qui en découle a également une complexité O(n lgn), donc l’algorithme complet trie le tableau en une durée égale à O(n lgn).
11.4
Tous ces arbres sont des arbres binaires de recherche, à l’exception de l’arbre a.
11.5
logφ n = (logφ2) (lgn) = (ln2 / lnφ) (lgn) = (0,693148/0,481212) (lgn) = (1,4404) (lgn) = 1,44 lgn
11.6
• private AVLTree rotateRight() • { AVLTree x = this; • AVLTree y = left; • AVLTree z = y.left; • x.left = z; • y.left = x; • int xb = x.balance; • int yb = y.balance; • if (yb > 1) • { ++x.balance; • y.balance = ( xb<0 ? yb+1 : xb+yb+2 ); • } • else if (yb > xb) • { x.balance += yb-1; • ++y.balance; • } • else y.balance = xb+2; • return y; •}
11.7
✽ Théorème : chaque sous-arbre d’un arbre binaire de recherche est lui-même un arbre binaire de recherche. Démonstration : supposons que T soit un arbre binaire de recherche et S un sous-arbre de T. x est un élément de S, et L et R sont respectivement les sous-arbres gauche et droit de x dans S. Étant donné que S est un sous-arbre de T, x est également un élément de T, et L et R sont les sous-arbres gauche et droit de x dans T. Donc, y ≤ x ≤ z lorsque y ∈ L et lorsque z ∈ R. Donc S a aussi la propriété de l’arbre binaire de recherche.
11.8
✽ Théorème : chaque sous-arbre d’un arbre AVL est lui-même un arbre AVL. Démonstration : nous venons de démontrer dans la solution précédente que chaque sous-arbre d’un arbre binaire de recherche est lui-même un arbre binaire de recherche. À cette démonstration s’ajoute le fait que si S est un sous-arbre d’un arbre AVL T, chaque nœud de S se trouve également dans T. Le nombre d’équilibre de chaque nœud est alors égal à –1, 0 ou 1.
11.9
La solution est illustrée par les arbres suivants :
231
Révision et entraînement
DE
DE
PA
DE 0
DE
NJ
1
2
PA
PA
NJ
0
-1
1
NJ
r_l
DE
rR
2
NJ
PA
0
0
NJ
GA
0
NJ
CT
-1
-1
DE
PA
DE
PA
DE
PA
0
0
1
0
0
0
NJ
MA
GA
CT
GA
0
0
0
NJ
rL
-2
GA
rR
-2
0
DE
PA
GA
PA
DE
NJ
1
0
-1
0
-1
0
CT
GA
DE
MA
CT
MA
PA
0
1
-1
0
0
0
0
MA
CT
0
0
GA
MD
GA
SC
1
1
DE
NJ
DE
NJ
-1
0
-1
0
CT
MA
PA
CT
MA
PA
0
1
0
0
1
1
MD
MD
SC
0
0
0
GA
NH
GA
rL
2
1
DE
NJ
DE
NJ
-1
-1
-1
0
CT
MA
PA
CT
MD
PA
0
2
1
0
0
1
MD
SC
MA
NH
SC
1
0
0
0
0
NH 0
GA
VA
GA
rL
2
1
DE
NJ
DE
NJ
-1
1
-1
0
CT
MD
PA
CT
MD
SC
0
0
2
0
0
0
MA
NH
SC
MA
NH
PA
VA
0
0
1
0
0
0
0
VA 0
Chapitre 12
Tas et files de priorité 12.1 TAS Un tas est un arbre binaire parfait dont les éléments ont des clés conformes à la propriété suivante des tas : les clés se trouvant le long du chemin racine-vers-feuille sont décroissantes. Par exemple, l’arbre ciaprès est un tas parce qu’il s’agit d’un arbre binaire parfait et que les éléments situés le long de ses chemins racine-vers-feuille sont décroissants : 77
77 77 77 77 77 77
66 66 66 66 55 55
44 44 60 60 33 55.
22 41 58 25 29
; ; ; ; ;
66
55
44 22
60 41
58
33 25
55
29
Remarquez que les tas pourraient contenir des arbres généalogiques puisque leur propriété signifie que chaque parent est plus vieux que son/ses enfant(s). Les tas permettent d’implémenter les files de priorité (voir la section 12.5) et l’algorithme de tri vertical (voir la section 13.8).
12.2 MAPPAGE NATUREL
0
1
2
3
4
5
6
7
8
9
10 11 12
77 66 55 44 60 33 55 22 41 58 25 29
Chaque arbre binaire parfait a un mappage naturel dans un tableau, comme nous l’avons déjà vu dans l’algorithme 10.1. Par exemple, le tas de la section 12.1 est mappé dans le tableau ci-dessus. Le mappage naturel est obtenu à partir du parcours en largeur de l’arbre. Dans le tableau final, le parent de l’élément situé à l’index i est à l’index i/2 et les enfants de cet élément sont aux index 2i et 2i + 1. Par exemple, l’élément 60 est à l’index i = 5, son parent est l’élément 66 situé à l’index i/2 = 2 et ses enfants sont les éléments 58 et 25 aux index 2i = 10 et 2i + 1 = 11. Le mappage naturel entre un arbre binaire parfait et 1 un tableau est une correspondance bidirectionnelle. Pour 2 3 remapper les éléments d’un tableau dans un arbre binaire parfait, il vous suffit de numéroter les nœuds de l’arbre consécutivement en suivant un parcours en largeur et en 4 5 6 7 commençant par le numéro 1 à la racine. Vous copiez 8 9 10 11 12 ensuite l’élément du tableau situé à l’index i dans le nœud numéroté i. Si l’arbre obtenu présente la propriété de tas, vous pouvez dire que le tableau a également cette propriété.
234
Tas et files de priorité
Exemple 12.1 Déterminer si un tableau présente la propriété de tas Pour déterminer si un tableau a la propriété de tas, nous 0 1 2 3 4 5 6 7 8 9 10 88 66 77 33 44 55 75 30 22 51 devons d’abord le mapper dans un arbre binaire, puis vérifier le chemin racine-vers-feuille. Le chemin {88, 66, 44, 51} n’est pas décroissant parce que 44 < 51. Cet arbre n’a donc pas la propriété de tas et, par conséquent, le tableau non plus. 88 Un tableau avec la propriété de tas est partiellement 66 77 ordonné. Cela signifie que la plupart des clés plus importantes sont placées avant les plus petites et, plus précisément, 33 55 75 44 que chaque sous-tableau tas-chemin est trié dans un ordre décroissant, le sous-tableau tas-chemin étant une sous30 22 51 séquence d’éléments du tableau dans lequel chaque numéro d’index correspond à la moitié de son successeur. Par exemple, le sous-tableau {a[1], a[2], a[5], a[11], a[22], a[45], a[90], a[180] } serait un sous-tableau du tableau a[] de 200 éléments. L’algorithme de tri vertical exploite cette caractéristique pour obtenir une méthode rapide et efficace de tri des tableaux.
12.3 INSÉRER DES ÉLÉMENTS DANS UN TAS Dans un tas, les éléments sont insérés à côté de la feuille la plus à droite du niveau le plus bas. La propriété de tas est ensuite restaurée en faisant remonter l’élément dans l’arbre jusqu’à ce qu’il soit plus « jeune » que son parent, c’est-à-dire que sa clé soit plus importante. À chaque itération, l’enfant est permuté avec son parent.
Exemple 12.2 Insérer un élément dans un tas La clé 75 serait insérée de la façon suivante dans le tas : 88
88
66 33 30
44 22
75
77
51
55
66
75
77
33 30
44
75
75
51
22
55
88 66
77
33
75 22
30
55
75
44
51
88 75
77
33 30
66 22
51
55 44
75
235
Supprimer un élément d’un tas
L’élément 75 est ajouté dans l’arbre comme la dernière nouvelle feuille. Il est ensuite permuté avec son élément parent 44 parce que 75 > 44. Puis, il est permuté avec son élément parent 66 parce que 75 > 66. La propriété de tas est maintenant rétablie puisque le nouvel élément 75 est inférieur à son parent et supérieur à ses enfants. Remarquez que l’insertion ne concerne que les nœuds situés le long d’un chemin racine-vers-feuille.
12.4 SUPPRIMER UN ÉLÉMENT D’UN TAS L’algorithme de suppression d’un élément du tas élimine toujours l’élément racine de l’arbre. Pour cela, le dernier élément feuille est déplacé dans l’élément racine, puis la propriété de tas est rétablie en faisant descendre le nouvel élément racine dans l’arbre jusqu’à ce qu’il soit plus « vieux » que ses enfants, c’està-dire que sa clé soit inférieure à celle de ses enfants.
Exemple 12.3 Supprimer un élément d’un tas L’élément racine (clé 88) serait supprimé du tas de la façon suivante : 88
44
75
77
33 30
66 22
51
55 44
75 75
77
33 30
66 22
55
75
51 77 75
44
33
66 22
30
55
75
51 77 75
75
33 30
66 22
55
44
51
La dernière feuille (clé 44) est supprimée et copiée dans la racine ; elle remplace la racine précédente (clé 88) qui est supprimée. Ensuite, pour rétablir la propriété de tas, l’élément 44 permute avec le plus grand de ses deux enfants (77). Cette étape est répétée jusqu’à ce que l’élément 44 ne soit plus inférieur à ses enfants. Dans le cas présent, 44 redevient finalement une feuille. Remarquez que la suppression touche uniquement les nœuds situés le long d’un seul chemin racinevers-feuille, ce qui nous donne le résultat suivant d’après le théorème 10.2 : ✽ Théorème 12.1 : les insertions dans un tas et les suppressions d’éléments de ce tas ont une durée d’exécution égale à O(lgn).
236
Tas et files de priorité
12.5 CLASSE PriorityQueue Une pile est un conteneur LIFO, c’est-à-dire que le dernier élément entré est le premier sorti. Une file est un conteneur FIFO, c’est-à-dire que le premier élément entré est le premier sorti. Une file de priorité est un conteneur BIFO, c’est-à-dire que l’élément avec la priorité la plus élevée est le premier à sortir. En effet, chaque élément porte un numéro de priorité dans le contexte des files de priorité. Ce type de file est largement utilisé dans les systèmes informatisés. Par exemple, si plusieurs ordinateurs sont connectés à une même imprimante sur un réseau local, les travaux d’impression qui sont dans une file d’attente sont généralement mis temporairement dans une file de priorité où les travaux les plus petits sont prioritaires. La plupart du temps, les files de priorité sont implémentées comme des tas puisque leur structure de données conserve toujours l’élément avec la clé la plus importante à la racine. En outre, les tas permettent d’insérer et de supprimer efficacement des éléments (reportez-vous à la section 12.7). Cependant, le framework de collections Java vous propose une autre structure arborescente qui se révélera presque aussi efficace, à savoir la classe java.util.TreeSet. Grâce à elle, vous pourrez aisément implémenter la classe PriorityQueue Java à l’aide de l’interface Comparator, comme démontré dans l’exemple suivant.
Exemple 12.4 Classe PriorityQueue La classe suivante est destinée aux files de priorité des objets : import java.util.*; public class PriorityQueue extends TreeSet { public PriorityQueue() { super(); } public PriorityQueue(Comparator comparator) { super(comparator); } public void push(Object object) { add(object); } public Object top() { return first(); } public Object pop() { Object object = first(); remove(object); return object; } }
Cette sous-classe exploite la fonctionnalité de la classe TreeSet afin d’insérer et de supprimer des éléments efficacement en O(lgn).
Interface Java Comparator
237
12.6 INTERFACE JAVA Comparator Une file de priorité doit être capable de comparer ses éléments en fonction de la priorité qui leur a été affectée. C’est le rôle du framework de collections Java qui utilise pour cela un objet Comparator.
Exemple 12.5 Classe Person et ses propres classes Comparator La classe Person implémentée ci-après contient des individus, leur date de naissance (yob, correspondant à l’anglais year of birth) et leur date de décès (yod correspondant à l’anglais year of death). Sa méthode toString() imprimera ces informations au format suivant : Pascal(1623-1662)
Cette classe est composée de deux constructeurs car vous ne connaîtrez pas toujours la date de décès d’un individu qui peut d’ailleurs être encore en vie. public class Person { private String name; private int yob, yod; public Person(String name, int yob) { this.name = name; this.yob = yob; } public Person(String name, int yob, int yod) { this(name,yob); this.yod = yod; } public int getYob() { return yob; } public int getYod() { return yod; } public String toString() { return name + "(" + yob + (yod>0?"-"+yod:"") + ")"; } }
Vous trouverez ci-après l’exemple d’une classe Comparator destinée à comparer les instances de la classe Person en fonction des dates de naissance. class YobComparator implements java.util.Comparator { public int compare(Object o1, Object o2) { if (o1==null || !(o1 instanceof schaums.dswj.Person)) return -1; Person p1 = (Person)o1; if (o2==null || !(o2 instanceof Person)) return 1; Person p2 = (Person)o2; if (p1.getYob() < p2.getYob()) return -1; if (p1.getYob() > p2.getYob()) return 1; return 0; } }
238
Tas et files de priorité
Une instance de cette classe renvoie –1 si la date de naissance de la première personne est antérieure à celle de la seconde. Elle renvoie 1 dans le cas contraire. Il s’agit du protocole généralement utilisé dans les classes Comparator : un nombre négatif signifie « inférieur à », 0 signifie « égal à » et un nombre positif signifie « supérieur à ». Par ailleurs, comme vous avez pu le constater, l’interface Comparator requiert la signature suivante : public int compare(Object o1, Object o2)
C’est pourquoi toute implémentation doit vérifier la classe intrinsèque à laquelle appartient chaque argument grâce à l’opérateur instanceof. En outre, les arguments doivent être transtypés en objets Person de façon à pouvoir accéder aux champs yob.
Exemple 12.6 Tester la classe PriorityQueue contenant un comparateur public class Ex1206 { static public void main(String[] args) { PriorityQueue yobQueue = new PriorityQueue(new YobComparator()); yobQueue.push(new Person("Barrow",1630)); yobQueue.push(new Person("Fermat",1601)); yobQueue.push(new Person("Pascal",1623)); yobQueue.push(new Person("Wallis",1616)); System.out.println(yobQueue); while (!(yobQueue.isEmpty())) System.out.println("pop()=" + yobQueue.pop() + ": " + yobQueue); } } [Fermat(1601), Wallis(1616), Pascal(1623), Barrow(1630)] pop()=Fermat(1601): [Wallis(1616), Pascal(1623), Barrow(1630)] pop()=Wallis(1616): [Pascal(1623), Barrow(1630)] pop()=Pascal(1623): [Barrow(1630)] pop()=Barrow(1630): []
Ce programme instancie l’objet yobQueue de PriorityQueue en lui associant un YobComparator. Le constructeur TreeSet qui prend un argument Comparator gère cette opération de liaison. Ensuite, la méthode add() implémentée par la classe TreeSet est utilisée afin d’ajouter quatre objets Person dans la file de priorité. Puis, le programme teste la méthode pop() à plusieurs reprises jusqu’à ce que la file de priorité soit vide. À chaque itération, il imprime tout le contenu de cette file à l’aide de la méthode héritée TreeSet.toString() qui appelle la méthode Person.toString(). Il est possible d’utiliser la même classe PriorityQueue avec différents comparateurs. Pour cela, il vous suffit de passer un autre objet comparateur à son constructeur.
Exemple 12.7 Tester la classe PriorityQueue avec un autre comparateur public class Ex1207 { static public void main(String[] args) { PriorityQueue yodQueue = new PriorityQueue(new YodComparator()); yodQueue.add(new Person("Barrow",1630,1677)); yodQueue.add(new Person("Fermat",1601,1665)); yodQueue.add(new Person("Pascal",1623,1662)); yodQueue.add(new Person("Wallis",1616,1703)); System.out.println(yodQueue); } }
Implémentation directe
239
class YodComparator implements java.util.Comparator { public int compare(Object o1, Object o2) { if (o1==null || !(o1 instanceof Person)) return -1; Person p1 = (Person)o1; if (o2==null || !(o2 instanceof Person)) return 1; Person p2 = (Person)o2; if (p1.yodp2.yod) return 1; return 0; } [Pascal(1623-1662), Fermat(1601-1665), Barrow(1630-1677), ➥ Wallis(1616-1703)]
La classe PriorityQueue a recours aux méthodes de la classe TreeSet pour conserver l’équilibre des éléments d’un arbre binaire de recherche. La méthode TreeSet.toString() dont elle hérite renvoie une représentation chaînée des éléments dans un ordre croissant déterminé par le comparateur qui lui est associé. Le comparateur de l’exemple 12.6 utilise le champ yob, c’est pourquoi Wallis(1616) s’affiche avant Pascal(1623). En revanche, le comparateur de l’exemple 12.7 utilise le champ yod, c’est pourquoi Wallis(1616-1703) s’affiche après Pascal(1623-1662).
12.7 IMPLÉMENTATION DIRECTE L’implémentation de la classe PriorityQueue illustrée dans la section 12.5 a recours au framework de collections Java ; c’est pourquoi les instances de cette classe doivent stocker des objets. Dans l’exemple suivant, vous allez pouvoir vous familiariser avec une implémentation des types primitifs.
Exemple 12.8 Interface IntPriorityQueueInterface Dans cet exemple, vous constaterez que cinq méthodes sont nécessaires à l’implémentation d’une file de priorité pour des entiers : public interface IntPriorityQueueInterface { public int top(); public int pop(); public void push(int x); public int size(); public String toString(); }
Exemple 12.9 Classe IntPriorityQueue Cette classe utilise un tas pour implémenter l’interface définie dans l’exemple 12.8 : public class IntPriorityQueue implements IntPriorityQueueInterface { private final int CAPACITY=16; private int capacity, size; private int[] ints; public IntPriorityQueue() { capacity = CAPACITY; ints = new int[capacity+1]; }
240
Tas et files de priorité
public IntPriorityQueue(int capacity) { this.capacity = capacity; ints = new int[capacity+1]; } public int top() { return ints[1]; } public int pop() { int x = ints[1]; ints[1] = ints[size--]; heapifyDown(); return x; } public void push(int x) { if (size==capacity) resize(); ints[++size] = x; heapifyUp(); } public int size() { return size; } public String toString() { if (size==0) return "[]"; String string = "[" + ints[1]; for (int i=2; i<=size; i++) string += "," + ints[i]; return string + "]"; } private void heapifyUp() { for (int j=size, i; j>0; j = i) { i = j/2; if (ints[j]ints[j+1]) ++j; if (ints[j]
Implémentation directe
241
private void swap(int[] a, int i, int j) { int tmp=a[i]; a[i] = a[j]; a[j] = tmp; } }
Cette implémentation utilise un tableau pour stocker la file de priorité sous forme d’un tas. Le champ size stocke le nombre d’éléments présents dans cette file dans ints[1..size];. Cela signifie que l’implémentation utilise une indexation basée sur 1 et non sur 0 afin de simplifier les opérations de tas. Il s’agit donc d’un mappage naturel (reportez-vous à l’algorithme 10.1 et à la section 12.2). Afin de faciliter la croissance du tableau, cette implémentation a recours au protocole de doublement de capacité, ce qui signifie qu’elle utilise un champ séparé, nommé capacity, pour déterminer la taille réelle du tableau ints[]. Étant donné que nous utilisons une indexation de base 1, la taille réelle sera toujours égale à capacity+1. Le champ capacity est initialisé à l’aide de la capacité constante qui est égale à 16 dans le cas présent, mais pourrait être représentée par n’importe quel entier positif. Ainsi, le tableau ints[] commence à la taille 17, ce qui signifie que vous pouvez insérer jusqu’à 16 éléments dans la file de priorité. Si la méthode push() est appelée lorsque size == capacity, la méthode private resize() est appelée afin de doubler la capacité du tas. L’implémentation de push() est conforme à l’algorithme de la section 12.3 et celle de pop() est conforme à l’algorithme de la section 12.4. Ces deux implémentations utilisent respectivement les méthodes heapifyUp() et heapifyDown().
Exemple 12.10 Tester la classe IntPriorityQueue Ce programme teste la classe IntPriorityQueue implémentée dans l’exemple 12.9 : public class Ex1210 { public static void main(String[] args) { IntPriorityQueue queue = new IntPriorityQueue(); queue.push(50); queue.push(95); queue.push(70); queue.push(30); queue.push(90); queue.push(25); queue.push(55); queue.push(35); queue.push(80); queue.push(60); queue.push(40); queue.push(20); queue.push(10); queue.push(75); queue.push(45); queue.push(65); System.out.println(queue.size() + ": " + queue); queue.push(85); System.out.println(queue.size() + ": " + queue); queue.pop(); System.out.println(queue.size() + ": " + queue); while(queue.size()>0) System.out.println("queue.pop() = " + queue.pop()); System.out.println(queue.size() + ": " + queue); } }
242
Tas et files de priorité
16: [10,35,20,50,40,25,45,65,80,90,60,70,30,75,55,95] 17: [10,35,20,50,40,25,45,65,80,90,60,70,30,75,55,95,85] 16: [20,35,25,50,40,30,45,65,80,90,60,70,85,75,55,95] queue.pop() = 20 queue.pop() = 25 queue.pop() = 30 queue.pop() = 35 queue.pop() = 40 queue.pop() = 45 queue.pop() = 50 queue.pop() = 55 queue.pop() = 60 queue.pop() = 65 queue.pop() = 70 queue.pop() = 75 queue.pop() = 80 queue.pop() = 85 queue.pop() = 90 queue.pop() = 95 0: []
Après avoir instancié queue comme objet IntPriorityQueue, ce programme appelle sa méthode push() afin d’insérer 17 éléments dans la file de priorité. Puis, la méthode println() appelle la méthode toString() de IntPriorityQueue afin d’imprimer les éléments en fonction de leur ordre de stockage dans le tableau ints[]. Par exemple, la première ligne affichée 16: [10,35,20,50,40,25,45,65,80,90,60,70,30,75,55,95]
indique que la file est composée de 16 éléments, puis elle imprime ints[1..16]. Nous pouvons en déduire que la propriété de tas peut être vérifiée. Par exemple, le chemin racine-vers-feuille ints[1,2,4,8,16] correspond à la séquence croissante [10,35,50,65,95], et le chemin racine-vers-feuille ints[1,3,6,13] correspond à la séquence croissante [10,20,25,70] (reportez-vous à l’exercice d’entraînement 12.7 si vous souhaitez consulter une trace complète de cet exemple). Quant à la deuxième ligne affichée, elle indique que la méthode private resize() fonctionne parfaitement. En effet, le dernier appel de la méthode push() queue.push(85);
essaie d’insérer un élément alors que le tableau ints[] est plein (size == capacity). C’est pourquoi push() appelle resize() afin de doubler la capacité du tableau ints[] avant l’insertion. L’opération suivante appelle pop() dans le but de supprimer un élément de la file de priorité. Elle supprime l’élément ayant la priorité la plus élevée (l’entier le plus petit), à savoir l’élément 10. Étant donné que la file de priorité est implémentée sous forme d’un tas, l’élément le plus petit se trouve à la racine de ce dernier, c’est-à-dire à l’index 1 du tableau ints[]. L’algorithme de suppression (reportez-vous à la section 12.4) déplace le dernier élément dans la racine, puis il effectue une opération heapifyDown() afin de rétablir la propriété de tas. L’élément 85 se trouve alors poussé en bas du tas. C’est pourquoi le chemin racine-vers-feuille ints[1,3,6,13] n’est plus égal à [85,20,25,30], mais à [20,25,30,85]. En dernier lieu, une boucle while permet de supprimer tous les autres éléments de la file de priorité en appelant pop() 16 fois. Vous remarquerez que les éléments sont dépilés dans un ordre croissant : 20, 25, 30, 35, …, 95.
Révision et entraînement
243
La sortie de l’exemple 12.10 vous suggère une méthode efficace de tri d’une séquence consistant à pousser les données dans une file de priorité, puis à les dépiler. Si la file de priorité est implémentée à l’aide d’un tas, chaque appel de pop() est exécuté en O(lgn). Ainsi, le dépilement des n éléments est exécuté en O(n lgn). Les n insertions sont exécutées en O(n), ce qui signifie que tout le processus de tri est exécuté en O(n lgn). Il s’agit de l’algorithme de tri vertical que nous aborderons plus en détail dans la section 13.8.
?
QUESTIONS DE RÉVISION
QUESTIONS DE RÉVISION
12.1
Quelles sont les deux applications principales des tas ?
12.2
Quelle est la durée d’exécution des opérations d’insertion d’éléments dans un tas et de leur suppression ?
12.3
Pourquoi une file de priorité est-elle qualifiée de conteneur BIFO ?
12.4
Quelle est la différence entre une file et une file de priorité ?
12.5
Pourquoi les tas sont-ils utilisés lors de l’implémentation des files de priorité ?
12.6
Au cours du mappage naturel d’un arbre binaire dans un tableau a[], pourquoi devez-vous commencer à a[1] et non à a[0] ?
12.7
L’implémentation de la classe IntPriorityQueue reconstruit le tableau de stockage en doublant sa capacité lorsque la méthode push() est appelée et que le tableau est plein. Serait-il par conséquent possible de reconstruire le tableau en divisant sa capacité en deux lorsque la méthode pop() est appelée et que le tableau est seulement à moitié plein ?
¿
RÉPONSES
RÉPONSES
12.1
Les tas sont utilisés pour implémenter les files de priorité et le tri vertical (voir la section 13.8).
12.2
Les insertions et les suppressions effectuées dans le cadre d’un tas sont excessivement efficaces puisqu’elles ont une durée d’exécution égale à O(lgn).
12.3
Une file de priorité est un conteneur BIFO (Best-In-First-Out, première priorité, premier élément sorti), c’est-à-dire que l’élément avec la priorité la plus élevée est le premier à sortir.
12.4
Les éléments sont retirés de la file dans l’ordre de leur insertion, c’est-à-dire premier entré, premier sorti. En revanche, les éléments d’une file de priorité doivent avoir un champ clé ordinal qui déterminera la priorité en fonction de laquelle ils seront supprimés.
12.5
Les tas sont utilisés lors de l’implémentation des files de priorité parce qu’ils permettent O(lg n) insertions et suppressions. En effet, les fonctions push() et pop() sont implémentées selon un parcours du chemin racine-vers-feuille via le tas. Ces chemins ne peuvent pas être plus longs que la hauteur de l’arbre, c’est-à-dire lg n au maximum.
12.6
Le mappage naturel commence à a[1] et non à a[0] afin de faciliter le parcours ascendant et descendant du tas (de l’arbre). Si vous attribuez le numéro 1 à la racine et que vous poursuivez séquentiellement par un parcours en largeur, le numéro de n’importe quel nœud parent k sera égal à k/2 et les numéros de ses enfants seront égaux à 2k et 2k + 1.
244
Tas et files de priorité
12.7
La reconstruction du tableau de IntPriorityQueue lorsque la méthode push() est appelée est nécessaire. En revanche, diviser par deux la capacité d’un tableau à moitié plein lorsque la méthode pop() est appelée n’est pas indispensable. Cette opération vous fera parfois économiser de l’espace de stockage, mais vous risquez surtout de faire augmenter sa durée d’exécution et sa complexité.
?
EXERCICES D’ENTRAÎNEMENT
EXERCICES D’ENTRAÎNEMENT
12.1
Parmi les arbres binaires suivants, lesquels sont des tas ? a.
b.
88 66
33
44 55
d.
77
33
55
55
33
33 22
f. 88 55
55
88
66
99
66 33
44
22
88
44
77
66
77 33
44
22
Parmi les tableaux suivants, lesquels ont la propriété de tas ? a.
0
1
2
3
4
5
6
7
b.
0
88 66 44 33 55 77 33
d.
0
1
2
3
4
88 77 55 44
12.3
44
77 33
55
66
e. 66
44
12.2
77
88 77
c.
88
5
6
7
33 22
1
2
3
4
5
6
7
c.
0
88 77 66 55 44 33 22
e.
0
1
2
3
4
5
6
7
88 66 77 22 33 44 55
1
2
3
4
5
6
7
88 44 77 22 33 55 66
f.
0
1
2
3
4
5
6
7
88 77 22 33 44 55 66
Dessinez les tas après insertion de ces clés dans l’ordre suivant : 44, 66, 33, 88, 77, 55, 22
12.4 12.5 12.6
Dessinez les tableaux résultant du mappage naturel de chaque tas obtenu dans l’exercice 12.3. Démontrez que chaque sous-arbre d’un tas est un tas. Ajoutez la méthode suivante dans la classe IntPriorityQueue de l’exemple 12.9, puis testezla : • public boolean isHeap() • // renvoie true si et seulement si ints[] • // répond à la propriété de tas
12.7 12.8
Tracez l’exécution du programme de l’exemple 12.10 en illustrant le tas comme arbre binaire et comme tableau après chaque modification. Révisez la simulation d’une structure client/serveur de l’exemple 7.7 en remplaçant la file d’attente simple par une file de priorité. Définissez une classe Comparator pour la classe Client de façon à ce que les travaux d’impression les plus courts aient la priorité la plus élevée.
245
Révision et entraînement
¿
SOLUTIONS
SOLUTIONS
12.1
a. Cet arbre n’est pas un tas parce que le chemin racine-vers-feuille {88, 44, 77} n’est pas décroissant (44 < 77). b. Cet arbre est un tas. c. Cet arbre n’est pas un tas parce que le chemin racine-vers-feuille {55, 33, 44} n’est pas décroissant (33 < 44) et que le chemin racine-vers-feuille {55, 77, 88} n’est pas décroissant (55 < 77 < 88). d. Cet arbre n’est pas un tas parce qu’il n’est pas parfait. e. Cet arbre est un tas. f. Cet arbre n’est pas un tas parce qu’il n’est pas binaire.
12.2
a. Ce tableau n’a pas la propriété de tas parce que le chemin racine-vers-feuille {a[1], a[3], a[6]} = {88, 44, 77} n’est pas décroissant (44 < 77). b. Ce tableau a la propriété de tas. c. Ce tableau a la propriété de tas. d. Ce tableau n’a pas la propriété de tas parce que ses éléments de données ne sont pas contigus ; il ne représente pas un arbre binaire parfait. e. Ce tableau a la propriété de tas. f. Ce tableau n’a pas la propriété de tas parce que le chemin racine-vers-feuille {a[1], a[3], a[6]} = {88, 22, 55} n’est pas décroissant (22 < 55) et que le chemin racine-vers-feuille {a[1], a[3], a[7]} = {88, 22, 66} n’est pas décroissant (22 < 66).
12.3
L’insertion des clés 44, 66, 33, 88, 77, 55, 22 dans un tas se présente de la façon suivante :
44
44
44
66
66
66
44
44
33
66
44
33
77 44
77
33
44
88
77
55 33
22
77 44
55 66
77
55
66
88
66
88
88
66 44
33
44
88
44
88
88
33
88
77
33
44
66
66 88
66
33
33
22
33 66
55
246 12.4
Tas et files de priorité
Les tableaux des tas de l’exercice 12.3 sont les suivants : 0
44
1
0
66
44
0
33
1
2
3
0
66 44 33
0
1
2
3
88
0
12.5
1
2
3
4
1
0
77
5
2
0
2
1
2
3
4
0
6
0
1
2
2
3
4
3
4
1
2
3
4
66 88 33 44
5
0
88 66 33 44 77
88 77 33 44 66 55
1
66 44
66 44 33 88
4
88 66 33 44
55
1
44 66
5
1
2
3
4
5
88 77 33 44 66
6
88 77 55 44 66 33
0
22
1
2
3
4
5
6
7
88 77 55 44 66 33 22
✽ Théorème : chaque sous-arbre d’un tas est également un tas. T S
q
x p
Démonstration : supposons que T soit un tas et que S soit un sous-arbre de T. Par définition, T est un arbre binaire parfait avec la propriété de tas. Donc, d’après le théorème de l’exercice 10.21, S est également un arbre binaire parfait. Supposons que x soit la racine de S et que p soit un chemin racine-vers-feuille dans S. Ensuite, x est un élément de T puisque S est un sousarbre de T, et il existe un chemin unique q dans T qui va de x à la racine de T. En outre, p est un chemin de T qui connecte x à une feuille de T puisque S est un sous-arbre de T. Supposons que q–1 représente l’inverse du chemin q et que q–1p représente la concaténation de q–1 et de p dans T. Ensuite, q–1p est un chemin racine-vers-feuille dans T. Nous pouvons en déduire que les éléments situés sur le chemin q–1p doivent être décroissants puisque T a la propriété de tas. Par conséquent, les éléments le long de p sont décroissants. S a donc la propriété de tas. 12.6
• public boolean isHeap() • { for (int leaf=size/2+1; leaf<=size; leaf++) • for (int j=leaf, i; j>0; j = i) • { i = j/2; • if (ints[j]
247
Révision et entraînement
12.7
La trace de l’exemple 12.10 est la suivante : 50
30
1
1
95
70
2
50
70
2
3
3
30
95
90
25
4
4
5
6
25
25
1
1
50
30
35
30
2
3
2
3
95
90
70
55
4
5
6
7
50 4
35
95
80
60
8
8
9
10
90
70
5
6
25
25
1
1
55 7
35
30
35
30
2
3
2
3
50
60
70
55
50
40
70
55
4
5
6
7
4
5
6
7
95 8
80 9
90
40
10
10
95
80
90
60
20
8
9
10
13
14
20
10
1
1
35
25
35
20
2
3
2
3
50
40
30
55
50
40
25
55
4
5
6
7
4
5
6
7
95
80
90
60
70
10
95
80
90
60
70
30
75
45
8
9
10
13
14
13
8
9
10
13
14
13
10
13
10
10
1
1
35
20
35
20
2
3
2
3
50
40
25
45
50
40
25
45
4
5
6
7
4
5
6
7
95
80
90
60
70
30
75
55
65
80
90
60
70
30
75
55
8
9
10
13
14
13
10
13
8
9
10
11
12
13
14
15
65
95
85
16
16
17
85
20
1
1
35
20
35
25
2
3
2
3
50
40
25
45
50
40
30
45
4
5
6
7
4
5
6
7
65
80
90
60
70
30
75
55
65
80
90
60
70
85
75
55
8
9
10
11
12
13
14
15
8
9
10
11
12
13
14
15
95
85
16
17
95 16
248 12.8
Tas et files de priorité
Le programme révisé de simulation d’une structure client/serveur est le suivant : • public class Pr1208 • { private static final int NUMBER_OF_SERVERS = 4; • private static final double MEAN_INTERARRIVAL_TIME = 5.0; • private static final int DURATION = 200; • private static Server[] servers = new Server[NUMBER_OF_SERVERS]; • private static PriorityQueue clients • = new PriorityQueue(new ClientComparator()); • • private static Random random • = new Random(MEAN_INTERARRIVAL_TIME); • • public static void main(String[] args) • { for (int i=0; i
Révision et entraînement
249
• timeServiceEnds = time + serviceTime; • System.out.println(time + "\t" + id + " commence à servir " • + client); • } • • public void endServing(int time) • { client.endService(time); • System.out.println(time + "\t" + id + " finit de servir " • + client); • this.client = null; • } • • public int getTimeServiceEnds() • { return timeServiceEnds; • } • • public boolean isFree() • { return client == null; • } • • public String toString() • { int percentMeanServiceRate • = (int)Math.round(100*meanServiceRate); • int percentServiceRate = (int)Math.round(100*serviceRate); • return id + "(" + percentMeanServiceRate + "%," • + percentServiceRate + "%)"; • } •} • • public class ClientComparator implements java.util.Comparator • { public int compare(Object o1, Object o2) • { if (o1==null || !(o1 instanceof schaums.dswj.Client)) • return -1; • Client c1 = (Client)o1; • if (o2==null || !(o2 instanceof Client)) return 1; • Client c2 = (Client)o2; • if (c1.getJobSize() < c2.getJobSize()) return -1; • if (c1.getJobSize() > c2.getJobSize()) return 1; • return 0; • } •} • • public class Client • { public static final int MEAN_JOB_SIZE = 20; • private static Random randomJobSize = new Random(MEAN_JOB_SIZE); • private static int nextId = 0; • private int id, jobSize, tArrived, tBegan, tEnded; • private Server server; • • public Client(int time) • { id = ++nextId; • jobSize = randomJobSize.intNextExponential(); • tArrived = time; • System.out.println(time + "\t" + this + " arrive"); • } • • public double getJobSize() • { return jobSize; • } •
250
Tas et files de priorité • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • •}
public int getWaitTime() { return tBegan - tArrived; } public int getServiceTime() { return tEnded - tBegan; } public void beginService(Server server, int time) { this.server = server; tBegan = time; } public void endService(int time) { tEnded = time; server = null; } public String toString() { return "#" + id + "(" + (int)Math.round(jobSize) + ")"; } private static void print(int job, int time, double size) { System.out.println("Le travail #" + job + " arrive au temps " + time + " avec " + (int)Math.round(size) + " pages."); } private static void printBegins(Server server, int job, int time) { System.out.println("L’imprimante " + server + " commence le travail #" + job + " au temps " + time + "."); } private static void printEnds(Server server, int job, int time) { System.out.println("L’imprimante " + server + " termine le travail #" + job + " au temps " + time + "."); }
Chapitre 13
Algorithmes de tri Dans le chapitre 2, nous avons vu que toute recherche dans un tableau est beaucoup plus efficace si les éléments ont été triés, ce qui semblera évident à toute personne ayant déjà cherché un numéro de téléphone dans un annuaire ou un mot dans un dictionnaire. Ce chapitre résume les 10 algorithmes les plus couramment utilisés afin de trier une structure de données linéaire telle qu’un tableau ou une liste. Lorsqu’il s’agit de trier un tableau d’entiers, tous les algorithmes de tri sont implémentés à l’aide de la même signature Java : public static void sort(int[] a)
Cette signature peut aisément être modifiée pour trier un tableau d’un autre type primitif ou bien un tableau d’objets implémentant l’interface Comparable. Les exercices figurant en fin de chapitre vous permettront de vous familiariser avec les processus de tri des listes et d’autres structures. Le pseudo-code et le code Java comprennent des conditions préalables, des conditions postérieures et des invariants de boucle permettant de prouver que les algorithmes sont corrects et d’analyser leur complexité. Les tris par permutation ont recours à la méthode générale suivante : private static void swap(int[] a, int i, int j) { // CONDITIONS PREALABLES : 0 <= i < a.length; 0 <= j < a.length; // CONDITION POSTERIEURE : a[i] et a[j] sont permutés; if (i == j) return; int temp=a[j]; a[j] = a[i]; a[i] = temp; }
Ce méthode ne fait qu’intervertir les i-ième et j-ième éléments du tableau. Dans le pseudo-code, nous utiliserons la notation s = {s0, s1, …, sn – 1} pour une séquence de n éléments. La notation {sp… sq} correspond à la sous-séquence {sp, sp + 1, …, sq} d’éléments de sp à sq. Dans les commentaires de code en Java, nous représenterons cette sous-séquence de la façon suivante : s[p..q]. Par exemple, {s3…s7} et s[3..7] correspondent à la sous-séquence {s3, s4, s5, s6, s7}. Sauf indication contraire, le terme « trié » fera systématiquement référence aux éléments de la séquence organisés selon un ordre croissant : s0 ≤ s1 ≤ s2 ≤ … ≤ sn – 1.
252
Algorithmes de tri
13.1 MÉTHODE JAVA Arrays.sort() La bibliothèque de classes standard Java définit une méthode sort() dans la classe java.util .Arrays. Pour être exact, elle définit 18 versions surchargées de cette méthode, notamment quatre pour les tableaux d’objets et deux pour les tableaux de type primitif à l’exception de boolean. La signature des deux méthodes sort() des tableaux d’entiers est la suivante : public static void sort(int[] a) public static void sort(int[] a, int p, int q)
La première signature trie la totalité du tableau et la seconde trie uniquement le sous-tableau a[p..q].
Exemple 13.1 Utiliser la méthode Arrays.sort() public static void main(String[] args) { int[] a = { 77, 44, 99, 66, 33, 55, 88, 22 }; print(a); java.util.Arrays.sort(a); print(a); } private static void print(int[] a) { for (int i=0; i
La méthode Arrays.sort() implémente l’algorithme 13.6 de tri par segmentation.
13.2 TRI PAR PERMUTATION Le tri par permutation effectue n – 1 itérations dans une séquence de n éléments. À chaque itération, il compare les éléments adjacents par paire de gauche à droite et intervertit les paires qui ne sont pas dans l’ordre. Les éléments les plus importants se trouvent ainsi déplacés vers la droite. Ce tri est également qualifié de tri à bulles parce que, si vous essayez de vous représenter les différents éléments dans une seule colonne, vous aurez l’impression que chaque itération fait remonter les éléments les plus grands comme les bulles d’une boisson gazeuse.
Algorithme 13.1 Tri par permutation. (Condition préalable : s = {s0, s1, …, sn – 1} est une séquence de n valeurs ordinales.) (Condition postérieure : tout la séquence s est triée.) 1. Effectuez les étapes 2 à 4 de i = n – 1 à 1. 2. Effectuez l’étape 3 de j = 1 à i. 3. Si les deux éléments sj – 1 et sj ne sont pas dans l’ordre, permutez-les. 4. (Invariants : la sous-séquence {sj…sn – 1} est triée et si = max{s0...si}.)
253
Tri par permutation
Exemple 13.2 Tri par permutation public static void sort(int[] a) { // CONDITION POSTERIEURE : a[0] <= a[1] <= ... <= a[a.length-1]; for (int i=a.length-1; i>0; i--) // étape 1 for (int j=1; j<=i; j++) // étape 2 if (a[j-1]>a[j]) swap(a,j-1,j); // étape 3 // INVARIANTS : a[i] <= a[i+1] <= ... <= a[a.length-1]; // a[i] >= a[j] pour tout j < i; }
Exemple 13.3 Tracer le tri par permutation Vous trouverez ci-après le tri par permutation d’un tableau composé des entiers {77, 44, 99, 66, 33, 55, 88, 22} : a[0]
a[1]
a[2]
a[3]
a[4]
a[5]
a[6]
a[7]
77
44
99
66
33
55
88
22
44
77 66
99 33
99 55
99 88
99 22
66
77 33
99 77 55
99 77
99 88
99
88
99
88
99
77
88
99
77
88
99
22 33
66 55
66 22
33
22
99
44 22
66
77
88
99
22
55
66
77
88
99
22
44
55
66
77
88
99
33
44
55
66
77
88
99
✽ Théorème 13.1 : le tri par permutation est correct. Reportez-vous à la solution de l’exercice d’entraînement 13.14 pour consulter la démonstration de ce théorème. ✽ Théorème 13.2 : le tri par permutation a une durée d’exécution égale à O(n2). Reportez-vous à la solution de l’exercice d’entraînement 13.15 pour consulter la démonstration de ce théorème.
254
Algorithmes de tri
13.3 TRI PAR SÉLECTION Le tri par sélection est similaire au tri par permutation. Il effectue n – 1 itérations dans une séquence de n éléments, en déplaçant à chaque passage le plus important des éléments non triés afin de le mettre à sa place. Cependant, cet algorithme de tri est plus efficace que le précédent parce qu’il ne déplace aucun élément au cours de sa recherche de l’élément le plus important. Il n’effectue donc qu’un seul échange à chaque itération après avoir trouvé l’élément qui n’est pas dans l’ordre. Il porte ce nom parce qu’il sélectionne le plus important des éléments non triés restants à chaque itération et qu’il l’insère à l’emplacement correct.
Algorithme 13.2 tri par sélection. (Condition préalable : s = {s0, s1, …, sn – 1} est une séquence de n valeurs ordinales.) (Condition postérieure : tout la séquence s est triée.) 1. Effectuez les étapes 2 à 4 de i = n – 1 à 1. 2. Recherchez l’index j de l’élément le plus important parmi {s0...si}.. 3. Intervertissez si et sj. 4. (Invariants : la sous-séquence {sj…sn – 1} est triée et si = max{s0...si}.)
Exemple 13.4 Tri par sélection public static void sort(int[] a) { // CONDITION POSTERIEURE : a[0] <= a[1] <= ... <= a[a.length-1]; for (int i=a.length-1; i>0; i--) // étape 1 { int j=0; // étape 2 for (int k=1; k<=i; k++) if (a[k] > a[j]) j = k; // INVARIANT : a[j] >= a[k] pour tout k <= i; swap(a,i,j); // étape 3 // INVARIANTS : a[i] >= a[k] pour tout k <= i; // a[i] <= a[i+1] <= ... <= a[a.length-1]; } }
Exemple 13.5 Tracer le tri par sélection Vous trouverez ci-après la trace du tri par sélection appliqué au tableau précédent composé des entiers {77, 44, 99, 66, 33, 55, 88, 22} : a[0]
a[1]
a[2]
a[3]
a[4]
a[5]
a[6]
a[7]
77
44
99
66
33
55
88
22 99
22
55
33
22
88
99
77
88
99
33
66
77
88
99
55
66
77
88
99
22
44
55
66
77
88
99
33
44
55
66
77
88
99
255
Tri par insertion
✽ Théorème 13.3 : le tri par sélection est correct. Reportez-vous à la solution de l’exercice d’entraînement 13.19 pour consulter la démonstration de ce théorème. ✽ Théorème 13.4 : le tri par sélection a une durée d’exécution égale à O(n2). Reportez-vous à la solution de l’exercice d’entraînement 13.20 pour consulter la démonstration de ce théorème. Bien que le tri par permutation et le tri par sélection aient la même fonction de complexité, le deuxième d’entre eux est exécuté plus rapidement, comme l’illustrent les deux traces. En effet, le tri par permutation a effectué 18 échanges, tandis que celui par sélection n’en a réalisé que sept. En fait, le tri par sélection présente l’avantage d’intervertir des éléments éloignés les uns des autres en une seule opération quand le tri par permutation doit en effectuer plusieurs (reportez-vous à l’exercice 11.8).
13.4 TRI PAR INSERTION Comme les deux algorithmes précédents, l’algorithme de tri par insertion effectue n – 1 itérations dans une séquence de n éléments. À chaque itération, il insère l’élément suivant dans le sous-tableau situé à gauche qui est donc trié. Une fois le dernier élément inséré, la totalité du tableau est triée.
Algorithme 13.3 Tri par insertion. (Condition préalable : s = {s0, s1, …, sn – 1} est une séquence de n valeurs ordinales.) (Condition postérieure : tout la séquence s est triée.) 1. Effectuez les étapes 2 à 4 de i = n – 1 à 1. 2. Stockez l’élément si dans un espace temporaire. 3. Recherchez l’index j le moins important pour lequel sj >= si. 4. Remontez d’une position la sous-séquence {sj…si – 1} jusqu’à {sj + 1…si}. 5. Copiez la valeur stockée de si dans sj. 6. (Invariant : la sous-séquence {s0…si} est triée).
Exemple 13.6 Tri par insertion public static void sort(int[] a) { // CONDITION POSTERIEURE : a[0] <= a[1] for (int i=1; i0 && a[j-1]>temp; j--) // a[j] = a[j-1]; // étape 4 a[j] = temp; // étape 5 // INVARIANT : a[0] <= a[1] <= ... <= } }
<= ... <= a[a.length-1]; 1 étape 3 a[i];
Exemple 13.7 Tracer le tri par insertion Vous trouverez ci-après la trace du tri par insertion appliqué au tableau précédent composé des entiers {77, 44, 99, 66, 33, 55, 88, 22} :
256
Algorithmes de tri
a[0]
a[1]
a[2]
a[3]
a[4]
a[5]
a[6]
a[7]
77
44
99
66
33
55
88
22
44
77 66
77
99
44
66
77
99
55
66
77
33
22
33
44
55
66
99 88
99
77
88
99
✽ Théorème 13.5 : le tri par insertion est correct. Reportez-vous à la solution de l’exercice d’entraînement 13.23 pour consulter la démonstration de ce théorème. ✽ Théorème 13.6 : le tri par sélection a une durée d’exécution égale à O(n2). Reportez-vous à la solution de l’exercice d’entraînement 13.24 pour consulter la démonstration de ce théorème. ✽ Théorème 13.7 : le tri par sélection a une durée d’exécution égale à O(n) sur une séquence triée. Reportez-vous à la solution de l’exercice d’entraînement 13.25 pour consulter la démonstration de ce théorème.
13.5 TRI SHELL Le théorème 13.7 suggère que si la séquence est presque triée, l’algorithme de tri par insertion a une durée d’exécution de O(n), ce qui est vrai. Le tri Shell exploite cette caractéristique pour créer un algorithme généralement exécuté en O(n1.5). En effet, il applique plusieurs fois le tri par insertion aux sousséquences d’incrément telles que {s0, s3, s6, s9, …, sn – 2} et {s1, s4, s7, s10, …, sn – 1} qui représentent deux des trois sous-séquences d’incrément 3 possibles.
Algorithme 13.4 Tri Shell. (Condition préalable : s = {s0, s1, …, sn – 1} est une séquence de n valeurs ordinales.) (Condition postérieure : tout la séquence s est triée.) 1. Définissez d = 1 ; 2. Répétez l’étape 3 jusqu’à ce que 9d > n ; 3. Définissez d = 3d + 1 ; 4. Effectuez les étapes 5 à 6 jusqu’à ce que d = 0 ; 5. Appliquez l’algorithme de tri par insertion à chacune des d sous-séquences d’incrément d de s ; 6. Définissez d = d/3.
Exemple 13.8 Tri Shell Supposons que s soit composée de n = 200 éléments. La boucle de l’étape 2 serait alors répétée trois fois, ce qui ferait augmenter d = 1 à d =4, 13 et 40.
257
Tri Shell
La première itération de la boucle ayant lieu pendant l’étape 4 appliquerait le tri par insertion à chacune des 40 sous-séquences d’incrément 40 {s0, s40, s80, s120, s160}, {s1, s41, s81, s121, s161}, {s2, s42, s82, s122, s162}, …, {s39, s79, s119, s159, s199}. L’étape 6 réduirait alors d à 13, puis la deuxième itération de la boucle de l’étape 4 appliquerait le tri par insertion à chacune des treize sous-séquences d’incrément 13 {s0, s13, s26, s39, s52, s65, …, s194}, {s1, s14, s27, s40, s53, s66, …, s195}, …, {s12, s25, s38, s51, s64, s77, …, s193}. L’étape 6 réduirait ensuite d à 4 et la troisième itération de la boucle de l’étape 4 appliquerait le tri par insertion à chacune des quatre sous-séquences d’incrément 4 {s0, s4, s8, s12, …, s196}, {s1, s5, s9, s13, . . ., s197}, {s2, s6, s10, s14, . . ., s198} et {s3, s7, s11, s15, . . ., s199}. L’étape 6 réduirait ensuite d à 1 et la quatrième itération de boucle de l’étape 4 appliquerait le tri par insertion à toute la séquence. Pour résumer, ce processus appliquerait le tri par insertion 58 fois : 40 fois aux sous-séquences de taille n1 = 5, 13 fois aux sous séquences de taille n2 = 15, 4 fois aux sous-séquences de taille n3 = 50 et une fois à toute la séquence de taille n4 = 200. De prime abord, vous pourriez penser qu’il est plus long d’utiliser le tri par insertion dans le cadre du tri Shell plutôt que d’appliquer le tri par insertion directement à toute la séquence en une seule fois. Et effectivement, un calcul direct du nombre total de comparaisons nécessaires dans l’exemple 13.8 à l’aide de la fonction de complexité n2 crée le résultat suivant : 2
2
2
2
2
2
2
2
40 ( n 1 ) + 13 ( n 2 ) + 4 ( n 3 ) + 1 ( n 4 ) = 40 ( 5 ) + 13 ( 15 ) + 4 ( 50 ) + 1 ( 200 ) = 53 925 qui est nettement supérieur à n2 = 2002 = 40 000 Cependant, après la première itération de l’étape 4, les sous-séquences suivantes sont presque triées. Le nombre réel de comparaisons serait donc égal à : 2
2
40 ( n 1 ) + 13 ( n 2 ) + 4 ( n 3 ) + 1 ( n 4 ) = 40 ( 5 ) + 13 ( 15 ) + 4 ( 50 ) + 1 ( 200 ) = 1 595 ce qui est largement plus intéressant que 40 000. ✽ Théorème 13.8 : le tri Shell a une durée d’exécution égale à O(n1.5). Vous remarquerez que, pour n = 200, n1.5 = 2001.5 = 2 829, soit un résultat nettement meilleur que n2 = 2002 = 40 000.
Exemple 13.9 Tri Shell public static void sort(int[] a) { int d=1, j, n=a.length; // étape 1 while (9*d0) // étape 4 { for (int i=d; i=d && a[j-d]>temp) { a[j] = a[j-d]; j -= d; } a[j] = temp; } d /= 3; // étape 6 } }
258
Algorithmes de tri
13.6 TRI PAR FUSION Le tri par fusion applique la stratégie « diviser pour mieux régner » lorsqu’il met une séquence en ordre. En effet, il commence par sous-diviser la séquence en sous-séquences composées de singletons. Puis, il fusionne successivement les sous-séquences par paire jusqu’à ce qu’une nouvelle séquence unique soit à nouveau formée. Chaque fusion conservant l’ordre précédent, chaque sous-séquence fusionnée est triée. Une fois la dernière fusion terminée, toute la séquence est triée. Bien qu’il puisse être implémenté itérativement, l’algorithme de tri est naturellement récursif puisqu’il consiste à diviser la séquence en deux, à trier chaque moitié, puis à les fusionner à nouveau en conservant leur ordre. Vous obtenez votre base lorsque la sous-séquence ne contient qu’un seul élément.
Algorithme 13.5 tri par fusion. (Condition préalable : s = {s0, s1, …, sn – 1} est une séquence de n valeurs ordinales.) (Condition postérieure : tout la séquence s est triée.) 1. Si n > 1, effectuez les opérations 2 à 5. 2. Fractionnez s en deux sous-séquences : a = {s0…sm – 1} et b = {sm…sn – 1}, avec m = n/2. 3. Triez a. 4. Triez b. 5. Reformez s en fusionnant a et b, ce qui préservera l’ordre de la séquence.
Exemple 13.10 Tri par fusion public static void sort(int[] a) { // CONDITION POSTERIEURE : a[0] <= a[1] <= ... <= a[a.length-1]; if (n>1) sort(a,0,a.length); // étape 1 } public static void sort(int[] a, int k, int n) { // CONDITIONS PREALABLES : 2 <= n <= k+n <= a.length; // CONDITION POSTERIEURE : a[k] <= a[k+1] <= ... <= a[k+n-1]; if (n<2) return; sort(a,k,n/2); // étapes 2 et 3 sort(a,k+n/2,n-n/2); // étape 4 merge(a,k,n); // étape 5 } public static void merge(int[] a, int k, int n) { // CONDITIONS PREALABLES : 1 <= n <= k+n <= a.length; // a[k] <= a[k+1] <= ... <= a[k+n/2-1]; // a[k+n/2] <= ... <= a[k+n-1]; // CONDITION POSTERIEURE : a[k] <= a[k+1] <= ... <= a[k+n-1]; int[] temp = new int[n]; int i=0, lo=k, hi=k+n/2; while (lo
259
Tri par fusion
for (i=0; i
La méthode principale sort() trie la totalité du tableau en appelant la méthode surchargée sort() à l’aide des paramètres de l’index k de départ et de la longueur n du sous-tableau. Cette méthode à trois paramètres trie le sous-tableau en commençant par la moitié gauche, puis en passant à la moitié droite avant de procéder à la fusion de ces deux parties. La méthode merge() fusionne les deux moitiés s[k..m-1] et s[m..k+n-1] dans un tableau temporaire, m étant l’index central m = k + n/2. À chaque itération, la première boucle while copie le plus petit des deux éléments a[lo] et a[hi]. L’opérateur de post-incrémentation avance automatiquement l’index de l’élément copié. Une fois que tous les éléments d’une moitié ont été copiés, la première boucle while s’arrête, puis l’une des deux autres boucles while copie les éléments restants dans temp[]. En dernier lieu, tous les éléments sont copiés à nouveau dans a[].
Exemple 13.11 Tracer le tri par fusion Vous trouverez ci-après une trace du tri par fusion appliqué à notre tableau de huit entiers : 77 44 99 66 33 55 88 22
a[0] a[1] a[2] a[3] a[4] a[5] a[6] a[7] 77
44
44
77
66
99
66
66
99
33
55
88
22
77 22
44
77
88
77 44 99 66
77 44
99 66
33 55
88 22
77
99
33
88
44
44 77
22
33
44
44
55
66
66
66 99
44 66 77 99
La figure ci-contre illustre la méthode de division, puis de fusion des sous-tableaux.
33 55 88 22
55
33 55
22
22 88
22 33 55 88
22 33 44 55 66 77 88 99
✽ Théorème 13.9 : le tri par fusion est exécuté en O(n lgn). Démonstration : en général, le tri par fusion fonctionne en divisant à plusieurs reprises le tableau en deux jusqu’à ce que les éléments soient des singletons, puis en fusionnant ces éléments par paire jusqu’à ce qu’il ne reste plus qu’un seul élément, comme illustré dans le diagramme suivant. Le nombre d’itérations de la première partie est égal au nombre de fois où n peut être divisé par deux, c’est-à-dire lg n – 1. En ce qui concerne le nombre et la taille des éléments, la deuxième partie du processus inverse la première. Par conséquent, la deuxième partie se déroule également en lg n – 1 étapes. C’est pourquoi la totalité de l’algorithme est effectuée en O(lg n) étapes, chacun d’entre elles comparant tous les éléments n. Le nombre total de comparaisons est donc égal à O(n lg n). ✽ Théorème 13.10 : le tri par fusion est correct. Démonstration : notre démonstration se base sur les conditions préalables et postérieures données dans le code. Dans la méthode principale sort(), le tableau est déjà trié si sa longueur est égale à 0 ou 1. Si tel n’est pas le cas, la condition postérieure de la méthode sort() à trois paramètres garantit que le tableau sera trié une fois qu’elle aura terminé parce que c’est tout le tableau qui lui est passé. Cette condition postérieure est identique à celle de la méthode merge() qui est appelée en dernier. C’est pourquoi il ne nous reste plus qu’à démontrer que la condition postérieure de cette méthode est vraie. Cette condition est issue des trois invariants de boucle. En effet, une fois que les boucles sont terminées,
260
Algorithmes de tri
le tableau temp[] est trié et il est copié dans a[], son ordre étant toujours le même. Il ne nous reste donc qu’à vérifier les trois invariants de boucle. Supposons que, au cours du traitement de l’une des trois boucles while, un élément y soit copié dans temp[] alors qu’il est inférieur à un élément x copié auparavant. Cela signifierait que x a été copié de l’autre moitié du tableau puisque les deux moitiés avaient déjà été triées et que cette opération a eu lieu au cours de la première boucle while. Sans être trop précis, nous pouvons donc supposer que y était dans la première moitié et x dans la seconde. Maintenant, si y < x, tous les éléments de a[k] à y doivent également être inférieurs à x. Cependant, l’affectation temp[i++] = ( a[lo]<=a[hi] ? a[lo++] : a[hi++] );
copie systématiquement l’élément le plus petit en premier lieu ; elle doit donc avoir copié tous les éléments de a[k] à y dans temp[] avant d’avoir copié x. Cela va à l’encontre de notre supposition et vérifie par conséquent l’invariant de boucle. En mettant en œuvre sa stratégie « diviser pour mieux régner », l’algorithme de tri par fusion obtient une durée d’exécution de O(n lgn), soit une amélioration considérable par rapport au O(n2) des algorithmes précédents. Cette stratégie consiste à : k
hi
lo
a y 1. Fractionner la séquence en deux sousséquences. 0 i temp x y 2. Trier chaque sous-séquence séparément. 3. Fusionner les deux sous-séquences de façon à reformer une seule séquence.
x
Le tri par fusion effectue la première étape de la façon la plus simple et la plus équilibrée qui soit : il fractionne la séquence en son milieu. Si cette première étape est réalisée différemment, vous obtiendrez d’autres algorithmes de tri. La stratégie « diviser pour mieux régner » est également utilisée dans l’algorithme de recherche binaire (algorithme 2.2). La méthode la plus simple de fractionnement de la séquence de façon déséquilibrée consiste à insérer tous les éléments, sauf le dernier, dans la première sous-séquence et d’insérer uniquement le dernier élément restant dans la seconde. Vous obtenez ainsi la version récursive du tri par insertion (reportez-vous à l’exercice d’entraînement 13.22). Un autre méthode de fractionnement non équilibrée consiste à insérer uniquement l’élément le plus important dans la deuxième sous-séquence et de laisser ainsi tous les autres éléments dans la première. Vous obtenez ainsi la version récursive du tri par sélection (reportez-vous à l’exercice d’entraînement 13.18). Vous remarquerez que cette méthode fait de l’étape 3 un véritable jeu d’enfant puisqu’il vous suffit d’associer l’élément le plus important à la fin de la première sous-séquence. Une quatrième méthode de fractionnement de la séquence est également à votre disposition : elle consiste à diviser la séquence de façon à ce que chaque élément de la première sous-séquence soit inférieur à chaque élément de la seconde. Cette condition était bien évidemment vraie dans le cas précédent qui nous a conduit au tri par sélection récursif. Cependant, lorsque vous obtenez cette propriété et que vos deux sous-séquences ont la même taille, vous utilisez un nouvel algorithme O(n lgn) connu sous le nom de tri par segmentation.
13.7 TRI PAR SEGMENTATION Comme le tri par fusion, le tri par segmentation est récursif et requiert une fonction auxiliaire avec plusieurs boucles. En outre, il a également une complexité O(n lg n). Cependant, dans la plupart des cas, il est plus rapide que le tri par fusion. Il départage le tableau en deux parties séparées par un seul élément supérieur à tous les éléments de la partie gauche et inférieur à tous les éléments de la partie droite. Cette technique vous permet de vous assurer que l’élément seul, qualifié d’élément pivot, est à la place voulue. L’algorithme applique ensuite la même méthode aux deux éléments séparés. Il est donc naturellement récursif et très rapide.
Tri par segmentation
Algorithme 13.6 Tri par segmentation. (Condition préalable : s = {s0, s1, …, sn – 1} est une séquence de n valeurs ordinales.) (Condition postérieure : tout la séquence s est triée.) 1. 2. 3. 4. 5.
Si n > 1, effectuez les étapes 2 à 5. Appliquez la partition rapide (voir l’algorithme 13.7) à s afin d’obtenir l’élément pivot si. (Invariant : l’élément pivot si se trouve à son emplacement trié.) Appliquez le tri par segmentation à a = {s0, s1, …, si – 1}. Appliquez le tri par segmentation à b = {si + 1, si + 2, …, sn – 1}.
Algorithme 13.7 Partition rapide. (Condition préalable : s = {sk, sk + 1, sk + 2, …, sk + n – 1} est une séquence de n valeurs ordinales.) (Condition postérieure : k ≤ j et sp ≤ sj ≤ sq pour k ≤ p < j < q < k + n.) 1. 2. 3. 4. 5. 6. 7. 8. 9.
Définissez x = sk (l’élément pivot). Définissez i = k et j = k + n. Répétez les étapes 4 à 7 tant que i < j. Continuez à incrémenter i tant que si < x. Continuez à décrémenter j tant que sj > x. Permutez si et sj. (Invariant : sp ≤ x ≤ sq pour k ≤ p < i et j < q < k + n.) Permutez sk et sj. (Invariant : sp ≤ sj ≤ sq pour k ≤ p < j < q < k + n.)
Exemple 13.12 Tri par segmentation public static void sort(int[] a) { // CONDITION POSTERIEURE: a[0] <= a[1] <= ... <= a[a.length-1]; if (a.length>1) sort(a,0,a.length); // étape 1 algorithme 13.6 } public static void sort(int[] a, int k, int n) { // CONDITIONS PREALABLES : 0 <= k <= k+n <= a.length; // CONDITION POSTERIEURE : a[k] <= a[k+1] <= ... <= a[k+n-1]; if (n<2) return; int pivot = a[k]; // étape 1 algorithme 13.7 int i = k; int j = k+n; while (i < j) // étape 3 algorithme 13.7 { while (i+1pivot) ; // étape 5 algorithme 13.7 if (i < j) swap(a,i,j); // étape 3 algorithme 13.7 // INVARIANT : a[p]<=pivot<=a[q] pour k<=p=
261
262
Algorithmes de tri
Exemple 13.13 Tracer le tri par segmentation Vous trouverez ci-après la trace du tri par segmentation de notre tableau composé de huit entiers : a[0]
a[1]
a[2]
a[3]
a[4]
a[5]
a[6]
a[7]
77
44
99
66
33
55
88
22
22
99 77
55 33 33 22 22
33
22
33
44
44
66
77
55
77
88
55
77
88
55
77
88
99
77
88
99
66
55
L’algorithme 13.7 sélectionne l’élément pivot comme dernier élément de la séquence. Il fonctionnerait tout aussi bien si le pivot était inséré comme premier élément ou comme élément central. Vous obtiendrez des performances légèrement meilleures si vous sélectionnez la moyenne de ces trois éléments. La méthode Arrays.sort() implémente le tri par segmentation et sélectionne le pivot qui est égal à la moyenne des trois éléments {s0, sn/2, sn – 1} quand n ≤ 40 et celui qui est égal à la moyenne des 9 éléments à intervalle égal lorsque n > 40. Elle a recours au tri par insertion (algorithme 13.3) lorsque n < 7. ✽ Théorème 13.11 : le tri par segmentation est exécuté en O(n lgn) dans le meilleur des cas. Démonstration : le meilleur des cas correspond aux situations dans lesquelles les valeurs de la séquence sont distribuées uniformément de façon aléatoire, chaque appel de l’algorithme de tri par segmentation créant ainsi un fractionnement équilibré de la séquence. Le tri fractionné divise alors la séquence en deux sous-séquences de longueur presque égale. Comme pour la recherche binaire (algorithme 2.2) et le tri par fusion (algorithme 13.5), cette sous-division répétée a besoin de lgn étapes pour arriver à une seule sous-séquence (voir la figure suivante).
lg n
n
Par conséquent, nous avons effectué O(lgn) appels de l’algorithme de tri par segmentation qui est exécuté en O(n). La durée d’exécution totale de ce tri est donc égale à O(n lgn). ✽ Théorème 13.12 : le tri par segmentation est exécuté en O(n2) dans le pire des cas.
Tri vertical
263
Démonstration : le pire des cas correspond aux situations dans lesquelles la séquence est déjà triée (ou triée à l’envers). Le tri par segmentation sélectionne alors systématiquement le dernier élément (ou le premier si la séquence est triée à l’envers), créant ainsi le fractionnement le moins déséquilibré possible : une partie contient n – 2 éléments et l’autre 1 seul. Ce type de division sera répété O(n) fois jusqu’à ce que les deux sous-séquences ne comportent plus qu’un seul élément. Par conséquent, nous avons effectué O(n) appels de l’algorithme de tri par segmentation qui est exécuté en O(n). La durée d’exécution totale de ce tri est donc égale à O(n2). Vous remarquerez que, dans le pire des cas, l’algorithme de tri par segmentation devient algorithme de tri par sélection (algorithme 13.2) parce que chaque appel du tri par segmentation revient à sélectionner l’élément le plus important de la sous-séquence qui lui est passée. Ainsi, en fait, le théorème 13.12 est un corollaire du théorème 13.4. ✽ Théorème 13.13 : le tri par segmentation a en moyenne une durée d’exécution égale à O(n lgn). La démonstration de ce théorème implique des notions bien plus complexes que celles présentées dans ce livre d’initiation. ✽ Théorème 13.14 : le tri par segmentation est correct. Démonstration : l’invariant qui se trouve dans la démonstration de la boucle while indique que tous les éléments situés à gauche de a[i] sont inférieurs ou égaux à l’élément pivot, et que tous les éléments situés à droite de a[j] sont supérieurs ou égaux au pivot. Cette affirmation est vraie parce que chaque élément supérieur au pivot et situé à gauche de a[i] a changé de place avec un élément inférieur au pivot et situé à droite de a[j]. Et inversement, chaque élément inférieur au pivot et situé à droite de a[j] a changé de place avec un élément supérieur au pivot et situé à gauche de a[i]. Lorsque cette boucle se termine, j ≤ i, ce qui signifie que tous les éléments qui sont supérieurs au pivot ont été déplacés à droite de a[i] et que tous les éléments inférieurs au pivot ont été déplacés à gauche de a[i]. Il s’agit de l’invariant présent à l’étape 7 de l’algorithme de partition rapide. Après la permutation de l’étape 8, tous les éléments qui sont supérieurs à a [i] se trouvent sur sa droite, et tous les éléments qui sont inférieurs à a[i] se trouvent sur sa gauche. Il s’agit toujours de l’invariant présent à l’étape 7 de l’algorithme de partition rapide, invariant identique à celui de l’étape 3 du tri par segmentation. Par conséquent, le tri indépendant du segment de droite et du segment de gauche vous permet de trier toute la séquence.
13.8 TRI VERTICAL Par définition, un tas est partiellement trié parce que chaque chaîne linéaire de la racine à la feuille est triée, d’où l’algorithme de tri vertical. Comme tous les algorithmes de tri, il s’applique à un tableau (ou à un vecteur), mais la structure sous-jacente du tas représentée par le tableau est utilisée afin de définir cet algorithme. À l’instar du tri par fusion et du tri par segmentation, le tri vertical utilise une fonction auxiliaire appelée depuis la fonction sort() et a une fonction de complexité O(n lg n). Cependant, contrairement à ces deux algorithmes, le tri vertical n’est pas récursif. Le tri vertical consiste essentiellement à charger n éléments dans un tas, puis à les décharger. D’après le théorème 12.1, chaque élément est chargé en O(lgn) et déchargé en O(lgn). La durée d’exécution pour n éléments est donc égale à O(n lgn).
Algorithme 13.8 Tri vertical. ((Condition préalable : s = {s0, s1, …, sn – 1} est une séquence de n valeurs ordinales.) (Condition postérieure : tout la séquence s est triée.) 1. Effectuez les opérations 2 à 3 pour i = n/2 – 1 jusqu’à 0.
264
Algorithmes de tri
2. 3. 4. 5. 6. 7.
Appliquez l’algorithme de la fonction heapify à la sous-séquence {si, si + 1, …, sn – 1}. (Invariant : dans s, chaque chemin racine-vers-feuille est décroissant.) Effectuez les opérations 5 à 7 pour i = n –1 jusqu’à 1. Permutez si et s0. (Invariant : la sous-séquence {si, si + 1, …, sn – 1} est triée.) Appliquez l’algorithme de la fonction heapify à la sous-séquence {s0, s1, …, si – 1}.
Algorithme 13.9 heapify (Conditions préalables : ss = {si, si + 1, …, sj – 1} est une sous-séquence des valeurs ordinales de j – i avec 0 ≤ i < j ≤ n ; et les deux sous-séquences {si + 1, …, sj – 1} et {si + 2, …, sj – 1} ont la propriété de tas.) (Condition postérieure : ss a la propriété de tas.) 1. Suposons que t = s2i + 1. 2. Supposons que sk = max {s2i + 1, s2i + 2}, donc k = 2i + 1 ou 2i + 2, l’index de l’enfant le plus important. 3. Si t < sk, effectuez les étapes 4 à 6. 4. Définissez si = sk. 5. Définissez i = k. 6. Si i < n/2 et si < maxi < max {s2i + 1, s2i + 2}, répétez les étapes 1 à 4. 7. Définissez sk = t. Ces algorithmes se distinguent des méthodes du chapitre 12 sur deux points. D’une part, les tas sont dans l’ordre inverse et chaque chemin racine-vers-feuille est décroissant. Et d’autre part, ces algorithmes utilisent une indexation basée sur 0. Grâce à l’ordre inversé, la fonction heapify laissera systématiquement l’élément le plus important à la racine de la sous-séquence. En outre, l’utilisation d’une indexation basée sur 0 et non sur 1 permet d’avoir recours à une méthode sort() cohérente avec toutes les autres méthodes sort(), même si cela signifie que votre code sera légèrement plus compliqué.
Exemple 13.14 Tri vertical public static void sort(int[] a) { // CONDITION POSTERIEURE : a[0] <= a[1] <= ... <= a[a.length-1]; for (int i=a.length/2-1; i>=0; i--) // étape 1 heapify(a,i,n); // étape 2 for (int i=a.length-1; i>0; i--) // étape 4 { swap(a,0,i); // étape 5 heapify(a,0,i); // étape 7 } } private static void heapify(int[] a, int i, int j) { int tmp=a[i], k; // étape 1 while (2*i+1<j) { k=2*i+1; if (k+1<j && a[k+1] > a[k]) ++k; // a[k] est l’enfant le // plus important if (tmp>=a[k]) break; // étape 3 a[i] = a[k]; // étape 4 i = k; // étape 5 } a[k] = tmp; // étape 7 }
Vous remarquerez que la méthode heapify() utilisée ici équivaut à la méthode heapifyDown() de l’exemple 12.9.
265
Tri vertical
Exemple 13.15 Tracer le tri vertical Vous trouverez ci-après la trace du tri vertical du tableau {77, 44, 99, 66, 33, 55, 88, 22, 44} : a[0]
a[1]
77
a[2]
44
99
66
66 99
a[3]
a[4]
a[5]
a[6]
a[7]
a[8]
33
55
88
22
44
33
55
77
22
44
44 88
66
44
44
99
88
77
66
44
33
44
55
22
22
88
77
55
66
44
33
22
44
44
77
66
44
55
44
33
22
22 66
55
22
44
44
33
33 55
44
44
22
33
33
44
44
33
22
22
44
33
22
22
33
Chaque zone qui apparaît en grisé correspond au résultat de l’appel de fonction heapify(). Cet appel a lieu une fois au cours de l’étape 2, puis n – 2 fois au cours de l’étape 7. La fonction sort() commence par convertir le tableau de façon à ce que l’arbre binaire sous-jacent complet soit transformé en tas. Pour cela, la fonction heapify() est appliquée à chaque sous-arbre non trivial, c’està-dire aux sous-arbres composés de plus d’un élément et dont la racine est située au-dessus du niveau de la feuille. Dans le tableau, les feuilles sont stockées aux emplacements a[n/2] à a[n]. C’est pourquoi la première boucle for de la fonction sort() applique heapify() aux éléments a[n/2-1] en remontant jusqu’à a[0] (c’est-à-dire la racine de l’arbre sous-jacent). Vous obtenez alors un tableau dont l’arbre correspondant a la propriété de tableau, comme illustré dans la figure ci-après : 0
99 1 0
1
2
3
4
5
6
7
2
66
8
99 66 88 44 33 55 77 22 44
4
3
44 7
22
88 33
8
44
5
55
6
77
266
Algorithmes de tri
La deuxième boucle for, qui est en fait la boucle principale, effectue maintenant n – 1 itérations, chacune de ces dernières réalisant les deux opérations suivantes : elle permute l’élément racine et l’élément a[i], puis elle applique la fonction heapify() au sous-arbre des éléments a[0:i-1]. Ce sous-arbre est composé de la partie encore non triée du tableau. Avant l’exécution de swap() à chaque itération, le sous-tableau a[0:i] a la propriété de tas, c’est pourquoi a[i] est son élément le plus important. Cela signifie que swap() insère l’élément a[i] à l’emplacement correct. Les sept premières itérations de la boucle for créent le résultat illustré par les sept figures suivantes : 0
88 1 0
1
2
3
4
5
6
7
2
66
8
88 66 77 44 33 55 44 22 99
77 4
3
44 7
6
5
55
33
44
8
22
99
0
77 1 0
1
2
3
4
5
6
7
2
66
8
77 66 55 44 33 22 44 88 99
55 4
3
44 7
6
5
22
33
44
8
88
99
0
66 1 0
1
2
3
4
5
6
7
2
44
8
66 44 55 44 33 22 77 88 99
55 4
3
44 7
6
5
22
33
77
8
88
99 0
55 1 0
1
2
3
4
5
6
7
2
44
8
55 44 22 44 33 66 77 88 99
22 4
3
44 7
6
5
66
33
77
8
88
99
0
44 1 0
1
2
3
4
5
6
7
2
44
8
44 44 22 33 55 66 77 88 99
4
3
33 7
88
22 5
66
55
8
99 0
44
6
77
267
Tri vertical
0
44 1 0
1
2
3
4
5
6
7
2
33
8
44 33 22 44 55 66 77 88 99
4
3
44 7
22 6
5
66
55
77
8
88
99
0
22 1 0
1
2
3
4
5
6
7
2
33
8
22 33 44 44 55 66 77 88 99
4
3
44 7
88
44 55
5
66
6
77
8
99
Le tableau (et son arbre binaire correspondant imaginaire) est divisé en deux parties : la première est le sous-tableau a[0:i-1] qui a la propriété de tas, et la seconde le sous-tableau restant a[i:n-1] dont les éléments se trouvent à l’emplacement correct. Cette deuxième partie apparaît en grisé dans l’illustration précédente. Chaque itération de la boucle for principale décrémente la taille de la première partie et incrémente celle de la seconde. Ainsi, une fois que la boucle a terminé le processus, la première partie est vide et la seconde, triée, constitue la totalité du tableau. Cette analyse vient de nous permettre de démontrer que le tri vertical fonctionne. ✽ Théorème 13.15 : le tri vertical est correct. Reportez-vous à la solution 13.1 pour consulter la démonstration de ce théorème. ✽ Théorème 13.16 : le tri vertical est exécuté en O(n lgn). Démonstration : chaque appel de la fonction heapify() est effectué en lg n étapes au maximum parce qu’il est uniquement itéré le long d’un chemin allant de l’élément courant jusqu’à une feuille. Le plus long chemin de ce type pour un arbre binaire complet de n éléments est lg n. La fonction heapify() est appelée n/2 dans la première boucle for et n – 1 fois dans la seconde boucle for, ce qui équivaut à une durée inférieure à (3n/2) lg n, qui est proportionnelle à n lg n. Si vous utilisez l’algorithme de tri comme le traitement d’un flux au cours duquel les éléments sont insérés dans un tableau de façon aléatoire, puis en sont extraits une fois qu’ils ont été triés, le tri vertical peut être considéré comme un juste milieu entre le tri par sélection et le tri par insertion. En effet, le tri par sélection est réalisé cours de l’étape de suppression, une fois que les éléments ont été stockées dans l’ordre non trié dans lequel ils arrivent. Quant au tri par insertion, il est effectué au cours de l’étape d’insertion afin que les éléments puissent être extraits du tableau dans l’ordre trié dans lequel ils ont été stockés. Le tri vertical présente l’avantage supplémentaire d’effectuer un tri partiel en insérant les éléments dans un tas, puis en finissant l’opération de tri lorsque les éléments sont supprimés du tas. Cette solution intermédiaire est nettement plus efficace puisqu’elle est exécutée en O(n lg n) au lieu de O(n2).
268
Algorithmes de tri
13.9 RAPIDITÉ DES ALGORITHMES DE TRI PAR COMPARAISON ✽ Théorème 13.17 : aucun algorithme de tri permettant la réorganisation du tableau en comparant ses éléments ne peut avoir de fonction de complexité meilleure que O(n lg n) dans le pire des cas. Démonstration : prenons l’exemple d’un arbre de décision couvrant tous les résultats possibles de l’algorithme pour un tableau de taille n. Étant donné que l’algorithme réorganise le tableau en comparant ses éléments, chaque nœud de l’arbre de décision représente une condition de la forme suivante : (a[i] < a[j]). Puisque ce type de condition peut créer deux résultats différents, true ou false, nous avons affaire à un arbre binaire. En outre, l’arbre couvrant toutes les réorganisations possibles, il doit avoir au moins n! feuilles. Ainsi, d’après le corollaire 10.3, la hauteur de l’arbre de décision doit être au moins égale à lg(n!). Dans le pire des cas, le nombre de comparaisons effectuées par l’algorithme est identique à celui de l’arbre de décision. Ainsi, la fonction de complexité de l’algorithme dans le pire des cas doit être O(lg(n!)). Maintenant, d’après la formule de Stirling (voir le théorème A.11), n n n! ≈ 2n --- e
donc n
n log ( n! ) ≈ log 2n --- ≈ log ( n n ) = n log n e
Dans le cas présent, « log » signifie logarithme binaire lg = log2. La fonction de complexité de l’algorithme dans le pire des cas doit donc être O(n lg n). Le théorème 13.17 s’applique uniquement aux algorithmes de tri par comparaison, c’est-à-dire aux algorithmes qui trient les éléments en comparant leurs valeurs, puis en modifiant leurs emplacements relatifs en fonction du résultat de ces comparaisons. Tous les algorithmes que nous venons de voir font partie de cette catégorie. Nous allons maintenant étudier deux autres types d’algorithmes qui n’ont pas recours aux comparaisons.
13.10 TRI DIGITAL La méthode de tri digital implique l’utilisation d’un tableau lexicographique de taille constante comme type d’élément de la séquence, c’est-à-dire d’un type chaîne ou d’un type entier. Supposons que r soit la base de numération associée à l’élément du tableau (r = 26 pour les chaînes de caractères ASCII, r = 10 pour les entiers décimaux, r = 2 pour les chaînes de bits), et supposons que w soit la largeur constante du tableau lexicographique. Si nous prenons l’exemple d’un numéro de sécurité sociale français, d = 10 et w = 13.
Exemple 13.16 Trier des livres par ISBN Tous les livres publiés depuis 1970 se voient attribuer un numéro unique qualifié de numéro ISBN pour International Standard Book Number. Ce numéro est généralement imprimé en bas du verso de la couverture d’un livre. Par exemple, l’ISBN de cet ouvrage est 0071361286. Une dernière précision, les ISBN sont généralement composés de quatre groupes de chiffres séparés par des traits d’union de la façon suivante : 0-07-136128-6. Le dernier chiffre est utilisé dans un but de vérification ; il est formé en additionnant les neuf autres. Étant donné qu’il peut s’agir de n’importe quel
269
Tri digital
chiffre de 0 à 10 ou bien de la lettre X, nous obtenons la base r = 11 et la largeur d = 10 puisque le numéro est composé de 10 chiffres.
Algorithme 13.10 Méthode de tri digital. (Condition préalable : s = {s0, s1, s2, …, sn – 1} est une séquence de n entiers ou chaînes de caractère avec une base de numération r et une largeur w.) (Condition postérieure : la séquence s est triée par ordre alphabétique.) 1. Répétez l’étape 2 pour d = 0 et ce jusqu’à w – 1. 2. Appliquer un algorithme de tri stable à la séquence s en triant uniquement le nombre d. Un algorithme de tri est dit stable s’il conserve l’ordre relatif des éléments avec des clés égales. Par exemple, le tri par insertion est un algorithme stable.
Exemple 13.17 Trier les ISBN à l’aide de la méthode de tri digital Voici les trois premières itérations du tri digital appliqué à une séquence de 21 ISBN : 0070308373 0071353461 0071342109 0071353453 0070308683 0071361286 007052713X 0830636528 0830628479 8838650527
0071353461 0070308373 0071353453 0070308683 8838650454 9742080585 0071361286 8838650527 0830636528 0830628479
0071342109 8838650527 0830636528 007052713X 0071353453 8838650454 0071353461 0070308373 0830628479 0070308683
0071342109 007052713X 0071361286 0070308373 0071353453 8838650454 0071353461 0830628479 8838650527 0830636528
Cette figure illustre l’importance de la stabilité qui permet de conserver l’ordre des itérations précédentes. Par exemple, après la première itération, 8838650527 précède 0830636528 parce que 7 < 8. Ces deux clés ont la même valeur 2 comme deuxième chiffre en partant de la droite (d = 1). Ainsi, lors de la deuxième itération, qui trie uniquement le chiffre 1, ces deux clés sont évaluées comme étant égales. Cependant, leur ordre ne doit pas être modifié parce que 27 < 28. La stabilité garantit le respect de cet ordre. Les colonnes qui ont été traitées apparaissent en grisé. Après la troisième itération, 0830628479 0830636528 les sous-séquences de 3 chiffres situées les plus à droite sont triées : 109 < 13X 0070308373 < 286 < 373 < 453, etc. Vous remarquerez que X correspond à la valeur 10, 0070308683 13X signifiant 130 + 10 = 140 d’un point de vue numérique. 007052713X 0071353461 Une fois les sept itérations terminées, la séquence a l’aspect suivant :
Exemple 13.18 Méthode de tri digital Cette méthode suppose que les constantes RADIX et WIDTH ont déjà été définies. Par exemple, dans le cas de tableaux d’entiers :
0071342109 0071353453 0071361286 8838650454
public static final int RADIX=10; public static final int WIDTH=10; public static void sort(int[] a) { // CONDITION POSTERIEURE : a[0] <= a[1] <= ... <= a[a.length-1]; for (int d=0; d<WIDTH; d++) // étape 1 sort(a,d); // étape 2 } private static void sort(int[] a, int d) { // CONDITION POSTERIEURE : a[] est trié de façon stable pour d;
270
Algorithmes de tri
int[] c = new int[RADIX]; for (int i=0; i=0; i--) tmp[--c[digit(d,a[i])]] = a[i]; for (int i=0; i
La deuxième méthode de tri est qualifiée de tri de comptage. ✽ Théorème 13.18 : la méthode de tri digital est exécutée en O(n). Démonstration : l’algorithme a des itérations WIDTH et traite les n éléments trois fois lors de chaque itération. La durée d’exécution est donc proportionnelle à WIDTH*n et elle est constante. Bien que O(n) soit théoriquement mieux que O(n lgn), cette méthode est rarement plus rapide que les algorithmes de tri O(n lgn), c’est-à-dire ceux de tri par fusion, par segmentation et vertical. En effet, elle perd beaucoup de temps à extraire les chiffres et à copier les tableaux.
13.11 TRI PANIER Le tri panier fait également partie de la catégorie des tris par distribution parce qu’il répartit les éléments dans des paniers en fonction d’un critère déterminé, puis qu’il applique un autre algorithme de tri à chaque panier. Il est similaire au tri par segmentation sur un point : tous les éléments du panier i sont supérieurs ou égaux à tous les éléments du panier i – 1, et inférieurs ou égaux à tous les éléments du panier i + 1. Cependant, alors que le tri par segmentation sépare la séquence en deux paniers, le tri panier la sépare en n paniers.
Algorithme 13.11 Tri panier. (Condition préalable : s = {s0, s1, s2, …, sn – 1} est une séquence de n valeurs ordinales avec une valeur minimum (min) et une valeur maximum (max) connues.) (Condition postérieure : la séquence s est triée.) 1. Initialisez un tableau de n paniers (collections). 2. Répétez l’étape 3 pour chaque si de la séquence. 3. Insérez si dans le panier j, avec j = rn, r = (si – min)/(max + 1 – min). 4. Triez chaque panier. 5. Répétez l’étape 6 pour j de 0 à n – 1. 6. Insérez à nouveau les éléments du panier j séquentiellement dans s.
Exemple 13.19 Trier des numéros d’identification composés de 9 chiffres à l’aide du tri panier Supposons que vous ayez 1 000 numéros d’identification composés de 9 chiffres. Vous allez paramétrer 1 000 tableaux de type int, puis répartir les nombres à l’aide de la formule j = rn, r = (si – min)/ (max + 1 – min) = (si – 0)/(109+ 1 – 0) si/109. Ainsi, par exemple, le numéro d’identification
271
Tri panier
666666666 serait inséré dans le panier numéro j, avec j = rn = (666666666/109)(103) = 666,666666 = 666. De la même façon, le numéro d’identification 123456789 serait inséré dans le panier numéro 123, et le numéro d’identification 666543210 serait inséré dans le panier 666. Chaque panier serait alors trié. Vous remarquerez que le nombre d’éléments présents dans chaque panier est en moyenne égal à 1, c’est pourquoi le choix de cet algorithme de tri n’influera pas sur la durée d’exécution. En dernier lieu, les éléments sont recopiés dans s en commençant par le panier numéro 0.
000284791 000550376
Panier #0
Panier #1
000284791 000550376 002846113 004479198 004760115 004766632 005503276
0 1 2 3 4 5 6
002846113
Panier #2
Panier #3 004479198 004760115 004766632
Panier #4
Panier #5
005503276
Panier #6
Exemple 13.20 Tri panier public static void sort(int[] a) { // CONDITION POSTERIEURE : a[0] <= a[1] <= ... <= a[a.length-1]; int min=minimum(a); int max=maximum(a); int n=a.length; Bucket[] bucket = new Bucket[n]; // étape 1 for (int j=0; j
272
Algorithmes de tri
for (int k=0; k
Ce programme requiert l’implémentation de l’interface suivante : public interface Bucket { public void add(int x); public int get(int k); public int size(); }
// ajoute x à la fin du panier // renvoie l’élément k du panier // renvoie le nombre d’éléments
Il doit également implémenter deux méthodes locales : public int minimum(int[] a); // renvoie la plus petite valeur de a[] public int maximum(int[] a); // renvoie la plus grande valeur de a[]
✽ Théorème 13.19 : le tri panier a une durée d’exécution égale à O(n). Démonstration : l’algorithme est composé de trois boucles parallèles, chacune étant répétée n fois. La dernière boucle contient une boucle interne, mais elle n’effectue en moyenne qu’une seule itération. Les méhodes minimum() et maximum() sont également exécutées toutes les deux en n étapes. C’est la raison pour laquelle le nombre d’étapes exécutées est proportionnel à 5n. À l’instar de la méthode de tri digital, l’algorithme de tri panier O(n) est beaucoup plus lent que les algorithmes de tri O(n lgn) dans la pratique.
?
QUESTIONS DE RÉVISION
QUESTIONS DE RÉVISION
Pourquoi le tri par permutation est-il lent ? 13.2 La démonstration du théorème 13.2 conclut que le tri par permutation effectue n(n – 1)/2 comparaisons. Comment en arrive-t-on à la conclusion que sa fonction de complexité est O(n2) ? 13.3 Pourquoi les algorithmes de tri O(n) tels que la méthode de tri digital et le tri panier sont-ils plus lents que les algorithmes de tri O(n lg n) tels que le tri par fusion, le tri par segmentation et le tri vertical ? 13.4 Le tri par fusion applique la stratégie « diviser pour mieux régner » afin de trier un tableau. En effet, il divise ce dernier en plusieurs parties et s’applique récursivement à chacune d’entre elles. Quels autres algorithmes de tri ont également recours à cette stratégie ? 13.5 Quels algorithmes de tri sont aussi efficaces sur une liste chaînée que sur les tableaux ? 13.6 Quels algorithmes de tri ont une complexité moyenne différente de leur complexité dans le pire des cas ? 13.7 Quels algorithmes de tri ont une complexité moyenne différente de leur complexité dans le meilleur des cas ? 13.8 Pourquoi la version non récursive d’un algorithme de tri récursif est-elle généralement plus efficace ? 13.9 Quels sont les points communs entre les algorithmes de tri par segmentation et par fusion ? 13.10 Dans quelles conditions est-il préférable d’appliquer le tri par fusion plutôt que les deux autres algorithmes O(n lg n) ? 13.1
Révision et entraînement
273
13.11 Dans quelles conditions le tri par segmentation est-il similaire au tri par sélection ? 13.12 Dans quelles conditions est-il préférable d’appliquer le tri par segmentation plutôt que les deux
autres algorithmes O(n lg n) ? 13.13 Quels sont les points communs entre le tri vertical et les tris par sélection et par insertion ? 13.14 Quel algorithme l’API Java utilise-t-elle pour implémenter ses méthodes java.util.Arrays .sort() ? 13.15 Un algorithme de tri est considéré comme stable s’il préserve l’ordre des éléments égaux. Parmi
les algorithmes de tri que nous avons vus, lesquels ne sont pas stables ? 13.16 Parmi les neuf algorithmes de tri que nous venons de voir, lesquels ont besoin d’un espace sup-
plémentaire pour les tableaux ? 13.17 Parmi les neuf algorithmes que nous venons de voir, lesquels seraient les mieux adaptés à un
fichier externe d’enregistrements ? 13.18 Le tri par fusion est parallélisable, c’est-à-dire que plusieurs étapes peuvent être effectuées
simultanément, indépendamment les unes des autres, à condition que l’ordinateur ait plusieurs processeurs susceptibles d’être exécutés en parallèle. Ce procédé fonctionne pour le tri par fusion parce que plusieurs parties différentes du tableau peuvent être sous-divisées ou fusionnées indépendamment des autres parties. Quels autres algorithmes de tri décrits dans ce chapitre sont également parallélisables ? 13.19 Imaginez un site web avec un applet Java pour chaque algorithme de tri. Ce site indique le fonc-
tionnement de l’algorithme en affichant l’animation d’une exécution test appliquée à un tableau a[] de 256 nombres aléatoires dans un intervalle de 0.0 à 1.0. Cette animation affiche à chaque itération de la boucle principale un graphique bidimensionnel composé de 256 points (x, y), un pour chaque élément du tableau, avec x = i+1 et y = a[i]. Chaque graphique correspond au résultat obtenu à la moitié du processus de tri pour chacun des algorithmes suivants : • Tri par sélection • Tri par insertion • Tri par fusion • Tri par segmentation • Tri vertical • Méthode de tri digital Associez chacun des graphiques suivants à l’algorithme de tri qui l’a créé :
274
Algorithmes de tri
¿
RÉPONSES
RÉPONSES
13.1
Le tri par permutation est lent parce qu’il n’opère que localement. Chaque élément ne bouge que d’une place à la fois. Par exemple, l’élément 99 de l’exemple 13.1 est déplacé par six appels différents de la fonction swap() pour arriver à la place voulue, soit a[8].
13.2
Le passage de n(n – 1)/2 à O(n2) se justifie de la façon suivante : a. Pour les valeurs importantes de n (c’est-à-dire n > 1000), n(n – 1)/2 est presque identique à n2/2. b. Une fonction de complexité est utilisée uniquement pour les comparaisons. Par exemple, combien de temps prendrait le tri d’un tableau deux fois plus grand ? Pour cette analyse, les fonctions proportionnelles sont équivalentes. En outre, étant donné que n2/2 est proportionnel à n2, nous pouvons laisser tomber la division 1/2 et simplifier notre conclusion qui devient O(n2).
13.3
Les algorithmes de tri O(n) (méthode de tri digital et tri panier) sont plus lents que les algorithmes de tri O(n lg n) (tri par fusion, par segmentation et vertical) parce que, malgré une durée d’exécution proportionnelle à n, la constante de la proportion est en grande partie due à des traitements supplémentaires. Ainsi, pour les deux algorithmes de tri O(n), tous les éléments doivent être copiés dans une liste de files ou de tableaux à chaque itération, puis recopiés dans la séquence initiale.
13.4
Les algorithmes de tri par fusion et par segmentation, ainsi que celui de tri panier mettent en œuvre la stratégie « diviser pour mieux régner ».
13.5
Les algorithmes de tri par permutation, par sélection, par insertion, par fusion et par permutation sont aussi efficaces sur des listes chaînées que sur des tableaux.
13.6
Le tri par permutation et le tri panier sont nettement plus lents dans le pire des cas.
13.7
Le tri par insertion, le tri Shell et la méthode de tri digital sont nettement plus rapides dans le meilleur des cas.
13.8
La récursivité présente l’inconvénient d’implémenter de nombreux appels récursifs de méthodes.
13.9
Ces algorithmes mettent tous les deux en œuvre la stratégie « diviser pour mieux régner », mais de façon différente. Ainsi, le tri par permutation commence par effectuer une division O(lgn) de la séquence, puis il trie récursivement chaque sous-séquence indépendamment. Quant au tri par fusion, il procède dans l’ordre inverse. En effet, il commence par effectuer deux appels récursifs, puis il fusionne les deux parties en une durée O(lgn). Ces deux algorithmes effectuent donc O(n) opérations un nombre O(lgn) de fois, d’où une complexité égale à O(n lgn).
13.10 Le tri par fusion est mieux adapté au tri des listes chaînées et des fichiers externes. 13.11 Le tri par segmentation inverse le tri par sélection dans le pire des cas, c’est-à-dire lorsque la
séquence est déjà triée. 13.12 Le tri par segmentation est mieux adapté au tri des tableaux de taille importante comportant des
types primitifs. 13.13 Le tri par sélection peut être considéré comme un processus de tri des sorties. En effet, vous insé-
rez les éléments dans un tableau selon l’ordre dans lequel ils vous sont donnés, puis vous sélectionnez à plusieurs reprises l’élément suivant le plus important. À l’inverse, le tri par sélection peut être considéré comme un processus de tri des entrées. En effet, vous insérez chaque élément dans le tableau à son emplacement trié correct, puis vous supprimez tous les éléments selon l’ordre du tableau. Ainsi, le tri par sélection insère les éléments dans le tableau en une durée O(n) et il les supprime en O(n2), tandis que le tri par insertion insère les éléments dans le tableau en O(n2) et les supprime en O(n). Le résultat obtenu dans les deux cas est un algorithme O(n2).
275
Révision et entraînement
13.14
13.15 13.16 13.17 13.18 13.19
?
Quant au tri vertical, il peut être considéré comme un processus de tri mi-entrées, mi-sorties puisque vous insérez les éléments dans un tableau tout en conservant la propriété de tas (partiellement trié) et que vous sélectionnez ensuite le premier élément (en fait le plus petit) afin de rétablir la propriété de tas. Les opérations d’insertion et de suppression ont toutes les deux une durée d’exécution égale à O(n lgn), soit une durée d’exécution totale de O(n lgn). L’API Java applique le tri par fusion pour implémenter les méthodes Arrays.sort() aux tableaux d’objets, et le tri par segmentation pour implémenter ses méthodes Arrays.sort() aux tableaux composés de types primitifs. Les tris Shell et vertical, ainsi que le tri par segmentation sont instables. Le tri par fusion, le tri digital et le tri panier requièrent un stockage supplémentaire dans le tableau. Les tris par permutation, par sélection, par insertion, par fusion et par segmentation pourraient être appliqués à des fichiers externes d’enregistrements. Les tris par fusion et par segmentation, ainsi que les tris Shell et panier seraient exécutés nettement plus rapidement sur un ordinateur parallèle. Chaque algorithme est indiqué sous le graphique qu’il a créé :
Tri par fusion
Tri vertical
Tri digital
Tri par segmentation
Tri par sélection
Tri par insertion
EXERCICES D’ENTRAÎNEMENT
EXERCICES D’ENTRAÎNEMENT
13.1
13.2
13.3
Si un algorithme O(n2), par exemple le tri par permutation, celui par sélection ou celui par insertion, prend 3,1 millisecondes pour traiter un tableau de 200 éléments, combien de temps prendrait le traitement d’un tableau similaire de : a. 400 éléments ? b. 40 000 éléments ? Si un algorithme O(n lg n), par exemple le tri par fusion, le tri par segmentation ou le tri vertical, prend 3,1 millisecondes pour traiter un tableau de 200 éléments, combien de temps prendrait le traitement d’un tableau similaire de 40 000 éléments ? Le tri par insertion est exécuté de façon linéaire sur un tableau déjà trié, mais que se passe-t-il lorsque le tableau est trié à l’envers ?
276
Algorithmes de tri
Comment le tri par permutation procède-t-il dans le cas : a. d’un tableau déjà trié ? b. d’un tableau trié à l’envers ? 13.5 Comment le tri par sélection procède-t-il dans le cas : a. d’un tableau déjà trié ? b. d’un tableau trié à l’envers ? 13.6 Comment le tri par fusion procède-t-il dans le cas : a. d’un tableau déjà trié ? b. d’un tableau trié à l’envers ? 13.7 Comment le tri par segmentation procède-t-il dans le cas : a. d’un tableau déjà trié ? b. d’un tableau trié à l’envers ? 13.8 Comment le tri vertical procède-t-il dans le cas : a. d’un tableau déjà trié ? b. d’un tableau trié à l’envers ? 13.9 Les tris par permutation, par sélection et par insertion sont tous des algorithmes O(n2). Lequel d’entre eux est le plus rapide et lequel est le plus lent ? 13.10 Le tri par fusion, le tri par segmentation et le tri vertical sont tous des algorithmes O(n lg n). Lequel d’entre eux est le plus rapide et lequel est le plus lent ? 13.11 Tracez le tri du tableau suivant : 13.4
• int a[] = { 44, 77, 55, 99, 66, 33, 22, 88, 77 }
13.12 13.13 13.14 13.15 13.16
13.17 13.18 13.19 13.20
pour chacun des algorithmes suivants : a. Tri par permutation b. Tri par sélection ; c. Tri par insertion ; d. Tri par fusion ; e. Tri par segmentation ; f. Tri vertical. Modifiez le tri par permutation de façon à ce qu’il trie le tableau en ordre décroissant. Modifiez le tri par permutation de façon à ce qu’il soit capable de s’arrêter dès que le tableau est trié. Démontrez le théorème 13.1. Démontrez le théorème 13.2. Le tri cocktail shaker améliore légèrement le tri par permutation dans la mesure où il alterne la migration des éléments les plus importants vers la fin du tableau avec la remontée des éléments les plus petits vers le haut de ce tableau. En tenant compte de cette définition, implémentez l’algorithme de tri cocktail shaker et déterminez s’il est plus efficace qu’un simple tri par insertion. Modifiez le tri par sélection (algorithme 13.2) pour qu’il utilise le plus petit élément de {si..sn – 1} au cours de l’étape 2. Réécrivez le tri par sélection récursivement. Démontrez le théorème 13.3. Démontrez le théorème 13.4.
Révision et entraînement
277
13.21 Modifiez le tri par insertion de façon à ce qu’il trie le tableau indirectement. Vous devrez utiliser
un tableau d’index distinct dont les valeurs correspondront aux index des éléments de données réels. Ce tri indirect réorganisera donc le tableau d’index sans modifier le tableau de données. 13.22 Réécrivez le tri par insertion récursivement. 13.23 Démontrez le théorème 13.5. 13.24 Démontrez le théorème 13.6. 13.25 Démontrez le théorème 13.7. 13.26 Modifiez le tri par segmentation de façon à ce qu’il sélectionne sont pivot comme dernier élé-
ment et non comme premier élément de la sous-séquence. 13.27 Modifiez le tri par segmentation de façon à ce qu’il sélectionne un pivot égal à la moyenne des
trois éléments suivants : le premier, le dernier et celui du milieu. 13.28 Modifiez le tri par segmentation de façon à ce qu’il inverse le tri par insertion lorsque la taille du
tableau est inférieure à 8. 13.29 Étant donné que le tri vertical a une durée d’exécution égale à O(n lgn), pourquoi n’est-il pas
appliqué plus souvent que le tri par segmentation qui est exécuté en O(n2) dans le pire des cas ? 13.30 Étant donné que le tri vertical a une durée d’exécution égale à O(n lgn) et qu’il ne requiert aucun
espace de tableau supplémentaire, pourquoi n’est-il pas appliqué plus souvent que le tri par fusion qui ne nécessite qu’un espace de stockage double pour le tableau ? 13.31 Démontrez le théorème 13.15. 13.32 L’algorithme de tri Las Vegas appliqué au tri d’un jeu de cartes est le suivant :
1. Battre les cartes de façon aléatoire. 2. Si le jeu n’est pas trié, répéter l’étape 1. Déterminez la fonction de complexité de cet algorithme de tri.
¿
SOLUTIONS
SOLUTIONS
13.1
L’algorithme O(n2) prendrait : a. 12,4 millisecondes (4 fois plus longtemps) pour traiter un tableau de 400 éléments ; b. 124 secondes (40 000 fois plus longtemps) pour traiter un tableau de 40 000 éléments, soit environ 2 minutes. Cette réponse peut être calculée mathématiquement de la façon suivante. Le temps de traitement t est proportionnel à n2, il y a donc une constante c pour laquelle t = c·n2. S’il faut t = 3,1 millisecondes pour trier n = 200 éléments, alors (3,1 millisecondes) = c·(200 éléments)2, alors c = (3,1 millisecondes)/(200 éléments)2 = 0,0000775 millisecondes/ élément2. Ensuite, pour n = 40 000, t = c·n2 = (0,0000775 millisecondes/élément2) (40 000 éléments)2 = 124 000 millisecondes = 124 secondes.
13.2
L’algorithme O(n lg n) prend 1,24 secondes (400 fois plus longtemps) pour traiter un tableau de 40 000 éléments. Cette réponse peut être calculée mathématiquement de la façon suivante. Le temps de traitement t est proportionnel à n lg n, il existe donc une constante c pour laquelle t = cn lg n. S’il faut t = 3,1 millisecondes pour trier n = 200 éléments, (3,1 millisecondes) = c(200)lg(200), donc c = (3,1 millisecondes)/(200lg(200)) = 0,0155/lg(200). Ensuite, pour n = 40 000, t = cn lg n = (0,0155/lg(200))(40 000lg(40 000)) = 620(lg(40 000)/lg(200)). Maintenant, 40 000 = 2002, donc lg(40 000) = lg(2002) = 2lg200. Ainsi, lg(40 000)/lg(200) = 2, donc t = 6202 millisecondes = 1 240 millisecondes = 1,24 secondes.
278
Algorithmes de tri
13.3
Le tri par insertion n’est pas du tout efficace lorsqu’il est appliqué à un tableau trié à l’envers parce que chaque nouvel élément inséré force tous les éléments sur sa gauche à bouger d’une place sur la droite.
13.4
Le tri par permutation tel qu’il est implémenté dans l’algorithme 13.1 est insensible à l’entrée, c’est-à-dire qu’il effectuera le même nombre n(n – 1)/2 de comparaisons quel que soit l’ordre initial des éléments du tableau. Le fait que le tableau soit déjà trié ou non, ou qu’il soit trié à l’envers n’a donc aucune importance ; cette méthode reste très lente.
13.5
Le tri par sélection est également insensible aux entrées, ce qui signifie qu’il aura besoin à peu près du même temps pour trier des tableaux de même taille, quel que soit leur ordre initial.
13.6
Le tri par fusion est également insensible aux entrées, ce qui signifie qu’il aura besoin à peu près du même temps pour trier des tableaux de même taille, quel que soit leur ordre initial.
13.7
Le tri par segmentation est en revanche plutôt sensible aux entrées. Tel qu’il est implémenté dans l’algorithme 13.5, il devient un algorithme O(n2) dans les cas spéciaux où le tableau est déjà trié, quel que soit l’ordre de tri. En effet, l’élément pivot sera toujours une valeur extrême dans le sous-tableau et le partage fractionnera donc le sous-tableau de façon très inégale, demandant ainsi n étapes au lieu de lg n.
13.8
Le tri vertical est légèrement sensible aux entrées, mais pas énormément. La fonction heapify() aura généralement besoin de moins de lg n itérations.
13.9
Le tri par permutation est plus lent que le tri par sélection et, dans la plupart des cas, le tri par insertion est légèrement plus rapide.
13.10 Le tri par fusion est plus lent que le tri vertical et, dans la plupart des cas, le tri par segmentation
est plus rapide. a. La trace du tri par permutation est la suivante : a[0]
a[1]
a[2]
a[3]
a[4]
a[5]
a[6]
a[7]
a[8]
44
77
55
99
66
33
22
88
77
55
77 66
99 33
99 22
99 88
99 77
66
77 33
77 22
77 77
33
66 22
33
55
66
88
99
279
Révision et entraînement
(suite) a[0]
33
a[1]
a[3]
22
55
a[4]
a[5]
a[6]
a[7]
a[8]
44 22
22
a[2]
44
33
b. La trace du tri par sélection est la suivante : a[0]
a[1]
a[2]
a[3]
a[4]
a[5]
a[6]
a[7]
a[8]
44
77
55
99
66
33
22
88
77
22
44 33
77 44
55 55
99 77
99
c. La trace du tri par insertion est la suivante : a[0]
a[1]
a[2]
a[3]
a[4]
a[5]
a[6]
a[7]
a[8]
44
77
55
99
66
33
22
88
77
55
77 66
77
99
33
44
55
66
77
99
22
33
44
55
66
77
99 88
99
77
88
99
d. La trace du tri par fusion est la suivante : a[0]
a[1]
a[2]
a[3]
a[4]
a[5]
a[6]
a[7]
a[8]
44
77
55
99
66
33
22
88
77
44
55
77
99 33
66 77
88
22
33
44
55
22
33
66
77
88
66
77
77
88
99
280
Algorithmes de tri
e. La trace du tri par segmentation est la suivante : a[0]
a[1]
a[2]
a[3]
a[4]
a[5]
a[6]
a[7]
a[8]
44
77
55
99
66
33
22
88
77
22
99 77
22
99
44 33
77 44
55
f. La trace du tri vertical est la suivante : a[0]
a[1]
a[2]
a[3]
a[4]
a[5]
a[6]
a[7]
a[8]
44
77
55
99
66
33
22
88
77
99
77 88
99
77
44 88
44 77
44
77 88
99 77
44 77
88 44 77
44
22 77
77 22 66
22
33 66
77 33 44
33
22
66
55
22 33
22 44
22 55
22
281
Révision et entraînement
(suite) a[0]
a[1]
33 22
a[2]
a[3]
a[4]
a[5]
a[6]
a[7]
a[8]
44 33
13.12 Le tri par permutation permettant de trier dans un ordre décroissant est le suivant : • public static void sort(int[] a) • { for (int i=a.length-1; i>0; i--) • for (int j=1; j<=i; j++) • if (a[j-1]
13.13 Le tri par permutation « intelligent » est le suivant : • public static void sort(int[] a) • { boolean sorted=false; • for (int i=a.length-1; i>0; i--) • { for (int j=1; j<=i; j++) • { sorted = true; • if (a[j-1] > a[j]) • { swap(a,j-1,j); • sorted = false; • } • } • if (sorted) return; • } •}
13.14 ✽ Théorème : le tri par permutation est correct.
Démonstration : l’invariant de la boucle permet de démontrer que le tri par permutation trie réellement le tableau. Après la première itération de la boucle i principale, l’élément le plus important doit avoir été transféré à la dernière position. Quel que soit l’endroit où il a commencé, il doit être transféré progressivement complètement à droite parce que chaque comparaison permet de le déplacer à droite. De la même façon, le deuxième élément le plus important doit avoir été transféré de la deuxième position avant la fin au cours de la deuxième itération de la boucle principale i. C’est pourquoi les deux éléments les plus importants se trouvent aux bons emplacements. Ce raisonnement démontre que l’invariant de la boucle est vrai à la fin de chaque itération de la boucle principale i. Cependant, après la dernière itération, les éléments n-1 les plus importants doivent être aux bons emplacements. Cela oblige l’énième élément le plus important (c’està-dire le plus petit élément) à se trouver également au bon emplacement. Par conséquent, le tableau doit être trié. 13.15 ✽ Théorème : le tri par permutation est exécuté en O(n2).
Démonstration : la fonction de complexité O(n2) signifie que, pour les valeurs importantes de n, le nombre d’itérations de la boucle a tendance à être proportionnel à n2. Il s’ensuit qu’un tableau de taille importante faisant le double d’un autre tableau, devrait être quatre fois plus long à trier. La boucle interne j itère n – 1 fois lors de la première itération de la boucle externe i, n – 2 fois lors de la deuxième itération de la boucle i, n – 3 fois lors de la troisième itération de la boucle i, etc. Par exemple, si n = 7, 6 comparaisons sont effectuées au cours de la première itération de la boucle i, 5 au cours de la deuxième itération, 4 au cours de la troisième, etc., ce qui nous mène à un nombre total de comparaisons de 6 + 5 + 4 + 3 + 2 + 1 = 21. En général, le nombre total de comparaisons est de (n – 1) + (n – 2) + (n – 3) + … + 3 + 2 + 1
282
Algorithmes de tri
Cette somme peut être exprimée de la façon suivante : n(n – 1)/2 (reportez-vous au théorème B.8 pour plus d’informations). Pour des valeurs importantes de n, cette expression est presque égale à n2/2, qui est proportionnel à n2. 13.16 Le tri cocktail shaker est le suivant : • public static void sort(int[] a) • { for (int i=a.length; i>0; i -= 2) • { for (int j=1; j a[j]) swap(a,j-1,j); • for (int j=i-2; j>0; j--) • if (a[j-1] > a[j]) swap(a,j-1,j); • } •}
13.17 La modification du tri par sélection de façon à ce qu’il utilise le plus petit élément de chaque
sous-séquence est la suivante : • public static void sort(int[] a) • { for (int i=0; i
13.18 Le tri par sélection récursif est le suivant : • public static void sort(int[] a) • { sort(a,a.length); •} • public static void sort(int[] a, int n) • { if (n<2) return; • int j=0; • for (int k=1; k a[j]) j = k; • swap(a,n-1,j); • sort(a,n-1); •}
13.19 ✽ Théorème : le tri par sélection est correct.
Démonstration : c’est l’invariant de boucle qui prouve l’exactitude de ce théorème. C’est pourquoi, comme dans le cas du tri par permutation, nous aurons uniquement besoin de vérifier les invariants de boucle. À la première itération de la boucle principale (étape 1), a[i] est le dernier élément du tableau, c’est pourquoi l’index k de la boucle interne parcourt chaque élément situé après a[0]. La valeur de l’index j commence à 0, puis elle est modifiée chaque fois que k trouve un élément plus important. Étant donné que j est systématiquement réinitialisé à l’index de l’élément le plus important, a[j] sera l’élément le plus important du tableau lorsque la boucle interne se terminera. Cela vérifie le premier invariant de la boucle. À chaque itération successive de la boucle externe, l’index k parcourt le segment du tableau restant non trié. Ainsi, pour la même raison que précédemment, a[j] sera l’élément le plus important du segment restant lorsque la boucle interne se terminera. Cela vérifie que le premier invariant de boucle est vrai à chaque itération de la boucle externe. Étant donné que swap(a,i,j) ne fait que permuter a[i] et a[j], le deuxième invariant de boucle découle du premier. Le troisième invariant de boucle découle du second par induction mathématique. Au cours de la première itération de la boucle principale, la boucle interne détermine que a[j] est l’élément le plus important du tableau. La méthode swap(a,i,j) insère cet élément au niveau du dernier emplacement de a[i], c’est
283
Révision et entraînement
pourquoi a[i] doit être >= à la totalité du tableau a[j]. Avant la iième itération de la boucle principale, nous savons par hypothèse inductive que le sous-tableau a[i+1..n-1] est trié et que toutes les valeurs du sous-tableau a[0..i] sont plus petites que a[i+1]. Ensuite, après la iième itération, a[i] est l’un des éléments les plus petits. Par conséquent, a[i] <= a[i+1] <= ...<= a[n-1]. 13.20 ✽ Théorème : le tri par sélection est exécuté en O(n2).
Démonstration : cette fois encore, notre démonstration sera globalement identique à celle du théorème correspondant pour le tri par permutation. À la première itération de la boucle externe i, la boucle interne j itère n – 1 fois. À la deuxième itération, elle itère n – 2 fois. Cette progression se poursuit et crée un total de : (n –1) + (n – 2) + … + 2 + 1 = n(n – 1)/2 13.21 Le test du tri par insertion indirect est le suivant : • public static void main(String[] args) • { int[] a = { 77, 44, 99, 66, 33, 55, 88, 22 }; • int[] index = {0,1,2,3,4,5,6,7}; • print(a,index); • sort(a,index); • print(a,index); •} • public static void sort(int[] a, int[] index) • { for (int i=1; i0 && a[index[j-1]]>temp; j--) • index[j] = index[j-1]; • index[j] = temp; • } •} • private static void print(int[] a, int[] index) • { for (int i=0; i
13.22 Le tri par insertion récursif est le suivant : • public static void sort(int[] a) • { sort(a,a.length); •} • public static void sort(int[] a, int n) • { if (n<2) return; • sort(a,n-1); • int temp=a[n-1], j; • for (j=n-1; j>0 && a[j-1]>temp; j--) • a[j] = a[j-1]; • a[j] = temp; •}
13.23 ✽ Théorème : le tri par insertion est correct.
Démonstration : à la première itération de la boucle principale i, a[1] est comparé à a[0] et ils sont intervertis si nécessaire. Par conséquent, a[0] <= a[1] après la première itération. Si nous supposons que l’invariant de la boucle est vrai avant la kième itération, il doit également être vrai après la fin de cette itération puisque a[k+1] a été inséré entre les éléments qui lui sont inférieurs ou égaux et ceux qui lui sont supérieurs. D’après le principe d’induction mathématique, l’invariant de la boucle est donc vrai pour tout k. 13.24 ✽ Théorème : le tri par insertion est exécuté en O(n2).
284
Algorithmes de tri
Démonstration : elle est identique à celle des algorithmes de tri par permutation et par sélection. À la première itération de la boucle externe i, la boucle interne j se répète une fois. À la deuxième itération, elle se répète une ou deux fois en fonction du résultat de l’expression a[1] < a[2]. À la troisième itération, la boucle interne j est répétée trois fois au maximum, là aussi en fonction du nombre d’éléments à gauche de a[3] supérieurs à a[3]. Le traitement se poursuit sur ce modèle de façon à ce que, à la kième itération de la boucle externe, la boucle interne soit répétée k fois au maximum. Par conséquent, le nombre total d’itérations est le suivant : 1 + 2 + 3 + … + (n – 2) + (n – 1) = n(n – 1)/2 13.25 ✽ Théorème : le tri par insertion est exécuté en O(n) lorsque la séquence est déjà triée.
Démonstration : dans ce cas, la boucle interne est répétée une seule fois pour chaque itération de la boucle externe. Le nombre total d’itérations de la boucle interne est donc le suivant : 1 + 1 + 1 + … +1 + 1 = n – 1 13.26 Le tri par segmentation pivotant au niveau du dernier élément est le suivant : • public static void sort(int[] a) • { if (a.length>1) sort(a,0,a.length); •} • • public static void sort(int[] a, int k, int n) • { if (n<2) return; • int pivot = a[k+n-1]; • int i = k-1; • int j = k+n-1; • while (i < j) • { while (a[++i]0 && a[--j]>pivot) ; • if (i < j) swap(a,i,j); • } • swap(a,i,k+n-1); • sort(a,k,i-k); • sort(a,i+1,k+n-i-1); •}
13.27 Le tri par segmentation dont le pivot est égal à la moyenne de trois éléments est le suivant : • private static void setMedian(int[] a, int i, int j, int k) • { // CONDITION POSTERIEURE : either a[i] <= a[j] <= a[k] • // ou a[k] <= a[j] <= a[i] • // c’est-à-dire, a[j] est la moyenne de {a[i],a[j],a[k]} • if (a[i] <= a[k] && a[k] <= a[j]) swap(a,j,k); • else if (a[j] <= a[i] && a[i] <= a[k]) swap(a,j,i); • else if (a[j] <= a[k] && a[k] <= a[i]) swap(a,j,k); • else if (a[k] <= a[i] && a[i] <= a[j]) swap(a,j,i); •} • • public static void sort(int[] a) • { if (a.length>1) sort(a,0,a.length); •} • • public static void sort(int[] a, int k, int n) • { // CONDITIONS PREALABLES : 0 <= k <= k+n <= a.length; • if (n<2) return; • setMedian(a,k+n/2,k,k+n-1); // a[k] = moyenne • int pivot = a[k]; • int i = k; • int j = k+n; • while (i < j)
Révision et entraînement • • • • • • • •}
285
{ while (i+1pivot) ; if (i < j) swap(a,i,j); } swap(a,k,j); sort(a,k,j-k); sort(a,j+1,k+n-j-1);
13.28 Le tri par segmentation qui applique le tri par insertion lorsque n < 8 est le suivant : • public static void sort(int[] a) • { if (a.length>1) sort(a,0,a.length); •} • • public static void sort(int[] a, int k, int n) • { // CONDITIONS PREALABLES : 0 <= k <= k+n <= a.length; • if (n<2) return; • if (n<8) • { insertionSort(a,k,n); • return; • } • int pivot = a[k]; • int i = k; • int j = k+n; • while (i < j) • { while (i+1pivot) ; • if (i < j) swap(a,i,j); • } • swap(a,k,j); • sort(a,k,j-k); • sort(a,j+1,k+n-j-1); •} • • public static void insertionSort(int[] a, int k, int n) • { // CONDITIONS POSTERIEURES : a[k] <= a[k+1] <= ... <= a[n-1]; • for (int i=k+1; ik && a[j-1]>temp; j--) • a[j] = a[j-1]; • a[j] = temp; • } •}
13.29 Le tri vertical n’est généralement pas préféré au tri par segmentation parce qu’il est plus lent en
moyenne. 13.30 Le tri vertical n’est généralement pas préféré au tri par fusion parce qu’il n’est pas stable. 13.31 ✽ Théorème : le tri vertical est correct.
Démonstration : la condition postérieure de Heapify établit l’invariant de boucle de l’étape 3. Grâce à cette condition, la racine s0 est l’élément maximum de la sous-séquence. L’étape 5 insère ce maximum à la fin de la sous-séquence. Ainsi, lorsque la boucle se termine à l’étape 4, la séquence est triée. L’algorithme Heapify rétablit la propriété de tas à tout le segment ss en appliquant la méthode heapifyDown() depuis sa racine. 13.32 Le tri Las Vegas a une complexité O(nn). Il existe n! permutations différentes d’un jeu de n cartes.
Lorsque vous les battez, cela revient à sélectionner une permutation au hasard. Une seule des n! permutations est correcte, c’est-à-dire qu’une seule d’entre elles laissera les cartes dans l’ordre.
286
Algorithmes de tri
Le nombre de mélanges aléatoires nécessaires avant d’arriver à l’ordre voulu est donc égal à n!. Ensuite, chaque permutation a besoin de n – 1 comparaisons pour vérifier si elle est correcte. C’est pourquoi la complexité totale de l’algorithme est égale à O(nn!). En dernier lieu, d’après la formule de Stirling (reportez-vous au corollaire A.2), O(nn!) = O(nn).
Chapitre 14
Tables Une table (également qualifiée de mappe, table de conversion, tableau associatif ou dictionnaire) est un conteneur qui permet un accès direct par type d’index. Elle fonctionne comme un tableau ou un vecteur, à l’exception du fait que la variable d’index Dictionnaire ne doit pas nécessairement être un entier. En fait, vous pouvez imaginer qu’il s’agit d’un dictionnaire, la variable d’index étant le mot consulté et l’élément qui l’indexe étant sa définition. Une table est une séquence de paires. Le premier composant d’une paire est une clé qui sert d’index dans la table et généralise l’indice entier utilisé dans les tableaux. Le deuxième composant est la valeur du composant clé qui contient les informations recherchées. Si nous reprenons l’exemple du dictionnaire, la clé est le mot consulté et la valeur correspond à la définition de ce mot (et à tout ce qui est listé pour lui). Vous pouvez également parler d’une mappe parce que les clés sont mappées à leurs valeurs comme une fonction mathématique de type f(clé) = valeur. Vous pouvez aussi qualifier les tables de tableaux associatifs parce qu’elles peuvent être implémentées à l’aide de deux tableaux parallèles, l’un contenant les clés et l’autre les valeurs.
14.1 INTERFACE JAVA Map Object
L’interface Map est définie de la façon suivante dans le paquetage java.util : public interface Map { int size(); boolean isEmpty(); boolean containsKey(Object key); boolean containsValue(Object value); Object get(Object key); Object put(Object key, Object value); Object remove(Object key); void putAll(Map map); void clear(); public Set keySet(); public Collection values(); public Set entrySet(); public interface Entry { Object getKey();
AbstractMap
Map
HashMap TreeMap WeakHashMap
SortedMap
288
Tables
Object getValue(); Object setValue(Object value); boolean equals(Object o); int hashCode(); } boolean equals(Object o); int hashCode(); }
14.2 CLASSE HashMap Si vous regardez la hiérarchie de classe présentée dans la figure précédente, vous constaterez que Java définit quatre implémentations de son interface Map, à savoir les classes AbstractMap, HashMap, TreeMap et WeakHashMap.
Exemple 14.1 Dictionnaire allemand-anglais Ce programme utilise la classe HashMap afin de créer un dictionnaire allemand-anglais : public class Ex1401 { public static void main(String[] args) { Map map = new HashMap(); map.put("Tag","day"); map.put("Hut","hat"); map.put("Uhr","clock"); map.put("Rad","wheel"); map.put("Ohr","ear"); map.put("Tor","gate"); System.out.println("map=" + map); System.out.println("map.size()=" + map.size()); System.out.println("map.keySet()=" + map.keySet()); System.out.println("map.values()=" + map.values()); System.out.println("map.get(\"Uhr\")=" + map.get("Uhr")); System.out.println("map.remove(\"Rad\")=" + map.remove("Rad")); System.out.println("map.get(\"Rad\")=" + map.get("Rad")); System.out.println("map=" + map); System.out.println("map.size()=" + map.size()); } } map={Rad=wheel, Uhr=clock, Ohr=ear, Tor=gate, Hut=hat, Tag=day} map.size()=6 map.keySet()=[Rad, Uhr, Ohr, Tor, Hut, Tag] map.values()=[wheel, clock, ear, gate, hat, day] map.get("Uhr")=clock map.remove("Rad")=wheel map.get("Rad")=null map={Uhr=clock, Ohr=ear, Tor=gate, Hut=hat, Tag=day} map.size()=5
La méthode put() insère les paires clé/valeur dans la table. Par exemple, map.put("Tag", "day"); insère la paire clé/valeur ("Tag","day"), "Tag" correspondant à la clé et "day" à la valeur. Le premier appel de println() invoque la méthode HashMap.toString() et imprime la totalité de l’objet map. Le deuxième appel de println() invoque la méthode HashMap.size() et indique que l’objet Map comporte six éléments clé/valeur. L’appel suivant de println() invoque la
Codes de hachage Java
289
méthode HashMap.keySet() qui renvoie un objet Set contenant toutes les clés (c’est-à-dire les six mots allemands). L’appel suivant de println() invoque la méthode HashMap.values() qui renvoie un objet Collection contenant toutes les valeurs (c’est-à-dire les six mots anglais). L’appel suivant de println() invoque la méthode HashMap.get() qui renvoie la valeur d’une clé donnée. Cet appel renvoie la valeur "clock" de la clé "Uhr". L’appel suivant de println() invoque la méthode HashMap.remove() qui supprime la paire ("Rad","wheel"). La réussite de cette opération est confirmée par l’appel suivant puisque map.get("Rad") renvoie null, indiquant ainsi qu’il n’existe aucune paire clé/valeur dans map dont la clé serait "Rad". Les deux dernières lignes impriment à nouveau la totalité de map. Comme vous pouvez le constater, la paire ("Rad","wheel") a bien été supprimée. L’ordre de stockage des paires clé/valeur dans l’objet HashMap de cet exemple semble aléatoire. En effet, il ne correspond pas à l’ordre d’insertion de ces paires, comme le prouve l’exemple suivant.
Exemple 14.2 Les objets Java HashMap sont des tables de hachage Ce programme crée deux objets HashMap indépendants, puis il y insère les mêmes paires clé/valeur que dans l’exemple précédent, mais dans un ordre différent : public class Ex1402 { public static void main(String[] args) { Map map1 = new HashMap(); map1.put("Tor","gate"); map1.put("Rad","wheel"); map1.put("Tag","day"); map1.put("Uhr","clock"); map1.put("Hut","hat"); map1.put("Ohr","ear"); System.out.println("map1=" + map1); Map map2 = new HashMap(); map2.put("Rad","wheel"); map2.put("Uhr","clock"); map2.put("Ohr","ear"); map2.put("Tag","day"); map2.put("Tor","gate"); map2.put("Hut","hat"); System.out.println("map2=" + map2); } map1={Rad=wheel, Uhr=clock, Ohr=ear, Tor=gate, Hut=hat, Tag=day} map2={Rad=wheel, Uhr=clock, Ohr=ear, Tor=gate, Hut=hat, Tag=day}
L’ordre de stockage des paires clé/valeur dans la table HashMap est reflété dans la sortie obtenue après exécution de la méthode toString(). En effet, il est identique à celui de notre exemple précédent et ne dépend donc pas de l’ordre d’insertion des données. Vous remarquerez également que cet ordre est identique à celui de l’exemple 14.1.
14.3 CODES DE HACHAGE JAVA L’ordre de stockage des paires clé/valeur dans la table HashMap dépend uniquement de la capacité de cette dernière et de la valeur du code de hachage des objets. N’oubliez pas que, comme nous l’avons déjà vu à la section 3.4, chaque objet de Java est associé à un code de hachage intrinsèque qui est calculé à partir des données réelles stockées dans l’objet. C’est ce code qui est renvoyé par la méthode Object.hashCode() pour chaque objet.
290
Tables
Exemple 14.3 Codes de hachage de certains objets chaîne Ce programme imprime les codes de hachage intrinsèques des objets String stockés dans les programmes précédents : public class Ex1403 { public static void main(String[] args) { printHashCode("Rad"); printHashCode("Uhr"); printHashCode("Ohr"); printHashCode("Tor"); printHashCode("Hut"); printHashCode("Tag"); } private static void printHashCode(String word) { System.out.println(word+": "+word.hashCode()); } } Rad: Uhr: Ohr: Tor: Hut: Tag:
81909 85023 79257 84279 72935 83834
Comme vous pouvez le constater, les six codes obtenus sont des entiers de 5 chiffres proches parce que les objets String ont tous une longueur de 3.
14.4 TABLES DE HACHAGE Une table de hachage utilise une fonction spéciale, appelée fonction de hachage, pour calculer l’emplacement des valeurs des données à partir des valeurs de leurs clés au lieu de stocker ces dernières dans la table. Étant donné que la durée de recherche ne dépend pas de la taille de la table, les tables de hachage accèdent généralement plus vite aux données. Java définit la classe Hashtable dans son paquetage java.util, mais il s’agit surtout 0 1 d’une amélioration de la classe HashMap. En effet, une table HashMap peut effectuer exac2 Ohr 3 Rad tement les mêmes opérations qu’un objet Hashtable et elle présente l’avantage supplé4 Uhr mentaire d’être cohérente avec le reste du framework de collections Java. En général, une 5 Hut 6 Tag table de hachage ressemble à la figure suivante, à savoir qu’il s’agit d’un tableau d’objets 7 indexés par leur valeur de hachage. 8 Tor Attention, cette structure implique une correspondance exacte entre l’intervalle défini 109 dans la fonction de hachage et celui des valeurs d’index du tableau. Pour cela, il vous suffit d’utiliser un modulo égal à la taille du tableau.
Exemple 14.4 Mapper des clés dans une table de hachage de taille 11 Ce programme imprime les valeurs du code de hachage pour les objets String qui seront stockés dans une table de hachage de taille 11 : public class Ex1404 { private static final int MASK = 0x7FFFFFFF; // 2^32-1 private static final int CAPACITY = 11;
291
Tables de hachage
public static void main(String[] args) { printHash("Rad"); printHash("Uhr"); printHash("Ohr"); printHash("Tor"); printHash("Hut"); printHash("Tag"); } private static void printHash(String word) { System.out.println("hash(" + word + ") = " + hash(word)); } private static int hash(Object object) { return (object.hashCode() & MASK) % CAPACITY; } } hash(Rad) hash(Uhr) hash(Ohr) hash(Tor) hash(Hut) hash(Tag)
= = = = = =
3 4 2 8 5 3
Les valeurs de la fonction de hachage sont calculées par l’instruction return (object.hashCode() & MASK) % CAPACITY; , CAPACITY étant égal à 11 et MASK à 2147483647, soit 0x7FFFFFFF sous sa forme hexadécimale. L’opération n & MASK ne fait que supprimer le signe de l’entier n. Vous devez procéder ainsi en Java avant d’utiliser l’opérateur modulo afin de calculer un index de tableau parce que ce langage, contrairement au C++, peut créer un résultat négatif pour m % CAPACITY si m est négatif. Ainsi, le résultat renvoyé par la fonction hash() de cet exemple sera nécessairement compris entre 0 et 10. Les cinq premières chaînes ont les valeurs d’index 3, 4, 2, 8 et 5 ; elles seront donc stockées à ces emplacements dans la table de hachage. Cependant, la sixième chaîne ("Tag") a également une valeur d’index de 3, ce qui crée un conflit avec "Rad", qui est déjà stocké dans le composant 3. Dans ce genre de situation, vous appliquerez l’algorithme qui consiste à insérer le nouvel élément dans le prochain composant disponible, c’est-à-dire le composant 6 dans cet exemple puisque "Uhr" est déjà stocké dans le composant 4 et "Hut" dans le composant 5. Cet algorithme de résolution des collisions est qualifié de hachage avec essais linéaires. La classe HashMap utilise la fonction de hachage comme dans l’exemple 14.4 pour implémenter ses accesseurs : containsKey(), get(), put(), remove() et entrySet(). Elle définit la taille initiale de la table de hachage à 101. Grâce à ces explications, vous êtes maintenant en mesure de comprendre pourquoi les six chaînes des exemples précédents étaient stockées dans l’ordre indiqué.
Exemple 14.5 Mapper les clés dans une table de hachage de taille 101 Le programme suivant est identique à celui de l’exemple 14.4 mais, cette fois, la capacité de la table de hachage est définie à 101 au lieu de 11 : public class Ex1405 { private static final int MASK = 0x7FFFFFFF; // 2^32-1 private static final int CAPACITY = 101; public static void main(String[] args) { printHash("Rad");
292
Tables
printHash("Uhr"); printHash("Ohr"); printHash("Tor"); printHash("Hut"); printHash("Tag"); } private static void printHash(String word) { System.out.println("hash(" + word + ") = " + hash(word)); } private static int hash(Object object) { return (object.hashCode() & MASK) % CAPACITY; } } hash(Rad) hash(Uhr) hash(Ohr) hash(Tor) hash(Hut) hash(Tag)
= = = = = =
99 82 73 45 13 4
Comme vous pouvez le constater, les éléments sont finalement stockés dans l’ordre inverse de leur accessibilité.
14.5 PERFORMANCES DES TABLES DE HACHAGE Les performances d’une table de hachage de taille 101 contenant seulement six éléments seront excellentes. Dans ces conditions, les risques de collisions sont peu probables, c’est pourquoi les fonctions d’accès sont immédiates et la durée d’exécution est égale à O(1). Il s’agit d’un accès direct, comme dans le cas des tableaux. En revanche, les performances d’une table de hachage de taille 101 contenant 100 éléments seront plutôt décevantes parce que de nombreuses collisions auront lieu au cours du processus de stockage des éléments. Par exemple, supposons que la chaîne "Lob" doive subir 60 collisions avant qu’un composant libre soit trouvé, cela signifie que vous aurez besoin de procéder à 60 tests avant de trouver ce composant chaque fois que vous essaierez d’accéder à la chaîne. Ce type de performance est proche de O(n), c’est-à-dire une performance à peine meilleure que celle d’une liste chaînée. Pour résoudre ce problème, vous devrez éviter de trop remplir la table de hachage en la redimensionnant chaque fois qu’elle atteint une taille plafond. Afin de savoir où en est votre table, vous devrez tenir compte des deux paramètres suivants : la taille de la table, c’est-à-dire le nombre réel d’éléments qu’elle contient, et sa capacité, c’est-à-dire le nombre de composants qu’elle comporte. Le rapport entre ces deux paramètres est qualifié de facteur de charge. Dans le premier exemple de cette section, notre table avait une taille égale à 6 et une capacité égale à 101, d’où un facteur de charge de 6/101 = 5,94 %. Dans le deuxième exemple, la taille de la table était de 100, d’où un facteur de chargé égal à 100/101 = 99,01 %. La classe HashMap redimensionne automatiquement sa table de hachage lorsque le facteur de charge atteint une valeur plafond. Cette dernière peut être définie au moment de la création de la table de hachage à l’aide du constructeur public HashMap(int initialCapacity, float loadFactor) qui permet également le paramétrage de la capacité initiale. Si vous utilisez un constructeur qui ne prend aucun de ces deux arguments, les valeurs par défaut de la capacité et du plafond de charge seront utilisées, c’est-à-dire 101 et 75 % respectivement.
Algorithmes de résolution des collisions
293
14.6 ALGORITHMES DE RÉSOLUTION DES COLLISIONS L’algorithme de résolution des collisions que nous avons abordé dans les exemples précédents est qualifié de hachage avec essais linéaires. Lorsqu’un nouvel élément doit être inséré dans un composant de table déjà utilisé, cet algorithme consiste à incrémenter l’index jusqu’à ce qu’un composant vide soit trouvé. Cela implique que vous devrez parfois retourner au début de la table de hachage.
Exemple 14.6 Essais linéaires Le programme suivant vient compléter celui de l’exemple 14.4. Il garde un suivi des composants de table utilisés et de la valeur du facteur de charge après chaque hachage. public class Ex1406 { private static final int MASK = 0x7FFFFFFF; // 2^32-1 private static final int CAPACITY = 11; private static int size=0; private static boolean[] used = new boolean[CAPACITY]; public static void main(String[] args) { printHash("Rad"); printHash("Uhr"); printHash("Ohr"); printHash("Tor"); printHash("Hut"); printHash("Tag"); printHash("Eis"); printHash("Ast"); printHash("Zug"); printHash("Hof"); printHash("Mal"); } private static void printHash(String word) { System.out.println("hash(" + word + ") = " + hash(word) + ", charge = " + 100*size/CAPACITY + "%"); } private static int hash(Object object) { ++size; int h = (object.hashCode() & MASK) % CAPACITY; while (used[h]) { System.out.print(h + ", "); h = (h+1)%CAPACITY; } used[h] = true; return h; } } hash(Rad) = 3, charge = 9% hash(Uhr) = 4, charge = 18% hash(Ohr) = 2, charge = 27% hash(Tor) = 8, charge = 36% hash(Hut) = 5, charge = 45% 3, 4, 5, hash(Tag) = 6, charge = 54% 5, 6, hash(Eis) = 7, charge = 63% 3, 4, 5, 6, 7, 8, hash(Ast) = 9, charge = 72% 9, hash(Zug) = 10, charge = 81% 3, 4, 5, 6, 7, 8, 9, 10, hash(Hof) = 0, charge = 90% 2, 3, 4, 5, 6, 7, 8, 9, 10, 0, hash(Mal) = 1, charge = 100%
294
Tables
Le champ size contient le nombre d’éléments hachés dans la table. Le tableau used[] indique quels composants sont occupés dans cette même table. La méthode printHash() imprime l’index de la table de hachage et le facteur de charge correspondant sous forme d’un pourcentage. Lorsque les essais linéaires sont effectués, chaque test successif des numéros d’index est imprimé. Comme nous l’avons déjà vu dans l’exemple 14.4, la collision a lieu au moment de l’insertion de "Tag". Ce programme indique que trois collisions se sont produites, au niveau des numéros d’index 3, 4 et 5, avant qu’un emplacement de hachage libre soit trouvé à l’index 6. Après cette insertion, la table est remplie à 54 %. Chaque élément suivant entrera également en collision avec les autres. Bien évidemment, les collisions deviennent plus fréquentes au fur et à mesure du remplissage de la table. Ainsi, le dernier élément "Hut" rencontre 10 collisions. Cela signifie qu’à ce stade, chaque fois que vous accéderez à cet élément, vous devrez rechercher 11 éléments avant de le trouver, d’où un processus exécuté en O(n). Un autre algorithme de résolution des collisions, plus efficace que les essais linéaires, est à votre disposition : il s’agit de la méthode quadratique qui ignore certains éléments au cours des tests, créant ainsi une répartition plus uniforme des composants utilisés et moins de blocs importants. Cette méthode améliorera les performances de vos programmes puisqu’elle implique des chaînes d’essais plus courtes. Vous remarquerez que l’index revient au début de la liste au moment de l’insertion de "Mal" : 2, 3, 4, 5, 6, 7, 8, 9, 10, 0, 1.
Exemple 14.7 Méthode quadratique Ce programme ajoute à celui de l’exemple précédent la fonction hash() modifiée : private static int hash(Object object) { ++size; int h = (object.hashCode() & MASK) % CAPACITY; if (used[h]) { int h0=h; int jump=1; while (used[h]) { System.out.print(h + ", "); h = (h0+jump*jump)%CAPACITY; // incrément au carré ++jump; } } used[h] = true; return h; } hash(Rad) = 3, charge = 9% hash(Uhr) = 4, charge = 18% hash(Ohr) = 2, charge = 27% hash(Tor) = 8, charge = 36% hash(Hut) = 5, charge = 45% 3, 4, hash(Tag) = 7, charge = 54% 5, hash(Eis) = 6, charge = 63% 3, 4, 7, hash(Ast) = 1, charge = 72% hash(Zug) = 9, charge = 81%
Cette méthode se différencie essentiellement par ses essais successifs sur les numéros d’index effectués dans la boucle while dès qu’une collision a lieu. En outre, au lieu d’avoir recours à une recherche linéaire, elle utilise un incrément au carré. Par exemple, lorsque l’insertion de "Ast" est en conflit avec l’index 3, l’essai linéaire poursuit ses vérifications aux index 4, 5, 6, 7, 8 et 9, comme c’est le cas dans l’exemple 14.6. En revanche, avec la méthode quadratique, seuls les index 3, 4, 7 et 1 ( = 12 mod 11) sont testés en ignorant 1, 4 et 9 (12, 22 et 32). Dans le cadre d’une méthode linéaire,
Chaînage séparé
295
50 % d’essais minimum sont nécessaires. Cependant, même si la méthode quadratique améliore considérablement les performances de l’algorithme précédent, elle risque de créer une boucle infinie, comme illustré dans l’exemple 14.7 avec l’insertion suivante. En effet, si nous prenons l’exemple de la chaîne "Hof" initialement hachée à l’index 3, l’algorithme d’essais linéaires a trouvé une cellule vide à l’index 0 ( = 11 mod 11) après huit collisions, tandis que la séquence d’essais quadratiques sur le même élément est identique à celle de la chaîne "Ast" : 3, 4, 7, 1, 8, 6, 6, 8 1, 7, 4, 3, 4, etc. Ce résultat est calculé à partir de la séquence quadratique non modulée 3, 4, 7, 12, 19, 28, 39, 52, 67, 84, 103, 124, 147, etc., qui continue indéfiniment et ne teste que les six index 3, 4, 7, 1, 8 et 6, par ailleurs déjà utilisés. Ainsi, bien que la table ne soit remplie qu’à 81 %, l’insertion échoue, ce qui n’aurait pas eu lieu avec les essais linéaires.
14.7 CHAÎNAGE SÉPARÉ Au lieu de chercher à définir un algorithme de résolution des collisions plus efficace, il est préférable d’éviter complètement les collisions en autorisant l’insertion de plusieurs éléments par composant de table. Cette méthode est qualifiée de chaînage séparé parce qu’elle a recours aux listes chaînées pour stocker plusieurs éléments. Dans ce contexte, les composants de tables sont généralement qualifiés de compartiments.
Exemple 14.8 Chaînage séparé Votre classe HashTable peut être en partie définie de la façon suivante à l’aide du chaînage séparé : public class HashTable { private static final int MASK = 0x7FFFFFFF; // 2^32-1 private static int capacity=101; private static int size=0; private static float load=0.75F; private static LinkedList[] buckets; HashTable() { buckets = new LinkedList[capacity]; for (int i=0; i
296
Tables
Comme vous pouvez le constater, la fonction put() joue deux rôles différents : si la table comporte déjà une entrée associée à la clé donnée, elle se contente de modifier la valeur de cette entrée ; dans le cas contraire, elle ajoute simplement une nouvelle entrée avec la paire clé/valeur. La classe java.util.HashMap utilise un chaînage séparé similaire à celui-ci dans l’exemple 14.8.
14.8 APPLICATIONS Les tables sont fréquemment utilisées dans les programmations de systèmes. En outre, il s’agit des blocs principaux qui constituent les bases de données relationnelles. L’exemple suivant illustre leur utilisation dans le cadre de la programmation d’une application.
Exemple 14.9 Concordance Une concordance est une liste de mots apparaissant dans un document texte avec le numéro des lignes sur lesquelles figurent ces mots. Vous pourriez la comparer à l’index d’un livre qui listerait les numéros de ligne au lieu des numéros de page. Les concordances sont particulièrement utiles pour l’analyse de documents lorsque vous recherchez la fréquence de certains mots et des associations qui ne sont pas évidentes si vous lisez directement le document. Shakeaspeare.txt
Ex1409.out
Friends, Romans countrymen, lend me your ears ! I come to bury Caesar, not to praise him. The evil taht men do lives after them, The good is oft interred with their bones ; So let it be with Caesar. The noble Brutus Hath told you Caesar was ambitious ; If it were so, it was a grievous fault ; And grievously hath Caesar answer’d it. Here, under leave of Brutus and the rest, -For Brutus is an honourable man ; So are they all, all honourable men. Come I to speak in Caesar’s funeral. He was my friend, faithful and just to me. But Brutus says he was ambitious ; And Brutus is an honourable man. He hath brought manycaptives home to Rome. Whose ransoms did the general coffers fill : Did this in Caesar seem ambitious ? When that the poor have cried, Caesar hath wept ; Ambition should be made of sterner stuff. Yet Brutus says he was ambitious ; And Brutus is an honourable man. You all did see that on the Lupercal I thrice presented him with a kingly crown, Which he did thrice refuse : was this ambition ? Yet Brutus says he was ambitious ; And, sure, is an honourable man. I speak not to disprove that Brutus spoke, But here I am to speak what I do know. You all did love him once, not without cause. What cause withholds you, then, to mourn for him ? O judgement ! thou art fled to brutish beasts, And men have lost their reason !
STUFF=20 THE=3, 4, 5, 9, 17, 19, 23 GRIEVOUS=7 GRIEVOUSLY=8 WHOSE=17 REASON=33 AND= 8, 9, 13, 15, 22, 27, 33 FAULT=7 KINGLY=24 COUNTRYMEN=1 MOURN=31 FRIENDS=1 GOOD=4 LEAVE=9 ROME=16 CROWN=24 SHOULD=20 INTERRED=4 WEPT=19 FOR=10, 31 FRIEND=13 BUT=14, 29 BRUTUS=5, 9, 10, 14, 15, 21, 22, 26, 28 MAN=10, 15, 22, 27 CAUSE=30, 31 SURE=27 PRESENTED=24 YOU=6, 23, 30, 31 SEE=23 BONES=4 LIVES=3 REFUSE=25 HERE=9, 29
Ce programme crée une concordance pour le texte anglais ci-dessus extrait du Jules César de Shakespeare. La première partie de la concordance obtenue se trouve à droite. Elle est créée grâce à l’utilisation d’une vue Set de la concordance, puis à une itération de l’ensemble qui permet d’imprimer un élément par ligne. Chacun de ces éléments est un objet Map Entry composé de Key, qui correspond au mot dans le texte (en lettres majuscules), et de Value, qui correspond à la liste des
Applications
297
numéros de lignes où ce mot apparaît. Par exemple, le mot man figure aux lignes 10, 15, 22 et 27, la ligne 10 étant For Brutus is an honourable man;
Le programme complet est le suivant : import java.io.*; import java.util.*; public class Ex1409 { private static HashMap concordance; private static File file; private static FileReader reader; private static BufferedReader in; public static void main(String[] args) { new Ex1409("Shakespeare.txt"); load(); dump(); } Ex1409(String filename) { try { concordance = new HashMap(); file = new File(filename); reader = new FileReader(file); in = new BufferedReader(reader); } catch(Exception e) { System.out.println(e); } } private static void load() { String line=null; StringTokenizer parser=null; int lineNumber=0; try { while ((line=in.readLine()) != null) { ++lineNumber; parser = new StringTokenizer(line,",.;:()-!?’ "); while (parser.hasMoreTokens()) { String word = parser.nextToken().toUpperCase(); String listing = (String)concordance.get(word); if (listing==null) listing = "" + lineNumber; else listing += ", " + lineNumber; concordance.put(word,listing); } } } catch(Exception e) { System.out.println(e); } } private static void dump() { Set set = concordance.entrySet(); for (Iterator it=set.iterator(); it.hasNext(); ) System.out.println(it.next()); } }
La méthode main() instancie la classe Ex1409 et passe la chaîne "Shakespeare.txt" à son constructeur. Ce dernier instancie la classe HashMap et crée un objet concordance. Il instancie également l’objet qui lit le fichier d’entrée, à savoir in. La fonction main() appelle load() qui charge la concordance à partir du fichier texte. Cette méthode utilise l’objet parser de la classe
298
Tables
StringTokenizer afin d’analyser le texte, puis elle extrait chaque mot à l’aide de la méthode nextToken(). Elle assure le suivi des numéros de ligne grâce au compteur lineNumber qui est incrémenté chaque fois qu’une nouvelle ligne est lue. Chaque itération de la boucle while externe lit une ligne, tandis que chaque itération de la boucle while interne lit un mot. L’instruction String listing = (String)concordance.get(word); recherche le mot en tant que clé
dans le cadre de la concordance. Si elle le trouve, elle affecte sa valeur correspondante à la chaîne listing. Dans le cas contraire, listing est null et se voit affecter une chaîne avec le numéro de ligne. Si listing n’est pas null, le numéro de ligne est concaténé au listing existant. Dans ces deux cas, l’instruction concordance.put(word,listing); insère ensuite le nouveau listing dans la
table de hachage, sous forme d’une nouvelle entrée ou bien sous forme d’une entrée mise à jour. Vous remarquerez que l’objet parser est créé par l’appel de constructeur parser = new StringTokenizer(line,",.;:()-!? "); qui utilise le constructeur composé de deux arguments pour la classe StringTokenizer, le second argument étant une chaîne composée de tous les caractères délimiteurs. En listant la virgule ’,’, le point ’.’ et le point-virgule ’;’, etc., nous pouvons exclure ces caractères des mots stockés. En dernier lieu, main() appelle dump() qui imprime la concordance. Étant donné qu’il est impossible de parcourir directement la classe HashMap (reportez-vous à la section 14.1), nous devons d’abord insérer la concordance dans un objet Set. Cela nous permettra d’obtenir une « vue » différente de la table de hachage, c’est-à-dire un ensemble dont les éléments correspondent à des entrées de table. Ensuite, grâce à la méthode iterator() de l’interface Set, nous pouvons aisément parcourir cette dernière à l’aide d’une boucle for en accédant à chaque entrée de façon séquentielle. La méthode next() de l’itérateur renvoie une entrée sous forme d’un Object dont le type intrinsèque est Map.Entry. D’ailleurs, comme vous pouvez le constater dans la définition de l’interface Map présentée au début de ce chapitre, Entry est une interface interne de Map. C’est pourquoi, lorsqu’elle est passé à println(), la méthode Map.Entry .toString() est appelée, créant ainsi une chaîne de sortie similaire à l’exemple suivant : BRUTUS=5, 9, 10, 14, 15, 21, 22, 26, 28
Le signe égal '=' sépare la clé de l’entrée et sa valeur. Dans le cas présent, la clé est constituée par la chaîne "BRUTUS" et la valeur par la chaîne "5, 9, 10, 14, 15, 21, 22, 26, 28". Les concordances permettent d’analyser un texte. Grâce à l’exemple que nous venons de voir, vous pouvez déterminer que cette scène de Shakespeare est plus consacrée à Brutus et à l’utilisation de son homonyme « brutish » qu’à César. La sortie du programme de l’exemple 14.9 illustre un inconvénient majeur des tables de hachage : elles ne sont pas ordonnées. Afin d’obtenir une sortie par ordre alphabétique de la concordance, nous devons d’abord trier cette dernière.
14.9 CLASSE TreeMap La classe TreeMap étend la classe AbstractMap et implémente l’interface SortedMap (reportez-vous à la section 14.1). Il s’agit en fait d’un arbre binaire de recherche et non d’une table de hachage. Cependant, elle a encore recours aux mappes et à leurs entrées clé/valeur. Étant une structure arborescente, elle perd son temps d’accès égal à O(1), mais présente l’avantage d’être ordonnée.
Exemple 14.10 Concordance ordonnée Ce programme se différencie de celui de l’exemple 14.9 par le fait que la classe HashMap a été remplacée par TableMap dans le constructeur : Ex1410(String filename) { try { concordance = new TableMap();
Classe TreeMap
299
file = new File(filename); reader = new FileReader(file); in = new BufferedReader(reader); } catch(Exception e) { System.out.println(e); } }
Ce programme crée une concordance stockée sous forme d’arbre binaire de recherche ordonné ; c’est pourquoi votre sortie est maintenant triée : A=7, 24 AFTER=3 ALL=11, 11, 23, 30 AM=29 AMBITION=20, 25 AMBITIOUS=6, 14, 18, 21, 26 AN=10, 15, 22, 27 AND=8, 9, 13, 15, 22, 27, 33 ANSWER=8 ARE=11 ART=32 BE=5, 20 BEASTS=32 BONES=4 BROUGHT=16 BRUTISH=32 BRUTUS=5, 9, 10, 14, 15, 21, 22, 26, 28 BURY=2 BUT=14, 29 CAESAR=2, 5, 6, 8, 12, 18, 19 CAPTIVES=16 CAUSE=30, 31 COFFERS=17 COME=2, 12 COUNTRYMEN=1 CRIED=19 CROWN=24 D=8 DID=17, 18, 23, 25, 30 DISPROVE=28 DO=3, 29 EARS=1 EVIL=3 FAITHFUL=13 FAULT=7 FILL=17 FLED=32 FOR=10, 31 FRIEND=13 FRIENDS=1 FUNERAL=12 GENERAL=17 GOOD=4 GRIEVOUS=7 GRIEVOUSLY=8 HATH=6, 8, 16, 19 HAVE=19, 33 HE=13, 14, 16, 21, 25, 26 HERE=9, 29 HIM=2, 24, 30, 31
300
Tables
Cet exemple prouve non seulement la dichotomie fondamentale entre les tables de hachage non ordonnées linéaires et les arbres binaires de recherche ordonnés quadratiques, mais illustre également l’efficacité du framework de collections Java. En effet, étant donné que les classes HashMap et TreeMap implémentent toutes les deux la même interface Map, nous pouvons les permuter en changeant simplement le nom du constructeur appelé pour créer la mappe.
?
QUESTIONS DE RÉVISION
QUESTIONS DE RÉVISION
14.1
Quelle est la différence entre une table et un vecteur ?
14.2
Pourquoi une table est-elle également qualifiée de mappe ?
14.3
Pourquoi une table est-elle également qualifiée de tableau associatif ?
14.4
Pourquoi une table est-elle également qualifiée de dictionnaire ?
14.5
Qu’est-ce qu’une concordance ?
14.6
Qu’est-ce qu’une table de hachage ?
14.7
Quelle est la différence entre les classes Java Hashtable et HashMap ?
14.8
Les deux premiers exemples de ce chapitre ont démontré que l’ordre d’insertion des éléments dans une table de hachage n’a aucune importance s’il n’y a pas de collisions. Mais que se passet-il si des collisions ont lieu ?
14.9
Quels sont les avantages et les inconvénients de la méthode quadratique par rapport à la méthode des essais linéaires ?
14.10 Dans quelles conditions est-il préférable d’utiliser une classe HashMap plutôt qu’une classe TreeMap, et inversement ?
¿
RÉPONSES
RÉPONSES
14.1
Un vecteur fournit un accès direct à ses éléments grâce à des index entiers. Une table fournit un accès direct à ses éléments grâce à un champ clé de type ordinal, c’est-à-dire de type int, double, string, etc.
14.2
Une table est également qualifiée de mappe parce qu’elle agit comme une fonction mathématique et mappe chaque valeur de clé à un élément unique.
14.3
Une table est également qualifiée de tableau associatif parce qu’elle fonctionne comme un tableau (voir la réponse 14.1) dans lequel chaque valeur de clé est associée à son élément unique. Comme les fonctions mathématiques, elle mappe chaque valeur de clé à un élément unique.
14.4
Une table est également qualifiée de dictionnaire parce qu’elle est utilisée comme un dictionnaire de langue standard, c’est-à-dire pour rechercher des éléments au même titre que dans un dictionnaire.
14.5
Une concordance est une liste de mots trouvés dans un document texte et associés aux numéros des lignes dans lesquelles ils apparaissent.
Révision et entraînement
301
Une table de hachage est une table qui utilise une fonction spéciale afin de calculer l’emplacement des valeurs des données à partir des valeurs des clés au lieu de stocker ces clés dans la table. 14.7 Elles sont presque identiques. La classe HashMap constitue une amélioration de la classe Hashtable, ce qui lui permet de rester cohérente avec le framework de collections Java. 14.8 Même si des collisions ont lieu, l’ordre d’insertion n’a aucune importance. 14.9 La méthode quadratique crée généralement moins de collisions parce que les essais qu’elle effectue ignorent les emplacements vides de la fourchette d’index. Cependant, contrairement aux essais linéaires, elle risque de provoquer des boucles infinies même lorsque la table n’est pas pleine. 14.10 Un objet HashMap est une table de hachage implémentée avec un chaînage séparé et un plafond de charge par défaut égal à 75 %. Il offre donc un temps d’accès presque égal à O(1) pour les insertions, les suppressions et les recherches. En revanche, un objet TreeMap est un arbre de recherche binaire équilibré implémenté comme un arbre rouge et noir. C’est pourquoi il offre un temps d’accès presque égal à O(lgn) pour les insertions, les suppressions et les recherches. HashMap est donc plus rapide, alors que TreeMap présente l’avantage d’être ordonné. 14.6
?
EXERCICES D’ENTRAÎNEMENT
EXERCICES D’ENTRAÎNEMENT
14.1
Exécutez un programme similaire à celui de l’exemple 14.1 vous permettant d’insérer 16 entrées dans le dictionnaire allemand-anglais : • map.put("Ast","gate"); • map.put("Eis","ice"); • map.put("Hof","court, yard, farm"); • map.put("Hut","hat"); • map.put("Lob","praise"); • map.put("Mal","mark, signal"); • map.put("Mut","courage"); • map.put("Ohr","ear"); • map.put("Ost","east"); • map.put("Rad","wheel"); • map.put("Rat","advice, counsel"); • map.put("Tag","day"); • map.put("Tor","gate"); • map.put("Uhr","clock"); • map.put("Wal","whale"); • map.put("Zug","procession, train");
14.2
14.3
14.4
Modifiez la classe Concordance pour qu’elle filtre et qu’elle élimine les mots communs (pronoms, adverbes, etc.) dont la liste n’apporte rien à l’analyse du document. Stockez ces mots communs dans un fichier séparé que vous nommerez MotsCommuns.txt et qui sera similaire à celui de la figure suivante : Modifiez le programme de l’exemple 14.1 de façon à ce qu’il stocke les mots par ordre alphabétique. Il devra charger les mêmes données que celles de l’exercice 14.1, puis imprimer le contenu de la table par ordre alphabétique. Implémentez une classe FrequencyTable destinée à créer une liste de mots et leur fréquence d’apparition dans un fichier texte donné.
MotsCommuns.txt A AFTER ALL AM AN AND ARE BE BROUGHT BUT COME DID DO FOR HATH HAVE HE HERE HIM I IF IN IS IT
302
Tables
¿
SOLUTIONS
SOLUTIONS
14.1
Vous insérerez 16 nouvelles entrées dans le dictionnaire allemand-anglais de la façon suivante : • import java.util.*; • public class Testing • { public static void main(String[] args) • { Map map = new HashMap(11); • map.put("Ast","gate"); • map.put("Eis","ice"); • map.put("Hof","court, yard, farm"); • map.put("Hut","hat"); • map.put("Lob","praise"); • map.put("Mal","mark, signal"); • map.put("Mut","courage"); • map.put("Ohr","ear"); • map.put("Ost","east"); • map.put("Rad","wheel"); • map.put("Rat","advice, counsel"); • map.put("Tag","day"); • map.put("Tor","gate"); • map.put("Uhr","clock"); • map.put("Wal","whale"); • map.put("Zug","procession, train"); • System.out.println("map=" + map); • System.out.println("map.keySet()=" + map.keySet()); • System.out.println("map.size()=" + map.size()); • } •}
14.2
La classe Concordance capable de filtrer les mots courants est la suivante : • import java.io.*; • import java.util.*; • public class Pr1403 • { private static Map concordance; • private static Set words; • private static File file; • private static FileReader reader; • private static BufferedReader in; • public static void main(String[] args) • { loadSet("MotsCourants.txt"); • dumpSet(); • loadMap("Shakespeare.txt"); • dumpMap(); • } • private static void loadSet(String filename) • { try • { words = new HashSet(); • file = new File(filename); • reader = new FileReader(file); • in = new BufferedReader(reader); • String line=null; • StringTokenizer parser=null; • while ((line=in.readLine()) != null) • { parser = new StringTokenizer(line,",.;:()-!?’ "); • String word = parser.nextToken().toUpperCase(); • words.add(word);
Révision et entraînement • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • •}
14.3
} } catch(Exception e) { System.out.println(e); } } private static void dumpSet() { for (Iterator it=words.iterator(); it.hasNext(); ) System.out.println(it.next()); } private static void loadMap(String filename) { try { concordance = new TreeMap(); file = new File(filename); reader = new FileReader(file); in = new BufferedReader(reader); String line=null; StringTokenizer parser=null; int lineNumber=0; while ((line=in.readLine()) != null) { ++lineNumber; parser = new StringTokenizer(line,",.;:()-!?’ "); while (parser.hasMoreTokens()) { String word = parser.nextToken().toUpperCase(); if (!words.contains(word)) { String listing = (String)concordance.get(word); if (listing==null) listing = "" + lineNumber; else listing += ", " + lineNumber; concordance.put(word,listing); } } } } catch(Exception e) { System.out.println(e); } } private static void dumpMap() { Set set = concordance.entrySet(); for (Iterator it=set.iterator(); it.hasNext(); ) System.out.println(it.next()); }
Vous pouvez trier le dictionnaire allemand-anglais de la façon suivante : • import java.util.*; • public class Pr1202 • { private static Map map; • public static void main(String[] args) • { map = new TreeMap(); • load(); • dump(); • } • private static void load() • { map.put("Ast","gate"); • map.put("Eis","ice"); • map.put("Hof","court, yard, farm"); • map.put("Hut","hat"); • map.put("Lob","praise"); • map.put("Mal","mark, signal"); • map.put("Mut","courage"); • map.put("Ohr","ear"); • map.put("Ost","east"); • map.put("Rad","wheel");
303
304
Tables • • • • • • • • • • • • •}
14.4
map.put("Rat","advice, counsel"); map.put("Tag","day"); map.put("Tor","gate"); map.put("Uhr","clock"); map.put("Wal","whale"); map.put("Zug","procession, train"); } private static void dump() { Set set = map.entrySet(); for (Iterator it=set.iterator(); it.hasNext(); ) System.out.println(it.next()); }
La table de fréquence peut être implémentée de la façon suivante : • import java.io.*; • import java.util.*; • public class Pr1404 • { private static Map concordance; • private static File file; • private static FileReader reader; • private static BufferedReader in; • public static void main(String[] args) • { loadMap("Shakespeare.txt"); • dumpMap(); • } • private static void loadMap(String filename) • { try • { concordance = new TreeMap(); • file = new File(filename); • reader = new FileReader(file); • in = new BufferedReader(reader); • String line=null; • StringTokenizer parser=null; • int lineNumber=0; • while ((line=in.readLine()) != null) • { ++lineNumber; • parser = new StringTokenizer(line,",.;:()-!?’ "); • while (parser.hasMoreTokens()) • { String word = parser.nextToken().toUpperCase(); • String frequency = (String)concordance.get(word); • if (frequency==null) frequency = "1"; • else • { int n=Integer.parseInt(frequency); • ++n; • frequency = "" + n; • } • concordance.put(word,frequency); • } • } • } • catch(Exception e) { System.out.println(e); } • } • private static void dumpMap() • { Set set = concordance.entrySet(); • for (Iterator it=set.iterator(); it.hasNext(); ) • System.out.println(it.next()); • } •}
Chapitre 15
Ensembles 15.1 ENSEMBLES MATHÉMATIQUES Un ensemble est une collection d’éléments uniques. Sa taille correspond au nombre d’éléments qui le composent. L’ensemble de taille 0 est qualifié d’ensemble vide ; il est représenté par le symbole ∅. En mathématiques, les ensembles sont déterminés par la liste de leurs éléments présentée de la façon suivante : A= {44, 77, 22} ou par une liste basée sur un modèle donné : B = {2, 4, 6, 8, 10, 12, . . .} ou bien à l’aide d’une condition : C = {n ∈ Z | n est pair} = {n ∈ Z : n est impair} Le symbole suivant correspond à l’ensemble de tous les entiers : Z = { 0, 1, –1, 2, –2, 3, –3, . . .} Le symbole de l’opérateur de transfert de données (|) et les deux-points (:) sont lus « tel que ». Ainsi, la définition de l’ensemble C ci-dessus serait lue « l’ensemble de tous les entiers n tels que n est pair », ce qui signifie simplement l’ensemble de tous les entiers pairs. Les trois opérations principales susceptibles d’être effectuées sur un ensemble sont l’union, l’intersection et le complément relatif (également qualifié de soustraction). L’union des ensembles A et B est indiquée et définie de la façon suivante : A ∪ B = {x : x ∈ A ou x ∈ B} L’intersection des ensembles A et B est indiquée et définie de la façon suivante : A ∩ B = {x : x ∈ A et x ∈ B} Quant au complément relatif de l’ensemble B dans l’ensemble A, il est indiqué et défini de la façon suivante : A - B = {x : x ∈ A mais (x ∉ B)} L’opérateur relationnel principal susceptible d’être appliqué aux ensembles est l’opérateur de sousensemble : A est un sous-ensemble de B si et seulement si chaque élément de A est également un élément de B. Cette condition est signalée de la façon suivante : A ⊆ B. Vous remarquerez que chaque ensemble est un sous-ensemble de lui-même et que l’ensemble vide est un sous-ensemble de chaque ensemble.
306
Ensembles
Exemple 15.1 Opérations théoriques sur les ensembles Supposons que A = {1, 2, 3, 4, 5} et B = {4, 5, 6, 7}. Alors A ∪ B = {1, 2, 3, 4, 5, 6, 7}, A ∩ B = {4, 5} et A – B = {1, 2, 3}. Supposons que C = {2, 3, 4}, alors C est un sous-ensemble de A, mais pas de B.
15.2 INTERFACE JAVA Set Dans le cadre du framework de collections Java (reportez-vous à la collection 5.1), l’implémentation des ensembles est parallèle à celles des tables que nous venons d’étudier au chapitre 14. Ce framework comprend notamment les classes et les interfaces suivantes : Object AbstractCollection
Collection
AbstractSet
Set
HashSet TreeSet AbstractMap
SortedSet Map
HashMap TreeMap
SortedMap
L’interface java.util.Set est une sous-interface de Collection ; elle hérite donc des méthodes de cette dernière : public public public public public public public public public public public public public public public
boolean boolean void boolean boolean boolean int boolean Iterator boolean boolean boolean int Object[] Object[]
add(Object); addAll(Collection); clear(); contains(Object); containsAll(Collection); equals(Object); hashCode(); isEmpty(); iterator(); remove(Object); removeAll(Collection); retainAll(Collection); size(); toArray(); toArray(Object[]);
Aucune autre méthode n’est ajoutée à l’interface Set.
15.3 CLASSE JAVA AbstractSet La classe java.util.AbstractSet est une implémentation partielle de l’interface Set. À l’instar des autres classes du framework de collections, elle est conçue de façon à limiter les efforts nécessaires à la construction d’une classe complète. Elle implémente la plupart des méthodes requises par l’interface Set : public abstract class AbstractSet extends AbstractCollection implements Set { protected AbstractSet() { } public boolean add(Object); public boolean addAll(Collection);
307
Classe Java HashSet
public public public public public public public abstract public public public public abstract public public
void boolean boolean boolean int boolean Iterator boolean boolean boolean int Object[] Object[]
clear(); contains(Object); containsAll(Collection); equals(Object); hashCode(); isEmpty(); iterator(); remove(Object); removeAll(Collection); retainAll(Collection); size(); toArray(); toArray(Object[]);
}
15.4 CLASSE JAVA HashSet La classe Java HashSet est globalement identique à la classe HashMap, si ce n’est que ses objets sont des composants simples constitués d’éléments d’un ensemble au lieu des composants doubles constitués de paires clé/valeur. À l’instar de la classe HashMap, la classe HashSet a recours aux valeurs hashCode() intrinsèques des objets pour les stocker dans une table de hachage. Ainsi, l’accès à ses éléments (à l’aide des méthodes add(), contains() et remove()), a une durée d’exécution presque constante (c’est-à-dire que les algorithmes ont une complexité O(1)). Cette classe est définie de la façon suivante dans le paquetage java.util : public class HashSet extends AbstractSet implements Set { public HashSet(); public HashSet(Collection collection); public HashSet(int capacity, float threshold); public HashSet(int capacity); public Iterator iterator(); public int size(); public boolean isEmpty(); public boolean contains(Object object); public boolean add(Object object); public boolean remove(Object object); public void clear(); public Object clone(); }
Exemple 15.2 Table de hachage HashSet destinée aux chaînes Le programme suivant crée une table de hachage HashSet nommée pays, puis il la charge à l’aide de sept chaînes. Il utilise ensuite un itérateur pour la parcourir afin d’imprimer son contenu import java.util.*; public class Ex1502 { public static void main(String[] args) { Set pays = new HashSet(); load(pays); dump(pays); } private static void load(Set set) { set.add("Cuba"); set.add("Iran"); set.add("Iraq"); set.add("Laos"); set.add("Russie");
308
Ensembles
set.add("Chine"); set.add("Togo"); } private static void dump(Set set) { for (Iterator it=set.iterator(); it.hasNext(); ) System.out.println(it.next()); System.out.println("set.size() = " + set.size()); } } Chine Russie Iraq Iran Cuba Togo Laos set.size() = 7
Comme vous pouvez le constater, l’ordre des éléments de la table de hachage est totalement aléatoire. Les index réels des éléments sont calculés par une fonction de hachage privée.
Exemple 15.3 Modifier la table de hachage HashSet composée de chaînes Ce programme illustre l’utilisation des méthodes add(), contains() et remove() sur les ensembles HashSet. Il reprend les mêmes données que celles de l’exemple précédent : import java.util.*; public class Ex1503 { public static void main(String[] args) { Set pays = new HashSet(); load(pays); report(pays,"Iraq"); System.out.println("pays.remove(\"Iraq\") = " + pays.remove("Iraq")); report(pays,"Iraq"); report(pays,"Fiji"); System.out.println("pays.remove(\"Fiji\") = " + pays.remove("Fiji")); System.out.println("pays.add(\"Fiji\") = " + pays.add("Fiji")); report(pays,"Fiji"); System.out.println("pays.add(\"Fiji\") = " + pays.add("Fiji")); report(pays,"Fiji"); } private static void report(Set set, String string) { System.out.println("\t\t" + set.size() + " éléments : " + set); System.out.println("\t\tset.contains(" + string + ") = " + set.contains(string)); } private static void load(Set set) { set.add("Cuba"); set.add("Iran"); set.add("Iraq"); set.add("Laos"); set.add("Russie"); set.add("Chine"); set.add("Togo");
309
Classe Java TreeSet
} } 7 éléments : [Chine, Russie, Iraq, set.contains(Iraq) = true pays.remove("Iraq") = true 6 éléments : [Chine, Russie, Iran, set.contains(Iraq) = false 6 éléments : [Chine, Russie, Iran, set.contains(Fiji) = false pays.remove("Fiji") = false pays.add("Fiji") = true 7 éléments : [Chine, Fiji, Russie, set.contains(Fiji) = true pays.add("Fiji") = false 7 éléments : [Chine, Fiji, Russie, set.contains(Fiji) = true
Iran, Cuba, Togo, Laos] Cuba, Togo, Laos] Cuba, Togo, Laos]
Iran, Cuba, Togo, Laos] Iran, Cuba, Togo, Laos]
La méthode remove() signale si l’opération est réussie ou non. C’est pourquoi l’appel pays .remove("Fiji") renvoie false. En effet, à ce stade de l’exécution du programme, "Fiji" n’était pas encore un élément de l’ensemble. La méthode add() signale également si l’opération est réussie ou non. L’accès aux éléments de l’ensemble (via les méthodes add(), contains() et remove() est donc exécuté en une durée logarithmique, c’est-à-dire par des algorithmes de complexité O(lgn).
15.5 CLASSE JAVA TreeSet La classe Java TreeSet est globalement identique à la classe HashMap, si ce n’est que ses objets sont des composants simples constitués d’éléments d’un ensemble au lieu des composants doubles constitués des paires clé/valeurs. À l’instar de la classe TreeMap, la classe TreeSet stocke ses objets dans un arbre binaire de recherche équilibré (un arbre rouge et noir). C’est pourquoi l’accès à ses éléments (à l’aide des méthodes add(), contains() et remove()) est exécuté en une durée logarithmique, c’està-dire par des algorithmes de complexité O(lgn). Cette classe est définie dans le paquetage java.util de la façon suivante : public class TreeSet extends AbstractSet implements Set { public TreeSet(); public TreeSet(Collection collection); public TreeSet(SortedSet set); public TreeSet(Comparator comparator); public Iterator iterator(); public int size(); public boolean isEmpty(); public boolean contains(Object object); public boolean add(Object object); public boolean remove(Object object); public void clear(); public Object clone(); public Comparator comparator(); public Object first(); public Object last(); public SortedSet subSet(Object start, Object stop); public SortedSet headSet(Object stop); public SortedSet tailSet(Object start); }
310
Ensembles
Ce programme implémente non seulement les méthodes de la classe HashSet, mais également des méthodes supplémentaires requises par l’interface SortedSet. Il s’agit notamment des cinq dernières méthodes de la liste précédente qui renvoient le premier et le dernier éléments, ainsi que trois types de segments de sous-ensembles. La classe HashSet utilise la méthode equals() de ses éléments pour rechercher les éléments donnés, ce qui est largement suffisant puisque l’ordre n’a pas d’importance dans une table de hachage. En revanche, l’ordre jouant un rôle essentiel dans les arbres de recherche, la classe TreeSet utilise soit la méthode compareTo() de ses éléments, soit un objet Comparator. Si le quatrième constructeur listé dans le programme ci-dessus est appelé, l’objet Comparator est utilisé (reportez-vous à la section 12.6).
Exemple 15.4 Utiliser un ensemble TreeSet de chaînes Ce programme illustre l’utilisation des méthodes first(), last(), headSet(), subSet() et tailSet() pour les ensembles TreeSet : import java.util.*; public class Ex1504 { public static void main(String[] args) { SortedSet pays = new TreeSet(); load(pays); System.out.println(pays); System.out.println("pays.first() = " + pays.first()); System.out.println("pays.last() = " + pays.last()); String s1="Fiji"; String s2="Laos"; System.out.println("pays.headSet(" + s1 + ") = " + pays.headSet(s1)); System.out.println("pays.subSet(" + s1 + "," + s2 + ") = " + pays.subSet(s1,s2)); System.out.println("pays.tailSet(" + s2 + ") = " + pays.tailSet(s2)); } private static void load(Set set) { set.add("Laos"); set.add("Cuba"); set.add("Iraq"); set.add("Togo"); set.add("Iran"); set.add("Russie"); set.add("Tchad"); set.add("Chine"); set.add("Guam"); set.add("Fiji"); } } [Tchad, Cuba, Fiji, Guam, Iran, Iraq, Laos, Russie, Chine, Togo] pays.first() = Tchad pays.last() = Togo pays.headSet(Fiji) = [Tchad, Cuba] pays.subSet(Fiji,Laos) = [Fiji, Guam, Iran, Iraq] pays.tailSet(Laos) = [Laos, Russie, Chine, Togo]
Vous remarquerez cette fois encore que, dès qu’un intervalle d’éléments est spécifié, l’élément de début est le premier du segment, et l’élément de fin est celui qui se trouve après le dernier élément du segment. Par exemple, l’appel pays.subSet("Fiji","Laos") renvoie le segment qui commence par "Fiji" et se termine par "Iraq" qui se trouve juste avant "Laos".
Révision et entraînement
?
311
QUESTIONS DE RÉVISION
QUESTIONS DE RÉVISION
15.1
Que se passe-t-il lorsque vous essayez d’ajouter (add()) dans un ensemble un élément qui y figure déjà ?
15.2
Que se passe-t-il lorsque vous essayez de supprimer (remove()) d’un ensemble un élément qui n’y figure plus ?
15.3
Quels sont les avantages et les inconvénients de HashSet par rapport à TreeSet ?
¿
RÉPONSES
RÉPONSES
15.1
Toute tentative d’ajout d’un élément en double dans un ensemble via la méthode add() renverra false sans que l’ensemble soit modifié (reportez-vous à l’exemple 15.3).
15.2
Toute tentative de suppression d’un élément inexistant dans un ensemble via la méthode remove() renverra false sans que l’ensemble soit modifié (reportez-vous à l’exemple 15.3).
15.3
Un objet HashSet est une table de hachage implémentée avec un chaînage séparé et un seuil de chargement par défaut égal à 75 %. C’est pourquoi il offre un temps d’accès presque égal à O(1) pour les insertions, les suppressions et les recherches. Un objet TreeSet est un arbre de recherche binaire équilibré implémenté sur le modèle d’un arbre rouge et noir. C’est pourquoi il offre un temps d’accès égal à O(lgn) pour les insertions, les suppressions et les recherches. Nous pouvons en déduire qu’une table de hachage HashSet présente l’avantage d’être plus rapide, et l’objet TreeSet celui d’être ordonné.
?
EXERCICES D’ENTRAÎNEMENT
EXERCICES D’ENTRAÎNEMENT
15.1
Implémentez l’opération théorique suivante sur les ensembles pour des objets TreeSet : • public static Set union(Set a, Set b) • // renvoie un nouvel ensemble contenant chaque élément de • // l’ensemble a, chaque élément de l’ensemble b et rien d’autre.
15.2
Implémentez l’opération théorique suivant sur les ensembles pour des objets TreeSet : • public static Set intersection(Set a, Set b) • // renvoie un nouvel ensemble contenant uniquement les éléments • // qui se trouvent dans les ensembles a et b
15.3
Implémentez l’opération théorique suivante sur les ensembles pour des objets TreeSet : • public static Set complement(Set a, Set b) • // renvoie un nouvel ensemble contenant uniquement les éléments • // qui se trouvent dans l’ensemble a, mais pas dans l’ensemble b
312
Ensembles
Implémentez l’opération théorique suivante sur les ensembles pour des objets TreeSet :
15.4
• public static isSubset(Set a, Set b) • // renvoie true si et seulement si chaque élément de l’ensemble a • // est également un élément de l’ensemble b
¿
SOLUTIONS
SOLUTIONS
15.1
L’opération d’union pour les objets TreeSet est la suivante : • public static Set union(Set a, Set b) • { Set c = new TreeSet(a); • for (Iterator it=b.iterator(); it.hasNext(); ) • c.add(it.next()); • return c; •}
15.2
L’opération d’intersection pour les objets TreeSet est la suivante : • public static Set intersection(Set a, Set b) • { Set c = new TreeSet(a); • for (Iterator it=a.iterator(); it.hasNext(); ) • { String x=(String)it.next(); • if (!b.contains(x)) c.remove(x); • } • return c; •}
15.3
L’opération de soustraction (complément relatif) pour les objets TreeSet est la suivante : • public static Set complement(Set a, Set b) • { Set c = new TreeSet(a); • for (Iterator it=b.iterator(); it.hasNext(); ) • c.remove(it.next()); • return c; •}
15.4
L’opération de sous-ensemble pour les objets TreeSet est la suivante : • public static boolean isSubset(Set a, Set b) • { for (Iterator it=a.iterator(); it.hasNext(); ) • if (!b.contains(it.next())) return false; return true;
Chapitre 16
Graphes 16.1 GRAPHES SIMPLES Un graphe simple est un couple G = (V, E), dans lequel V et E sont des ensembles finis et chaque élément E est un sous-ensemble de V composé de deux éléments (c’est-à-dire une paire non ordonnée d’éléments distincts de V). Les éléments de V sont qualifiés de sommets (ou nœuds) et les éléments de E sont qualifiés d’arêtes (ou arcs). Si e ∈ E, alors e = {a, b} pour a, b ∈ V. Dans ce cas, vous pouvez représenter e plus simplement de la façon suivante : e = ab = ba. Nous disons que l’arête e connecte les sommets a et b, que e est incident à a et b, que a et b sont incidents sur e, que a et b sont les points terminaux de l’arête e et que a et b sont adjacents. La taille d’un graphe correspond au nombre d’éléments qui composent son ensemble de sommets.
Exemple 16.1 Graphe simple
a
b
La figure ci-contre illustre le cas d’un graphe simple (V, E) de taille 4. Son ensemble de sommets est V = {a, b, c, d}, et son ensemble d’arêtes E = {ab, ac, ad, bd, cd}. Ce graphe est donc composé de quatre sommets et de cinq arêtes.
c
d
Par définition, une arête est une ensemble composé exactement de deux éléments. C’est pourquoi une boucle ne peut pas être une arête : elle ne comprend qu’un sommet. Nous pouvons en déduire que la définition d’un graphe simple exclut les boucles. D’autre part, étant donné que E est un ensemble, une arête ne peut pas être listée plusieurs fois (les ensembles n’autorisent pas la répétition des membres qui les composent). Nous pouvons en déduire que la définition d’un graphe simple exclut également les arêtes répétées. En revanche les graphes généraux (qui ne sont pas nécessairement simples) autorisent les boucles et les répétitions d’arêtes.
16.2 TERMINOLOGIE DES GRAPHES Si G = (V, E) est un graphe et G′ = (V′, E′), avec V′ ⊆ V et E′ ⊆ E, alors G′ est un sous-graphe de G. Chaque graphe est un sous-graphe maximal de lui-même.
314
Graphes
Exemple 16.2 Sous-graphes Le graphe G1 = (V1, E1) a un ensemble de sommets V1 = {a, b, d} et son ensemble de sommets E1 = {ad, bd} est un sous-graphe non maximal du graphe de l’exemple 16.1. Ce sous-graphe est de taille 3. Le graphe G2 = (V2, E2) a un ensemble de sommets V2 = {a, b, c, d} et son ensemble de sommets E2 = {ad, ac, cd} est un sous-graphe maximal du graphe de l’exemple 16.1. Ce sous-graphe est de taille 4.
G1 a
b
G2 a
b
d
c
d
Le degré (ou la valence) d’un sommet correspond au nombre de sommets incidents. Ainsi, dans le graphe de l’exemple 16.1, le sommet a a un degré 3 et le sommet b a un degré 2. En revanche, dans le sous-graphe de l’exemple 16.2, les sommets a et b ont tous les deux un degré égal à 1. Un point isolé est un sommet de degré 0. ✽ Théorème 16.1 : la somme des degrés des sommets d’un graphe composé de m arêtes est égale à 2m. Démonstration : chaque sommet ajoute la valeur 1 au degré de chacun des deux sommets qui le déterminent. Par conséquent, la contribution totale de m arêtes est égale à 2m. Un graphe complet est un graphe simple dans lequel chaque couple de sommets est connecté par une arête. Pour un nombre donné de n sommets, il n’existe qu’un seul graphe complet de cette taille. Nous faisons donc référence au graphe complet d’un ensemble de sommets donné.
Exemple 16.3 Graphe complet à quatre sommets La figure ci-contre illustre le cas d’un graphe complet de l’ensemble V = {a, b, c, d}, dont l’arête E = {ab, ac, ad, bc, bd, cd}. Vous remarquerez que les graphes des exemples précédents sont des sous-graphes de celui-ci.
a
b
c
d
✽ Théorème 16.2 : le nombre d’arêtes du graphe complet composé de n sommets est égal à n(n–1)/2. Démonstration : pour chacun des n sommets, il existe n – 1 sommets dont ils peuvent être adjacents. Il y a donc n(n – 1) paires ordonnées de sommets et, par conséquent, n(n – 1)/2 paires non ordonnées parce que chaque paire non ordonnée peut être ordonnée de deux façons différentes : (a, b) ou (b, a) dans le cas d’une paire{a, b}. Par exemple, le nombre d’arêtes du graphe complet à quatre sommets de l’exemple 16.3 est le suivant : n(n –1)/2 = 4(4 –1)/2 = 6. ■ Corollaire 16.1 : le nombre d’arêtes d’un graphe de n sommets est égal à m ≤ n(n – 1)/2.
16.3 CHEMINS ET CYCLES Le chemin du sommet a au sommet b d’un graphe correspond à une séquence de sommets (a0a1, a1a2, ak – 1ak), avec a0 = a and ak = b et à une séquence de sommets (e1, e2,…, ek) pour lesquelles si ei connecte un sommet au sommet ai, le sommet suivant ei + 1 connecte le sommet ai à un autre sommet de façon à former une chaîne de sommets connectés de a à b. La longueur d’un chemin est égale au nombre k de sommets le formant.
315
Chemins et cycles
Bien qu’un chemin soit une séquence d’arêtes, il induit naturellement une séquence de sommets adjacents connectés par ces arêtes. Nous pouvons donc noter le chemin (a0a1, a1a2, ak – 1ak) plus simplement sous la forme a0a1a2…ak – 1ak tant que chaque paire (ai – 1, ai) est une arête valide du graphe. Si p = a0a1a2…ak – 1ak est un chemin du graphe, nous pouvons dire que p est un chemin de a0 à ak (ou inversement de ak à a0), que p connecte a0 à ak et que a0 et ak sont connectés par p. Les sommets a0 et ak sont également qualifiés de points terminaux ou points nodaux du chemin. Un chemin élémentaire est un chemin qui ne contient pas plusieurs fois le même sommet.
Exemple 16.4 Chemins de graphes Ce graphe illustre le cas d’un chemin élémentaire abcfde de longueur 5. Écrit plus formellement, il s’agit du chemin (ab, bc, cf, fd, de). En revanche, le chemin abefdbc de longueur 6 n’est pas élémentaire parce que le sommet b apparaît deux fois. C’est également le cas du chemin abefa de longueur 4 dans lequel le sommet a apparaît deux fois. La séquence abf ne constitue pas un chemin étant donné que bf n’est pas une arête. De la même manière, la séquence abb n’est pas un chemin parce que bb n’est pas une arête. En dernier lieu, aba est un chemin de longueur 2 et ab un chemin de longueur 1.
b
a
c
e
d
f
Un graphe est dit connexe si, pour toute paire de sommets distincts, il existe un chemin les reliant. Dans le cas contraire, le graphe est dit non non connexe. Tous les graphes que nous venons de voir étaient connexes.
Exemple 16.5 Graphe non connexe Ce graphe de taille 12 n’est pas connexe. Le composant connexe d’un graphe est un sous-graphe connexe maximal, c’est-à-dire un sous-graphe non contenu dans un autre sous-graphe fortement connexe. Le graphe de l’exemple 16.5 comporte 5 composants connexes de taille 3, 1, 4, 2 et 2. ✽ Théorème 16.3 : chaque graphe correspond à une union d’un ensemble unique de composants connexes. Un chemin fermé a pour dernier sommet le premier. Un cycle est un chemin fermé de longueur égale à 3 au minimum et dont tous les sommets internes sont distincts.
Exemple 16.6 Cycles d’un graphe Le chemin abefa du graphe figurant dans l’exemple 16.4 est un cycle. En revanche, le chemin abedbcfa n’est pas un cycle parce qu’il ne s’agit pas d’un chemin (il contient deux fois le sommet b). Le chemin abef n’est pas un cycle non plus parce qu’il n’est pas fermé. Quant au chemin aba, il ne présente pas les caractéristiques d’un cycle puisque sa longueur est seulement égale à 2. Un graphe est dit acyclique s’il ne contient aucun cycle. Parmi ceux que nous venons de voir, seul le graphe de l’exemple 16.2 est acyclique.
316
Graphes
Un graphe acyclique porte également le nom de forêt libre et un graphe acyclique connexe d’arbre libre. Vous constaterez qu’un arbre (reportez-vous au chapitre 9) est un arbre libre dans lequel un nœud est désigné comme étant la racine. Dans le cadre des graphes, un arbre est donc qualifié d’arbre enraciné et défini comme graphe acyclique connexe avec un sommet désigné comme sa racine. L’arbre maximal d’un graphe est un sous-graphe maximal acyclique connexe.
Exemple 16.7 Arbre maximal La figure suivante représente un graphe et son arbre maximal.
16.4 GRAPHES ISOMORPHES Les graphes G = (V, E) et G′ = (V′, E′) sont isomorphes si la fonction f affecte à chaque sommet x de V un sommet y = f(x) dans V′ de façon à ce que les trois conditions suivantes soient respectées : 1. f est appliquée unilatéralement : chaque x de V se voit affecter un sommet différent y = f(x) dans V′. 2. f est surjective : chaque y de V′ est affecté à x dans V. 3. f préserve l’adjacence : si {x1, x2} est une arête de E, alors {f(x1), f(x2)} est une arête de E. Deux graphes sont dits isomorphes si ce sont les mêmes graphes, mais dessinés différemment. D’un point de vue mathématique, cela signifie qu’ils présentent la même structure (topologie) ; d’un point de vue graphique, cela implique qu’ils peuvent être pivotés dans tous les sens tant que les connexions entre les arêtes ne sont pas rompues.
Exemple 16.8 Graphes isomorphes Les deux graphes suivants sont isomorphes : a a
b
b
d
f
c g
e
e h
h
f g
d
c
317
Graphes isomorphes
L’isomorphisme est indiqué par l’étiquetage des sommets. Vous pouvez ainsi vérifier que le sommet x1 est adjacent au sommet x2 dans le premier graphe, et que les sommets correspondants sont également adjacents dans le second. Par exemple, le sommet a est adjacent aux sommets b, d, e, et f (mais pas aux sommets c, g ou h) dans les deux graphes. Afin de démontrer théoriquement que deux graphes sont isomorphes, il est nécessaire de trouver un isomorphisme entre eux, ce qui revient à les étiqueter avec les mêmes étiquettes pour que l’adjacence s’applique de la même façon. Les chances de découvrir ainsi un isomorphisme sont peu nombreuses étant donné qu’il existe n! possibilités différentes. Par exemple, on dénombre 8! = 40 320 façons différentes d’attribuer 8 étiquettes aux 8 sommets de chaque graphe illustré dans l’exemple 16.8. C’est pourquoi l’algorithme suivant sera plus efficace : 1. Étiquetez arbitrairement les sommets d’un graphe en utilisant ici des entiers positifs. 2. Dans le second graphe, trouvez un sommet ayant le même degré que le sommet 1 du premier graphe et attribuez-lui le numéro 1 également. 3. Étiquetez les sommets adjacents au nouveau sommet 1 avec les numéros identiques à ceux des sommets adjacents à l’autre sommet 1. 4. Répétez l’étape 3 pour chacun des autres sommets au fur et à mesure de leur étiquetage. Si l’étape 3 est impossible à un certain stade du processus, retournez en arrière et essayez un étiquetage différent. Si cela ne fonctionne pas, essayez de prouver que les deux graphes ne sont pas isomorphes. Pour démontrer théoriquement que deux graphes ne sont pas isomorphes, vous devez prouver que chaque étiquetage parmi les n! possibilités différentes est incapable de préserver l’adjacence, ce qui n’est pas très réaliste. Le théorème suivant vous permettra de démontrer beaucoup plus aisément que deux graphes ne sont pas isomorphes. ✽ Théorème 16.4 : tester l’isomorphisme de deux graphes. Pour que deux graphes soient isomorphes, toutes les conditions suivantes doivent être respectées : 1. Ils doivent comporter le même nombre de sommets. 2. Ils doivent comporter le même nombre d’arêtes. 3. Ils doivent comporter le même nombre de composants connexes. 4. Ils doivent comporter le même nombre de sommets de chaque degré. 5. Ils doivent comporter le même nombre de cycles de chaque longueur.
Exemple 16.9 Démontrer que deux graphes ne sont pas isomorphes Comparez les trois graphes suivants aux deux graphes isomorphes de l’exemple 16.8 :
G1
G2
G3
318
Graphes
Chacun de ces graphes est composé de 8 sommets, ils pourraient donc tous être isomorphes des deux autres graphes. Le graphe G1 n’est pas isomorphe aux deux autres parce qu’il ne contient que 14 arêtes, contre 16 pour les graphes de l’exemple 16.8. Or, la condition 2 du théorème 16.5 indique que des graphes isomorphes doivent avoir le même nombre d’arêtes. Bien que le graphe G2 contienne 16 arêtes, il n’est pas isomorphe aux deux autres non plus parce qu’il comporte 2 composants connexes, contre 1 pour les graphes de l’exemple 16.8. Or, la condition 3 du théorème 16.5 indique que des graphes isomorphes doivent avoir le même nombre de composants connexes. Bien que le graphe G3 soit composé de 16 arêtes et d’un seul composant connexe, il n’est pas isomorphe aux deux autres non plus parce qu’il comporte des sommets de degré 3 et d’autres de degré 5, alors que les deux graphes de l’exemple 16.8 ont des sommets de degré 4. Or, la condition 4 du théorème 16.5 indique que des graphes isomorphes doivent avoir le même nombre de sommets de chaque degré. Comme vous pouvez le constater, il n’est pas nécessaire de comparer chaque graphe de l’exemple 16.9 aux deux graphes de l’exemple 16.8 simultanément ; une comparaison avec chaque graphe l’un après l’autre suffit amplement. ✽ Théorème 16.5 : l’isomorphisme des graphes dans une relation d’équivalence. La relation d’isomorphisme entre les graphes respecte les trois propriétés d’une relation d’équivalence : 1. Chaque graphe est isomorphe à lui-même. 2. Si G1 est isomorphe à G2, alors G2 est isomorphe à G1. 3. Si G1 est isomorphe à G2 et que G2 est isomorphe à G3, alors G1 est isomorphe à G3. ■ Corollaire 16.2 : si G1 est isomorphe à G2 et qu’un autre graphe G n’est pas isomorphe à G1, alors G n’est pas isomorphe à G2 non plus.
16.5 MATRICE D’ADJACENCE D’UN GRAPHE La matrice d’adjacence d’un graphe (V, E) est représentée par un tableau booléen bidimensionnel boolean[][] a; obtenu en ordonnant les sommets V = {v0, v1, …, vn – 1} et en affectant true à a[i][j] si et seulement si le sommet vi est adjacent au sommet vj.
Exemple 16.10 Matrice d’adjacence La matrice d’adjacence du graphe de l’exemple 16.1 est illustrée ci-contre : Les matrices d’adjacence présentent les caractéristiques suivantes : 1. Une matrice est symétrique, c’est-à-dire que a[i][j]==a[j][i] est vraie pour tout i et tout j. 2. Le nombre d’entrées true est le double du nombre de sommets. 3. Un ordre différent de l’ensemble de sommets V créera des matrices d’adjacence différentes pour le même graphe. Les matrices d’adjacence comportent souvent les valeurs 0 et 1 au lieu de true et false. Dans ce cas, la matrice d’adjacence de l’exemple 16.10 aurait l’aspect ci-contre :
a c
a
b
c
d
a F
T
T
T
b T
F
F
T
c T
F
F
T
d T
T
T
F
b d
a
b
c
d
0
1
1
1
b 1
0
0
1
c 1
0
0
1
d 1
1
1
0
a
319
Matrice d’incidence d’un graphe
16.6 MATRICE D’INCIDENCE D’UN GRAPHE La matrice d’incidence d’un graphe (V, E) est un tableau bidimensionnel int[][] a; obtenu en classant les sommets V = {v0, v1, …, vn – 1} et les arêtes E = {e0, e1, …, em – 1}, puis en affectant 1 à a[i][j] si le sommet vi est incident à l’arête ej, et 0 dans le cas contraire.
Exemple 16.11 Matrice d’incidence
a
b
La matrice d’incidence du graphe de l’exemple 16.1 est illustrée ci-contre : La première ligne indique que le sommet a est incident aux arêtes 1, 2 et 3 ; la deuxième ligne indique que le sommet b est incident aux arêtes 1 et 4, etc.
c
d
a 1
1
1
0
0
b 1
0
0
1
0
c 0
1
0
0
1
d 0
0
1
1
1
Quel que soit le nombre de sommets et d’arêtes d’un graphe, il y aura systématiquement deux valeurs 1 dans chaque colonne d’une matrice d’incident. Pour comprendre la raison de cette propriété, consultez la réponse à la question de révision 16.6.
16.7 LISTE D’ADJACENCES D’UN GRAPHE La liste d’adjacences d’un graphe (V, E) contient un élément pour chaque sommet du graphe, cet élément contenant également une liste des sommets qui lui sont adjacents. La liste secondaire de chaque sommet est qualifiée de liste des arêtes.
Exemple 16.12 Liste d’adjacences La liste d’adjacences du graphe de l’exemple 16.1 est illustrée ci-contre : La liste des arêtes d’un sommet a contient trois éléments, un pour chacune des trois arêtes incidentes à a ; la liste d’incidences du sommet b a deux éléments, un pour chacune des deux arêtes incidentes à b, etc. Vous constaterez que chaque élément de la liste d’arêtes correspond à une entrée true unique dans la matrice d’incidence du graphe. Par exemple, les trois éléments de cette liste pour le sommet c correspondent aux trois true de la troisième ligne (celle du sommet c) dans la matrice d’incidence de l’exemple 16.11. La présentation sous forme de tableau de tous les nœuds composant la liste d’arêtes est identique à celle de la matrice d’incidence. Le diagramme ci-dessus est plutôt complexe. En effet, bien qu’il représente les pointeurs (c’est-à-dire les références) qui seraient stockés dans les listes d’arêtes, les auteurs simplifient souvent sa représentation en listant directement le sommet référencé au lieu des références indirectes comme illustré ci-contre : Vous remarquerez que la liste d’arêtes n’est pas ordonnée ; son ordre n’a en effet aucune importance.
a
b
c
d
a
b
c
b
a
d
c
d
a
d
c
b
d
a
320
Graphes
16.8 DIGRAPHES Un digraphe (ou graphe orienté) est un couple G = (V, E), V étant un ensemble fini et E un ensemble de paires ordonnées d’éléments de V. Comme dans le cas des graphes non orientés, les éléments de V sont qualifiés de sommets (ou nœuds) et les éléments de E sont qualifiés d’arêtes (ou arcs). Si e ∈ E, alors e = (a, b) pour a, b ∈ V. Dans ce cas, il est possible de noter e plus simplement sous la forme e = ab. Nous disons alors que l’arête e part du sommet a et arrive au sommet b. Le degré sortant d’un sommet correspond au nombre d’arêtes qui en partent, et le degré entrant au nombre d’arêtes qui y arrivent. Contrairement à la définition du graphe, celle du digraphe autorise naturellement une arête à arriver au sommet dont elle part. Ce type d’arête est qualifié de boucle. Un digraphe simple n’autorise pas les boucles.
Exemple 16.13 Digraphe Le digraphe suivant a un ensemble de sommets V = {a, b, c, d} et un ensemble d’arêtes E = {ab, ad, bd, ca, dc}. Le sommet a a un degré sortant de 2 et un degré entrant de 1. Les sommets b et c ont tous les deux un degré sortant de 1 et un degré entrant de 1. Le sommet d a un degré sortant de 1 et un degré entrant de 2.
a
b
c
d
✽ Théorème 16.6 : Si G est un digraphe composé de m arêtes, alors la somme de tous ses degrés sortants est égale à m et la somme de tous ses degrés entrants est égale à m. Démonstration : chaque arête ajoute la valeur 1 au total de tous les degrés sortants et 1 au total de tous les degrés entrants. Chaque total doit donc être égal à m. Un digraphe complet est composé d’une arête orientée partant de chaque sommet et allant vers chaque sommet.
Exemple 16.14 Digraphe complet à six sommets Le graphe suivant est un digraphe complet à six sommets. Il est composé de 15 arêtes orientées doubles ; le nombre total d’arêtes unidirectionnelles est donc égal à 30, soit n(n – 1) = 6(6 –1) = 6(5) = 30. ✽ Théorème 16.7 : le nombre d’arêtes d’un digraphe complet à n sommets est égal à n(n – 1). Démonstration : d’après le théorème 16.2, il existe n(n – 1)/2 arêtes non orientées, soit n(n – 1)/2 arêtes orientées doubles. C’est pourquoi le nombre total d’arêtes orientées unidirectionnelles est le double de ce nombre. ■ Corollaire 16.3 : le nombre d’arêtes d’un digraphe à n sommets est égal à m n(n – 1). Chaque digraphe est composé d’un graphe incorporé qui est obtenu en convertissant chaque arête orientée en arête non orientée, puis en supprimant les arêtes et les boucles en double. D’un point de vue mathématique, cela revient à convertir chaque paire ordonnée (x, y) des sommets de E en un ensemble {x, y}, puis à supprimer tous les ensembles de taille 1 (c’est-à-dire les singletons).
Exemple 16.15 Graphe incorporé dans un digraphe Le graphe incorporé du digraphe de l’exemple 16.13 est le graphe de l’exemple 16.1.
a
b
c
d
321
Chemins d’un digraphe
La matrice d’adjacence d’un digraphe (V, E) est un tableau booléen bidimensionnel boolean[][] a; obtenu en classant les sommets V = {v0, v1, …, vn – 1}, puis en affectant la valeur true à a[i][j] si et seulement si une arête part du sommet vi et arrive au sommet vj.
Exemple 16.16 Matrice d’adjacence d’un digraphe
a b c d La matrice d’adjacence du graphe de l’exemple 16.13 est la suivante : a F T F T Comme vous pouvez le constater dans cette matrice, le nombre b F F F T d’entrées true est égal au nombre d’arêtes. En outre, comme dans le cas c T F F F des graphes non orientés, un ordre différent de l’ensemble de sommets V créera des matrices d’adjacence différentes pour le même digraphe. d F F T F La matrice d’incidence d’un digraphe (V, E) est un tableau d’entiers bidimensionnel int[][] a; obtenu en classant les sommets V = {v0, v1, …, vn – 1} et les arêtes E = {e0, e1, …, em – 1}, puis en affectant les valeurs 1 à a[i][j] et –1 à a[j][i] si une arête part du sommet vi et arrive au sommet vj , et en affectant 0 dans tous les autres cas.
Exemple 16.17 Matrice d’incidence d’un digraphe
a 3
1
b 2
a 1
1
0
-1
0
4
b -1 0 1 0 0 La matrice d’incidence du digraphe de l’exemple c d 16.13 est illustrée ci-contre : c 0 0 0 1 -1 5 La première ligne indique que les deux arêtes d 0 -1 -1 0 1 partent du sommet a et que l’une d’entre elles y arrive. La dernière valeur 1 se trouve dans la ligne du sommet d, dans la dernière colonne. La seule autre entrée qui ne soit pas égale à 0 dans cette colonne est la valeur –1 de la ligne du sommet c, ce qui signifie que cette arête part du sommet d et arrive au sommet c.
La liste d’adjacences d’un digraphe (V, E) contient un élément pour chaque sommet du graphe, chacun de ces éléments comportant également une liste des arêtes qui partent de ce sommet. Cette liste d’adjacences est identique à celle d’un graphe mais, cette fois, les liens ne sont pas dupliqués à moins que les arêtes ne soient bidirectionnelles entre deux sommets.
Exemple 16.18 Liste d’adjacences d’un digraphe La liste d’adjacences du digraphe de l’exemple 16.13 est illustrée ci-contre : La liste d’arêtes du sommet a comporte deux éléments, un pour chacune des deux arêtes qui partent de ce sommet : ab et ad.
a
b
c
d
16.9 CHEMINS D’UN DIGRAPHE Le chemin du sommet a au sommet b d’un digraphe correspond à une séquence de sommets (a0a1, a1a2, ak – 1ak), avec a0 = a and ak = b Comme dans le cas des chemins non orientés des graphes non orientés, les chemins orientés sont généralement abrégés par leur chaîne de sommets de la façon suivante : p = a0a1a2…ak – 1ak. Quelle que soit la notation utilisée, nous disons que le chemin part du sommet a et arrive au sommet b. Un chemin est dit fermé s’il arrive au sommet dont il part. Un chemin élémentaire est un chemin qui ne contient pas plusieurs fois le même sommet. Un cycle est un chemin fermé dont tous les sommets internes sont distincts.
322
Graphes
Exemple 16.19 Chemins orientés Dans le digraphe de l’exemple 16.13, adcabdc est un chemin de longueur 6 qui n’est pas fermé. En revanche, le chemin abdcacda est fermé, mais il ne s’agit pas pour autant d’un cycle parce que les sommets internes d (et c) sont répétés. Le chemin dcab est élémentaire et non fermé. Le chemin cabdc est un cycle de longueur 4 et le chemin dcad est un cycle de longueur 3. Vous remarquerez que des cycles différents peuvent parcourir les mêmes sommets. Ainsi, adca et cadc sont des cycles distincts du digraphe de l’exemple 16.13. Un digraphe est fortement connexe s’il existe un chemin entre chaque paire de sommets. À l’inverse, il est faiblement connexe si son graphe incorporé est connexe. Un digraphe qui n’est pas faiblement connexe est dit non connexe.
Exemple 16.20 Digraphes fortement et faiblement connexes Le digraphe G1 est fortement connexe (et donc également faiblement connexe). Le digraphe G2 est faiblement connexe, mais il n’est pas fortement connexe parce qu’aucun chemin n’arrive au sommet x. Quant au digraphe G3, il est non connexe.
G1
G2
G3 x
16.10 DIGRAPHES PONDÉRÉS ET GRAPHES Un digraphe pondéré est un couple (V, w) dans lequel V est un ensemble fini de sommets et w une fonction qui affecte à chaque paire (x, y) un entier positif ou le symbole de l’infini ∞. La fonction w est qualifiée de fonction pondérée et sa valeur w(x, y) peut être interprétée comme le coût (ou le temps, ou encore la distance) de déplacement direct de x à y. La valeur w(x, y) = ∞ indique qu’il n’existe aucune arête entre x et y. Un graphe pondéré est un digraphe pondéré (V, w) dont la fonction w est symétrique, c’est-à-dire que w(x, y) = w(x, y) pour tout x, y ∈ V. De la même façon qu’un digraphe comporte un graphe incorporé, tout digraphe pondéré comporte un graphe pondéré incorporé (V, w). La fonction pondérée de ce dernier peut être définie de la façon suivante : w′(x,y) = min{w(x,y), w(y,x)}, w étant la fonction pondérée du digraphe pondéré. L’ensemble de sommets du graphe incorporé peut être défini comme E = {(x,y) : w(x,y) < ∞}. Les propriétés que nous avons déjà énumérées pour les graphes et les digraphes s’appliquent également aux graphes et aux digraphes pondérés. En outre, de nouvelles propriétés peuvent être ajoutées selon la fonction pondérée sous-jacente. Par exemple, la longueur du chemin pondéré est égale à la somme du poids des arêtes du chemin. Et la distance la plus courte de x à y est égale à la longueur du chemin pondéré minimum parmi tous les chemins qui vont de x à y.
Exemple 16.21 Digraphe pondéré et ses structures incorporées La figure suivante illustre un digraphe pondéré et son graphe pondéré incorporé, son digraphe incorporé et son graphe incorporé. Les pondérations sont indiquées sur les arêtes.
323
Chemins et cycles eulériens et hamiltoniens
3
G1 a 2 c
4 1
b 2
G2 a
d
c
Un digraphe pondéré
3 4
2
1
b 2
G3 a
b
G4 a
b
d
c
d
c
d
Son graphe pondéré incorporé
Son digraphe incorporé
Son graphe incorporé
Dans le graphe G1, la longueur du chemin pondéré cabd est égale à |cabd| = 2 + 3 + 2 = 7 et la distance la plus courte de c à d est de 6 (le long du chemin cad). Cependant, dans le graphe G2, la distance la plus courte est égale à 1 (le long du chemin cd). Vous remarquerez que G3 est identique au graphe de l’exemple 16.13 et que G4 est identique au graphe de l’exemple 16.1. Les matrices d’adjacence, d’incidence et de listes du graphe G1 sont les suivantes : a b c d a ∞ 3 ∞ 4
a 3
4
0
-2 0
b ∞ ∞ ∞ 2 c 2 ∞ ∞ ∞
b -3 0 c 0 0
2
0
0
0
2
-1
d ∞ ∞ 1 ∞
d 0 -4 -2 0
a
3
b
2
c
5
d
1
4
1
16.11 CHEMINS ET CYCLES EULÉRIENS ET HAMILTONIENS Un chemin eulérien d’un graphe comprend chaque arête une seule fois. Un cycle eulérien est un chemin fermé qui comprend chaque arête une seule fois. Quant au graphe eulérien, il comporte un cycle eulérien. Vous constaterez que les chemins et les cycles eulériens n’ont pas besoin d’avoir des sommets distincts ; il ne s’agit donc pas de chemins stricts.
Exemple 16.22 Chemins et cycles eulériens Dans le graphe suivant, le chemin fermé acedabefdbcfa est un cycle eulérien ; vous êtes donc en présence d’un graphe eulérien. Vous remarquerez que chaque sommet du graphe a un degré 4 et que ses 12 arêtes sont réparties en trois cercles. Comme l’indique le théorème suivant, ces deux propriétés garantissent systématiquement qu’un graphe est eulérien.
b
a
e
d
✽ Théorème 16.8 : graphes eulériens Si G est un graphe connexe, les conditions suivantes sont vraies : 1. G est eulérien. 2. Chaque sommet a un degré pair. 3. L’ensemble de toutes les arêtes de G peut être divisé en cycles.
c
f
324
Graphes
Le chemin hamiltonien d’un graphe comprend chaque sommet une seule fois. Un cycle hamiltonien est un cycle qui comprend chaque sommet une seule fois. Un graphe hamiltonien est un graphe qui comporte un cycle hamiltonien. Malheureusement, il n’existe pas de théorème similaire au théorème 16.8 pour les graphes hamiltoniens. Il s’agit d’ailleurs de l’un des plus gros problèmes encore insolubles à l’heure actuelle dans le domaine de l’informatique.
Exemple 16.23 Graphes hamiltoniens Le graphe de gauche est hamiltonien, contrairement à celui de droite qui admet un chemin hamiltonien, mais pas de cycle hamiltonien.
16.12 ALGORITHME DE DIJKSTRA L’algorithme de Dijkstra trouve le chemin le plus cours d’un sommet v0 à un autre sommet du digraphe. Une fois qu’il a terminé, la longueur de la distance la plus courte de v0 à v est stockée dans le sommet v et le chemin le plus court de v0 à v est enregistré dans les pointeurs de v et les autres sommets du chemin (voir l’exemple 16.24). L’algorithme utilise une file de priorité : il l’initialise à l’aide de tous les sommets, puis il retire un sommet de la file à chaque itération.
Algorithme 16.1 Algorithme Dijkstra (Condition préalable : G = (V,w) est un graphe pondéré de sommet initial v0.) (Condition postérieure : chaque sommet v de V stocke la distance la plus courte de v0 à v et une référence au sommet précédent sur ce chemin le plus court.) 1. Initialisez le champ de distance à 0 pour v0 et à ∞ pour chacun des sommets. 2. Insérez dans la file de priorité Q tous les sommets, la priorité la plus élevée étant attribuée à la valeur la plus basse du champ de distance. 3. Répétez les étapes 4 à 10 jusqu’à ce que Q soit vide. 4. (Invariant : les champs de distance et de référence de chaque sommet ne se trouvant pas dans Q sont corrects.) 5. Retirez de la file de priorité le sommet ayant la priorité la plus élevée afin de l’insérer dans x. 6. Effectuez les étapes 7 à 10 pour chaque sommet y adjacent à x et se trouvant dans la file de priorité. 7. Supposons que s soit la somme du champ de distance de x et du poids de l’arête allant de x à y. 8. Si s est inférieur au champ de distance de y, effectuez les opérations 9 à 10 ; dans le cas contraire, retournez à l’étape 3. 9. Affectez s au champ de distance de y. 10. Affectez x au champ de référence de y.
325
Algorithme de Dijkstra
Exemple 16.24 Tracer l’algorithme de Dijkstra La trace de l’algorithme 16.1 sur un graphe de 8 sommets est la suivante : ∞
0 A
5
B
4
3
2
6
∞
1
1
C
4
D
2
∞
4
4
1
∞
5
F
3
6
G
∞
5
B
E
0 H
∞
x
A
6
1
3
3
E
2
6
1
C
4
2
1
∞
5
F
3
6
D
∞
∞
G
1
H
∞
3
∞
À chaque itération, les sommets qui se trouvent encore dans la file de priorité sont mis en grisé et le sommet x est étiqueté. Les champs de distance de chaque sommet sont indiqués comme étant adjacents au sommet et les pointeurs sont dessinés sous forme de flèches. Au cours de la première itération, le sommet ayant la plus grande priorité est x = A parce que son champ de distance est égal à 0 et que tous les autres sont infinis. Les étapes 7 à 10 sont répétées 3 fois, une fois pour chaque voisin de A y = B, C et D. Les valeurs de s calculées pour ces voisins sont de 0 + 4 = 4, 0 + 6 = 6 et 0 + 1 = 1, soit des nombres inférieurs à la valeur infinie courante du champ de distance correspondant. C’est pourquoi ces trois valeurs sont affectées et les pointeurs des trois voisins définis de façon à pointer vers A. Lors de la deuxième itération, le sommet ayant la priorité la plus élevée dans la file de priorité est x = D qui a un champ de distance de 1. Les étapes 7 à 10 sont à nouveau répétées trois fois, une fois pour chaque voisin non visité de D y = B, F et G. Les valeurs de s calculées pour ces voisins sont de 1 + 4 = 5, 1 + 2 = 3 et 1 + 6 = 7, soit des nombres inférieurs à la valeur courante du champ de distance correspondant. C’est pourquoi ces trois valeurs sont affectées et les pointeurs sont définis de façon à pointer vers D. Remarquez comment ces nouvelles opérations ont modifié le champ de distance et le pointeur du sommet B. 4
5
B
4
0 A
3
1
C
1
4
x
4 E
2
6
D 1
5
B
2
5
6
∞
4
1
3
5
F
3
3
G
0 H
∞
A
6
3
E
2
4
1
C
1
3 F
x
5
H 8
1
4
2
6
D 7
∞
1
3
3
G 6
Lors de la troisième itération, le sommet de la file de priorité ayant la priorité la plus élevée est x = F avec un champ de distance égal à 3. Les étapes 7 à 10 sont à nouveau répétées trois fois, une fois pour chaque voisin de F non visité y = C, G et H. Les valeurs de s calculées pour ces trois voisins sont égales à 3 + 1 = 4, 3 + 3 = 6 et 3 + 5 = 8, soit des nombres inférieurs à la valeur courante. C’est pourquoi elles sont affectées et les pointeurs sont définis de façon à pointer vers F. Remarquez comment ces opérations modifient à nouveau le champ de distance et le pointeur du sommet B.
326
Graphes
x
4 B
4
0 A
6
3
5
9
4 E
2
4
1
C
4
1
3
5
F
0 H
A
6
8
1
4
2
3
6
D
1
3
3
6 E
2
4
1
C
x
1
3
5
F
H 8
4
G
2
3
6
D 6
1
5
B
3
G 6
1
Lors de la quatrième itération, le sommet de la file de priorité ayant la priorité la plus élevée est x = B avec un champ de distance de 4. Les étapes 7 à 10 sont répétées deux fois pour y = C et E. Les valeurs de s calculées pour ces deux sommets sont 4 + 3 = 7 et 4 + 5 = 9. Ce dernier nombre étant inférieur à la valeur courante infinie de E, son champ de distance se voit affecter la valeur 9 et son pointeur est défini de façon à pointer vers B. Cependant, étant donné que la valeur 7 n’est pas inférieure au champ de distance courant de C, son champ n’est pas modifié. L’algorithme poursuit avec les itérations restantes, pour x = C, E, G et H de la même façon : 4
5
B
4
0 A
6
3
6 E
2
4
1
C
4
x 1
3
5
F
5
B
4
0 H
A
6
3
6 E
2
4
1
C
1
3
5
F
H
7
1
4
2
3
6
D
4
0 A
6
3
1
C
4
2
6
D 1
6
4 E
5
B
2
4
1
6
5
B
3
G
1
4
7
4
1
3
5
F
x H
0 A
6
3
3
3
G
x
6
6 E
2
4
1
C
1
3
5
F
7
1
4
2
6
D 1
3
3
G
7
1
4
2
6
D 6
H
1
3
3
G 6
Le résultat final vous indique par exemple que le chemin le plus court de A à E est ADFCE. Il a une longueur de 6.
Algorithme de Dijkstra
327
Exemple 16.25 Implémenter de l’algorithme de Dijkstra en Java Le programme Java suivant implémente l’algorithme 16.1 en définissant une classe Network dont les instances représentent des digraphes pondérés : public class Network { Vertex start; private class Vertex { Object object; Edge edges; Vertex nextVertex; boolean done; int dist; Vertex back; } private class Edge { Vertex to; int weight; Edge nextEdge; } public Network() { if (start != null) { start.dist = 0; for (Vertex p = start.nextVertex; p != null; p = p.nextVertex) p.dist = Integer.MAX_VALUE; // infini } } public void findShortestPaths() { // implémente l’algorithme de Dijkstra : for (Vertex v = start; v != null; v = closestVertex()) { for (Edge e = v.edges; e != null; e = e.nextEdge) { Vertex w = e.to; if (!w.done && v.dist+e.weight < w.dist) { w.dist = v.dist+e.weight; w.back = v; } } v.done = true; } } private Vertex closestVertex() { // renvoie le sommet avec la distance minimum parmi les // sommets restants : Vertex v = null; int minDist = Integer.MAX_VALUE; for (Vertex w = start; w != null; w = nextVertex) if (!w.done && w.dist < minDist) { v = w; minDist = w.dist; } return v; } }
Dans le cadre de cette implémentation, nous avons utilisé une méthode de recherche simple closestVertex() au lieu d’une file de priorité. Cette méthode est moins efficace puisqu’elle est exécutée en O(n) au lieu de O(lgn) pour la file de priorité.
328
Graphes
16.13 ALGORITHMES DE PARCOURS DES GRAPHES Les chemins créés par l’algorithme de Dijkstra créent un arbre couvrant minimal pour le graphe. Cet arbre couvrant a la longueur pondérée totale minimale pour le graphe, c’est-à-dire qu’aucun autre arbre n’a de longueur totale plus petite. Cet arbre est créé en largeur d’abord, en considérant les sommets adjacents au sommet courant à chaque itération. Il s’agit de l’une des deux méthodes de parcours d’un graphe. L’algorithme de recherche en largeur d’abord est globalement identique à l’algorithme de Dijkstra si vous ne tenez pas compte des champs de distance.
Algorithme 16.2 Algorithme de recherche en largeur d’abord (Conditions préalables : G = (V,E) est un graphe ou un digraphe dont le sommet initial est v0 ; chaque sommet a un champ booléen visité initialisé à false ; T est un ensemble vide d’arêtes ; L est une liste vide de sommets). (Condition postérieure : L liste les sommets en largeur d’abord et T est un arbre couvrant construit en largeur pour G.) 1. Initialisez la file vide Q en vue du stockage temporaire des sommets. 2. Insérez v0 dans Q. 3. Répétez les étapes 4 à 6 tant que Q n’est pas vide. 4. Retirez les sommets de Q et insérez-les dans x. 5. Ajoutez x à L. 6. Effectuez l’étape 7 pour chaque sommet y adjacent à x. 7. Si y n’a pas été visité, effectuez les étapes 8 à 9. 8. Ajoutez l’arête xy à T. 9. Insérez y dans Q.
Exemple 16.26 Tracer l’algorithme de recherche en largeur d’abord Vous trouverez ci-après la trace de l’algorithme 16.2 pour le graphe suivant : Q
x
L
y
T
A
A
A
B
AB
E
AB,AE
C
AB, AE, BC
F
AB, AE, BC, BF
D
AB, AE, BC, BF, CD
G
AB, AE, BC, BF, CD, CG
B B,E
B
E, C
A, B
E, C, F
E
A, B, E
C, F
C
A, B, E, C
F, D F, D, G
F
A, B, E, C, F
D, G
D
A, B, E, C, F, D
G
G
A, B, E, C, F, D, G
Dans le cas présent, le sommet de départ est v0 = A. Le parcours de recherche en largeur obtenu est renvoyé dans la liste L = (A, B, E, C, F, D, G) et l’arbre minimal de recherche en largeur créé est renvoyé dans l’ensemble T = {AB, AE, BC, BF, CD, CD}.
A
B
C
E
F
G
A
B
C
E
F
G
D
D
329
Algorithmes de parcours des graphes
L’algorithme de recherche en profondeur d’abord utilise une pile au lieu d’une file.
Algorithme16.3 Algorithme de recherche en profondeur d’abord (Conditions préalables : G = (V,E) est un graphe ou un digraphe de sommet initial v0 ; chaque sommet a un champ booléen visité initialisé à false ; T est un ensemble vide d’arêtes ; L est une liste vide de sommets.) (Condition postérieure : L liste les sommets en profondeur d’abord et T est un arbre couvrant construit en profondeur pour G.) 1. Initialisez une pile vide S en vue du stockage temporaire des sommets. 2. Ajoutez v0 à L. 3. Poussez v0 dans S. 4. Indiquez que v0 a été visité. 5. Répétez les étapes 6 à 8 tant que S n’est pas vide. 6. Supposons que x soit l’élément supérieur de S. 7. Si des sommets adjacents de x n’ont pas encore été visités, effectuez les étapes 9 à 13. 8. Sinon, dépilez S et retournez à l’étape 5. 9. Supposons que y soit un sommet non visité adjacent à x. 10. Ajoutez l’arête xy à T. 11. Ajoutez y à L. 12. Poussez y dans S. 13. Indiquez que y a été visité.
Exemple 16.27 Tracer l’algorithme de recherche en profondeur d’abord Vous trouverez ci-après la trace de l’algorithme 16.3 pour le graphe ci-contre identique à celui de l’exemple 16.26 :
A
B
C
E
F
G
D
L
S
x
y
T
A
A
A
B
AB
A, B
A, B
B
C
AB,BC
A, B, C
A, B, C
C
D
AB, BC, CD
A, B, C, D
A, B, C, D
D
G
AB, BC, CD, DG
A, B, C, D, G
A, B, C, D, G
G
A, B, C, D
D
A, B, C
C
F
AB, BC, CD, DG, CF
A, B, C, F
F
A, B, C
C
A, B
B
E
AB, BC, CD, DG, CF, BE
A, B, E
E
A, B
B
A
A
A, B, C, D, G, F
A, B, C, D, G, F, E
Dans le cas présent, le sommet de départ est v0 = A.
330
Graphes
Le parcours de recherche en profondeur obtenu est renvoyé dans la liste L = (A, B, C, D, G, F, E) et l’arbre minimal de recherche en profondeur créé est renvoyé dans l’ensemble T = {AB, BC, CD, DG, CF, BE}.
A
B
C
E
F
G
D
Étant donné que le parcours en profondeur d’abord utilise une pile, il a une version récursive naturelle.
Algorithme 16.4 algorithme récursif de recherche en profondeur d’abord (Conditions préalables : G = (V,E) est un graphe ou un digraphe de sommet initial x ; chaque sommet a un champ visité booléen initialisé à false ; T est un ensemble global d’arêtes ; L est une liste globale de sommets.) (Conditions postérieures : L liste les sommets en profondeur et T est un arbre couvrant construit en profondeur pour G.) 1. Indiquez que x a été visité. 2. Ajoutez x à L. 3. Répétez les étapes 4 à 5 pour chaque sommet y non visité adjacent à v. 4. Ajoutez l’arête xy à T. 5. Appliquez l’algorithme de recherche en profondeur au sous-graphe avec le sommet initial y.
Exemple 16.28 Tracer l’algorithme récursif de recherche en profondeur Vous trouverez ci-après la trace de l’algorithme 16.4 pour le graphe ci-contre identique à celui de l’exemple 16.26 :
A
B
C
E
F
G
L
x
y
T
A
A
B
AB
A, B
B
C
AB,BC
A, B, C
C
D
AB, BC, CD
A, B, C, D
D
G
AB, BC, CD, DG
A, B, C, D, G
G F
AB, BC, CD, DG, CF
E
AB, BC, CD, DG, CF, BE
D
D C A, B, C, D, G, F
F C B
A, B, C, D, G, F, E
E B A
Dans le cas présent, le sommet de départ est v0 = A. Le résultat obtenu est bien évidemment identique à celui de l’exemple 16.27. La seule différence notable est que la pile explicite S a été remplacée par la pile du système qui assure le suivi des appels récursifs.
Algorithmes de parcours des graphes
331
Exemple 16.29 Implémenter les algorithmes de parcours des graphes Le programme Java suivant implémente les deux algorithmes de parcours pour la classe Network que nous avons déjà vue dans le cadre de l’exemple 16.25 : public class Network { Vertex start; private class Vertex { Object object; Edge edges; Vertex nextVertex; boolean visited; } private class Edge { Vertex to; int weight; Edge nextEdge; } public static void visit(Vertex x) { System.out.println(x.object); } public void breadthFirstSearch() { if (start == null) return; Vector queue = new Vector(); visit(start); start.visited = true; queue.addElement(start); while (!queue.isEmpty()) { Vertex v = queue.firstElement(); queue.removeElementAt(0); for (Edge e = v.edges; e != null; e = e.nextEdge) { Vertex w = e.to; if (!w.visited) { visit(w); w.visited = true; queue.addElement(w); } } } } public void depthFirstSearch() { if (start != null) depthFirstSearch(start); } public void depthFirstSearch(Vertex x) { visit(x); x.visited = true; for (Edge e = x.edges; e != null; e = e.nextEdge) { Vertex w = e.to; if (!w.visited) depthFirstSearch(w); } } }
Ce programme utilise la version récursive de la recherche en profondeur qui requiert l’utilisation d’une méthode depthFirstSearch() sans aucun paramètre afin de lancer la méthode récursive depthFirstSearch().
332
Graphes
?
QUESTIONS DE RÉVISION
QUESTIONS DE RÉVISION
16.1 16.2 16.3 16.4 16.5 16.6 16.7
16.8
¿
Quelle est la différence entre un graphe et un graphe simple ? Dans un graphe non orienté, une arête peut-elle être un chemin ? Quelle est la différence entre des sommets connexes et des sommets adjacents ? En utilisant uniquement la définition de l’isomorphisme des graphes, est-il plus facile de démontrer que deux graphes sont isomorphes ou bien qu’ils ne le sont pas ? Pourquoi ? Les cinq conditions exposées dans le théorème 16.4 sont-elles suffisantes pour démontrer que deux graphes sont isomorphes ? Pourquoi la définition naturelle d’un graphe simple interdit-elle les boucles tandis que la définition naturelle d’un digraphe les autorise ? Les affirmations suivantes sont-elles vraies ou fausses : a. Si un graphe a n sommets et n(n – 1)/2 arêtes, il est nécessairement complet. b. La longueur d’un chemin doit être inférieure à la taille du graphe. c. La longueur d’un cycle doit être égale au nombre de sommets distincts qu’il contient. d. Si la matrice d’incidence d’un graphe a n lignes et n(n – 1)/2 colonnes, le graphe est nécessairement complet. e. Dans la matrice d’incidence d’un digraphe, la somme des entrées de chaque ligne est égale au degré entrant du sommet. f. La somme de toutes les entrées d’une matrice d’incidence d’un graphe est égale à 2|E|. g. La somme de toutes les entrées d’une matrice d’incidence d’un digraphe est toujours égale à 0. Un graphe (V, E) est qualifié de dense si |E| = Θ(|V|2), et de creux si |E| = O(|V|). a. Parmi les trois types de représentations que nous avons vues (matrice d’adjacence, matrice d’incidence et liste d’adjacences), laquelle serait la mieux adaptée à un graphe dense ? b. Laquelle serait la mieux adaptée à un graphe creux ?
RÉPONSES
RÉPONSES
16.1 16.2
16.3 16.4
16.5
Un graphe est dit simple lorsqu’il ne contient aucune boucle ni aucune répétition d’arêtes. Non, dans un graphe non orienté, une arête ne peut pas être un chemin. En effet, une arête est nécessairement composée d’un ensemble de deux éléments (c’est-à-dire une paire non ordonnée), tandis qu’un chemin est une séquence (c’est-à-dire une liste ordonnée de sommets). Deux sommets sont dits connexes s’il existe un chemin entre eux. Deux sommets sont dits adjacents s’ils forment une arête. En utilisant uniquement la définition de l’isomorphisme des graphes, il est aisé de démontrer que deux graphes sont isomorphes parce qu’il suffit de trouver un isomorphisme et de le vérifier. En revanche, pour démontrer à partir de cette définition que deux graphes ne sont pas isomorphes, il est nécessaire de vérifier si chacune des n! fonctions unilatérales n’est pas un isomorphisme. Non, les cinq conditions du théorème 16.4 ne suffisent pas pour que deux graphes soient considérés comme isomorphes. Des graphes non isomorphes peuvent tout à fait répondre à ces caractéristiques (reportez-vous à l’exercice d’entraînement 16.7).
333
Révision et entraînement
16.6
La définition naturelle d’un graphe interdit les boucles parce que l’arête d’un graphe est un ensemble composé de deux éléments qui doivent être distincts. En revanche, d’après la définition naturelle d’un digraphe, une arête est une paire ordonnée, ce qui signifie que les deux composants peuvent être identiques.
16.7
a. b. c. d. e. f. g.
16.8
La matrice d’adjacence convient mieux à un graphe dense parce qu’elle est compacte et permet un accès direct rapide. En revanche, c’est la liste d’adjacences qui est la mieux adaptée à un graphe creux parce qu’elle permet d’insérer et de supprimer aisément des arêtes.
?
Vraie Vraie. Vraie. Vraie. Fausse. Vraie. Vraie.
EXERCICES D’ENTRAÎNEMENT
EXERCICES D’ENTRAÎNEMENT
16.1
16.2
Déterminez les propriétés suivantes de ce graphe : a. sa taille n ; b. son ensemble de sommets V ; c. son ensemble d’arêtes E ; d. le degré d(x) de chaque sommet x ; e. un chemin de longueur 3 ; f. un chemin de longueur 5 ; g. un cycle de longueur 4 ; h. un arbre maximal ; i. sa matrice d’adjacence ; j. sa matrice d’incidence ; k. sa liste d’adjacences. Déterminez les propriétés suivantes de ce digraphe : a. sa taille n ; b. son ensemble de sommets V ; c. son ensemble d’arêtes E ; d. le degré entrant id(x) de chaque sommet x ; e. le degré sortant od(x) de chaque sommet x ; f. un chemin de longueur 3 ; g. un chemin de longueur 5 ; h. un cycle de longueur 4 ; i. sa matrice d’adjacence ; j. sa matrice d’incidence ; k. sa liste d’adjacences.
c a
b
e
f
d
b
c
a
f d
e
334 16.3 16.4 16.5
Graphes
Dessinez un graphe complet de n sommets pour n = 2, 3, 4, 5 et 6. Déterminez si le graphe G1 ci-après est eulérien ou hamiltonien. Déterminez si le graphe G2 ci-après est eulérien ou hamiltonien. G1
16.6
G2
Pour chacun des sous-graphes suivants du graphe présenté dans l’exemple 16.7, indiquez s’il est connexe, acyclique et/ou maximal. a.
b.
c.
d.
e.
f.
335
Révision et entraînement
g.
h.
i.
j.
k.
l.
16.7
Trouvez deux graphes non isomorphes pour lesquels les cinq conditions du théorème 16.4 sont vraies.
16.8
Démontrez le corollaire 16.2.
16.9
Décrivez la matrice d’adjacence d’un graphe complet composé de n sommets.
16.10 Décrivez la matrice d’incidence d’un graphe complet composé de n sommets. 16.11 Supposons que G1 soit un graphe représenté par sa liste d’adjacences suivante :
a. b. c. d. e. f.
Dessinez G1. S’agit-il d’un graphe orienté ? S’agit-il d’un graphe fortement connexe ? S’agit-il d’un graphe faiblement connexe ? S’agit-il d’un graphe acyclique ? Dessinez sa matrice d’adjacence.
A B C D E F
F C B A C E
B D
336
Graphes
16.12 Supposons que G2 soit un graphe dont la matrice d’adjacence est la suivante :
a. Dessinez G2. A G2 0 1 01 0 b. S’agit-il d’un graphe simple ? 1 0 10 1 c. S’agit-il d’un digraphe ? 1 4 3 0 1 01 0 E d. S’agit-il d’un digraphe fortement connexe ? 1 1 1 0 1 B 2 e. S’agit-il d’un digraphe faiblement connexe ? 0 1 1 0 1 2 4 f. S’agit-il d’un digraphe acyclique ? 16.13 En vous basant sur le digraphe pondéré G2 suivant : a. Dessinez la matrice d’adjacence de ce graphe. 3 D b. Dessinez la liste d’adjacences de ce graphe. C c. Ce graphe est-il connexe ? Justifiez votre réponse. d. Ce graphe est-il acyclique ? Justifiez votre réponse. 16.14 Parmi les graphes suivants, lesquels sont isomorphes ? Vous remarquerez qu’ils ont tous une taille égale à 10. G1
G2
G3
G4
G6 G5
G7
337
Révision et entraînement
16.15 En vous basant sur les graphes G1 (graphe que nous avons déjà vu dans l’exemple 16.22) et G2
suivants : a. Déterminez s’ils sont isomorphes. b. Trouvez un cycle eulérien dans G2 ou bien expliquez pourquoi il n’en comporte pas. c. Trouvez un cycle hamiltonien dans G2 ou bien expliquez pourquoi il n’en comporte pas. G1
G2
16.16 Un graphe de type roue composé de n sommets est un graphe de taille n + 1
avec un n-cycle dans lequel chacun des n sommets est également adjacent à un sommet central commun unique. Par exemple, le graphe de type roue suivant est composé de 6 sommets. En gardant ces propriétés présentes à l’esprit, décrivez : a. la matrice d’adjacence d’un graphe de type roue composé de n sommets ; b. la matrice d’incidence d’un graphe de type roue composé de n sommets ; c. la liste d’adjacences d’un graphe de type roue composé de n sommets. 16.17 Tracez l’algorithme de Dijkstra (algorithme 16.1) du graphe G8 ci-après en illustrant son chemin le plus court et la distance qui sépare le nœud A de chaque autre nœud. 16.18 Tracez l’algorithme de Dijkstra (algorithme 16.1) du graphe G9 ci-après en illustrant son chemin le plus court et la distance qui sépare le nœud A de chaque autre nœud. A
G8
G9 2
4
F
B
5
5
B 4
2
1
C 3
1
D 3
1
1
2
G 1
4 2
3
E
1
3
5
J
I 2
3
1
E 6
C
4 3
5
A
1
H
2
G
1
F
D
16.19 Comme nous l’avons déjà vu, il existe quatre algorithmes standard de parcours des arbres binai-
res : le parcours préfixe, le parcours infixe, le parcours postfixe et le parcours en largeur. Si vous considérez qu’un arbre binaire est un graphe acyclique connexe, quel parcours d’arbre obtiendrez-vous si vous effectuez une recherche : a. en profondeur d’abord ; b. en largeur d’abord.
338
Graphes
16.20 Appliquez l’algorithme de parcours indiqué sur le
graphe ci-contre : Donnez l’ordre des sommets visités et dessinez l’arbre maximal obtenu. a. Tracez la recherche en largeur d’abord. b. Tracez la recherche en profondeur d’abord, en commençant par le nœud A et en imprimant l’étiquette de chaque nœud une fois qu’il a été visité.
A
D
B
C
E
F
H
K
G
I
J
L
M
O
N
P
G1
Q
3
A
B
16.21 Pour le digraphe pondéré G1 ci-contre :
a. Dessinez la matrice d’adjacence. b. Dessinez la matrice d’incidence. c. Dessinez la liste d’adjacences.
6
7
1
5
2
D
C
4
¿
SOLUTIONS
SOLUTIONS
16.1
a. n = 6 b. V = {a, b, c, d, e, f} c. E = {ab, bc, bd, cd, ce, de, cf, df} d. d(a) = 1, d(b) = 3, d(e) = d(f) = 2, d(c) = d(d) = 4. e. Le chemin abcd a une longueur égale à 3. f. Le chemin abcfde a une longueur égale à 5. g. Le cycle bcedb a une longueur égale à 4. h. L’arbre maximal est illustré ci-contre :
c a
b
e d
c a
b
f
e d
i. La matrice d’adjacence est la suivante :
a b c d e f a b c d e f
F T F F F F F F T T F F F T F T T T F T T F T T F F T T F F F F T T F F
f
339
Révision et entraînement
j. La matrice d’incidence est la suivante : a b c d e f
1 0 0 0 0 0 0 0
a
b
b
a
c
d
c
b
d
e
f
d
b
c
e
f
e
c
d
f
c
d
1 1 1 0 0 0 0 0 0 1 0 1 1 1 0 0 0 0 1 1 0 0 1 1 0 0 0 0 1 0 1 0 0 0 0 0 0 1 0 1
k. La liste d’adjacences est illustrée ci-contre :
16.2
a. n = 6 b. V = {a, b, c, d, e, f} c. E = {ad, ba, bd, cb, cd, ce, cf, de, ec, fe} d. id(a) = id(b) = id(c) = id(f) = 1, id(d) = id(e) = 3. e. od(a) = od(d) = od(e) = od(f) = 1, od(b) = 2, od(c) = 4. f. Le chemin adec a une longueur égale à 3. g. Le chemin fecbad a une longueur égale à 5. h. Le cycle adcba a une longueur égale à 4. i. L’arbre maximal est illustré ci-contre :
b a
1 -1 0 0 0 0 0 0 0 0
c
a
f e
d a b c d e f
F F F T F F T F F T F F F T F T T T F F F F T F F F T F F F F F F F T F
a
d
b
a
d
c
b
d
d
e
e
c
f
e
0 1 1 -1 0 0 0 0 0 0 0 0 0 1 1 1 1 0 -1 0 -1 0 -1 0 -1 0 0 1 0 0 0 0 0 0 0 -1 0 -1 1 -1 0 0 0 0 0 0 -1 0 0 1
l. La liste d’adjacences est illustrée ci-contre : 16.3
e
b
a b c d e f
a b c d e f
f d
j. La matrice d’adjacence est la suivante :
k. La matrice d’incidence est la suivante :
c
Les graphes complets sont les suivants :
e
f
340 16.4
16.5 16.6
16.7
16.8
16.9
Graphes
Le graphe G1 ne peut pas être eulérien parce qu’il est composé de sommets de degré impair, mais son cycle hamiltonien illustré dans le diagramme ci-contre vérifie qu’il est hamiltonien : Le graphe G2 n’est ni eulérien, ni hamiltonien. a. Non connexe, cyclique et maximal. b. Non connexe, acyclique et maximal. c. Non connexe, cyclique et maximal. d. Non connexe, cyclique et non maximal. e. Connexe, acyclique et maximal. f. Connexe, acyclique et maximal. g. Connexe, cyclique et maximal. h. Connexe, acyclique et non maximal. i. Non connexe, cyclique et maximal. j. Connexe, cyclique et non maximal. k. Non connexe, acyclique et non maximal. l. Connexe, acyclique et non maximal. Ces deux graphes ne sont pas isomorphes parce que celui de gauche est un 4-cycle contenant 2 sommets de degré 2, contrairement à celui de droite. Pourtant, les cinq conditions du théorème 16.4 sont respectées. Supposons que G1 et G2 soient isomorphes et que G ne soit pas isomorphe à G1. D’après le théorème 16.5, G2 est également non isomorphe à G1. Si G est isomorphe à G2, d’après la partie 3 du théorème 16.5, G doit également être isomorphe à G1. Ainsi, puisque G est non isomorphe à G1, il ne peut pas être isomorphe à G2 non plus. La matrice d’adjacence du graphe complet composé de n sommets est une matrice de n par m avec la valeur false à chaque entrée de la diagonale, et la valeur true pour toutes les autres entrées.
16.10 La matrice d’incidence Mn du graphe complet
composé de n sommets est illustrée ci-contre : Elle est composée de n lignes et de n(n – 1)/2 colonnes (voir le théorème 16.2). Si n = 2, il s’agit de la matrice 2-par-1 contenant true dans les deux entrées. Si n > 2, il s’agit de la matrice A concaténée horizontalement à la matrice obtenue de Mn – 1 en insérant une ligne de valeurs false au-dessus. 16.11 a. Le digraphe G1 est présenté page suivante.
M2 =
1 1 0
1 1
M3 =
1 0 1 0 1 1
1 1 1 0 0 0
M4 =
1 0 0 1 1 0 0 1 0 1 0 1 0 0 1 0 1 1 1 1 1 1 0 0 0 0 0 0 1 0 0 0 1 1 1 0 0 0
M5 = 0 1 0 0 1 b. Oui, il s’agit d’un digraphe puisqu’il contient 0 0 1 0 0 au moins une arête bilatérale. 0 0 0 1 0 c. Non, le digraphe n’est pas fortement connexe parce qu’il n’existe pas de chemin entre C et D. d. Oui, le digraphe est faiblement connexe parce que son graphe incorporé connexe. e. Non, le digraphe n’est pas acyclique puisqu’il contient le cycle AFEDA.
0 0 1 1 0 1 0 1 0 1 0 1 0 1 1
non orienté est
341
Révision et entraînement
f. La matrice d’adjacence du digraphe est la suivante : 0 0 0 1 0 0
0 0 1 1 0 0
0 1 0 0 1 0
0 0 0 0 1 0
0 0 0 0 0 1
G1
1 0 0 0 0 0
G2
A
F
B
E
C
A
E
B
D
C
D
16.12 a. Le digraphe G2 est illustré ci-dessus :
b. c. d. e. f.
Non, il ne s’agit pas d’un digraphe simple parce qu’il contient une boucle. Oui, il s’agit d’un digraphe puisque sa matrice d’adjacence n’est pas symétrique. Oui, ce digraphe est fortement connexe. Oui, ce digraphe est faiblement connexe. Non, ce digraphe n’est pas acyclique puisqu’il contient le cycle ADB.
16.13 Pour le graphe donné, la matrice d’adjacence et la liste d’adjacences sont les suivantes :
∞ ∞ ∞ ∞ ∞
3 ∞ ∞ 2 ∞
4 ∞ ∞ 3 4
1 ∞ 3 ∞ ∞
∞ 2 ∞ ∞ ∞
A
3 B
B
2 E
C
3 D
D
2 B
E
4 C
4 C
1 D
3 C
Il n’est pas connexe parce qu’il n’existe aucun chemin de B à A. Il n’est pas acyclique non plus parce qu’il contient le cycle BECDB. 16.14 Parmi les sept graphes suivants : a G1
G2 e
b
g
f
f
g
a
e
b
h
j
h
j
i
d d
c p
c i
342
Graphes
p
G3
u
t
G4
r
s
q
s
t
w
p q
v u
y
r
w x
v
x
G6
y G5
D A
G
J F
C B
E
G7
I
S
T
U
R
Q
P
H
G1 est isomorphe à G2 : l’isomorphisme est indiqué par les étiquettes des sommets a-j. G3 est isomorphe à G4 : l’isomorphisme est indiqué par les étiquettes des sommets p-y. G6 ne peut pas être isomorphe à un autre graphe puisqu’il est composé de 25 arêtes et que tous les autres n’en ont que 20. G3 (et par conséquent G4) ne peut pas être isomorphe à un autre graphe parce qu’il est composé d’une pyramide de quatre 3-cycles adjacents (pqr, prs, pst et ptq), ce qu’aucun autre graphe n’a, à l’exception de G6. G6 ne peut être isomorphe à aucun autre graphe parce qu’il est composé d’une chaîne de trois 4-cycles adjacents (ABCD, CEFG et FHIJ), contrairement à tous les autres graphes. De la même façon, G7 ne peut être isomorphe à aucun autre graphe parce qu’il comporte une chaîne de quatre 3-cycles adjacents (PQS, QSR, SRT et RTU), contrairement à tous les autres graphes, à l’exception de G6. 16.15 a. Ces deux graphes sont isomorphes : leur bijection est définie par les étiquettes des sommets,
comme illustré ci-après. A
A
G1
E
G2
D
D
E B
B
F
C
C
F
343
Révision et entraînement
b. Le cycle eulérien de G2 est ABCDEBFCADFEA. c. Le cycle hamiltonien de G2 est ABCDFEA. 16.16 a. La matrice d’adjacence d’un graphe de type roue est similaire à la matrice A suivante. F T T T T T T F T F F F A=
T T F T F F T F T F T F T F F T F T T F F F T F
b. La matrice d’incidence d’un graphe de type roue est similaire à la matrice B de la figure précédente (lorsque n = 4). Elle sera généralement composée de n 1 et de n 0 sur la première ligne. Sous cette ligne se trouvera la matrice d’identité (uniquement des valeurs 1 sur la diagonale et des 0 ailleurs) suivie de la matrice au carré remplie de 1 sur la diagonale et la sous-diagonale. Comparez cette solution récursive à l’exercice 16.10. 1 1 1 1 0 0 0 0
a
b
c
d
b
c
f
a
c
d
b
a
d
e
c
a
e
f
d
a
f
b
e
a
e
1 0 0 0 1 0 0 1 B=
0 1 0 0 1 1 0 0 0 0 1 0 0 1 1 0 0 0 0 0 0 0 1 1
c. La liste d’adjacences d’un graphe de type roue est similaire à celui-ci. La liste des arêtes du premier sommet (c’est-àdire le sommet central) comporte n nœuds d’arêtes, soit un pour chaque autre sommet. Chaque autre liste de sommets est composée de trois nœuds d’arêtes, un pointant vers le sommet central (étiqueté a dans l’exemple suivant) et un autre pour chacun de ses voisins.
16.17 La trace de l’algorithme de Dijkstra pour le graphe G8 est la suivante : A
G8 F
G9
0
4
2
4
G
1
5
C
4 1 D
6
1
C
3
2 5
A
2
3
6
5
4
4
3 3
E
2
1
2
4B
D
7
B
5
1
I
3
1
4
5 2
3 5
J
3
1
E
1
6
5
1H
2
G
1
3
16.18 La trace de l’algorithme de Dijkstra pour le graphe G9 est illustrée ci-dessus.
F
4
8
f
344
Graphes
16.19 a. Si la recherche en profondeur d’abord est appliquée à un arbre, vous effectuez un parcours
préfixe. b. Si la recherche en largeur d’abord est appliquée à un arbre, vous effectuez un parcours en largeur. 16.20 a. La recherche en largeur d’abord visite ABDECHFIGKLJMONPQ, comme illustré par l’arbre
maximal sur la figure suivante (à gauche). b. La recherche en profondeur d’abord visite ABCFEIHDKLMJGN, comme illustré par l’arbre maximal sur la figure suivante (à droite). A
B
D
C
E
H
F
I
K
G
D
N
K
I
L
Q
O
C
F
H
M
P
B
E
J
L
O
A
G
J
M
P
N
Q
16.21 La matrice d’adjacence et la liste d’adajcences du graphe donné sont les suivantes : 3
A
6
7
5
2
D
4
B
1
A =
0 0 0 7
306 015 002 004
A
3
6
B
1
5
C
2
D
7
C 4
Annexes
A. Mathématiques de base Étant une science, l’informatique est basée sur des principes théoriques fondamentaux qui sont dérivés et appliqués à l’aide des mathématiques. Dans cette annexe, vous trouverez un résumé des concepts mathématiques nécessaires à l’étude des structures de données.
A.1 FONCTIONS PLANCHER ET PLAFOND Les fonctions plancher et plafond renvoient x- 1< x ≤ x ≤ x < x+1 l’entier le plus proche d’un nombre réel donné. 1 2 3 4 Le plancher de x, noté sous la forme x est l’entier le plus grand qui ne soit pas supérieur à x. Le plafond de x, noté sous la forme x est l’entier le plus petit qui ne soit pas inférieur à x. Vous pouvez également dire que le plafond et le plancher de x sont les entiers les plus proches respectivement à gauche et à droite de x.
Exemple A.1 Fonctions plancher et plafond Si x = 2,71828, alors x = 2 et x = 3. Supposons que Z soit l’ensemble de tous les entiers et que R soit l’ensemble de tous les nombres réels. Les fonctions plancher et plafond mappent alors R dans Z, c’est-à-dire que chaque fonction renvoie un entier correspondant à un nombre décimal. Dans le théorème suivant, nous supposerons également que N est l’ensemble de tous les nombres naturels : N = {n ∈ Z n ≥ 1} = {1, 2, 3, …}. ✽ Théorème A.1 : les fonctions plancher et plafond ont les propriétés suivantes pour tous les nombres réels x : a) b) c) d) e) f) g) h)
x = max{m ∈ Z m ≤ x}. x ≤ x < x + 1 et x - 1 < x ≤ x x – 1 < x ≤ x ≤ x < x + 1 Si n ∈ Z et que n ≤ x < n + 1, alors n = x. Si n ∈ Z et que n – 1 < x ≤ n, alors n = x. Si x ∈ Z, alors x = x = x. Si x ∉ Z, alors x < x < x. -x = -x et -x = -x. x + 1 = x + 1 et x + 1 = x + 1.
Reportez-vous à l’exercice d’entraînement A.2 pour consulter une démonstration de ce théorème.
346
Annexes
A.2 LOGARITHMES Le logarithme de base b d’un nombre x est l’exposant d’une base b donnée qui crée la valeur x. Par exemple, le logarithme en base 10 de 1000 est 3 (log101000 = 3) parce que 3 est l’exposant de 10, ce qui donne comme résultat la valeur 1000 : 103 = 1000. Dans le cadre des sciences sociales, c’est la base 10 qui est utilisée et log x au lieu de log10x ; il s’agit du logarithme commun. En revanche, les physiciens et les mathématiciens préfèrent utiliser la base e (= 2,718281828459) et écrire ln x au lieu de logex ; il s’agit du logarithme naturel. En informatique, les scientifiques choisissent souvent d’utiliser la base 2 et d’écrire lg x au lieu de log2x ; il s’agit du logarithme binaire. À l’instar des fonctions mathématiques, les logarithmes sont les inverses des fonctions exponentielles : y = log b x 0 ⇔ b y = x
Par exemple, log2256 = 8 parce que 28 = 256. Cette équivalence peut être considérée comme une définition des logarithmes pour toutes les bases b. Les propriétés suivantes découlent de cette définition. ✽ Théorème A.2 : lois des logarithmes a) b) c) d) e) f)
log b (b y) = y b log b x = x log b uv = log b u + log b v log b u/v = log b u – log b v log b uv = v log b u log b x = (log c x)/(log c b) = (log b c) (log c x)
Reportez-vous à l’exercice A.3 pour consulter une démonstration de ce théorème.
Exemple A.2 Appliquer les lois des logarithmes log 2 256 = log 2 (28) = 8 log 2 1 000 = (log 10 1 000)/(log 10 2) = 3/0,30103 = 9,966 log 2 1 000 000 000 000 = log 2 10004 = 4(log 2 1000) = 4(9,966) = 39,86
✽ Théorème A.3 : le logarithme binaire a les propriétés suivantes : a) Si p ∈ Z et que 2p < n < 2p + 1, alors p = lgn et p + 1 = lgn. b) Si n ∈ Z, alors lg(n + 1) = lgn + 1. Reportez-vous à l’exercice A.4 pour consulter une démonstration de ce théorème. L’entier lgn est qualifié de logarithme binaire intégral de n. Il s’agit en fait du nombre de fois où n peut être divisé par 2 avant d’atteindre 1. Par exemple, le logarithme binaire intégral de 1 000 est 9 parce que 1 000 peut être divisé par 2 10 fois et donner les résultats suivants : 500, 250, 125, 62, 31, 15, 7, 3, 1.
Exemple A.3 Tester le logarithme binaire intégral public class ExA01 { public static void main(String[] args) { System.out.println("iLg(1) = " + iLg(1));
347
A. Mathématiques de base
System.out.println("iLg(2) = " + iLg(2)); System.out.println("iLg(3) = " + iLg(3)); System.out.println("iLg(4) = " + iLg(4)); System.out.println("iLg(10) = " + iLg(10)); System.out.println("iLg(100) = " + iLg(100)); System.out.println("iLg(1000) = " + iLg(1000)); System.out.println("iLg(10000) = " + iLg(10000)); } public static int iLg(int n) { int count=0; while (n > 1) { n /= 2; ++count; } return count; } } iLg(1) = 0 iLg(2) = 1 iLg(3) = 1 iLg(4) = 2 iLg(10) = 3 iLg(100) = 6 iLg(1000) = 9 iLg(10000) = 13
A.3 CLASSES DE COMPLEXITÉ En informatique, les algorithmes sont classés selon leurs fonctions de complexité. Ces dernières décrivent la durée d’exécution des algorithmes qui dépend de la taille des problèmes à résoudre. Par exemple, le tri par permutation a une complexité O(n2) parce que, si vous l’utilisez pour trier un tableau deux fois plus grand, le traitement sera quatre fois plus long : (2n2) = 4n2. Le symbole O() signifie Ordre et O(n2) Ordre n au carré. Cette notation peut être définie avec précision en termes de limites. Si f et g sont des fonctions croissantes sur des entiers positifs, supposons que L(f,g) exprime la limite de la façon suivante : f( n) ----------L ( f, g ) = nlim → ∞ g(n)
Cette constante peut être égale à 0, à n’importe quel nombre positif ou bien être infinie. Par exemple, si f(n) = n2 et que g(n) = n, alors L(f,g) = parce que n2/n = n3/2. Grâce à cette notation, nous pouvons définir les trois classes de complexité standard suivantes : o (g) ={ f ∈ M | L(f, g) < ∞} O(g) ={ f ∈ M | 0 ≤ L(f, g) < ∞} Θ(g) ={ f ∈ M | 0 < L(f, g) < ∞} Ω(g) ={ f ∈ M | 0 < L(f, g) ≤ ∞} ω(g) ={ f ∈ M | L(f, g) = ∞}
Dans le cas présent, M est un ensemble de toutes les fonctions croissantes positives sur des entiers positifs.
348
Annexes
✽ Théorème A.4 : les trois ensembles o(g), Θ(g) et ω(g) divisent M, c’est-à-dire que chaque fonction de M se trouve dans un et un seul de ces trois ensembles.
O(g) o(g)
θ(g)
Ω(g)
ω (g)
✽ Théorème A.5 : O(g) = o(g) ∪ Θ(g) Θ(g) = O(g) ∩ Ω(g) Ω(g) = Θ(g) ∪ ω(g)
Le diagramme précédent illustre les relations entre les cinq classes de complexité. Cependant, bien que les cinq classes de complexité soient des ensembles de fonctions, il est plus courant d’écrire f(n) = O(g(n)) que l’expression plus précise f ∈ O(g), et f(n) ≠ O(g(n)) plutôt que f ∉ O(g).
Exemple A.4 Classes de complexité • n lg n = O(n2) mais n lg n ≠ Θ(n2) parce que (n lg n)/(n2) = (lg n)/n → 0 ; • n lg n = Θ(n lg n) parce que (n lg n)/(n log n) = (log2n)/(log10n) = log102, qui est une constante positive ; • n lg n = Ω( n ) mais n lg n ≠ O( n ) parce que (n lg n)/( n ) = n lg n → ∞. ✽ Théorème A.6 : pour toute base b > 1, logbn = O(lgn). Démonstration : d’après le théorème A.2(f), logbn = c lg n, avec c = 1/lgb = logb2. Attention, la limite L(f, g) n’existe pas systématiquement. Dans ce cas, nous supposons que L(f,g) représente l’ensemble de toutes les limites des sous-séquences de la séquence f(n)/g(n). Ensuite, les inégalités de L(f, g) sont interprétées comme étant obligatoires pour chaque élément de l’ensemble.
Exemple A.5 Coefficient oscillant Supposons que f(n) = 2n et que g(n) = 4n/2. Ensuite, la séquence {f(n)/g(n)} = {1, 2, 1, 2, 1, 2, 1, 2, 1, 2, …} n’a aucune limite (unique), c’est pourquoi le symbole représenterait l’ensemble {1, 2}. Étant donné que chaque élément x de cet ensemble (c’est-à-dire x = 1 et x = 2) est conforme aux inégalités 0 < x < ∞, il s’ensuit que f = Θ(g) (et g = Θ(f)).
A.4 PREMIER PRINCIPE D’INDUCTION MATHÉMATIQUE Le premier principe d’induction mathématique, également appelé induction faible, est souvent utilisé afin de démontrer des formules relatives aux nombres naturels. ✽ Théorème A.7 : premier principe d’induction mathématique. Si {P(1), P(2), P(3), P(4), …} est une séquence de propositions avec les deux propriétés suivantes, alors toutes les propositions sont vraies : 1. P(1) est vraie. 2. Chaque proposition peut être déduite de ses prédécesseurs.
349
A. Mathématiques de base
Exemple A.6 Utiliser l’induction faible Démontrez l’inégalité 2n ≤ (n + 1)! Pour tout n ≤ 1. Cette formule affirme la séquence suivante de propositions : P(1) : 21 ≤ 2! P(2) : 22 ≤ 3! P(3) : 23 ≤ 4! P(4) : 24 ≤ 5! etc. Les quatre premières propositions sont sans aucun doute vraies parce que : 21 = 2 ≤
2 = 2!
22 = 4 ≤
6 = 3!
23 = 8 ≤ 24 = 4! 24 = 16 ≤ 120 = 5! Toutes les autres formules pourraient aussi être vérifiées directement de cette façon, mais elles sont beaucoup trop nombreuses pour que vous arriviez à toutes les vérifier. La première partie du théorème A.7 requiert la vérification de P(1), ce que nous avons fait dans le paragraphe précédent. Pour vérifier la deuxième partie de ce théorème, vous devez soustraire P(n) de P(n – 1). Supposons donc que P(n – 1) soit vraie pour n > 1. Cela signifie que nous supposons également que 2n – 1 n! est vraie pour n. Pour déduire P(n) de cette supposition, nous devons rechercher les relations entre P(n – 1) et P(n) ; c’est-à-dire essayer de relier les deux formules : P(n – 1) : 2n – 1 ≤ n! P(n) : 2n ≤ (n + 1)! La proposition P(n) peut être réécrite de la façon suivante : P(n) : (2)(2n – 1) ≤ (n + 1)(n!) L’évolution de P(n – 1) à P(n) est maintenant évidente : la partie gauche de l’inégalité est multipliée par 2, tandis que la partie droite est multipliée par n + 1. Tant que n + 1 > 2, l’augmentation de gauche est inférieure à celle de droite. Et nous savons que n + 1 > 2 puisque n > 1. Nous arrivons donc à l’implication suivante : 2n – 1 ≤ n! ⇒ 2n ≤ (n + 1)! soit, P(n – 1) ⇒ P(n) C’est ce qui est spécifié dans la deuxième partie du théorème A.7. Nous pouvons donc conclure que P(n) et vraie pour tout n ≥ 1. La première partie de ce théorème est qualifiée de base de la démonstration et la seconde partie d’étape inductive. Quant à la supposition de la deuxième partie selon laquelle P(n) est vraie pour n, il s’agit de l’hypothèse inductive.
A.5
DEUXIÈME PRINCIPE D’INDUCTION MATHÉMATIQUE
Le deuxième principe d’induction mathématique, également qualifié d’induction forte, est presque identique au premier principe ; seule l’étape inductive (la deuxième partie) diffère.
350
Annexes
✽ Théorème A.8 : deuxième principe d’induction mathématique. Si {P(1), P(2), P(3), P(4), …} est une séquence de propositions avec les deux propriétés suivantes, alors toutes les propositions sont vraies : 1. P(1) est vraie. 2. Chaque proposition peut être déduite de tous ses prédécesseurs. L’étape inductive (deuxième partie) signifie que P(n) peut être déduite de la supposition selon laquelle toutes les propositions précédentes {P(1), P(2), P(3), …, P(n – 1)} sont vraies.
Exemple A.7 Démonstration du théorème fondamental de l’arithmétique ✽ Théorème A.9 : théorème fondamental de l’arithmétique. Chaque entier positif a une représentation unique comme produit des exposants des nombres premiers : p1n1p2n2p3n3pknk p1, p2, p3, … étant les nombres premiers : p1 = 2, p2 = 3, p3 = 5, etc., et chaque nj étant un entier non négatif. Par exemple, l’entier positif 23 115 456 correspond à la représentation unique 26 34 50 73 110 131. La démonstration applique le deuxième principe d’induction mathématique à la séquence de propositions : P(1) = 1 a une représentation unique comme produit des nombres premiers. P(2) = 2 a une représentation unique comme produit des nombres premiers. P(3) = 3 a une représentation unique comme produit des nombres premiers. P(4) = 4 a une représentation unique comme produit des nombres premiers. etc. Ces quatre premières propositions sont vraies parce que : 1 = 20 2 = 21 3 = 31 4 = 22 La première supposition vérifie la base de l’induction. Afin de vérifier la démarche inductive, nous supposons que toutes les propositions {P(1), P(2), P(3), …, P(n – 1)} sont vraies pour n > 1. Si n a un facteur premier, appelez-le p et supposez que m = n/p. Ensuite, étant donné que m est un entier positif inférieur à n, l’hypothèse inductive nous permet de déduire que P(m) doit être vraie, c’est-à-dire que m a une représentation unique comme produit des exposants premiers. Il en va de même pour n parce que n = p·m. C’est pourquoi P(n) est vraie dans ce cas. En revanche, si n n’a pas de facteurs premiers, il doit être un nombre premier lui-même et a donc certainement une représentation unique comme produit des exposants premiers : n = n1. C’est pourquoi P(n) est vraie dans ce cas. La démonstration est terminée puisque nous avons déduit P(n) à partir de {P(1), P(2), P(3), …, P(n – 1)}.
A.6 SÉRIES GÉOMÉTRIQUES Une série géométrique est une somme dans laquelle chaque terme est égal au précédent multiplié par une valeur fixe. Par exemple, 10 + 30 + 90 + 270 + 810 + 2430 + … est une série géométrique parce que chaque terme est égal à 3 fois son prédécesseur. Le multiplicateur 3 est qualifié de raison de la série.
351
A. Mathématiques de base ✽ Théorème A.10 : somme d’une série géométrique finie. 2
3
a + ar + ar + ar + … + ar
n–1
n
( 1 – r -) = a--------------------1–r
Dans le cas présent, a est le premier terme de la série, r est la raison et n le nombre de termes composant la somme.
Exemple A.8 Rechercher la somme d’une série géométrique finie 10 + 30 + 90 + 270 + 810 + 2 430 = 10(1 – 36)/(1 – 3) = 10(1 – 729)/(-2) = 10(-728)/(-2) = 3 640 ✽ Théorème A.11 : somme d’une série géométrique infinie. 2 3 aa + ar + ar + ar + … = ---------1–r
Cette formule est valide uniquement pour –1 < r < 1.
Exemple A.9 Rechercher la somme d’une série géométrique infinie 6 + 3 + 3/2 + 3/4 + 3/8 + … = 6/(1 – 1/2) = 6/(1/2) = 12
A.7 FORMULES DE SOMMATION ✽ Théorème A.12 : somme des n premiers entiers positifs. ( n + 1 -) 1 + 2 + 3 + … + n = n------------------2 Remarquez que le paramètre n est égal au nombre de termes de la somme. Pour vous souvenir plus facilement de cette formule, mémorisez la figure suivante qui est composée de deux triangles de points. Ces deux triangles, le premier étant blanc et le second gris, contiennent le même nombre de points, à savoir S = 1 + 2 + 3 + … + n. Lorsqu’ils sont réunis, ils forment un rectangle de largeur égale à n points et de hauteur égale à n + 1 points. Ainsi, le nombre total de points est égal à n(n + 1), soit le double de la taille de la somme S.
Exemple A.10 Rechercher la somme d’une séquence arithmétique 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 = 8(9)/2 = 36 ✽ Théorème A.13 : somme des n premiers carrés. 2 2 2 2 ( n + 1 ) ( 2n + 1 -) 1 + 2 + 3 + … + n = n---------------------------------------6
Remarquez que cette somme est un entier même si la partie droite est une fraction.
352
Annexes
Exemple A.11 Rechercher une somme de carrés 1 + 4 + 9 + 16 + 25 + 36 + 49 = 7(8)(15)/6 = 140
A.8 NOMBRES HARMONIQUES Les nombres harmoniques sont définis par la formule suivante : n
Hn =
1 1 1 1 1 1 Σ --k- = 1 + --2- + --3- + --4- + --5- + … + --nk=1
Les six premiers nombres harmoniques sont illustrés dans le tableau ci-contre : La séquence harmonique a beau croître très lentement, cette croissance est illimitée, comme le démontrent le théorème suivant et son corollaire.
n
Hn
1
1.000000
2
1.500000
3
1.833333
4
2.083333
5
2.283333
6
2.450000
✽ Théorème A.14 : la séquence harmonique est asymptomatiquement logarithmique. La limite suivante : lim ( H – ln n ) est une constante positive. n→∞
■ Corollaire A.1 : les nombres harmoniques croissent logarithmiquement. Hn = Θ(ln n) L’exemple suivant démontre empiriquement que le théorème A.14 est vrai.
Exemple A.12 Constante d’Euler Afin de tester la supposition selon laquelle la limite lim ( H n – ln n ) est une constante positive, nous n→∞ allons exécuter le programme suivant qui imprimera les valeurs de Hn – lnn pour toutes les valeurs de n qui sont des exposants de 2 inférieures à 10 000 000 : public class ExA10 { public static void main(String[] args) { double hn=0.0; // nombres harmoniques int pow2=1; // exposants de 2 for (int n=1; n<1e7; n++) { hn += 1.0/n; // énième nombre harmonique if (n==pow2) // n’imprimer que les exposants de 2 { double ln=Math.log(n); // logarithme naturel de n double difn = hn - ln; // s’approche de la constante d’Euler System.out.println(n+"\t"+hn+"\t"+ln+"\t"+difn); pow2 *= 2; } } } } 1 2 4 8 16 32 64 128 256 512
1,0 0,0 1,0 1,5 0,6931471805599453 0,8068528194400547 2,083333333333333 1,3862943611198906 0,697038972213 2,7178571428571425 2,0794415416798357 0,638415601177 3,3807289932289937 2,772588722239781 0,608140270989 4,05849519543652 3,4657359027997265 0,592759292636 4,7438909037057675 4,1588830833596715 0,585007820346 5,433147092589174 4,852030263919617 0,581116828669 6,124344962817281 5,545177444479562 0,579167518337 6,81651653454972 6,238324625039508 0,578191909510
353
A. Mathématiques de base
1024 2048 4096 8192 16384 32768 65536 131072 262144 524288 1048576 2097152 4194304 8388608
7,509175672278132 8,202078771817716 8,89510389696629 9,588190046095265 10,281306710008463 10,974438632012168 11,667578183235785 12,360721549112862 13,053866822328144 13,747013049214582 14,440159752936799 15,133306695078193 15,826453756428641 16,51960087738358
6,931471805599453 7,6246189861593985 8,317766166719343 9,010913347279288 9,704060527839234 10,39720770839918 11,090354888959125 11,78350206951907 12,476649250079015 13,16979643063896 13,862943611198906 14,556090791758852 15,249237972318797 15,942385152878742
0,577703866678 0,577459785658 0,577337730246 0,577276698815 0,577246182169 0,577230923612 0,577223294276 0,577219479593 0,577217572249 0,577216618575 0,577216141737 0,577215903319 0,577215784109 0,577215724504
Au fur et à mesure de la croissance de n, la différence Hn – ln n se rapproche de la constante 0,5772157, plus connue sous le nom de constante d’Euler et désignée par la lettre gamma grecque :
γ = lim ( H n – ln n ) = 0, 5772157… n→∞
On ignore encore si γ est un nombre rationnel.
A.9 FORMULE DE STIRLING La fonction factorielle est souvent utilisée dans le cadre des analyses informatiques. Dans ce contexte, l’analyse concerne la grandeur des expressions contenant n! au fur et à mesure de la croissance de n. Cependant, ces valeurs sont difficiles à obtenir directement parce qu’elles sont trop grandes comme, par exemple, 70! > 10100. La formule de Stirling est une méthode pratique qui permet d’obtenir une approximation des factorielles de grande taille. ✽ Théorème A.15 : formule de Stirling. n n! 1 1 < -------------- --e- < 1 + -----n 2n 2nπ
Dans le cas présent, e et π sont les constantes mathématiques e = 2,71828 et π = 3,14159. Le programme suivant illustre le fait que ces liaisons plus serrées s’appliquent : n 2nπ --e-
n
≤ n! ≤
n 2nπ --e-
n + 1 / ( 12n )
Exemple A.13 Approximation de Stirling public class ExA12 { public static void main(String[] args) { final double E=Math.E; // e = 2,71828 final double PI=Math.PI; // pi = 3,14159 final double INF=Double.POSITIVE_INFINITY; // = 1.8E308 double f=3628800.0; // 10! = 3,628,800 int n=10; while (f != INF) { double s = Math.sqrt(2*n*PI)*Math.pow(n/E,n); double ss = Math.sqrt(2*n*PI)*Math.pow(n/E,n+1.0/(12*n));
354
Annexes
System.out.println(n+"\t"+s+"\t"+f+"\t"+ss); for (int i=n+1; i<=n+10; i++) f *= i; n += 10; } } } 10 20 30 40 50 60 70 80 90 100 110 120 130 140 150 160 170
3598695,6187410373 2,4227868467611351E18 2,6451709592296516E32 8,142172644946236E47 3,036344593938168E64 8,3094383149767E81 1,1964320047337557E100 7,14949447318118E118 1,4843409438918685E138 9,32484762526942E157 1,587042784164154E178 6,68485904870404E198 6,462711405582573E219 1,3454001771051692E241 5,7102107404794024E262 4,7122686933249974E284 7,25385893454291E306
3628800,0 2,43290200817664E18 2,6525285981219103E32 8,159152832478977E47 3,0414093201713376E64 8,320987112741392E81 1,197857166996989E100 7,156945704626378E118 1,4857159644817607E138 9,33262154439441E157 1,5882455415227421E178 6,689502913449124E198 6,466855489220472E219 1,346201247571752E241 5,7133839564458505E262 4,714723635992059E284 7,257415615307994E306
3637971,7959937225 2,4430176532620851E18 2,6628732015735442E32 8,187911721520889E47 3,0511169215892805E64 8,345226643189724E81 1,2010678721362897E100 7,174726163602993E118 1,4891588486241144E138 9,352904468745705E157 1,5914981328581203E178 6,702464725447769E198 6,4787535645487E219 1,3485604820896678E241 5,7229480213358916E262 4,7222810411380736E284 7,268579978559315E306
Vous constaterez que l’approximation de Stirling est précise à 1 % près et que cette précision relative augmente parallèlement à la croissance de n. Par exemple, pour n = 100, n! = 9,3326(10178) et l’approximation de Stirling est égale à 9,3248(10178), soit une erreur absolue de 0,0078 (10178), c’est-à-dire une erreur relative de 0,08 %. ■ Corollaire A.2 : n! = o(nn). Démonstration : d’après la formule de Stirling, il s’agit du rapport : n! nn
--------- ≤
1 / ( 12n )
n 2nπ --e-
-1- e
n
La limite de l’expression se trouvant de la partie droite de l’équation est 0.
A.10 LES NOMBRES DE FIBONACCI Les nombres de Fibonacci sont 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, … Chaque nombre suivant le second est égal à la somme des deux nombres précédents : 0, si n = 0 F n = 1, si n = 1 F n – 1 + F n – 2 , si n > 1
Les dix premiers nombres de Fibonacci sont illustrés dans le tableau ci-contre :
n
Fn
0
0
1
1
2
1
3
2
4
3
5
5
6
8
7
13
8
21
9
34
10
55
355
A. Mathématiques de base
A.11 NOMBRE D’OR Le nombre d’or est la constante mathématique suivante : 1+ 5 φ = ---------------- ≈ 1,618 2
En fait, il s’agit de la solution apportée au problème suivant de la Grèce antique : comment déterminer le point C sur un segment AB de façon à ce que AB/AC = AC/AB. Ce point intermédiaire était appelé section dorée, divine proportion ou nombre d’or. A
C
B
✽ Théorème A.16 : nombre d’or. Si C est le nombre d’or de AB, alors AB/AC = φ. La démonstration (voir l’exercice A.11) prouve que φ est l’une des racines de l’équation quadratique x2 = x + 1. L’autre racine, ψ = (1 – 5 )/2 = –0,618, est qualifiée de conjugué du nombre d’or. Ces deux formes du nombre d’or ont des propriétés inhabituelles. ✽ Théorème A.17 : quelques propriétés du nombre d’or Si φ = (1 + 5 )/2 et que ψ = (1 – 5 )/2, alors φ2 = φ + 1 ψ2 = ψ + 1 1/φ = φ – 1 1/ψ = ψ – 1 φ+ψ=1 φ-ψ=
5
Le nombre d’or est lié aux nombres de Fibonacci et, par conséquent, à l’informatique en raison du résultat suivant. ✽ Théorème A.18 : formule explicite des nombres de Fibonacci. n
n
φ – ΨF n = ----------------5 La formule du théorème A.18 est remarquable parce que, bien que φ, ψ et irrationnels, les nombres de Fibonacci sont des entiers positifs.
5 soient tous des nombres
■ Corollaire A.3 : la fonction Fibonacci est asymptotiquement exponentielle. Fn = Θ(φn)
A.12 ALGORITHME D’EUCLIDE L’algorithme d’Euclide calcule le plus grand multiple commun de deux entiers positifs. Par exemple, le PGCD de 494 et 130 est 26 parce que les diviseurs de 494 sont {1, 2, 13, 19, 26, 38, 247, 494} et ceux de 130 sont {1, 2, 5, 10, 13, 26, 130}.
356
Annexes
Algorithme A.1 Algorithme d’Euclide. La version itérative est la suivante : public static long gcd(long a, long b) { while (b>0) { long r=a%b; a = b; b = r; // INVARIANT : gcd(a,b) est constant } return a; }
La version récursive est la suivante : public static long gcd(long a, long b) { if (b==0) return a; // INVARIANT : gcd(a,b) est constant return gcd(b,a%b); }
Par exemple, la trace suivante illustre pourquoi gcd(494,130) renvoie 26 : gcd(494,130)
main()
gcd(130,104)
a 494 26
b 130
gcd(104,26)
a 130 26
b 104
gcd(26,0)
a 104 26
b 26
a 26 26
b
0
L’analyse suivante dépend du résultat issu de la théorie des nombres : ✽ Théorème A.19 : Si a > b > 0 et r = a%b > 0 et d | b, alors d | a ⇔ d | r. Dans le cas présent, le symbole « | » signifie « d divise b » et le symbole « % » signifie « le reste de la division de a par b ». ■ Corollaire A.4 : si a > b > 0 et r = a%b > 0, alors (d | a) ∧ (d | b) ⇔ (d | b) ∧ (d | r). ■ Corollaire A.5 : si a > b > 0 et r = a%b > 0, alors gcd(a, b) = gcd(b, r). Par exemple, gcd(494,130) = gcd(130,104) = gcd(104,26) = gcd(26,0) = 26. ✽ Théorème A.20 : l’algorithme d’Euclide est correct. L’invariant de la boucle est Qk : pgcd(ak, bk) = pgcd(a,b), ak et bk étant les valeurs de a et de b à la k-ième itération. ✽ Théorème A.21 : l’algorithme d’Euclide a une durée d’exécution logarithmique. L’algorithme d’Euclide exécute logϕa divisions au maximum, φ étant le nombre d’or et n = max{a, b}.
A.13 NOMBRES CATALANS Les nombres catalans sont définis récursivement par la formule suivante : C ( n ) = 1, if n = 0 C ( 0 )C ( n – 1 ) + C ( 1 )C ( n – 2 ) + C ( 2 )C ( n – 3 ) + … + C ( n – 1 )C ( 0 ), if n > 0
357
A. Mathématiques de base
Les dix premiers nombres catalans figurent dans le tableau ci-contre : Cette séquence est similaire à la séquence de Fibonacci. Elle est définie récursivement et croît de façon exponentielle.
Exemple A.14 Implémenter la fonction des nombres catalans public static long cat(int n) { if (n < 2) return 1; long sum=cat(n-1); for (int i=1; i
La séquence des nombres catalans a été découverte en appliquant le deuxième principe d’induction mathématique au problème suivant :
n
C(n)
0
1
1
1
2
2
3
5
4
14
5
42
6
132
7
429
8
1430
9
4862
Exemple A.15 Compter les triangulations d’un polygone Un polygone est une région plane limitée par des segments de droite qui ne se croisent pas. Un polygone est dit convexe si toute droite passant par deux sommets consécutifs laisse tous les autres sommets dans le même demi-plan de frontière par rapport à cette droite. Les polygones suivants sont convexes :
triangle s=3
quadrilatère s=4
pentagone s=5
hexagone s=6
La triangulation d’un polygone convexe est une sous-division de ce polygone en triangles dont tous les sommets sont également les sommets du polygone. La triangulation d’un polygone composé de s côtés est simplement un ensemble de s-3 diagonales qui ne se coupent pas. L’exemple suivant illustre le cas de quatre triangulations différentes dans un hexagone :
Les figures suivantes représentent deux triangulations d’un quadrilatère, cinq triangulations d’un pentagone et 14 triangulations d’un hexagone :
358
Annexes
Les notions que nous venons de voir nous permettent d’arriver aux suppositions suivantes. ✽ Théorème A.22 : le nombre de triangulations dans un polygone composé de n + 2 côtés est égal au énième nombre catalan C(n). Démonstration : supposons que f(s) soit le nombre de triangulations d’un polygone composé de s côtés, nous devons donc prouver que f(n + 2) = C(n) pour tout n ≥ 1. Nous utiliserons pour cela l’induction forte. D
E
E
C f(6)=14
f(5)=5 f(8)=132
G f(3)=1
B
F
G
f(9)=429
A
H
La base est l’instruction f(3) = C(1) = 1 et elle est vérifiée par le fait qu’il n’existe qu’une seule triangulation possible dans un triangle, à savoir lui-même. En ce qui concerne l’étape inductive, nous allons supposer que n > 1, f(k + 2) = C(k) pour tout k < n. Nous ignorons quelle est la valeur de n, mais nous pouvons supposer que n = 8. Il nous reste donc à démontrer que f(10) = C(8), c’est-à-dire qu’un décagone a 1430 triangulations. La figure ci-dessus située la plus à droite illustre un décagone composé d’un triangle qui le divise en deux parties : un hexagone et un pentagone. D’après notre hypothèse inductive, nous savons que f(6) = C(4) = 14 possibilités différentes de triangulation dans un hexagone, et que f(5) = C(3) = 5 possibilités différentes de triangulation dans un pentagone. Nous pouvons donc en déduire qu’il existe C(4) ⋅ C(3) = 14 ⋅ 5 = 70 possibilités différentes de triangulation dans ce décagone pour que le triangle en grise fasse partie de la triangulation. De la même façon, la deuxième figure indique qu’il existe f(8) ⋅ f(5) = C(4) ⋅ C(3) = 132 ⋅ 1 = 132 possibilités différentes de triangulation dans le décagone pour que le triangle en grisé fasse partie de la triangulation. Et enfin, la dernière figure indique qu’il existe f(9) = C(7) = 429 possibilités différentes de triangulation dans ce décagone pour que le triangle en grisé fasse partie de la triangulation. Les f(10) triangulations sont toutes divisées en cas distincts, chaque cas étant déterminé par le triangle spécial qui a la même base que le décagone. Nous pouvons dénombrer huit de ces cas, un pour chaque sommet A à H. Ainsi, le nombre total de triangulations dans un décagone est égal à : f(10) = f(9) + f(3) ⋅ f(8) + f(4) ⋅ f(7) + f(5) ⋅ f(6) + f(6) ⋅ f(5) + f(7) ⋅ f(4) + f(8) ⋅ f(3) + f(9) Étant donné que f(s) = C(n – 2) et C(0) = 1, f(10) = C(7) + C(1)⋅C(6) + C(2)⋅C(5) + C(3)⋅C(4) + C(4)⋅C(3) + C(5)⋅C(2) + C(6)⋅C(1) + C(7) = C(8) Le même argument indique que f(n +2) = C(n) pour tout n. La technique « diviser pour mieux régner » est utilisée dans la démonstration du théorème A.22. Comme vous avez déjà pu le constater, cette stratégie est très pratique dans le domaine de l’informatique. En effet, une fois que vous avez analysé un problème pour toutes les tailles inférieures à n, elle consiste à procéder à une analyse pour la taille n en la fractionnant en deux parties et en appliquant l’analyse précédente à chaque partie. Ensuite, l’analyse du problème complet revient à voir comment cela s’applique à des problèmes plus petits. Nous avons déjà eu affaire à cette stratégie dans le cadre de la recherche binaire (algorithme 2.2), du tri par fusion (algorithme 13.5) et du tri par segmentation (algorithme 13.6). La formule qui définit les nombres catalans est récursive, ce qui signifie que vous ne pouvez pas l’utiliser afin d’obtenir la valeur de C(n) à moins d’avoir obtenu au préalable les valeurs de C(k) pour tout k < n. En revanche, la formule qui suit est plus pratique et explicite.
A. Mathématiques de base
359
✽ Théorème A.23 : ( 2n )! ( 2n ) ( 2n – 1 ) ( 2n – 2 ) … ( n + 2 ) C ( n ) = ------------------------ = --------------------------------------------------------------------------(n )(n – 1 )( n – 2 )…( 2) n! ( n + 1 )!
Cette formule vous indique qu’il faut former la fraction (2n)/(n), puis ajouter à plusieurs reprises les facteurs par paire, un dans le numérateur et un dans le dénominateur, chacun étant inférieur d’1 à son prédécesseur jusqu’à ce que le facteur du dénominateur soit égal à 2.
Exemple A.16 Calculer les nombres catalans directement L’application suivante du théorème A.23 confirme la valeur 4862 donnée dans le tableau de l’exemple A.13 : ( 18 ) ( 17 ) ( 16 ) ( 15 ) ( 14 ) ( 13 ) ( 12 ) ( 11 ) C ( 9 ) = ----------------------------------------------------------------------------------------- = ( 17 ) ( 13 ) ( 11 ) ( 2 ) = 4862 ( 9 )( 8 )( 7 ) ( 6 )( 5 ) ( 4 )( 3 )( 2 )
Étant donné que n = 9 dans le cas présent, vous dénombrerez 8 facteurs dans le numérateur et 8 dans le dénominateur. Comme vous pouvez le constater, tous les facteurs du dénominateur s’annulent. ✽ Théorème A.24 : nombre d’arbres binaires de taille n. f(1)=1 : Le nombre d’arbres binaires distincts de taille n est égal au énième nombre catalan C(n). f(2)=2 : Démonstration : supposons que f(n) soit le nombre d’arbres f(3)=5 : binaires distincts de taille n. Alors, f(0) = 1 = C(0) parce qu’il existe très exactement 1 arbre binaire de taille 0, à savoir l’arbre vide. Les deux figures indiquent que f(1) = 1 = C(1), f(2) = 2 = C(2) et f(3) = 5 = C(3). Cela vérifie la base de la démonstration par induction forte (théorème A.8). Supposons maintenant que pour tout n > 0, f(k) = C(k) pour tout k < n. Supposons que T soit un arbre binaire de taille n. Comme pour le théorème A.22, nous appliquerons la stratégie « diviser pour mieux régner ». Supposons que TL et TR soient les sous-arbres gauche et droit de T. Supposons également que nL et nR soient respectivement les tailles de TL et TR. Ensuite, étant donné que nL et nR sont tous les deux inférieurs à n, nous pouvons appliquer l’hypothèse f(nL) = C(nL) et f(nR) = C(nR). Maintenant, n = 1 + nL + nR parce que chaque nœud de T doit être la racine, ou bien être situé dans TL ou bien dans TR . Donc, nR = n – 1 – nL . Et nL peut être n’importe quel nombre k situé dans l’intervalle 0 ≤ k ≤ n – 1. Par conséquent, tous les arbres binaires de taille n sont divisés en n classes, une pour chaque valeur de nL = k pour 0 ≤ k ≤ n – 1. Les deux figures illustrent deux arbres binaires de taille n = 8, un pour le cas où nL = 3, et un pour le cas où nL = 5. Pour le cas où nL = k, il existe C(nL) possibilités différentes d’arbres binaires pour TL et C(nR) possibilités pour TR. Le nombre total de possibilités pour ce cas est donc de C(nL) ⋅ C(nR) = C(k) ⋅ C(n – 1 – n). D’où le nombre total d’arbres binaires égal à : f(n)= C(0) ⋅ C(n – 1) + C(1) ⋅ C(n – 2) + C(2) ⋅ C(n – 3) + … + C(n – 1) ⋅ C(0) = C(n) Nous pouvons donc en déduire que f(n) = C(n) pour tout n.
360
Annexes
?
QUESTIONS DE RÉVISION
QUESTIONS DE RÉVISION
A.1
Une fonction f() est dite idempotente si f(f(x)) = f(x) pour tout x du domaine de f(x). Expliquez pourquoi les fonctions de plancher et de plafond sont idempotentes.
A.2
Qu’est-ce qu’un logarithme ?
A.3
Quelle est la différence entre l’induction faible et l’induction forte ?
A.4
Quand est-il préférable d’avoir recours à l’inductions forte ?
A.5
Qu’est-ce que la constante d’Euler ?
A.6
Pourquoi la formule de Stirling est-elle si pratique ?
A.7
Est-il préférable d’utiliser la définition récursive des nombres de Fibonacci (exemple 4.5) ou leur formule explicite (théorème A.18) ?
¿
RÉPONSES
RÉPONSES
A.1
Les fonctions de plancher et de plafond sont idempotentes parce qu’elles renvoient des valeurs entières. En outre, d’après le théorème A.1, le plancher ou le plafond d’un entier est l’entier luimême.
A.2
Un logarithme est l’exposant de la base donnée qui crée la valeur donnée.
A.3
Le premier principe de l’induction mathématique (induction faible) autorise l’hypothèse inductive d’après laquelle la proposition P(n) est vraie pour une valeur unique de n. En revanche, le deuxième principe d’induction mathématique (induction forte) autorise l’hypothèse inductive d’après laquelle toutes les propositions P(k) sont vraies pour tout k inférieur ou égal à une valeur de n.
A.4
Utilisez l’induction faible (premier principe) lorsque la proposition P(n) peut être directement associée à son prédécesseur P(n – 1). Utilisez l’induction forte (deuxième principe) lorsque la proposition P(n) dépend de P(k) pour k < n –1.
A.5
La constante d’Euler est la limite de la différence (1 + 1/2 + 1/3 + … + 1/n) – ln n. Sa valeur est approximativement égale à 0,5772.
A.6
La formule de Stirling est particulièrement pratique lorsque vous devez approximer n! pour des n de taille importante, par exemple n > 20.
A.7
La définition récursive des nombres de Fibonacci (exemple 4.5) est inutile lorsque n > 20 parce que le nombre d’appels récursifs croît de façon exponentielle. En revanche, la formule explicite (théorème A.18) est une solution très pratique lorsque n < 1475.
A. Mathématiques de base
?
361
EXERCICES D’ENTRAÎNEMENT
EXERCICES D’ENTRAÎNEMENT
A.1
Dessinez les graphes de : a. b. c. d.
y = x y = x y = x - x y = x - x
A.2
Démontrez le théorème A.1.
A.3
Démontrez le théorème A.2.
A.4
Démontrez le théorème A.3.
A.5
Vrai ou faux : a. f = o(g) ⇔ g = ω(f) b. f = O(g) ⇔ g = Ω(f) c. f = Θ(g) ⇔ g = Θ (f) d. f = O(g) ⇒ f = Θ (g) e. f = Θ (g) ⇒ f = Ω (g) f. f = Θ (h) ∧ g = Θ (h) ⇒ f + g = Θ (h) g. f = Θ (h) ∧ g = Θ (h) ⇒ fg = Θ (h) h. n2 = O(n lg n) i. n2 = Θ (n lg n) j. n2 = Ω (n lg n) k. lg n = ω(n) l. lg n = o(n)
A.6
Démontrez le théorème A.4.
A.7
Démontrez le théorème A.5.
A.8
Démontrez le théorème A.10.
A.9
Démontrez le théorème A.11.
A.10 Démontrez le théorème A.12. A.11 Démontrez le théorème A.16. A.12 Démontrez le théorème A.17. A.13 Démontrez le théorème A.18. A.14 Démontrez le corollaire A.3. A.15 Exécutez un programme qui testera la formule du théorème A.18 en comparant les valeurs qu’il
crée à celles de la définition récursive des nombres de Fibonacci. A.16 Démontrez le théorème A.19. A.17 Démontrez le corollaire A.4. A.18 Démontrez le corollaire A.5. A.19 Démontrez le théorème A.20. A.20 Démontrez le théorème A.21.
362
Annexes
¿
SOLUTIONS
SOLUTIONS
A.1
A.2
Les graphes (a) y = x, (b) y = x, (c) y = x – x et (c) y = x – x : a
b
c
d
La démonstration du théorème A.1 est la suivante : a. Les relations x = max{m ∈ Z | m ≤ x} et x = min{n ∈ Z | n ≥ x} ne font que reformuler les définitions de x et x. b. Supposons que m = x et n = x. Alors, par définition, m ≤ x < m + 1 et n – 1 < x ≤ n. Ensuite, x – 1 < m et n < x + 1. Ainsi, x – 1 < m ≤ x ≤ n < x + 1. c. Les inégalités x - 1 < x < x + 1 ne font que résumer celles du point précédent. d. Supposons que n ∈ Z de telle façon que n ≤ x < n + 1, et supposons que A = {m ∈ Z | m ≤ x}. Ensuite, n ∈ A et x = maxA, c’est pourquoi n ≤ x. Maintenant, si n < x, alors n + 1 ≤ x puisque n et x dont entiers. Mais, par hypothèse, n + 1 ≤ x, d’où n = x. La démonstration de la deuxième partie est analogue. e. Supposons que x ∈ Z (c’est-à-dire que x soit un entier). Ensuite, n = x dans le point d que nous venons de voir : x ≤ x < x + 1, donc x = x et x – 1 < x≤ x, donc x = x. f. Supposons que x ∉ Z (c’est-à-dire que x ne soit pas un entier). Supposons que u = x – x et v = x – x. Ensuite, d’après c, u ≥ 0 et v ≥ 0. Toujours d’après c, x – 1 < x = x – u et v + x = x < x + 1, c’est pourquoi u < 1 et v < 1. Ainsi, 0 ≤ u < 1 et 0 ≤ v < 1. Cependant, u et v ne peuvent pas être entiers : en effet, si c’était le cas, x le serait également parce que x = x+ u = x – v. Par conséquent, 0 < u < 1 et 0 < v < 1, d’où x = x + u > et x = x – v < x. g. Supposons que n = – -x. Alors, (–n) = (-x). Par conséquent, d’après c, (–x) – 1 < (–n) ≤ (–x), d’où x ≤ n < x + 1, d’où x ≤ n et n – 1 < x, d’où n – 1 < x ≤ n. Ainsi, d’après d, n = x et donc – -x = x, d’où -x = -x La deuxième identité suit la première si vous remplacez x par –x. h. Supposons que n = x + 1. D’après c, (x + 1) – 1 < n ≤ (x + 1), d’où x – 1 < n – 1 ≤ x et x = (x + 1) – 1 < n. Par conséquent, n – 1 ≤ x < n, c’est-à-dire que (n – 1) ≤ x < (n – 1) + 1.
A. Mathématiques de base
363
Par conséquent, d’après d, (n – 1) = -x, et donc x + 1 = n = x + 1. La démonstration de la deuxième identité est similaire. A.3
Démonstration du théorème A.2 : a. Supposons que x = by. Par définition, logb(by) = logb(x) = y. log x y b. Supposons que y = logb x. Par définition, b b = b = x . c. Supposons que y = logb u et z = logb v. Ensuite, par définition, u = by et v = bz, donc uv = (by)(bz) = by+z, donc logb(uv) = y + z = logb u + logb v. d. D’après la loi c que nous venons de voir, logb v + logb u/v = logb (v u/v) = logb u, donc logb u/v = logb u + logb u. e. Supposons que y = logb u. Ensuite, par définition, u = by, donc uv = (by)v = bvy. Ensuite, par définition, logb (uv) = v y = v logb u. f. Supposons que y = logb x. Ensuite, par définition, x = by, donc logc x = logc (by) = y logcb, d’après la loi e que nous venons de voir. Par conséquent, logb x = y = (logc x)/( logcb).
A.4
La démonstration du théorème A.3 est la suivante : a. Étant donné que 2p < n < 2p + 1, avec n, p ∈ Z, supposons que x = lg n. Ensuite, en prenant le logarithme binaire de chaque partie de la double inégalité, nous avons p < x < p + 1. Ainsi, d’après le théorème A.1 (d), p = x = lg n. De la même façon, (p + 1) – 1 < x < (p + 1), donc p + 1 = lg n. b. Supposons que x = lg (n + 1) et y = lg n. D’après le théorème A.1(c), y < y + 1, donc n = 2y < 2y + 1. Mais maintenant, les deux parties de l’inégalité n < 2y + 1 sont des entiers, c’est pourquoi n + 1 ≤ 2y + 1. Par conséquent, x = lg (n+1) + 1. Mais également, x = lg (n + 1) > = lg n = y ≥ y, donc m – 1 < x ≤ m, avec m = y + 1 = lg n + 1. Par conséquent, d’après le théorème A.1 (d), x = m, c’est-à-dire que lg(n + 1) = lg n + 1.
A.5
a. Vrai. b. Vrai. c. Vrai. d. Faux. e. Vrai. f. Vrai. g. Faux. h. Faux. i. Faux. j. Vrai. k. Faux. l. Vrai.
A.6
La démonstration du théorème A.4 est la suivante : Pour des fonctions données f et g dans M, l’expression L(f,g) est égale à 0, est positive ou bien infinie. Ces trois cas qui s’excluent mutuellement déterminent respectivement si f est dans o(g), Θ(g) ou bien dans ω(g).
A.7
La démonstration du théorème A.5 est la suivante : O(g) = { f ∈ M | 0 ≤ L(f, g) < ∞} = { f ∈ M | L(f, g) = 0 ou 0 < L(f, g) < ∞} = { f ∈ M | L(f, g) = 0} ∪ { f ∈ M | 0 < L(f, g) < ∞} = o(g) ∪ Θ(g)
364
Annexes
Θ(g) = { f ∈ M | 0 < L(f, g) < ∞} = { f ∈ M | L(f, g) > 0 et L(f, g) < ∞} = { f ∈ M | L(f, g) > 0} ∩ { f ∈ M | L(f, g) < ∞} = Ω(g) ∩ O(g) Ω(g) = { f ∈ M | 0 < L(f, g) ≤ ∞} = { f ∈ M | 0 < L(f, g) < ∞ ou L(f, g) = ∞} = { f ∈ M | 0 < L(f, g) < ∞} ∪ { f ∈ M | L(f, g) = ∞} = Θ(g) ∪ ω (g)
La démonstration du théorème A.10 est la suivante : Supposons que S = a + ar + ar2 + ar3 + … + arn – 1. Ensuite, rS = ar + ar2 + ar3 + ar4 … + arn, donc S – rS = a – arn, (1 – r)S = a(1 – rn), et ainsi S = a(1 – rn)/(1 – r). A.9 La démonstration du théorème A.11 est la suivante : Si –1 < r < 1, alors, au fur et à mesure de la croissance illimitée de n, rn décroît jusqu’à 0. Si nous laissons rn = 0 dans la formule du théorème A.10, nous obtenons le théorème A.11. A.10 La démonstration du théorème A.12 est la suivante : Supposons que S = 1 + 2 + 3 + … + n. Alors, S = n + … + 3 + 2 + 1 également. Ajoutez ces deux équations en ajoutant les 2n termes à droite de la paire : (1 + n), (2 + (n – 1)), etc. Il existe n paires et chacune d’entre elles a la même somme n + 1. Par conséquent, le sommet total de la partie droite est n (n + 1). Ensuite, étant donné que la somme de gauche est 2S, la valeur correcte de S doit être n(n + 1)/2. A.11 La démonstration du théorème A.16 est la suivante : Si r = AC/CB, alors r = AC/CB = AB/AC = (AC + CB)/AC = 1 + CB/AC = 1 + 1/r, donc r2 = r + 1. Si nous finissons le carré, nous obtenons (r – 1/2)2 = r2 – r + 1/4 = (r + 1) – r + 1/4 = 5/4, donc r – 1/2 = ± 5 ⁄ 4 Et r = (1 ± 5 )/2. Les formes décimales de ces racines sont A.8
r1 = (1 + r2 = (1 –
5 )/2 = φ = 1,6180339887498948482045868343656… 5 )/2 = ψ = –0,6180339887498948482045868343656…
étant donné que le nombre d’or φ est la seule solution positive, il doit s’agir du bon coefficient. A.12 La démonstration du théorème A.14 est la suivante : a. φ2 + 1 = φ + 1 parce que φ est une solution à l’équation x2 = x + 1. b. ψ 2 = ψ + 1 parce que ψ est une solution à l’équation x2 = x + 1. c. 1/φ = φ – 1 parce que 1 = φ2 – φ. d. 1/ψ = ψ – 1 parce que 1 = ψ 2 – ψ . e. φ + ψ = (1 + 5 )/2 + (1 – 5 )/2 = 1. f. φ – ψ = (1 + 5 )/2 – (1 – 5 )/2 = 2( 5 )/2) = 5 ). A.13 La démonstration du théorème A.18 est la suivante, d’après l’hypothèse inductive : 0
0
1
1
0 φ –Ψ 1–1 F 0 = ----------------- = ------------ = ------- = 0 5 5 5 φ –Ψ φ–Ψ 5 F 1 = ----------------- = ------------- = ------- = 1 5 5 5
365
A. Mathématiques de base
n+1
n+1
n
n–1
n
n–1
φ –Ψ (φ + φ ) – (Ψ + Ψ ) F n + 1 = ------------------------------- = ---------------------------------------------------------------5 5 n
n
n–1
n–1
+Ψ φ +Ψ φ = ------------------ + -----------------------------5 5 = Fn + Fn – 1
A.14 La démonstration du corollaire A.3 est la suivante :
Étant donné que –1 < ψ < 0, les exposants élevés de ψ sont négligeables. C’est pourquoi Fn = k φn, avec k = 1/ 5 . A.15 Le programme suivant teste la formule explicite des nombres de Fibonacci (théorème A.18) : • public class PrA15 • { public static void main(String[] args) • { final double SQRT5 = Math.sqrt(5.0); • final double PHI = (1 + SQRT5)/2; • final double PSI = (1 - SQRT5)/2; • long f0, f1=0, f2=1; • for (int n=2; n<32; n++) • { f0 = f1; • f1 = f2; • f2 = f1 + f0; • float fn = (float)((Math.pow(PHI,n)-Math.pow(PSI,n))/SQRT5); • System.out.println(f2+"\t"+fn); • } • } •}
A.16 La démonstration du théorème A.19 est la suivante :
Supposons que a > b > 0, que r = a%b > 0 et que d | b. Ensuite, a = qb + r pour q ∈ N, et b = kd pour k ∈ N. Ainsi, a = qb + r = qkd + r. Ensuite, d | a ⇒ a = md, pour m ∈ N ⇒ md = a = qkd + r ⇒ r = md – qkd = (m – qk)d ⇒ d | r. De la même façon, d | r ⇒ r = nd, pour n ∈ N ⇒ a = qkd + r = qkd + nd = (qkd + n)d ⇒ d | a. Ainsi, d | a ⇔ d | r. A.17 La démonstration du corollaire A.4 est la suivante :
Il s’agit d’une conséquence de l’argument logique (P∧Q) → (R↔S) ⇒ P → (Q∧R↔Q∧S), avec P = « a > b > 0 » et r = « a%b » > 0, Q = « d | b », R = « d | a », et R = « d | r ». A.18 La démonstration du corollaire A.5 est la suivante :
Supposons que A = {d ∈ N : d | a et d | b} et supposons que B = { q ∈ N : d | b et d | r}. Ensuite, d’après le corollaire A.4, A = B. Par conséquent, maxA = maxB. Cependant, par définition, maxA = gcd(a,b) and maxB = gcd(b,r). A.19 La démonstration du théorème A.20 est la suivante :
Le corollaire A.4 garantit que l’invariant de boucle est toujours vrai. La boucle s’arrête lorsque b = 0 et renvoie a qui est le gcd(a,b). Par conséquent, la valeur renvoyée est également le gcd de la paire initiale (a,b). A.20 La démonstration du théorème A.21 est la suivante :
À la k-ième itération, ak = qk bk + rk, avec qk = ak/bk ≥ 1 et rk = ak%bk. Ainsi, ak ≥ bk + rk. Mais bk = ak + 1 et rk = bk + 1 = ak + 2. Ainsi, ak ≥ ak + 1 + ak + 2. Si nous considérons maintenant la séquence inverse {xi} avec x0 = am et xi = am – i : x0 ≥ 1, xi ≥ xi – 1 + xi – 2 pour tout i, par conséquent, x2 ≥ x1 + x0 ≥ 1 + 1 = 2, x3 ≥ x2 + x1 ≥ 2 + 1 = 3, etc. Ainsi, xi ≥ Fi + 1 (c’est-à-dire les nombres de Fibonacci). Par conséquent, a = xm ≥Fm + 1 ≥ ϕm, et donc logϕa ≥ m.
B. De C++ à Java Cette annexe illustre les correspondances entre les concepts du C++ et ceux de Java. Elle est destinée aux programmeurs C++ qui apprennent Java. Les différences fondamentales entre ces deux langages sont les suivantes : • • • • • •
En Java, toutes les instructions exécutables doivent être encapsulées dans des classes. Java utilise les éléments Objects au lieu des classes template. Java n’admet aucune fonction, ni aucune variable externes. Java utilise des références et non des pointeurs. Toutes les arguments sont passés par valeur. La définition C++ string* s = new string; équivaut à la définition Java String s = new String();.
• Java a recours à un procédé de ramasse-miettes automatique au lieu d’utiliser l’opérateur delete. Vous trouverez ci-après une liste des principales correspondances entre Java et le C++ : bool . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . boolean char . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Ne s’applique pas wchar_t . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . char short . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . short int . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . int long . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . long unsigned char . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . byte unsigned short . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Ne s’applique pas unsigned int . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Ne s’applique pas unsigned long . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Ne s’applique pas float . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . float double . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . double enum . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Ne s’applique pas string . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . String const . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . final goto . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Ne s’applique pas pointeur . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Ne s’applique pas référence . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Ne s’applique pas passage par valeur . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . passer passage par référence . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Ne s’applique pas inline . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Ne s’applique pas register . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Ne s’applique pas namespace . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . package flot entrées/sorties . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Ne s’applique pas printf() . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . System.out.println() scanf() . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Ne s’applique pas class . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . class struct . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . class
367
B. De C++ à Java
membre de donnée . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . fonction membre . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . public . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . protected . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . private . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . static . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . this . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . new . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . delete . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . template . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . operator . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . friend . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . sizeof . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . typedef . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . typeid . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . virtual . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . virtual class . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . fonction virtual . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . classe virtual pure . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . fichier d’en-tête . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . #include . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . vector . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . stack . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . queue . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . deque . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . priority_queue . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . list . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . map . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . set . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
champ, variable d’instance méthode public protected private static this new
Ne s’applique pas Ne s’applique pas Ne s’applique pas Ne s’applique pas Ne s’applique pas Ne s’applique pas getClass() abstract abstract class abstract method interface package import Vector Stack
Ne s’applique pas Ne s’applique pas Ne s’applique pas LinkedList HashMap HashSet
C. Environnements de développement Java Un environnement de développement est un ensemble des programmes informatiques qui permettent de créer un programme. Il est essentiellement composé des deux éléments suivants : un éditeur de texte et un compilateur. Par ailleurs, les environnements de développement intégré, ou IDE, comprennent également un débogueur, de la documentation sur le langage de programmation, ainsi que d’autres outils.
C.1 INVITE DE COMMANDE WINDOWS L’environnement de développement le plus simple ne vous coûte rien puisque vous pouvez utiliser l’éditeur de texte qui est fourni avec votre système d’exploitation, puis télécharger la dernière version de Java du site web de Sun Microsystems http://java.sun.com/. Si vous travaillez sous Microsoft Windows, vous pouvez utiliser les éditeurs NotePad ou WordPad. Créez un fichier que vous nommerez Hello.java. Enregistrez ce fichier au format Texte seulement sur le lecteur A:. Lancez ensuite l’invite de commandes Windows depuis Démarrer > Programmes > Accessoires et exécutez les commandes qui vous permettront notamment de paramétrer la variable de chemin d’accès PATH, si nécessaire, de façon à ce que le système d’exploitation puisse trouver la commande du compilateur javac et la commande d’exécution java.
C.2 WEBGAIN VISUAL CAFE Visual Cafe a été initialement créé par Symantec Corp et est maintenant commercialisé par WebGain Inc. La version 4.0 a été lancée en août 2000. Pour créer un programme d’application Java standard : 1. Sélectionnez New Project… dans le menu File (ou appuyez sur Ctrl+Shift+N), puis sélectionnez Win32 Console Application dans la boîte de dialogue New Project. 2. Cette opération vous permet de créer un nouveau projet et de définir une classe nommée SimplCon de la façon suivante : /* Classe stub java de base pour une application console Win32. */ public class SimplCon { public SimplCon () { } static public void main(String args[]) { System.out.println("Hello World"); } }
Ce programme est une variation du programme classique « Hello, World! ».
C. Environnements de développement Java
369
3. Pour l’exécuter, sélectionnez Execute dans le menu Project (ou appuyez sur Ctrl+F5). Cela vous permettra de compiler et d’exécuter le code, puis de créer une sortie dans une fenêtre DOS classique. 4. Appuyez sur Entrée pour fermer la fenêtre DOS. 5. Pour enregistrer votre projet, cliquez sur la fenêtre correspondante nommée Project – Untitled, puis sélectionnez Enregistrer sous… dans le menu Fichier. Dans notre exemple, nous avons enregistré le projet sous Project1.vep dans A:. Vous remarquerez que le fichier SimplCon.java est alors enregistré automatiquement dans le même répertoire. 6. Pour renommer votre classe principale, éditez les deux occurrences de la chaîne SimplCon dans la fenêtre du code source, puis sélectionnez Enregistrer sous… dans le menu Fichier. Dans le cas présent, nous avons renommé la classe principale Main. Cela signifie que vous avez maintenant deux classes contenant chacune leur propre méthode main(), à savoir SimplCon et Main. Double cliquez sur l’icône du fichier SimplCon dans la fenêtre du projet afin de l’afficher à nouveau dans sa propre fenêtre d’édition. 7. Un projet ne peut comporter qu’une seule classe main(), c’est-à-dire une seule classe dont la méthode main() est désignée comme cible de départ de l’exécution. Afin de savoir quelle méthode main() est la cible de votre projet, modifiez la chaîne println() de la classe Main et remplacezla par "Goodbye, World!". Appuyez ensuite sur Ctrl+F5 pour exécuter le projet ou bien sélectionnez Execute dans le menu Project. Vous devriez obtenir un message Hello World vous indiquant que la cible n’a pas été modifiée. 8. Pour changer de cible, sélectionnez Options… dans le menu Project afin d’afficher la fenêtre Project Options. Modifiez ensuite le listing sous MainClass en remplaçant SimplCon par Main, puis cliquez sur OK. Exécutez à nouveau votre projet. Cette fois, c’est le message Goodbye,World! qui devrait s’afficher. 9. Fermez maintenant la fenêtre SimplCon.java. Cliquez ensuite avec le bouton droit de la souris sur l’icône de ce fichier dans la fenêtre Project, puis sélectionnez Couper dans le menu déroulant, ou bien dans le menu Edition afin de supprimer la classe du projet et de n’y laisser que la nouvelle classe Main. 10. Éditez maintenant la classe principale en ajoutant une autre instruction println de façon à ce que main() soit similaire au code suivant: static public void main(String args[]) { System.out.println(args[0] + " **** " + args[1]); System.out.println("Goodbye, World!"); }
Ouvrez ensuite la fenêtre Project Options à nouveau, sélectionnez Options dans le menu Project, puis ajoutez le texte Hello World!!!! dans le champ Program Arguments avant de cliquer sur OK. Exécutez votre projet une nouvelle fois. Cette fois, votre sortie devrait commencer par la ligne suivante : Hello **** World!!!! Goodbye, World!
Nous venons donc de voir comment utiliser les arguments des lignes de commande. Chaque chaîne listée figurant dans le champ Program Arguments devient alors l’un des éléments String du paramètre de tableau args[] de la méthode main(). 11. Pour insérer une nouvelle classe dans votre projet, sélectionnez Class… dans le menu Insert. Nommez votre nouvelle classe Widget et entrez DSwJ.util dans le champ Package. Cliquez ensuite sur le bouton Finish, puis sur Yes pour répondre à la question concernant la création d’un nouveau répertoire. Vérifiez ensuite ce qui a été créé dans votre répertoire de projet. Votre paquetage
370
Annexes
Java est devenu un chemin de répertoire, chacun de ses composants correspondant au nom d’un dossier imbriqué. Ainsi, le paquetage DSwJ.util a créé un dossier Util imbriqué dans un dossier DSwJ, et le nouveau fichier Widget.java a été inséré dans ce dossier interne. 12. Double cliquez sur le nouveau listing Widget dans la fenêtre Project afin d’ouvrir ce fichier de code source qui contient une présentation minimale de classe : package DSwJ.util; public class Widget { }
13. Cliquez sur l’onglet Packages situé en bas dela fenêtre Project afin d’afficher la vue correspondante. Dans la partie supérieure de cette vue, vous trouverez le fichier Main.java listé sous le paquetage Default Package,et suivi du fichier Widget.java listé dans le paquetage DSwJ.util. Sous ces fichiers, vous trouverez une très longue liste de tous les paquetages de la bibliothèque standard Java. Faites dérouler la liste jusqu’à java.util et étendez cette entrée de façon à ce qu’elle liste les classes qu’elle définit. Double-cliquez sur le listing Stack.java afin d’ouvrir le fichier et d’afficher le code source. 14. Sélectionnez Class Browser dans le menu View ou bien appuyez sur Ctrl+Shift+C afin d’ouvrir la fenêtre de navigateur de classes de Visual Café. Cette fenêtre contient trois sections : le listing Classes, une sous-fenêtre Members et une sous-fenêtre d’édition. Cliquez avec le bouton droit de la souris dans le listing Classes, puis sélectionnez Collapse All afin de visualiser les listings des dossiers. Faites défiler le listing de la même manière que pour l’étape 13 jusqu’au paquetage java.util, étendez-le, puis double-cliquez sur l’entrée Stack. La sous-fenêtre Members doit maintenant contenir une liste de tous les membres de la classe java.util.Stack. Cliquez sur la méthode push pour afficher son code source dans la sous-fenêtre d’édition. Visual Cafe offre toutes les fonctionnalités habituellement disponibles dans d’autres environnements IDE avancés : • éditeur couleur ; • débogueur contrôlé par un bouton ; • aide en ligne exhaustive. Il se distingue des autres environnements IDE grâce aux caractéristiques suivantes : • • • • • •
des messages de compilateur utiles et significatifs ; un assistant de classe ; un code helper ; un correcteur de syntaxe ; un éditeur de hiérarchie ; le support des macros définies par l’utilisateur.
Le système d’aide en ligne de Visual Café est particulièrement bien fait. La commande Java API Reference… dans le menu Help offre des listings complets de tous les paquetages, classes, champs et méthodes disponibles qui vont de la bibliothèque standard Java aux bibliothèques supplémentaires fournies par WebGain. La fenêtre Class Hierarchy présente un arbre d’héritage gigantesque, de la classe Object jusqu’aux classes qui se trouvent au niveau le plus bas. Les rubriques Index of all Classes, Fields et Methods comportent plus de 20 000 listings. La rubrique Package Index liste les 95 paquetages des bibliothèques Java, Sun et Symantec. Vous pouvez passer d’une rubrique à l’autre en cliquant sur les onglets correspondants situés dans la partie supérieure de la fenêtre API Reference. Toutes ces rubriques utilisent des liens hypertextes afin de faciliter la navigation. Malheureusement, la référence API n’est pas à jour puisque la version d’août 2000 ne contient aucune des classes Java 1.2 telles que java.util.Arrays.
Index
A abstract, mot-clé 64 AbstractCollection , classe
101 AbstractList, classe 151 AbstractSequentialList ,
classe 151 AbstractSet, classe 306
accesseur 7 algorithme d'Euclide 83, 355 récursivité 83 de Dijkstra 324 de parcours 178 d'un graphe 328 en largeur 178, 194 infixe 196 postfixe 179, 195 préfixe 178, 195 de recherche binaire 33 récursive 80 en largeur d'abord 328 en profondeur d'abord 329 séquentielle 31 de résolution des collisions hachage avec essais linéaires 293 méthode quadratique 294 de tri digital 268 panier 270 par fusion 258 par insertion 255 par permutation 252 par segmentation 260 par sélection 254 rapidité 268
Shell 256 stable 269 vertical 263 récursif, complexité 84 arbre 171 algorithme de parcours 178 binaire 187 algorithme de parcours 194 complet 189 d'expression 196 de recherche 222 AVL 224 déséquilibré 223 équilibré 223 propriété 222 incomplet 193 parfait 192 chemin 173 complet 173 couvrant minimal 328 de recherche 217 multidirectionnel 217 décisionnel 174 degré d'un nœud 173 enfant 171 équilibré ou B 219 feuille 172 hauteur 173 libre 315 longueur de chemin 173 niveau 173 nœud 172 interne 172 profondeur 173 non ordonné 172 égalité 172 ordonné 177 inégalité 177 ordre 173 parent 171
propriété 173 racine 171 sous-arbre 173 superarbre 173 taille 172 arêtes, graphe 313 ArrayList, classe 154 ArrayQueue, classe 132 Arrays, classe 28 Arrays.sort(), méthode 252
B Bag, classe 102 BinaryTree, classe 198
boucle 5 graphe 320
C calculatrice RPN 118 chaînage séparé 295 champ 7 clé, arbre binaire de recherche 222 chemin 314 élémentaire 315 eulérien 323 fermé 315 hamiltonien 324 le plus court 324 orienté 322 classe 7 AbstractCollection 101 AbstractList 151 AbstractSequentialList 151
372
Structures de données en Java
D
classe (suite) AbstractSet 306
abstraite 64 ArrayList 154 ArrayQueue 132 Arrays 28 Bag 102 BinaryTree 198
de complexité 347 enfant 57 enveloppe 4 HashMap 288 HashSet 307 LinkedList 155 LinkedQueue 135 Math 13 Object 62 parent 57 PriorityQueue 236 Ring 156 Stack 115 String 10 TreeMap 298 TreeSet 309 Vector 35 clonage 63 code de hachage 289 coefficient binomial 82 itérativité 83 récursivité 82 oscillant 348 collection 99 Collection, interface 100 Comparator, interface 237 complexité 347 concordance 296 ordonnée 298 constante d'Euler 352 constructeur 7 contains(), méthode 202 contrôle du flux 5 conversion de chaîne 60 de l'affectation 60 de l'appel de méthode 61 de type 60 cycle 315 eulérien 323 hamiltonien 324
degré entrant 320 sortant 320 dequeue, méthode 130 diagramme de transition 174 digraphe 320 chemin 321 complet 320 faiblement connexe 322 fortement connexe 322 liste d'adjacences 321 matrice d'adjacence 321 d'incidence 321 pondéré 322 simple 320 données classées 154
E égalité 190 des tableaux 191 enqueue, méthode 130 ensemble 305 intersection 305 soustraction 305 taille 305 union 305 vide 305 environnement de développement 368 exception 70, 116 gestion 116 UnsupportedOperationException 150
vérifiée 71
F facteur de charge 292 file 129 de priorité 233, 236
utilisation 130, 136 fonction cosinus, récursivité mutuelle 87 de hachage 290 factorielle 77 base 78 itérativité 78 partie récursive 78 récursivité 77 Fibonacci récursivité 79 plafond 345 plancher 345 récursive 77 sinus, récursivité mutuelle 87 forêt 205 libre 315 formule de sommation 351 de Stirling 353 framework de collections 129 des files 129
G génération de nombres aléatoires 138 graphe 313 acyclique 315 algorithme de parcours 328 arête 313 chemin 314 complet 314 connexe 315 cycle 314 eulérien 323 hamiltonien 324 incorporé 320 isomorphe 316 orienté 320 pondéré 322 simple 313 sommet 313
373
Index
H hachage avec essais linéaires 291, 293 fonction de ~ 290 table de ~ 290 HashMap, classe 288 HashSet, classe 307 héritage 57 hiérarchie 1
I identité 190 implémentation chaînée 134 contiguë 132 itérative coefficient binomial 83 fonction factorielle 78 tours de Hanoi 121 récursive algorithme d’Euclide 83 coefficient binomial 82 fonction factorielle 77 nombre de Fibonaci 79 induction mathématique 83 deuxième principe 349 étape inductive 349 hypothèse inductive 349 premier principe 348 instanciation 7 interface 67 Collection 100 Comparator 237 Iterator 109, 153 List 149 Map 287 Queue 129 Set 306 isomorphisme 190, 191, 316 des arbres 191 itérateur 153 bidirectionnel 150, 153 de liste 153 indépendant 164
unidirectionnel 153 Iterator, interface 109, 153
J Java définition 1 et le C++ 366 java.util, paquetage 99, 115
L LinkedList, classe 155 LinkedQueue, classe 135 List, interface 149
liste 149 chaînée 151, 155, 295 circulaire 156 d'adjacences 319, 321 des arêtes 319 ListIterator 150 logarithme binaire 346 commun 346 de base b 346 lois des ~ 346 naturel 346
push() 117
quadratique 294 stochastique 176 modificateur 9 d'accès 10 mot-clé, abstract 64 mutateur 7
N nœud, arbre 172 nombre catalan 356 d'équilibre 224 d'or 355 de Fibonacci 78, 354 harmonique 352 notation infixée 118 suffixée 118
O Object, classe 62
objet 2 opérateur d'expression conditionnelle 159 d'index 25
M Map, interface 287
mappage naturel 193, 206, 233 Math, classe 13 matrice d'adjacence 318, 321 d'incidence 319, 321 méthode 6 abstraite 64, 131 Arrays.sort() 252 concrète 131 contains() 202 dequeue 130 enqueue 130 pop() 117
P paire clé/adresse 220 clé/valeur 218, 287 paquetage 70 java.util 99, 115 pile 115 pivot 260 point isolé 314 polymorphisme 58 pop(), méthode 117 PriorityQueue, classe 236 problème de Josephus 162
374
Structures de données en Java
programmation dynamique 85 orientée objet 1 promotion numérique 60 push(), méthode 117
Q Queue, interface 129
R racine, arbre 171 raison, série géométrique 350 recherche binaire 33 séquentielle 31 récursivité 77, 121 algorithme de recherche binaire 80 base 78 fonction factorielle 78 directe 87 indirecte 87 mutuelle 87 cosinus, fonction 87 sinus, fonction 87 partie récursive 78 fonction factorielle 78 rééquilibrage 224 représentation d'une expression infixe 197 postfixe 197 préfixe 197
Ring, classe 156
S série géométrique 350 raison 350 Set, interface 306 simulation en temps réel 142 événementielle 142 simulation d'un système client/serveur 136 singleton 172 sommet degré 314 graphe 313 point isolé 314 sous-arbre 173 sous-classe 57 sous-ensemble 305 sous-graphe maximal 313 Stack, classe 115 String, classe 10 structure contiguë 151 non contiguë 151 superarbre 173 superclasse 57
T table 287 clé 287 de hachage 289, 290 et chaînes 307 performance 292 valeur 287
tableau 25, 132, 150, 151 composant 25 copier 27 élément 25 extensif 27 tas 233 propriété de ~ 233 tours de Hanoi 85 implémentation itérative 121 tracer un appel de fonction 79 TreeMap, classe 298 TreeSet, classe 309 tri digital 268 panier 270 par fusion 258 par insertion 255 par permutation 252 par segmentation 260 par sélection 254 Shell 256 vertical 233, 263 triangulation 357 type primitif 3
U Unsupported Operation Exception, exception 150
utilitaire 7
V variable 2 Vector, classe 35