Poly
Poly
Poly
INF411
et de l'algorithmique
Jean-Christophe Filliâtre
Édition 2019
ii
Avant-propos
Ce polycopié est utilisé pour le cours INF411 intitulé Les bases de la programmation
et de l'algorithmique. Ce cours fait suite au cours INF361 intitulé Introduction à l'infor-
matique et précède le cours INF421 intitulé Design and Analysis of Algorithms.
Ce polycopié reprend, dans le chapitre 2, quelques éléments d'un précédent polycopié
écrit, en plusieurs itérations, par Jean Berstel, Jean-Éric Pin, Philippe Baptiste, Luc
Maranget et Olivier Bournez. Je les remercie sincèrement pour m'avoir autorisé à réutiliser
une partie de ce polycopié. D'autres éléments sont repris, et adaptés, d'un ouvrage écrit
en collaboration avec mon collègue Sylvain Conchon [4]. Je remercie également Didier
AT X
Rémy pour son excellent paquet L E exercise.
Enn, je remercie très chaleureusement les diérentes personnes qui ont pris le temps
de relire tout ou partie de ce polycopié : Marie Albenque, Martin Clochard, Alain Cou-
vreur, Stefania Dumbrava, Léon Gondelman, Mário Pereira, François Pottier, David Sa-
vourey.
On peut consulter la version PDF de ce polycopié, ainsi que l'intégralité du code Java,
sur le site du cours :
http://www.enseignement.polytechnique.fr/informatique/INF411/
L'auteur peut être contacté par courrier électronique à l'adresse suivante :
Historique
Version 1 : août 2013
Version 2 : juillet 2014
Version 3 : juillet 2015
Version 4 : juillet 2016
Version 5 : juillet 2017
Version 6 : juillet 2018
Version 7 : juillet 2019
iv
Table des matières
I Préliminaires 1
1 Le langage Java 3
1.1 Programmation orientée objets . . . . . . . . . . . . . . . . . . . . . . . . 3
1.1.1 Encapsulation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4
1.1.2 Champs et méthodes statiques . . . . . . . . . . . . . . . . . . . . . 6
1.1.3 Surcharge . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6
1.1.4 Héritage . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7
1.1.5 Classes abstraites . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10
1.1.6 Classes génériques . . . . . . . . . . . . . . . . . . . . . . . . . . . 11
1.1.7 Interfaces . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 12
1.1.8 Règles de visibilité . . . . . . . . . . . . . . . . . . . . . . . . . . . 13
1.2 Modèle d'exécution . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 13
1.2.1 Arithmétique des ordinateurs . . . . . . . . . . . . . . . . . . . . . 14
1.2.2 Mémoire . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 16
1.2.3 Valeurs . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 18
2 Notions de complexité 23
2.1 Complexité d'algorithmes et complexité de problèmes . . . . . . . . . . . . 23
2.1.1 La notion d'algorithme . . . . . . . . . . . . . . . . . . . . . . . . . 23
2.1.2 La notion de ressource élémentaire . . . . . . . . . . . . . . . . . . 24
2.1.3 Complexité d'un algorithme au pire cas . . . . . . . . . . . . . . . . 24
2.1.4 Complexité moyenne d'un algorithme . . . . . . . . . . . . . . . . . 25
2.1.5 Complexité d'un problème . . . . . . . . . . . . . . . . . . . . . . . 26
2.2 Complexités asymptotiques . . . . . . . . . . . . . . . . . . . . . . . . . . 27
2.2.1 Ordres de grandeur . . . . . . . . . . . . . . . . . . . . . . . . . . . 27
2.2.2 Conventions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 28
2.2.3 Notation de Landau . . . . . . . . . . . . . . . . . . . . . . . . . . 28
2.3 Quelques exemples . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 28
2.3.1 Factorielle . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 29
2.3.2 Tours de Hanoi . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 29
vi
4 Listes chaînées 49
4.1 Listes simplement chaînées . . . . . . . . . . . . . . . . . . . . . . . . . . . 49
4.2 Application 1 : Structure de pile . . . . . . . . . . . . . . . . . . . . . . . . 54
4.3 Application 2 : Structure de le . . . . . . . . . . . . . . . . . . . . . . . . 54
4.4 Listes cycliques . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 57
4.5 Listes doublement chaînées . . . . . . . . . . . . . . . . . . . . . . . . . . . 60
4.6 Code générique . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 66
5 Tables de hachage 67
5.1 Réalisation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 68
5.2 Redimensionnement . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 71
5.3 Code générique . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 72
5.4 Brève comparaison des tableaux, listes et tables de hachage . . . . . . . . . 74
6 Arbres 75
6.1 Représentation des arbres . . . . . . . . . . . . . . . . . . . . . . . . . . . 76
6.2 Opérations élémentaires sur les arbres . . . . . . . . . . . . . . . . . . . . . 76
6.3 Arbres binaires de recherche . . . . . . . . . . . . . . . . . . . . . . . . . . 78
6.3.1 Opérations élémentaires . . . . . . . . . . . . . . . . . . . . . . . . 78
6.3.2 Équilibrage . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 81
6.3.3 Structure d'ensemble . . . . . . . . . . . . . . . . . . . . . . . . . . 88
6.3.4 Code générique . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 91
6.4 Arbres de préxes . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 92
13 Tri 149
13.1 Tri par insertion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 149
13.2 Tri rapide . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 150
13.3 Tri fusion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 154
13.4 Tri par tas . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 158
13.5 Code générique . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 161
13.6 Exercices supplémentaires . . . . . . . . . . . . . . . . . . . . . . . . . . . 161
IV Graphes 173
15 Dénition et représentation 175
15.1 Matrice d'adjacence . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 176
15.2 Listes d'adjacence . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 178
15.3 Code générique . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 178
Annexes 197
A Solutions des exercices 197
B Lexique Français-Anglais 241
Bibliographie 243
Index 245
Première partie
Préliminaires
1
Le langage Java
class Polar {
double rho;
double theta;
}
Ici rho et theta sont les deux champs de la classe Polar, de type double. On crée une
instance particulière d'une classe, appelée un objet , avec la construction new. Ainsi
Polar p = new Polar();
déclare une nouvelle variable locale p, de type Polar, dont la valeur est une nouvelle
instance de la classe Polar. L'objet est alloué en mémoire. Ses champs reçoivent des
valeurs par défaut (en l'occurrence ici le nombre ottant 0.0). On peut accéder aux
champs de p, et les modier, avec la notation usuelle p.x. Ainsi on peut écrire
p.rho = 2;
p.theta = 3.14159265;
double x = p.rho * Math.cos(p.theta);
p.theta = p.theta / 2;
Pour allouer de nouveaux objets en initialisant leurs champs avec des valeurs particulières,
autres que les valeurs par défaut, on peut introduire un ou plusieurs constructeurs . Un
constructeur naturel pour la classe Polar prend les valeurs des champs rho et theta en
arguments. On l'écrit ainsi (dans la classe Polar) :
4 Chapitre 1. Le langage Java
Polar(double r, double t) {
if (r < 0) throw new Error("Polar: negative length");
rho = r;
theta = t;
}
Ici, on ne se contente pas d'initialiser les champs. On vérie également que r n'est pas
négatif. Dans le cas contraire, on lève une exception. Ce constructeur nous permet d'écrire
maintenant
Attention
Nous avions pu écrire plus haut new Polar() sans avoir déni de constructeur.
En eet, toute classe possède un constructeur par défaut, sans argument. Mais
une fois qu'un constructeur est ajouté à la classe Polar, le constructeur implicite
sans argument disparaît. Dans l'exemple ci-dessus, si on tente d'écrire maintenant
new Polar(), on obtient un message d'erreur du compilateur : The constructor Polar() is unde
Rien ne nous empêche cependant de réintroduire un constructeur sans argument. Une
classe peut en eet avoir plusieurs constructeurs, avec des arguments en nombre ou
en nature diérents. On parle de surcharge. La surcharge est expliquée plus loin.
1.1.1 Encapsulation
Supposons maintenant que l'on veuille maintenir l'invariant suivant pour tous les
objets de la classe Polar :
Pour cela on déclare les champs rho et theta privés, de sorte qu'ils ne sont plus visibles
à l'extérieur de la classe Polar.
class Polar {
private double rho, theta;
Polar(double r, double t) { /* garantit l'invariant */ }
}
Si on cherche à accéder au champ rho depuis une autre classe, en écrivant par exemple
p.rho pour un certain objet p de la classe Polar, on obtient un message d'erreur du
compilateur :
class Polar {
private double rho, theta;
...
double norm() { return rho; }
}
p.norm()
Naïvement, on peut voir cet appel de méthode comme un appel norm(p) à une fonction
norm qui recevrait l'objet comme premier argument. Dans une méthode, cet argument
implicite, qui est l'objet sur lequel on appelle une méthode, est désigné par le mot-clé
this. Ainsi on peut réécrire la méthode norm ci-dessus de la manière suivante :
On explicite le fait que rho désigne ici un champ de l'objet. En particulier, on évite une
confusion possible avec une variable locale ou un paramètre de la méthode. De la même
manière, nous aurions pu écrire le constructeur sous la forme
Polar(double r, double t) {
this.rho = r;
this.theta = t;
}
car, dans le constructeur, this désigne l'objet qui vient d'être alloué. Du coup, on peut
même donner aux paramètres du constructeur les mêmes noms que les champs :
Attention
En revanche, il serait incorrect d'écrire le constructeur sous la forme
class Polar {
double rho, theta;
static double two_pi = 6.283185307179586;
De même, une méthode peut être statique et elle s'apparente alors à une fonction tradi-
tionnelle.
Ce qui n'est pas statique est appelé dynamique. Dans une méthode statique, l'emploi de
this n'est pas autorisé. En eet, il n'existe pas nécessairement d'objet particulier ayant
été utilisé pour appeler cette méthode. Pour la même raison, une méthode statique ne peut
pas appeler une méthode dynamique. À l'inverse, en revanche, une méthode dynamique
peut parfaitement appeler une méthode statique. Enn, on note que le point d'entrée d'un
programme Java, à savoir sa méthode main, est une méthode statique :
1.1.3 Surcharge
Plusieurs méthodes d'une même classe peuvent porter le même nom, pourvu qu'elles
aient des arguments en nombre et/ou en nature diérents ; c'est ce que l'on appelle la
surcharge (en anglais overloading ). Ainsi on peut écrire dans la classe Polar deux mé-
thodes mult pour multiplier respectivement par un autre nombre complexe en coordonnées
polaires ou par un simple ottant.
class Polar {
...
void mult(Polar p) {
this.rho *= p.rho; this.theta = normalize(this.theta + p.theta);
}
void mult(double f) {
this.rho *= f;
}
}
On peut alors écrire des expressions comme p.mult(p) ou encore p.mult(2.5). La sur-
charge est résolue par le compilateur, au moment du typage. Tout se passe comme si on
avait écrit en fait deux méthodes avec des noms diérents
1.1. Programmation orientée objets 7
class Polar {
...
void mult_Polar(Polar p) {
this.rho *= p.rho; this.theta = normalize(this.theta + p.theta);
}
void mult_double(double f) {
this.rho *= f;
}
}
puis les expressions p.mult_Polar(p) et p.mult_double(2.5). Ce n'est donc rien d'autre
qu'une facilité fournie par le langage pour ne pas avoir à introduire des noms diérents. On
peut surcharger autant les méthodes statiques que dynamiques, ainsi que les constructeurs
(voir notamment l'encadré page 4).
1.1.4 Héritage
Le concept central de la programmation orientée objet est celui d'héritage : une classe
B peut être dénie comme héritant d'une classe A, ce qui se note
class A { ... } A
class B extends A { ... }
class C extends A { ... } B C
class D extends C { ... }
D
Prenons comme exemple un ensemble de classes pour représenter des objets graphiques
(cercles, rectangles, etc.). On introduit en premier lieu une classe Graphical représentant
n'importe quel objet graphique :
class Graphical {
int x, y; /* centre */
int width, height;
On hérite donc des champs x, y, width et height et des méthodes move et draw. On peut
écrire un constructeur qui prend en arguments deux coins du rectangle :
On peut utiliser directement toute méthode héritée de Graphical. On peut écrire par
exemple
p.draw();
On procède de même pour dénir des cercles. Ici on ajoute un champ radius pour le
rayon, an de le conserver.
Group() {
this.group = new LinkedList<Graphical>();
}
La classe LinkedList est une classe générique, paramétrée par le type des éléments conte-
nus dans la liste, ici Graphical. On indique ce type avec la notation <Graphical> juste
après le nom de la classe générique. (La notion de classe générique est expliquée en détail
un peu plus loin.) Initialement la liste est vide. On dénit une méthode add pour ajouter
un objet graphique à cette liste.
void add(Graphical g) {
this.group.add(g);
// + mise à jour de x,y,width,height
}
Il reste à redénir les méthodes draw et move. Pour dessiner un groupe, il faut dessiner
tous les éléments qui le composent, c'est-à-dire tous les éléments de la liste this.group,
chaque élément g étant dessiné en appelant sa propre méthode draw. Pour parcourir la
liste this.group on utilise la construction for de Java :
void draw() {
for (Graphical g : this.group)
g.draw();
}
Cette construction aecte successivement à la variable g les diérents éléments de la liste
this.group et, pour chacun, exécute le corps de la boucle. Ici le corps de la boucle est
réduit à une seule instruction, à savoir l'appel à g.draw. De même, on redénit la méthode
move dans la classe Group en appelant la méthode move de chaque élément, sans oublier
de déplacer également le centre de tout le groupe.
10 Chapitre 1. Le langage Java
La classe Object
Une classe qui n'est pas déclarée comme héritant d'une autre classe hérite de la classe
Object. Il s'agit d'une classe prédénie dans la bibliothèque Java, de son vrai nom
java.lang.Object. Par conséquent, toute classe hérite, directement ou indirectement,
de la classe Object. Parmi les méthodes de la classe Object, dont toute classe hérite
donc, on peut citer notamment les trois méthodes
Les paramètres, ici A et B, sont indiqués entre les symboles < et >. À l'intérieur de la classe
Pair, on utilise A ou B comme tout autre nom de classe. Ainsi, on déclare deux champs
fst et snd avec
A fst;
B snd;
Pair(A a, B b) {
this.fst = a;
this.snd = b;
}
(On note qu'on ne répète pas les paramètres dans le nom du constructeur, car ils sont
identiques à ceux de la classe.) De la même manière, les paramètres peuvent être utili-
sés dans les déclarations et dénitions de méthodes. Ainsi, on peut écrire une méthode
renvoyant la première composante d'une paire, c'est-à-dire une valeur de type A :
pour déclarer une variable p0 contenant une paire formée d'un entier et d'une chaîne de
caractères. Une telle déclaration peut être faite aussi bien dans la classe Pair qu'à l'exté-
rieur, dans une autre classe. Comme on le voit, la syntaxe pour réaliser l'instanciation est,
sans surprise, la même que pour la déclaration. Comme on le voit également, l'instancia-
tion doit être répétée après new Pair. On note que le premier paramètre a été instancié
par Integer et non pas int. En eet, seule une expression de type dénotant une classe
peut être utilisée pour instancier une classe générique, et int ne désigne pas une classe.
La classe Integer de la bibliothèque Java a justement pour rôle d'encapsuler un entier
de type int dans un objet. La création de cet objet est ajoutée automatiquement par le
compilateur, ce qui nous permet d'écrire 89 au lieu de new Integer(89). La bibliothèque
Java contient des classes similaires Boolean, Long, Double, etc.
1. D'une façon assez surprenante, une telle classe n'existe pas dans la bibliothèque standard Java.
2. On nous pardonnera cet anglicisme.
12 Chapitre 1. Le langage Java
1.1.7 Interfaces
Le langage Java fournit un mécanisme pour réaliser un contrat entre une fournisseur de
code et son client. Ce mécanisme s'appelle une interface. Une interface est un ensemble de
méthodes. Voici par exemple une interface minimale pour une structure de pile contenant
des entiers.
interface Stack {
boolean isEmpty()
void push(int x)
int pop()
}
Elle déclare trois méthodes isEmpty, push et pop. Du côté du code client, cette interface
peut être utilisée comme un type. On peut ainsi écrire une méthode sum qui vide une pile
et renvoie la somme de ses éléments de la manière suivante :
interface Comparable<K> {
int compareTo(K k);
}
On l'utilise pour exiger que les objets d'une classe donnée soient comparables entre eux.
Des exemples d'utilisation se trouvent dans les sections 6.3.4, 8.4 13.5 ou encore 14.2.
Entiers. Un entier est représenté en base 2, sur n chires appelés bits . Ces chires sont
conventionnellement numérotés de droite à gauche :
Le bit b0 est appelé le bit de poids faible et le bit bn−1 le bit de poids fort. Selon le type
Java, n vaut 8, 16, 32 ou 64. Par la suite, on utilisera la notation 1010102 pour dénoter
une suite de bits.
L'interprétation la plus simple de ces n bits est celle d'un entier non signé en base 2,
dont la valeur est donc
n−1
X
bi 2i .
i=0
n−2
X
−bn−1 2n−1 + bi 2i .
i=0
Les valeurs possibles s'étendent donc de −2n−1 , c'est-à-dire 100...0002 , à 2n−1 − 1, c'est-
à-dire 011...1112 . On notera la dissymétrie de cette représentation, avec une valeur de
plus à gauche de 0 qu'à droite. En revanche, il n'y a qu'une seule représentation de 0, à
savoir 00...002 . En Java, les types byte, short, int et long désignent des entiers signés,
sur respectivement 8, 16, 32 et 64 bits. Les plages de valeurs de ces types sont donc
7 7 15 15 31 31 63 63
respectivement −2 ..2 − 1, −2 ..2 − 1, −2 ..2 − 1 et −2 ..2 − 1.
Les constantes littérales peuvent être écrites dans le système décimal, de la façon
usuelle, mais aussi hexadécimal (base 16) avec le préxe 0x, octal (base 8) avec le préxe 0
ou encore binaire (base 2) avec le préxe 0b. Ainsi, 0x2A, 052 et 0b101010 sont trois autres
façons d'écrire la constante 42.
Il est important de signaler qu'un calcul arithmétique peut provoquer un débordement
de capacité et que ce dernier n'est pas signalé, ni par le compilateur (qui ne pourrait pas
le faire de manière générale) ni à l'exécution. Ainsi, le résultat de 100000 * 100000
est 1410065408. Le résultat peut même être du mauvais signe. Ainsi le résultat de
200000 * 100000 est -1474836480.
Outre les opérations arithmétiques élémentaires, le langage Java fournit également
des opérations permettant de manipuler directement la représentation binaire d'un entier,
1.2. Modèle d'exécution 15
c'est-à-dire d'un entier vu simplement comme n bits. L'opération est la négation logique,
qui échange les 0 et les 1, et les opérations &, | et ^ sont respectivement le ET, le OU et le
OU exclusif appliqués bit à bit aux bits de deux entiers. Il existe également des opérations
de décalage des bits. L'opération << k est un décalage logique à gauche, qui insère k zéros
de poids faible. De même, l'opération >>> k est un décalage logique à droite, qui insère k
zéros de poids fort. Enn, l'opération >> k est un décalage arithmétique à droite, qui
réplique le bit de signe k fois. Dans certaines circonstances, on se sert de ces opérations
pour manipuler une valeur de type int qui ne représente pas directement un entier mais
plutôt un petit tableau de booléens, de taille inférieure ou égale à 32. En particulier, on
peut représenter facilement un sous-ensemble de {0,1, . . . ,31} avec une valeur de type
int, le i-ième bit indiquant la présence de l'élément i dans l'ensemble. Des exemples sont
donnés dans les chapitres 11 et 12.
0x1.5p3 pour 1.516 × 23 = 10,5. Par défaut, une constante a pour type double, à moins
que le suxe f ne soit ajouté pour spécier le type float.
Ce cours n'entre pas dans les détails de cette représentation, qui est complexe, mais
plusieurs choses importantes se doivent d'être signalées. En premier lieu, il faut avoir
conscience que la plupart des nombres réels, et même la plupart des nombres décimaux,
ne sont pas représentables par un ottant. En conséquence, les résultats des calculs sont
arrondis et il faut en tenir compte dans les programmes que l'on écrit. Ainsi, le nombre 0,1
n'est pas représentable et un simple calcul comme 1732 × 0,1 donne en réalité un ottant
qui n'est pas égal à 173,2. En particulier, une condition dans un programme ne doit
pas tester en général qu'un nombre ottant est nul, mais plutôt qu'il est inférieur à une
−9
borne donnée, par exemple 10 . En revanche, si les calculs sont arrondis, ils le sont d'une
manière qui est spéciée par un standard, à savoir le standard IEEE 754 [8]. En particulier,
ce standard spécie que le résultat d'une opération, par exemple la multiplication 1732 ×
0,1 ci-dessus, doit être le ottant le plus proche du résultat exact.
Conversions automatiques. Les valeurs des diérents types numériques de Java su-
bissent des conversions automatiques d'un type vers un autre dans certaines circonstances.
Ainsi, si une méthode doit renvoyer un entier de type int, on peut écrirereturn c où c
est de type char. De même on peut aecter à une variable de type long un entier de
type int. La gure 1.1 illustre les diérentes conversions automatiques de Java, chaque
trait de bas en haut étant vu comme une conversion et la conversion étant transitive. Les
conversions entre les types entiers se font sans perte. En revanche, les conversions vers les
types ottants peuvent impliquer un arrondi (on peut s'en convaincre par un argument
16 Chapitre 1. Le langage Java
double
float
long
int
char short
byte
combinatoire, par exemple en constatant que le type long contient plus de valeurs que le
type float).
Lorsque la conversion n'est pas possible, le compilateur Java indique une erreur. Ainsi,
on ne peut pas aecter une valeur de type int à une variable de type char ou encore
renvoyer une valeur de type float dans une méthode qui doit renvoyer un entier.
Lors d'un calcul arithmétique, Java utilise le plus petit type à même de recevoir le
résultat du calcul, en eectuant une promotion de certaines des opérandes si nécessaire.
Ainsi, si on ajoute une valeur de type char et une valeur de type int, le résultat sera de
type int. Plus subtilement, l'addition d'un char et d'un short sera de type int.
1.2.2 Mémoire
Cette section explique comment la mémoire est structurée et notamment comment les
objets y sont représentés. Reprenons l'exemple de la classe Polar de la section précédente
class Polar {
double rho, theta;
}
et construisons un nouvel objet de la classe Polar dans une variable locale p :
p Polar
rho 0.0
theta 0.0
Les petites boîtes correspondent à des zones de la mémoire. Les noms à côté de ces boîtes
(p, rho, theta) n'ont pas de réelle incarnation en mémoire. En tant que noms, ils n'existent
que dans le programme source. Une fois celui-ci compilé et exécuté, ces noms sont devenus
des adresses désignant des zones de la mémoire, qui ne sont rien d'autre que des entiers.
Ainsi la boîte p contient en réalité une adresse (par exemple 1381270477) à laquelle on
trouve les petites boîtes dessinées à droite. Dans une est mentionnée la classe de l'objet,
iciPolar. Là encore, il ne s'agit pas en mémoire d'un nom, mais d'une représentation plus
bas niveau (en réalité une autre adresse mémoire vers une description de la classe Polar).
Dans d'autres boîtes on trouve les valeurs des deux champs rho et theta. Là encore on a
1.2. Modèle d'exécution 17
explicité le nom à côté de chaque champ mais ce nom n'est pas représenté en mémoire. Le
compilateur sait qu'il a rangé le champ rho à une certaine distance de l'adresse de l'objet
et c'est tout ce dont il a besoin pour retrouver la valeur du champ rho.
Notre schéma est donc une simplication de l'état de la mémoire, les zones mémoires
apparaissent comme des cases (les variables portent un nom) et les adresses apparaissent
comme des èches qui pointent vers les cases, alors qu'en réalité il n'y a rien d'autre que
des entiers rangés dans la mémoire à des adresses qui sont elles-mêmes des entiers. Par
ailleurs, notre schéma est aussi une simplication car la représentation en mémoire d'un
objet Java est plus complexe : elle contient aussi des informations sur l'état de l'objet.
Mais ceci ne nous intéresse pas ici. Dans ce polycopié, on s'autorisera parfois même à ne
pas écrire la classe dans la représentation d'un objet quand celle-ci est claire d'après le
contexte.
Tableaux
Un tableau est un objet un peu particulier, puisque ses diérentes composantes ne
sont pas désignées par des noms de champs mais par des indices entiers. Néanmoins l'idée
reste la même : un tableau occupe une zone contiguë de mémoire, dont une petite partie
décrit le type du tableau et sa longueur et le reste contient les diérents éléments. Dans
la suite, on représentera un tableau de manière simpliée, avec uniquement ses éléments
présentés horizontalement. Ainsi un tableau contient les trois entiers 1, 2 et 3 sera tout
simplement représenté par 1 2 3 .
Allocation et libération
Comme expliqué ci-dessus, l'utilisation de la construction new de Java conduit à une
allocation de mémoire. Celle-ci se fait dans la partie de la mémoire appelée le tas
l'autre étant la pile, décrite dans le paragraphe suivant. Si la mémoire vient à s'épuiser,
l'exception OutOfMemoryError est levée. Au fur et à mesure de l'exécution du programme,
de la mémoire peut être récupérée, lorsque les objets correspondants ne sont plus utili-
sés. Cette libération de mémoire n'est pas à la charge du programmeur (contrairement à
d'autres langages comme C++) : elle est réalisée automatiquement par le Garbage Col-
lector (ou GC). Celui-ci libère la mémoire allouée pour un objet lorsque cet objet ne peut
plus être référencé à partir des variables du programme ou d'autres objets pouvant encore
être référencés. La libération de mémoire est eectuée incrémentalement, c'est-à-dire par
petites étapes, au fur et à mesure de l'exécution du programme. En première approxima-
tion, on peut considérer que le coût de la libération de mémoire est uniformément réparti
sur l'ensemble de l'exécution. En particulier, on peut s'autoriser à penser que le coût
d'une expression new se limite à celui du code du constructeur, c'est-à-dire que le coût de
l'allocation proprement dite est constant.
Pile d'appels
Dans la plupart des langages de programmation, et en Java en particulier, les appels de
fonctions/méthodes obéissent à une logique dernier appelé, premier sorti , c'est-à-dire
que, si une méthode f appelle une méthode g, l'appel à g terminera avant l'appel à f. Cette
propriété permet au compilateur d'organiser les données locales à un appel de fonction
18 Chapitre 1. Le langage Java
(paramètres et variables locales) sur une pile. Illustrons ce principe avec l'exemple d'une
3
méthode récursive calculant la factorielle de n.
static int fact(int n) {
if (n == 0) return 1;
return n * fact(n-1);
}
Si on évalue l'expression fact(4), alors le paramètre formel n de la méthode fact sera
matérialisé quelque part en mémoire et recevra la valeur 4. Puis l'évaluation de fact(4)
va conduire à l'évaluation de fact(3). De nouveau, le paramètre formel n de la méthode
fact doit être matérialisé pour recevoir la valeur 3. On comprend qu'on ne peut pas
réutiliser le même emplacement que pour l'appel fact(4), sinon la valeur 4 sera perdue,
alors même qu'il nous reste à eectuer une multiplication par cette valeur à l'issue de
l'appel à fact(3). On a donc une seconde matérialisation en mémoire du paramètre n, et
ainsi de suite. Lorsqu'on en est au calcul de fact(2), on se retrouve donc dans la situation
suivante :
fact(4) n 4
fact(3) n 3
fact(2) n 2
.
.
.
On visualise bien une structure de pile (qui croît ici vers le bas). Lorsqu'on parvient
nalement à l'appel à fact(0), on atteint enn une instruction return, qui conclut l'appel
à fact(0). n contenant 0 est alors dépilée et on revient à l'appel à fact(1).
La variable
On peut alors eectuer la multiplication 1 * 1 puis c'est l'appel à fact(1) qui est terminé
et la variable n contenant 1 qui est dépilée. Et ainsi de suite jusqu'au résultat nal.
La pile a une capacité limitée. Si elle vient à s'épuiser, l'exception StackOverflowError
est levée. Par défaut, la taille de pile est relativement petite, de l'ordre de 1 Mo, ce qui
correspond à environ 10 000 appels imbriqués (bien entendu, cela dépend de l'occupation
sur la pile de chaque appel de méthode). On peut modier la taille de la pile avec l'option
-Xss de la machine virtuelle Java. Ainsi
1.2.3 Valeurs
Il y a en Java deux grandes catégories de valeurs : les valeurs primitives et les objets.
La distinction est en fait technique, elle tient à la façon dont ces valeurs sont traitées par
la machine, ou plus exactement sont rangées dans la mémoire. Une valeur primitive se
sut à elle-même ; il s'agit d'un entier, d'un caractère, ou encore d'un booléen. La valeur
d'un objet est une adresse , désignant une zone de la mémoire. On parle aussi de pointeur .
Écrivons par exemple
3. La pile d'appels n'est pas liée à la récursivité mais à la notion d'appels imbriqués, mais une fonction
récursive conduit naturellement à des appels imbriqués.
1.2. Modèle d'exécution 19
int x = 1 ;
int[] t = {1, 2, 3} ;
Les variables x et t sont deux cases, qui contiennent chacune une valeur, la première
valeur étant primitive et la seconde un pointeur. Un schéma résume la situation :
x 1 t
1 2 3
int y = x ;
int[] u = t ;
alors on obtient maintenant l'état mémoire suivant :
x 1 y 1 t u
1 2 3
En particulier, les deux variables t et u pointent vers le même tableau. On dit que ce
sont des alias . On peut s'en convaincre en modiant un élément de u et en vériant que
la modication s'observe également dans t. Si par exemple on exécute u[1] = 42; alors
on obtient
x 1 y 1 t u
1 42 3
ce que l'on peut observer facilement, par exemple en achant la valeur de t[1]. Cepen-
dant, t et u ne sont pas liés à jamais. Si on aecte à t un nouveau tableau, par exemple
avec t = new int[] {4, 5};, alors on a la situation suivante
x 1 y 1 t u
4 5 1 42 3
int[] t = {1, 2, 3} ;
int[] u = t ;
int[] v = {1, 2, 3} ;
System.out.println("t==u : " + (t == u) + ", t==v : " + (t == v)) ;
ache t==u : true, t==v : false. Les pointeurs t et u sont égaux parce qu'ils pointent
vers le même objet. Les pointeurs t et v, qui pointent vers des objets distincts, sont
distincts. Cela peut se comprendre si on revient aux états mémoire simpliés.
t u v
1 2 3 1 2 3
On dit parfois que == est l'égalité physique. L'égalité physique donne parfois des résultats
surprenants. Ainsi le programme suivant
String t = "coucou" ;
String u = "coucou" ;
String v = "cou" ;
String w = v + v ;
System.out.println("t==u : " + (t == u) + ", t==w : " + (t == w)) ;
ache t==u : true, t==w : false, ce qui révèle que les chaînes (objets) référencés par
t et u sont exactement les mêmes (le compilateur les a partagées), tandis que w est une
autre chaîne.
t u w
"coucou" "coucou"
emplacements mémoire. Mais il faut bien garder à l'esprit qu'une telle valeur est soit une
valeur primitive, soit un pointeur vers une zone de la mémoire, comme expliqué ci-dessus.
En particulier, dans ce second cas, seul le pointeur est copié. Considérons par exemple la
méthode f suivante
int x = 1;
int[] y = {1, 2, 3};
f(x, y);
Juste avant l'appel à f on a la situation suivante :
x 1 y 1 2 3
Juste après l'appel à f on a deux nouvelles variables a et b (les arguments formels de f),
qui ont reçu respectivement les valeurs de x et y :
x 1 y 1 2 3
a 1 b
En particulier, les deux variables y et b sont des alias pour le même tableau. Les deux
aectations a = 2 et b[2] = 7 conduisent donc à la situation suivante :
x 1 y 1 2 7
a 2 b
x 1 y 1 2 7
Cet exemple utilise un tableau, mais la situation serait la même si y était un objet et si
la méthode f modiait un champ de cet objet : la modication persisterait après l'appel
à f.
Plus subtilement encore, si on remplace l'aectation b[2] = 7 dans f par b = new int[] {4, 5, 6}
on se retrouve dans la situation suivante à la n du code de f :
x 1 y 1 2 3
a 2 b 4 5 6
x 1 y 1 2 3
En particulier, le tableau {4, 5, 6} n'est plus nulle part référencé et sera donc récupéré
par le GC.
Un objet alloué dans une méthode n'est pas systématiquement perdu pour autant. En
eet, il peut être renvoyé comme résultat (ou stocké dans une autre structure de données).
Si on considère par exemple la méthode suivante
a 1 b 1 1 1
et c'est la valeur de b qui est renvoyée, c'est-à-dire le pointeur vers le tableau. Bien que les
variables a et b sont détruites, le tableau survivra (si la valeur renvoyée par g est utilisée
par la suite, bien entendu).
Exercice 1. Expliquer pourquoi le programme suivant ne peut acher autre chose que 1,
quelle que soit la dénition de la méthode f :
int x = 1;
f(x);
System.out.println(x);
Solution
2
Notions de complexité
Elle s'intéresse en fait essentiellement à discuter les problèmes qui sont résolubles
informatiquement, c'est-à-dire à distinguer les problèmes décidables (qui peuvent être
résolus informatiquement) des problèmes indécidables (qui ne peuvent avoir de solution
informatique). La calculabilité est très proche de la logique mathématique et de la théorie
de la preuve : l'existence de problèmes qui n'admettent pas de solution informatique est
très proche de l'existence de théorèmes vrais mais qui ne sont pas démontrables.
Autrement dit, la complexité µ(A,n) est la complexité la pire sur les données de taille
n. Par défaut, lorsqu'on parle de complexité d'algorithme en informatique, il s'agit de
complexité au pire cas, comme ci-dessus.
Si l'on ne sait pas plus sur les données, on ne peut guère faire plus que d'avoir cette
vision pessimiste des choses : cela revient à évaluer la complexité dans le pire des cas (le
meilleur des cas n'a pas souvent un sens profond, et dans ce contexte le pessimisme est
de loin plus signicatif ).
où π(d) désigne la probabilité d'avoir la donnée d parmi toutes les données de taille n.
En pratique, le pire cas est rarement atteint et l'analyse en moyenne peut sembler plus
séduisante. Mais il est important de comprendre que l'on ne peut pas parler de moyenne
sans loi de probabilité (sans distribution) sur les entrées, ce qui est souvent très délicat
à estimer en pratique. Comment anticiper par exemple les matrices qui seront données
à un algorithme de calcul de déterminant ? On fait parfois l'hypothèse que les données
sont équiprobables (lorsque cela a un sens, comme lorsqu'on trie n nombres entre 1 et n
et où l'on peut supposer que les permutations en entrée sont équiprobables), mais cela
est bien souvent totalement arbitraire, et pas réellement justiable. Enn, les calculs de
complexité en moyenne sont plus délicats à mettre en ÷uvre.
(k − 1)i−1
.
ki
26 Chapitre 2. Notions de complexité
Il faut alors procéder à i itérations. Au total, nous avons donc une complexité moyenne
de
n
(k − 1)n X (k − 1)i−1
C= × n + × i.
kn i=1
ki
Or
n
X 1 + xn (nx − n − 1)
∀x, ixi−1 =
i=1
(1 − x)2
1−xn+1
Pn
(il sut pour établir ce résultat de dériver l'identité i=0 xi = 1−x
) et donc
n
(k − 1)n (k − 1)n
n 1
C=n +k 1− (1 + ) = k 1 − 1 − .
kn kn k k
Lorsque k est très grand devant n (on eectue par exemple une recherche dans un tableau
de n = 1000 éléments dont les valeurs sont parmi les k = 232 valeurs possibles de type
int), alors C ∼ n. La complexité moyenne est donc linéaire en la taille du tableau, ce qui
ne nous surprend pas. Lorsqu'en revanche k est petit devant n (on eectue par exemple
une recherche parmi peu de valeurs possibles), alors C ∼ k . La complexité moyenne est
donc linéaire en le nombre de valeurs, ce qui ne nous surprend pas non plus.
Autrement dit, on ne fait plus seulement varier les entrées de taille n, mais aussi l'algo-
rithme. On considère le meilleur algorithme qui résout le problème, le meilleur étant celui
avec la meilleure complexité au sens de la dénition précédente, c'est-à-dire au pire cas.
C'est donc la complexité du meilleur algorithme au pire cas.
L'intérêt de cette dénition est le suivant : si un algorithme A possède la complexité
µ(P,n), i.e. est tel que µ(A,n) = µ(P,n) pour tout n, alors cet algorithme est clairement
optimal. Tout autre algorithme est moins performant, par dénition. Cela permet donc
de prouver qu'un algorithme est optimal.
Il y a une subtilité cachée dans la dénition ci-dessus. Elle considère la complexité
du problème P sur les entrées de taille n. En pratique, cependant, on écrit rarement
un algorithme pour une taille d'entrées xée, mais plutôt un algorithme qui fonctionne
quelle que soit la taille des entrées. Plus subtilement encore, cet algorithme peut ou non
procéder diéremment selon la valeur de n. Une dénition rigoureuse de la complexité
d'un problème se doit de faire cette distinction ; on parle alors de complexité uniforme et
non-uniforme. Mais ceci dépasse largement le cadre de ce cours.
2.2. Complexités asymptotiques 27
Exemple. Reprenons l'exemple de la méthode max donné plus haut. On peut se poser
la question de savoir s'il est possible de faire moins de n−1 comparaisons. La réponse
est non ; dit autrement, cet algorithme est optimal en nombre de comparaisons. En ef-
fet, considérons la classe C des algorithmes qui résolvent le problème de la recherche du
maximum de n éléments en utilisant comme critère de décision les comparaisons entre
éléments. Commençons par énoncer la propriété suivante : tout algorithme A de C est tel
que tout élément autre que le maximum est comparé au moins une fois avec un élément
qui lui est plus grand. En eet, soit i0 le rang du maximum M renvoyé par l'algorithme
sur un tableau T = e1 ,e2 , . . . ,en , c'est-à-dire ei0 = M = max1≤i≤n ei . Raisonnons par
l'absurde : soit j0 6= i0 tel que ej0 ne soit pas comparé avec un élément plus grand que lui.
L'élément ej0 n'a donc pas été comparé avec ei0 le maximum. Considérons alors le tableau
T 0 = e1 ,e2 , . . . ,ej0 −1 ,M + 1,ej0 +1 , . . . ,en obtenu à partir de T en remplaçant l'élément d'in-
dice j0 par M + 1. L'algorithme A eectuera exactement les mêmes comparaisons sur T
0 0 0 0
et T , sans comparer T [j0 ] avec T [i0 ] et renverra donc T [i0 ], ce qui est incorrect. D'où
une contradiction, qui prouve la propriété.
Complexité n n log2 n n2 n3 ( 32 )n 2n n!
n = 10 <1 s <1s <1s <1s <1s <1s 4s
n = 30 <1 s <1s <1s <1s <1s 18 min 1025 ans
n = 50 <1 s <1s <1s <1s 11 min 36 ans ∞
n = 100 <1 s <1s <1s 1s 12,9 ans 1017 ans ∞
n = 1000 <1 s <1s 1s 18 min ∞ ∞ ∞
n = 10000 <1 s <1s 2 min 12 jours ∞ ∞ ∞
n = 100000 < 1 s 2s 3 heures 32 ans ∞ ∞ ∞
n = 1000000 1s 20s 12 jours 31 710 ans ∞ ∞ ∞
28 Chapitre 2. Notions de complexité
2.2.2 Conventions
Ce type d'expérience invite à considérer qu'une complexité en ( 32 )n , 2n ou n! ne peut
pas être considérée comme raisonnable. On peut discuter de savoir si une complexité en
n158 est en pratique raisonnable, mais depuis les années 1960 environ, la convention en
informatique est que oui : toute complexité bornée par un polynôme en n est considérée
comme raisonnable. Si on préfère, cela revient à dire qu'une complexité est raisonnable
d
dès qu'il existe des constantes c, d, et n0 telles que la complexité est bornée par cn , pour
n > n0 . Des complexités non raisonnables sont par exemple nlog n , ( 23 )n , 2n et n!.
Cela ore beaucoup d'avantages : on peut raisonner à un temps (ou à un espace
mémoire, ou à un nombre de comparaisons) polynomial près. Cela évite par exemple de
préciser de façon trop ne le codage, par exemple comment sont codées les matrices pour
un algorithme de calcul de déterminant : passer du codage d'une matrice par des listes à
un codage par tableau se fait en temps polynomial et réciproquement.
D'autre part, on raisonne souvent à une constante multiplicative près. On considère que
deux complexités qui ne dièrent que par une constante multiplicative sont équivalentes :
3 3
par exemple 9n et 67n sont considérés comme équivalents. Ces conventions expliquent
que l'on parle souvent de complexité en temps de l'algorithme sans préciser nement la
mesure de ressource élémentaire µ. Dans ce polycopié, par exemple, on ne cherchera pas
à préciser le temps de chaque instruction élémentaire Java. Dit autrement, on suppose
dans ce polycopié qu'une opération arithmétique ou une aectation entre deux variables
Java se fait en temps constant (unitaire) : cela s'appelle le modèle RAM.
f (n) = O(g(n))
∀n ≥ n0 ,f (n) ≤ Bg(n).
Ceci signie que f ne croît pas plus vite que g. En particulier O(1) signie constant(e).
Par exemple, un algorithme qui fonctionne en temps O(1) est un algorithme dont le
temps d'exécution est constant et ne dépend pas de la taille des données. C'est donc un
ensemble constant d'opérations élémentaires (exemple : l'addition de deux entiers avec les
conventions données plus haut).
On dit d'un algorithme qu'il est linéaire s'il utilise O(n) opérations élémentaires. Il est
polynomial s'il existe une constante a telle que le nombre total d'opérations élémentaires
a
est O(n ) : c'est la notion de raisonnable introduite plus haut.
2.3.1 Factorielle
Prenons l'exemple archi-classique de la fonction factorielle, et intéressons-nous au
nombre d'opérations arithmétiques (comparaisons, additions, soustractions, multiplica-
tions) nécessaires à son calcul. Considérons un premier programme calculant n! à l'aide
d'une boucle.
Exercice 2. Quelle est la complexité de la méthode suivante qui calcule le n-ième élément
de la suite de Fibonacci ?
3 7 42 1 4 8 12
La particularité d'une structure de tableau est que le contenu de la i-ième case peut être
lu ou modié en temps constant.
En Java, on peut construire un tel tableau en énumérant ses éléments entre accolades,
séparés par des virgules :
Les cases sont numérotées à partir de 0. On accède à la première case avec la notation
a[0], à la seconde avec a[1], etc. Si on tente d'accéder en dehors des cases valides du
tableau, par exemple en écrivant a[-1], alors on obtient une erreur :
a[1] = 0;
Si l'indice ne désigne pas une position valide dans le tableau, l'aectation provoque la
même exception que pour l'accès.
On peut obtenir la longueur du tableau a avec l'expression a.length. Ainsi a.length
vaut 7 sur l'exemple précédent. L'accès à la longueur se fait en temps constant. Un tableau
peut avoir la longueur 0.
Il existe d'autres procédés pour construire un tableau que d'énumérer explicitement ses
éléments. On peut notamment construire un tableau de taille donnée avec la construction
new :
Ses éléments sont alors initialisés avec une valeur par défaut (ici 0 car il s'agit d'entiers).
34 Chapitre 3. Tableaux
int s = 0;
for (int x : a)
s += x;
return s;
Ce programme eectue exactement a.length additions, soit une complexité linéaire.
Comme exemple plus complexe, considérons l'évaluation d'un polynôme
X
A(X) = ai X i
0≤i<n
On suppose que les coecients du polynôme A sont stockés dans un tableau a, le coecient
ai étant stocké dans a[i]. Ainsi le tableau [1, 2, 3] représente le polynôme 3X 2 +2X+1.
Une méthode simple, mais naïve, consiste à écrire une boucle qui réalise exactement la
somme ci-dessus.
tableau de la droite vers la gauche, pour que le traitement de la i-ième case de a consiste
à multiplier par X la somme courante puis à lui ajouter a[i]. s contient la
Si la variable
somme courante, la situation est donc la suivante :
Exercice 3. Écrire une méthode qui prend un tableau d'entiers a en argument et renvoie
le tableau des sommes cumulées croissantes de a, autrement dit un tableau de même taille
Exercice 4. Écrire une méthode qui renvoie un tableau contenant les n premières valeurs
de la suite de Fibonacci dénie par
F0 = 0
F1 = 1
Fn = Fn−2 + Fn−1 pour n ≥ 2.
pour i de 1 à n−1
soit j un entier aléatoire entre 0 et i (inclus)
échanger les éléments d'indices i et j
On obtient un entier aléatoire entre 0 et k−1 avec (int)(Math.random() * k ). Cet
algorithme sera réutilisé plus loin dans l'exercice 60 et dans la section 13.2 concernant le
tri rapide. Solution
Dans la section 2.1.4, nous avons montré que la complexité en moyenne de cet algorithme
est linéaire.
Exercice 6. Écrire une variante de la méthode contains qui renvoie l'indice où la valeur v
apparaît dans a, le cas échéant. Que faire lorsque a ne contient pas v? Solution
Exercice 7. Écrire une méthode qui renvoie l'élément maximal d'un tableau d'entiers.
On discutera des diverses solutions pour traiter le cas d'un tableau de longueur 0.
Solution
On note que seulement trois comparaisons ont été nécessaires pour trouver la valeur. C'est
une application du principe diviser pour régner .
Pour écrire l'algorithme, on délimite la portion du tableau a dans laquelle la valeur v
doit être recherchée à l'aide de deux indices lo et hi. On maintient l'invariant suivant :
les valeurs strictement à gauche de lo sont inférieures à v et les valeurs à partir de hi
sont supérieures à v, ce qui s'illustre ainsi
0 lo hi n
<v ? >v
if (a[m] == v)
return true;
Sinon, on détermine si la recherche doit être poursuivie à gauche ou à droite. Si a[m] < v,
on poursuit à droite :
if (a[m] < v)
lo = m + 1;
Sinon, on poursuit à gauche :
else
hi = m;
}
Si on sort de la boucle while, c'est que l'élément ne se trouve pas dans le tableau, car
il ne reste que des éléments strictement plus petits (à gauche de lo) ou strictement plus
grands (à partir de hi). On renvoie alors false pour signaler l'échec.
return false;
Le code complet est donné programme 1 page 38. Note : La bibliothèque standard de
Java fournit une telle méthode binarySearch dans la classe java.util.Arrays.
Montrons maintenant que la complexité de cet algorithme est au pire O(log n) où n
est la longueur du tableau. En particulier, on eectue au pire un nombre logarithmique
de comparaisons. La démonstration consiste à établir qu'après k itérations de la boucle,
on a l'inégalité
n
hi − lo ≤ ·
2k
La démonstration se fait par récurrence sur k. Initialement, on a lo = 0
hi = n et et
k = 0, donc l'inégalité est établie. Supposons maintenant l'inégalité vraie au rang k et
lo < hi. À la n de la k + 1-ième itération, on a soit lo = m+1, soit hi = m. Dans le
premier cas, on a donc
lo + hi hi − lo n n
hi − (m + 1) ≤ hi − = ≤ k = k+1 ·
2 2 2 ×2 2
38 Chapitre 3. Tableaux
Le second cas est laissé au lecteur. On conclut ainsi : pour k > log2 (n), on a hi − lo < 1,
c'est-à-dire hi ≤ lo, et on sort donc de la boucle.
La complexité de la recherche dichotomique est donc O(log n), alors que celle de la
recherche par balayage est O(n). Il faut cependant ne pas oublier qu'elles ne s'appliquent
pas dans les mêmes conditions : une recherche dichotomique exige que les données soient
triées.
Exercice 9. Expliquer pourquoi ce n'est pas une bonne idée de calculer la valeur de m
tout simplement comme (lo + hi) / 2. Solution
a
0 1 2 3
b
3.4. Tableaux redimensionnables 39
a
0 1 42 3
b
En particulier, après l'appel à la méthode f, on a a[2] == 42. (Nous avions déjà expliqué
cela section 1.2.3 mais il n'est pas inutile de le redire.)
Il est parfois utile d'écrire des méthodes qui modient le contenu d'un tableau reçu en
argument. Un exemple typique est celui d'une méthode qui échange le contenu de deux
cases d'un tableau :
Un autre exemple est celui d'une méthode qui trie un tableau ; plusieurs exemples seront
donnés dans le chapitre 13.
ResizableArray(int len)
pour construire un nouveau tableau redimensionnable de taille len, et (au minimum) les
méthodes suivantes :
int size()
void setSize(int len)
int get(int i)
void set(int i, int v)
La méthode size renvoie la taille du tableau. À la diérence d'un tableau usuel, cette
taille peut être modiée a posteriori avec la méthode setSize. Les méthodes get et set
sont les opérations de lecture et d'écriture dans le tableau. Comme pour un tableau usuel,
elles lèveront une exception si on cherche à accéder en dehors des bornes du tableau.
40 Chapitre 3. Tableaux
3.4.1 Principe
L'idée de la réalisation est très simple : on utilise un tableau usuel pour stocker les
éléments et lorsqu'il devient trop petit, on en alloue un plus grand dans lequel on recopie
les éléments du premier. Pour éviter de passer notre temps en allocations et en copies,
on s'autorise à ce que le tableau de stockage soit trop grand, les éléments au-delà d'un
certain indice n'étant plus signicatifs. La classe ResizableArray est donc ainsi déclarée
class ResizableArray {
private int length;
private int[] data;
où le champ data est le tableau de stockage des éléments et length le nombre signicatif
d'éléments dans ce tableau, ce que l'on peut schématiser ainsi :
−this.length →
← −
this.data . . . éléments . . . . . . inutilisé . . .
←−−−−−this.data.length−−−−−→
On maintiendra donc toujours l'invariant suivant :
0 ≤ length ≤ data.length
On note le caractère privé des champs data et length, ce qui nous permettra de maintenir
l'invariant ci-dessus.
Pour créer un nouveau tableau redimensionnable de taille len, il sut d'allouer un
tableau usuel de cette taille-là dans data, sans oublier d'initialiser le champ length éga-
lement :
ResizableArray(int len) {
this.length = len;
this.data = new int[len];
}
La taille du tableau redimensionnable est directement donnée par le champ length.
int size() {
return this.length;
}
Pour accéder au i-ième élément du tableau redimensionnable, il convient de vérier la
validité de l'accès, car le tableau this.data peut contenir plus de this.length éléments.
int get(int i) {
if (i < 0 || i >= this.length)
throw new ArrayIndexOutOfBoundsException(i);
return this.data[i];
}
L'aectation est analogue (voir page 42). Toute la subtilité est dans la méthode setSize
qui redimensionne un tableau pour lui donner une nouvelle taille len. Plusieurs cas de
gure se présentent. Si la nouvelle taille len est inférieure ou égale à la taille de this.data,
il n'y a rien à faire, si ce n'est mettre le champ length à jour. Si en revanche len est plus
grand la taille de this.data, il va falloir remplacer data par un tableau plus grand. On
commence donc par eectuer ce test :
3.4. Tableaux redimensionnables 41
this.data = a;
}
L'ancien tableau sera ramassé par le GC. Enn, on met à jour la valeur de this.length,
quel que soit le résultat du test len > n
this.length = len;
}
ce qui conclut le code de setSize. L'intégralité du code est donnée programme 2 page 42.
Exercice 10. Il peut être souhaitable de rediminuer parfois la taille du tableau, par
exemple si elle devient grande par rapport au nombre d'éléments eectifs et que le tableau
occupe beaucoup de mémoire. Modier la méthode setSize pour qu'elle divise par deux
la taille du tableau lorsque le nombre d'éléments devient inférieur au quart de la taille du
tableau. Solution
Exercice 11. Ajouter une méthode int[] toArray() qui renvoie un tableau usuel conte-
nant les éléments du tableau redimensionnable.
Solution
class ResizableArray {
ResizableArray(int len) {
this.length = len;
this.data = new int[len];
}
int size() {
return this.length;
}
int get(int i) {
if (i < 0 || i >= this.length)
throw new ArrayIndexOutOfBoundsException(i);
return this.data[i];
}
while (true) {
String s = f.readLine();
La valeur null signale la n du chier, auquel cas on sort de la boucle avec break :
if (s == null) break;
Dans le cas contraire, on étend la taille du tableau redimensionnable d'une unité, et on
stocke l'entier lu dans la dernière case du tableau :
Complexité. Dans le programme ci-dessus, nous avons démarré avec un tableau redi-
mensionnable de taille 0 et nous avons augmenté sa taille d'une unité à chaque lecture
d'une nouvelle ligne. Si la méthode setSize avait eectué systématiquement une allo-
cation d'un nouveau tableau et une recopie des éléments dans ce tableau, la complexité
aurait été quadratique, puisque la lecture de la i-ième ligne du chier aurait alors eu un
coût i, d'où un coût total
n(n + 1)
1 + 2 + ··· + n = = O(n2 )
2
pour un chier de n lignes. Cependant, la stratégie de setSize est plus subtile, car
elle consiste à doubler (au minimum) la taille du tableau lorsqu'il doit être agrandi.
Montrons que le coût total est alors linéaire. Supposons, sans perte de généralité, que
n ≥ 2 et posons k = blog2 (n)c c'est-à-dire 2k ≤ n < 2k+1 . Au total, on aura eectué
k + 2 redimensionnements pour arriver à un tableau data de taille nale 2k+1 . Après le
i-ième redimensionnement, pour i = 0, . . . ,k + 1, le tableau a une taille 2i et le i-ième
i
redimensionnement a donc coûté 2 . Le coût total est alors
k+1
X
2i = 2k+2 − 1 = O(n).
i=0
Autrement dit, certaines opérations setSize ont un coût constant (lorsque le redimen-
sionnement n'est pas nécessaire) et d'autres au contraire un coût non constant, mais
44 Chapitre 3. Tableaux
la complexité totale reste linéaire. Ramené à l'ensemble des n opérations, tout se passe
comme si chaque opération d'ajout d'un élément avait eu un coût constant. On parle
de complexité amortie pour désigner la complexité d'une opération en moyenne sur un
ensemble de n opérations. Dans le cas présent, on peut donc dire que l'extension d'un
tableau redimensionnable d'une unité a une complexité amortie O(1).
String s = "0";
for (int i = 1; i <= n; i++)
s += ", " + i;
alors la complexité est quadratique, car chaque concaténation de chaînes, pour construire
s + ", " + i, a un coût proportionnel à la longueur de s, c'est-à-dire pro-
le résultat de
i. Là encore, on peut avantageusement exploiter le principe du tableau redi-
portionnel à
mensionnable pour construire la chaîne s. Il sut d'adapter le code de ResizableArray
pour des caractères plutôt que des entiers (ou encore en faire une classe générique voir
section 3.4.5 plus loin).
Plus simplement encore, la bibliothèque Java fournit une classe StringBuffer pour
construire des chaînes de caractères incrémentalement sur le principe du tableau re-
dimensionnable. Une méthode append permet de concaténer une chaîne à la n d'un
StringBuffer, d'où le code
Exercice 13. Ajouter une méthode String toString() à la classe ResizableArray, qui
renvoie le contenu d'un tableau redimensionnable sous la forme d'une chaîne de caractères
telle que "[3, 7, 2]". On se servira d'un StringBuffer pour construire cette chaîne.
Solution
↓↑
C
B
A
3.4. Tableaux redimensionnables 45
où C est empilé sur B , lui-même empilé sur A. On peut soit retirer C de la pile (on dit
qu'on dépile C ), soit ajouter un quatrième élément D (on dit qu'on empile D). Si
on veut accéder à l'élément A, il faut commencer par dépiler C puis B . L'image associée
à une pile est donc dernier arrivé, premier sorti (en anglais, on parle de LIFO pour
last in, rst out ).
Nous allons écrire la structure de pile dans une classe Stack, en fournissant les opéra-
tions suivantes :
le constructeur Stack() renvoie une nouvelle pile, initialement vide ;
la méthode pop() dépile et renvoie le sommet de la pile ;
la méthode push(v) empile la valeur v.
la méthode size() renvoie le nombre d'éléments contenus dans la pile ;
la méthode isEmpty() indique si la pile est vide ;
la méthode top() renvoie le sommet de la pile, sans la modier.
Seules les opérations push et pop modient le contenu de la pile. Voici une illustration de
l'utilisation de cette structure :
C
s.push(B);
B
s.push(C);
A
int x = s.pop(); B
// x vaut C A
class Stack {
private ResizableArray elts;
...
}
Ainsi, seules les méthodes fournies pourront en modier le contenu. Le code complet est
donné programme 3 page 46.
Exercice 14. Écrire une méthode swap qui échange les deux éléments au sommet d'une
pile, d'abord à l'extérieur de la classe Stack, puis comme une nouvelle méthode de la
classe Stack. Solution
46 Chapitre 3. Tableaux
class Stack {
Stack() {
this.elts = new ResizableArray(0);
}
boolean isEmpty() {
return this.elts.size() == 0;
}
int size() {
return this.elts.size();
}
void push(int x) {
int n = this.elts.size();
this.elts.setSize(n + 1);
this.elts.set(n, x);
}
int pop() {
int n = this.elts.size();
if (n == 0)
throw new NoSuchElementException();
int e = this.elts.get(n - 1);
this.elts.setSize(n - 1);
return e;
}
int top() {
int n = this.elts.size();
if (n == 0)
throw new NoSuchElementException();
return this.elts.get(n - 1);
}
}
3.4. Tableaux redimensionnables 47
class ResizableArray<T> {
private int length;
private T[] data;
Le code reste essentiellement le même, au remplacement du type int par le type T aux
endroits opportuns. Il y a cependant deux subtilités. La première concerne la création
d'un tableau de type T[]. Naturellement, on aimerait écrire dans le constructeur
Dans la version non générique, nous n'avions pas ce problème, car les éléments étaient
des entiers, et non des pointeurs.
48 Chapitre 3. Tableaux
class Singly {
int element;
Singly next;
}
La valeur null nous sert à représenter la liste vide i.e. la liste ne contenant aucun élément.
Le constructeur naturel de cette classe prend en arguments les valeurs des deux champs :
Le bloc contenant la valeur 3 a été construit en premier, puis celui contenant la valeur 2,
puis enn celui contenant la valeur 1. Ce dernier est appelé la tête de la liste. Dans le cas
très particulier où x est la liste vide, on écrit tout simplement
Singly x = null;
ce qui correspond à une situation où aucun objet n'a été alloué en mémoire :
x⊥
Bien que de type Singly, une telle valeur n'en est pas pour autant un objet de la classe
Singly, avec un champ element et un champ next. Par conséquent, il faudra pendre soin
dans la suite de toujours bien traiter le cas de la liste vide, de manière à éviter l'exception
NullPointerException. Dès la section suivante, nous verrons comment apporter une
solution élégante à ce problème.
while (x != null) {
...
x = x.next;
}
Comme premier exemple, considérons une méthode statique contains qui détermine si
un entier x apparaît dans une liste s donnée. On parcourt la liste s pour comparer suc-
cessivement la valeur x avec tous les éléments de la liste.
s = s.next;
}
Il est important de bien comprendre que cette aectation ne modie pas la liste mais
seulement la variable s, qui est locale à la méthode contains. Si on nit par sortir de la
boucle, c'est que x n'apparaît pas dans la liste et on renvoie alors false :
return false;
}
Il est important de noter que ce code fonctionne correctement sur une liste vide, c'est-à-
dire lorsque s vaut null. En eet, on sort immédiatement de la boucle et on renvoie false,
ce qui est le résultat attendu. Le code de la méthode contains est donné programme 4
page 51.
4.1. Listes simplement chaînées 51
class Singly {
int element;
Singly next;
Exercice 15. Écrire une méthode statique int length(Singly s) qui renvoie la lon-
gueur de la liste s.
Solution
Exercice 16. Écrire une méthode statique int get(Singly s, int i) qui renvoie l'élé-
ment d'indice i de la liste s, l'élément de tête étant considéré d'indice 0. Lever une ex-
ception si i ne désigne pas un indice valide (par exemple IllegalArgumentException).
Solution
Achage
À titre de deuxième exemple, écrivons une méthode statique listToString qui conver-
tit une liste chaînée en une chaîne de caractères de la forme "[1 -> 2 -> 3]". Comme
nous l'avons expliqué plus haut (section 3.4.3), la façon ecace de construire une telle
52 Chapitre 4. Listes chaînées
chaîne est d'utiliser un StringBuffer. On commence donc par allouer un tel objet, avec
une chaîne réduite à "[" :
static String listToString(Singly s) {
StringBuffer sb = new StringBuffer("[");
Puis on réalise le parcours de la liste, ainsi qu'expliqué ci-dessus. Pour chaque élément, on
ajoute sa valeur, c'est-à-dire l'entier s.element, au StringBuffer, puis la chaîne " -> "
s.next n'est pas null.
s'il ne s'agit pas du dernier élément de la liste, c'est-à-dire si
while (s != null) {
sb.append(s.element);
if (s.next != null) sb.append(" -> ");
s = s.next;
}
Une fois sorti de la boucle, on ajoute le crochet fermant et on renvoie la chaîne contenue
dans le StringBuffer.
return sb.append("]").toString();
}
Là encore, ce code fonctionne correctement sur une liste vide, renvoyant la chaîne "[]".
Exercice 17. Quelle est la complexité de la méthode listToString ? (Il faut éventuel-
lement relire ce qui avait été expliqué dans la section 3.4.3.)
Solution
Puis on réalise le parcours de la liste. On remplace candidate par l'élément courant avec
probabilité 1/index.
while (s != null) {
if ((int)(index * Math.random()) == 0) candidate = s.element;
Pour cela, on tire un entier aléatoirement entre 0 inclus et index exclus et on le compare
Math.random(), qui renvoie un ottant entre 0 inclus et 1 exclus,
à 0. Pour cela on utilise
et on multiplie le résultat par index, ce qui donne un ottant entre 0 inclus et index
exclus. Sa conversion en entier, avec (int)(...), en fait bien un entier entre 0 inclus et
index exclus. On passe ensuite à l'élément suivant, sans oublier d'incrémenter index.
index++;
s = s.next;
}
return candidate;
}
On note qu'au tout premier tour de boucle qui existe car la liste est non vide
l'élément de la liste est nécessairement sélectionné car Math.random() < 1 et donc
(int)(1 * Math.random()) = 0. La valeur arbitraire que nous avions utilisée pour ini-
tialiser la variable candidate ne sera donc jamais renvoyée. On en déduit également que
le programme fonctionne correctement sur une liste réduite à un élément. Le code complet
est donné programme 5 page 53.
Exercice 18. Montrer que, si la liste contient n éléments avec n ≥ 1, chaque élément est
1
choisi avec probabilité . Solution
n
54 Chapitre 4. Listes chaînées
class Stack {
private Singly head;
...
}
Le code complet de la structure de pile est donné programme 6 page 55. Si on construit
une pile avec s = new Stack(), dans laquelle on ajoute successivement les entiers 1, 2 et
3, dans cet ordre, avec s.push(1); s.push(2); s.push(3), alors on se retrouve dans la
situation suivante :
Stack
head
Exercice 19. Ajouter un champ privé int size à la classe Stack, contenant le nombre
d'éléments de la pile, et une méthode int size() pour renvoyer sa valeur. Expliquer
pourquoi le champ size doit être privé. Solution
Exercice 20. Écrire une méthode dynamique publique String toString() pour la classe
Stack, qui renvoie le contenu d'une pile sous la forme d'une chaîne de caractères telle
que "[1, 2, 3]" où 1 est le sommet de la pile. On pourra s'inspirer de la méthode
listToString donnée plus haut. Solution
class Stack {
Stack() {
this.head = null;
}
boolean isEmpty() {
return this.head == null;
}
void push(int x) {
this.head = new Singly(x, this.head);
}
int top() {
if (this.head == null)
throw new NoSuchElementException();
return this.head.element;
}
int pop() {
if (this.head == null)
throw new NoSuchElementException();
int e = this.head.element;
this.head = this.head.next;
return e;
}
}
56 Chapitre 4. Listes chaînées
pop au niveau du premier élément de la liste. Il faut donc conserver un pointeur sur le
dernier élément de la liste. Tout comme dans la section précédente, on va encapsuler la
liste chaînée dans une classe Queue. Cette fois, il y a deux champs privés, head et tail,
pointant respectivement sur le premier et le dernier élément de la liste.
class Queue {
private Singly head, tail;
...
}
Ainsi, si on construit une le avec q = new Queue(), dans laquelle on ajoute successive-
ment les entiers 1, 2 et 3, dans cet ordre, avec q.push(1); q.push(2); q.push(3), alors
on se retrouve dans la situation suivante
Queue
head
tail
où les insertions se font à droite et les retraits à gauche. Les éléments apparaissent donc
chaînés dans le mauvais sens . Le code est plus subtil que pour une pile et mérite qu'on
s'y attarde. Le constructeur se contente d'initialiser les deux champs à null :
Queue() {
this.head = this.tail = null;
}
De manière générale, nous allons maintenir l'invariant que this.head vaut null si et
seulement this.tail vaut null. En particulier, la le est vide si et seulement this.head
vaut null :
boolean isEmpty() {
return this.head == null;
}
void push(int x) {
Singly e = new Singly(x, null);
Il faut alors distinguer deux cas, selon que la le est vide ou non. Si elle est vide, alors
this.head et this.tail pointent désormais tous les deux sur cet unique élément de liste :
if (this.head == null)
this.head = this.tail = e;
4.4. Listes cycliques 57
Dans le cas contraire, on ajoute e à la n de la liste existante, dont le dernier élément est
pointé par this.tail, sans oublier de mettre ensuite à jour le pointeur this.tail :
else {
this.tail.next = e;
this.tail = e;
}
Ceci conclut le code de push. Pour le retrait d'un élément, on procède à l'autre extrémité
de la liste, c'est-à-dire du côté de this.head. On commence par évacuer le cas d'une liste
vide :
int pop() {
if (this.head == null)
throw new NoSuchElementException();
Si en revanche la liste n'est pas vide, on peut accéder à son premier élément, qui nous
donne la valeur à renvoyer :
int e = this.head.element;
Avant de la renvoyer, il faut supprimer le premier élément de la liste, ce qui est aussi
simple que
this.head = this.head.next;
Cependant, pour maintenir notre invariant sur this.head et this.tail, on va mettre
this.tail à null si this.head est devenu null :
if (this.head == null) this.tail = null;
Cette ligne de code n'est pas nécessaire pour la correction de notre structure de le.
En eet, notre méthode push teste la valeur de this.head et non celle de this.tail.
Cependant, mettre this.tail à null permet au GC de Java de récupérer la cellule de
liste devenue maintenant inutile. Enn, il n'y a plus qu'à renvoyer la valeur e :
return e;
Le code complet de la structure de le est donné programme 7 page 58.
Exercice 21. Ajouter un champ privé int size à la classe Queue, contenant le nombre
d'éléments de la pile, et une méthode int size() pour renvoyer sa valeur. Expliquer
pourquoi le champ size doit être privé. Solution
class Queue {
Queue() {
this.head = this.tail = null;
}
boolean isEmpty() {
return this.head == null;
}
void push(int x) {
Singly e = new Singly(x, null);
if (this.head == null)
this.head = this.tail = e;
else {
this.tail.next = e;
this.tail = e;
}
}
int top() {
if (this.head == null)
throw new NoSuchElementException();
return this.head.element;
}
int pop() {
if (this.head == null)
throw new NoSuchElementException();
int e = this.head.element;
this.head = this.head.next;
if (this.head == null) this.tail = null;
return e;
}
}
4.4. Listes cycliques 59
0 1 2 3 4 ⊥ (4.1)
Si on modie le champ next de son dernier élément s4 pour qu'il pointe désormais sur
l'élément s2, c'est-à-dire
s4.next = s2;
0 1 2 3 4
(4.2)
Cette liste ne contient plus aucun pointeur null, mais seulement des pointeurs vers
d'autres éléments de la liste. D'une manière générale, on peut montrer que toute liste
simplement chaînée est soit de la forme (4.1), c'est-à-dire une liste linéaire se terminant
par null, soit de la forme (4.2), c'est-à-dire une poêle à frire avec un manche de
longueur nie µ≥0 et une boucle de longueur nie λ≥1 (dans l'exemple ci-dessus on a
µ=2 et λ = 3).
Il est important de comprendre que les programmes que nous avons écrits plus haut, qui
sont construits autour d'un parcours de liste, ne fonctionnent plus sur une liste cyclique,
car ils ne terminent plus dans certains cas. En eet, le critère d'arrêt s == null ne sera
jamais vérié. Si on voulait les adapter pour qu'ils fonctionnent également sur des listes
cycliques, il faudrait être à même de détecter la présence d'un cycle. Si on y rééchit un
instant, on comprend que le problème n'est pas trivial.
On présente ici un algorithme de détection de cycle, dû à Floyd, et connu sous le nom
d'algorithme du lièvre et de la tortue. Comme son nom le suggère, il consiste à parcourir
la liste à deux vitesses diérentes : la tortue parcourt la liste à la vitesse 1 et le lièvre
parcourt la même liste à la vitesse 2. Si à un quelconque moment, le lièvre atteint la n
de la liste, elle est déclarée sans cycle. Et si à un quelconque moment, le lièvre et la tortue
se retrouvent à la même position, c'est que la liste contient un cycle. Le code est donné
programme 8 page 60. La seule diculté dans ce code consiste à correctement traiter les
diérents cas où le lièvre (la variable hare) peut atteindre la n de la liste, an d'éviter
un NullPointerException dans le calcul de hare.next.
Toute la subtilité de cet algorithme réside dans la preuve de sa terminaison. Si la liste
est non cyclique, alors il est clair que le lièvre nira par atteindre la valeur null et que
la méthode renverra alors false. Dans le cas où la liste est cyclique, la preuve est plus
délicate. Tant que la tortue est à l'extérieur du cycle, elle s'en approche à chaque étape
60 Chapitre 4. Listes chaînées
de l'algorithme, ce qui assure la terminaison de cette première phase (en au plus µ étapes
avec la notation ci-dessus). Et une fois la tortue présente dans le cycle, on note qu'elle ne
peut être dépassée par le lièvre. Ainsi la distance qui les sépare diminue à chaque étape
de l'algorithme, ce qui assure la terminaison de cette seconde phase (en au plus λ étapes).
Incidemment, on a montré que la complexité de cet algorithme est toujours au plus n où
n est le nombre d'éléments de la liste. Cet algorithme est donc étonnamment ecace. Et
il n'utilise que deux variables, soit un espace (supplémentaire) constant.
En pratique, cet algorithme est rarement utilisé pour adapter un parcours de liste au
cas d'une liste cyclique. Son application se situe plutôt dans le contexte d'une liste
virtuelle dénie par un élément de départ x0 et une fonction f telle que f (x) est
l'élément qui suit x dans la liste. Un générateur de nombres aléatoires est un exemple
de telle fonction. L'algorithme de Floyd permet alors de calculer à partir de quel rang ce
générateur entre dans un cycle et la longueur de ce cycle.
class Doubly {
int element;
Doubly next, prev;
...
où next est le pointeur vers l'élément suivant, comme pour les listes simplement chaînées,
et prev le pointeur vers l'élément précédent. Une liste réduite à un unique élément peut
être allouée avec le constructeur suivant :
4.5. Listes doublement chaînées 61
Doubly(int element) {
this.element = element;
this.next = this.prev = null;
}
Bien entendu, on pourrait aussi écrire un constructeur naturel qui prend en arguments les
valeurs des trois champs. On ne le fait pas ici, et on choisit plutôt un style de construction
de listes où de nouveaux éléments seront insérés avant ou après des éléments existants.
Écrivons ainsi une méthode dynamique insertAfter(int v) qui ajoute un nouvel élé-
ment de valeur v juste après l'élément désigné par this. On commence par construire le
nouvel élément e, avec le constructeur ci-dessus.
void insertAfter(int v) {
Doubly e = new Doubly(v);
Il faut maintenant mettre à jour les diérents pointeurs next et prev pour lier ensemble
this et e. De manière évidente, on indique que l'élément qui précède e est this.
e.prev = this;
Inversement, on souhaite indiquer que l'élément qui suit this est e. Cependant, il y avait
peut-être un élément après this, c'est-à-dire désigné par this.next, et il convient alors
de commencer par mettre à jour les pointeurs entre e et this.next.
if (this.next != null) {
e.next = this.next;
e.next.prev = e;
}
Enn, on peut mettre à jour this.next, ce qui achève le code le la méthode insertAfter.
this.next = e;
}
Exercice 22. Écrire de même une méthode insertBefore(v) qui insère un nouvel élé-
ment de valeur v juste avant this.
Solution
Suppression d'un élément. Une propriété remarquable des listes doublement chaînées
est qu'il est possible de supprimer un élément e de la liste sans connaître rien d'autre que
sa propre valeur (son pointeur). En eet, ses deux champs prev et next nous donnent
l'élément précédent et l'élément suivant et il sut de les lier entre eux pour que e soit
eectivement retiré de la liste. Écrivons une méthode dynamique remove() qui supprime
l'élément this de la liste dont il fait partie. Elle est aussi simple que
void remove() {
if (this.prev != null)
this.prev.next = this.next;
if (this.next != null)
this.next.prev = this.prev;
}
Le code complet des listes doublement chaînées est donné programme 9 page 63.
class Doubly {
int element;
Doubly next, prev;
Doubly(int element) {
this.element = element;
this.next = this.prev = null;
}
void insertAfter(int v) {
Doubly e = new Doubly(v);
e.prev = this;
if (this.next != null) {
e.next = this.next;
e.next.prev = e;
}
this.next = e;
}
void remove() {
if (this.prev != null)
this.prev.next = this.next;
if (this.next != null)
this.next.prev = this.prev;
}
}
64 Chapitre 4. Listes chaînées
placés en cercle. Ils choisissent un entier p et procèdent alors à une élection de la manière
suivante. Partant du joueur 1, ils comptent jusqu'à p p-ième joueur, qui
et éliminent le
sort du cercle. Puis, partant du joueur suivant, ils éliminent de nouveau le p-ième joueur,
et ainsi de suite jusqu'à ce qu'il ne reste plus qu'un joueur. Si n désigne le nombre de
joueurs au départ, on note J(n,p) le numéro du joueur ainsi élu. Avec n = 7 et p = 5 on
élimine successivement les joueurs 5, 3, 2, 4, 7, 1 et le gagnant est donc le joueur 6 i.e.
J(7,5) = 6.
Écrivons une méthode statique josephus(int n, int p) qui calcule la valeur de
J(n,p) en utilisant une liste doublement chaînée cyclique, représentant le cercle des joueurs.
La méthode remove ci-dessus pourra alors être utilisée directement pour éliminer un
joueur. On commence par écrire une méthode statique circle(int n) qui construit une
liste doublement chaînée cyclique de longueur n. Elle commence par créer le premier
élément, de valeur 1 (on suppose ici n ≥ 1).
return l1;
}
On passe maintenant à la méthode josephus. Elle commence par appeler la méthode
circle pour construire le cercle c des joueurs.
c = c.next;
}
Ceci achève la boucle while. Le gagnant est le dernier élément dans la liste.
return c.element;
}
Le code complet est donné programme 10 page 65. Pour plus de détails concernant ce
problème, et notamment une solution analytique, on pourra consulter Concrete Mathe-
matics [6, Sec. 1.3].
Exercice 23. Réécrire la méthode josephus en utilisant une liste cyclique simplement
chaînée. Indication : dans la boucle interne, conserver un pointeur sur l'élément précédent,
de manière à pouvoir supprimer facilement le p-ième élément en sortie de boucle.
Solution
class Singly<E> {
E element;
Singly<E> next;
et pour les listes doublement chaînées
class Doubly<E> {
E element;
Doubly<E> next, prev;
Le reste du code est le même, au remplacement près de int par E aux endroits opportuns.
La bibliothèque Java fournit une classe générique de listes doublement chaînées dans
java.util.LinkedList<E>. Sa structure conserve un pointeur vers le premier et le dernier
élément de la liste, ainsi que le nombre d'éléments.
LinkedList
first
last
size n
On peut ainsi opérer des deux côtés de la liste, avec des méthodes comme addFirst,
removeFirst, addLast et removeLast. En particulier, la bibliothèque Java fournit une
interface générique de la structure de le dans java.util.Queue<E>, dont LinkedList
est une implémentation. On peut donc écrire notamment
Ainsi le paquet 2 contient les deux chaînes "the codes" et"in", respectivement de
longueurs 9 et 2, car ces deux chaînes ont pour image 2 par la fonction f . (Mais l'ordre dans
lequel ces deux chaînes apparaissent dans la liste peut varier suivant l'ordre d'insertion
des éléments dans la table.)
5.1 Réalisation
Réalisons une telle table de hachage dans une classe HashTable. On commence par
introduire une classe de liste simplement chaînée, Bucket, pour représenter les paquets
(en anglais on parle de seau plutôt que de paquet ).
class Bucket {
String element;
Bucket next;
Bucket(String element, Bucket next) {
this.element = element;
this.next = next;
}
}
La classe HashTable ne contient qu'un seul champ, à savoir le tableau des diérents
paquets :
class HashTable {
private Bucket[] buckets;
Pour écrire le constructeur, il faut se donner une valeur pour m, c'est-à-dire un nombre de
paquets. Idéalement, cette taille devrait être du même ordre de grandeur que le nombre
d'éléments qui seront stockés dans la table. L'utilisateur pourrait éventuellement fournir
cette information, par exemple sous la forme d'un argument du constructeur, mais ce n'est
pas toujours possible. Considérons donc pour l'instant une situation simpliée où cette
taille est une constante complètement arbitraire, à savoir m = 17.
final private static int M = 17;
HashTable() {
this.buckets = new Bucket[M];
}
(Nous verrons plus loin comment supprimer le caractère arbitraire de cette constante.) On
procède alors à l'écriture de la fonction de hachage proprement dite. Il y a de nombreuses
façons de la choisir, plus ou moins heureuses. De façon un peu moins naïve que la simple
longueur de la chaîne, on peut chercher à combiner les valeurs des diérents caractères de
la chaîne, comme par exemple
5.1. Réalisation 69
void add(String s) {
int i = hash(s);
this.buckets[i] = new Bucket(s, this.buckets[i]);
}
Une variante consisterait à vérier que s ne fait pas déjà partie de cette liste (voir l'exer-
cice 25). Mais la version ci-dessus a l'avantage de garantir une complexité O(1) dans tous
les cas. Et on peut parfaitement être dans une situation où on sait que s ne fait pas partie
de la table par exemple parce qu'on a eectué le test d'appartenance au préalable.
Pour réaliser le test d'appartenance, justement, on procède de la même façon, en
utilisant la méthode hash pour déterminer dans quel paquet la chaîne à rechercher doit
se trouver, si elle est présente. Pour chercher dans la liste correspondante, on ajoute par
exemple une méthode statique contains à la classe Bucket :
boolean contains(String s) {
return Bucket.contains(this.buckets[hash(s)], s);
}
Le code complet est donné programme 11 page 70.
1. En revanche, il ne serait pas correct d'écrire Math.abs(h) % M car si h est égal au plus petit entier,
c'est-à-dire −231 , alors Math.abs(h) vaudra −231 et le résultat de hash sera négatif.
70 Chapitre 5. Tables de hachage
class Bucket {
String element;
Bucket next;
Bucket(String element, Bucket next) {
this.element = element;
this.next = next;
}
static boolean contains(Bucket b, String s) {
for (; b != null; b = b.next)
if (b.element.equals(s)) return true;
return false;
}
}
class HashTable {
HashTable() {
this.buckets = new Bucket[M];
}
void add(String s) {
int i = hash(s);
this.buckets[i] = new Bucket(s, this.buckets[i]);
}
boolean contains(String s) {
return Bucket.contains(this.buckets[hash(s)], s);
}
}
5.2. Redimensionnement 71
Exercice 25. Modier la méthode add de HashTable pour qu'elle ne fasse rien lorsque
l'élément est déjà contenu dans la table. Solution
Exercice 27. Ajouter une méthode void remove(String s) pour supprimer un élé-
ment s de la table de hachage. Quel est l'impact sur la méthode add ?
Solution
5.2 Redimensionnement
Le code que nous venons de présenter est en pratique trop naïf. Le nombre d'éléments
contenus dans la table peut devenir grand par rapport à la taille du tableau. Cette charge
implique de gros paquets, qui dégradent les performances des opérations (ici seulement
de l'opération contains). Pour y remédier, il faut modier la taille du tableau dynami-
quement, en fonction de la charge de la table. On commence par modier légèrement la
méthode hash pour obtenir une valeur modulo this.buckets.length et non plus mo-
dulo M :
void add(String s) {
if (this.size > this.buckets.length/2) resize();
...
Tout le travail se fait dans cette nouvelle méthode resize. On commence par calculer la
nouvelle taille du tableau, comme le double de la taille actuelle :
Complexité. Nous n'avons pas choisi la stratégie consistant à doubler la taille du ta-
bleau par hasard. Exactement comme nous l'avons fait pour les tableaux redimension-
nables (voir page 43), on peut montrer que l'insertion successive de n éléments dans la
table de hachage aura un coût total O(n). Certains appels à add sont plus coûteux que
d'autres, et même d'une complexité proportionnelle au nombre d'éléments déjà dans la
table, mais la complexité amortie de add reste O(1).
La complexité de la recherche est plus dicile à évaluer, car elle dépend de la qualité
de la fonction de hachage. Si la fonction de hachage envoie tous les éléments dans le
même paquet c'est le cas par exemple si elle est constante alors la complexité de
contains sera clairement O(n). Si au contraire la fonction de hachage répartit bien les
éléments dans les diérents paquets, alors la taille de chaque paquet peut être bornée
par une constante et la complexité de contains sera alors O(1). La mise au point d'une
fonction de hachage se fait empiriquement, par exemple en mesurant la taille maximale
et moyenne des paquets. Sur des types tels que des chaînes de caractères, ou encore des
tableaux d'entiers, une fonction telle que celle que nous avons donnée plus haut donne
des résultats très satisfaisants.
Exercice 28. Vu que l'on double la taille du tableau à chaque fois que la table de hachage
est agrandie, on peut maintenir l'invariant que cette taille est toujours une puissance de
deux. L'intérêt est que l'on peut alors simplier le calcul
class Pair {
String fst, snd;
...
}
alors il conviendra de l'équiper d'une fonction de hachage d'une part, par exemple en
faisant la somme des valeurs de hachage des deux chaînes fst et snd
et d'une égalité structurelle d'autre part en comparant les deux paires membre à membre.
Il y a là une subtilité : la méthode equals est dénie dans la classe Object avec un
argument de type Object et il faut donc respecter ce prol de méthode pour la redénir.
On doit donc écrire
où (Pair)o est une conversion explicite car potentiellement non sûre de la classe
Object vers la classe Pair. Si cette méthode equals n'est utilisée que depuis le code de
HashSet<Pair> ou de HashMap<Pair, V>, on a la garantie que cette conversion n'échouera
jamais. En eet, le typage de Java nous garantit qu'un ensemble de type HashSet<Pair>
(resp. un dictionnaire de type HashMap<Pair, V>) ne pourra contenir que des éléments
(resp. des clés) de type Pair.
Il convient d'expliquer soigneusement un piège dans lequel on aurait pu facilement
tomber. Naturellement, on aurait plutôt écrit la méthode suivante :
Mais, bien qu'accepté par le compilateur, ce code ne donne pas les résultats attendus.
En eet, la méthode equals est maintenant surchargée et non plus redénie : il y a
deux méthodes equals, l'une prenant un argument de type Object et l'autre prenant un
argument de type Pair. Comme le code de HashSet et HashMap est écrit en utilisant la
méthode equals ayant un argument de type Object (même si cela peut surprendre), alors
c'est la première qui est utilisée, c'est-à-dire celle directement héritée de la classe Object.
Il se trouve qu'elle coïncide avec l'égalité physique, c'est-à-dire avec l'opération ==, ce qui
n'est pas en accord avec l'égalité structurelle que nous souhaitons ici sur le type Pair.
Une façon d'éviter ce piège consiste à indiquer au compilateur Java qu'il s'agit d'une
redénition de méthode, à l'aide de la directive @Override placée juste avant la dénition
de la méthode :
74 Chapitre 5. Tables de hachage
@Override
public boolean equals(Object o) {
...
Le compilateur vérie alors qu'il s'agit bien là d'une redénition, c'est-à-dire qu'il existe
eectivement une telle méthode, avec ce type-là, dans la super-classe. Dans le cas contraire,
la compilation échoue.
Quelle que soit la façon de redénir les méthodes hashCode et equals, il convient de
toujours maintenir la propriété suivante :
Autrement dit, des éléments égaux doivent être rangés dans le même seau.
B C D
E F G
représente un arbre de racine A ayant trois ls. Un n÷ud qui ne possède aucun ls est
appelé une feuille . Les feuilles de l'arbre ci-dessus sont B, E, F et G. La hauteur d'un
arbre est dénie comme le nombre de n÷uds le long du plus long chemin de la racine à
une feuille (ou, de manière équivalente, comme la longueur de ce chemin, plus un). La
hauteur de l'arbre ci-dessus est donc trois.
La notion d'arbre binaire est également dénie récursivement. Un arbre binaire est
soit vide, soit un n÷ud possédant exactement deux ls appelés ls gauche et ls droit. Un
arbre binaire n'est pas un cas particulier d'arbre, car on distingue les sous-arbres gauche
et droit (on parle d'arbre positionnel). Ainsi, les deux arbres suivants sont distincts :
A A
B B
Exercice 29. Soit un arbre binaire tel que, pour tout n÷ud, les sous-arbres gauche et
droit contiennent le même nombre de n÷uds, à un près. Montrer qu'alors sa hauteur est
logarithmique en son nombre de n÷uds. Solution
76 Chapitre 6. Arbres
class Tree {
int value;
Tree left, right;
}
où les champs left et right contiennent respectivement le ls gauche et le ls droit.
L'arbre vide est représenté par null. Cette représentation n'est en rien diérente de
la représentation d'une liste doublement chaînée (voir page 63), aux noms des champs
près. Ce qui change, c'est l'invariant de structure. Pour une liste doublement chaînée, la
structure imposée par construction était linéaire : tout élément suivait son précédent et
précédait son suivant. Ici la structure imposée par construction sera celle d'un arbre. En
se donnant le constructeur naturel de la classe Tree, à savoir
new Tree(new
et des entiers B, D, E et F, on peut construire un arbre avec l'expression
Tree(new Tree(B, null, null), D, null), E, new Tree(null, F, null)). On le
dessine de façon simpliée, sans expliciter les objets comme des petites boîtes avec des
champs ; cela prendrait trop de place.
D F
Plus loin dans ce chapitre, nous montrerons d'autres façons de construire des arbres, dans
les sections 6.4 et 7.2.
Exercice 30. Écrire une méthode statique Tree leftDeepTree(int n) qui construit un
arbre linéaire gauche contenant n n÷uds. Un arbre linéaire gauche est un arbre où chaque
n÷ud ne possède pas de ls droit. Solution
Exercice 32. Réécrire la méthode size avec une boucle while. Indication : utiliser une
structure de données contenant des sous-arbres dont il faut calculer la taille. Solution
Exercice 33. Écrire une méthode statique récursive int height(Tree t) qui renvoie la
hauteur d'un arbre. Solution
Exercice 34. Réécrire la méthode height de l'exercice précédent sans utiliser de récur-
sivité. Indication : parcourir les n÷uds de l'arbre niveau par niveau en utilisant une
le. Solution
Parcours. De même que nous avions écrit des méthodes parcourant les éléments d'une
liste chaînée, on peut chercher à parcourir les éléments d'un arbre, par exemple pour les
acher tous. Supposons par exemple que l'on veuille acher les éléments de la gauche
vers la droite , c'est-à-dire d'abord les éléments du ls gauche, puis la racine, puis les
éléments du ls droit. Là encore, il est naturel de procéder récursivement et le parcours
est aussi simple que
Un tel parcours est appelé un parcours inxe de l'arbre (inorder traversal en anglais). Si
on ache la valeur de la racine avant le parcours du ls gauche (resp. après le parcours
du ls droit) on parle de parcours préxe (resp. postxe ) de l'arbre.
1. Il est même toujours possible de remplacer une fonction récursive par une boucle.
78 Chapitre 6. Arbres
Pour tout n÷ud de l'arbre, de valeur x, les éléments situés dans le ls gauche
sont plus petits que x et ceux situés dans le ls droit sont plus grands que x.
On appelle cela un arbre binaire de recherche. En particulier, on en déduit que les éléments
apparaissent dans l'ordre croissant lorsque l'arbre est parcouru dans l'ordre inxe. Nous
allons exploiter cette structure pour écrire des opérations de recherche et de modication
ecaces. Par exemple, chercher un élément dans un arbre binaire de recherche ne requiert
pas de parcourir tout l'arbre : il sut de descendre à gauche ou à droite selon la compa-
raison entre l'élément recherché et la racine de l'arbre. Dans ce qui suit, on considère des
arbres binaires de recherche dont les valeurs sont des entiers. On se donne donc la classe
suivante pour les représenter.
class BST {
int value;
BST left, right;
}
Elle suppose que b n'est pas null et contient donc au moins un élément. Cette méthode
sera réutilisée plus loin pour écrire la méthode de suppression dans un arbre binaire de
recherche. Le cas de null y sera alors traité de façon particulière.
Exercice 36. Écrire une méthode static int floor(BST b, int x) qui renvoie le plus
grand élément de b inférieur ou égal à x, s'il existe, et lève une exception sinon.
Solution
Insertion d'un élément. L'insertion d'un élément x dans un arbre binaire de re-
cherche b consiste à trouver l'emplacement de x dans b, en suivant le même principe
que pour la recherche. On écrit pour cela une méthode add :
static BST add(BST b, int x) {
Cette méthode renvoie la racine de l'arbre, une fois l'insertion réalisée. On procède ré-
cursivement. Si b est vide, on se contente de construire un arbre contenant uniquement
x.
if (b == null)
return new BST(null, x, null);
Dans l'autre cas, on compare l'élément x à la racine de b, et on poursuit récursivement
l'insertion à gauche ou à droite lorsque la comparaison est stricte :
if (x < b.value)
b.left = add(b.left, x);
else if (x > b.value)
b.right = add(b.right, x);
On prend soin de mettre à jour b.left ou b.right, selon le cas, avec le résultat de l'appel
récursif. Cette aectation est en fait inutile pour tous les n÷uds internes le long de la
descente, mais nécessaire pour le dernier n÷ud rencontré, auquel on ajoute un nouveau
sous-arbre. Dans le cas où x est égal à b.value, on ne fait rien. Dans tous les cas, on
termine la méthode add en renvoyant b.
return b;
}
On fait ici le choix de ne pas construire d'arbre contenant de doublon, mais on aurait très
bien pu choisir de renvoyer au contraire un arbre contenant une occurrence supplémentaire
de x. Le choix que nous faisons ici est cohérent avec l'utilisation des arbres binaires de
recherche que nous allons faire plus loin pour réaliser une structure d'ensemble.
Exercice 37. Écrire la variante de la méthode add qui ajoute x dans l'arbre dans tous
les cas, i.e. même si x y apparaît déjà. (On réalise donc un multi-ensemble plutôt qu'un
ensemble.) Solution
Exercice 38. Pourquoi est-il dicile d'écrire la méthode add avec une boucle while
plutôt que récursivement ? Solution
80 Chapitre 6. Arbres
if (x < b.value)
b.left = remove(b.left, x);
else if (x > b.value)
b.right = remove(b.right, x);
Lorsqu'il y a égalité, en revanche, on se retrouve confronté à une diculté : il faut suppri-
mer la racine de l'arbre, c'est-à-dire renvoyer un arbre contenant exactement les éléments
de b.left et b.right, mais il n'y a pas de moyen simple de réaliser cette union. On sou-
haite autant que possible conserver b.left ou b.right inchangé, pour limiter la quantité
de n÷uds à modier. La propriété d'arbre binaire de recherche nous suggère alors de pla-
cer à la racine du nouvel arbre, soit le plus grand élément de b.left, soit le plus petit
élément de b.right. Vu que nous avons déjà une méthode getMin, nous allons opter
pour la seconde solution. Il convient de traiter correctement le cas où b.right ne possède
aucun élément. Dans ce cas, il sut de renvoyer b.left.
else { // x == b.value
if (b.right == null)
return b.left;
Sinon, la racine devient getMin(b.right) et cet élément est supprimé du sous-arbre droit
avec une méthode removeMin que nous allons écrire dans un instant.
b.value = getMin(b.right);
b.right = removeMin(b.right);
}
return b;
}
Dans tous les cas, on achève le code de remove en renvoyant b, le principe étant le même
que pour add. Écrivons maintenant la méthode removeMin. C'est un cas particulier de
remove, beaucoup plus simple, où l'on descend uniquement à gauche, jusqu'à trouver un
n÷ud n'ayant pas de sous-arbre gauche.
Il est important de noter que cette méthode suppose b diérent de null (dans le cas
contraire, b.left provoquerait un NullPointerException). Cette propriété est bien as-
surée par la méthode remove. Le code complet de la classe BST est donné programme 12
page 82.
6.3.2 Équilibrage
Telles que nous venons de les écrire dans la section précédente, les diérentes opérations
sur les arbres binaires de recherche ont une complexité linéaire, c'est-à-dire O(n) où n est
le nombre d'éléments contenus dans l'arbre. En eet, notre insertion peut tout à fait
conduire à un peigne c'est-à-dire un arbre de la forme
Il sut en eet d'insérer les éléments dans l'ordre A, B, C, D. Une insertion dans l'ordre
inverse donnerait de même un peigne, dans l'autre sens. Au-delà de la dégradation des per-
formances, un tel arbre linéaire peut provoquer un débordement de pile dans les méthodes
add ou remove, se traduisant par une exception StackOverflowError.
récursives telles que
Dans cette section, nous allonséquilibrer les arbres binaires de recherche, de manière
à garantir une hauteur logarithmique en le nombre d'éléments. Ainsi les diérentes opé-
rations auront une complexité O(log n) et le débordement de pile sera évité. Il existe de
nombreuses manières d'équilibrer un arbre binaire de recherche. Nous optons ici pour une
solution connue sous le nom d'AVL (de leurs auteurs Adelson-Velsky et Landis [1]). Elle
consiste à maintenir l'invariant suivant :
Pour tout n÷ud, les hauteurs de ses sous-arbres gauche et droit dièrent d'au
plus une unité.
Écrivons une nouvelle classe AVL pour les arbres binaires de recherche équilibrés. On
reprend la structure de la classe BST, à laquelle on ajoute un champ height contenant la
hauteur de l'arbre :
class AVL {
int value;
AVL left, right;
int height;
...
Pour traiter correctement le cas d'un arbre vide, on se donne la méthode suivante pour
renvoyer la hauteur d'un arbre :
class BST {
int value;
BST left, right;
BST(BST left, int value, BST right) {
this.left = left; this.value = value; this.right = right;
}
static boolean contains(BST b, int x) {
while (b != null) {
if (b.value == x) return true;
b = (x < b.value) ? b.left : b.right;
}
return false;
}
static BST add(BST b, int x) {
if (b == null) return new BST(null, x, null);
if (x < b.value)
b.left = add(b.left, x);
else if (x > b.value)
b.right = add(b.right, x);
return b;
}
static int getMin(BST b) { // suppose b != null
while (b.left != null) b = b.left;
return b.value;
}
static BST removeMin(BST b) { // suppose b != null
if (b.left == null) return b.right;
b.left = removeMin(b.left);
return b;
}
static BST remove(BST b, int x) {
if (b == null) return null;
if (x < b.value)
b.left = remove(b.left, x);
else if (x > b.value)
b.right = remove(b.right, x);
else { // x == b.value
if (b.right == null)
return b.left;
b.value = getMin(b.right);
b.right = removeMin(b.right);
}
return b;
}
}
6.3. Arbres binaires de recherche 83
Dès lors, on peut écrire un constructeur qui calcule la hauteur de l'arbre en fonction des
hauteurs de ses sous-arbres left et right.
AVL(AVL left, int value, AVL right) {
this.left = left;
this.value = value;
this.right = right;
this.height = 1 + Math.max(height(left), height(right));
}
Il n'y a pas là de circularité malsaine : la méthodeheight permet de renvoyer la hauteur
d'un arbre déjà construit et le constructeur s'en sert pour calculer la hauteur au moment
de la construction, avec des arbres left et right déjà construits.
Les méthodes qui ne construisent pas d'arbres, mais ne font que les consulter, sont
exactement les mêmes que dans la classe BST (en remplaçant partout BST par AVL, bien
évidemment). C'est le cas des méthodes getMin et contains. En revanche, pour les mé-
thodes qui construisent des arbres, c'est-à-dire les méthodes add, removeMin et remove,
une modication est nécessaire, car il faut parfois rétablir l'équilibrage. On va écrire une
méthode
D F
B
(6.1)
et que l'on insère la valeur A avec la méthode d'insertion dans les arbres binaires de
recherche, alors on obtient l'arbre
D F
A
(6.2)
qui n'est pas équilibré, puisque la diérence de hauteurs entre les sous-arbres gauche et
droit du n÷ud E est maintenant de deux. Il est néanmoins facile de rétablir l'équilibre.
84 Chapitre 6. Arbres
class AVL {
int value;
AVL left, right;
int height;
En eet, il est possible d'eectuer des transformations locales sur les n÷uds d'un arbre
qui conservent la propriété d'arbre binaire de recherche. Un exemple de telle opération
est la rotation droite, qui s'illustre ainsi :
n k
rotation droite
k
x>n x<k n
B F
A D
qui est bien un AVL. Une simple rotation, gauche ou droite, ne sut pas nécessairement
à rétablir l'équilibre. Si par exemple on insère maintenant C, on obtient l'arbre
B F
A D
qui n'est pas un AVL. On peut alors tenter d'eectuer une rotation droite à la racine E
ou une rotation gauche au n÷ud B, mais on obtient les deux arbres suivants
B E
A E D F
D F B
C A C
qui ne sont toujours pas des AVL. Cependant, celui de droite peut être facilement rééqui-
libré en eectuant une rotation droite sur la racine E. On obtient alors l'AVL
86 Chapitre 6. Arbres
B E
A C F
v lv
lv v
rotation droite
lr ll lr r
ll
v lrv
lv lv v
rotation gauche-droite
lrv
r lrr
ll lrr ll lrl r
lrl
else {
t.left = rotateLeft(t.left);
return rotateRight(t);
}
La propriété d'AVL est bien garantie, comme le montre la gure 6.1 (en bas). On notera
que le déséquilibre peut être causé par lrl ou lrr, indiéremment, et que dans les deux cas
la double rotation gauche-droite rétablit bien l'équilibre. On traite de manière symétrique
le cas où r est la cause du déséquilibre
} else {
t.height = 1 + Math.max(hl, hr);
88 Chapitre 6. Arbres
return t;
}
ce qui achève le code de la méthode balance. Le code complet est donné programme 14.
Hauteur d'un AVL. Montrons qu'un AVL a eectivement une hauteur logarithmique
en son nombre d'éléments. Considérons un AVL de hauteur h et cherchons à encadrer son
h
nombre n d'éléments. Clairement n ≤ 2 −1, comme dans tout arbre binaire. Inversement,
quelle est la plus petite valeur possible pour n? Elle sera atteinte pour un arbre ayant
un sous-arbre de hauteur h−1 et un autre de hauteur h−2 (car dans le cas contraire
on pourrait encore enlever des éléments à l'un des deux sous-arbres tout en conservant la
propriété d'AVL). En notant Nh le plus petit nombre d'éléments dans un AVL de hauteur
h, on a donc Nh = 1 + Nh−1 + Nh−2 , ce qui se réécrit Nh + 1 = (Nh−1 + 1) + (Nh−2 + 1).
On reconnaît là la relation de récurrence dénissant la suite de Fibonacci. Comme on
a par ailleurs N0 = 0 et N1 = 1, c'est-à-dire N0 + 1 = 1 et N1 + 1 = 2, on√en déduit
Nh + 1√= Fh+2 où (Fi ) est la suite de Fibonacci. On a l'inégalité Fi > φi / 5 − 1 où
φ = 1+2 5 est le nombre d'or, d'où
√
n ≥ Fh+2 − 1 > φh+2 / 5 − 2
Un AVL a donc bien une hauteur logarithmique en son nombre d'éléments. Comme nous
l'avons dit plus haut, cela garantit une complexité O(log n) pour les toutes les opérations,
mais aussi l'absence de StackOverflowError.
boolean isEmpty();
boolean contains(int x);
void add(int x);
void remove(int x);
Exactement comme nous l'avons fait précédemment pour construire des structures de pile
et de le au dessus de la structure de liste chaînée, nous encapsulons un objet de type
AVL dans cette nouvelle classe AVLSet :
class AVLSet {
private AVL root;
...
}
6.3. Arbres binaires de recherche 89
class AVLSet {
private AVL root;
AVLSet() {
this.root = null;
}
boolean isEmpty() {
return this.root == null;
}
boolean contains(int x) {
return AVL.contains(this.root, x);
}
void add(int x) {
this.root = AVL.add(x, this.root);
}
void remove(int x) {
this.root = AVL.remove(x, this.root);
}
}
6.3. Arbres binaires de recherche 91
Exercice 39. Ajouter à la classe AVLSet un champ privé size contenant le nombre
d'éléments de l'ensemble et une méthode int size() qui en renvoie la valeur. Modier
les méthodes add et remove pour mettre à jour la valeur de ce champ. Il faudra traiter
correctement le cas où l'élément ajouté par add est déjà dans l'ensemble et celui où
l'élément supprimé par remove n'est pas dans l'ensemble. Solution
Exercice 41. Ajouter à la classe AVL une méthode AVL ofList(Queue<Integer> l) qui
prend en argument une le de N entiers, supposée triée par ordre croissant, et renvoie un
AVL contenant ces N entiers, en temps O(N ). Indication : généraliser avec une méthode
qui construit un arbre avec les n premiers éléments de la le seulement. Solution
Exercice 42. Déduire des deux exercices précédents des méthodes réalisant l'union, l'in-
tersection et la diérence ensembliste de deux AVL en temps O(n + m), où n et m sont
les nombres d'éléments de chaque AVL. Solution
class AVL<E> {
E value;
AVL<E> left, right;
int height;
Cependant, cela ne sut pas. Le code a besoin de pouvoir comparer les éléments entre
eux, par exemple dans les méthodes contains, add et remove. Pour l'instant, nous avons
utilisé une comparaison directe entre entiers, avec les opérateurs ==, < et >. Pour comparer
des éléments de type E, ce n'est plus possible. On va donc exiger que la classe E fournisse
une méthode pour comparer deux éléments. Pour cela on utilise l'interface suivante :
interface Comparable<K> {
int compareTo(K k);
}
Le signe de l'entier renvoyé par compareTo this se compare à k. Une
indique comment
telle interface fait déjà partie de la bibliothèque Java, dans java.lang.Comparable<T>.
On va exiger que le paramètre E de la classe AVL implémente l'interface Comparable<E>,
ce que l'on écrit ainsi :
false
’i’ ’d’
false false
’f’ ’n’ ’o’
true true true
’n’
false
’e’
true
Un tel arbre est appelé un arbre de préxes, plus connu sous le nom de trie en anglais.
L'intérêt d'une telle structure de donnée est de borner le temps de recherche d'un élément
dans un ensemble à la longueur du mot le plus long de cet ensemble, quelque soit le nombre
de mots qu'il contient. Plus précisément, cette propriété est garantie seulement si toutes
les feuilles d'un arbre de préxes représentent bien un mot de l'ensemble, c'est-à-dire si
elles contiennent toutes une valeur booléenne à vrai. Cette bonne formation des arbres de
préxes sera maintenue par toutes les opérations dénies ci-dessous.
Écrivons une classe Trie pour représenter de tels arbres. On utilise la bibliothèque
HashMap pour représenter le branchement à chaque n÷ud par une table de hachage :
class Trie {
private boolean word;
private HashMap<Character, Trie> branches;
...
6.4. Arbres de préxes 93
Ainsi, dans l'exemple ci-dessus, le champ branches de la racine de l'arbre est une table de
hachage contenant deux entrées, une associant le caractère 'i' au sous-arbre de gauche,
et une autre associant le caractère 'd' au sous-arbre de droite.
L'arbre de préxes vide est représenté par un arbre réduit à un unique n÷ud où le
champ word vaut false et branches est un dictionnaire vide :
Trie() {
this.word = false;
this.branches = new HashMap<Character, Trie>();
}
Recherche d'un élément. Écrivons une méthode contains qui détermine si une
chaîne s appartient à un arbre de préxes.
boolean contains(String s) {
La recherche consiste à descendre dans l'arbre en suivant les lettres de s. On le fait ici à
l'aide d'une boucle for, en se servant d'une variable t contenant le n÷ud de l'arbre où
l'on se trouve à chaque instant.
Trie t = this;
for (int i = 0; i < s.length(); i++) { // invariant t != null
t = t.branches.get(s.charAt(i));
Dans le cas contraire, on passe au caractère suivant. Si on sort de la boucle, c'est qu'on
est parvenu jusqu'au dernier caractère de s. Il sut alors de renvoyer le booléen présent
dans le n÷ud qui a été atteint.
return t.word;
}
Insertion d'un élément. L'insertion d'un mot s dans un arbre de préxes consiste
à descendre le long de la branche étiquetée par les lettres de s, de manière similaire
au parcours eectué pour la recherche. C'est cependant légèrement plus subtil, car il faut
éventuellement créer de nouvelles branches dans l'arbre pendant la descente. Comme pour
la recherche, on procède à la descente avec une boucle for parcourant les caractères du
mot et une variable t contenant le sous-arbre courant.
void add(String s) {
Trie t = this;
for (int i = 0; i < s.length(); i++) { // invariant t != null
char c = s.charAt(i);
94 Chapitre 6. Arbres
if (t == null) {
t = new Trie();
b.put(c, t);
}
On peut alors passer au caractère suivant, car on a assuré que t n'est pas null. Une fois
sorti de la boucle, il ne reste plus qu'à positionner le booléen à true pour indiquer la
présence du mot s.
}
t.word = true;
Si le mot s était déjà présent dans l'arbre, cette aectation est sans eet.
Le code complet est donné programme 16 page 95. La structure d'arbre de préxes
peut être généralisée à toute valeur pouvant être vue comme une suite de lettres, quelle
que soit la nature de ces lettres. C'est le cas par exemple pour une liste. C'est aussi le
cas d'un entier, si on voit ses bits comme formant un mot avec les lettres 0 et 1. Dans ce
dernier cas, on parle d'arbre de Patricia [12].
Exercice 43. Ajouter à la classe Trie une méthode void remove(String s) qui sup-
prime l'occurrence de la chaîne s, si elle existe.
Solution
Exercice 44. La méthode remove de l'exercice précédent peut conduire à des branches
vides, i.e. ne contenant plus aucun mot, ce qui dégrade les performances de la recherche.
Modier la méthode remove pour qu'elle supprime les branches devenues vides. Il s'agit
donc de maintenir l'invariant qu'un champ branches ne contient jamais une entrée vers
un arbre ne contenant aucun mot. Indication : on pourra procéder récursivement et se
servir de la méthode suivante
boolean isEmpty() {
return !this.word && this.branches.isEmpty();
}
qui teste si un arbre ne contient aucun mot à supposer que l'invariant ci-dessus est
eectivement maintenu, bien entendu. Solution
Exercice 45. Optimiser la structure de Trie pour que le champ branches des feuilles
de l'arbre ne contiennent pas une table de hachage vide, mais plutôt la valeur null.
Solution
6.4. Arbres de préxes 95
class Trie {
Trie() {
this.word = false;
this.branches = new HashMap<Character, Trie>();
}
boolean contains(String s) {
Trie t = this;
for (int i = 0; i < s.length(); i++) { // invariant t != null
t = t.branches.get(s.charAt(i));
if (t == null) return false;
}
return t.word;
}
void add(String s) {
Trie t = this;
for (int i = 0; i < s.length(); i++) { // invariant t != null
char c = s.charAt(i);
Map<Character, Trie> b = t.branches;
t = b.get(c);
if (t == null) {
t = new Trie();
b.put(c, t);
}
}
t.word = true;
}
}
96 Chapitre 6. Arbres
7
Structures de données immuables
Ce chapitre montre l'intérêt de structures de données qui ne peuvent plus être modiées
une fois construites. Bien que la bibliothèque standard de Java en contienne peu, un
exemple notable est la classe String des chaînes de caractères.
x 0 1 2 3 4 ⊥
y 5
La variable x pointe sur une liste 0 → 1 → 2 → 3 → 4 et la variable y pointe sur une liste
5 → 2 → 3 → 4 mais, détail important, elles partagent une partie de leurs éléments, à
savoir la queue de liste 2 → 3 → 4. Si maintenant le détenteur de la variable x décide de
modier le contenu de la liste désignée par x, par exemple pour remplacer la valeur 3 par
17, ou encore d'en modier la structure, pour faire reboucler l'élément 4 vers l'élément 2,
alors ce changement aectera la liste y également. À cet égard, c'est la même situation
d'alias que nous avons déjà évoquée avec les tableaux (voir page 19).
Il existe de nombreuses situations dans lesquelles on sait pertinemment qu'une liste
ne sera pas modiée après sa création. On peut donc chercher à le garantir, comme un
invariant du programme. Une solution consisterait à faire des champs element et next
des champs privés et à n'exporter que des méthodes qui ne modient pas les listes. Une
solution encore meilleure consiste à déclarer les champs element et next comme final.
Ceci implique qu'ils ne peuvent plus être modiés au-delà du constructeur (le compilateur
le vérie), ce qui est exactement ce que nous recherchons.
class Singly {
final int element;
final Singly next;
...
}
98 Chapitre 7. Structures de données immuables
Dès lors, une situation de partage telle que celle illustrée ci-dessus n'est plus probléma-
tique. En eet, la portion de liste partagée ne peut être modiée ni par le détenteur de
x ni par celui de y, et donc son partage ne présente plus aucun danger. Au contraire, il
permet même une économie d'espace.
Lorsqu'une structure de données ne fournit aucune opération permettant d'en modier
le contenu, on parle de structure de données immuable 1 . Un style de programmation
qui ne fait usage que de structures de données immuables est dit purement applicatif.
L'un des intérêts de ce style de programmation est une diminution signicative du risque
d'erreurs dans les programmes. En particulier, il devient beaucoup plus facile de raisonner
sur le code, en utilisant le raisonnement mathématique usuel, sans avoir à se soucier
constamment de l'état des structures de données. Un autre avantage est la possibilité
d'un partage signicatif entre diérentes structures de données, et une possible économie
substantiel de mémoire.
Ceci n'est évidemment pas limité aux listes. On peut ainsi garantir le caractère im-
muable d'un arbre binaire de recherche en ajoutant simplement le qualicatif final à ses
trois champs :
class BST {
final int value;
final BST left, right;
}
Certaines méthodes, comme getMin ou contains, restent inchangées car elles ne font
que consulter la structure de l'arbre, sans chercher à la modier. D'autres méthodes, en
revanche, doivent être écrites diéremment sur les arbres immuables. Ainsi, la méthode
add qui insère un élément dans un arbre binaire de recherche renvoie maintenant un nouvel
arbre. On peut l'écrire ainsi :
static BST add(BST b, int x) {
if (b == null)
return new BST(null, x, null);
if (x < b.value)
return new BST(add(b.left, x), b.value, b.right);
if (x > b.value)
return new BST(b.left, b.value, add(b.right, x));
return b; // x déjà dans b
}
De nouveaux n÷uds sont donc construits tout le long du chemin parcouru par l'insertion
(là où auparavant on se contentait d'aectations). Cela peut paraître coûteux, mais pour
un arbre équilibré, tel qu'un AVL, il n'y a qu'un nombre logarithmique de n÷uds le long
de ce chemin. L'insertion a donc un coût logarithmique en espace dans ce cas.
Exercice 46. La structure d'arbres de préxes (section 6.4) est une structure de données
modiable. Expliquer pourquoi, à la diérence des arbres binaires, on ne peut pas en faire
facilement une structure immuable en ajoutant simplement le qualicatif final sur les
deux champs word et branches. Solution
1. Un concept plus général est celui de structure de données persistante , où les modications peuvent
exister à l'intérieur de la structure mais ne sont pas observables depuis l'extérieur.
7.2. Exemple : structure de corde 99
App
est une des multiples façons de représenter la chaîne "a very long string". Deux consi-
dérations nous poussent à raner légèrement l'idée ci-dessus. D'une part, de nombreux
algorithmes auront besoin d'un accès ecace à la longueur d'une corde, notamment pour
décider de descendre dans le sous-arbre gauche ou dans le sous-arbre droit d'un n÷ud App.
Il est donc souhaitable d'ajouter la taille de la corde comme une décoration de chaque
n÷ud interne. D'autre part, il est important de pouvoir partager des sous-chaînes entre
les cordes elles-mêmes et avec les chaînes usuelles qui ont été utilisées pour les construire.
Dès lors, plutôt que d'utiliser une chaîne complète dans chaque feuille, on va stocker plus
d'information pour désigner un fragment d'une chaîne Java, par exemple sous la forme
de deux entiers indiquant un indice et une longueur. Pour représenter de tels arbres, on
pourrait imaginer la classe suivante
class Rope {
final int length;
final String word; // feuille
final Rope left, right; // noeud interne
...
}
où le champ length est utilisé systématiquement, le suivant dans le cas d'une feuille
uniquement et les deux derniers dans le cas d'un n÷ud interne uniquement. Cette re-
présentation a tout de même le défaut d'être inutilement gourmande : des champs sont
systématiquement gâchés dans chaque objet. Nous allons adopter une représentation plus
subtile, en tirant parti de l'héritage de classes fourni par Java.
On commence par écrire une classe Rope représentant une corde quelconque, c'est-
à-dire aussi bien une feuille qu'un n÷ud interne. On y stocke la longueur de la corde,
puisque c'est là l'information commune aux deux types de n÷uds.
eectivement les cordes vont appartenir à deux sous-classes de Rope, représentant respec-
tivement les feuilles et les n÷uds internes. On les dénit ainsi :
Par le principe de l'héritage, un objet de la classe Str a donc deux champs, à savoir length
et str, et un objet de la classe App a trois champs, à savoirlength, left et right. Les
classes Str et App ne sont pas abstraites et on les utilisera justement pour construire des
cordes. Commençons par le code des constructeurs. Pour la classe Str, l'argument est une
chaîne de caractères.
Str(String str) {
super(str.length());
this.str = str;
}
Avec ces constructeurs, on peut déjà construire des cordes. La corde donnée en exemple
plus haut peut être construite avec
Accès à un caractère. Écrivons maintenant une méthode char get(int i) qui ren-
voie lei-ième caractère d'une corde. On la déclare dans la classe Rope, car on veut pouvoir
accéder au i-ième caractère d'une corde sans connaître sa nature. Ainsi, on veut pouvoir
écrire r.get(3) avec r de type Rope comme dans l'exemple ci-dessus. Mais on ne peut
pas dénir get dans la classe Rope. Aussi on la déclare comme une méthode abstraite.
2. De manière générale, le code d'un constructeur commence toujours par l'appel au code d'un autre
constructeur : soit il s'agit d'un appel implicite au constructeur de la super-classe (comme si on avait
écrit super()) ; soit il s'agit d'un appel explicite à un constructeur de la même classe, avec this(...),
ou de la super-classe, avec super(...).
7.2. Exemple : structure de corde 101
Pour que le code soit maintenant accepté par le compilateur, il faut dénir la méthode
get dans les deux sous-classes Str et App. Dans la classe Str, c'est immédiat.
char get(int i) {
return this.str.charAt(i);
}
char get(int i) {
return (i < this.left.length) ?
this.left.get(i) : this.right.get(i - this.left.length);
}
Exercice 47. Modier le code des méthodes get pour qu'il vérie que i désigne bien une
position valide dans la corde. Dans le cas contraire, lever une exception. Solution
Puis on la dénit dans chacune des sous-classes. Dans la classe Str, c'est immédiat :
Dans la classe App, c'est plus subtil. En eet, la sous-corde peut se retrouver soit entiè-
rement dans la corde de gauche, soit entièrement dans la corde de droite, soit à cheval
sur les deux. On commence par calculer combien de caractères se trouvent dans la partie
droite :
Si cette quantité est négative ou nulle, c'est que le résultat se trouve tout entier dans la
corde this.left.
if (endr <= 0)
return this.left.sub(begin, end);
Sinon, on détermine si au contraire le résultat se trouve tout entier dans la corde this.right.
102 Chapitre 7. Structures de données immuables
Exercice 49. Ajouter des qualicatifs appropriés (private, protected) sur les diérents
champs des classes Rope, Str et App. Solution
Exercice 50. Ajouter une méthode String toString() qui renvoie la chaîne Java dénie
par une corde. On prendra soin de le faire ecacement à l'aide d'un StringBuffer.
Solution
Exercice 51. Modier la méthode sub pour qu'elle renvoie directement this lorsque
begin et end désigne la corde toute entière. Quel est l'intérêt ? Solution
Exercice 52. Pour améliorer l'ecacité des cordes, on peut utiliser l'idée suivante : dès
que l'on cherche à concaténer deux cordes dont la somme des longueurs ne dépasse pas
une constante donnée (par exemple 256 caractères) alors on construit directement un
n÷ud de type Str plutôt qu'un n÷ud App. Écrire une méthode Rope append(Rope r)
qui concatène deux cordes (this et r) en utilisant cette idée. On pourra réutiliser la
méthode toString de l'exercice précédent. Solution
7.2. Exemple : structure de corde 103
Programme 17 Cordes
boolean isEmpty();
int size();
void add(int x);
int getMin();
void removeMin();
Dans cette interface, la notion de minimalité coïncide avec la notion de plus grande prio-
rité. Contrairement aux les, on préfère distinguer l'accès au premier élément et sa sup-
pression, par deux opérations distinctes, pour des raisons d'ecacité qui seront expliquées
plus loin. Ainsi, la méthode getMin renvoie l'élément le plus prioritaire de la le et la mé-
thode removeMin le supprime. On trouvera des applications des les de priorités plus loin
dans les chapitres 13 et 14.
(8.1)
7 12
21 9
On note qu'il existe d'autres tas contenant ces mêmes éléments. Par dénition, l'élément
le plus prioritaire est situé à la racine et on peut donc y accéder en temps constant. Les
106 Chapitre 8. Files de priorité
deux sections suivantes proposent deux façons diérentes de représenter un tel tas.
3(0)
7(1) 12(2)
21(3) 9(4)
Cette numérotation permet de représenter le tas dans un tableau. Ainsi, le tas ci-dessus
correspond au tableau à 5 éléments suivant :
0 1 2 3 4
3 7 12 21 9
De manière générale, la racine de l'arbre occupe la case d'indice 0 et les racines des deux
sous-arbres du n÷ud stocké à la case i sont stockées respectivement aux cases 2i + 1 et
2i + 2. Inversement, le père du n÷ud i est stocké en b(i − 1)/2c.
De cette structure de tas, on déduit les diérentes opérations de la le de priorité
de la manière suivante. Le plus petit élément est situé à la racine de l'arbre, c'est-à-dire
à l'indice 0 du tableau. On y accède donc en temps constant. Pour ajouter un nouvel
élément dans un tas, on le place tout en bas à droite du tas et on le fait remonter à sa
place. Pour supprimer le plus petit élément, on le remplace par l'élément situé tout en bas
à droite du tas, que l'on fait alors descendre à sa place. Ces deux opérations sont décrites
en détail dans les deux sections suivantes. Ce que l'on peut déjà comprendre, c'est que
leur coût est proportionnel à la hauteur de l'arbre. Un arbre binaire complet ayant une
hauteur logarithmique, l'ajout et le retrait dans un tas ont donc un coût O(log n) où n
est le nombre d'éléments dans le tas.
Pour mettre en ÷uvre cette structure de tas, il reste un petit problème. On ne connaît
pas a priori la taille de la le de priorité. On pourrait xer à l'avance une taille maximale
pour la le de priorité mais une solution plus élégante consiste à utiliser un tableau
redimensionnable. De tels tableaux sont présentés dans le chapitre 3 et on va donc réutiliser
ici la classe ResizableArray présentée plus haut. Un tas n'est donc rien d'autre qu'un
objet encapsulant un tableau redimensionnable (ici dans un champ appelé elts) :
class Heap {
private ResizableArray elts;
8.2. Représentation dans un tableau 107
Heap() {
this.elts = new ResizableArray(0);
}
Le nombre d'éléments contenus dans le tas est exactement celui du tableau redimension-
nable, d'où un code immédiat pour les deux méthodes size et isEmpty :
int size() {
return this.elts.size();
}
boolean isEmpty() {
return this.elts.size() == 0;
}
La méthode getMin renvoie la racine du tas, si elle existe, et lève une exception sinon.
Comme expliqué ci-dessus, la racine du tas est stockée à l'indice 0.
int getMin() {
if (this.elts.size() == 0)
throw new NoSuchElementException();
return this.elts.get(0);
}
Insertion d'un élément. L'insertion d'un élément x dans un tas consiste à étendre le
tableau d'une case, à y mettre la valeur x, puis à faire remonter x jusqu'à la bonne
position. Pour cela, on utilise l'algorithme suivant : tant que x est plus petit que son père,
c'est-à-dire la valeur située immédiatement au dessus dans l'arbre, on échange leurs deux
valeurs et on recommence. Par exemple, l'ajout de 1 dans le tas (8.1) est réalisé en trois
étapes :
3 3 1
1 < 12 1<3
7 12 7 1 7 3
21 9 1 21 9 12 21 9 12
On commence donc par écrire une méthode récursive moveUp(int x, int i) qui insère
un élément x dans le tas, en partant de la position i. Cette méthode suppose que l'arbre
de racine i obtenu en plaçant x en i est un tas. La méthode moveUp considère tout d'abord
le cas où i vaut 0, c'est-à-dire où on est arrivé à la racine. Il sut alors d'insérer x à la
position i.
S'il s'agit en revanche d'un n÷ud interne, on calcule l'indice fi du père de i et la valeur
y stockée dans ce n÷ud.
108 Chapitre 8. Files de priorité
} else {
int fi = (i - 1) / 2;
int y = this.elts.get(fi);
if (y > x) {
this.elts.set(i, y);
moveUp(x, fi);
} else
this.elts.set(i, x);
Ceci achève le code de moveUp. La méthode add procède alors en deux temps. Elle aug-
mente la taille du tableau d'une unité, en ajoutant une case à la n du tableau, puis
appelle la méthode moveUp à partir de cette case.
void add(int x) {
int n = this.elts.size();
this.elts.setSize(n + 1);
moveUp(x, n);
}
On note que add préserve bien la structure de tas. D'une part, on a étendu le tableau
d'une unité vers la droite, ce qui revient à ajouter un élément en bas à droite de l'arbre, et
on conserve donc la structure d'arbre binaire complet. D'autre part, l'invariant de moveUp
est bien respecté, car on démarre avec un arbre de racine n réduit à un élément, qui est
donc trivialement un tas. Comme expliqué plus haut, la méthode add a une complexité
O(log n) où n est le nombre d'éléments de la le de priorité.
Exercice 53. Réécrire la méthode moveUp à l'aide d'une boucle while. Solution
Suppression du plus petit élément. Supprimer le plus petit élément d'un tas est
légèrement plus délicat que d'insérer un nouvel élément. La raison en est qu'il s'agit de
supprimer la racine de l'arbre et qu'il faut donc trouver par quel élément la remplacer.
L'idée consiste à choisir l'élément tout en bas à droite du tas, c'est-à-dire l'élément occu-
pant la dernière case du tableau, comme candidat, puis à le faire descendre dans le tas
jusqu'à sa place, un peu comme on a fait monter le nouvel élément lors de l'insertion.
Supposons par exemple que l'on veuille supprimer le plus petit élément du tas suivant :
4 7
11 5 8
8.2. Représentation dans un tableau 109
8 4 4
4 < 8, 7 5 < 11, 8
4 7 8 7 5 7
11 5 11 5 11 8
Écrivons une méthode récursive moveDown(int x, int i) qui réalise la descente d'un
élément x à sa place, en partant de l'indice i.
int j = 2 * i + 1;
if (j + 1 < n && this.elts.get(j + 1) < this.elts.get(j))
j++;
Si le n÷ud j existe, et qu'il contient une valeur plus petite que x, alors x doit descendre.
On fait donc remonter la valeur située à l'indice j, à la position i, puis on procède
récursivement à partir de l'indice j, pour poursuivre la descente.
void removeMin() {
int n = this.elts.size() - 1;
if (n < 0) throw new NoSuchElementException();
Puis on extrait la valeur x située tout en bas à droite du tas, c'est-à-dire à la dernière
position du tableau, avant de diminuer la taille du tableau d'une unité, puis d'appeler la
méthode moveDown pour placer x à sa place, en partant de la racine du tas, c'est-à-dire
de la position 0.
int x = this.elts.get(n);
this.elts.setSize(n);
if (n > 0) moveDown(x, 0);
}
110 Chapitre 8. Files de priorité
La structure est bien préservée. D'une part, on a supprimé l'élément situé tout en bas
à droite du tas et on conserve donc une structure d'arbre binaire complet. D'autre part,
moveDown assure que la structure de tas est bien rétablie. Comme expliqué plus haut, la
méthode removeMin a une complexité O(log n) où n est le nombre d'éléments de la le
de priorité. Le code complet de la classe Heap est donné programme 18 page 111.
Exercice 54. On peut utiliser la structure de tas pour réaliser un tri ecace très facile-
ment, appelé tri par tas (en anglais heapsort ). L'idée est la suivante : on insère tous les
éléments à trier dans un tas, puis on les ressort successivement avec les méthodes getMin
et removeMin. Écrire une méthode void sort(int[] a) pour trier un tableau en utili-
sant cet algorithme. Quel est la complexité de ce tri ? (Le tri par tas est décrit en détail
section 13.4.) Solution
class SkewHeap {
private Tree root;
private int size;
SkewHeap() {
this.root = null;
this.size = 0;
}
Les méthodes isEmpty et size sont également immédiates. On note qu'elles s'exécutent
en temps constant.
boolean isEmpty() {
return this.size == 0;
}
int size() {
return this.size;
}
8.3. Représentation comme un arbre 111
class Heap {
private ResizableArray elts;
Heap() { this.elts = new ResizableArray(0); }
int size() { return this.elts.size(); }
boolean isEmpty() { return this.elts.size() == 0; }
private void moveUp(int x, int i) {
if (i == 0) {
this.elts.set(i, x);
} else {
int fi = (i - 1) / 2;
int y = this.elts.get(fi);
if (y > x) {
this.elts.set(i, y);
moveUp(x, fi);
} else
this.elts.set(i, x);
}
}
void add(int x) {
int n = this.elts.size();
this.elts.setSize(n + 1);
moveUp(x, n);
}
int getMin() {
if (this.elts.size() == 0) throw new NoSuchElementException();
return this.elts.get(0);
}
private void moveDown(int x, int i) {
int n = this.elts.size();
int j = 2 * i + 1;
if (j + 1 < n && this.elts.get(j + 1) < this.elts.get(j))
j++;
if (j < n && this.elts.get(j) < x) {
this.elts.set(i, this.elts.get(j));
moveDown(x, j);
} else
this.elts.set(i, x);
}
void removeMin() {
int n = this.elts.size() - 1;
if (n < 0) throw new NoSuchElementException();
int x = this.elts.get(n);
this.elts.setSize(n);
if (n > 0) moveDown(x, 0);
}
}
112 Chapitre 8. Files de priorité
La méthode isEmpty pourrait tout aussi bien tester si this.root est null. Enn la
méthode getMin renvoie le plus petit élément, c'est-à-dire la racine du tas. On prend
cependant soin de tester que l'arbre est non vide.
int getMin() {
if (this.isEmpty()) throw new NoSuchElementException();
return this.root.value;
}
Opération de fusion. Toute la subtilité de ces tas auto-équilibrés tient dans une mé-
thode merge qui fusionne deux tas. On l'écrit comme une méthode statique et privée qui
prend en arguments deux arbres t1 et t2, supposés être des tas. Le résultat renvoyé est
la racine de l'arbre obtenu.
Si en revanche aucun des tas n'est vide, on construit le tas résultant de la fusion de la
manière suivante. Sa racine est clairement la plus petite des deux racines de t1 et t2.
Supposons que la racine de t1 soit la plus petite.
La racine de t1 sera la racine du résultat. On doit maintenant déterminer ses deux sous-
arbres. Il y a plusieurs possibilités, obtenues en appelant récursivement merge sur deux
des trois arbres t1.left, t1.right et t2 et en choisissant de mettre le résultat comme
sous-arbre gauche ou droit. Parmi toutes ces possibilités, on choisit celle qui échange les
deux sous-arbres de t1, de manière à assurer l'auto-équilibrage. Ainsi, t1.right prend la
place de t1.left et est fusionné avect2.
Tree l1 = t1.left;
t1.left = merge(t1.right, t2);
t1.right = l1;
return t1;
} else {
Tree l2 = t2.left;
t2.left = merge(t2.right, t1);
t2.right = l2;
return t2;
}
}
Autres opérations. De cette opération merge on déduit facilement les méthodes add
et removeMin. En eet, pour ajouter un nouvel élément x au tas, il sut de fusionner ce
dernier avec un arbre réduit à l'élément x, sans oublier de mettre à jour le champ size.
void add(int x) {
this.root = merge(this.root, new Tree(null, x, null));
this.size++;
}
Pour supprimer le plus petit élément, c'est-à-dire la racine du tas, il sut de fusionner
les deux sous-arbres gauche et droit. On commence par tester si le tas est eectivement
non vide.
int removeMin() {
if (this.isEmpty()) throw new NoSuchElementException();
Le cas échéant, on conserve sa racine dans une variable res (pour la renvoyer comme
résultat) et on fusionne les deux sous-arbres avec la méthode merge.
Exercice 55. Ajouter à la classe SkewHeap une méthode void merge(SkewHeap that)
qui ajoute au tas this that.
le contenu du tas Solution
class SkewHeap {
private Tree root;
private int size; // nombre de noeuds de root
SkewHeap() {
this.root = null;
this.size = 0;
}
int getMin() {
if (this.isEmpty()) throw new NoSuchElementException();
return this.root.value;
}
void add(int x) {
this.root = merge(this.root, new Tree(null, x, null));
this.size++;
}
int removeMin() {
if (this.isEmpty()) throw new NoSuchElementException();
int res = this.root.value;
this.root = merge(this.root.left, this.root.right);
this.size--;
return res;
}
}
8.4. Code générique 115
S'il s'agit d'une représentation dans un tableau, on utilise par exemple les tableaux redi-
mensionnables génériques de la section 3.4.5 (ou plus simplement la classe Vector<E> de
la bibliothèque Java). S'il s'agit d'arbres binaires, on utilise des arbres génériques, comme
au chapitre 6. Le reste du code est alors facilement adapté. Lorsqu'il s'agit de comparer
deux éléments x et y, on n'écrit plus x < y mais x.compareTo(y) < 0.
La bibliothèque Java propose une telle structure de données générique dans la classe
java.util.PriorityQueue<E>. Si la classe E implémente l'interface Comparable<E>, leur
méthode compareTo est utilisée pour comparer les éléments. Dans le cas contraire, l'utili-
sateur peut fournir un comparateur au moment de la création de la le de priorité, sous
la forme d'un objet qui implémente l'interface java.util.Comparator<T> :
interface Comparator<T> {
int compare(T x, T y);
}
C'est alors la méthode compare de ce comparateur qui est utilisée pour ordonner les
éléments.
116 Chapitre 8. Files de priorité
9
Classes disjointes
Ce chapitre présente une structure de données pour le problème des classes disjointes,
connue sous le nom de union-nd. Ce problème consiste à maintenir dans une structure
de données une partition d'un ensemble ni, c'est-à-dire un découpage en sous-ensembles
disjoints que l'on appelle des classes . On souhaite pouvoir déterminer si deux éléments
appartiennent à la même classe et réunir deux classes en une seule. Ce sont ces deux
opérations qui ont donné le nom de structure union-nd.
9.1 Principe
Sans perte de généralité, on suppose que l'ensemble à partitionner est celui des n
entiers {0,1, . . . ,n − 1}. On cherche à construire une classe UnionFind avec l'interface
suivante :
class UnionFind {
UnionFind(int n)
int find(int i)
void union(int i, int j)
}
Le constructeur UnionFind(n) construit une nouvelle partition de {0,1, . . . ,n − 1} où
chaque élément forme une classe à lui tout seul. L'opération find(i) détermine la classe
de l'élément i, sous la forme d'un entier considéré comme l'unique représentant de cette
classe. En particulier, on détermine si deux éléments sont dans la même classe en com-
parant les résultats donnés par find pour chacun. Enn, l'opération union(i,j) réunit les
deux classes des éléments i et j , la structure de données étant modiée en place.
L'idée principale est de lier entre eux les éléments d'une même classe. Dans chaque
classe, ces liaisons forment des chemins qui mènent tous à un unique représentant, qui est
le seul élément lié à lui-même. La gure 9.1 montre un exemple où l'ensemble {0,1, . . . ,7}
est partitionné en deux classes dont les représentants sont respectivement 3 et 4. Il est
possible de représenter une telle structure en utilisant des n÷uds alloués en mémoire
individuellement (voir exercice 59). Cependant, il est plus simple et souvent plus ecace
d'utiliser un tableau qui lie chaque entier à un autre entier de la même classe. Ces liaisons
mènent toujours au représentant de la classe, qui est associé à sa propre valeur dans le
tableau. Ainsi, la partition de la gure 9.1 est représentée par le tableau suivant :
118 Chapitre 9. Classes disjointes
3 4
5 7 6
1 0 2
0 1 ··· 7
7 5 6 3 4 3 4 3
9.2 Réalisation
Décrivons maintenant le code de la structure union-nd, dans une classe UnionFind
dont une instance représente une partition. Cette classe contient deux tableaux privés :
link qui contient les liaisons et rank qui contient le rang de chaque classe.
class UnionFind {
private int[] link;
private int[] rank;
L'information contenue dans rank n'est signicative que pour des éléments i qui sont des
représentants, c'est-à-dire pour lesquels link[i] = i. Initialement, chaque élément forme
une classe à lui tout seul, c'est-à-dire est son propre représentant, et le rang de chaque
classe vaut 0.
UnionFind(int n) {
if (n < 0) throw new IllegalArgumentException();
this.link = new int[n];
for (int i = 0; i < n; i++) this.link[i] = i;
this.rank = new int[n];
}
9.2. Réalisation 119
La fonction find calcule le représentant d'un élément i. Elle s'écrit naturellement comme
une fonction récursive. On commence par calculer l'élément p lié à i dans le tableau link.
Si c'est i lui-même, on a terminé et i est le représentant de la classe.
int find(int i) {
if (i < 0 || i >= this.link.length)
throw new ArrayIndexOutOfBoundsException(i);
int p = this.link[i];
if (p == i) return i;
Sinon, on calcule récursivement le représentant r de la classe avec l'appel find(p). Avant
de renvoyer r, on réalise la compression de chemins, c'est-à-dire qu'on lie directement i
à r.
int r = this.find(p);
this.link[i] = r;
return r;
}
Ainsi, la prochaine fois que l'on appellera find sur i, on trouvera r directement. Bien
entendu, il se trouvait peut-être que i était déjà lié à r et dans ce cas l'aectation est
sans eet.
L'opération union regroupe en une seule les classes de deux éléments i et j. On
commence par calculer leurs représentants respectifs ri et rj. S'ils sont égaux, il n'y a
rien à faire.
else {
this.link[rj] = ri;
Dans le cas où les deux classes ont le même rang, l'information de rang doit alors être
mise à jour, car la longueur du plus long chemin est susceptible d'augmenter d'une unité.
if (this.rank[ri] == this.rank[rj])
this.rank[ri]++;
}
}
120 Chapitre 9. Classes disjointes
Il est important de noter que la fonction union utilise la fonction find et réalise donc
des compressions de chemin, même dans le cas où il s'avère que i et j sont déjà dans la
même classe. Le code complet de la classe UnionFind est donné programme 20 page 121.
Complexité. Il est facile de montrer que la complexité de find et union est en O(log n).
Pour cela, on commence par montrer l'invariant suivant : une classe de rang k
a des chemins de longueur au plus k;
possède au moins 2k élément.
On le prouve par récurrence sur le nombre d'opérations union. C'est vrai initialement
car toutes les classes ont le rang 0. Supposons maintenant l'invariant et eectuons une
nouvelle opération union, k . Il y a deux cas de gure. La
donnant une classe de rang
0
nouvelle classe peut être la réunion d'une classe de rang k et d'une classe de rang k < k .
0
Dans ce cas, les nouveaux chemins ont une longueur maximale k + 1 ≤ k . Et par ailleurs
k
la première des deux classes contenait déjà au moins 2 éléments par hypothèse. L'autre
cas de gure correspond à la réunion de deux classes de rang k − 1. Dans ce cas, les
nouveaux chemins ont une longueur au plus k − 1 + 1 = k et la nouvelle classe contient
k−1
au moins 2 + 2k−1 = 2k éléments. Ceci achève la preuve de notre invariant.
Il est clair que la complexité de find est proportionnelle à la longueur du chemin
parcouru. On déduit donc de l'invariant que find a une complexité au plus égale au rang
de la classe. Or l'invariant implique également que toute classe a un rang au plus log n.
Dès lors, la complexité de find est O(log n). En particulier, on est assuré que find ne
fera pas déborder la pile d'appels. La complexité de union est également O(log n), car
union ne fait que deux appels à find puis un nombre constant d'opérations.
La complexité est en réalité bien meilleure. On peut montrer qu'une suite de m opé-
rations find et union réalisées sur une structure contenant n éléments s'exécute en un
temps total O(m α(n,m)), où α est une fonction qui croît extrêmement lentement. Elle
croît si lentement qu'on peut la considérer comme constante pour toute application pra-
tique vues les valeurs de n et m que les limites de mémoire et de temps nous autorisent à
admettre ce qui nous permet de supposer un temps amorti constant pour chaque opéra-
tion. Cette analyse de complexité est subtile et dépasse largement le cadre de ce polycopié.
On en trouvera une version détaillée dans Introduction to Algorithms [5, chap. 22].
Exercice 56. Expliquer comment on peut utiliser la structure union-nd pour décider si
une égalité est conséquence d'autres égalités :
x1 = x7 ∧ x3 = x8 ∧ · · · ⇒ x4 = x17 ?
Solution
Exercice 57. Ajouter à la structure union-nd une méthode int numClasses() donnant
le nombre de classes distinctes. On s'eorcera de fournir cette valeur en temps constant,
en maintenant la valeur comme un champ supplémentaire. Solution
Exercice 58. Si les éléments ne sont pas des entiers consécutifs, on peut remplacer les
deux tableaux rank et link par deux tables de hachage. Réécrire la classe UnionFind en
utilisant cette idée. Solution
Exercice 59. Une autre solution pour réaliser la structure union-nd consiste à ne pas
utiliser de tableaux, mais à représenter directement chaque élément comme un objet
9.2. Réalisation 121
class UnionFind {
UnionFind(int n) {
if (n < 0) throw new IllegalArgumentException();
this.link = new int[n];
for (int i = 0; i < n; i++) this.link[i] = i;
this.rank = new int[n];
}
int find(int i) {
if (i < 0 || i >= this.link.length)
throw new ArrayIndexOutOfBoundsException(i);
int p = this.link[i];
if (p == i) return i;
int r = this.find(p);
this.link[i] = r;
return r;
}
contenant deux champs rank et link. Si E désigne le type des éléments, on peut dénir
la classe générique suivante :
class Elt<E> {
private E value;
private Elt<E> link;
private int rank;
...
}
Il n'est plus nécessaire de maintenir d'information globale sur la structure union-nd, car
chaque élément contient toute l'information nécessaire. (Attention cependant à ne pas
partager une valeur de type Elt<E> entre plusieurs partitions.) L'interface de la classe
Elt est la suivante :
Elt(E x)
Elt<E> find()
void union(Elt<E> e)
Le constructeur Elt(E x) construit une classe contenant un unique élément, de valeur x.
On pourra choisir la convention qu'un pointeur link est null lorsqu'il s'agit d'un repré-
sentant. Écrire ce constructeur ainsi que les méthodes find et union.
Solution
On procède de la manière suivante. On crée une structure union-nd dont les éléments
sont les diérentes cases. L'idée est que deux cases sont dans la même classe si et seulement
si elles sont reliées par un chemin. Initialement, toutes les cases du labyrinthe sont séparées
les unes des autres par des murs. Puis on considère toutes les paires de cases adjacentes
(verticalement et horizontalement) dans un ordre aléatoire. Pour chaque paire (c1 ,c2 ) on
compare les classes des cases c1 et c2 . Si elles sont identiques, on ne fait rien. Sinon, on
supprime le mur qui sépare c1 et c2 et on réunit les deux classes avec union. Écrire un
code qui construit un labyrinthe selon cette méthode.
Indication : pour parcourir toutes les paires de cases adjacentes dans un ordre aléatoire,
le plus simple est de construire un tableau contenant toutes ces paires, puis de le mélanger
aléatoirement en utilisant le mélange de Knuth (exercice 5 page 35).
Justier que, à l'issue de la construction, chaque case est reliée à toute autre case par
un unique chemin. Solution
Troisième partie
Algorithmes élémentaires
10
Arithmétique
jusqu'à ce que v soit nul, et renvoie alors la valeur de u, qui est le pgcd des valeurs initiales
de u et v. Le code est immédiat ; il est donné programme 21 page 125. La terminaison
de cet algorithme est assurée par la décroissance stricte de v et le fait que v reste par
ailleurs positif ou nul. On a en eet l'invariant de boucle évident u,v ≥ 0. La correction
de l'algorithme repose sur le fait que l'instruction (10.2) préserve le plus grand diviseur
commun. Quand on parvient à v = 0 on renvoie alors u c'est-à-dire gcd(u,0), qui est donc
le pgcd des valeurs initiales de u et v .
La complexité de l'algorithme d'Euclide est donnée par le théorème de Lamé, qui
stipule que si l'algorithme eectue s itérations pour u>v>0 alors u ≥ Fs+1 et v ≥ Fs
où (Fn ) est la suite de Fibonacci. On en déduit facilement que s = O(log u). Une analyse
détaillée est donnée dans The Art of Computer Programming [10, sec. 4.5.3].
Exercice 61. L'algorithme d'Euclide que l'on vient de présenter suppose u,v ≥ 0. Si
u ou v est négatif, il peut renvoyer un résultat négatif (l'opération % de Java renvoie
une valeur du même signe que son premier argument). Modier le code de la méthode
gcd pour qu'elle renvoie toujours un résultat positif ou nul, quel que soit le signe de ses
arguments. Solution
Exercice 62. Dans quel cas la méthode gcd peut-elle renvoyer zéro ? Solution
Exercice 63. Le résultat de complexité donné ci-dessus suppose u > v. Montrer que,
dans le cas général, la complexité est O(log(max(u,v))). Solution
jusqu'à ce que v2 = 0, et on renvoie ~u. On appelle cela l'algorithme d'Euclide étendu. Une
traduction littérale de cet algorithme en Java, utilisant des tableaux pour représenter
les vecteurs ~u et ~v , est donnée programme 22 page 126. (L'exercice 64 en propose une
écriture un peu plus ecace.) On se convainc facilement que la troisième composante du
vecteur renvoyé est bien le pgcd de u et v. En eet, si on se focalise sur le calcul de u2
et v2 uniquement, on retrouve les mêmes calculs que ceux eectués dans la méthode gcd
avec les variables u et v, à la seule diérence que u2 mod v2 est maintenant calculé par
u2 − bu2 /v2 cv2 . Pour justier la correction de l'algorithme d'Euclide étendu, on note que
l'invariant suivant est maintenu :
u0 u + u1 v = u2
v0 u + v1 v = v2
10.2. Exponentiation rapide 127
En particulier, la première identité est exactement celle que l'on voulait pour le résultat.
La complexité reste la même que pour la méthode gcd. Le nombre d'opérations eectuées
à chaque tour de boucle est certes supérieur, mais il reste borné et le nombre d'itérations
est exactement le même. La complexité est donc toujours O(log(max(u,v))).
Exercice 64. Modier la méthode extendedGcd pour qu'elle n'alloue pas de tableau
intermédiaire. Solution
Exercice 65. Soient u,v,m trois entiers strictement positifs tels que gcd(v,m) = 1. On
appelle quotient de u par v modulo m tout entier w tel que 0 ≤ w < m et u ≡ vw
(mod m). Écrire une méthode calculant le quotient de u par v modulo m. Solution
Sa traduction en Java, pour x de type int, est immédiate. On peut écrire par exemple le
code donné programme 23 page 127. Il existe de multiples variantes. On peut par exemple
faire un cas particulier pourn = 1 mais ce n'est pas vraiment utile. Voir aussi l'exercice 66.
n
Quoiqu'il en soit, l'idée centrale reste la suivante : pour calculer x , cet algorithme ef-
fectue un nombre de multiplications proportionnel à log(n), ce qui est une amélioration
signicative par rapport à l'algorithme naïf qui eectue exactement n − 1 multiplications.
(On verra plus loin pourquoi on s'intéresse uniquement aux multiplications, et pas aux
n lui-même.) On peut s'en convaincre aisément en montrant
calculs faits par ailleurs sur
par récurrence sur k que, si 2
k−1
≤ n < 2k , alors la méthode exp eectue exactement k
appels récursifs. Chaque appel récursif à exp eectuant une ou deux multiplications, on
en déduit le résultat ci-dessus.
Les applications de cet algorithme sont innombrables, car rien n'impose à x d'être de
type int. Dès lors qu'on dispose d'une unité et d'une opération associative, c'est-à-dire
d'un monoïde M , alors on peut appliquer cet algorithme pour calculer xn avec x ∈ M et
n ∈ N. Donnons un exemple. Les nombres de la suite de Fibonacci (Fn ) vérient l'identité
suivante : n
1 1 Fn+1 Fn
= . (10.3)
1 0 Fn Fn−1
128 Chapitre 10. Arithmétique
Autrement dit, on peut calculer Fn en élevant une matrice 2×2 à la puissance n. Avec l'al-
gorithme d'exponentiation rapide, on peut le faire en O(log n) opérations arithmétiques,
1
ce qui est une amélioration signicative par rapport à un calcul direct en temps linéaire .
Exercice 66. Écrire une variante de la méthode exp qui repose sur les identités suivantes :
x2k = (xk )2
2k+1
x = x(xk )2
Y a-t-il une diérence d'ecacité ? Solution
Exercice 67. Écrire l'exponentiation rapide avec une boucle while. Solution
Exercice 68. Écrire un programme qui calcule Fn en utilisant l'équation (10.3) et l'algo-
rithme d'exponentiation rapide. On écrira une classe minimale pour des matrices 2×2 à
coecients dans int, avec la matrice identité, la multiplication et l'exponentiation rapide.
Solution
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
Puis on détermine le premier entier non encore éliminé. Il s'agit de 2. On élimine alors
tous ses multiples, à savoir ici tous les entiers pairs supérieurs à 2.
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
Puis on recommence. Le prochain entier non éliminé est 3. On élimine donc à leur tour
tous les multiples de 3.
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
On note que certains étaient déjà éliminés (les multiples de 6, en l'occurrence) mais ce
n'est pas grave. Le prochain entier non éliminé est 5. Comme 5 × 5 > 23 le crible est
terminé. En eet, tout multiple de 5, c'est-à-dire k × 5, est soit déjà éliminé si k < 5, soit
au-delà de 23 si k ≥ 5. Les nombres premiers inférieurs ou égaux à N sont alors tous les
nombres qui n'ont pas été éliminés, c'est-à-dire ici 2, 3, 5, 7, 11, 13, 17, 19 et 23.
Écrivons une méthode sieve qui réalise le crible d'Ératosthène à l'aide d'un tableau
de booléens, qui sera renvoyé au nal.
1. Attention cependant à ne pas conclure hâtivement qu'on sait calculer Fn pour de grandes valeurs
de n. Les éléments de la suite de Fibonacci croissent en eet de manière exponentielle. Si on a recours à des
entiers en précision arbitraire, le coût des opérations arithmétiques elles-mêmes doit être pris en compte,
et la complexité ne sera pas O(log n). Et dans le cas contraire, on aura rapidement un débordement
arithmétique.
10.3. Crible d'Ératosthène 129
return prime;
}
Le code complet est donné programme 24 page 129.
Évaluons la complexité du crible d'Ératosthène. La complexité en espace est clairement
O(N ). La complexité en temps nous amène à considérer le coût de chaque itération de la
boucle principale. S'il ne s'agit pas d'un nombre premier, le coût est constant (on ne fait
N
rien). Mais lorsqu'il s'agit d'un nombre premier p, alors la boucle interne à un coût car
p
on considère tous les multiples de p (en fait, un peu moins car on commence l'itération à
p2 , mais cela ne change pas l'asymptotique). Le coût total est donc
XN
N+
p≤N
p
130 Chapitre 10. Arithmétique
où la somme est faite sur les nombres premiers. Un théorème d'Euler nous dit que
1
P
p≤N p ∼ ln(ln(N )) d'où une complexité N ln(ln(N )) pour le crible d'Ératosthène.
Exercice 69. L'entier 2 étant le seul nombre premier pair, on peut optimiser le code de
la méthode sieve en traitant à part le cas des nombres pairs et en progressant de 2 en 2
à partir de 3 dans la boucle principale. Mettre en ÷uvre cette idée. Solution
Exercice 71. Écrire une méthode int[] firstPrimesUpto(int max) qui renvoie un
tableau contenant tous les nombres premiers inférieurs ou égaux à max, dans l'ordre crois-
sant.
Solution
Exercice 72. Écrire une méthode int[] firstNPrimes(int n) qui renvoie un tableau
contenant les n premiers nombres premiers. Indication : si pn désigne le n-ième nombre
premier, on a l'inégalité pn < n log n + n log log n dès que n ≥ 6. Solution
11
Programmation dynamique et
mémoïsation
La programmation dynamique et la mémoïsation sont deux techniques très proches
qui s'appuient sur l'idée naturelle suivante : ne pas recalculer deux fois la même chose.
Illustrons-les avec l'exemple très simple du calcul de la suite de Fibonacci. On rappelle
que cette suite d'entiers (Fn ) est dénie par :
F0 = 0
F1 = 1
Fn = Fn−2 + Fn−1 pour n ≥ 2.
Écrire une méthode récursive qui réalise ce calcul en suivant cette dénition est immédiat.
(On calcule ici avec le type long car les nombres de Fibonacci deviennent rapidement très
grands.)
11.1 Mémoïsation
Puisqu'on a compris qu'on calculait plusieurs fois la même chose, une idée naturelle
consiste à stocker les résultats déjà calculés dans une table. Il s'agit donc d'une table
132 Chapitre 11. Programmation dynamique et mémoïsation
associant à certains entiers i la valeur de Fi . Dès lors, on procède ainsi : pour calculer
fib(n) on regarde si la table possède une entrée pour n. Le cas échéant, on renvoie la valeur
correspondante. Sinon, on calcule fib(n), toujours comme fib(n-2)+fib(n-1), c'est-à-
dire récursivement, puis on ajoute le résultat dans la table, avant de le renvoyer. Cette
technique consistant à utiliser une table pour stocker les résultats déjà calculés s'appelle
la mémoïsation (en anglais memoization, une terminologie forgée par le chercheur Donald
Michie en 1968).
Mettons en ÷uvre cette idée dans une méthode fibMemo. On commence par introduire
une table de hachage pour stocker les résultats déjà calculés
Long l = memo.get(n);
if (l != null) return l;
On utilise ici le fait que la méthode get de la table de hachage renvoie la valeur null
lorsqu'il n'y a pas d'entrée pour la clé donnée. Dans ce cas, justement, on calcule le résultat
exactement comme pour la méthode fib c'est-à-dire avec deux appels récursifs.
memo.put(n, l);
return l;
}
Ceci conclut la méthode fibMemo. Son ecacité est bien meilleure que celle de fib. Le
calcul de F50 , par exemple, est devenu instantané (au lieu de 89 secondes avec fib). On
peut montrer que la complexité de fibMemo est linéaire. Le calcul n'est pas complètement
trivial mais, intuitivement, on comprend que le calcul de Fn n'implique plus maintenant
que le calcul des valeurs de Fi pour i ≤ n une seule fois chacune. Le code complet de
la méthode fibMemo est donné programme 25 page 133. On notera que la table memo est
dénie à l'extérieur de la méthode fibMemo, car elle doit être la même pour tous les appels
récursifs.
11.2. Programmation dynamique 133
// mémoïsation
static HashMap<Integer, Long> memo = new HashMap<Integer, Long>();
static long fibMemo(int n) {
if (n <= 1) return n;
Long l = memo.get(n);
if (l != null) return l;
l = fibMemo(n - 2) + fibMemo(n - 1);
memo.put(n, l);
return l;
}
// programmation dynamique
static long fibDP(int n) {
long[] f = new long[n + 1];
f[1] = 1;
for (int i = 2; i <= n; i++)
f[i] = f[i - 2] + f[i - 1];
return f[n];
}
Une fois le tableau rempli, il ne reste plus qu'à renvoyer la valeur contenue dans sa dernière
case.
return f[n];
}
Ceci conclut la méthode fibDP. Comme pour fibMemo, son ecacité est linéaire (ici cela
se voit facilement) et le calcul de F50 , par exemple, est également instantané. Le code
complet de la méthode fibDP est donné programme 25 page 133.
11.3 Comparaison
Le code des méthodes fibMemo et fibDP peut nous laisser penser que la programmation
dynamique est plus simple à mettre en ÷uvre que la mémoïsation. Sur cet exemple,
c'est vrai. Mais il faut comprendre que, pour écrire fibDP, nous avons exploité deux
informations capitales : le fait de savoir qu'il fallait calculer les Fi pour tous les i ≤ n, et
le fait de savoir qu'on pouvait les calculer dans l'ordre croissant. De manière générale, les
entrées de la fonction à calculer ne sont pas nécessairement des indices consécutifs, ou ne
sont même pas des entiers, et les dépendances entre les diverses valeurs à calculer ne sont
pas nécessairement aussi simples. En pratique, la mémoïsation est plus simple à mettre
en ÷uvre : il sut en eet de rajouter quelques lignes pour consulter et remplir la table
de hachage, sans modier la structure de la méthode.
C(n,0) = 1
C(n,n) = 1
C(n,k) = C(n − 1,k − 1) + C(n − 1,k) pour 0 < k < n.
Pourtant, à y regarder de plus près, le calcul des C(n,k) pour une certaine valeur de n
ne nécessite que les valeurs des C(n − 1,k). Dès lors on peut les calculer pour des valeurs
de n croissantes, sans qu'il soit utile de conserver toutes les valeurs calculées jusque là.
On le visualise mieux en dessinant le triangle de Pascal
11.3. Comparaison 135
1
1 1
1 2 1
1 3 3 1
1 4 6 4 1
1 5 10 10 5 1
.
.
.
et en expliquant que l'on va le calculer ligne à ligne, en ne conservant à chaque fois que
la ligne précédente pour le calcul de la ligne suivante. Mettons cette idée en ÷uvre dans
une méthode cnkSmartDP(int n, int k). On commence par allouer un tableau row de
taille n+1 qui va contenir une ligne du triangle de Pascal.
row[0] = 1;
Cette valeur ne bougera plus car la première colonne du triangle de Pascal ne contient
que des 1. On écrit ensuite une boucle pour calculer la ligne i du triangle de Pascal :
Exercice 73. Modier la méthode cnkSmartDP pour ne pas calculer les valeurs du triangle
de Pascal au-delà de la colonne k. Solution
136 Chapitre 11. Programmation dynamique et mémoïsation
Exercice 74. Modier la méthode cnkSmartDP pour qu'elle renvoie un résultat de type
BigInteger. Il s'agit là d'une classe de la bibliothèque Java représentant des entiers en
précision arbitraire. Attention : la complexité n'est plus O(nk) car les additions ne sont
plus des opérations atomiques ; leur coût dépend de la taille des opérandes, qui grandit
vite dans le triangle de Pascal. Solution
Exercice 75. Modier la méthode fibDP pour qu'elle n'utilise plus de tableau, mais
seulement deux entiers. Solution
On commence par remplir ce dictionnaire avec les sous-ensembles de s qui sont des sin-
gletons, représentés par les entiers de la forme 2 avec 0 ≤ i < s.length.
i
m.putAll(res.get(left));
for (int x: res.get(left).keySet()) {
String ex = res.get(left).get(x);
for (int y: res.get(right).keySet()) {
String ey = res.get(right).get(y);
m.put(x + y, "("+ex+"+"+ey+")");
m.put(x * y, "("+ex+"*"+ey+")");
if (x >= y) m.put(x - y, "("+ex+"-"+ey+")");
if (y != 0 && x % y == 0) m.put(x / y, "("+ex+"/"+ey+")");
}
}
L'intégralité du programme est donnée programme 26 page 138. Avec ce code, il faut
seulement 116 millisecondes pour déterminer les 8684 entiers que l'on peut former à par-
tir de l'ensemble{2,5,7,13,17,23}. On peut notamment construire l'entier 338 et notre
dictionnaire donne la chaîne "((((23+17)+7)+5)*13)/2)" comme solution. Le plus petit
entier que l'on ne peut pas construire est 1182.
Exercice 78. Expliquer comment modier le programme 26 pour calculer, pour chaque
sous-ensemble U de S , les entiers que l'on peut obtenir en utilisant les nombres de U
exactement une fois chacun et non pas au plus une fois. Solution
138 Chapitre 11. Programmation dynamique et mémoïsation
3 1 6 5 9
6 8 7
2
5 3 9
7 9 6 2 1 8
1 8 4
8
3 9 6
5 6 8 4 7
Les sous-groupes 3×3 y sont délimités par des traits gras. On se propose d'utiliser la
technique du rebroussement pour résoudre ce problème.
140 Chapitre 12. Rebroussement (backtracking )
class Sudoku {
private int[] grid = new int[81];
Si i (resp. j ) est un numéro de ligne (resp. de colonne) compris entre 0 et 8, la case (i,j) de
la grille est identiée à l'indice de tableau 9i+j . On se donne trois méthodes pour calculer
respectivement le numéro de ligne, de colonne et de sous-groupe de la case représentée
par l'indice c.
int row(int c) { return c / 9; }
int col(int c) { return c % 9; }
int group(int c) { return 3 * (row(c) / 3) + col(c) / 3; }
Il est en particulier très facile d'en déduire si deux cases c1 et c2 appartiennent à la même
colonne, la même ligne ou le même sous-groupe.
boolean check(int p) {
for (int c = 0; c < 81; c++)
et, pour chacune, on teste l'existence d'un conit avec la case p. Le cas échéant, on le
signale immédiatement.
return true;
}
L'algorithme de rebroussement proprement dit est écrit sous la forme d'une méthode
récursive solve(). Cette méthode cherche une solution pour le problème se trouvant
actuellement dans le tableau grid. Elle renvoie le booléen true dès qu'elle a trouvé une
solution et cette solution est contenue dans le tableau grid. Si en revanche aucune solution
n'existe, elle renvoie false et le contenu de grid est laissé inchangé.
boolean solve() {
La méthode solve cherche la première case vide de la grille, par un simple parcours de
toutes les cases.
12.1. Le problème du Sudoku 141
On note que la méthode solve n'est pas appelée si check renvoie false, car l'opérateur &&
est paresseux.
Si en revanche on sort de la boucle for, c'est que les 9 valeurs possibles ont toutes été
essayées sans succès. Dans ce cas, on restaure la valeur 0 dans la case c, puis on signale
l'échec de la recherche.
this.grid[c] = 0;
return false;
}
Il est important de comprendre que seule la première case vide a été considérée. En eet,
si une solution existe, alors la valeur correspondante de la case c aurait dû mener à cette
solution. Il est donc inutile de considérer les autres cases vides. Ce serait une perte de
temps considérable.
Si en revanche on sort de la boucle for, c'est que la grille ne contient aucune case
vide. Dans ce cas, on signale le succès.
return true;
}
Le code complet est donné programme 27 page 142. Un tel code résout un problème de
Sudoku en quelques centièmes de seconde.
Exercice 80. Ajouter une méthode print à la classe Sudoku pour imprimer le contenu
de la grille. Solution
142 Chapitre 12. Rebroussement (backtracking )
class Sudoku {
// vérifie que la valeur dans la case p est compatible avec les autres cases
boolean check(int p) {
for (int c = 0; c < 81; c++)
if (c != p && sameZone(p, c) && this.grid[p] == this.grid[c])
return false;
return true;
}
q
q
q
q
q
q
q
q
On va procéder de façon relativement brutale, par exploration de toutes les possibilités.
On fait cependant preuve d'un peu d'intelligence en remarquant qu'une solution comporte
nécessairement une et une seule reine sur chaque ligne de l'échiquier. Du coup, on va
chercher à remplir l'échiquier ligne par ligne, en positionnant à chaque fois une reine sans
qu'elle soit en prise avec les reines déjà posées. Ainsi, si on a déjà posé trois reines sur les
trois premières lignes de l'échiquier, alors on en vient à chercher une position valide sur
la quatrième ligne :
q
q
q
? ? ? ? ? ? ? ?
Si on en trouve une, alors on place une reine à cet endroit et on poursuit l'exploration
avec la ligne suivante. Sinon, on fait machine arrière sur l'un des choix précédents, et
on recommence. Si on parvient à remplir la dernière ligne, on a trouvé une solution. En
procédant ainsi de manière systématique, on ne ratera pas de solution.
Écrivons une méthode int[] findSolution(int n) qui met en ÷uvre cette technique
et renvoie la solution trouvée, le cas échéant, ou lève une exception pour signaler l'absence
de solution. On commence par allouer un tableau cols qui contiendra, pour chaque ligne
de l'échiquier, la colonne où se situe la reine placée sur cette ligne.
}
return false;
}
Il nous reste à écrire la méthode check qui vérie que le choix qui vient juste d'être fait
pour la ligne r est cohérent avec les choix précédents. C'est une simple boucle sur les r
premières lignes.
return true;
}
Le code complet est donné programme 28 page 145. Il est important de bien comprendre
que ce programme s'interrompt à la première solution trouvée. C'est le rôle du second
return true placé au milieu de la boucle de la méthode findSolutionRec.
Exercice 81. Modier le programme précédent pour qu'il dénombre toutes les solutions.
Les solutions ne seront pas renvoyées, mais seulement leur nombre total. Pour 1 ≤ N ≤ 9,
on doit obtenir les valeurs 1, 0, 0, 2, 10, 4, 40, 92, 352. Solution
12.2. Le problème des N reines 145
L'opérateur & de Java est le ET bit à bit et l'opérateur le NON bit à bit. En termes
ensemblistes, on vient de calculer (a\b)\c.
Sur la base de cette idée, écrivons une méthode récursive countSolutionsRec qui
prend justement en arguments les trois entiers a, b et c.
static int countSolutionsRec(int a, int b, int c) {
L'entier a désigne les colonnes restant à pourvoir, l'entier b (resp. c) les colonnes interdites
car en prise sur une diagonale ascendante (resp. descendante). La méthode renvoie le
nombre de solutions qui sont compatibles avec ces arguments. La recherche parvient à son
terme lorsque a devient vide, c'est-à-dire 0. On signale alors la découverte d'une solution.
if (a == 0) return 1;
Dans le cas contraire, on calcule les colonnes à considérer avec l'expression a & b & c,
comme expliqué ci-dessus, dans une variable e. On initialise également une variable f pour
tenir le compte des solutions.
while (e != 0) {
Il existe une astuce arithmétique qui nous permet d'extraire exactement un bit d'un entier
non nul. Elle exploite la représentation en complément à deux des entiers, en combinant
le ET bit à bit et la négation (entière) :
e -= d;
}
Une fois sorti de la boucle, il n'y a plus qu'à renvoyer la valeur de f.
return f;
}
148 Chapitre 12. Rebroussement (backtracking )
0 i-1
. . . déjà trié . . . a[i] . . . à trier . . .
Pour insérer l'élément a[i] à la bonne place, on utilise une seconde boucle qui décale vers
la droite les éléments tant qu'ils sont supérieurs à a[i].
int v = a[i], j = i;
for (; 0 < j && v < a[j-1]; j--)
a[j] = a[j-1];
Une fois sorti de la boucle, il reste à positionner a[i] à sa place, c'est-à-dire à la position
donnée par j :
a[j] = v;
}
Le code complet est donné programme 30 page 150.
l m i r
p <p ≥p ?
L'indice i de la boucle dénote le prochain élément à considérer et l'indice m partitionne
la portion déjà parcourue.
int m = l;
for (int i = l + 1; i < r; i++)
Si a[i] est supérieur ou égal à p, il n'y a rien à faire. Dans le cas contraire, pour conserver
l'invariant de boucle, il sut d'incrémenter m et d'échanger a[i] et a[m].
if (a[i] < p)
swap(a, i, ++m);
(Le code de la méthode swap est donné page 152.) Une fois sorti de la boucle, on met le
pivot à sa place, c'est-à-dire à la position m, et on renvoie cet indice.
swap(a, l, m);
return m;
On peut bien entendu se dispenser de l'appel à swap lorsque l = m, mais cela ne change
rien fondamentalement. On écrit alors la partie récursive du tri rapide sous la forme
d'une méthode quickrec qui prend les mêmes arguments que la méthode partition. Si
l ≥ r-1, il y a au plus un élément à trier et il n'y a donc rien à faire.
quickrec(a, l, m);
quickrec(a, m + 1, r);
ce qui achève la méthode quickrec. Pour trier un tableau, il sut d'appeler quickrec
sur la totalité de ses éléments.
Le pire des cas correspond à K = 0, ce qui donne C(N ) = N − 1 + C(N − 1), d'où
2
C(N ) ∼ N2 . Le meilleur des cas correspond à une portion coupée en deux moitiés égales,
c'est-à-dire K = N/2. On en déduit facilement C(N ) ∼ N log N . Pour le nombre de
comparaisons en moyenne, on considère que les N places nales possibles pour le pivot
sont équiprobables, ce qui donne
1 X
C(N ) = N − 1 + C(K) + C(N − 1 − K)
N 0≤K≤N −1
2 X
= N −1+ C(K).
N 0≤K≤N −1
C(N ) C(N − 1) 2 2
= + − .
N +1 N N + 1 N (N + 1)
C(N )
∼ 2 log N
N +1
et donc que C(N ) ∼ 2N log N .
En ce qui concerne le nombre d'aectations, on note que la méthode partition eectue
autant d'appels à swap que d'incrémentations de m. Le meilleur des cas est atteint lorsque
le pivot est toujours à sa place. Il n'y a alors aucune aectation. Il est important de
noter que ce cas ne correspond pas à la meilleure complexité en termes de comparaisons
(qui est alors quadratique). En moyenne, toutes les positions nales pour le pivot étant
équiprobables, on a donc moins de r − l + 1 aectations (chaque appel à swap réalise deux
aectations), d'où un calcul analogue au nombre moyen de comparaisons. Dans le pire
des cas, le pivot se retrouve toujours à la position r-1. La méthode partition eectue
alors2(r − l) aectations, d'où un total de N 2 aectations. Au nal, on obtient donc les
résultats suivants :
tableau avant de commencer le tri rapide. L'exercice 5 propose justement une méthode
très simple pour eectuer un tel mélange.
Toutefois, si les valeurs du tableau sont toutes identiques, cela ne sura pas. En eet,
le pivot se retrouvera à une extrémité de l'intervalle et on aura toujours une complexité
quadratique. La solution à ce problème consiste en une méthode partition un peu plus
subtile, proposée dans l'exercice 82 ci-dessous. Avec ces deux améliorations, on peut consi-
dérer en pratique que le tri rapide est toujours en O(N log N ).
Comme on le voit, réaliser un tri rapide en prenant soin d'éviter un pire cas quadratique
n'est pas si facile que cela. Dans les sections suivantes, nous présentons deux autres tris,
le tri par tas et le tri fusion, qui ont tous les deux une complexité O(N log N ) dans le pire
des cas tout en étant plus simples à réaliser. Néanmoins, le tri rapide est souvent préféré
en pratique, car meilleur en temps que le tri par tas et meilleur en espace que le tri fusion.
Exercice 82. Modier la méthode partition pour qu'elle sépare les éléments strictement
plus petits que le pivot (à gauche), les éléments égaux au pivot (au milieu) et les éléments
strictement plus grands que le pivot (à droite). Au lieu de deux indices m et i découpant le
segment de tableau en trois parties, comme illustré sur la gure page 151, on utilisera trois
indices découpant le segment de tableau en quatre parties. (Un tel découpage en trois est
aussi l'objet de l'exercice 89 plus loin.) La nouvelle méthode partition doit maintenant
renvoyer deux indices. Modier la méthode quick_rec en conséquence. Solution
Exercice 83. Pour éviter le débordement de pile potentiel de la méthode quickrec, une
idée consiste à eectuer d'abord l'appel récursif sur la plus petite des deux portions, puis
à remplacer le second appel récursif par une boucle while en modiant la valeur de l
ou r en conséquence. Montrer que la taille de pile est alors logarithmique dans le pire des
cas. Solution
Exercice 84. Une idée classique pour accélérer un algorithme de tri consiste à eectuer
un tri par insertion quand le nombre d'éléments à trier est petit, i.e. devient inférieur à une
constante xée à l'avance (par exemple 5). Modier le tri rapide pour prendre en compte
cette idée. On pourra reprendre la méthode insertionSort de la section précédente
(gure 30) et la généraliser en lui passant deux indices l et r pour délimiter la portion
du tableau à trier. Solution
a1[m..r[ sont supposées triées. L'objectif est de les fusionner dans a2[l..r[. Pour cela, on va
parcourir les deux portions de a1 avec deux variables i et j et la portion de a2 à remplir
avec une boucle for.
l m r
a1 trié trié
↑i ↑j
a2 trié
↑k
Il faut alors déterminer la prochaine valeur à placer en a2[k]. Il s'agit de la plus petite
des deux valeurs a1[i] et a1[j]. Il convient cependant de traiter correctement le cas où
il n'y a plus d'élément dans l'une des deux moitiés. On détermine si l'élément doit être
pris dans la moitié gauche avec le test suivant :
car ils sont tous plus petits que ceux de l'autre portion. Dans ce cas f (N ) = N/2 et donc
C(N ) ∼ 12 N log N . Dans le pire des cas, tous les éléments sont examinés par merge et
donc f (N ) = N − 1, d'où C(N ) ∼ N log N . L'analyse en moyenne est plus subtile (voir
[11, ex 2 p. 646]) et donne f (N ) = N − 2 + o(1), d'où C(N ) ∼ N log N également.
Le nombre d'aectations est le même dans tous les cas : N aectations dans la mé-
thode merge (chaque élément est copié de a1 vers a2) et N aectations eectuées par
mergesortrec. Si on note A(N ) le nombre total d'aectations pour mergesort, on a
donc
A(N ) = 2A(N/2) + 2N,
d'où un total de 2N log N aectations.
Exercice 86. Le tri fusion est une bonne méthode pour trier des listes. Supposons par
exemple que l'on souhaite trier des listes de type LinkedList<Integer>. Écrire tout
d'abord une méthode split qui prend en arguments trois listes l1, l2 et l3 et met la
moitié des éléments de l1 dans l2 et l'autre moitié dans l3 (par exemple un élément
sur deux). La méthode split ne doit pas modier la liste l1. Écrire ensuite une méthode
merge qui prend en arguments deux listes l1 et l2, supposées triées, et renvoie la fusion de
ces deux listes. Elle peut vider ses deux arguments de leurs éléments. Écrire une méthode
récursive mergesort qui prend une liste en argument et renvoie une nouvelle liste triée
contenant les mêmes éléments. Elle ne doit pas modier son argument. Solution
Exercice 87. Le tri fusion permet également de trier des listes en place, c'est-à-dire
sans aucune allocation supplémentaire, dès lors que le contenu et la structure des listes
peuvent être modiés. Considérons par exemple les listes d'entiers du type Singly de
la section 4.1. Écrire tout d'abord une méthode Singly split(Singly l) qui coupe la
liste l en son milieu, c'est-à-dire remplace le champ next qui relie la première moitié
à la seconde moitié par null et renvoie le premier élément de la seconde moitié. On
pourra supposer que la liste l contient au moins deux éléments. Écrire ensuite une mé-
thode Singly merge(Singly l1, Singly l2) qui fusionne deux listes l1 et l2, suppo-
sées triées, et renvoie le premier élément du résultat. La méthode merge doit procéder en
place, sans allouer de nouvel objet de la classe Singly. On pourra commencer par écrire la
méthode merge récursivement puis on en fera une version itérative avec une boucle while,
an d'éviter tout débordement de pile. Écrire enn une méthode récursive mergesort qui
prend une liste en argument et la trie en place. Expliquer pourquoi la méthode mergesort
ne peut pas provoquer de débordement de pile. Solution
158 Chapitre 13. Tri
0 k k+1 n
? tas en construction
0 k n
tas ≤ trié
c'est-à-dire un tas dans la portion a[0..k[, dont tous les éléments sont plus petits que ceux
de la partie a[k..n[, qui est triée.
Les deux étapes de l'algorithme ci-dessus utilisent la même opération consistant à faire
descendre une valeur jusqu'à sa place dans un tas. On la réalise à l'aide d'une méthode
récursive moveDown qui prend en arguments le tableau a, un indice k, une valeur v et une
limite n sur les indices.
On fait l'hypothèse qu'on a déjà un tas h12k+1 dès lors que 2k+1 < n, et
enraciné en
de même un tas h2 enraciné en 2k+2 dès lors que 2k+2 < n. L'objectif est de construire
un tas enraciné en k, contenant v et tous les éléments de h1 et h2 . On commence par
déterminer si le tas enraciné en k est réduit à une feuille, c'est-à-dire si le tas h1 n'existe
pas. Si c'est le cas, on aecte la valeur v à a[k] et on a terminé.
13.4. Tri par tas 159
int r = 2 * k + 1;
if (r >= n)
a[k] = v;
else {
if (r + 1 < n && a[r] < a[r + 1]) r++;
Si la valeur v est supérieure ou égale à a[r], la descente est terminée et il sut d'aecter
v à a[k].
if (a[r] <= v)
a[k] = v;
else {
a[k] = a[r];
moveDown(a, r, v, n);
}
Ceci achève la méthode moveDown. La méthode de tri proprement dite prend un tableau
a en argument
Elle commence par construire le tas de bas en haut par des appels à moveDown. On évite
les appels inutiles sur des tas réduits à des feuilles en commençant la boucle à b n2 c − 1 (en
eet, pour tout indice k strictement supérieur, on a 2k+1 ≥ n).
Une fois le tas entièrement construit, on en extrait les éléments un par un dans l'ordre
décroissant. Comme expliqué ci-dessus, pour chaque indice k, on échange a[0] avec la
valeur v en a[k] puis on fait descendre v à sa place.
On note que la spécication de moveDown nous permet d'éviter d'aecter v en a[0] avant
d'entamer le descente. Le code complet est donné programme 33 page 160.
160 Chapitre 13. Tri
Exercice 89. (Le drapeau hollandais de Dijkstra) Écrire une méthode qui trie en place
un tableau contenant des valeurs représentant les trois couleurs du drapeau hollandais, à
savoir
Exercice 90. Plus généralement, on considère le cas d'un tableau contenant k valeurs
0, . . . ,k − 1. Écrire une
distinctes. Pour simplier, on suppose qu'il s'agit des entiers
méthode qui trie un tel tableau en place en temps O(max(k,N )) où N est la taille du
tableau. Solution
14
Compression de données
La compression de données consiste à tenter de réduire l'espace occupé par une infor-
mation. On l'utilise quotidiennement, par exemple en téléchargeant des chiers ou encore
sans le savoir en utilisant des logiciels qui compressent des données pour économiser les
ressources. L'exemple typique est celui des formats d'image et de vidéo qui sont le plus
souvent compressés. Ce chapitre illustre la compression de données avec un algorithme
simple, à savoir l'algorithme de Human [7]. Il va notamment nous permettre de mettre
en pratique les arbres de préxes (section 6.4) et les les de priorité (chapitre 8).
i
s
m p
164 Chapitre 14. Compression de données
Il sut alors d'associer à chaque caractère le chemin qui l'atteint depuis la racine, un 0
dénotant une descente vers la gauche et un 1 une descente vers la droite. Par construction,
un tel code est un code préxe. On a déjà croisé une telle représentation avec les arbres
préxes dans la section 6.4, même si le problème n'était pas posé en ces termes.
L'algorithme de Human permet de construire, étant donné un nombre d'occurrences
pour chacun des caractères, un arbre ayant la propriété d'être le meilleur possible pour
cette distribution (dans un sens qui sera expliqué plus loin). La fréquence des caractères
peut être calculée avec une première passe ou donnée à l'avance s'il s'agit par exemple
d'un texte écrit dans un langage pour lequel on connaît la distribution statistique des
caractères. Si on reprend l'exemple de la chaîne "mississippi", les nombres d'occurrences
des caractères sont les suivantes :
L'algorithme de Human procède alors ainsi. Il sélectionne les deux caractères avec les
nombres d'occurrences les plus faibles, à savoir ici les caractères 'm' et 'p', et les réunit
en un arbre auquel il donne un nombre d'occurrences égal à la somme des nombres d'oc-
currences des deux caractères. On a donc la situation suivante :
Puis on recommence avec ces trois arbres , c'est-à-dire qu'on en sélectionne deux ayant
les occurrences les plus faibles, ici 3 et 4, et on les réunit en un nouvel arbre, ce qui donne
par exemple ceci :
i(4) (7)
(3) s(4)
m(1) p(2)
(11)
i(4) (7)
(3) s(4)
m(1) p(2)
C'est l'arbre que nous avions proposé initialement. Il se trouve qu'il est optimal, pour un
sens que nous donnons maintenant.
X
S= fi × di
i
somme S est strictement plus petite que celle obtenue avec l'algorithme de Human. On
choisit un tel arbre T qui minimise le nombre n de caractères. Sans perte de généralité,
supposons que c0 et c1 sont les deux caractères choisis initialement par l'algorithme de
Human, c'est-à-dire deux caractères avec les fréquences les plus basses. On peut supposer
que ces deux caractères sont des feuilles de T, car on n'augmente pas la somme S en les
échangeant avec des feuilles. De même, on peut supposer que ce sont deux feuilles d'un
même n÷ud, car on peut toujours les échanger avec d'autres feuilles. Si on remplace alors
ce n÷ud par une feuille de fréquence f0 +f1 , la somme S diminue de f0 +f1 . En particulier,
cette diminution ne dépend pas de la profondeur du n÷ud. Du coup, on vient de trouver
un arbre meilleur que celui donné par l'algorithme de Human pour n−1 caractères, ce
qui est une contradiction.
14.2 Réalisation
On commence par introduire des classes pour représenter les arbres de préxes utili-
sés dans l'algorithme de Human. Qu'il s'agisse d'une feuille désignant un caractère ou
d'un n÷ud interne, tout arbre contient un nombre d'occurrences qui lui permettra d'être
comparé à un autre arbre. On introduit donc une classe abstraite HuffmanTree pour
représenter un arbre, quelle que soit sa nature.
Le nombre d'occurrences est stocké dans le champ freq. Cette classe implémente l'inter-
faceComparable et sa méthode compareTo compare les valeurs stockées dans le champ
freq. Une feuille est représentée par une sous-classe Leaf dont le champ c contient le
caractère qu'elle désigne.
this.right = right;
this.freq = left.freq + right.freq;
}
}
Le constructeur calcule le nombre d'occurrences, là encore hérité de la classe HuffmanTree,
comme la somme des nombres d'occurrences des deux sous-arbres.
Écrivons maintenant le code de l'algorithme de Human dans une classe Huffman.
Cette classe contient l'arbre de préxes dans un champ tree et le code associé à chaque
caractère dans un second champ code, sous la forme d'une table.
class Huffman {
private HuffmanTree tree;
private Map<Character, String> codes;
On va se contenter ici de construire des messages encodés sous la forme de chaînes de
caractères '0' et '1' ; en pratique il s'agirait de bits. C'est pourquoi la table codes
associe de simples chaînes aux caractères de l'alphabet.
On suppose que les fréquences d'apparition des diérents caractères sont données ini-
tialement, sous la forme d'une collection de feuilles, c'est-à-dire d'une valeur alphabet de
type Collection<Leaf>. L'exercice 92 propose le calcul de ces fréquences. Le constructeur
prend alors la forme suivante :
Huffman(Collection<Leaf> alphabet) {
if (alphabet.size() <= 1) throw new IllegalArgumentException();
this.tree = buildTree(alphabet);
this.codes = new HashMap<Character, String>();
this.tree.traverse("", this.codes);
}
La méthode buildTree construit l'arbre de préxes à partir de l'alphabet donné et la
méthode traverse le parcourt pour remplir la table codes.
Commençons par le code de la méthode buildTree. Pour suivre l'algorithme présenté
dans la section précédente, qui sélectionne à chaque fois les deux arbres les plus petits, on
utilise une le de priorité. Ce peut être la classe Heap présentée au chapitre 8 ou encore
la classe java.util.PriorityQueue de la bibliothèque Java.
Lorsqu'on sort de la boucle, la le ne contient plus qu'un seul arbre, qui est le résultat
renvoyé.
return pq.getMin();
}
Une fois l'arbre construit, on peut remplir la table codes. Il sut pour cela de parcourir
l'arbre, en maintenant le chemin depuis la racine, et de remplir la table chaque fois qu'on
atteint une feuille. Écrivons pour cela une méthode traverse dans la classe HuffmanTree.
Elle prend en arguments le chemin prefix, sous la forme d'une chaîne de caractères, et
une table m à remplir.
On dénit ensuite cette méthode dans les deux sous-classes. Dans la classe Leaf, il sut
de remplir la table m en associant la chaîne prefix au caractère représenté par la feuille.
Dans la classe Node, il s'agit de descendre récursivement dans les deux sous-arbres gauche
et droit, en mettant à jour le chemin à chaque fois.
Encodage et décodage. Il reste à expliquer comment écrire les deux méthodes qui en-
codent et décodent chacune respectivement un texte donné. Commençons par la méthode
d'encodage. On utilise un StringBuffer pour construire le résultat (voir page 44).
168 Chapitre 14. Compression de données
int i = 0;
while (i < msg.length()) {
Pour décoder un caractère, il faut descendre dans l'arbre this.tree en suivant le chemin
désigné par les 0 et les 1 du message, jusqu'à atteindre une feuille. Une solution simple
consiste à écrire une méthode find dans la classe HuffmanTree pour cela, qui prend en
arguments le message msg et la position i, et renvoie le caractère obtenu. On l'ajoute
alors à la chaîne décodée.
i += this.codes.get(c).length();
}
Une autre solution aurait été de faire renvoyer cette longueur par la méthode find mais il
n'est pas aisé de renvoyer deux résultats. Une fois sorti de la boucle, on renvoie la chaîne
construite.
return sb.toString();
}
Il reste à écrire le code de la méthode find qui descend dans l'arbre. Comme pour
la méthodetraverse plus haut, on commence par la déclarer dans la classe abstraite
HuffmanTree
14.2. Réalisation 169
Collection<Leaf> buildAlphabet(String s)
qui calcule les nombres d'occurrences des diérents caractères d'une chaîne s et les renvoie
sous la forme d'une collection de feuilles. Indication : on pourra utiliser une table de
hachage associant des feuilles à des caractères. Une fois cette table remplie, sa méthode
values() permet de renvoyer la collection de feuilles directement. Solution
170 Chapitre 14. Compression de données
class Huffman {
private HuffmanTree tree;
private Map<Character, String> codes;
Huffman(Collection<Leaf> alphabet) {
if (alphabet.size() <= 1) throw new IllegalArgumentException();
this.tree = buildTree(alphabet);
this.codes = new HashMap<Character, String>();
this.tree.traverse("", this.codes);
}
Graphes
15
Dénition et représentation
Plus formellement, un tel graphe est la donnée d'un ensemble V de sommets et d'un
ensemble E d'arêtes, qui sont des paires de sommets. Si {x,y} ∈ E , on dit que les sommets
x et y sont adjacents et on note x − y . Cette relation d'adjacence étant symétrique, on
parle de graphe non orienté.
On peut également dénir la notion de graphe orienté en choisissant pour E un en-
semble de couples de sommets plutôt que de paires. On parle alors d'arcs plutôt que
1
d'arêtes . Si (x,y) ∈ E on dit que y est un successeur de x et on note x → y . Voici un
exemple de graphe orienté :
Un arc d'un sommet vers lui-même, comme sur cet exemple, est appelé une boucle . Le
degré entrant (resp. sortant) d'un sommet est le nombre d'arcs qui pointent vers ce sommet
(resp. qui sortent de ce sommet).
Les sommets comme les arcs peuvent porter une information ; on parle alors de graphe
étiqueté . Voici un exemple de graphe orienté étiqueté :
1. Dans la suite, on utilisera systématiquement le terme d'arc, y compris pour des graphes non orientés.
176 Chapitre 15. Dénition et représentation
γ
α
x y
β δ
z σ
t
Il est important de noter que l'étiquette d'un sommet n'est pas la même chose que le som-
met lui-même. En particulier, deux sommets peuvent porter la même étiquette. Formel-
lement, un graphe étiqueté est donc la donnée supplémentaire de deux fonctions donnant
respectivement l'étiquette d'un sommet de V et l'étiquette d'un arc de E.
Un chemin du sommet u au sommet v est une séquence x0 , . . . ,xn de sommets tels que
x0 = u, xn = v et xi → xi+1 pour 0 ≤ i < n. Un tel chemin est de longueur n (il contient
n arcs).
Exercice 93. Modier les matrices d'adjacence pour des graphes où les arcs sont étiquetés
(par exemple par des entiers). Solution
Exercice 94. Le plus simple pour représenter des graphes non orientés est de conserver
la même structure que pour des graphes orientés, mais en maintenant l'invariant que
pour chaque arc a→b on a également l'arc b → a. Modier les opérations addEdge et
removeEdge des matrices d'adjacence en conséquence. Solution
15.1. Matrice d'adjacence 177
class AdjMatrix {
AdjMatrix(int n) {
this.n = n;
this.m = new boolean[n][n];
}
Exercice 96. Ajouter une méthode int nbEdges() donnant le nombre d'arcs en temps
constant. Indication : maintenir le nombre d'arcs dans la structure de graphe, en mettant
à jour sa valeur dans addEdge et removeEdge. Solution
Exercice 97. Modier les listes d'adjacence pour des graphes où les arcs sont étiquetés
(par exemple par des entiers). On pourra remplacer l'ensemble des successeurs du sommet
x par un dictionnaire (HashMap) donnant pour chaque successeur y l'étiquette de l'arc
x → y. Solution
Exercice 98. Le plus simple pour représenter des graphes non orientés est de conserver
la même structure que pour des graphes orientés, mais en maintenant l'invariant que
pour chaque arc a→b on a également l'arc b → a. Modier les opérations addEdge et
removeEdge des listes d'adjacence en conséquence. Solution
class AdjList {
AdjList() {
this.adj = new HashMap<Integer, Set<Integer>>();
}
void addVertex(int x) {
Set<Integer> s = this.adj.get(x);
if (s == null) this.adj.put(x, new HashSet<Integer>());
}
}
180 Chapitre 15. Dénition et représentation
class Graph<V> {
private Map<V, Set<V>> adj;
Si la réalisation utilise les classes HashMap et HashSet de la bibliothèque standard, on
suppose donc que la classe V redénit correctement les méthodes hashCode et equals.
Exercice 99. Écrire la version générique du programme 37 page 179. Solution
Pour les algorithmes sur les graphes que nous allons écrire dans le chapitre suivant,
il est nécessaire de pouvoir accéder à l'ensemble des sommets du graphe d'une part et à
l'ensemble des successeurs d'un sommet donné d'autre part. Plutôt que d'exposer la table
de hachage qui contient la relation d'adjacence (ci-dessus on l'a d'ailleurs déclarée comme
privée), il sut d'exporter les deux méthodes suivantes :
Set<V> vertices() {
return this.adj.keySet();
}
Set<V> successors(V v) {
return this.adj.get(v);
}
Dès lors, pour parcourir tous les sommets d'un graphe g, il sut d'écrire
0 1 2 3
(16.1)
4 5 6 7
Quel que soit son fonctionnement, un parcours qui démarrerait du sommet 4 parviendra
à un moment où à un autre au sommet 5 et il ne doit pas entrer alors dans un boucle
innie du fait de la présence du cycle 5 − 2 − 6. Dans le cas d'un arbre, un tel cycle n'est
pas possible et nous avons pu écrire le parcours inxe d'un arbre assez facilement (voir
page 77). L'arbre vide null était la condition d'arrêt du parcours récursif. Dans le cas
d'une liste chaînée, nous avons évoqué la possibilité de listes cycliques (section 4.4). Nous
avons certes donné un algorithme pour détecter un tel cycle (l'algorithme du lièvre et de
la tortue, page 59), mais il exploite de façon cruciale le fait que chaque élément de la liste
ne possède qu'au plus un successeur. Dans le cas d'un graphe, ce n'est plus vrai.
Nous allons donc devoir marquer les sommets atteints par le parcours, d'une façon
ou d'une autre. Nous utiliserons ici une table de hachage. Une autre solution consiste à
modier directement les sommets, si le type V le permet.
01 10 22 33
42 51 62 74
Pour mettre en ÷uvre ce parcours, on va utiliser une table de hachage. Elle contiendra les
sommets déjà atteints par le parcours, en leur associant de plus la distance à la source.
On renverra cette table comme résultat du parcours. On écrit donc une méthode statique
avec le type suivant :
La table, appelée ici visited, est créée au tout début de la méthode et on y met initia-
lement la source, avec la distance 0.
Le parcours proprement dit repose sur l'utilisation d'une le, dans laquelle les sommets
vont être insérés au fur et à mesure de leur découverte. L'idée est que la le contient,
à chaque instant, des sommets situés à distance d de la source, suivis de sommets à
distance d+1 :
Un autre invariant important est que tout sommet présent dans la le est également
présent dans la table visited. On procède alors à une boucle, tant que la le n'est pas
vide. Le cas échéant, on extrait le premier élément v de la le et on récupère sa distance d
à la source dans visited.
while (!q.isEmpty()) {
V v = q.poll();
int d = visited.get(v);
On examine alors chaque successeur w de v.
for (V w : g.successors(v))
S'il n'avait pas encore été découvert, c'est-à-dire s'il n'était pas dans la table visited,
alors on l'ajoute dans la le d'une part, et dans la table visited avec la distance d+1
d'autre part.
if (!visited.containsKey(w)) {
q.add(w);
visited.put(w, d+1);
}
Ceci achève la boucle sur les successeurs de v. On passe alors à l'élément suivant de la
le, et ainsi de suite. Une fois sorti de la boucle principale, le parcours est achevé et on
renvoie la table visited.
}
return visited;
}
Le code complet est donné programme 38 page 184. Il s'applique aussi bien à un graphe non
orienté qu'à un graphe orienté. La complexité est facile à déterminer. Chaque sommet
est mis dans la le au plus une fois et donc examiné au plus une fois. Chaque arc est
donc considéré au plus une fois, lorsque son origine est examinée. La complexité est donc
O(V + E), ce qui est optimal. La complexité en espace est O(V ) car la le, comme la
table de hachage, peut contenir (presque) tous les sommets dans le pire des cas.
On note qu'il peut rester des sommets non atteints par le parcours en largeur. Ce sont
les sommets v pour lesquels il n'existe pas de chemin entre la source et v. Sur le graphe
suivant, en partant de la source 1, seuls les sommets 1, 0, 3 et 4 seront atteints.
01 10 2
32 41 5
Dit autrement, le parcours en largeur détermine l'ensemble des sommets accessibles depuis
la source, et donne même pour chacun la distance minimale en nombre d'arcs depuis la
source.
Comme on l'a fait remarquer plus haut, la le a une structure bien particulière, avec
des sommets à distance d, suivis de sommets à distance d + 1. On comprend donc que la
184 Chapitre 16. Algorithmes élémentaires sur les graphes
class BFS {
structure de le n'est pas vraiment nécessaire. Deux sacs susent, l'un contenant les
sommets à distance d et l'autre les sommets à distance d + 1. On peut les matérialiser par
exemple par des listes. Lorsque le sac d vient à s'épuiser, on le remplit avec le contenu du
sac d + 1, qui est lui-même vidé. (On les échange, c'est plus simple.) Cela ne change en
rien la complexité.
Exercice 100. Modier la méthode bfs pour conserver le chemin entre la source et
chaque sommet atteint par le parcours. Une façon simple de procéder consiste à stocker,
pour chaque sommet atteint, le sommet qui a permis de l'atteindre, par exemple dans
une table de hachage. Le chemin est donc décrit à l'envers , du sommet atteint vers la
source. Solution
Exercice 101. En s'inspirant du parcours en largeur d'un graphe, écrire une méthode
qui parcourt les n÷uds d'un arbre en largeur. Solution
0 1 2
3 4 5
et d'un parcours en profondeur qui démarre du sommet 2. Deux arcs sortent de ce sommet,
2→4 et 2→5 et on choisit (arbitrairement) de considérer en premier l'arc 2 → 5. On
passe donc au sommet 5. Aucun arc ne sort de 5 ; c'est une impasse. On revient alors
au sommet 2, dont on considère maintenant le second arc sortant, 2 → 4. De 4, on ne
peut que suivre l'arc 4→3 puis, de même, de 3 on ne peut que suivre l'arc 3 → 1. Du
sommet 1 sortent deux arcs, 1→0 et 1 → 4. On choisit de suivre en premier lieu l'arc
1 → 4. Il mène à un sommet déjà visité, et on fait donc machine arrière. De retour sur 1,
on considère l'autre arc, 1 → 0, qui nous mène à 0. De là le seul arc sortant mène à 3,
là encore déjà visité. On revient donc à 0, puis à 1, puis à 3, puis à 4, puis enn à 2. Le
parcours est terminé. Si on redessine le graphe avec l'ordre de découverte des sommets en
exposant, on obtient ceci :
05 14 20
33 42 51
class DFS<V> {
private final Graph<V> g;
private final HashMap<V, Integer> visited;
private int count;
DFS(Graph<V> g) {
this.g = g;
this.visited = new HashMap<V, Integer>();
this.count = 0;
}
Le champ count est le compteur qui nous servira à associer, dans la table visited,
chaque sommet avec l'instant de sa découverte. Le parcours en profondeur proprement
dit est alors écrit dans une méthode récursive dfs prenant un sommet v en argument.
Son code est d'une simplicité enfantine :
void dfs(V v) {
if (this.visited.containsKey(v)) return;
this.visited.put(v, this.count++);
186 Chapitre 16. Algorithmes élémentaires sur les graphes
for (V w : this.g.successors(v))
dfs(w);
}
Si le sommet v a déjà été atteint, on ne fait rien. Sinon, on le marque comme déjà atteint,
en lui donnant le numéro count. Puis on considère chaque successeur w, sur lequel on
lance récursivement un parcours en profondeur. On ne peut imaginer plus simple. Un
détail, cependant, est crucial : on a ajouté v dans la table visited avant de considérer
ses successeurs. C'est là ce qui nous empêche de tourner indéniment dans un cycle.
La complexité est O(V + E), par le même argument que pour le parcours en largeur.
Le parcours en profondeur est donc également optimal. La complexité en espace est lé-
gèrement plus subtile, car il faut comprendre que c'est ici la pile des appels récursifs qui
contient les sommets en cours de visite (et joue le rôle de la le dans le parcours en lar-
geur). Dans le pire des cas, tous les sommets peuvent être présents sur la pile, d'où une
complexité en espace O(V ).
Comme le parcours en largeur, le parcours en profondeur a déterminé l'ensemble des
sommets accessibles depuis la source v. Voici un autre exemple où le parcours en profon-
deur est lancé à partir du sommet 1.
03 10 2
32 41 5
void dfs() {
for (V v : this.g.vertices())
dfs(v);
}
(La surcharge nous permet d'appeler également cette méthode dfs.) Pour un sommet déjà
visité par un précédent parcours, l'appel dfs(v) va nous redonner la main immédiatement,
et sera donc sans eet. Le code complet est donné programme 39 page 187. On l'a complété
par une méthode getNum qui permet de consulter le contenu de visited (une fois le
parcours eectué).
Comme on vient de l'expliquer, le parcours en profondeur est, comme le parcours en
largeur, un moyen de déterminer l'existence d'un chemin entre un sommet particulier, la
source, et les autres sommets du graphe. Si c'est là le seul objectif (par exemple, la dis-
tance minimale ne nous intéresse pas), alors le parcours en profondeur est généralement
plus ecace. En eet, son occupation mémoire (la pile d'appels) sera le plus souvent bien
inférieure à celle du parcours en largeur. L'exemple typique est celui d'un arbre, où l'oc-
cupation mémoire sera limitée par la hauteur de l'arbre pour un parcours en profondeur,
mais pourra être aussi importante que l'arbre tout entier dans le cas d'un parcours en lar-
geur. Le parcours en profondeur a beaucoup d'autres application, qui dépassent largement
le cadre de ce cours ; voir par exemple Introduction to Algorithms [5].
16.1. Parcours de graphes 187
class DFS<V> {
DFS(Graph<V> g) {
this.g = g;
this.visited = new HashMap<V, Integer>();
this.count = 0;
}
void dfs(V v) {
if (this.visited.containsKey(v)) return;
this.visited.put(v, this.count++);
for (V w : this.g.successors(v))
dfs(w);
}
void dfs() {
for (V v : this.g.vertices())
dfs(v);
}
int getNum(V v) {
return this.visited.get(v);
}
}
188 Chapitre 16. Algorithmes élémentaires sur les graphes
Exercice 102. Modier la classe DFS pour conserver le chemin entre la source et chaque
sommet atteint par le parcours. Une façon simple de procéder consiste à stocker, pour
chaque sommet atteint, le sommet qui a permis de l'atteindre, par exemple dans une table
de hachage. Le chemin est donc décrit à l'envers , du sommet atteint vers la source.
Solution
Exercice 104. Réécrire la méthode dfs en utilisant une boucle while plutôt qu'une
méthode récursive. Indication : on utilisera une pile contenant des sommets à partir
desquels il faut eectuer le parcours en profondeur. Le code doit ressembler à celui du
parcours en largeur la pile prenant la place de la le mais il y a cependant une
diérence dans le traitement des sommets déjà visités. Question subsidiaire : les sommets
sont-ils nécessairement numérotés exactement comme dans la version récursive ?
Solution
Exercice 105. Soit un graphe orienté G ne contenant pas de cycle (on appelle cela un
DAG pour Directed Acyclic Graph ). tri topologique de G est une liste de ses sommets
Un
compatible avec les arcs, c'est-à-dire où un sommet x apparaît avant un sommet y dès lors
qu'on a un arc x → y . Modier le programme 39 pour qu'il renvoie un tri topologique,
sous la forme d'une méthode List<V> topologicalSort(). On pourra introduire une
liste de type LinkedList<V> comme un nouveau champ, dans laquelle le sommet v est
ajouté par la méthode dfs. Solution
Exercice 106. Le parcours en profondeur peut être modié pour détecter la présence
d'un cycle dans le graphe. Lorsque la méthode dfs tombe sur un sommet déjà visité, on
ne sait pas a priori si on vient de trouver un cycle ; il peut s'agir en eet d'un sommet
déjà atteint par un autre chemin, parallèle. Il faut donc modier le marquage des sommets
pour utiliser non pas deux états (atteint / non atteint) mais trois : non atteint / en cours
de visite / visité. Modier la classe DFS en conséquence, par exemple en ajoutant une
méthode boolean hasCycle() qui détermine la présence d'un cycle.
Question subsidiaire : Dans le cas très particulier d'une liste simplement chaînée, en
quoi cela est-il plus/moins ecace que l'algorithme du lièvre et de la tortue (page 59) ?
Solution
2 4
0 1 2
1 1
1 1 3
1 1
3 4 5
16.2. Plus court chemin 189
interface Weight<V> {
int weight(V x, V y);
}
c'est-à-dire qui fournit une méthode weight donnant le poids de l'arc x → y. Cette
méthode ne sera appelée sur des arguments x et y que lorsqu'il existe eectivement un
arc entre x et y dans le graphe.
Pour réaliser la le de priorité, on utilise la bibliothèque Java PriorityQueue. Elle va
contenir des paires (v,d) où v est un sommet et d sa distance à la source. On représente
ces paires avec la classe
class Node<V> {
final V node;
final int dist;
}
Pour que ces paires soient eectivement ordonnées par la distance, et utilisées en consé-
PriorityQueue, il faut que la classe Node implémente
quence par la classe l'interface
Comparable<Node<V>>, et fournisse donc une méthode compareTo. Le code est immé-
diat ; il est donné page 192.
On en vient au code de l'algorithme proprement dit. On l'écrit comme une méthode
shortestPaths, qui prend le graphe, la source et la fonction de poids en arguments, et
qui renvoie une table donnant les sommets atteints et leur distance à la source.
3. Une autre solution consisterait à utiliser une structure de le de priorité où il est possible de
modier la priorité d'un élément se trouvant déjà dans la le. Bien que de telles structures existent, elles
sont complexes à mettre en ÷uvre et, bien qu'asymptotiquement meilleures, leur utilisation n'apporte
pas nécessairement un gain en pratique. La solution que nous présentons ici est un très bon compromis.
190 Chapitre 16. Algorithmes élémentaires sur les graphes
On commence par créer un ensemble visited contenant les sommets pour lesquels on a
déjà trouvé le plus court chemin.
Puis on crée une table distance contenant les distances déjà connues. On y met initia-
lement la source avec la distance 0. Les distances dans cette table ne sont pas forcément
optimales ; elles pourront être améliorées au fur et à mesure du parcours.
Comme pour le parcours en largeur, on procède alors à une boucle, tant que la le n'est
pas vide.
while (!pqueue.isEmpty()) {
Le cas échéant, on extrait le premier élément de la le. S'il appartient à visited, c'est que
l'on a déjà trouvé le plus court chemin jusqu'à ce sommet. On l'ignore donc, en passant
directement à l'itération suivante de la boucle.
Node<V> n = pqueue.poll();
if (visited.contains(n.node)) continue;
Cette situation peut eectivement se produire lorsqu'un premier chemin est trouvé puis
un autre, plus court, trouvé plus tard. Ce dernier passe alors dans la le de priorité devant
le premier. Lorsque le chemin plus long nira par sortir de la le, il faudra l'ignorer. Si le
sommet n'appartient pas à visited, c'est qu'on vient de déterminer le plus court chemin.
On ajoute donc le sommet à visited.
visited.add(n.node);
for (V v: g.successors(n.node)) {
int d = n.dist + w.weight(n.node, v);
Plusieurs cas de gure sont possibles pour le sommet v. Soit c'est la première fois qu'on
l'atteint, soit il était déjà dans distance. Dans ce dernier cas, on peut ou non améliorer
la distance à v en passant par n.node. On regroupe les cas où distance doit être mise à
jour dans un seul test.
16.2. Plus court chemin 191
On a d'une part mis à jour distance et d'autre part inséré v dans la le avec la nouvelle
distance d. Une fois tous les successeurs traités, on réitère la boucle principale. Une fois
qu'on est sorti de celle-ci, tous les sommets atteignables sont dans distance, avec leur
distance minimale à la source. C'est ce que l'on renvoie.
return distance;
}
Le code complet est donné programme 40 page 192. Le résultat de l'algorithme de Dijkstra
sur le graphe donné en exemple plus haut, à partir de la source 2, est ici dessiné avec les
distances obtenues au nal pour chaque sommet en exposant :
2 4
05 13 20
1 1
1 1 3
1 1
32 41 52
Sur cet exemple, tous les sommets ont été atteints par le parcours. Comme pour les
parcours en largeur et en profondeur, ce n'est pas toujours le cas : seuls les sommets pour
lesquels il existe un chemin depuis la source seront atteints.
distance[source] = 0 (16.3)
192 Chapitre 16. Algorithmes élémentaires sur les graphes
interface Weight<V> {
int weight(V x, V y);
}
class Dijkstra {
}
16.2. Plus court chemin 193
Le troisième invariant stipule que distance contient eectivement la longueur d'un che-
min pour tout sommet déjà considéré.
distance[v]
∀v ∈ visited ∪ pqueue, source −−−−−−→? v (16.4)
Pour les sommets dans visited, le quatrième invariant stipule plus précisément qu'il
s'agit de la longueur d'un plus court chemin.
d
∀v ∈ visited, ∀d, si source →
−?v alors distance[v] ≤ d (16.5)
Le cinquième invariant stipule que, pour tout arc v→w déjà considéré, la distance à w
n'excède pas celle du chemin passant par v.
d
∀v ∈ visited, ∀w t.q. v → w,
w ∈ visited ∪ pqueue et distance[w] ≤ distance[v] + d (16.6)
Enn, le sixième invariant indique que tout sommet v à une distance inférieure au plus
petit élément de pqueue est nécessairement déjà dans visited.
d
∀v, si source →? v et d < min(pqueue) alors v ∈ visited (16.7)
Montrer que ces six propriétés sont eectivement des invariants de boucle nécessite de
montrer que d'une part elles sont établies initialement (i.e., avant la boucle) et que d'autre
part elles sont préservées par toute exécution du corps de la boucle. La première partie
de cette preuve est simple, car, initialement, visited est vide et pqueue ne contient
que le sommet source. La préservation des invariants est plus subtile. Les deux premiers
invariants sont clairement préservés, car la source passe de pqueue à visited à la première
itération, puis y reste. Par ailleurs, sa distance est nulle et donc ne peut être améliorée par
la suite. L'invariant (16.4) est préservé car chaque mise à jour de distance correspond à la
somme de la longueur d'un chemin jusqu'à n.node, pour lequel l'invariant est supposé, et
du poids d'un arc sortant de ce sommet. Pour montrer la préservation de l'invariant (16.5),
considérons un sommet u distance[u] est xée, c'est-à-dire l'instant où u
et l'instant où
sort de la le pour être ajouté à visited. Un chemin source → u strictement plus court
?
visited
source
v w
u
Mais alors on aurait distance[w] < distance[u] ce qui contredit le choix de u. La préser-
vation de l'invariant (16.6) découle directement du fait que, lorsqu'un sommet est ajouté
à visited, tous les arcs sortant de ce sommet sont examinés. Enn, l'invariant (16.7) est
préservé par un argument analogue à celui de la préservation de l'invariant (16.5) : un
chemin plus court que min(pqueue) vers un sommet qui n'est pas dans visited devrait
nécessairement sortir de visited par un arc dont l'extrémité est dans pqueue, en vertu
194 Chapitre 16. Algorithmes élémentaires sur les graphes
Exercice 1, page 22
Lors de l'appel à la méthode f, la valeur de la variable locale x
copiée dans une
est
nouvelle variable, à savoir l'argument formel de f. Quoi que fasse la méthode f avec cette
nouvelle variable, cela ne peut aecter la variable x.
Exercice 2, page 30
Notons (Fn ) la suite de Fibonacci calculée par la méthode fib. Il est facile de voir
√
que le calcul de
√ fib(n) nécessite exactement Fn+1 − 1 additions. Or Fn ∼ φn / 5, où
φ = (1 + 5)/2 est le nombre d'or, et on en déduit donc que la méthode fib a une
complexité exponentielle.
Exercice 3, page 35
Il faut évidemment éviter de recalculer la somme à chaque fois, ce qui serait quadra-
tique. Une solution consiste à maintenir la somme dans une variable s :
Bien entendu, on peut se passer de cette variable en utilisant la valeur précédente, puisque
c[i] = c[i-1] + a[i]. Mais il faut alors faire attention au cas i=0 et éventuellement
aussi au cas d'un tableau a de taille nulle, deux problèmes évités par la solution ci-dessus.
198 Chapitre A. Solutions des exercices
Exercice 4, page 35
Il n'a pas de diculté ici, si ce n'est qu'il faut faire attention au cas n ≤ 1.
Exercice 5, page 35
On suit l'algorithme proposé, l'échange se faisant en utilisant une variable auxiliaire
tmp. Attention à bien écrire i+1 pour tirer un entier compris entre 0 et i inclus.
Exercice 6, page 36
Dès que la valeur est trouvée, on renvoie l'indice correspondant. On choisit donc ici
de renvoyer le plus petit indice où la valeur apparaît (car c'est le plus simple). Mais on
pouvait tout aussi bien parcourir le tableau dans l'autre sens et donc renvoyer l'indice le
plus grand.
Dans le cas où v n'apparaît pas dans le tableau, on a choisi ici de lever une exception, à
savoir l'exception NoSuchElementException de la bibliothèque Java. C'est là une solution
propre et idiomatique. On aurait pu aussi renvoyer une valeur non signicative, telle que -
1 par exemple, mais c'est plus dangereux car cette valeur pourrait être utilisée à tort
comme un indice de tableau dans le code qui appelle indexOf.
199
Exercice 7, page 36
Un tableau de taille nulle constitue l'unique diculté de cette exercice. On pourrait
lever une exception dans ce cas, ou encore supposer que le tableau contient au moins une
valeur. On peut alors initialiser la variable m avec a[0]. Une autre solution consiste à
l'initialiser avec le plus petit entier, Integer.MIN_VALUE 31
(à savoir −2 ). Il n'y a alors
pas de cas particulier à traiter.
Exercice 8, page 38
La quantité hi − lo décroît strictement à chaque tour de boucle. Si la valeur v n'ap-
paraît pas dans le tableau, cette quantité nira donc par être négative ou nulle, mettant
ainsi n à la bouclewhile. Remarque : Dans le calcul de complexité qu'on a fait plus haut,
on a de fait majoré le nombre de tours de boucle par log n. Mais l'argument concernant
le signe de hi − lo est une façon plus simple de justier la terminaison.
Exercice 9, page 38
Pour un tableau de plus de 230 m sous la forme (lo+hi)/2
éléments, le calcul de l'index
peut provoquer un débordement de la capacité du type int. Si par exemple lo = 2
30
et
hi = 2 + 1, alors lo+hi dépasse la plus grande valeur du type int, à savoir 2 − 1. On
30 31
obtient alors une valeur négative pour lo+hi, donc pour m, ce qui provoque ensuite un
1
accès en dehors des bornes du tableau . En revanche, le calcul
est correct. En eet, la soustraction hi-lo se fait sans débordement arithmétique car
on est ici sous l'hypothèse lo < hi. Puis l'addition se fait également sans débordement
arithmétique car le résultat est plus petit que hi. Une autre solution consiste à écrire
Il s'agit ici d'un décalage logique à droite (i.e., sans réplication du bit de signe). S'il y a
débordement arithmétique, ce ne peut être que d'un seul bit, le bit de signe en l'occurrence,
et ce décalage nous redonne bien la bonne valeur.
int[] toArray() {
return Arrays.copyOfRange(this.data, 0, this.length);
}
Bien entendu, on peut faire la même chose de façon élémentaire, en allouant un nouveau
tableau et en y copiant les éléments un par un, mais Arrays.copyOfRange le fait plus
ecacement.
void append(int v) {
int n = this.length;
this.setSize(n + 1);
this.data[n] = v;
}
On simplie alors le code de lecture du chier en remplaçant les trois dernières lignes par
r.append(Integer.parseInt(s));
Ce qu'il important de comprendre ici, c'est qu'une suite répétée de n opérations append
aura une complexité totale O(n), comme l'explique le paragraphe qui suit cet exercice.
En particulier, la lecture du chier se fait donc en un temps proportionnel à la taille
du chier, même si certains des appels à append conduisent à une recopie complète du
tableau.
On notera que la méthode append le StringBuffer, ce qu'on utilise ici pour chaîner
plusieurs opérations. Comme on vient juste de l'expliquer, la complexité de cette méthode
toString est linéaire. Une solution utilisant des concaténations de chaînes de caractères
(avec +) aurait été quadratique.
Le code ci-dessus est correct, mais il modie quatre fois la taille du tableau redimension-
nable. C'est inutilement coûteux, car la taille ne change pas au nal. C'est pourquoi il est
préférable d'écrire cette méthode swap dans la classe Stack. On peut le faire ainsi :
void swap() {
int n = this.elts.size();
if (n <= 1) throw new IllegalArgumentException();
int tmp = this.elts.get(n - 1);
this.elts.set(n - 1, this.elts.get(n - 2));
this.elts.set(n - 2, tmp);
}
len++;
return len;
}
Comme pour contains, on se sert de la variable locale s pour le parcours.
int pred = n - 1, c = 0;
while (c != pred) { // tant qu'il reste au moins deux joueurs
for (int i = 1; i < p; i++) {
pred = c; c = next[c]; // on avance p-1 fois
}
next[pred] = next[c]; c = next[c];
}
return c+1; // on renvoie le joueur, pas l'indice
}
void add(String s) {
int i = hash(s);
if (Bucket.contains(this.buckets[i], s)) return;
this.buckets[i] = new Bucket(s, this.buckets[i]);
}
On note que la valeur de hachage n'est calculée qu'une seule fois. Si on s'était servi plutôt
de la méthode contains de HashTable, elle aurait été calculée deux fois.
void add(String s) {
int i = hash(s);
if (Bucket.contains(this.buckets[i], s)) return;
this.buckets[i] = new Bucket(s, this.buckets[i]);
this.size++;
}
Il est important que size soit un champ privé, an de maintenir l'invariant que sa valeur
est égale au nombre d'éléments de la table. Sans le caractère privé, sa valeur pourrait être
modiée par le code client de la classe HashTable. C'est pourquoi on doit fournir une
méthode size pour rendre accessible la valeur de size.
int size() {
return this.size;
}
void remove(String s) {
int i = hash(s);
if (!Bucket.contains(this.buckets[i], s)) return;
this.buckets[i] = Bucket.remove(this.buckets[i], s);
this.size--;
}
On a gagné sur deux tableaux : d'une part, on masque l'éventuel bit de signe de h (ce
que l'on faisait avant avec & 0x7fffffff) ; d'autre part, on remplace une opération coû-
teuse (%) par une opération très ecace (&). La bibliothèque standard de Java procède
ainsi.
2h−1 − 1 < N ≤ 2h − 1.
C'est vrai pour un arbre vide, où N = h = 0. Si N = 2M + 1, alors l'arbre a deux
h−2
sous-arbres de taille M et de hauteur h − 1, avec 2 − 1 < M ≤ 2h−1 − 1 par hypothèse
de récurrence. (Les deux sous-arbres ont forcément la même hauteur, car M ne peut
être encadré par deux puissances de deux consécutives que d'une seule façon.) Du coup,
2h−1 − 1 < 2M + 1 ≤ 2h − 1. Si en revanche N = 2M , alors l'arbre a un sous-arbre de
0
taille M et de hauteur h − 1 et un sous-arbre de taille M − 1 et de hauteur h ≤ h − 1.
h−2
Par hypothèse de récurrence sur le sous-arbre de taille M , on a 2 − 1 < M ≤ 2h−1 − 1.
h−2 h−2
Or N ≥ 2 implique h ≥ 2. Dès lors, 2 est entier et donc 2 ≤ M . On en déduit
h−1 h
2 − 1 < 2M < 2 − 1.
Puis le programme consiste à retirer les éléments de la pile, un par un, pour en calculer la
taille. Si l'élément retiré est null, il n'y a rien à faire. On utiliser l'instruction continue
pour passer directement à l'itération suivante de la boucle.
while (!s.isEmpty()) {
Tree n = s.pop();
if (n == null) continue;
size++;
s.push(n.left);
s.push(n.right);
}
return size;
}
Si l'arbre contient N n÷uds, on aura mis au total N +1 arbres vides dans la pile. On
peut éviter ce surcoût en garantissant que la pile ne contient jamais d'arbre vide. On peut
alors supprimer le test n == null mais il faut en revanche tester si n.left et n.right
ne sont pas vides avant de les ajouter à la pile. Et, bien entendu, il faut penser à tester
initialement si t est vide ou non.
La descente proprement dite est semblable à celle de contains, si ce n'est qu'il faut mettre
c à jour lorsqu'on se déplace vers la droite.
Une fois sorti de la boucle, on teste si c est toujours null. Le cas échéant, cela signie
que x est plus petit que tous les éléments de b et on lève alors une exception. Sinon, on
renvoie c.
On notera que le compilateur Java a inséré automatiquement deux conversions entre les
types int et Integer, dans un sens pour aecter à c l'entier b.value et dans l'autre sens
pour permettre return c à la n du programme.
if (x < b.value)
210 Chapitre A. Solutions des exercices
par le test
if (x <= b.value)
Ainsi, en cas d'égalité, on poursuit l'insertion vers la gauche. On nira donc par tomber
sur un arbre vide et donc par insérer l'élément.
void add(Integer x) {
if (!AVL.contains(this.root, x)) this.size++;
this.root = AVL.add(this.root, x);
}
211
Cela peut paraître coûteux, mais les deux parcours sont tous les deux de même complexité
O(log n) et on conserve donc des opérations logarithmiques au nal. Une autre solution
consisterait à maintenir un champ size dans la classe AVL plutôt que dans la classe
AVLSet. Mais il faudrait alors penser à le mettre à jour pendant les opérations de rotation
notamment.
Puis il sut de l'appeler avec une liste initialement vide, que l'on renvoie une fois l'appel
terminé.
int n1 = (n - 1) / 2;
AVL left = ofList(l, n1);
int v = l.poll();
AVL right = ofList(l, n - n1 - 1);
return new AVL(left, v, right);
}
212 Chapitre A. Solutions des exercices
static LinkedList<Integer>
listUnion(LinkedList<Integer> l1, LinkedList<Integer> l2) {
LinkedList<Integer> l = new LinkedList<Integer>();
while (!l1.isEmpty() || !l2.isEmpty()) {
if (l2.isEmpty() || !l1.isEmpty() && l1.peek() <= l2.peek())
l.add(l1.remove());
else
l.add(l2.remove());
}
return l;
}
L'intersection et la diérence s'écrivent de façon similaire.
void remove(String s) {
Trie t = this;
for (int i = 0; i < s.length(); i++) { // invariant t != null
t = t.branches.get(s.charAt(i));
if (t == null) return;
}
t.word = false;
}
213
Le défaut d'une telle méthode remove est qu'elle conduit à des sous-arbres vides, qui
occupent inutilement de l'espace et augmentent potentiellement le coût de futures opéra-
tions. L'exercice suivant propose d'y remédier.
b.removeRec(s, i+1);
if (b.isEmpty()) this.branches.remove(c);
}
Note : c'est justement parce qu'on a souhaité eectuer une instruction après la suppression
qu'on a privilégié une écriture récursive. Pour écrire remove, il sut maintenant d'appeler
removeRec avec l'indice 0.
void remove(String s) {
removeRec(s, 0);
}
De même, dans la boucle de la méthode add, il faut créer la table branches si elle n'existe
pas encore.
...
if (b.isEmpty()) this.branches.remove(c);
if (this.branches.isEmpty()) this.branches = null;
class Rope {
public String toString() {
StringBuffer b = new StringBuffer();
toStringHelper(b);
return b.toString();
}
Cette méthode auxiliaire est déclarée dans cette même classe Rope comme une méthode
abstraite.
void toStringHelper(StringBuffer b) {
b.append(this.str);
}
Dans la classe App, on procède à deux appels récursifs, en commençant bien sûr par le
sous-arbre de gauche.
void toStringHelper(StringBuffer b) {
this.left.toStringHelper(b);
this.right.toStringHelper(b);
}
216 Chapitre A. Solutions des exercices
Une fois sorti de la boucle (soit par le break, soit parce que i vaut 0), on aecte la valeur
de x à la case i.
this.elts.set(i, x);
}
La complexité se déduit immédiatement du fait que add et getMin ont chacune une
complexité O(log n) où n est le nombre d'éléments du tas. Du coup, chacune des deux
boucles a une complexité totale
où N est le nombre total d'éléments. La complexité de ce tri par tas est donc O(N log N ).
Le chapitre 13 établit que c'est là une complexité optimale pour un tri n'eectuant que
des comparaisons. En revanche, ce tri utilise un espace supplémentaire en O(N ).
class HashUnionFind<E> {
private HashMap<E, E> link;
private HashMap<E, Integer> rank;
Comme les éléments ne sont plus nécessairement des entiers consécutifs, on ne passe plus
d'argument au constructeur. Celui-ci se contente donc de créer deux tables de hachage
vides.
HashUnionFind() {
this.link = new HashMap<>();
this.rank = new HashMap<E, Integer>();
}
L'ajout des éléments peut alors se faire par une méthode add, qui crée une nouvelle classe
réduite à un singleton.
void add(E x) {
this.link.put(x, x);
this.rank.put(x, 0);
}
On a conservé ici l'idée qu'un représentant est associé à lui-même dans link. Il ne reste
plus qu'à adapter les méthodes find et union. Cela consiste uniquement à remplacer
link[i] par link.get(i), link[i]=j par link.put(i,j ), etc.
219
Elt<E> find() {
if (this.link == null) return this;
Elt<E> repr = this.link.find();
this.link = repr; // compression de chemin
return repr;
}
La méthode union devient également une méthode dynamique. Elle réalise l'union des
classes de this et de son argument that. Le code est là encore identique à ce que nous
avons écrit précédemment.
shuffle(walls);
Comme suggéré dans l'énoncé, on crée alors une structure union-nd contenant toutes les
cases du labyrinthe. La case (x,y) x ∗ height + y .
peut être identiée à l'entier
Justions qu'il s'agit là d'un labyrinthe parfait, par récurrence sur la dernière boucle
du programme ci-dessus. L'hypothèse de récurrence est qu'à tout moment deux cases sont
reliées par un chemin si et seulement si elles appartiennent à la même classe et que ce
chemin est alors unique. Initialement, c'est trivialement vrai car chaque classe ne contient
qu'une seule case. Supposons la propriété vraie et eectuons un tour de boucle. Si les deux
cases cell et next choisies sont déjà dans la même classe, on ne fait rien et la propriété
reste donc trivialement vériée. Si en revanche on réunit les deux classes, considérons deux
cases a et b dans cette nouvelle classe. Si a et b sont toutes deux dans l'ancienne classe
de cell, appelons-la C , alors elles sont reliées par un unique chemin dans C . Si un autre
chemin existait, il devrait emprunter deux fois w et ce ne serait pas un chemin. De même
si a et b sont toutes deux dans la classe de next. Si enn a est dans la classe de cell et
b dans la classe de next (ou le contraire), alors par hypothèse il existe un unique chemin
de a à cell et un unique chemin de next à b, donc un unique chemin de a à b.
while (v2 != 0) {
int q = u2 / v2;
int t0 = u0 - q * v0, t1 = u1 - q * v1, t2 = u2 - q * v2;
u0 = v0; u1 = v1; u2 = v2;
v0 = t0; v1 = t1; v2 = t2;
}
return new int[] { u0, u1, u2 };
}
Bien qu'on ne change pas la complexité, cette version-là est bien meilleure que celle
utilisant des tableaux car tous les calculs se font maintenant avec des variables allouées
sur la pile et non plus des tableaux alloués sur le tas. En particulier, on ne sollicite plus
du tout le GC, sauf à la toute n de la fonction.
av + bm = gcd(v,m) = 1
(au)v + (bu)m = u
et donc au est une solution, ou plus exactement (au) mod m. En écrivant le code, il faut
faire attention au fait que a peut être négatif.
Il n'y a pas de diérence d'ecacité, car les nombre d'appels récursifs, de divisions et de
multiplications restent exactement les mêmes.
223
class Mat22 {
int m[][];
Mat22(int m00, int m01, int m10, int m11) {
this.m = new int[][] { { m00, m01 }, { m10, m11 } };
}
static Mat22 identity = new Mat22(1, 0, 0, 1);
Mat22 mul(Mat22 x) { ... }
Puis on écrit l'algorithme d'exponentiation rapide sur ce type. Si on choisit d'écrire une
méthode statique, le code est identique à celui du programme 23. Seuls les types et les
opérations atomiques changent.
prime[2] = true;
for (int i = 3; i <= max; i += 2)
prime[i] = true;
for (int n = 3; n * n <= max; n += 2)
..
On alloue alors un tableau de cette taille dans lequel on range les nombres premiers par
un second parcours.
Puis il sut de ranger les nombres premiers trouvés dans un nouveau tableau, de taille n.
Attention : on s'arrête dès qu'on en a trouvé n et non pas lorsque tout le tableau prime
est parcouru, car il peut y en avoir plus que n.
Le reste est facilement adapté. On peut alors calculer C(2000,1000) en une fraction de
seconde. On obtient un entier de 601 chires 20481516...49120.
226 Chapitre A. Solutions des exercices
m.putAll(res.get(left));
qui ajoute à m tous les entiers que l'on former à partir de left.
227
Sudoku(String s) {
this.grid = new int[81];
for (int i = 0; i < 81; i++)
this.grid[i] = s.charAt(i) - '0';
}
Les caractères étant des entiers en Java, on calcule l'entier correspondant par soustraction
avec le caractère '0', car les caractères '0', '1', . . ., '9' sont consécutifs dans le jeu de
caractères.
La variable f sert à sommer les nombres de solutions trouvés récursivement. On note que
si aucune reine ne peut être placée sur la ligne, les tests de check sont tous faux et la
somme renvoyée vaut donc 0. Enn, on écrit une méthode count qui appelle countRec
pour obtenir le nombre total.
l lo i hi r
<p =p ? >p
Le partage proprement dit est réalisé en comparant chaque valeur a[i] au pivot. Quand
il y a égalité, il sut de passer à la valeur suivante. Quand a[i] < p, on échange a[i]
et a[lo] et on incrémente i et lo. Enn, quand a[i] > p, on décrémente hi puis on
échange a[i] et a[hi].
Enn, on conclut par les deux appels récursifs, sur les portions a[l..lo[ et a[hi..r[.
quickrec(a, l, lo);
quickrec(a, hi, r);
}
229
Vu que l'appel récursif est eectué sur le plus petit des deux intervalles, sa longueur est
au moins deux fois plus petite que r-l. La longueur de l'intervalle étant divisée par au
moins 2 à chaque fois, il ne peut y avoir qu'un nombre logarithmique d'appels imbriqués
avant qu'on atteigne une largeur d'intervalle au plus égale à 1.
if (l >= r - 1) return;
par
où cutoff est une constante que l'on a introduite par ailleurs et dont la valeur peut être
déterminée empiriquement.
Pour la méthode principale mergesort, il y a une petite subtilité. Il ne faut pas prendre
pour tmp un tableau de contenu quelconque mais une copie du tableau a. En eet, la
méthode mergesortrec ne fait rien lorsque l >= r-1 mais elle doit tout de même assurer
le déplacement vers tmp lorsque inplace vaut false. Partir d'une copie de a sut à le
garantir.
La fusion de deux listes l1 et l2 dans une nouvelle liste n'est pas dicile, d'autant qu'on
peut ici vider les deux listes au fur et à mesure. Il faut cependant traiter soigneusement
le moment où l'une des listes vient à s'épuiser.
On a utilisé ici getFirst qui renvoie le premier élément d'une liste sans le retirer et
removeFirst qui retire et renvoie le premier élément. Les éléments sont ajoutés au résultat
avec addLast, pour préserver l'ordre. (Les méthodes removeFirst et addLast existent
aussi sous les noms plus courts poll et add mais on a choisi ici d'être explicite.)
On écrit enn la méthode mergesort. Elle partage la liste en deux listes l1 et l2 avec
split, les trie récursivement, puis les fusionne. Il faut penser au cas de base d'une liste
ayant au plus un élément pour assurer la terminaison.
res → · · · → l1 → · · · → null
l2 → · · · → null
oùres est la tête de liste qui sera renvoyée au nal et où on assure de plus que l1.element ≤
l2.element. La portion de liste entre res et l1 est déjà triée. Les listes l1 et l2
232 Chapitre A. Solutions des exercices
contiennent des éléments plus grands, qui restent à fusionner. Initialement, on compare
les têtes de l1 et l2 et on échange leur rôle si nécessaire.
Puis on insère le premier élément de l2. Pour cela, on avance dans la liste l1 tant que
l'élément de l1 est inférieur ou égal au premier élément de l2.
Les méthodes split et merge ont été écrites avec des boucles. Seule la méthode mergesort
utilise de la place sur la pile. Vu que la longueur de la liste est divisée par deux à chaque
étape, le nombre d'appels imbriqués à mergesort est logarithmique. On ne risque donc
pas de débordement de pile, car il faudrait pour cela une liste de longueur L avec log L
de l'ordre de plusieurs milliers et il est impossible de construire une telle liste (dans des
limites de temps et surtout ici d'espace raisonnables).
i j
false ? true
La complexité est clairement linéaire, car chaque tour de boucle diminue d'au moins une
unité la largeur de l'intervalle i..j.
l lo i hi r
Blue White ? Red
Pour écrire le code, on exploite le fait qu'on peut utiliser la construction switch de Java
sur un type énuméré. On rappelle que sans l'instruction break, l'exécution se poursuivrait
avec le code du cas suivant.
int next = 0;
for (int v = 0; v < k; v++)
while (count[v]-- > 0)
a[next++] = v;
}
Cette redondance d'information peut paraître inutilement coûteuse mais c'est là la meilleure
solution. En particulier, de nombreux algorithmes sur les graphes orientés s'appliquent
également aux graphes non orientés. Avec ce choix de représentation, on pourra les réuti-
liser directement.
void addEdge(V x, V y) {
if (this.adj.get(x).add(y)) this.nbEdges++;
}
void removeEdge(V x, V y) {
if (this.adj.get(x).remove(y))
this.nbEdges--;
}
Pour ajouter un arc x → y avec une étiquette label, il sut de chercher la table de
hachage associée à x, puis d'y ajouter l'entrée y 7→ label.
void addEdge(int x, L label, int y) {
this.adj.get(x).put(y, label);
}
La subtilité vient du fait qu'il peut déjà y avoir un arc entre x et y, avec potentiellement
une autre étiquette. Il est alors écrasé. On pourrait aussi choisir d'échouer avec une ex-
ception. En revanche, cette représentation ne permet pas d'avoir plusieurs arcs entre x
et y (ce qu'on appelle un multi-graphe). Le code de removeEdge, en revanche, ne change
pas. En eet, supprimer y dans un ensemble ou dans une table s'écrit de la même façon.
pred.put(source, null);
Lorsqu'on atteint un sommet w pour la première fois, il faut l'ajouter dans la table pred.
Le sommet qui a permis de l'atteindre est v, le sommet dont on est en train de parcourir
les successeurs.
if (!this.visited.containsKey(w)) {
q.add(w);
visited.put(w, d+1);
pred.put(w, v);
}
À ce point-là, on comprend que les tables visited et pred sont en partie redondantes,
car elles permettent toutes les deux de caractériser les sommets atteints. Si par exemple
on ne cherchait pas à conserver la distance dans la table visited, alors la seule table
pred surait.
Une fois que le parcours en largeur est terminé, on peut chercher à reconstruire le
chemin qui mène de la source à un sommet v qui a été atteint. Si par exemple on veut le
stocker dans une LinkedList, alors on peut procéder ainsi :
237
On utilise addFirst pour que le chemin soit au nal dans le bon ordre. En eet, cette
boucle remonte de v jusqu'à la source et on aura bien ainsi le début du chemin, côté
source, au début de la liste. On voit ici l'intérêt d'avoir associé la source à null dans
pred, pour sortir de la boucle.
Une (petite) amélioration consiste à éviter de mettre des valeurs null dans la le. Cela
ne change pas la complexité, bien entendu.
Pour eectuer une modication minimale à la méthode dfs, le plus simple est de lui
ajouter le sommet dont on vient comme un second argument from. On peut alors remplir
la table pred lorsque le sommet est atteint pour la première fois.
for (V w : this.g.successors(v))
dfs(v, w);
}
Pour retrouver la méthode dfs d'origine, il sut de prendre null comme valeur de from.
Ainsi, comme pour le parcours en largeur, les sommets atteints par le parcours en pro-
fondeur seront exactement les clés de pred, y compris la source.
void dfs(V v) {
dfs(null, v);
}
Pour ce qui est de la reconstruction du chemin, voir la solution de l'exercice 100.
void dfs(Tree t) {
if (t == null) return;
dfs(t.left);
dfs(t.right);
C'est donc bien la même chose qu'un parcours inxe/préxe/postxe (selon le moment
où on choisit de traiter la racine de t).
void dfs(V v) {
Stack<V> stack = new Stack<V>();
stack.add(v);
while (!stack.isEmpty()) {
v = stack.pop();
if (this.visited.containsKey(v)) continue;
this.visited.put(v, this.count++);
for (V w : this.g.successors(v))
stack.add(w);
}
}
On n'obtient pas nécessairement le même ordre de parcours que dans la version récursive.
En eet, les successeurs de v étant mis sur une pile, ils vont être considérés exactement
dans l'ordre inverse. Mais cela reste un parcours en profondeur. Dit autrement, tout se
passe comme si la méthode g.successors nous donnait les successeurs dans un ordre
diérent.
239
void dfs(V v) {
if (this.visited.contains(v)) return;
this.visited.add(v);
for (V w : this.g.successors(v))
dfs(w);
this.list.addFirst(v);
}
Ainsi, tout sommet descendant de v est bien ajouté à la liste avantv, soit antérieu-
rement à cet appel àdfs(v), soit par l'un des appels récursifs dfs(w). La
méthode
topologicalSort assure que dfs a bien été appelé sur tous les sommets du graphe avant
de renvoyer la liste.
List<V> topologicalSort() {
for (V v : this.g.vertices())
dfs(v);
return list;
}
Ce qui est assez subtil ici, c'est que l'ordre dans lequel tous ces appels à dfs sont réalisés
n'importe pas.
boolean dfs(V v) {
if (this.color.get(v) == Color.Gray) return true;
if (this.color.get(v) == Color.Black) return false;
this.color.put(v, Color.Gray);
for (V w : this.g.successors(v))
if (dfs(w)) return true;
this.color.put(v, Color.Black);
return false;
}
La méthode demandée commence par mettre la couleur de chaque sommet à White puis
appelle dfs sur tous les sommets. Là encore, on s'interrompt dès qu'un cycle est découvert.
boolean hasCycle() {
for (V v : this.g.vertices())
color.put(v, Color.White);
for (V v : this.g.vertices())
if (dfs(v)) return true;
return false;
}
On aurait pu aussi prendre la convention que ne pas être dans la table de hachage est
identique à avoir la couleur White. Du coup, deux valeurs susent.
Dans le cas particulier d'une liste chaînée, c'est plus coûteux que l'algorithme du lièvre
et de la tortue, car on stocke ici une quantité d'information proportionnelle aux nombre
de sommets rencontrés.
[1] G. M. Adel'son-Vel'ski˘
and E. M. Landis. An algorithm for the organization of
information. Soviet MathematicsDoklady, 3(5) :12591263, September 1962.
[2] Alfred V. Aho, John E. Hopcroft, and Jerey Ullman. Data Structures and Algo-
rithms. Addison-Wesley Longman Publishing Co., Inc., Boston, MA, USA, 1983.
[3] Joshua Bloch. Nearly all binary searches and mergesorts are
broken, 2006. http://googleresearch.blogspot.com/2006/06/
extra-extra-read-all-about-it-nearly.html.
[4] Sylvain Conchon and Jean-Christophe Filliâtre. Apprendre à programmer avec
OCaml. Algorithmes et structures de données. Eyrolles, September 2014.
[5] Thomas H. Cormen, Charles E. Leiserson, Ronald L. Rivest, and Cliord Stein.
Introduction to Algorithms, Second Edition. The MIT Press, September 2001.
[6] R.L. Graham, D.E. Knuth, and O. Patashnik. Concrete mathematics, a Foundation
for Computer Science. Addison-Wesley, 1989.
[10] Donald E. Knuth. The Art of Computer Programming, volume 2 (3rd ed.) : Semi-
numerical Algorithms. Addison-Wesley Longman Publishing Co., Inc., 1997.
[11] Donald E. Knuth. The Art of Computer Programming, volume 3 : (2nd ed.) Sorting
and Searching. Addison Wesley Longman Publishing Co., Inc., 1998.
[12] Donald R. Morrison. PATRICIAPractical Algorithm To Retrieve Information Co-
ded in Alphanumeric. J. ACM, 15(4) :514534, 1968.
[13] Robert Sedgewick and Kevin Wayne. Introduction to Programming in Java. Addison
Wesley, 2008.
[14] Robert Endre Tarjan. Eciency of a Good But Not Linear Set Union Algorithm. J.
ACM, 22(2) :215225, 1975.
244 BIBLIOGRAPHIE
^ (opérateur), 15 champ, 3
(opérateur), 15 chemin
dans un graphe, 176
abstract, 10, 99, 165 classe, 3
adresse, 18 abstraite, 10
algorithme, 23 classes disjointes, 117
de Dijkstra, 189 code préxe, 163
de Human, 163 Comparable<T> (java.lang.), 91, 161
alias, 19, 21, 38, 97 Comparator<T> (java.util.), 115
arbre, 75 complément à deux, 14
auto-équilibré, 110 complexité, 23
binaire, 75 amortie, 44, 72
binaire de recherche, 78 asymptotique, 27
de Patricia, 94 d'un algorithme
de préxes, 92 au pire cas, 24
équilibré, 81 en moyenne, 25
arc, 175 d'un problème, 26
arithmétique, 125 compression, 163
Arrays (java.util.), 37 constructeur, 3
arête, 175 corde, 99
AVL, 81 crible d'Ératosthène, 128
cycle
backtracking, 139 détection de, 59, 188
Bézout, 126
BFS, 182 DAG, 188
246 INDEX
racine
d'un arbre, 75
rebroussement, 139
recherche
dichotomique, 36
redénition, 8
reines