Introduction à l'algorithmique et complexité
Introduction à l'algorithmique et complexité
Gilles G EERAERTS
Université Libre de Bruxelles
1 Introduction 11
1.1 Le contenu et les objectifs du cours . . . . . . . . . . . . . . . . . . . 11
1.1.1 Algorithmes, Algorithmique . . . . . . . . . . . . . . . . . . 11
1.1.2 Les objectifs et l’orientation du cours . . . . . . . . . . . . . 14
1.2 Le pseudo-langage . . . . . . . . . . . . . . . . . . . . . . . . . . . 14
1.2.1 Types et variables . . . . . . . . . . . . . . . . . . . . . . . . 14
1.2.2 Instructions . . . . . . . . . . . . . . . . . . . . . . . . . . . 15
1.2.3 Gestion de la mémoire . . . . . . . . . . . . . . . . . . . . . 16
1.3 Bibliographie . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 18
3 La complexité algorithmique 33
3.1 Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 33
3.2 Notion d’efficacité . . . . . . . . . . . . . . . . . . . . . . . . . . . 33
3.2.1 Mesure du temps de calcul . . . . . . . . . . . . . . . . . . . 34
3.2.2 Compter le nombre d’étapes . . . . . . . . . . . . . . . . . . 35
3.3 La notion de grand O . . . . . . . . . . . . . . . . . . . . . . . . . . 37
3.3.1 Intuition . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 37
3.3.2 Définition formelle . . . . . . . . . . . . . . . . . . . . . . . 37
3.3.3 Classes de complexité algorithmique . . . . . . . . . . . . . . 39
3.3.4 Règles de combinaison et de simplification . . . . . . . . . . 40
3.4 Le cas des programmes récursifs . . . . . . . . . . . . . . . . . . . . 42
3.4.1 Utilisation de l’induction . . . . . . . . . . . . . . . . . . . . 43
3
4 TABLE DES MATIÈRES
4 Les listes 45
4.1 Motivation : critique des tableaux . . . . . . . . . . . . . . . . . . . . 45
4.2 Les listes simplement liées . . . . . . . . . . . . . . . . . . . . . . . 46
4.2.1 Comparaison aux tableaux . . . . . . . . . . . . . . . . . . . 47
4.2.2 Algorithmes . . . . . . . . . . . . . . . . . . . . . . . . . . 48
4.2.3 Listes triées . . . . . . . . . . . . . . . . . . . . . . . . . . . 53
4.3 Les listes circulaires avec élément pré-tête . . . . . . . . . . . . . . . 55
4.3.1 Différences par rapport aux listes « simples » . . . . . . . . . 56
4.3.2 Algorithmes . . . . . . . . . . . . . . . . . . . . . . . . . . 58
4.4 Les listes doublement liées . . . . . . . . . . . . . . . . . . . . . . . 63
4.5 Implémentation des listes dans les vecteurs . . . . . . . . . . . . . . 65
4.5.1 Application . . . . . . . . . . . . . . . . . . . . . . . . . . . 66
4.6 Vue récursive . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 69
4.6.1 Algorithmes . . . . . . . . . . . . . . . . . . . . . . . . . . 69
6 Les arbres 91
6.1 Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 91
6.1.1 Définitions . . . . . . . . . . . . . . . . . . . . . . . . . . . 93
6.1.2 Vocabulaire . . . . . . . . . . . . . . . . . . . . . . . . . . . 95
6.1.3 Cas particuliers . . . . . . . . . . . . . . . . . . . . . . . . . 95
6.1.4 Implémentation . . . . . . . . . . . . . . . . . . . . . . . . . 100
6.1.5 Application : les arbres d’expressions . . . . . . . . . . . . . 101
6.2 Parcours des arbres binaires . . . . . . . . . . . . . . . . . . . . . . . 104
6.2.1 Parcours préfixé ou parcours en profondeur . . . . . . . . . . 105
6.2.2 Parcours infixe . . . . . . . . . . . . . . . . . . . . . . . . . 110
6.2.3 Parcours postfixé . . . . . . . . . . . . . . . . . . . . . . . . 110
6.2.4 Le parcours par niveaux ou parcours en largeur . . . . . . . . 111
6.2.5 Applications des parcours . . . . . . . . . . . . . . . . . . . 111
1.1 Une illustration du tri par sélection. Les cases grisées sont celles qui
sont considérées en place. . . . . . . . . . . . . . . . . . . . . . . . . 13
1.2 Une illustration de la méthodologie que nous adopterons dans le cours. 14
4.1 Un exemple de liste comprenant trois éléments, et dont la tête est sto-
ckée dans la variable L. . . . . . . . . . . . . . . . . . . . . . . . . . 47
4.2 Un exemple de suppression dans une liste simplement liée. Les poin-
teurs et éléments en pointillés disparaissent lors de la suppression. . . 48
4.3 Un exemple d’insertion dans une liste simplement liée. Les pointeurs
et éléments en pointillés disparaissent lors de l’insertion. . . . . . . . 48
4.4 Un exemple de déplacement : l’élément 4 se trouve entre 2 et 7 avant
le déplacement, et se retrouve entre 1 et 2 après les déplacement. Trois
pointeurs ont été modifiés. Les pointeurs en pointillés réprésentent les
anciennes valeurs. . . . . . . . . . . . . . . . . . . . . . . . . . . . . 49
4.5 Un exemple de liste circulaire avec élément pré-tête. . . . . . . . . . 57
4.6 Un exemple d’insertion en tête dans une liste circulaire avec élément
pré-tête. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 57
4.7 Une liste circulare avec élément pré-tête vide. . . . . . . . . . . . . . 58
4.8 La recherche de l’information k dans une liste circulaire avec élément
pré-tête. Au pire cas (quand k n’est pas présent dans la liste), le pointeur
fait le tour de la liste et revient à l’élément pré-tête, qui contient k. . . 59
4.9 Un exemple de liste doublement liée. . . . . . . . . . . . . . . . . . . 63
4.10 Un exemple d’insertion dans une liste doublement liée. On insère un
élément portant la valeur 3 avant l’élément portant la valeur 4. . . . . 64
4.11 Un exemple de liste implémentée dans un vecteur, et son équivalent
utilisant des pointeurs. . . . . . . . . . . . . . . . . . . . . . . . . . 66
7
8 TABLE DES FIGURES
Cet ouvrage constitue les notes du cours d’Algorithmique, dispensé dans le cadre
de l’année préparatoire au Master en Informatique (horaire décalé), co-organisé par les
Universités de Bruxelles et Mons. Il s’agit de la première version de ces notes. À ce
titre, ce travail est certainement perfectible, et susceptible de contenir l’une ou l’autre
coquille. Je fais donc appel à l’indulgence du lecteur, qui – j’en suis convaincu – me
pardonnera volontiers en songeant aux conseils de Nicolas B OILEAU :
Hâtez-vous lentement ; et, sans perdre courage,
Vingt fois sur le métier remettez votre ouvrage :
Polissez-le sans cesse et le repolissez ;
Ajoutez quelquefois, et souvent effacez.
Mes remerciements les plus vifs vont à M. Jérome J ONCKERS, qui a accepté sans
réserve de relire ces notes, avec la rigueur et le professionnalisme qui le caractérisent.
Ses remarques toujours à propos ont indéniablement contribué à améliorer la qualité
de ce syllabus. Je remercie également les étudiants qui, à travers leurs nombreuses
questions et remarques durant le cours m’ont permis de corriger plusieurs erreurs et
imprécisions.
9
10 TABLE DES FIGURES
Chapitre 1
Introduction
11
12 CHAPITRE 1. INTRODUCTION
La méthodologie que nous venons d’esquisser est résumée à la Fig. 1.2. Dans ce
cadre, la phase de conception de l’algorithme est l’étape essentielle, car c’est à tra-
vers l’algorithme qu’on spécifie l’idée du traitement à réaliser. Cela ne veut pas dire
pour autant que la phase de traduction en programme est facile ou triviale : elle a ses
problèmes spécifiques, mais nous ne les aborderons pas ici. Au contraire, nous allons
nous concentrer sur l’étude de l’algorithmique, c’est-à-dire la discipline qui étudie les
algorithmes et leur conception.
Pour autant, il ne faut pas confondre l’algorithmique avec une liste de solutions pré-
établies pour chaque problème, un peu comme un bon livre de cuisine offre une recette
adaptée à chaque circonstance. En effet, la méthode décrite ci-dessus peut soulever
certaines questions comme :
1. Cette méthode est-elle correcte ? Autrement dit, est-on sûr qu’en l’appliquant à
n’importe quel tableau, on obtiendra toujours un tableau trié ? Afin de nous en
convaincre le mieux possible, nous une preuve formelle et rigoureuse est néces-
saire. Le concept de correction d’un algorithme et les méthodes pour la prouver
seront introduits au Chapitre 2.
2. La méthode utilisée consiste en la répétition d’un même traitement. Est-il pos-
sible d’utiliser d’autres « canevas » d’algorithme ? Nous étudierons notamment
la récursivité au Chapitre 2.
3. Quelle est l’efficacité de la méthode utilisée ? Comment peut-on la mesurer ?
Comment peut-on comparer deux méthodes différentes résolvant le même pro-
blème afin de sélectionner la plus efficace ? Nous apporterons des réponses à ces
questions au Chapitre 3.
Par ailleurs, l’algorithmique est étroitement liée à l’étude des structures de don-
nées. Dans l’exemple de cette introduction, la structure des données est donnée expli-
citement : c’est un tableau d’entiers. On peut dès lors se demander si on n’aurait pas
obtenu un algorithme de tri plus efficace si on avait structuré les données autrement. De
même, la structure de tableau n’est pas forcément adaptée à tous les problèmes. Dans
les chapitres 4, 5 et 6 nous étudierons d’autres structures de données ainsi que leurs
algorithmes associés.
1.1. LE CONTENU ET LES OBJECTIFS DU COURS 13
F IG . 1.1 – Une illustration du tri par sélection. Les cases grisées sont celles qui sont
considérées en place.
14 CHAPITRE 1. INTRODUCTION
l a
r a
n
n
g
ç
u
a
e
i s
n
o
a
u
t u
a
r e
u
l
t
l
r
e
e
{ P
d
r
e
o
s
b
i
l
d
è
e
m
r a
e
t a
e t
F o r m u l a t i o n d e s h y p o t h è s e s
e t r é fl e x i o n s u r l e p r o b l è m e
P s e u d o / l a n g a g e
{ A l g o r i t h m e
P e r m
P
e
r e
t
u
d
v
e
e
r
d
a
e
i s o
c
n
o
n
r r
e
e
r
c
f
t
o
i o
r
n
m
,
e
e
l
t
l e
c .
m
. .
e n t :
T r a d u c t i o n v e r s l e l a n g a g e c h o i s i
P r
L
o
a
g
n
r
g
a
a
m
g
m
e
a
d
t
e
i o n
{ P r o g r a m m e
1.2 Le pseudo-langage
Afin de pouvoir nous concentrer sur les aspects purement algorithmiques des no-
tions étudiées dans ce cours, nous allons nous détacher autant que faire se peut des
problèmes liés aux langages de programmation. Nous n’exprimerons donc pas nos al-
gorithmes dans un des langages classiques comme C, C++ ou PASCAL. Nous allons
adopter un pseudo-langage qui pourra être aisément traduit vers n’importe quel lan-
gage de programmation procédural. Cette approche aura l’intérêt de mettre en avant les
traitements nécessaires pour résoudre un certain problème.
une structure à l’aide du mot-clé struct, suivi du nom du type, suivi de la liste des
différentes variables à regrouper dans le type. Ces variables sont appelées des champs,
portent toutes un nom différent et peuvent être de types différents :
struct nom
Type1 nomChamp1 ;
Type2 nomChamp2 ;
..
.
TypeN nomChampN ;
Il faut bien garder à l’esprit qu’une telle déclaration crée un nouveau type et non
pas une nouvelle variable, ce qui signifie que rien n’est créé en mémoire par ces ins-
tructions. Par contre, une fois cette déclaration effectuée, nous pourrons déclarer des
variables du type Etudiant.
Étant donné une variable v dont le type est une structure, nous pouvons accéder
à chacun des champs de v comme à des variables indépendantes. Ces champs sont
nommés par [Link].
[Link] := 123 ;
1.2.2 Instructions
Les instructions que nous utiliserons sont les suivantes :
:= Il s’agit de l’opérateur d’assignation ou affectation. À gauche de cet opérateur, on
trouvera obligatoirement une variable. À droite de l’opérateur, on trouve une
expression du même type que la variable. L’effet de l’opérateur d’assignation est
le suivant : on commence par évaluer la valeur de l’expression à droite. Ensuite,
cette valeur est stockée dans la variable.
Certains ouvrages utilisent plutôt la notation ← pour désigner l’assignation en
pseudo-langage. Il nous arrivera d’utiliser l’une ou l’autre.
si. . . sinon Il s’agit de l’instruction permettant d’effectuer un test. La syntaxe typique
est si condition alors instructions 1 sinon instructions 2. Dans le cas où la
condition est vérifiée, le bloc instructions 1 uniquement sera exécuté. Dans le
cas contraire, c’est le bloc instructions 2 uniquement qui sera exécuté. La par-
tie sinon est optionnelle. On utilisera parfois l’extension permettant de tester
plusieurs conditions en séquences : si condition 1 alors instructions 1 sinon si
condition 2 alors instructions 2. . . sinon instructions n. Dans ce cas, la condi-
tion 1 est testée d’abord. Si elle est vérifiée, le bloc instructions 1 uniquement
est exécuté (les autres conditions ne sont pas testées). Si la condition 1 n’est pas
16 CHAPITRE 1. INTRODUCTION
Exemple 5 Si on veut déclarer un pointeur dont le nom est p et qui devra pointer vers
une zone mémoire capable de recevoir un élément de type Entier, on écrira :
Entier* p ;
Exemple 6 L’instruction
p := new Entier ;
crée en mémoire une zone mémoire anonyme de type Entier, dont l’adresse est stockée
dans p.
Il reste à expliquer comment on peut modifier une zone mémoire à travers un poin-
teur. Cela ne peut pas se faire par une « simple » assignation au pointeur puisque celle-ci
modifierait l’adresse qui y est stockée. Pour modifier la zone pointée par un pointeur p,
on utilise la syntaxe ∧ p. Ainsi, si p est un pointeur vers un entier et que l’on veut sto-
cker 5 dans la zone pointée, on écrira ∧ p := 5. Remarquons que cette valeur ne change
pas la valeur du pointeur, qui pointe toujours vers la même zone (et contient donc son
adresse). Il ne faut pas confondre cette instruction avec p := 5, qui stocke la valeur 5
dans le pointeur. Dans ce cas, l’adresse de la zone pointée par p avant l’assignation est
perdue, et p pointe vers la case mémoire d’adresse 5.
18 CHAPITRE 1. INTRODUCTION
Dans le cas où le pointeur p pointe vers une structure, dont on veut accéder au
champ c, par exemple, il faudrait donc écrire ∧ p.c. En effet, ∧ p est le nom de la struc-
ture elle-même (puisque c’est vers elle que p pointe), auquel il faut ajouter .c pour
accéder au champ c. Néanmoins, par souci de simplification, et puisque nous ne ma-
nipulons pas un langage qui doit, in fine être compilé, nous écrirons souvent p.c à la
place de ∧ p.c. Il est de toute façon toujours possible de retrouver le syntaxe exacte, en
regardant si on a affaire à une variable ou à un pointeur.
Exemple 7 Pour créer de façon dynamique un élément de type Etudiant et lui affecter
le numéro de matricule 123, on peut procéder de la façon suivante :
Etudiant * p ;
p := new Etudiant ;
[Link] := 123 ;
Remarquons que, dans le cas présent, p est un pointeur, contrairement à E dans l’Exemple 2.
1.3 Bibliographie
Ce cours se base en grande partie sur les premiers chapitres du livre Foundations of
Computer Science, A. Aho, J. Ullman, Computer Science Press, New York, 1992. Cet
excellent ouvrage aborde différents aspects fondamentaux de l’informatique, avec une
approche résolument scientifique. Néanmoins, il va au-delà du contenu de ce cours car
il aborde aussi les graphes ou la logique. Il en existe différentes versions dans lesquelles
les algorithmes sont présentés soit en C, soit en Pascal. Une traduction française est
publiée chez Dunod sous le titre Concepts fondamentaux de l’informatique.
L’ouvrage Introduction to algorithms 2e ed., T. Cormen, C. Leiserson, R. Rivest,
C. Stein, MIT Press, 2001 est considérée par beaucoup comme la référence en ma-
tière d’algorithmes. Le sujet est traité en profondeur, et, à nouveau, avec beaucoup de
rigueur. Une traduction en français existe également sous le titre Introduction à l’algo-
rithmique, chez Dunod (ISBN 9782100039227).
D’autres références sont données dans la bibliographie.
Chapitre 2
2.1 Introduction
Dans ce chapitre, nous allons étudier trois notions différentes mais proches : l’ité-
ration, l’induction et la récursivité.
2.1.1 Itération
Itération est synonyme de répétition, auquel on le préfère en général dans le vo-
cabulaire couramment utilisé en informatique. Tous les langages de programmation
fournissent des mécanismes permettant de répéter, ou itérer une certaine tâche. En gé-
néral, il s’agit de boucles (pour, tant que, etc). Le contenu de la boucle sera itéré
jusqu’à ce qu’une certaine condition soit remplie. Un algorithme est appelé itératif si
son traitement principal consiste en la répétition d’une certaine séquence d’instructions
(voir, par exemple, l’Algorithme 3, ci-dessous).
2.1.2 Induction
Ce terme appartient au vocabulaire des mathématiques. Le petit Robert y voit une
opération mentale qui consiste à remonter des faits à la loi, à des cas donnés. Dans le
cadre des mathématiques, l’induction est une méthode de preuve, permettant de mon-
trer qu’une proposition est vraie pour n’importe quelle valeur d’un paramètre n. Pour
ce faire, on commence par montrer que la proposition est vraie pour certaines valeurs
initiales bien choisies de n (par exemple n = 0). C’est ce qu’on appelle le « cas de
base1 ». Ensuite, on prouve que si la proposition est vraie pour certaines valeurs (dont
les valeurs initiales), alors elle est aussi vraie pour d’autres. On appelle cette partie de
la preuve le « cas inductif ». Le cas de base et le cas inductif permettent de construire
une chaîne de raisonnement montrant que la proposition est vraie pour toute valeur.
Comme exemple de cas inductif, on montre que si la proposition est vraie pour
n = k, avec k ≥ 0, elle est aussi vraie pour n = k + 1. Ainsi, nous obtenons la chaîne
de raisonnements suivante : puisque la proposition est vraie pour n = 0 (ce qui est vrai,
d’après le cas de base), elle est aussi vraie pour n = 1 (on utilise ici le cas inductif).
Maintenant que l’on sait que la proposition est vraie pour n = 1, on en déduit, grâce au
cas inductif à nouveau que la proposition est vraie pour n = 2. Elle est donc aussi vraie
1 Cela correspond à la loi, au cas donné évoqué par la définition du Petit Robert.
19
20 CHAPITRE 2. ITÉRATION, INDUCTION, RÉCURSIVITÉ
pour n = 3, pour n = 4, etc. La proposition est donc vraie pour toute valeur entière
positive de n.
En informatique, et plus particulièrement en algorithmique dès lors qu’il s’agit de
raisonner formellement sur certaines propriétés d’algorithmes, les preuves par induc-
tion sont très souvent utilisées.
2.1.3 Récurrence
Le terme « récurrence » recouvre lui aussi une certaine idée de répétition. Dans
le cadre des mathématiques, on parle de définition par récurrence ou de définition
récursive, quand on définit un concept par rapport à lui-même.
Exemple 8 On peut définir la factorielle n! du nombre n ≥ 1 comme
n
n! = ∏ i = n × (n − 1) × (n − 2) × · · ·× 1
i=1
La seconde définition est récursive, car dans le cas ou n > 1, on définit la factorielle
de n par rapport à une autre factorielle (en l’occurrence, celle de n − 1).
Entier Facto(Entier n)
début
si n = 1 alors
retourner 1 ;
sinon
n := n × Facto(n − 1) ;
retourner n ;
fin
Algorithme 1 : Une implémentation récursive du calcul de la factorielle.
Entier Facto2(Entier n)
début
Entier F := n ;
Entier i := n − 1 ;
tant que i ≥ 2 faire
F := F × i ;
i := i − 1 ;
retourner F ;
fin
Algorithme 2 : Une implémentation itérative du calcul de la factorielle.
a u x
v r a i
I n s t r
allons d’abord examiner un algorithme itératif dont nous aimerions obtenir certaines
propriétés.
Dans le cadre de ce cours, nous considérerons des programmes itératifs dont le
traitement est réalisé par une boucle du type tant que. Pour rappel, un telle boucle
est composée d’une condition C, et d’une séquence d’instructions qui est répétée tant
que la condition C est vraie. Ce comportement est représenté par l’odinogramme de la
Fig. 2.1.
Remarquons d’emblée que la boucle tant que nous garantit que la condition C sera
fausse à la fin de son exécution. Cette information peut être intéressante pour raisonner
sur le comportement de la boucle, mais n’est en générale pas suffisante, comme le
montre l’exemple qui suit.
Considérons l’Algorithme 3 qui est un exemple d’algorithme itératif. Il reçoit un
tableau d’entiers T et un entier n. Il calcule la moyenne des valeurs stockées dans les n
premières cases de T . Pour ce faire, il calcule la somme de ces n première valeurs dans
22 CHAPITRE 2. ITÉRATION, INDUCTION, RÉCURSIVITÉ
∑n−1
i=0 T [i]
∀n > 0 : Moyenne(T, n) = (2.2)
n
Nous avons affaire à la proposition ∑ni=0 2i = 2n+1 − 1 qui doit être vraie pour n’importe
quelle valeur positive de n. Nous allons prouver ce fait par induction.
2.2. ITÉRATION ET INDUCTION 23
Par contre, les découpes suivantes ne sont pas suffisante pour prouver que la pro-
position est vraie pour tout n :
Cas de base la proposition est vraie pour n = 5.
Cas inductif si la proposition est vraie pour n = k, où k ≥ 5, alors elle l’est pour
n = k + 1.
Cas de base la proposition est vraie pour n = 0.
Cas inductif si la proposition est vraie pour n = k, où k ≥ 0 est un nombre pair, alors
elle l’est pour n = k + 2.
En effet, dans le premier cas, les valeurs 0, 1, 2, 3 et 4 ne sont pas couvertes. Dans
le second cas, les valeurs impaires de n ne sont pas couvertes.
Cas inductif Supposons que la proposition est vraie pour n = k. Cela veut dire que
(hypothèse d’induction) :
k
∑ 2i = 2k+1 − 1 (2.4)
i=0
k+1
∑ 2i = 2k+2 − 1
i=0
Or, ∑k+1
i=0 2 peut se réécrire en : ∑i=0 2 + 2
i k i k+1 . Donc on doit montrer que :
k
∑ 2i + 2k+1 = 2k+2 − 1
i=0
= 2k+2 − 1
et
i = ℓ−1
Ce qui prouve le théorème.
Ayant prouvé ce théorème, nous sommes maintenant en mesure de conclure que
notre algorithme est bien correct. En effet, nous avons déjà observé que la variable i
prend la valeur n lorsque la boucle tant que se termine. Donc, d’après le Théorème 1,
S = ∑n−1
j=0 T [ j] (nous pouvons remplacer i et k par n, puisque i = n et i = k). Comme la
fonction renvoie S/n, nous avons prouvé qu’elle calcule bien la moyenne des n premiers
éléments du tableau T .
26 CHAPITRE 2. ITÉRATION, INDUCTION, RÉCURSIVITÉ
Une assertion qui est toujours vraie en début de boucle est appelée un invariant de
la boucle. C’est le cas de la formule (2.5). Les invariants représentent un outil précieux
pour expliquer le comportement d’une boucle (et donc pour prouver la correction de la
boucle).
Remarquons qu’il existe plusieurs invariants possibles pour une boucle donnée. En
effet, il est également toujours vrai, en début de boucle, que S = ∑i−1 j=0 T [ j] ∧ i ≤ n,
ce qui est plus précis que (2.5). Il est également toujours vrai, en début de boucle,
que 1 + 1 = 2. En effet, cette dernière assertion est toujours vraie (indépendemment
de la boucle), et est donc aussi un invariant de la boucle tant que de l’Algorithme 3.
L’inconvénient est que ce dernier invariant ne nous apprend rien sur les variables de
l’Algorithme 3, et nous permettra donc pas de raisonner à propos de ce dernier. Il
importe donc de choisir un invariant suffisamment précis que pour pouvoir raisonner
sur l’algorithme considéré.
Remarquons également que l’invariant (2.5) nous permet de prouver la correction
de l’Algorithme 3. En effet, à la fin de l’exécution de la boucle, nous savons que i = n.
En remplaçant i par n dans 3, nous obtenons que S = ∑n−1 j=0 T [ j] à la fin de la boucle.
Dans l’exemple que nous avons considéré, nous avons d’abord prouvé le Théo-
rème 1, que nous avons ensuite ré-exprimé sous forme d’invariant. Cette démarche a
été adoptée afin d’exposer clairement les raisonnements à l’œuvre, et d’introduire ai-
sément la notion d’invariant. En pratique, on procède en général de façon quelque peu
différente. Imaginons qu’on veuille prouver qu’une certaine assertion P est vraie à la
fin d’une boucle.
1. On commence par « deviner » un invariant I de la boucle, qui exprime le plus
précisément possible le comportement de celle-ci.
2. On prouve que I est bien un invariant. Ceci se fait par induction, dans le même
esprit que la preuve du Théorème 1 :
(a) On montre que I est vrai initialement (« au bout de 0 itérations »).
(b) On montre que si I est vrai après k itérations, il est aussi vrai après k + 1
itérations.
Ainsi, on est certain que I est vrai au bout d’un nombre quelconque d’itérations,
ce qui en fait bien un invariant.
2.3. PREUVES DE PROGRAMMES 27
3. On montre que dès qu’on sort de la boucle (c’est-à-dire dès que la condition C de
la boucle est fausse), l’invariant garantit que P est vrai. Autrement dit, on montre
que I ∧ ¬C ⇒ P.
Si on n’arrive pas à effectuer le dernier point, c’est soit parce que P n’est pas vraie
à la fin de la boucle (on a donc trouvé un bug. . .), soit parce que l’invariant I n’est pas
assez précis. Il faut alors en trouver un autre et recommencer.
Afin de réaliser cette preuve, nous proposons l’invariant (2.6) qui formalise l’idée
développée ci-dessus. Cet invariant spécifie que M contient le minimum des cases de
T qui ont été explorées. Ces cases sont celles dont l’indice est compris entre 0 et i − 1.
M = min T [ j] ∧ i ≤ n
0≤ j≤i−1
On peut maintenant prouver que l’algorithme est correct. En effet, quand on sort de
la boucle, on sait que i ≥ n. À ce même moment, l’invariant M = min0≤ j≤i−1 T [ j]∧i ≤ n
reste valable. Donc, à la sortie de la boucle, on a3 : i ≥ n ∧ M = min0≤ j≤i−1 T [ j] ∧ i ≤ n.
La conjonction de i ≥ n et i ≤ n implique que i = n. On a donc :
M= min T [ j] ∧ i = n
0≤ j≤n−1
ce qui implique, en particulier que M (la valeur renvoyée) contient le minimum des n
premières cases du tableau T .
2.4 Récursivité
Comme nous l’avons déjà mentionné dans l’introduction de ce chapitre, une défini-
tion récursive est une définition qui fait référence à elle-même ; dans laquelle l’objet à
définir est défini en fonction d’un autre objet sur lequel la même définition s’applique.
Nous avions fourni l’exemple de la factorielle du nombre n : si n = 1, alors la factorielle
de n est 1, sinon, la factorielle de n est la factorielle de n − 1 multipliée par n (ce que
nous avions exprimé sous une forme plus « matheuse » ci-dessus).
Pourquoi une telle définition a-t-elle du sens ? Pour le comprendre, calculons la
factorielle de 5, à l’aide de cette définition :
5! = 4! × 5
2 Ceci pourrait être prouvé rigoureusement.
3 C’est ce que nous avions désigné par I ∧ ¬C plus haut.
2.4. RÉCURSIVITÉ 29
4! = 3! × 4
Donc :
5! = 3! × 4 × 5
5! = 2! × 3 × 4 × 5
5! = 1! × 2 × 3 × 4 × 5
Nous retombons donc finalement sur le cas 1!, qui vaut 1, par définition. Donc :
5! = 1×2×3×4×5
= 120
Exemple 11
5 si x = 0
f (x) = 2 × f 2x si x est pair et 6= 0
f (2 × x) − 1 si x est impair
Dans le cas f (0), la fonction est bien définie. Par contre, dans le cas f (2), par exemple,
la définition nous dit de calculer 2 × f (1), et f (1) = f (2)− 1. . . La valeur f (2) est donc
définie en fonction de f (1), qui est définie en fonction de f (2). Cette définition n’a pas
de sens.
Remarquons finalement que cette idée a donné lieu à diverses boutades, notamment
dans le choix des noms des projets dans la communauté open source. L’acronyme GNU,
par exemple, signifie officiellement4 GNU is not Unix. . . Un expert en pédagogie a
un jour décrété que « Pour comprendre la récursivité, il faut d’abord comprendre la
récursivité », etc. . .
4 Voir http ://[Link]/gnu/[Link].
30 CHAPITRE 2. ITÉRATION, INDUCTION, RÉCURSIVITÉ
Exemple 12 Prouvons le théorème suivant, qui indique que les deux définitions (non-
récursive et récursive) de la factorielle sont équivalentes. La fonction f1 calcule la
factorielle par la définition non-récursive, et la fonction f2 la calcule par la définition
récursive.
et
n × f2(n − 1)! si n > 1
f2 (n) =
1 si n = 1
f2 (n) = f2 (k) = k × f2 (k − 1)
f2 (n) = f2 (k) = k × f1 (k − 1)
f2 (n) = f2 (k)
= k × 1 × 2 × 3 × . . .× (k − 1)
= 1 × 2 × 3 × . . .× (k − 1) × k
n
= ∏i
i=1
= f1 (k)
= f1 (n)
2.4. RÉCURSIVITÉ 31
Entier FonctionRec(Entier x)
begin
si x = 0 alors
retourner 5 ;
sinon si x mod 2 = 0 alors
Entier y := FonctionRec(x/2) ;
retourner 2 × y ;
sinon
Entier y := FonctionRec(2 × x) ;
retourner y − 1 ;
end
Algorithme 5 : Une fonction récursive problématique. . .
Cette preuve se fait par induction est est essentiellement la même que celle du Théo-
rème 3.
D’autres exemples seront développés au cours.
32 CHAPITRE 2. ITÉRATION, INDUCTION, RÉCURSIVITÉ
Chapitre 3
La complexité algorithmique
3.1 Introduction
Le but d’un algorithme est de fournir une solution automatisable à un problème
donné, nous l’avons dit dans le chapitre d’introduction. Mais fournir une solution cor-
recte n’est pas suffisant, encore faut-il que l’algorithme proposé se montre efficace. En
effet, quelle est l’utilité d’un algorithme correct mais qui n’est pas capable de fournir
une réponse en moins de dix ans, même quand il est exécuté sur les meilleurs ordina-
teurs du marché ?
Nous aimerions donc disposer d’une mesure de l’efficacité d’un algorithme. Celle-
ci nous permettrait, étant donné plusieurs algorithmes résolvant le même problème, de
comparer leurs efficacités respectives et de choisir le plus adapté au cas de figure qui
nous intéresse.
C’est l’objet de la complexité algorithmique, que nous appellerons tout simplement
complexité dans ce cours1. La complexité d’un algorithme est une mesure de son effi-
cacité, et un algorithme aura une complexité d’autant plus élevée qu’il sera inefficace.
Tentons de préciser quelque peu ces notions. . .
1 Remarquez cependant qu’il existe d’autres notions de complexité, tout à fait différentes, comme la com-
33
34 CHAPITRE 3. LA COMPLEXITÉ ALGORITHMIQUE
début
i := 1 ;
lire n ;
tant que i ≤ n faire
j := 1 ;
tant que j ≤ n faire
si i = j alors Afficher i j;
j := j + 1;
i := i + 1;
fin
Algorithme 6 : Un algorithme pour afficher toutes les paires (i, i) avec 1 ≤ i ≤ n.
Dans le cas de l’algorithme 6, les variables i et j prennent des valeurs qui ne seront
pas affichées. En effet, initialement, i vaut 1 et j vaut 1. Ces valeurs seront affichées
puisque i = j. Ensuite, j sera incrémenté successivement à 2, 3,. . . jusqu’à atteindre n.
Toutes ces valeurs ne seront pas affichées puisque nous aurons i 6= j (autrement dit, le
couple (i, j) prendra les valeurs (1, 2), (1, 3),. . . (1, n), qui ne seront pas affichées). On
voit donc que toute une série d’opérations sont inutiles.
L’algorithme 7, par contre, s’arrange pour maintenir i = j et on économise ainsi
une boucle et un test2 .
2 Comme i et j ont toujours la même valeur, on aurait pu se passer d’une des deux variables. Nous avons
début
i := 1 ;
j := 1 ;
lire n ;
tant que i ≤ n faire
Afficher i j ;
j := j + 1 ;
i := i + 1 ;
fin
Algorithme 7 : Un autre algorithme pour afficher toutes les paires (i, i) avec 1 ≤ i ≤ n.
Il est clair que le second algorithme est intrinsèquement meilleur que le premier,
car il fait l’économie de calculs inutiles à l’affichage, et cette économie est totalement
indépendante de la machine choisie pour l’exécution du programme. C’est ce genre
d’observations que nous aimerions capturer dans la notion de complexité.
début
Lire n ;
Afficher n ;
fin
Algorithme 8 : Un algorithme dont le nombre d’étapes de calcul est constant.
36 CHAPITRE 3. LA COMPLEXITÉ ALGORITHMIQUE
début
lire n ;
i := 1 ;
tant que i ≤ n faire
Afficher n ;
i := i + 1 ;
fin
Algorithme 9 : Un algorithme dont le nombre d’étapes de calcul dépend de la valeur
n lue en entrée.
début
lire n ;
si n = 1 alors
Afficher n ;
sinon
i := 1 ;
tant que i ≤ n faire
Afficher n ;
i := i + 1 ;
fin
Algorithme 10 : Un algorithme dont le nombre d’étapes de calcul est parfois
constant, parfois linéaire.
On peut résumer les considérations de cette section par les quelques règles ci-
dessous. Celles-ci sont souvent3 suffisantes pour évaluer le nombre d’étapes néces-
saires à l’exécution d’un algorithme.
Dans les règles qui suivent I, I1 et I2 sont des blocs d’instructions, et T est un test.
1. Si I1 consomme k1 étapes et I2 consomme k2 étapes, la séquence I1 ; I2 consomme
k1 + k2 étapes.
2. Si I consomme k étapes, T consomme ℓ étapes, alors la boucle TantQue(T ){I}
consomme n × (ℓ + k) + ℓ étapes, où n est le nombre d’exécutions de la boucle.
3
Mais pas toujours : voir les exemples donnés au cours. Pour évaluer correctement le nombre d’étapes
consommé par un algorithme, il est donc impératif d’en comprendre le fonctionnement dans les moindres
détails.
3.3. LA NOTION DE GRAND O 37
Remarquons que les ℓ étapes supplémentaires viennent du fait qu’il faut exécuter
une fois supplémentaire le test pour se rendre compte qu’on doit sortir de la
boucle.
3. Si I1 consomme k1 étapes, I2 consomme k2 étapes et T consomme ℓ étapes,
l’instruction If(T ){I1 }Sinon{I2 } consomme ℓ + max{k1 , k2 } étapes.
3.3.1 Intuition
Pour ce faire, considérons l’exemple suivant. Supposons qu’on veuille comparer
trois algorithmes, qui doivent traiter des données de taille n. Pour réaliser ce traitement :
1. le premier algorithme nécessite n4 + 3n étapes.
2. le second algorithme nécessite 2n3 + n2 étapes.
3. le troisième algorithme nécessite n3 + 3n étapes.
Le graphique de la Fig. 3.1 montre l’évolution de ce nombre d’étapes en fonction de
la valeur de n (pour n compris entre 0 et 5). On a également indiqué le nombre d’étapes
que prendrait un algorithme nécessitant n3 étapes. On constate que les comportements
des algorithmes 2 et 3 sont très proches l’un de l’autre, et très proches de n3 , alors que
l’algorithme 1 se démarque. Cette tendance est beaucoup plus nette si on regarde des
grandes valeurs de n, comme on peut le voir sur la Fig. 3.2 : les courbes des algorithmes
2 et 3, et la courbe de n3 se confondent, l’algorithme 1 explose.
Sur base de ces observations, nous pouvons dire que les algorithmes 2 et 3 se com-
portent grosso modo de la même manière, c’est-à-dire en consommant de l’ordre de n3
étapes, alors que l’algorithme 1 est beaucoup moins efficaces et consomme de l’ordre
de n4 étapes. Remarquons qu’il s’agit à chaque fois du terme de poids le plus élevé du
polynôme (sans la constante).
La notion de grand O nous permet de formaliser cela. Nous dirons que 2n3 + n2
et n3 + 3n sont en grand O de n3 , tandis que n4 + 3n est en grand O de n4 . Les deux
derniers algorithmes seront donc considérés comme aussi performants l’un que l’autre,
alors que le premier sera vu comme moins performant.
Définition 1 Soient f (x) et g(x) deux fonctions réelles. On dit que f est en grand Ode
g, ce qui est noté f (x) = O g(x) , si et seulement s’il existe deux constantes x0 ≥ 0 et
k ≥ 0 telles que :
∀x > x0 : 0 ≤ f (x) ≤ k × g(x)
38 CHAPITRE 3. LA COMPLEXITÉ ALGORITHMIQUE
A l g o r i t h m e
1
6
0 0
x ^
A l g o r i t h m e
2
A l g o r i t h m e
3
5 0 0
4 0 0
3 0 0
2 0 0
1 0 0
0 1 2 3 4 5
1 x 1 0 8 A l g o r i t h m e
x ^ 3
A l g o r i t h m e
A l g o r i t h m e
8 x 1 0
7
6 x 1 0
7
4 x 1 0 7
2 x 1 0 7
0 2 0 4 0 6 0 8 0 1 0 0
Intuitivement, cette définition dit que la fonction g(x) (multipliée par une certaine
constante k) est toujours au-dessus de la fonction f (x), si on néglige les valeurs initiales
de x (celles qui sont ≤ x0 ). La fonction g(x) peut donc être vue comme une borne
supérieure de la fonction f (x), et ce, pour des valeurs de x suffisamment grandes, ce
qui est bien compatible avec notre choix de faire des analyses au pire cas.
Regardons quelques exemples :
ce qui est vrai. En effet, pour tout nombre x strictement positif, son carré est
positif et inférieur ou égal à son cube.
2. La fonction f (x) = 2x3 + x2 est aussi en O(x4 ), O(x5 ), etc. C’est bien cohérent
avec notre intuition qui consiste à voir le O comme une borne supérieure : il est
clair que x4 est une borne supérieure de x3 . Or, 2x3 + x2 est en O(x3 ). Donc, x4
est aussi une borne supérieure pour cette fonction.
3. Par contre, f (x) = 2x3 + x2 n’est pas en O(x2 ), O(x).
4. La fonction f (x) = 5 est en O(1) (preuve : prenez x0 = 0 et k = 5). Elle est donc
aussi en O(x), en O(x2 ),. . .
5. La fonction f (x) = 3 log(2x) est en O(log(x)). Preuve : exercice.
1 x 1 0 6 y = 1 ( 0 2 0 , 6 )
y = x ( 0 2 0 , 6 )
y = x * * 2 ( 0 2 0 , 6 )
1 0 0 0 0 0
y = x * * 3 ( 0 2 0 , 6 )
y = l o g ( x ) ( 0 2 0 , 6 )
y = 2 * * x ( 0 2 0 , 6 )
1 0 0 0 0
1 0 0 0
1 0 0
1 0
0 5 1 0 1 5 2 0
Dans ce tableau, nous avons présenté les classes par ordre croissant de complexité.
Ainsi, toute fonction en O(n2 ), par exemple, sera aussi en O(n3 ), O(2n ), etc. La
Fig. 3.3 illustre cela en fournissant une comparaison des fonctions caractéristiques de
ces classes.
Dans le cas particulier des polynômes, il s’agit du terme de plus haut degré. Par
exemple, 3x2 + 2x + 3 est en O(3x2 ), et donc en O(x2 ), par la règle de suppres-
sion des constantes multiplicatives.
Les règles de combinaison nous permette d’analyser le programme morceau par
morceau, et de combiner les différents résultats :
Séquence Si I1 est une suite d’instructions en O( f1 ) et I2 est une suite d’instructions
en O( f2 ), alors I1 ; I2 est en O( f1 + f2 ).
Boucle Si I est une suite d’instructions en O( f ), et que T est un test en O(g), alors
tant que (T ){I} est en O(ℓ × ( f + g)), où ℓ est le nombre d’exécutions de la
boucle.
Test Si I1 est une suite d’instructions en O( f1 ), I2 est une suite d’instructions en O( f2 )
et T est un test en O(g), alors si(T ){I1 }sinon{I2 } est en O(max f1 , f2 + g), ou,
plus simplement, en O( f1 + f2 + g). Cette dernière forme est équivalente par
la règle du Terme le plus significatif. De manière similaire, si(T ){I1 } est en
O( f1 + g).
Appel de fonction Un appel de fonction passe le contrôle de l’exécution à la fonction.
Il faut donc calculer la complexité de la fonction, qui pourra alors être utilisée
comme complexité de l’appel. Dans le cas des fonctions récursives, cette com-
plexité peut être plus difficile à calculer. La section 3.4 y est consacrée.
En pratique, le calcul de la complexité d’un algorithme peut faire appel à plusieurs
de ces règles, comme l’illustre l’exemple suivant.
Exemple 15 Revenons aux algorithmes 6 et 7. Nous avions vu intuitivement que le
premier était moins efficace que le second. Pouvons-nous obtenir cette information de
manière plus précise grâce à la complexité ?
La réponse est oui ! Pour ce faire, nous calculons d’abord la complexité de l’al-
gorithme 6. Nous commençons par analyser le contenu de la boucle tant que j ≤ n,
la plus imbriquée. Celle-ci contient un si, dont le corps ne contient qu’une instruction
d’affichage. Elle est donc en O(1). Le test i = j est également en O(1). Par la règle du
Test, tout le si est en O(1 + 1) = O(2), ce qui est en O(1), par la règle de suppression
des constantes multiplicatives.
Le si est suivi d’une assignation, qui est en O(1). Le contenu de la boucle est
donc en O(1 + 1) = O(2), par la règle de la séquence. À nouveau, on simplifie en
O(1). Le test j ≤ n de cette boucle est en O(1). La boucle s’exécute n fois, puisque
j est initialisée à 1 avant la boucle, et incrémentée de 1 à chaque tour de boucle. En
appliquant la règle de la boucle, on trouve que celle-ci est en O(n × (1 + 1)) = O(2n).
On simplifie à nouveau en O(n).
Regardons maintenant la boucle tant que i ≤ n. Elle contient une séquence com-
posée d’une assignation j := 1, en O(1) ; de la boucle analysée ci-dessous, en O(n) ;
et enfin d’une autre assignation i := i + 1 en O(1). Le contenu de la boucle est donc
en O(1 + n + 1) = O(n + 2), soit O(n), car on ne garde que le terme le plus signifi-
catif. Ici aussi, la boucle s’exécute n fois et le test est en O(1). La boucle est donc en
O(n × (n + 1)) = O(n2 + n). Cette dernière expression est en O(n2 ), car on ne garde
que le terme de degré le plus élevé.
Finalement, l’algorithme complet est lui-même une séquence composée d’une as-
signation i := 1 en O(1) ; d’une lecture de n, en O(1) ; et de la boucle analysée au
paragraphe précédent, en O(n2 ). L’algorithme 6 est donc en O(n2 + 2) = O(n2 ).
En analysant l’algorithme 7 à l’aide des mêmes méthodes, on trouve une complexité
de O(n). En effet, celui-ci est composé d’une initialisation en O(1), et d’une boucle
42 CHAPITRE 3. LA COMPLEXITÉ ALGORITHMIQUE
s’exécutant n fois, et dont le contenu et le test sont en O(1). Ceci nous permet de
conclure que l’algorithme 7 est plus efficace que l’algorithme 6.
f (x)
début
i := 0;
tant que i < n faire
g(x) ;
i := i + 1 ;
fin
g(x)
début
. . . Un traitement en O(n). . . ;
fin
Algorithme 11 : Exemple d’appel de fonction et de calcul de la complexité.
Facto(k)
}
n = k
é v a l u e r n 1 A p p e l r e c u r s i f
r e
é
t
v
o
a
u
l u
r n
e
e
r
r
n
} O (1)
n = k 1
é v a l u e r n 1 A p p e l r e c u r s i f
r e
é
t
v
o
a
u
l u
r n
e
e
r
r
n
} O (1)
n = k 2
é v a l u e r n 1
r e
é
t
v
o
a
u
l u
r n
e
e
r
r
n
} O (1)
n
A p p e l r e c u r s i f
n = 3
é v a l u e r n 1 A p p e l r e c u r s i f
r e
é
t
v
o
a
u
l u
r n
e
e
r
r
n
} O (1)
n = 2
é v a l u e r n 1 A p p e l r e c u r s i f
r e
é
t
v
o
a
u
l u
r n
e
e
r
r
n
} O (1)
n = 1
é
r e
v a
t o
l u
u
e
r n
r
e
n
r
=
1
1
} O (1)
Les listes
45
46 CHAPITRE 4. LES LISTES
pour contenir tous les étudiants. Malheureusement, le nombre d’étudiants n’est peut-
être pas connu à l’avance. On choisira donc une taille qui nous semble beaucoup plus
grande que le nombre réel d’étudiants, afin de ne pas dépasser la fin du tableau. Mal-
heureusement, les cases surnuméraires occuperont de la place « pour rien ». De plus,
ce nombre d’étudiants est susceptible de varier d’année en année (chaque fois que l’on
désire établir le palmarès). Rien ne nous garantit que la taille suffisante choisie en
2006–2007 pour stocker tous les étudiants le sera encore en 2007–2008. . .
Enfin, l’implémentation du tri risque d’être lourde, car elle implique de déplacer les
informations d’un étudiant d’une case du tableau vers une autre, ce qui peut être fort
coûteux en temps. En effet, considérons le tableau suivant :
0 1 2 3 4
Proust Hugo Lamartine Musset Baudelaire
12/20 15/20 18/20 19/20 14/20
Pour trier ces étudiants par ordre de notes croissantes, et insérer l’étudiant Baudelaire à
la case 1, entre Proust et Hugo, il est nécessaire de décaler Hugo, Lamartine et Musset
d’une case « vers la droite ».
Pour résumer, la faiblesse d’un tableau provient de son aspect statique :
1. Une fois qu’il est déclaré, sa taille ne change plus.
2. Il n’est pas possible d’insérer un élément à une position précise du tableau sans
écraser l’information qui s’y trouve, à moins de la déplacer elle-même.
1 2 4 7 N I L
F IG . 4.1 – Un exemple de liste comprenant trois éléments, et dont la tête est stockée
dans la variable L.
Ainsi, chaque élément d’une liste sera constitué d’un enregistrement du type :
struct Elem
Entier info ;
Elem * next ;
que l’on représentera graphiquement comme ceci :
Dans chaque élément de la liste, nous stockerons donc une information, ainsi qu’un
pointeur, indiquant l’endroit en mémoire où se trouve l’élément suivant, s’il existe. Cet
élément suivant indiquera lui-même où se trouve son suivant, et ainsi de suite. Le der-
nier élément de la liste, qui ne possède pas de suivant, contiendra la valeur convention-
nelle NIL dans son champ next. Ainsi, pour accéder à la totalité de la liste, il ne sera
nécessaire de sauvegarder dans une variable qu’un pointeur vers le premier élément. Ce
premier élément, et, par extension, la variable qui contient son adresse, seront souvent
appelés la tête de la liste.
On peut donc représenter schématiquement une liste simplement liée comme pré-
senté à la Fig. 4.1. Cet exemple présente une liste faite de quatre éléments, contenant,
respectivement et dans l’ordre, les valeurs 1, 2, 4 et 7. La tête de la liste est stockée
dans la variable L (qui est donc de type « pointeur vers un Elem », et non pas Elem).
Dans le cas où l’on désire une liste vide, il suffira de stocker la valeur NIL dans la
variable L pointant vers la tête de la liste.
1 2 4 7 N I L
F IG . 4.2 – Un exemple de suppression dans une liste simplement liée. Les pointeurs et
éléments en pointillés disparaissent lors de la suppression.
1 2 4 7 N I L
F IG . 4.3 – Un exemple d’insertion dans une liste simplement liée. Les pointeurs et
éléments en pointillés disparaissent lors de l’insertion.
on peut s’arranger pour qu’il n’apparaisse plus dans la séquence, et pour désal-
louer la mémoire qui le contenait, comme illustré sur la Fig. 4.2 . Si on désire
ajouter un élément, il suffit d’allouer un nouvel emplacement mémoire pour le
stocker, et modifier les pointeurs de manière à ce que cet élément apparaisse en
bonne place dans la séquence, comme illustré sur la Fig. 4.3.
2. Le déplacement d’un élément se résume à une manipulation de pointeurs, comme
illustré sur la Fig. 4.4
Par contre, le chaînage des éléments par pointeurs a un inconvénient : on ne peut
plus directement accéder à un élément arbitraire de la liste comme on pouvait le faire
avec un tableau. Pour accéder au ke élément du tableau T , par exemple, il suffit d’utili-
ser l’expression T [k]. Dans le cas d’une liste, il est nécessaire de parcourir toute la liste
depuis le début, élément par élément, comme nous le verrons ci-dessous.
4.2.2 Algorithmes
Passons maintenant en revue différents algorithmes permettant de manipuler des
listes simplement liées. Toutes ces fonctions recevront en paramètre une liste, c’est-à-
dire un pointeur vers un élément (la tête de la liste), ainsi que d’éventuels paramètres
supplémentaires.
4.2. LES LISTES SIMPLEMENT LIÉES 49
1 2 4 7 N I L
La fonction ListeVide a pour but d’initialiser une liste. Il suffit donc d’assigner la
valeur NIL à la tête de la liste. Sa complexité est O(1).
La fonction EstVide est une fonction booléenne qui reçoit une liste en paramètre,
et qui renvoie vrai si et seulement la liste est vide (c’est-à-dire si et seulement si la tête
est nulle). Sa complexité est O(1).
Accès au ke élément
La fonction KIemeElement reçoit une liste L et un entier k, et doit renvoyer un
pointeur vers le ke élément de L, s’il existe. Contrairement aux tableaux, il n’est pas
possible d’accéder directement au ke élément d’une liste. Il faut parcourir la liste depuis
le début, élément par élément, tout en comptant le nombre d’éléments visités. Il se peut
que le ke élément n’existe pas (la liste étant trop courte). Dans ce cas, la fonction
renvoie une erreur, sous forme d’un pointeur nul. Autrement, la fonction renvoie un
pointeur vers le ke élément. Cette fonction est en O(k).
Insertion à la ke position
La fonction InsèreKPos reçoit une liste L et deux entier i et k. Elle insère dans L
un nouvel élément dont l’information est i, et ce, à la position k (si possible). Pour que
l’insertion soit possible, il faut que la liste contienne au moins k − 1 éléments.
L’insertion à la ke position est aisée si on dispose d’un pointeur vers le k − 1e
élément (que l’on obtient à l’aide de la fonction KIemeElement). L’insertion suit alors
l’idée représentée à la Fig. 4.3. Remarquons, une fois de plus, que l’insertion en tête
doit être traitée comme un cas particulier. Celle-ci a lieu si k = 1.
Cette fonction est en O(n) au pire cas.
4.2. LES LISTES SIMPLEMENT LIÉES 51
ListeVide(Elem * &L)
début
L := NIL ;
fin
Booléen EstVide(Elem * L)
début
si L = NIL alors
retourner vrai ;
sinon
retourner faux ;
fin
Elem * PremierElem(Elem * L)
début
retourner L ;
fin
Elem * DernierElem(Elem * L)
début
Elem * p := L ;
si EstVide(L) alors retourner NIL ;
sinon
tant que [Link] 6= NIL faire
p := [Link] ;
retourner p ;
fin
(on peut aisément la modifier pour supprimer toutes les occurrences). Cette fonction
combine les difficultés rencontrées lors de la recherche d’une information et de la sup-
pression. En effet, il ne s’agit pas seulement de trouver l’élément contenant l’informa-
tion k demandée ; il faut également obtenir un pointeur sur l’élément qui le précède, et
ceci afin de pouvoir effectuer la suppression.
Pour ce faire, la stratégie adoptée consiste à parcourir la liste avec deux pointeurs
p et q. On s’arrangera, durant le parcours, pour que le pointeur q pointe toujours sur
l’élément qui précède p dans la liste. Ainsi, si p pointe vers un élément contenant l’in-
formation k (qu’il faut donc supprimer), q pointe vers l’élément dont on doit modifier
le champ next.
À la sortie de la boucle principale, il y a deux possibilités :
1. p = NIL. Dans ce cas, on a parcouru toute la boucle sans trouver l’information
à effacer. On peut décider d’afficher une erreur, ou de ne rien faire.
2. [Link] = k. Le pointeur p pointe donc vers un élément à supprimer. On doit
encore envisager deux cas de figure :
(a) Soit p = L, c’est-à-dire que p pointe vers le premier élément. Dans ce cas,
il y a lieu de faire une suppression, en tête, et le pointeur q ne contient pas
d’information utile.
(b) Soit p 6= L, c’est-à-dire que p est « au milieu » de la liste. À ce moment,
q pointe bien vers l’élément qui le précède et on peut donc procéder à la
suppression de façon habituelle.
La compexité de cette fonction est en O(n) au pire cas.
Insertion triée
Le but de cette opération est d’insérer une information k dans une liste L, qui est
déjà triée, tout en maintenant le caractère trié de cette liste. Pour ce faire, il convient
de repérer le premier élément de L qui contienne une information ≥ k. Il faudra alors
insérer la nouvelle information avant cet élément.
Comme d’habitude, l’insertion en tête doit être traitée avec soin. Celle-ci aura lieu
si et seulement si soit l’information stockée dans la tête est ≥ k, soit la liste est vide.
Dans le cas contraire, il faudra parcourir la liste. Nous allons à nouveau utiliser la
stratégie développée pour la fonction SuppressionInfo, à savoir l’utilisation de deux
pointeurs p et q, où q pointe toujours vers l’élément qui précède p. On s’arrangera pour
arrêter p sur le premier élément dont l’information est ≥ k (s’il existe). À la fin de la
boucle principale, nous aurons deux possibilités :
1. p = NIL. Dans ce cas, tous les élémentsde la liste sont < k, et il convient donc
d’insérer le nouvel élément à la fin de la liste. Cela revient à insérer après q, qui
pointe à ce moment vers le dernier élément (rappelons que la liste n’est pas vide,
et qu’on n’insère pas en tête).
54 CHAPITRE 4. LES LISTES
SuppressionPremier(Elem * &L)
début
si EstVide(L) alors
Afficher « Erreur, la liste est vide » ;
sinon
Elem * p := L ;
L := [Link] ;
delete p ;
fin
2. [Link] ≥ k. Dans ce cas, on peut sans risque d’erreur insérer après q, puisque la
liste n’est pas vide et qu’on ne fait pas d’insertion en tête.
Dans les deux cas, on peut donc insérer après q, sans risque d’erreur.
?
1 2 4 7
?
1 2 4 7
F IG . 4.6 – Un exemple d’insertion en tête dans une liste circulaire avec élément pré-
tête.
teur L aura donc lieu au moment de la création de la liste. La Fig. 4.6 donne un
exemple d’insertion tête.
– Une liste circulaire avec élément pré-tête, même vide, contient toujours au moins
un élément (l’élément bidon). On n’aura donc jamais de pointeur L égal à NIL.
La Fig. 4.7 montre une liste circulaire vide.
– La recherche d’une valeur k dans la liste sera également simplifiée. En effet,
comme l’information contenue dans l’élément bidon n’est pas significative, rien
ne nous empêche de la modifier. Ainsi, si on recherche l’information k dans la
liste, on peut placer cette information dans l’élément bidon. On commence la
recherche de k sur le premier élément significatif. Comme la liste est circulaire,
on est certain de trouver l’élément k : soit parmi les éléments significatifs, soit
dans l’élément bidon, qu’on rencontrera immanquablement comme successeur
du dernier élément. Ces deux cas peuvent aisément être discriminés : tout élé-
ment significatif a une adresse 6= L, et l’élément bidon est pointé par L. Le seul
test à faire en parcourant une telle liste à l’aide du pointeur p est donc [Link] 6= k.
La Fig. 4.8 illustre cela.
De manière générale, ces listes ont l’avantage de ne pas utiliser de pointeurs NIL,
ce qui limite les risques d’erreur.
58 CHAPITRE 4. LES LISTES
4.3.2 Algorithmes
Comme dans le cas des listes simplement liées, nous passons en revue quelques
algorithmes classiques. Ceux-ci sont présentés aux Algorithmes 16 et 17.
k
1 2 4 7
k
1 2 4 7
k
1 2 4 7
F IG . 4.8 – La recherche de l’information k dans une liste circulaire avec élément pré-
tête. Au pire cas (quand k n’est pas présent dans la liste), le pointeur fait le tour de la
liste et revient à l’élément pré-tête, qui contient k.
60 CHAPITRE 4. LES LISTES
n’est pas celui dont le champ next est NIL (puisqu’il n’y en a pas), mais celui dont le
champ next est L.
Comme il est nécessaire de parcourir toute la liste, cette fonction est en O(n), où n
est le nombre d’éléments de la liste.
Insertion à la ke position
Cette fonction reçoit une liste L, un entier k et un entier i. Elle insère l’entier i dans
la liste, à la ke position. Cette fonction profite grandement de l’élément pré-tête, ce qui
évite de traiter l’insertion en tête comme un cas particulier. Remarquons que si la liste
ne contient pas assez d’éléments (moins de k − 1), l’insertion n’est pas possible.
On parcourt la liste à l’aide du pointeur p, en commençant sur le premier élément
significatif. À chaque nouvel élément rencontré, on incrémente un compteur. La boucle
s’arrête soit parce que le compteur vaut k − 1, soit parce que p pointe vers l’élément
bidon. Si le compteur vaut k − 1, p pointe vers le k − 1e élément de la liste, et il suffit
d’insérer après p. Sinon, il n’est pas possible d’insérer car la liste ne contient pas assez
d’éléments.
Remarquons que cette fonction est également correcte dans le cas de l’insertion
en tête. En effet, dans ce cas k = 0, et on ne rentre donc pas dans la boucle. p garde
donc sa valeur initiale, à savoir L. On réalise donc l’insertion après l’élément bidon, ce
qui est bien une insertion en tête. Pour autant, celle-ci n’a pas demandé de traitement
particulier, comme c’était le cas dans les listes simples.
Comme cette fonction doit parcourir la liste afin d’atteindre le ke élément, elle est
en O(k).
Elem * ListeVide()
début
Elem * p := newElem ;
[Link] := p ;
retourner p ;
fin
Booléen EstVide(Elem * L)
début
si [Link] = L alors retourner vrai ;
sinon retourner faux ;
fin
Elem * PremierElem(Elem * L)
début
si EstVide(L) alors retourner NIL ;
sinon retourner [Link] ;
fin
Elem * DernierElem(Elem * L)
début
Elem * p ;
si EstVide(L) alors p := NIL ;
sinon
p := [Link] ;
tant que [Link] 6= L faire
p := [Link] ;
retourner p ;
fin
Algorithme 16 : Algorithmes de manipulation de listes circulaires avec élément pré-
tête (1).
62 CHAPITRE 4. LES LISTES
N I 1 2 3 4 N I
L L
Comme dans le cas des listes simplement liées, le dernier élément de la liste aura
son champ next à NIL. De façon symétrique, le premier élément de la liste aura son
champ prec à NIL également, puisqu’aucun élément ne le précède. La Fig. 4.9 présente
une illustration de liste doublement liée.
Naturellement, ce pointeur supplémentaire alourdit la structure. Néanmoins, cette
adjonction offre plus de flexibilité dans les modalités de parcours de la liste, qui peut
maintenant être traversée « en arrière ». Illustrons cela par l’algorithme suivant. Nous
désirons écrire une fonction qui reçoit une liste L, ainsi que deux entiers i et j. Cette
fonction doit insérer, dans la liste L, un nouvel élément dont l’information est i, et ce,
avant la première occurrence d’un élément portant l’information j. Cette insertion est
illustrée à la Fig. 4.10.
La fonction InsèreAvantJ est donnée à l’Algorithme 18. Elle consiste en un par-
cours de la liste à l’aide du pointeur p, à partir de la tête. Ce parcours s’arrête soit parce
que [Link] = j, auquel cas il faut insérer avant p ; soit parce que p = NIL, auquel cas
la liste ne contient pas d’élément dont l’information est j.
Dans le cas où l’insertion doit être effectuée, celle-ci est rendue plus simple par le
pointeur prec, qui permet d’accéder directement à l’élément précédent celui pointé par
p. Sans ce pointeur, il eût été nécessaire de maintenir un second pointeur q qui pointe
toujours sur l’élément précédent p, ce qui alourdit le parcours.
64 CHAPITRE 4. LES LISTES
N I 1 2 4 5 N I
L L
F IG . 4.10 – Un exemple d’insertion dans une liste doublement liée. On insère un élé-
ment portant la valeur 3 avant l’élément portant la valeur 4.
Par contre, l’insertion à proprement parler est plus complexe que dans le cas d’une
liste simplement liée, puisqu’il y a maintenant quatre (et non plus deux) pointeurs à
modifier.
Remarquons que les deux extensions des listes que nous venons de présenter peuvent
être combinées. Il est en effet tout à fait possible d’envisager des listes doublement liées
circulaires avec élément pré-tête. Dans ce cas, le premier élément de la liste ne sera pas
significatif, et aura son champ prec qui pointe vers le dernier élément de la liste. Ce
dernier aura son champ next qui pointe vers l’élément pré-tête.
listes.
66 CHAPITRE 4. LES LISTES
1 2 3 4 5 . . .
? ?
7 1 4 2 2
? ?
1 4 1 3 5
1 2 4 7 N I L
et Algorithme 13) permet de voir qu’il s’agit véritablement d’une traduction : rien ne
change sur le plan algorithmique.
4.5.1 Application
L’utilité de ces listes n’est peut être pas évidente au premier abord. Une première
justification de ces techniques est que certains langages (comme C OBOL) n’admettent
pas l’utilisation de pointeurs. Une autre utilité de ces listes consiste à optimiser certains
algorithmes travaillant sur des vecteurs, en le tranformant temporairement en liste, par
les moyens décrits ci-dessus. Illustrons cela à l’aide d’un algorithme recevant deux
vecteurs d’entiers V et W (de tailles n et m), qui sont triés par ordre croissant, et qui
doit insérer dans V tous les éléments de W , tout en gardant le caractère trié de V .
On supposera que V est suffisament grand que pour réaliser ce traitement. Pour ce
faire, on supposera que seules les ℓ premières cases de V contiennent de l’information
significative. Les cases ℓ + 1 à n sont non-initialisées.
Un tel algorithme demandera naturellement d’effectuer plusieurs déplacements d’élé-
ments dans le vecteur V pour « faire de la place » aux éléments de W qui doivent y être
insérés. Commençons par décrire une solution « standard » à ce problème (voir Al-
gorithme 20). Nous donnons d’abord une fonction DécallageVecteur, qui reçoit un
vecteur V (de taille n) et un entier i, et qui décalle toutes les cases d’indice ≥ i d’une
position vers la droite (la dernière case est donc perdue). Cette fonction parcourt le
vecteur V depuis sa penultième case jusqu’à sa case d’indice i, à l’aide d’une variable
j ; et déplace chaque case de V à l’aide de l’instruction V [ j + 1] := V [ j].
Nous pouvons maintenant écrire la fonction FusionVecteur. Celle-ci reçoit les
deux vecteurs V et W , ainsi que le nombre ℓ de cases de V qui contiennent de l’infor-
4.5. IMPLÉMENTATION DES LISTES DANS LES VECTEURS 67
Ainsi, une liste contenant un seul élément est vue comme un élément suivi de la
liste vide. Une liste de k éléments est un élément suivi d’une liste de k − 1 éléments,
etc.
4.6.1 Algorithmes
Il est maintenant aisé de donner des algorithmes récursifs sur les listes. À titre
d’exemple, nous considérons la recherche d’une valeur i et l’insertion en ke position.
Ces algorithmes sont présentés à l’Algorithme 22.
La recherche d’une valeur i La fonction Recherche reçoit une liste L et une valeur
i, et renvoie un pointeur vers la première occurrence d’un élément contenant i, ou NIL
s’il n’existe pas de tel élément. Dans le cas où la liste L est la liste vide, la fonction
peut directement renvoyer NIL. Sinon, la liste est composée d’une tête, et d’une liste
dont la tête est [Link]. Dans ce cas, deux possibilités se présentent. Soit la tête contient
i, auquel cas la fonction renvoie L. Soit la tête ne contient pas i, et la fonction effectue
un appel récursif pour obtenir un pointeur vers un élément contenant i dans la liste dont
la tête est [Link].
70 CHAPITRE 4. LES LISTES
i := 1 ;
j := 1 ;
k := −1 ;
tant que j ≤ m faire
V [ℓ + j] := W [ j] ;
tant que i 6= −1 et V [i] < W [ j] faire
k := i ;
i := N[i] ;
si k = −1 alors
N[ j + ℓ] := L ;
L := j + ℓ ;
sinon
N[k] := j + ℓ ;
N[ j + ℓ] := i ;
j := j + 1 ;
i := 1 ;
j := 1 ;
tant que i 6= −1 faire
tmp[i] := V [ j] ;
i := i + 1 ;
j := N[ j] ;
i := 1 ;
tant que i ≤ ℓ + m faire
V [i] := tmp[i] ;
i := i + 1 ;
fin
Algorithme 21 : Une solution au problème de fusion des vecteurs triés, version
« listes ».
4.6. VUE RÉCURSIVE 71
Dans ce chapitre, nous allons étudier deux nouvelles structures de données très
couramment utilisées : les files et les piles.
Les noms de ces structures évoquent clairement leur fonctionnement respectif. Une
file (ou queue en anglais) contiendra des informations ordonnées de façon linéaire
(comme dans une liste). Comme dans une file d’attente dans un commerce, il ne sera
possible d’ajouter de l’information qu’à la fin de la file, et de n’en prélever qu’au début.
Une pile (ou stack en anglais), au contraire, se comportera comme une pile d’assiettes :
on n’aura accès qu’à l’information se trouvant au sommet de la pile, que ce soit lors de
l’ajout ou lors de la lecture.
Les files et les piles ont énormément d’applications en informatique. Elles sont
présentes dans de nombreux composants d’un système d’exploitation. Elles sont égale-
ment nécessaires pour définir de façon simple et élégante certains algorithmes, comme
nous les verrons à la fin de ce chapitre, et dans le chapitre suivant.
Étant donné que les informations stockées dans une file ou dans une pile le sont
de façon linéaire, on aura souvent recours soit aux listes, soit aux tableaux pour implé-
menter ces structures.
73
74 CHAPITRE 5. LES PILES ET LES FILES
On voit donc que la politique d’accès à la pile ne permet de consulter que l’élément
le plus récemment inséré dans la pile, car c’est celui qui est présent au sommet. Au-
trement dit, le premier élément qu’on pourra supprimer d’une pile est celui qui a été
inséré en dernier. Cette constatation, qui résume bien les opérations propres à la pile,
est exprimée en anglais par last in, first out, abrégé en LIFO.
5 4 3 2 N I L
donné que c’est la seule partie de la pile que nous devrons manipuler directement. Les
éléments sont donc stockés dans la liste de haut en bas. La Fig. 5.2 illustre cela.
Ainsi, il est facile d’implémenter les opérations sur la pile à l’aide des opérations
sur la liste :
PileVide Créer une pile vide revient à créer une liste vide, grâce à ListeVide.
Push Le Push revient à effectuer une insertion en tête dans la liste, ce qui peut se faire
à l’aide d’InsèreTête.
Pop Le Pop revient à faire une suppression en tête, à l’aide de SuppressionPremier.
Top Le Top revient à consulter l’information stockée dans le premier élément, auquel
on accède à l’aide de la fonction PremierElem.
EstVide La pile est vide si et seulement si la liste est vide. On fait donc appel à
EstVide.
Remarquons que toutes ces fonctions sont correctes indépendemment du type de liste
(simplement ou doublement liée, avec ou sans élément pré-tête) choisi.
Comme une pile est une liste, on définira le type Pile comme une structure conte-
nant simplement un pointeur vers la tête de la liste. Ainsi, on cache les détails d’implé-
mentation, et les signatures des fonctions seront les mêmes si on choisit d’implémenter
la pile autrement (voir section suivante) :
struct Pile
Elem * som ;
À titre d’exemple, l’Algorithme 23 donne le code de ces cinq primitives dans le cas
d’une pile d’entiers.
PileVide(Pile & P)
début
ListeVide([Link]) ;
fin
Entier Top(Pile P)
début
Elem * p :=PremierElem([Link]) ;
retourner [Link] ;
fin
Booléen EstVide(Pile P)
début
retourner EstVide([Link]) ;
fin
Algorithme 23 : L’implémentation d’une pile d’entiers dans une liste.
5.2. LES FILES 77
On implémentera donc la pile à l’aide d’un vecteur V et d’une variable entière som,
telles que V [som] contient l’information au sommet de la pile. Dans le cas présent, une
pile sera donc plutôt un enregistrement du type :
struct Pile
Entier V [n] ;
Entier som ;
Exemple 17 Considérons la file vide ε . Effectuons trois Enqueue successifs des va-
leurs 5, 7 et 9. On obtient alors la file 5, 7, 9 (c’est-à-dire la même séquence que dans
78 CHAPITRE 5. LES PILES ET LES FILES
PileVide(Pile & P)
début
[Link] := 0 ;
fin
Pop(Pile & P)
début
[Link] := [Link] − 1;
fin
Cette convention étant fixée, il est facile de traduire les opérations sur une file en
terme d’opérations sur une liste :
FileVide revient à créer une liste vide.
Enqueue revient à ajouter un élément en fin de liste.
Dequeue revient à effectuer une suppression en tête.
First revient à renvoyer l’information stockée dans l’élément de tête.
EstVide revient à tester si la liste est vide.
L’Algorithme 25 détaille ces opérations.
FileVide(File & F)
début
ListeVide([Link]) ;
fin
Dequeue(File & F)
début
SuppressionPremier([Link]) ;
fin
Entier First(File F)
début
Elem * p := PremierElem([Link]) ;
retourner [Link] ;
fin
Remarquons que toutes ces opérations s’effectue en temps constant, sauf l’opéra-
tion Enqueue, qui demande de parcourir toute la liste pour insérer en fin, ce qui est en
O(n). Cette complexité peut être ramenée à O(1) si on garde en permanence un poin-
teur vers le dernier élément de la liste (en plus du pointeur vers la tête). On considère
alors un type File défini comme suit :
struct File
Elem * deb ;
Elem * fin ;
On peut alors adapter les algorithmes manipulant les files pour tenir compte de cette
nouvelle structure. Ceux-ci sont donnés à l’Algorithme 26. Remarquons que, comme
nous utilisons une structure quelque peu différente des listes chaînées simples, nous
sommes obligés d’écrire des algorithmes ad hoc, ce qui est quelque peu moins élégant
que la solution présentée à l’Algorithme 25.
Voici le détail de ces fonctions :
FileVide Il faut prendre garde à mettre la valeur NIL tant dans le pointeur de début
que dans le pointeur de fin.
Enqueue L’accès à la fin de la liste est maintenant simplifié grâce au pointeur de fin.
Il faut d’abord créer l’élément à ajouter, avec l’information i, et le champ next
à NIL (puisque cet élément deviendra le dernier de la liste). Ensuite, on ajoute
le nouvel élément à la suite de celui pointé par [Link], à condition que ce dernier
pointeur ne soit pas nul. Dans le cas contraire, la file est en fait vide, et il suffit
de faire pointer à la fois [Link] et [Link] sur le nouvel élément.
Dequeue La suppression en tête se fait de la même manière que dans une liste simple,
en utilisant [Link] comme pointeur tête. Il faut prendre garde à stocker NIL dans
[Link] si cette suppression donne lieu à une file vide (c’est-à-dire si la file ne
contenait qu’un seul élément avant la suppression).
First Cette opération revient à renvoyer l’information stockée dans l’élément de tête.
EstVide Cette opération revient à tester si la liste est vide, ce qui peut se vérifier en
testant un des deux pointeurs.
FileVide(File & F)
début
[Link] := NIL ;
[Link] := NIL ;
fin
Dequeue(File & F)
début
Elem * p := [Link] ;
[Link] := [Link] ;
delete p ;
si [Link] = NIL alors
[Link] := NIL ;
fin
Entier First(File F)
début
retourner [Link] ;
fin
valeurs dans la case f in, que l’on incrémentera alors. Symétriquement, un Dequeue
s’obtiendra simplement en incrémentant deb.
Malheureusement, cette simple politique de gestion du vecteur ne saurait être sa-
tisfaisante. Observons l’exemple de la Fig. 5.3. On y a représenté différentes étapes de
manipulation d’un vecteur contenant une file, et la file correspondante. Initialement, la
file est vide. On effectue ensuite 4 Enqueue, ce qui fait croître la valeur de f in. Puis,
on effectue deux Dequeue. Comme on le voit sur la figure, les cases 1 et 2 deviennent
alors inutilisables, car les éléments qui seront ensuite ajoutés apparaîtront à droite de
la case d’indice f in. Lorsque cet indice atteindra la dernière case du vecteur, il ne sera
plus possible de faire de nouveaux Enqueue. Ainsi, chaque Dequeue diminue la taille
potentielle de la file.
On peut palier ce problème en considérant que le vecteur est circulaire, tel qu’illus-
tré à la Fig. 5.4. Cela signifie qu’on considère qu’il existe une case « à droite » de la
case d’indice n, qui est la case d’indice 1. Ainsi, quand on incrémente f in ou deb, et
que la valeur d’un des deux dépasse n, il convient de la remettre à 1. Ceci peut aisément
s’obtenir à l’aide de l’opérateur modulo.
Dans le cadre de ce cours, nous avons adopté la convention de numéroter les cases
du vecteur de 1 à n. Ainsi, l’opération d’incrémentation « circulaire » d’un indice i
parcourant le tableau est i := (i mod n) + 1. Dans le cas où les cases sont numérotées
de 0 à n − 1 (comme dans le langage C ou C++, par exemple), l’opération devient
i := (i + 1)mod n.
Avec cette politique de gestion du vecteur, si la file est stockée dans un vecteur de
taille n, il est à tout moment possible d’y maintenir au plus n éléments, et ce, quelque
soient les opérations qui ont déjà été effectuées. Les fonctions qu’on obtient alors sont
données à l’Algorithme 27. Voici leurs descriptions :
FileVide Il suffit de stocker 1 dans [Link] et [Link].
Enqueue La nouvelle valeur doit être stockée dans la case d’indice [Link] (pour rappel,
cet indice indique la première case libre, et non pas la dernière case occupée).
On doit ensuite incrémenter [Link], en tenant compte du caractère circulaire du
vecteur.
Dequeue Cette opération consiste simplement à incrémenter [Link], de façon circu-
laire. Les informations supprimées restent dans le vecteur, mais sont ignorées.
First La case contenant le début de la file est celle d’indice [Link]. On renvoie simple-
ment son contenu.
EstVide La file est vide si et seulement si les indices de début de fin sont égaux.
Attention, ils ne sont pas forcément égaux à 1 (ils peuvent avoir avancé au fur et
à mesure des Enqueue et Dequeue).
5.3 Applications
Terminons ce chapitre en présentant deux applications des piles (nous verrons
d’autres applications des piles et des files dans le chapitre suivant).
Outre ces applications que nous donnons à titre d’exemple, rappelons tout de même
que les files et les piles ont de nombreuses applications dans les systèmes d’exploita-
tion et les compilateurs. La partie de la mémoire qui retient le contexte d’un processus
lorsque celui-ci est swappé out est gérée comme une pile (il s’agit du stack système).
5.3. APPLICATIONS 83
d e b
fi n
? ? ? ? ?
d e b fi n
5 7 9 1 1
d e b fi n
5 7 9 1 1
fi n d e b
5 7 9 1 1 1 3
F IG . 5.3 – Un exemple de manipulation de file dans un vecteur. Les cases grisées sont
celles qui font partie de la file.
84 CHAPITRE 5. LES PILES ET LES FILES
FileVide(File F)
début
[Link] := 1 ;
[Link] := 1 ;
fin
Enqueue(File F, Entier i)
début
F.V[[Link]] := i ;
[Link] := ([Link] n) + 1 ;
fin
Booléen EstVide(File F)
début
si [Link] = [Link] alors
retourner vrai ;
sinon
retourner faux ;
fin
Algorithme 27 : L’implémentation d’un file dans une vecteur (circulaire).
86 CHAPITRE 5. LES PILES ET LES FILES
Un compilateur gère les contextes des fonctions à l’aide d’une pile également : la fonc-
tion courante a son contexte stocké au sommet de la pile, et les fonctions appelantes
ont leurs contextes respectifs stockés dans la pile, selon l’ordre des appels. Ainsi, un
appel de fonction correspond à un Push, et un retour à un Pop. Enfin, les files trouvent
une application naturelle chaque fois qu’une file d’attente est nécessaire, comme, par
exemple, dans un spooler d’impression, ou dans un serveur web.
L’algorithme que nous proposons reçoit une expression S, telle que décrite ci-
dessus, et accède à chacun des n éléments qui la composent séquentiellement, et que
nous dénotons par S1 , S2 , . . . , Sn . Par exemple, dans (13+2), nous avons S1 =), S2 = 13,
S3 = +, etc.
Cet algorithme considère chaque élément Si l’un après l’autre (i = 1, . . . n). À tout
moment, il doit savoir quelles sont les parenthèses qui sont encore ouvertes, et il doit
également savoir dans quelle ordre ces parenthèses ont été ouvertes. Ainsi, quand il
rencontre une parenthèse fermante, il doit simplement comparer cette parenthèse fer-
mante à la dernière parenthèse ouvrante qui n’a pas encore été fermée, et vérifier que
les types correspondent.
On voit donc qu’il est nécessaire de pouvoir stocker toutes les parenthèses ou-
vrantes, dans l’ordre où elles ont été rencontrées, tout en ne devant maintenir un accès
direct qu’à la dernière parenthèse ouverte qui n’a pas encore été fermée. Ceci peut
aisément être obtenu à l’aide d’une pile :
– À chaque parenthèse ouvrante rencontrée, on effectue un Push.
– À chaque parenthèse fermante rencontrée, on vérifie que la pile n’est pas vide à
l’aide de EstVide. Si la pile est vide, on a trouvé une parenthèse fermante qui
n’a pas été ouverte. Sinon, on vérifie à l’aide de Top que la parenthèse fermante
correspond bien à la dernière parenthèse ouvrante, et on effectue une Pop.
– On ignore les autres caractères.
5.3. APPLICATIONS 87
À la fin du parcours de l’expression, la pile doit être vide, car, dans le cas contraire,
cela signifie qu’une parenthèse ouvrante n’a pas été fermée. L’Algorithme 28 présente
cette solution.
entier, nous devons le retenir jusqu’à ce que nous trouvions l’opérateur qui va lui être
appliqué (et qui le suit forcément). Chaque fois que nous rencontrons un opérateur,
nous savons qu’il s’applique aux deux dernières valeurs calculées. Nous devons donc
retenir toutes les valeurs qui ont été lues, et qui n’ont pas encore servi dans une opé-
ration. Ces valeurs doivent être retenues dans l’ordre où elles ont été lues. Après avoir
effectué le calcul, nous devons également retenir le résultat, puisqu’il pourrait lui aussi
être l’opérande d’un autre opérateur (par exemple, dans 3 2 + 5 ∗, il faut retenir le
résultat de 3 2 +, puisqu’il devra être multiplié par 5).
À nouveau, nous voyons que la mémoire dont nous avons besoin suit une politique
de pile : à tout moment, nous devons retenir toutes les dernières valeurs lues ou cal-
culées, et ce, dans l’ordre où elles l’ont été. Mais pour appliquer un opérateur, nous
n’avons besoin que des deux dernières valeurs.
Notre algorithme parcourra donc l’expression depuis Si jusqu’à Sn , et fonctionnera
ainsi :
– Chaque valeur entière rencontrée est stockée sur la pile grâce à Push.
– À chaque opérateur, on Pop les deux dernières valeurs de la pile, on effectue
l’opération et on Push le résultat.
À la fin, la valeur de l’expression se trouve au sommet de la pile. L’Algorithme 29
présente cette procédure.
5.3. APPLICATIONS 89
Les arbres
6.1 Introduction
Les structures que nous avons considérées jusqu’à présent sont uniquement des
structures linéaires, dans le sens où :
1. chaque élément de la structure possède au plus un prédécesseur direct, et
2. chaque élément de la structure possède au plus un successeur direct.
En effet, dans un tableau de taille n, chaque case i (pour 1 < i < n) possède comme
unique prédécesseur la case i − 1, et comme unique successeur la case i + 1. La case
0 n’a pas de prédécesseur, mais la case 1 est son unique successeur. La case n n’a elle
pas de successeur, mais bien un prédécesseur : la case n − 1. Dans les listes, chaque
élément possède un seul et unique champ next, qui référence un successeur unique, ou
qui contient un valeur conventionnelle pour indiquer qu’il n’y a pas de successeur. Par
ailleurs, la tête n’a pas de prédécesseur, et, pour tous les autre éléments e , il n’existe
qu’un seul autre élément e′ dont le champ next référence e. Enfin, les piles et files sont
essentiellement des listes.
Nous allons considérer, dans ce chapitre, une généralisation de ces structures li-
néaires en levant la restriction numéro 2 : chaque élément aura au plus un seul prédé-
cesseur, mais sera autorisé à avoir plusieurs successeurs1 . On a dès lors affaire à une
structure qui se ramifie, puisque, lors d’un parcours de cette structure, on a, à chaque
élément, plusieurs choix possibles pour « continuer le parcours ». C’est pour cette rai-
son que ce type de structures est appelé un arbre2 .
L’importance des arbres en informatique est considérable. D’une part, les struc-
tures de type arborescent se rencontrent naturellement dans plusieurs cas pratiques de
la vie quotidienne que l’on désire modéliser et manipuler à l’aide d’algorithmes. Par
exemple l’organisation hiérarchique d’une entreprise est un arbre. La Fig. 6.1 en donne
un exemple. D’autre part, les arbres jouent un rôle très important dans de nombreux
algorithmes, et ils sont la clef de voûte indispensable pour rendre ces algorithmes effi-
caces. Sans arbre, pas de base de données, pas de système de fichiers, pas de compres-
sion MP3, etc.
1 Nous supposerons également qu’il n’y a pas de cycle dans la structure.
2
Remarquons que si nous relâchons les deux hypothèses ci-dessus, à savoir qu’on autorise également
chaque élément à avoir plusieurs prédécesseurs, on obtient un graphe. Nous n’aborderons pas les graphes
dans ce cours.
91
F IG . 6.1 – Un exemple d’arbre, qui représente l’organisation hiérarchique d’une entreprise (un peu fantaisiste).
e
r
e
l
t
c
e
i
s
d
e
v
n
r
h
ä
e
c
H
S r
O
e
n
n
n
o
i
a
q
u
m
s
o
i
e
r
l
v
i
e
a
T
D
b
"
e
n
e
s
c
n
o
i
i
h
v
ug
t
ü
i
r
r
r
s
e
B
O
o
S
p
m
h
o
c
a
c
"
B
.
t
S
.
n
J
e
m
n
e
e
r
n
t
c
i e a
r
m
v d
a
u
r
e
p
i
h
e
é
c
L
S
S
D
e
t
n
u q
r
i
o
e
i
t
b
s
n
i
u
h
a
v
i
c
m
S
D
o
e
i
r
n
e
e
n
c
v
i
o
o
v
h
h
t
r
p
e
e
m
e
S
y
B
S
CHAPITRE 6. LES ARBRES 92
6.1. INTRODUCTION 93
6.1.1 Définitions
Il est possible de donner deux types de définitions des arbres : une définition « clas-
sique », en termes d’ensembles ; ou bien une définition récursive. Suivant la définition
qu’on adopte, on pourra formuler les algorithmes de façon itérative ou récursive.
Voici d’abord la définition ensembliste :
Remarquons que les points 2 et 3 de cette définition obligent chaque nœud (à part
la racine) à posséder exactement un prédécesseur. En effet, le point 2 indique que le
nombre de prédécesseur de chaque nœud est ≤ 1, et le point 3 implique que chaque
nœud (à part la racine) doit posséder un nombre de prédécesseurs ≥ 1.
Illustrons cette définition à l’aide d’un exemple :
Comme on le voit, la définition d’un arbre est plus complexe que celle d’une liste ou
d’une autre séquence linéaire. On peut néanmoins obtenir une définition relativement
simple si on admet une définition récursive :
Exemple 21 Reprenons l’arbre de la Fig. 6.2. Nous avons illustré la vue récursive de
cette arbre à la Fig. 6.3. Cet arbre est maintenant vu de la façon suivante : il est com-
posé du nœud n1 (qui constitue sa racine) et de trois sous-arbres A, B et C, représentés
en grisé sur la figure. A est un arbre dont la racine est n2 , et dont les sous-arbres sont
D et E. C est un arbre dont la racine est n4 et dont le sous-arbre est F. B, D, E et F
sont des arbres composés de leur seule racine (respectivement n3 , n5 , n6 , n7 ).
94 CHAPITRE 6. LES ARBRES
F IG . 6.2 – Un exemple d’arbre. Les informations associées aux nœuds ne sont pas
représentées.
n 1
A B C
n 2 n 3 n 4
D E F
n 5 n 6 n 7
Il n’est pas difficile de se convaincre que les deux définitions sont équivalentes. En
effet, l’arbre vide de la Définition 4 correspond à un arbre dont l’ensemble des nœuds
est vide. Un nœud n de la Définition 3 correspond à un nœud n′ dans la Définition 4, tel
que Λ (n′ ) = Λ (n), et tels que les successeurs de n′ sont des arbres A1 , A2 , . . . , Ak dont
les racines r1 , r2 , . . . rk constituent l’ensemble Succ (n).
6.1.2 Vocabulaire
Un certain vocabulaire conventionnel est associé aux arbres. Il permet en général de
simplifier l’exposé des algorithmes sur les arbres. Nous présentons ce vocabulaire dans
les cadre de la définition ensembliste, mais il peut aisément être transposé dans le cadre
de la vue récursive. Toutes ces définitions sont illustrées par des exemples ci-dessous.
Fils : Les nœuds de l’ensemble Succ (n) sont appelés les fils de n.
Père : Le père d’un nœud n est l’unique nœud n′ dont n est le fils. Remarquons que la
racine n’a pas de père.
Feuille : Une feuille est un nœud qui n’a pas de fils.
Nœud interne : Un nœud est un nœud interne si et seulement si il n’est pas une feuille.
Chemin : Un chemin dans un arbre est une séquence n1 n2 · · · nk de nœuds de l’arbre
telle que, pour tout 1 ≤ i < k : ni+1 ∈ Succ(ni ). On dit que ce chemin va de
n1 à nk . La longueur d’un chemin n1 n2 · · · nk est le nombre k de nœuds qui le
composent.
Remarquons que, d’après les définitions d’un arbre, il n’y a qu’un seul chemin
allant de la racine à un nœud n donné.
Accessible : Un nœud n est accessible à partir d’un nœud n′ s’il existe un chemin
allant de n à n′ dans l’arbre.
Descendant : Un nœud n est un descendant d’un nœud n′ si et seulement si n est
accessible à partir de n′ .
Hauteur : La hauteur d’un arbre est la longueur du plus long chemin partant de la
racine de l’arbre. Dans la suite, la hauteur d’un arbre A est notée hauteur(A).
Profondeur : La profondeur d’un nœud n de l’arbre est la longueur du chemin allant
de la racine à n.
P e e s e s
r o f o n d u r d n o u d
n 1
h
e
a u t u r = 4
F i l s e
d n 4
n 2 n 3 n 4
n 5 n 6 n 7 n 8 n 9
n 1 0 n 1 1 n 1 2
D e s c e s e
n d a n t d n 4
C h h
e m i e l e
n ( d o n n a a u t u r )
h
A e c e m i
u t r n
n 1
n 2 n 3
n 4 n 5 n 6
Arbres ordonnés Dans les définitions données au début de ce chapitre, nous avons
toujours considéré qu’un nœud possédait un ensemble de fils, ce qui signifie qu’il
n’existe pas d’ordre sur ces fils. En pratique, on fixera souvent un ordre, et on parlera
alors du premier fils, du second fils, du dernier fils, etc.
Définition 5 Un arbre n-aire est un arbre dont chaque nœud possède au plus n fils.
Le cas le plus utilisé en pratique (et dans la suite de ce cours), et l’arbre binaire, où
chaque nœud possède au plus n = 2 fils. Dans la suite, nous supposerons implicitement
qu’un arbre binaire est ordonné, et nous appellerons respectivement fils gauche et fils
droit le premier et le second fils d’un nœud. Dans les cas des arbres binaires (ordonnés)
nous autoriserons un nœud à posséder un fils droit mais pas de fils gauche. (ce qui
revient à supposer que le fils gauche est vide).
Exemple 23 La Fig. 6.5 présente un exemple d’arbre binaire. En effet, les nœuds n1 et
n2 ont deux fils ; le nœuds n3 a un fils ; et les nœuds n4 , n5 et n6 n’ont pas de fils. n2 est
le fils gauche de n1 et n3 est le fils droit de n1 .
1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987, 1597, 2584, 4181, . . .
3 D’après le nom du mathématicien italien Leonardo F IBONACCI (c. 1175 - c. 1250). Son Liber Abaci
contribua à introduire les chiffres arabes en occident. C’est dans ce livre qu’on trouve un exercice consistant
à calculer la séquence qui porte son nom. Néanmoins, cette séquence était déjà connue des mathématiciens
indiens, bien avant lui.
6.1. INTRODUCTION 99
Il a été établi que cette suite a plusieurs connexions avec le nombre d’or4 ϕ qui
vaut :
√
1+ 5
ϕ= ≈ 1, 618 . . .
2
Plus précisément, on sait que :
ϕi
∀i ≥ 1 : Fi ≥ √ − 1 (6.1)
5
Tous ces résultats peuvent être trouvés dans des ouvrages classiques de mathéma-
tiques discrètes, comme [5]. Nous pouvons maintenant prouver notre théorème :
Preuve. Nous allons tout d’abord prouver que si A est un arbre de n nœuds et de
hauteur h, alors Fh ≤ n, c’est-à-dire qu’un tel arbre contient nécessairement un nombre
de nœuds qui est supérieur au he nombre de Fibonacci. Nous allons établir ce résultat
par induction sur h.
n = n1 + n2 + 1
> n1 + n2
≥ Fℓ−1 + Fℓ−2
= Fℓ
= Fh
Nous savons donc maintenant que pour tout arbre binaire équilibré possédant n
4
Le nombre d’or est une valeur qui était régulièrement utilisée dans les mesures des statues ou des temples
grecs. Cette proportion était considérée par les Grecs comme particulièrement harmonieuse. Le nom ϕ est
choisi en hommage au sculpteur Feida (Phidias).
100 CHAPITRE 6. LES ARBRES
6.1.4 Implémentation
Nous pouvons maintenant nous tourner vers des considérations plus pratiques, à
savoir l’implémentation de la structure d’arbre. L’implémentation que nous allons pré-
senter ici utilise des pointeurs et est complètement dynamique. Remarquons qu’il est
également possible d’implémenter un arbre dans un tableau, mais nous ne discuterons
pas cette possibilité ici.
De manière générale, un arbre est composé de nœuds qui possèdent chacun un
certain nombre de successeurs, que nous avons appelés fils. La structure de base sera
donc celle qui permet d’implémenter un nœud, et un arbre sera essentiellement un
pointeur vers le nœud racine (ou bien NIL si l’arbre est vide).
Comme, de manière générale, le nombre de fils d’un nœud n’est pas borné, il faut
prévoir, dans la structure qui représente un nœud, une liste contenant les adresses de
tous les fils du nœud. On obtient alors les déclarations suivantes. Tout d’abord, les élé-
ments de la liste de successeurs :
struct ElemArbre
Nœud * info ;
ElemArbre * next ;
Puis, les nœuds à proprement parler :
struct Nœud
Entier info ;
ElemArbre * succ ;
En pratique, la gestion d’une liste pour stocker les adresses des fils d’un nœud est
souvent considérée comme lourde, surtout si on a affaire à un arbre ordonné et que l’on
désire pouvoir accéder immédiatement au ke fils d’un nœud. C’est pourquoi on adopte
souvent l’hypothèse simplificatrice qui consiste à considérer qu’on ne manipule que
des arbres N-aires, pour un N « suffisamment grand ». On peut dès lors stocker les
adresses des fils dans un tableau de successeurs, les cases inutilisées étant initialisées
à NIL :
struct Nœud
Entier info ;
Nœud * succ[N] ;
6.1. INTRODUCTION 101
Finalement, dans le cas des arbres binaires – de loin les arbres les plus courants –
on peut explicitement différencier fils gauche et fils droit dans deux champs du nœud :
struct Nœud
Entier info ;
Nœud * fg ;
Nœud * fd ;
ArbreVide(Nœud * & A)
début
A := NIL ;
fin
InsèreGauche(Nœud * A, Entier i)
début
[Link] := new Nœud ;
[Link] := i;
[Link] := NIL;
[Link] := NIL;
fin
InsèreDroite(Nœud * A, Entier i)
début
[Link] := new Nœud ;
[Link] := i;
[Link] := NIL;
[Link] := NIL;
fin
Algorithme 30 : Quelques fonctions de base pour manipuler un arbre binaire stockant
des entiers.
6.1. INTRODUCTION 103
Booléen EstVide(Nœud * A)
début
si A = NIL alors
retourner vrai ;
sinon
retourner faux ;
fin
Booléen EstFeuille(Nœud * A)
début
si EstVide(FilsGauche(A)) et EstVide(FilsDroit(A)) alors
retourner vrai ;
sinon
retourner faux ;
fin
Nœud * FilsGauche(Nœud * A)
début
retourner [Link] ;
fin
Nœud * FilsDroit(Nœud * A)
début
retourner [Link] ;
fin
Entier InfoRacine(Nœud * A)
début
retourner [Link] ;
fin
Algorithme 31 : Quelques fonctions de base pour manipuler un arbre binaire stockant
des entiers (suite)
104 CHAPITRE 6. LES ARBRES
l’arbre correspondant à 3 + 5, et le fils droit est étiqueté par 2. Ceci est illustré à la
Fig. 6.6. Comme on le voit, la priorité fixée par les parenthèses est implicite dans
l’arbre : afin de pouvoir évaluer le ∗, on devra d’abord évaluer le +, puisque celui-ci
est son fils gauche.
Nous pouvons maintenant définir un arbre d’expression :
Définition 8 Un arbre d’expression est un arbre binaire, tel que :
– Chaque feuille est étiquetée par un nombre entier i, et représente l’expression i ;
– Chaque nœud interne :
– est étiqueté par un opérateur op ;
– possède un fils gauche f g et un fils droit f d ;
– représente l’expression E1 op E2 , où E1 est l’expression représentée par f g,
et E2 est l’expression représentée par f d.
Nous verrons, dans la section suivante, que les algorithmes généraux de parcours
permettent de manipuler de façon très élégante ces arbres d’expression.
parcours, c’est-à-dire de procédure traversant tous les nœuds de l’arbre et les traitant
chacun une et une seule fois à un moment précis. L’ordre dans lequel les nœuds sont
traités fixe l’ordre sur les nœuds.
Dans cette section, nous étudions quatre parcours. Les trois premiers suivent le
canevas que nous venons d’exposer (traitement de la racine, du fils gauche et du fils
droit), en faisant varier le moment auquel la racine est traitée. Ces parcours sont très
faciles à exprimer de façon récursive, mais nous en étudierons également des versions
itératives, qui utilisent une pile. Nous étudierons ensuite le parcours par niveaux, qui
fait appel à la structure de file du Chapitre 5. Ces parcours sont illustrés à la Fig. 6.7 et
sont donnés aux Algorithmes 32 à 37.
ParcoursPréfixé(Nœud * A)
début
si ¬EstVide(A) alors
Traiter A ;
ParcoursPréfixé(FilsGauche(A)) ;
ParcoursPréfixé(FilsDroit(A)) ;
fin
ParcoursInfixe(Nœud * A)
début
si ¬EstVide(A) alors
ParcoursInfixe(FilsGauche(A)) ;
Traiter A ;
ParcoursInfixe(FilsDroit(A)) ;
fin
ParcoursPostfixé(Nœud * A)
début
si ¬EstVide(A) alors
ParcoursPostfixé(FilsGauche(A)) ;
ParcoursPostfixé(FilsDroit(A)) ;
Traiter A ;
fin
Algorithme 32 : Le trois parcours « de base » exprimés de façon récursive.
P
c
x
r
t
P
a
fi
n
é
r
i
a
e
p
p
CHAPITRE 6. LES ARBRES
0
1
9
2
8
4
1
3
6
5
8
1
2
3
4
7
5
1
8
0
106
x
r
i
a
a
p
F IG . 6.7 – Les quatre parcours d’arbre classiques. La flèche en pontillé indique l’ordre des appels. Un point à proximité d’un nœud indique à quel
moment le nœud sera effectivement traité lors du parcours.
6.2. PARCOURS DES ARBRES BINAIRES 107
ParcoursPréfixé(Nœud * A)
début
Pile P ;
PileVide(P) ;
Push(P, (A, d)) ;
tant que ¬EstVide(P) faire
(A, action) := Top(P) ;
Pop(P) ;
si ¬EstVide(A) alors
si action = d alors
Push(P, (FilsDroit(A), d)) ;
Push(P, (FilsGauche(A), d)) ;
Push(P, (A, t)) ;
sinon
Traiter A ;
fin
Algorithme 33 : Le parcours préfixé exprimé de façon itérative.
ParcoursInfixe(Nœud * A)
début
Pile P ;
PileVide(P) ;
Push(P, (A, d)) ;
tant que ¬EstVide(P) faire
(A, action) := Top(P) ;
Pop(P) ;
si ¬EstVide(A) alors
si action = d alors
Push(P, (FilsDroit(A), d)) ;
Push(P, (A, t)) ;
Push(P, (FilsGauche(A), d)) ;
sinon
Traiter A ;
fin
Algorithme 34 : Le parcours infixe exprimé de façon itérative.
108 CHAPITRE 6. LES ARBRES
ParcoursPostfixé(Nœud * A)
début
Pile P ;
PileVide(P) ;
Push(P, (A, d)) ;
tant que ¬EstVide(P) faire
(A, action) := Top(P) ;
Pop(P) ;
si ¬EstVide(A) alors
si action = d alors
Push(P, (A, t)) ;
Push(P, (FilsDroit(A), d)) ;
Push(P, (FilsGauche(A), d)) ;
sinon
Traiter A ;
fin
Algorithme 35 : Le parcours infixe exprimé de façon itérative.
récursif sur n2 effectue le traitement de n2 , ainsi qu’un nouvel appel sur n4 , ce qui met
l’appel récursif sur n5 « en attente », mais avant l’appel sur n3 lui aussi « en attente ».
Une façon de résumer cette explication, consiste à exprimer le travail effectué par
l’algorithme sous forme d’une séquence d’actions, qui peuvent être de deux types :
1. soit l’algorithme doit effectuer l’appel récursif sur un nœud, ce que nous appelle-
rons développer le nœud. L’action consistant à développer l’arbre dont la racine
est n sera indiquée dans la séquence par (n, d).
2. soit l’algorithme doit traiter le nœud. L’action consistant à traiter l’arbre dont la
racine est n sera indiquée dans la séquence par (n, t).
Les actions présentes dans la séquence sont exécutées l’une après l’autre par l’algo-
rithme : la première est donc l’action courante et les suivantes sont en attente. Exécu-
ter l’action consiste parfois à remplacer cette action par plusieurs autres actions dans la
séquence : dans le cas du développement de A, on doit insérer les actions qui indiquent
qu’il faut traiter A et développer ses deux fils.
Initialement, la seule action qui doit être effectuée est le développement de n1 ,
ce que nous noterons par la séquence suivante : (n1 , d). Développer n1 consiste tout
d’abord à traiter n1 , puis à développer le fils gauche de n1 , puis à développer le fils
droit de n1 . Nous pouvons donc remplacer cette séquence par (n1 t), (n2 , d), (n3 , d).
L’algorithme doit donc maintenant traiter n1 , comme indiqué par le premier élément de
la séquence. Une fois que cela est fait, nous pouvons supprimer (n1 t) de la séquence
des actions à effectuer, et nous voyons qu’il nous reste à effectuer (n2 , d), (n3 , d). Déve-
lopper n2 revient à traiter n2 , puis développer n4 et n5 . Nous obtenons donc la séquence
(n2 , t), (n4 , d), (n5 , d), (n3 , d). On voit que le développement de n3 reste « en attente »,
et que ce développement aura lieu avant les développements de n5 et n3 . L’algorithme
6.2. PARCOURS DES ARBRES BINAIRES 109
ParcoursPréfixé(Nœud * A)
début
Pile P ;
PileVide(P) ;
répéter
Traiter A ;
Push(P, FilsDroit(A)) ;
A := FilsGauche(A) ;
si EstVide(A) et ¬EstVide(P) alors
A := Top(P) ;
Pop(P) ;
jusqu’à EstVide(P) ;
fin
Algorithme 36 : Une version simplifiée du parcours préfixé.
ParcoursLargeur(Nœud * A)
début
File F ;
FileVide(F) ;
Enqueue(F, A) ;
tant que ¬EstVide(F) faire
A := First(F) ;
Dequeue(F) ;
si ¬EstVide(A) alors
Traiter A ;
Enqueue(F, FilsGauche(A)) ;
Enqueue(F, FilsDroit(A)) ;
fin
Algorithme 37 : Le parcours par niveaux d’un arbre binaire.
1
n
3
n
6
n
1
n
3
n
6
n
6
n
6
112
F IG . 6.8 – Une illustration du parcours en largeur. Les nœuds grisés ont été traités. Le contenu de la file à chaque étape est représenté sous l’arbre.
6.2. PARCOURS DES ARBRES BINAIRES 113
racine flanquée de deux sous-arbres, il est clair que la hauteur de l’arbre dépendra des
hauteurs des sous-arbre, auxquelles il faudra ajouter 1 pour la racine. Comme la hau-
teur d’un arbre est définie par rapport à son plus long chemin, il convient de garder la
hauteur du plus haut des deux sous-arbre :
Définition 9 La hauteur hauteur(A) d’un arbre A est :
– 0 si l’arbre est vide
– 1 + max{hauteur((FilsGauche(A)), hauteur((FilsDroit(A))}, sinon
On voit qu’il faut donc calculer la hauteur des deux fils avant de pouvoir calculer la
hauteur de l’arbre. Il s’agit d’un parcours postfixé, comme le montre l’Algorithme 38.
Entier Hauteur(Nœud * A)
début
si EstVide(A) alors
retourner 0 ;
sinon
Entier hg, hd ;
hg := Hauteur(FilsGauche(A)) ;
hd := Hauteur(FilsDroit(A)) ;
retourner 1 + max{hg, hd} ;
fin
Algorithme 38 : Un algorithme récursif pour calculer la hauteur d’un arbre binaire.
Entier ÉvaluationExpression(Nœud * A)
début
si EstVide(A) alors
retourner 0 ;
sinon si EstFeuille(A) alors
retourner InfoRacine(A) ;
sinon
Entier vg, vd ;
vg := ÉvaluationExpression(FilsGauche(A)) ;
vd := ÉvaluationExpression(FilsDroit(A)) ;
si InfoRacine(A) = + alors
retourner vg + vd ;
sinon si InfoRacine(A) = − alors
retourner vg − vd ;
sinon si InfoRacine(A) = ∗ alors
retourner vg ∗ vd ;
sinon
retourner vg/vd ;
fin
Algorithme 39 : Un algorithme récursif pour évaluer un arbre d’expression.
faut prendre garde à entourer cet affichage de parenthèses ouvrantes et fermantes, afin
de s’assurer que la priorité est respectée. En effet, sans cela, l’algorithme afficherait
3 + 5 ∗ 2 quand il est exécuté sur l’arbre de la Fig. 6.6, ce qui n’est pas correct. Avec
les parenthèses, on affiche ((5 + 3) ∗ 2).
Nous obtenons donc une variation du parcours infixe, puisque le contenu de la
racine est affiché, entre les deux appels récursifs qui traitent les fils gauche et droit.
l’Algorithme 40 présente cette solution.
Entier AffichageExpression(Nœud * A)
début
si ¬EstVide(A) alors
si EstFeuille(A) alors
Afficher Info(A) ;
sinon
Afficher ’(’ ;
AffichageExpression(FilsGauche(A)) ;
Afficher Info(A) ;
AffichageExpression(FilsGauche(D)) ;
Afficher ’)’ ;
fin
Algorithme 40 : Un algorithme récursif pour afficher une expression stockée dans
un arbre
Chapitre 7
Algorithmes de recherche
7.1 Introduction
L’objet premier de l’informatique est, comme sont nom le suggère, le traitement de
l’information. Les ordinateurs sont de plus en plus utilisés pour stocker et manipuler
des quantités gigantesques de données, auquel on souhaite un accès rapide et fiable.
Dans ce contexte, les algorithmes de recherche, qui ont pour but de rechercher une
information précise dans une masse d’informations donnée, sont d’une importance ca-
pitale. Il doit être possible de stocker les informations en mémoire de telle manière que
ces algorithmes de recherche soient les plus efficaces possible. Naturellement, l’effica-
cité de ces algorithme dépendra de la manière dont les informations en question sont
structurées.
Dans ce cours, nous avons étudié plusieurs structures de données : les vecteurs, les
listes, les files, les piles et les arbres. Il est clair que les files et les piles ne conviennent
pas à la recherche d’informations, car elles ne permettent d’accéder qu’à un seul des
éléments qu’elles contiennent à la fois (celui au sommet, dans le cas des piles, et celui
au début, dans le cas des files).
Par contre, les vecteurs, les listes et les arbres semblent convenir au stockage et à la
recherche d’information. Il est donc naturel de se demander quels sont les algorithmes
utilisables sur ces structures et quel sont leurs efficacités respectives.
Afin de pouvoir effectuer une comparaison correcte entre ces différentes structures,
nous allons considérer le problème suivant. Nous allons supposer que nous avons à
notre disposition une certaine séquence de valeurs entières1 V1 ,V2 , . . .Vn . En pratique,
ces informations peuvent être lues sur input, sur un fichier, etc. , Nous allons regarder
comment stocker ces informations dans chacune des trois structures considérées de
manière à ce que les algorithmes de recherche soient ensuite aussi efficace que possible.
Dans tous les cas, nous allons évaluer le coût de la construction de la structure, ainsi
que le coût de chaque recherche.
1 Cette hypothèse nous permettra de supposer qu’il existe un ordre sur les valeurs (c’est-à-dire que celles-
ci peuvent être triées). En pratique, on peut en général trouver un tel ordre même si les valeurs ne sont pas
des entiers. Il existe également bien des cas où une clef primaire entière est attachée à chaque donnée. Cette
hypothèse est donc tout à fait réaliste.
115
116 CHAPITRE 7. ALGORITHMES DE RECHERCHE
ConstruireVec(Entier T [n])
début
Entier i := 1 ;
tant que i ≤ n faire
T [i] := Vi ;
i := i + 1 ;
fin
i ≥ n + 1 ∨ T[i] = k
7.2. DONNÉES STOCKÉES DANS DES VECTEURS 117
dans la partie restante : nous ouvrons l’annuaire au milieu de sa seconde moitié (soit à
ses trois quarts), et nous comparons l’initiale de la page, et ainsi de suite.
Cette méthode est très rapide en pratique car, à chaque étape, nous séparons la partie
inexplorée de l’annuaire en deux parties (approximativement) égales, et nous éliminons
l’une des deux. Après la première étape, nous avons éliminé la moitié de l’annuaire,
après la seconde, les trois quarts, après la troisième, les 7/8, etc.
On peut transposer cette idée sous la forme d’un algorithme récursif qui fonctionne
sur un vecteur trié. Celui-ci reçoit un vecteur T , une valeur k et deux bornes bi et
bs. Il doit retourner vrai ssi la valeur k est présente dans une des cases T [i] telle que
bi ≤ i ≤ bs. Pour ce faire, il commence par repérer le « milieu » de la partie à traiter.
Cette partie est longueur bs − bi + 1, et son milieu est donc à la case3
bs − bi + 1
m = bi +
2
L’algorithme compare alors k à T [m]. Trois cas peuvent se présenter :
1. Si T [m] = k, l’information est trouvée, et l’algorithme retourne vrai.
2. Si T [m] < k, k ne peut se trouver qu’à droite de T [m]. On doit donc effectuer une
recherche dans la portion comprise entre les case m + 1 et bs du vecteur.
3. Si T [m] > k, k ne peut se trouver qu’à gauche de T [m]. On doit donc effectuer
une recherche dans la portion comprise entre les case bi et m − 1 du vecteur.
Les deux derniers cas sont traités à l’aide d’un appel récursif, et l’algorithme doit donc
renvoyer la valeur obtenue à l’aide de l’appel récursif. Si l’information à chercher n’est
pas dans le vecteur, la récursivité s’arrête dès que la portion du vecteur à traiter est
vide, c’est-à-dire ssi bs < bi. Dans ce cas, on retourne faux.
Cet algorithme est donné à l’Algorithme 43 et illustré par l’exemple ci-dessous.
Exemple 26 Considérons le tableau donné à la Fig. 7.1, et cherchons y la valeur k =
53 de manière dichotomique. Le tableau comporte 14 valeurs. À la première itération,
nous avons donc bi = 1, bs = 14 et m = bi+ ⌊(bs− bi+ 1)/2⌋ = 1 + (14 − 1 + 1/2) = 8.
Comme la valeur recherchée 53 est > T [8] = 45, on obtient bi = m + 1 = 9 et bs
reste inchangée. On continue ainsi jusqu’à avoir trouvé 53, ce qui arrive au bout de 4
itérations.
k =
5 3
1 2 3 4 5 6 7 8 9 1 0 1 1 1 2 1 3 1 4
1 3 8 1 2 2 4 2 8 3 2 4 5 5 3 6 2 7 9 8 5 9 5 9 8
1 2 3 4 5 6 7 8 9 1 0 1 1 1 2 1 3 1 4
1 3 8 1 2 2 4 2 8 3 2 4 5 5 3 6 2 7 9 8 5 9 5 9 8
1 2 3 4 5 6 7 8 9 1 0 1 1 1 2 1 3 1 4
1 3 8 1 2 2 4 2 8 3 2 4 5 5 3 6 2 7 9 8 5 9 5 9 8
1 2 3 4 5 6 7 8 9 1 0 1 1 1 2 1 3 1 4
1 3 8 1 2 2 4 2 8 3 2 4 5 5 3 6 2 7 9 8 5 9 5 9 8
bs − bi − ⌊ bs−bi+1
2 ⌋
≤ (bs − bi + 1) − ⌊ bs−bi+1
2 ⌋
bs−bi+1
≤ (bs − bi + 1) − 2
= bs−bi+1
2
≤ 12 · 2ℓ−1
n
Par HI
n
= 2ℓ
Preuve. Par la Proposition 2, on sait qu’au maximum log2 (n) + 1 appels récursifs
sont effectués. En comptant l’appel « principal » (en d’autres termes, l’appel 0), il y a
au maximum log2 (n) + 2 exécutions de la fonction. Les traitements propres à chaque
appel sont en O(1). La complexité totale est donc en O(log2 (n) + 2) = O(log(n)).
Exemple 27 Sur l’exemple de la Fig. 7.1, la valeur recherchée 53 est présente dans le
tableau. Quand la valeur est effectivement présente dans le tableau, le cas illustré est le
plus défavorable, puisqu’on est obligé de réduire l’intervalle de recherche à une seule
case. De manière générale, le pire cas se produit quand la valeur n’est pas présente : si
on avait recherché 61, par exemple, une itération supplémentaire aurait été nécessaire
pour rendre l’intervalle de recherche vide.
Sur cet exemple, le nombre d’appels confirme bien la théorie. En effet, log2 (14) +
2 ≈ 5, 81, et le nombre d’appels effectué est bien toujours ≤ 5, quelque soit la valeur k
recherchée.
On voit donc que pour pouvoir utiliser cette idée en pratique, et continuer à ex-
ploiter le fait qu’on peut accéder en O(1) à une case d’un tableau, il faut diminuer la
taille de la table. Supposons que nous admettions une table H de taille N ≪ Max. Cette
supposition a deux implications :
7.2. DONNÉES STOCKÉES DANS DES VECTEURS 123
Une fonction de hachage pour les entiers positifs Si les valeurs considérées sont
des entiers ≥ 0, la fonction :
f (x) = (x mod N) + 1
Une fonction de hachage pour les chaînes de caractères Écartons nous quelque
peu des hypothèses du chapitre et considérons que les valeurs à traiter sont des chaînes
de caractères de la forme S = α1 α2 · · · αn , où les αi sont les caractères de la chaîne.
Afin de définir une fonction de hachage pour ces données, il est nécessaire de
convertir les chaînes en nombres entiers. Il faut donc associer à chaque caractère un
nombre. Nous supposerons donc que nous disposons d’une fonction v qui reçoit un
124 CHAPITRE 7. ALGORITHMES DE RECHERCHE
caractère et qui renvoie le nombre associé à ce paramètre (en pratique, on peut simple-
ment prendre le code ASCII, par exemple). Nous supposerons que la fonction renvoie
toujours des valeurs dans l’intervalle [0, . . . B − 1], où B sera choisi suffisamment grand
en fonction du nombre de caractères (par exemple 256 pour le code ASCII)
Dans ce cas, une fonction de hachage pour les chaînes des caractères peut être :
! !
n
f (α1 α2 · · · αn ) = ∑ v(αn−i+1 )Bi−1 mod N + 1
i=1
Le second problème potentiel provient de la relation qu’il peut exister entre la va-
leur de N et la valeur de B. Supposons par exemple que N = B. Dans cas, nous avons :
!
n
∑ v(αn−i+1 )Bi−1 mod N = v(αn )
i=1
En effet, si un nombre est exprimé dans une base B, le reste de la division de ce nombre
par B est simplement son dernier chiffre. Une telle fonction de hachage n’est sûrement
pas satisfaisante, puisqu’elle ne prend en compte que le dernier caractère de la chaîne.
Remarquons que des phénomènes similaires se produisent si N est une puissance de
B : si N = Bℓ , ce sont les ℓ derniers caractères de la chaîne seulement qui sont pris en
compte. Il faut donc veiller à ce que N et B soient mutuellement premiers entre eux
(ce qui signifie qu’ils n’ont pas de diviseur commun, à part 1). En pratique, choisir un
nombre premier pour la valeur de N est une bonne idée (comme 101 dans l’Exemple 28)
7.2. DONNÉES STOCKÉES DANS DES VECTEURS 125
Entier f (Chaine α1 α2 · · · αn )
début
Entier i = 1 ;
Entier r = 0 ;
tant que i ≤ n faire
r := ((r × B) + αi ) mod N ;
retourner r + 1 ;
fin
Algorithme 44 : Le calcul efficace de la fonction de hachage sur les chaînes de
caractères.
6 2 8
1
5
4 3 1
7
6
7 7
Cette politique de gestion des collision est appelée le hachage avec chaînage par
référence aux listes chaînées qui y sont exploitées. Elle est illustrée à la Fig. 7.2.
Finalement, la table de hachage sera donc un vecteur Elem * H[N] de pointeurs
initialisé à NIL (initialement toutes les listes sont vides). On obtient les fonctions don-
126 CHAPITRE 7. ALGORITHMES DE RECHERCHE
nées à l’Algorithme 45 pour manipuler les tables de hachage dans le cas où les données
sont des entiers. Ces fonctions sont aisément transposables dans les autres cas, comme
les chaînes de caractères. Elles sont détaillées ci-dessous :
InitHachage Cette fonction initialise la table de hachage en créant des listes vides
dans toutes ses cases. Cette fonction est en O(N).
InsertionHachage Cette fonction repère la position p dans la table correspondant à la
clef j à l’aide de la fonction de hachage f , et insère cette clef en tête de la liste
dont la tête est en H[p]. Elle est donc en O(1) (en supposant que la fonction f
est elle-même en O(1)).
RechercheHachage Cette fonction booléenne indique si la clef k est présente dans la
table H. Pour ce faire, elle doit simplement rechercher la valeur j dans la liste
dont la tête est en H[ f ( j)]. La complexité de cette fonction dépend donc de la
longueur de la liste à parcourir. Au pire cas, et si la fonction de hachage est
de mauvaise qualité, toutes les informations sont stockées dans la même case.
Cette fonction est donc en O(n). Néanmoins, si la fonction de hachage est « bien
choisie », cette fonction est, en moyenne, en O(1). Nous renvoyons le lecteur à
des ouvrages plus approfondis [4] pour les détails.
InitHachage(Elem * H[N])
début
Entier i := 1 ;
tant que i ≤ N faire
ListeVide(H[i]) ;
fin
En pratique, les tables de hachage sont extrêmement efficaces, car la valeur (moyenne)
O(1) pour la recherche est tout à fait réaliste. Naturellement, une table de hachage reste
un tableau, et est donc de taille fixée. Au fur et à mesure que la table se remplit, les
performances se dégradent, car les listes qui résolvent les collisions s’allongent. Par
ailleurs, redimensionner une table de hachage est une opération coûteuse. Nous ver-
rons plus loin que les arbres binaires de recherche balancés offrent l’avantage d’opé-
7.3. DONNÉES STOCKÉES DANS DES LISTES 127
rations assez efficaces (en O(log(n)), où n est le nombre de valeurs stockées), dans une
structure dynamique.
RechercheArbre(Nœud * A)
début
si EstVide(A) alors
retourner faux ;
sinon
si [Link] = k alors
retourner vrai;
sinon si RechercheArbre(FilsGauche(A)) alors
retourner vrai ;
sinon si RechercheArbre(FilsDroit(A)) alors
retourner vrai ;
fin
Algorithme 46 : La recherche d’une information k dans un arbre.
1 5
2 2
7
2 2
5 1 1 7 5
6 2 3 3 2
5
F IG . 7.4 – L’ordre sur les nœuds induit par un arbre binaire de recherche (il s’agit de
l’ordre du parcours infixe).
Recherche La recherche dans un ABR suit directement ce que nous avons présenté
ci-dessus. Étant donné un arbre A, il y a quatre possibilités :
1. Soit l’arbre A est vide, et l’information n’est pas présente.
2. Soit l’arbre A n’est pas vide, et l’information est dans sa racine.
3. Soit l’arbre A n’est pas vide et l’information recherchée est plus petite que sa
racine. Il faut alors effectuer la recherche dans le sous-arbre gauche.
4. Soit l’arbre A n’est pas vide et l’information recherchée est plus grande que sa
racine. Il faut alors effectuer la recherche dans le sous-arbre droit.
Cet algorithme est correct par la définition d’un ABR. Nous avons également la garantie
qu’il termine car on effectue toujours un appel récursif sur un arbre de hauteur plus
petite. Il est donné à l’Algorithme 47.
7.4. DONNÉES STOCKÉES DANS DES ARBRES 131
Il est facile de voir que cet algorithme ne réalise pas un parcours de tout l’arbre,
mais construit un chemin dans l’arbre, partant de la racine, et arrivant dans le nœud
recherché (s’il existe). Le chemin en question est la séquence des nœuds sur lesquels
les appels récursifs successifs sont effectués. La Fig. 7.3 illustre cela : on appelle la
fonction sur la racine, ce qui donne lieu à un appel récursif sur 22, puis sur 25, et enfin
23, auquel cas on retourne vrai.
Cette constatation nous permet d’obtenir aisément un algorithme itératif qui réalise
la recherche (voir Algorithme 48). Celui-ci maintient, à tout moment, un pointeur p
vers un nœud de l’arbre. Initialement p pointe vers la racine. p est ensuite déplacé
vers le nœud qui est à la racine de l’arbre sur lequel l’appel récursif aurait été effectué
dans la version récursive de l’algorithme. On déplace donc p sur le fils gauche si la
racine contient une information plus grande que l’information k recherchée, et sur le
fils droit dans le cas contraire. Ce déplacement s’arrête dès que p pointe vers un nœud
qui contient l’information recherchée, ou si p est NIL.
Insertion L’insertion dans un arbre binaire de recherche suit le même principe que la
recherche. En effet, le cas de base correspondant à l’arbre vide est simple à traiter : on
crée tout simplement un nouveau nœud qui fera office de racine et qui reçoit l’informa-
tion à insérer. Les cas récursifs suivent également la logique de l’ABR que nous avons
déjà rencontrée dans la recherche : si l’information à insérer est ≥ à la racine, on doit
132 CHAPITRE 7. ALGORITHMES DE RECHERCHE
1 5
2 2
7
5 1 2 1 2 5
7
5 6 1 1 2 3 3 2
C h e m i n s u i v i p o u r l ' i n s e r t i o n d e 1 1
C h e m i n s u i v i p o u r l ' i n s e r t i o n d e 7
F IG . 7.5 – Deux insertions dans un ABR. Les chemins suivis sont indiqués en poin-
tillés. Les comparaisons réalisées sont indiquées le long des chemins, à côté des nœuds
concernés.
réaliser l’insertion dans le sous-arbre gauche. Sinon, on doit réaliser l’insertion dans le
sous-arbre droit.
Il faut simplement prendre garde à ce que l’insertion dans un sous-arbre peut modi-
fier la racine de ce sous-arbre, au cas où celui-ci est vide. On peut gérer cela aisément
en passant la racine par référence. Cette procédure est donnée à l’Algorithme 49 et
illustré à la Fig. 7.5.
En observant attentivement la Fig. 7.5, on constate deux choses :
1. Comme la recherche, cet algorithme construit un chemin dans l’arbre, partant de
la racine, et arrivant dans une feuille.
2. L’insertion consiste toujours à ajouter un nouveau fils à un nœud. L’insertion
consiste donc toujours à créer une nouvelle feuille.
Ces observations nous amènent tout naturellement à une version itérative de l’algo-
rithme d’insertion. Celui-ci procède de façon similaire à l’Algorithme 48 : il maintient
un pointeur qui doit repérer le nœud auquel on viendra ajouter un fils contenant l’infor-
mation à insérer. Pour ce faire, il parcourt un chemin dans l’arbre partant de la racine.
7.4. DONNÉES STOCKÉES DANS DES ARBRES 133
Dans le cas où le nœud n vers lequel p pointe a une étiquette ≥ à l’information à insérer,
on a deux possibilités :
1. Soit n possède un fils gauche, et il faut alors faire pointer p vers ce fils gauche.
2. Soit n ne possède pas de fils gauche, et on peut alors créer un fils gauche conte-
nant l’information à insérer.
Dans le cas où n a une étiquette < à l’information à insérer, on procède de façon
similaire dans le fils droit.
fin
Algorithme 50 : L’insertion dans un ABR : version itérative.
k à supprimer de l’arbre, il faut d’abord repérer le nœud qui contient cette information4.
Cela peut se faire de manière similaire à la recherche, en parcourant un chemin dans
l’ABR. Ensuite, il faut effacer le nœud n ainsi trouvé, ce qui sépare l’arbre en trois
parties : le sous-arbre gauche de n ; son sous-arbre droit et le reste de l’arbre. Il faut
donc « recoller les morceaux », et c’est là toute la difficulté de la suppression.
Commençons par les cas les plus simples :
1. Si n est une feuille, n n’a pas de sous-arbres gauche ou droit, et la suppres-
sion est donc terminé dès que n a été effacé. Cette situation est représentée à la
Fig. 7.6 (a).
2. Si n ne possède qu’un seul sous-arbre, il suffit de remplacer n par la racine de ce
sous-arbre. Cette situation est représentée à la Fig. 7.6 (b).
Le cas le plus délicat est celui où n possède deux sous-arbres non-vides. Quel nœud
va-t-on choisir pour remplacer n ? La réponse nous est donnée en considérant l’ordre
naturellement induit par l’arbre sur ses nœuds, qui correspond à l’ordre infixe (cfr.
Fig. 7.4) : il faut sélectionner un nœud qui permet de conserver cet ordre. Ceci cor-
respond sélectionner un nœud n’ qu’on va prélever soit dans le sous-arbre gauche, soit
dans le sous-arbre droit de n ; et dont l’étiquette est à la fois plus grande que ou égale
à toutes les étiquettes des nœuds du sous-arbre gauche, et plus petite que toutes les
étiquettes des nœuds du sous-arbre droit. Deux nœuds répondent à cette propriété : soit
le nœud qui précède directement n dans l’ordre infixe (c’est-à-dire le plus grand nœud
du sous-arbre gauche de n) ; soit le nœud qui suit directement n dans l’ordre infixe
(c’est-à-dire le plus petit nœud du sous-arbre droit de n). Le choix est indifférent.
L’avantage de cette procédure est que le maximum du sous-arbre gauche (le mi-
nimum du sous-arbre droit) d’un nœud est aisé à obtenir : il s’agit du nœud le plus à
droite (à gauche) dans son sous-arbre gauche (droit).
Dans l’agorithme de suppression que nous allons présenter, nous choisirons de rem-
placer un nœud par le plus grand nœud de son sous-arbre gauche. Nous allons donc
commencer par donner la fonction ExtraireMaxABR, qui réalise un parcours d’un
arbre pour repérer le nœud qui porte l’information maximale dans cet arbre. Il détache
alors ce nœud de l’arbre et renvoie un pointeur vers ce nœud. Afin de pouvoir détacher
un nœud, il est nécessaire d’avoir un pointeur vers le père de ce nœud (s’il existe).
C’est le but du pointeur q, utilisé dans la boucle de parcours. Ce pointeur respecte à
tout moment la propriété suivante (il s’agit d’un invariant de la boucle) :
ration de suppression.
7.4. DONNÉES STOCKÉES DANS DES ARBRES
A
A
A
F
B
E
D
E
(
)
a
A
A
B
C
D
G
H
J
(
)
c
K
135
F IG . 7.6 – Une illustration des trois cas de suppression dans un ABR. Le nœud à supprimer est indiqué en grisé.
136 CHAPITRE 7. ALGORITHMES DE RECHERCHE
2 2
1 5 1 5
2 2
7 7 1 7
2 0
1 6
2 2
5 1 1 7 5 1
2 0
1 1 1 6 1 1
SuppressionABR(Nœud * A, Entier k)
début
Nœud * p := A, q := NIL, r ;
Caractère sens ;
/* Re her he du n÷ud à supprimer p et de son père q. */
tant que ¬EstVide(p) et InfoRacine(p) 6= k faire
q := p ;
si InfoRacine(p) > k alors
p := FilsGauche(p) ;
sens := g ;
sinon
p := FilsDroit(p) ;
sens := d ;
si EstVide(p) alors
Afficher « Information non trouvée » ;
sinon
/* Re her he du n÷ud r qui va rempla er p. */
si EstFeuille(p) alors r := NIL ;
sinon si EstVide(FilsGauche(p)) alors r := FilsDroit(p) ;
sinon si EstVide(FilsDroit(p)) alors r := FilsGauche(p) ;
sinon
r := ExtraireMaxABR(FilsGauche(p)) ;
[Link] := FilsGauche(p) ;
[Link] := FilsDroit(p) ;
delete p ;
fin
Algorithme 52 : L’algorithme de suppression dans un arbre binaire de recherche.
7.4. DONNÉES STOCKÉES DANS DES ARBRES 139
Malheureusement, dans le pire des cas, la hauteur d’un arbre binaire de recherche
est en O du nombre de nœuds qu’il contient, comme montré sur le Fig. 7.8. Dans ce
cas, l’arbre construit est en fait une liste. Un tel arbre s’obtient par exemple en insérant
successivement des valeurs en ordre décroissant.
Théorème 8 Étant donné un arbre binaire de recherche A contenant n nœuds, les opé-
rations d’insertion, de recherche et de suppression sont en O(n).
Néanmoins, nous avons vu au Chapitre 6 que la hauteur d’un arbre binaire balancé,
est en O(log(n)), où n est le nombre de nœuds dans l’arbre. Donc :
Théorème 9 Étant donné un arbre binaire de recherche balancé A contenant n nœuds,
les opérations d’insertion, de recherche et de suppression sont en O (log(n)).
Il nous reste à comprendre comment nous pouvons garantir qu’un ABR soit ba-
lancé. En effet, il n’est pas difficile de voir que l’insertion peut rendre non-balancé un
140 CHAPITRE 7. ALGORITHMES DE RECHERCHE
arbre qui était balancé avant l’insertion. Considérez par exemple l’arbre à deux nœuds
ayant 3 comme information dans sa racine et 2 dans son fils gauche. Cet arbre est
balancé. L’ajout du nœud portant l’information 1 à gauche de 2 fait qu’il n’est plus
balancé, car le fils gauche de 3 est maintenant de hauteur 2, alors que son fils droit est
de hauteur 0. Le même phénomène peut apparaître avec la suppression.
Nous n’expliquerons pas en détail dans ce cours comment résoudre ces problèmes
(ils seront abordés dans un cours ultérieur). Nous pouvons néanmoins indiquer que :
1. Il existe des structures d’arbres binaires balancés, dans lesquels l’insertion et
la suppression maintiennent le caractère balancé de l’arbre, tout en gardant une
complexité en O(log(n)). On peut citer par exemple les AVL, les arbres rouge-
noir, etc. [4].
2. La solution qui consisterait à reconstruire complètement l’arbre de façon ba-
lancée après chaque insertion n’est pas satisfaisante, car elle serait en O(n), ce
qui fait perdre l’avantage du O(log(n)). Par contre, si on commence par insé-
rer toutes les valeurs, puis qu’on se contente de consulter très fréquemment la
structure, il peut être intéressant de réaliser toutes les insertions de façon non-
balancée dans un premier temps, puis de reconstruire l’arbre de façon balancée
(une seule fois : le coût est alors négligeable).
3. Si les données V1 ,V2 , . . .Vn sont fournies dans un ordre aléatoire, alors la proba-
bilité que l’arbre résultant des n insertions (sans balancement) soit balancé est
très grande.
Nous renvoyons le lecteur vers les ouvrages de référence [4, 9], et vers des cours
ultérieurs pour un traitement approfondi de ces structures essentielles en informatique.
Chapitre 8
Dans ce cours, nous avons tenté de poser les bases de l’algorithmique, et ce, de
façon scientifique et rigoureuse. Ce cours reste donc une introduction à l’algorithmique,
et de nombreux sujets restent à couvrir, comme :
– Les nombreuses variantes d’arbres balancés ;
– Les graphes ;
– Les méthodes d’analyse plus fines que la complexité au pire cas (comme la com-
plexité amortie) ;
– Les algorithmes de compression, ou de chiffrage des données ;
– ...
Tous ces domaines trouvent de nombreuses applications en pratique, et font encore
l’objet d’une recherche active. . .
Dans ce cours, nous nous sommes posés de nombreux problèmes. Par exemple, le
Chapitre 7 a été entièrement consacré à présenter différentes solutions au problème :
« comment stocker des données de façon à pouvoir les consulter de manière efficace ? »
À chaque fois, nous avons apporté des solutions qui ont pris la forme de structures de
données et surtout d’algorithmes qui manipulent ces structures. Nous avons également
utilisé des outils rigoureux (les invariants, les preuves par induction, la complexité,. . .)
pour raisonner sur ces algorithmes, prouver leur correction, et évaluer leur efficacité.
Bref, pour chacun des problèmes étudiés dans ces pages, nous avons pu apporter une
réponse satisfaisante, tant sur le plan théorique que pratique.
Le potentiel de l’algorithmique semble donc illimité.
141
142 CHAPITRE 8. CONCLUSION : AUX LIMITES DE L’ALGORITHMIQUE
Ce résultat très profond trouve sa source dans des problèmes de mathématiques po-
sés bien avant l’invention de l’ordinateur. En 1900, le mathématicien allemand David
H ILBERT1 pose ses célèbres 23 problèmes au congrès international de mathématiques
(Paris). Le 10e problème demande de trouver une méthode systématique (c’est-à-dire
un algorithme) déterminant si une équation diophantine2 possède une solution. La ré-
ponse à ce problème sera apporté par le mathématicien anglais Alan Mathison T U -
RING 3 et le mathématicien américain Alonzo C HURCH 4 en 1936 : il n’existe pas de tel
algorithme.
Néanmoins, il ne s’agit pas du seul problème pour lequel il n’existe pas d’algo-
rithme. À titre d’illustration, nous allons maintenant donner les idées principales de la
preuve qui montre qu’il n’existe aucune algorithme pour résoudre le problème du test
d’arrêt. Celui-ci est défini comme suit :
Étant donné un programme P et une input I pour P, P s’arrête-t-il quand
on l’exécute sur l’input I ?
FonctionMalÉcrite(Entier i)
début
Entier j := i ;
tant que j 6= 5 faire
Afficher j ;
j := j + 1 ;
fin
Algorithme 53 : Un exemple de programme qui ne s’arrête pas toujours.
Il est donc important de bien comprendre que nous ne posons pas une question en
général sur le programme (« le programme cycle-t-il ? »), mais bien sur une exécution
particulière du programme (« le programme cycle-t-il pour une certaine input ? »). Ce
que nous désirons, c’est un algorithme A, unique, qui est capable de répondre à cette
question quelque soit le programme P et quelque soit l’input I. Nous allons démontrer
que cela n’est pas possible :
mettent de poser les bases de l’informatique théorique, tel qu’on la conçoit encore aujourd’hui. Il aide les
alliés pendant la seconde guerre mondiale en découvrant le code de la machine Enigma utilisée par les nazis
pour chiffrer leurs messages secrets. Condamné pour homosexualité, il se suicide en 1952, en croquant une
pomme trempée dans du poison. La légende veut que le logo de la société californienne Apple Computers a
été choisi en hommage à Turing.
4 Né en 1903 et décédé en 1995.
143
Preuve. La preuve fonctionne par contradiction. Cela signifie que nous allons supposer
que la théorème est faux, et nous allons en dériver une contradiction, ce qui prouvera
que notre hypothèse « le théorème est faux » est elle-même fausse (et donc, le théorème
est vrai).
Supposons donc qu’il existe un algorithme A qui résolve le problème de l’arrêt. Cet
algorithme peut être exprimé sous la forme d’une fonction qui reçoit :
1. Un programme P encodé sous forme d’une chaîne de caractères ;
2. Une input I pour le programme P ;
et qui renvoie vrai si et seulement si P s’arrête quand il est exécuté sur I. Dans la suite,
nous dénoterons par P(I) l’exécution de P sur I.
Afin de rendre la preuve plus aisée nous allons supposer que l’input I est toujours
une chaîne de caractère. On pourrait croire que cela contredit notre desiderata stipulant
que A doit fonctionner pour n’importe quel programme. Ce n’est en fait pas le cas. En
effet, si P n’a pas une chaîne de caractères pour input, il est facile de le transformer en
un programme P′ qui a une chaîne de caractères pour input et qui convertit la chaîne
dans le bon format avant d’appeler P sur le résultat de la conversion. Clairement, P′
s’arrête si et seulement si P s’arrête, donc on peut appliquer A sur P′ pour savoir si P
s’arrête.
Par exemple, si P reçoit deux entiers en input, on construit le programme P′ qui
reçoit une chaîne des caractères qui représente les deux entiers séparés par une virgule
(ainsi, 5 et 24 sont représentés par la chaîne 5,24). On peut alors facilement extraire
les entiers de la chaîne, et appeler P.
Nous avons donc à notre disposition un algorithme A, qui reçoit une chaîne de
caractères P (le programme) et une chaîne de caractères I (l’input) et qui est tel que :
Nous arrivons maintenant au point central de la preuve, qui tient dans l’observation
suivante :
Comme I est une chaîne de caractères, rien ne nous empêche de choisir un
programme (qui est aussi une chaîne de caractères) comme input.
On peut donc construire une fonction Paradoxe qui reçoit un programme (donc une
chaîne de caractères) en input et se comporte ainsi :
Booléen Paradoxe(Chaine P)
début
si A(P, P) = vrai alors
tant que vrai faire
Afficher « Bonjour » ;
retourner faux ;
sinon
retourner vrai ;
fin
Que fait la fonction Paradoxe ? Elle appelle l’algorithme qui teste l’arrêt du pro-
gramme P quand il est exécuté avec son propre code pour input. Si A renvoie vrai,
c’est-à-dire si P(P) ne cylce pas, Paradoxe entre alors dans une boucle infinie. Dans
144 CHAPITRE 8. CONCLUSION : AUX LIMITES DE L’ALGORITHMIQUE
Comme nous l’avons vu, Paradoxe peut être appelée sur n’importe quel programme.
Donc, en particulier, Paradoxe peut être appelée sur elle-même ! Voyons ce que cela
signifie. Clairement, il y a deux possibilités : soit Paradoxe cycle quand on l’appelle
sur elle-même, soit elle ne cycle pas. Considérons cela plus en détail. Nous allons utili-
ser l’information donnée par (8.1) ci-dessus, en remplaçant P (le programme sur lequel
Paradoxe est appelée) par Paradoxe :
1. Si Paradoxe(Paradoxe) cycle, nous déduisons de (8.1) que P(P) ne cycle pas,
c’est-à-dire que Paradoxe(Paradoxe) ne cycle pas. . .
2. Par contre, si Paradoxe(Paradoxe) ne cycle pas, l’équation (8.1) nous apprend
que Paradoxe(Paradoxe) doit cycler. . .
Nous concluons donc que Paradoxe(Paradoxe) est une fonction qui cycle si et seule-
ment si elle ne cycle pas ! Clairement, une telle fonction ne peut pas exister. Or, cette
fonction ne contient rien d’autre qu’un appel à A (l’algorithme qui résout le problème
du test d’arrêt) et une boucle infinie. Donc, si Paradoxe n’existe pas, c’est nécessaire-
ment parce que A n’existe pas.
Ce simple raisonnement nous prouve qu’il existe au moins un problème qu’aucun
algorithme ne peut résoudre. Par le même genre de raisonnement, on peut prouver :
– qu’il n’existe pas d’algorithme pour savoir si un programme donné atteint tou-
jours une ligne de code donnée ;
– qu’il n’existe pas d’algorithme pour savoir si une certaine variable peut contenir
une certaine valeur dans un certain programme ;
– qu’il n’existe pas d’algorithme pour savoir si un programme ne cycle jamais
(quelque soit son input) ;
– ...
À vrai dire, toutes les propriétés un tant soit peu intéressantes des programmes ne
peuvent pas être vérifiées par un algorithme.
Néanmoins, les limites précises de l’algorithmique restent encore à définir. Entre les
problèmes insolubles et ceux pour lesquels on connaît des algorithmes, il reste encore
beaucoup de questions ouvertes (voir, pour plus de détails, des ouvrages de référence
comme [10, 6]). Par ailleurs, le fait qu’il existe un algorithme pour un problème donné
ne signifie pas que tout est dit sur cette matière : il reste certainement de nombreux
algorithmes à améliorer, tant sur le plan théorique que pratique.
Pour conclure, nous espérons que ces quelques exemples auront suscité la curiosité
du lecteur, qui trouvera dans ces questions un sujet de réflexions intéressant et toujours
renouvelé. . .
Bibliographie
[1] Alfred V. Aho and Jeffrey D. Ullman. Foundations of Computer Science. Com-
puter Science Press, 1992.
[2] Gilles Brassard and Paul Bratley. Fundamentals of Algorithmics. Prentice-Hall,
1996.
[3] Thomas H. Cormen, Charles E. Leiserson, and Ronald L. Rivest. Introduction to
Algorithms. MIT Press/McGraw-Hill, 1990.
[4] Thomas H. Cormen, Charles E. Leiserson, and Ronald L. Rivest. Introduction
à l’Algorithmique. Dunod, deuxième edition, 2002. ISBN : 9782100039227.
Traduction de [3].
[5] Ronald L. Graham, Donald E. Knuth, and Oren Patashnik. Concrete Mathema-
tics : A Foundation for Computer Science. Addison-Wesley Longman Publishing
Co., Inc., Boston, MA, USA, 1994.
[6] Harry R. Lewis, Christos H. Papadimitriou, and Christos Papadimitriou. Elements
of the Theory of Computation. Prentice Hall PTR, Upper Saddle River, NJ, USA,
1997.
[7] Olivier Markowitch. Algorithmique I. Presses de l’Université Libre de Bruxelles.
Syllabus du cours dispensé en première bachelier Informatique, Faculté des
Sciences, ULB.
[8] Robert Sedgewick. Algorithms in C. Addison-Wesley, 1990.
[9] Robert Sedgewick. Algorithmes en langage C. Dunod, 1991. ISBN :
2100492977. Traduction de [8].
[10] Pierre Wolper. Introduction à la calculabilité, 3e édition. Dunod. ISBN :
9782100499816.
145