Vous êtes sur la page 1sur 88

Structures de Données

et Algorithmes
NOTES DE COURS
partie 1

Michel Lemaître
ONERA, Centre de Toulouse

document mis à jour le 24 Octobre 2001


2

Le matériel associé au cours, y compris la dernière version de ces notes ainsi que les programmes en Java, est disponible
à partir de l’adresse http://www.cert.fr/dcsd/cd/MEMBRES/lemaitre/Enseignement
Toutes les remarques concernant ces notes seront appréciées. Elles doivent être adressées à Michel.Lemaitre@cert.fr.
Table des matières

1 Introduction 6
1.1 De quoi parle ce cours ? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6
1.2 Quels sont les buts du cours ? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8
1.3 Quel est le contenu du cours ? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8

2 Récursivité 10
2.1 Qu’est-ce que la récursivité ? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10
2.2 Critères d’un algorithme récursif correct . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10
2.3 Récursion et itération . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 11
2.4 Récursion et efficacité . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 11
2.5 Récursions célèbres . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 12
2.6 Programme Java . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 13

3 Analyse des algorithmes 16


3.1 Motivation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 16
3.2 Analyse de complexité en temps . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 16
3.3 Taux de croissance . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 18
3.4 Analyse des algorithmes récursifs . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 20
3.5 Point de vue critique sur l’analyse des algorithmes . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 21
3.6 Exercices . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 21

4 Généralités sur les structures de données 24


4.1 Les deux niveaux d’organisation des données . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 24
4.2 Structure abstraite et structure concrète . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 25
4.3 Collection et Ensemble . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 25
4.4 Dictionnaire . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 25
4.5 Pile . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 26
4.6 File . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 26
4.7 File de priorité . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 27
4.8 Programme Java : création et utilisation d’un dictionnaire . . . . . . . . . . . . . . . . . . . . . . . . . . 27

5 Tableau 29
5.1 Implémentations de la collection, de l’ensemble et du dictionnaire par un tableau . . . . . . . . . . . . . 29
5.2 Implémentations de la pile et de la file par un tableau . . . . . . . . . . . . . . . . . . . . . . . . . . . . 30

6 Liste chaînée 31
6.1 La liste chaînée la plus simple . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 31
6.2 Autres listes chaînées . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 32
6.3 Intérêts et inconvénients de la liste . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 32

3
4 TABLE DES MATIÈRES

6.4 Implémentations de la pile, de la file et de la file de priorité par une liste chaînée . . . . . . . . . . . . . . 32
6.5 Programme Java . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 33

7 Table de hachage 36
7.1 Présentation générale . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 36
7.2 Fonctionnement . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 36
7.3 Fonctions de hachage . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 37
7.4 Analyse du temps de l’opération RECHERCHE . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 38

8 Généralités sur les arborescences 39


8.1 Définition de l’arborescence . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 39
8.2 Vocabulaire et propriétés . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 39
8.3 Usages des arborescences . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 40
8.4 Représentations des arborescences . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 40
8.5 Parcours d’arborescences . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 40

9 Arbre binaire de recherche (ABR) 41


9.1 Définition de l’arbre binaire de recherche . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 41
9.2 Parcours d’ABR . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 42
9.3 Recherches dans un ABR . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 42
9.3.1 Opérations MINIMUM et MAXIMUM . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 42
9.3.2 Opération RECHERCHE . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 43
9.3.3 Opérations SUCCESSEUR et PRÉDÉCESSEUR . . . . . . . . . . . . . . . . . . . . . . . . . . . . 43
9.4 Insertion dans un ABR . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 44
9.5 Suppression dans un ABR . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 45
9.6 Avantages et inconvénients de l’ABR . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 46
9.7 Programme Java . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 47

10 Arbre rouge et noir (ARN) 51


10.1 Définition de l’ARN . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 51
10.2 Propriétés de l’ARN . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 51
10.3 Rotations . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 52
10.4 Insertion et suppression dans un ARN . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 52
10.5 Exercices . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 53

11 B-Arbre 54
11.1 Définition . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 54
11.2 Propriété essentielle du B-arbre . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 55
11.3 Recherche dans un B-arbre . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 55
11.4 Insertion dans un B-arbre . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 55
11.5 Suppression dans un B-arbre . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 56

12 Tas 58
12.1 Définition du tas . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 58
12.2 Restauration de la contrainte d’ordre . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 59
12.3 Construction d’un tas à partir d’un tableau quelconque . . . . . . . . . . . . . . . . . . . . . . . . . . . 60
12.4 Maximum et extraction du maximum . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 60
12.5 Insertion dans un tas . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 61
12.6 Le tri du tas . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 61
12.7 Programme Java . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 62
TABLE DES MATIÈRES 5

13 Tris 67
13.1 Le problème . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 67
13.2 Borne inférieure de complexité en temps
des tris par comparaison . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 67
13.3 Tris simples . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 68
13.4 Tri par tas (heapsort ) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 68
13.5 Tri par fusion (mergesort ) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 69
13.6 Tri rapide (quicksort ) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 69
13.7 Tri par dénombrement . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 71
13.8 Résumé des propriétés des tris . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 72
13.9 Programme Java . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 72

14 La bibliothèque collections framework de Java 78


14.1 Structures de données abstraites . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 78
14.1.1 Collection et ses sous-interfaces . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 78
14.1.2 Map et ses sous-interfaces . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 79
14.2 Structures de données concrètes . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 80
14.2.1 Implémentations de Collection . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 80
14.2.2 Implémentations de Map . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 81
14.3 Autres interfaces et classes utiles . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 81
14.4 Exercices . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 83
14.5 Programme Java : exemples d’utilisation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 83

15 Références 87
15.1 Livres . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 87
15.2 Toile . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 88
Chapitre 1

Introduction

1.1 De quoi parle ce cours ?


Algorithmique et Programmation
L’objet général de l’algorithmique est la conception, l’évaluation et l’optimisation des méthodes de calcul en mathé-
matique et en informatique.
L’algorithmique a pour but de concevoir des algorithmes, d’analyser leurs performances, de comparer entre eux différents
algorithmes résolvant le même problème. On s’appuie sur un modèle d’exécution raisonnablement universel, et on produit
des résultats indépendants des langages de programmation qui seront utilisés pour l’implémentation.
La programmation est davantage concernée par les aspects d’organisation : création, évolution, réutilisabilité, mainte-
nance, expressivité et lisibilité des programmes.
Les deux disciplines s’interpénètrent, d’autant plus que les langages de programmations évoluent vers de plus hauts
niveaux d’abstraction (Java, Caml, Prolog ...), laissant de plus en plus aux compilateurs et aux systèmes le soin de gérer
les niveaux inférieurs, comme la gestion de la mémoire.
L’algorithmique s’est développée avec l’essor des ordinateurs et de la programmation, mais elle existait depuis longtemps
déjà : songer à l’algorithme d’Euclide, par exemple. Elle a atteint une maturité certaine, et a maintenant un contenu
classique qu’il faut connaître. Cependant c’est une science non figée qui continue d’évoluer, et les chercheurs découvrent
constamment de nouveaux algorithmes et structures de données. De même, la programmation n’est plus une science
mineure, et son importance est considérable, à la mesure des montants financiers mis en jeu.

Un algorithme résout un problème. Un programme implémente ou réalise un algorithme.

Problème
En algorithmique, nous nommerons problème une question générale paramétrée. La description d’un problème doit com-
porter :
– les données du problème : une structure faisant apparaître des paramètres (des variables libres)
– une caractérisation de la solution cherchée, sous forme d’une contrainte ou d’une propriété à satisfaire.
Cela va s’éclaircir sur un exemple : la résolution d’une équation du second degré.
– données : l’équation du second degré ax2 + bx + c = 0, avec les paramètres a, b, c
– solution : une valeur de x satisfaisant l’équation.
La caractérisation de la solution cherchée peut revêtir des formes différentes, pour la même structure. Des formes clas-
siques sont :
– existe-t-il x tel que . . .. La solution est «oui» ou «non» (problème d’existence).
– trouver un x, ou tous les x satisfaisant telle contrainte ...
– trouver x, ou tous les x minimisant telle fonction (problème d’optimisation).
Autre exemple, le problème du tri :
– données : un sous-ensemble de n objets a1 , a2 , . . . , an à trier, tirés d’un ensemble muni d’une relation d’ordre total notée
 ; paramètres : le sous-ensemble à trier.

6
1.1. DE QUOI PARLE CE COURS ? 7

– solution cherchée : trouver une permutation σ : {1, . . . , n} → {1, . . . , n} telle que ∀i, j : i < j =⇒ aσ(i)  aσ( j) .
Encore un exemple : des problèmes de plus court chemin dans un graphe (décrit de façon plus informelle) :
– données : une carte C avec des villes et des segments de routes de longueurs données reliant certaines de ces villes ;
deux villes particulières a et b ; paramètres : C, a, b et k un nombre réel.
– plusieurs types de solutions peuvent être recherchées, donnant lieu à des problèmes différents ; ce peut être :
– existe-t-il dans C un chemin de a vers b de longueur inférieure à k ?
– trouver un chemin dans C de a vers b de longueur inférieure à k.
– trouver un chemin dans C de a vers b de longueur minimale.
On distinguera soigneusement «problème» et «instance d’un problème». Une instance d’un problème est obtenue en
remplaçant les paramètres du problème par des constantes. Exemple d’instance du problème du tri : trier la séquence de
nombres (2, 8, −178, 87).

Algorithme
Définition de l’Encyclopedia Universalis : « un algorithme est la spécification d’un schéma de calcul, sous forme d’une
suite d’opérations élémentaires obéissant à un enchaînement déterminé ».
Remarquer que l’on ne parle pas ici d’ordinateur.
Autre définition : un algorithme est une procédure générale permettant de résoudre un problème : appliqué à n’importe
quelle instance du problème, il produit sûrement une solution en un nombre de pas fini.
Dire qu’un algorithme résout un problème, c’est dire que le problème est une spécification pour l’algorithme.
Exemples d’algorithmes :
– les opérations arithmétiques manuelles.
– l’algorithme d’Euclide : pour calculer le pgcd de deux nombres entiers, on calcule la suite des restes des divisions
entières successives ; le dernier reste non nul est le pgcd.
– l’algorithme de Dijkstra, pour résoudre un problème de plus court chemin.
– la recherche d’un mot dans un dictionnaire.
– une recette de cuisine.
Un algorithme s’exprime de façon plus ou moins formelle : langage naturel, ou pseudo-code plus ou moins précis, ou
langage de programmation de haut niveau. On peut se contenter des grandes lignes, en négligeant les problèmes d’implé-
mentation fine, de façon à privilégier la lisibilité et la compréhension. En général, la description d’un algorithme tient en
quelques lignes, et dépasse rarement une page. On néglige à ce stade les problèmes de génie logiciel tels que protection,
visibilité, modularité, traitement des erreurs . . . : ces aspects importants mais hors du champ de l’algorithmique sont traités
en programmation.

Structures de Données
L’essentiel des traitements informatiques consiste à manipuler des données. Il faut nécessairement organiser et structurer
les données d’une manière appropriée aux traitements envisagés.
Ce problème d’organisation des données est bien antérieur à l’informatique. Par exemple, on numérote les pages d’un
livre ; on construit des index des termes importants ; dans un dictionnaire, les mots sont rangés dans l’ordre alphabétique,
etc ... L’informatique ne fait que pousser à l’extrême cette démarche organisatrice.
Nous distinguerons soigneusement structure de données abstraite et structure de données concrète. La distinction est
de même nature qu’entre problème et algorithme. Une structure de données abstraite spécifie un ensemble de services
algorithmiques associés à la structure, permettant la création, la consultation et la modification des données. Une structure
de donnée concrète décrit et regroupe les algorithmes promis par la structure abstraite correspondante.
Donnons quelques exemples de structures de données abstraites. La plus simple est la collection1 : elle regroupe simple-
ment une collection d’objets. Une structure plus élaborée est celle de dictionnaire, qui regroupe un ensemble d’objets
associés chacun à une clé, et permet de retrouver facilement un objet à partir de sa clé. Le tableau et la liste chaînée sont
deux exemples de structures de données concrètes.

Programme
Pour exécuter un algorithme sur un ordinateur, on le traduit — on dit : on le réalise ou on l’implémente — en un
programme, exprimé dans un langage de programmation. On pourrait dire aussi que l’algorithme spécifie le programme
1 On dit également : multi-ensemble.
8 CHAPITRE 1. INTRODUCTION

qui l’implémente.
Alors qu’un algorithme peut rester plus ou moins formel, un programme est on ne peut plus formel (c’est à dire qu’il est
soumis à des règles syntaxiques et sémantiques rigoureuses).
Pour être pleinement et facilement utilisables, les algorithmes, et spécialement ceux des structures de données, doivent
être réalisés sous forme de bibliothèque logicielle. Lorsque l’on réalise une bibliothèque, on doit obligatoirement prendre
en compte tous les aspects de génie logiciel qui facilitent la réutilisation, par exemple en s’appuyant sur les concepts de
la programmation orientée objet.

Le tableau qui suit résume les relations entre les différentes notions que nous venons d’introduire. La flêche descendante
indique la relation «spécifie». La flêche montante indique la relation «résout» ou «implémente».
problème structure de données abstraite
↓ ↑ ↓ ↑
algorithme structure de données concrète
↓ ↑ ↓ ↑
programme bibliothèque logicielle

1.2 Quels sont les buts du cours ?


Ce cours est restreint à l’algorithmique séquentielle2 et symbolique3 . Les buts principaux du cours sont :
– d’acquérir une connaissance des algorithmes et des structures de données les plus utiles et les plus classiques,
– de donner un sens à la notion d’efficacité des algorithmes, en termes de consommation de ressources (temps de calcul
et consommation d’espace mémoire).

Il existe des bibliothèques toutes faites et très bien faites de structures de données et d’algorithmes. Citons au moins deux
bibliothèques : le collections framework de la plate-forme Java4 , et la bibliothèque LEDA pour C++5 . Pour bien les utiliser,
une formation de base en algorithmique et structures de données est indispensable, car il faut connaître les propriétés des
différentes structures pour choisir celles qui sont les mieux adaptées à son problème et les utiliser convenablement, mais
surtout pour ne pas réinventer la poudre. C’est l’objet de ce cours.

Ce cours est un cours d’algorithmique, et non un cours de programmation. Cependant nous exprimerons la plupart des
algorithmes étudiés sous forme de petits programmes Java indépendants. On aura ainsi le loisir de les exécuter et d’appré-
hender concrètement leur comportement. Le choix de Java est motivé par sa simplicité6 , sa portabilité, et par le fait qu’il
est de plus en plus connu et apprécié. De plus, il existe, associée au langage, une excellente bibliothèque implémentant
à peu près toutes les structures de données que nous allons étudier, nommée collections framework. Elle est présentée au
chapitre 14.

1.3 Quel est le contenu du cours ?


Nous commencerons par étudier la notion de récursivité (chapitre 2). C’est un schéma d’organisation algorithmique élé-
gant et d’une grande puissance d’expression.
Nous verrons comment évaluer les performances des algorithmes au chapitre 3.
Le chapitre 4 revient sur la distinction entre structures de données abstraites et concrètes, et présente les structures de
données abstraites les plus utiles : collection, ensemble, dictionnaire, pile, file, file de priorité.
Les chapitres suivants sont consacrés à l’étude des principales structures de données concrètes, en commençant par les
structures linéaires : le tableau (chapitre 5), la liste chaînée (chapitre 6), la table de hachage (chapitre 7), puis les structures
arborescentes : l’arbre binaire de recherche (chapitre 9), l’arbre «rouge et noir» (chapitre 10), le B-arbre (chapitre 11), le
tas (chapitre 12).
Le tri est un problème algorithmique simple, une sorte d’archétype. Il existe de nombreux algorithmes de tri, dont certains
très ingénieux (chapitre 13).
2 Par opposition à l’algorithmique parallèle, branche plus difficile, et moins aboutie pour l’instant.
3 Paropposition à l’algorithmique numérique, branche importante mais très particulière.
4 http://java.sun.com/j2se/1.3/docs
5 http://www.mpi-sb.mpg.de/LEDA/leda.html
6 On apprécie tout particulièrement la gestion mémoire avec récupération automatique, la façon simple et uniforme dont les objets sont désignés et

transmis comme arguments (par des pointeurs qui restent implicites).


1.3. QUEL EST LE CONTENU DU COURS ? 9

Un graphe est une structure mathématique extrêmement utile en informatique pour la représentation et la résolution de
nombreux problèmes, et pour laquelle il existe toute une algorithmique spécifique. L’étude de cette algorithmique fera
l’objet de la seconde partie du cours.

Liens avec d’autres cours


Les algorithmes étudiés dans ce cours ont tous un temps de calcul «raisonnable»7 . Le cours d’Optimisation Combi-
natoire, dans la continuité de celui-ci, abordera des problèmes pour lesquels de tels algorithmes sont très fortement
soupçonnés de ne pas exister.

Le cours de Programmation Fonctionnelle présente un langage et une philosophie de programmation de haut niveau,
facilitant l’écriture, la lisibilité et la maintenance des programmes, ainsi plus proches des algorithmes qu’ils implémentent.

7 Techniquement : temps logarithmique, linéaire ou polynomial. Ces termes seront expliqués au chapitre 3.
Chapitre 2

Récursivité

La récursivité est un paradigme de calcul semblable au raisonnement par induction. Bien utilisée, la récursion permet
souvent une expression simple et élégante des algorithmes.

2.1 Qu’est-ce que la récursivité ?


Une fonction ou un algorithme sont dits récursifs lorsqu’ils sont auto-référents : ils font références à eux-même dans leur
définition. Par exemple, la fonction factorielle est définie de façon récursive par :

n! = 1, si n = 0
n! = n × (n − 1)! , si n > 0

L’algorithme récursif suivant, exprimé en Java, calcule la fonction factorielle :


1 s t a t i c i n t fact ( i n t n ) {
2 r e t u r n ( ( n == 0) ? 1 : n * fact (n - 1) );
3 }
On remarque la grande proximité entre la structure de la fonction et celle de l’algorithme : c’est quasiment la même.
Voici un autre exemple classique : l’algorithme du tri par fusion d’une séquence de nombres exprimé de façon informelle
(mergesort ) :

– cas d’arrêt : 0, 1 ou 2 nombres à trier.


– cas général :
– couper la séquence en deux
– trier séparément les deux séquences, récursivement (appel récursif de l’algorithme)
– fusionner les deux séquences triées

Autre façon de voir la récursion : « diviser pour régner » :


– diviser en sous-problèmes,
– régner en les résolvant directement (cas d’arrêt) ou récursivement,
– combiner les solutions.
La récursion sert aussi pour définir des structures de données : arbres, expressions arithmétiques ... grammaires (voir le
cours de Programmation Fonctionnelle).
La récursion est utilisable dans tous les langages de programmation modernes. En particulier, les langages fonctionnels
(Lisp, Caml ...) encouragent la récursivité.

2.2 Critères d’un algorithme récursif correct


Nous avons dit qu’un algorithme doit nécessairement se terminer : il doit se dérouler en un nombre d’étapes fini. Pour
cela, on s’assurera toujours que dans un algorithme récursif
– il existe un ou plusieurs cas d’arrêt,
– les appels récursifs utilisent toujours des arguments «plus petits» que ceux donnés en entrée.

10
2.3. RÉCURSION ET ITÉRATION 11

J
Exercice 2.1 Écrire un algorithme récursif calculant le pgcd de deux nombres par soustractions successives.
J
Exercice 2.2 Écrire l’algorithme d’Euclide.

2.3 Récursion et itération


Les algorithmes récursifs ont souvent un équivalent itératif. Par exemple pour la factorielle :
1 s t a t i c i n t fact_iter ( i n t n ) {
2 i n t resultat = 1;
3

4 f o r ( i n t i = 1; i <= n ; i += 1) {
5 resultat *= i;
6 }
7 r e t u r n resultat ;
8 }
Théoriquement, la récursivité n’est pas indispensable : on peut toujours transformer un algorithme récursif en un algo-
rithme itératif (c’est-à-dire avec des boucles) équivalent, éxécutant la même séquence d’opérations. Cependant, il est
beaucoup de cas pour lesquels l’expression récursive est simple et naturelle. Certains algorithmes récursifs n’ont pas
d’équivalent itératif évident, comme le tri par fusion (voir page 10), ou l’algorithme résolvant le problème des tours de
Hanoi (voir page 12).
Dans le cas général, la traduction itérative nécessite une pile au moins, pour simuler la gestion du passage des arguments
et des retours.

2.4 Récursion et efficacité


Certaines formes récursives sont très inefficaces, par exemple cet algorithme calculant la fonction de Fibonacci sur un
mode calqué sur la définition mathématique. Voici d’abord cette définition :
f ib(1) = f ib(2) = 1
f ib(n) = f ib(n − 1) + f ib(n − 2) , si n > 2
Puis l’algorithme :
1 s t a t i c i n t fib ( i n t n ) {
2 r e t u r n ( (( n == 1) || ( n == 2))
3 ? 1
4 : fib (n - 1) + fib (n - 2) );
5 }
J
Exercice 2.3 Soit A(n) le nombre d’additions exécutées par l’algorithme précédent. Montrer que A(n) croit plus vite
que (3/2)n mais moins vite que (5/2)n . (Plus précisément, montrer que ∃n0 : n > n0 ⇒ (3/2)n < A(n) < (5/2)n ). On dit
que A(n) est une fonction à croissance exponentielle.
Voici un algorithme récursif plus efficace :
1 s t a t i c i n t fib_eff ( i n t n ) {
2 r e t u r n fib_eff_aux (n , 0, 1);
3 }
4

5 s t a t i c i n t fib_eff_aux ( i n t n , i n t u , i n t v ) {
6 r e t u r n ( ( n == 1)
7 ? v
8 : fib_eff_aux (n - 1, v , u + v ) );
9 }
On montre facilement que le nombre d’additions effectuées par ce dernier algorithme est n − 1 (fonction linéaire).

La fonction fib_eff_aux est dite récursive terminale : le résultat, dans le cas général (c’est-à-dire hors du cas d’arrêt)
est directement le résultat de l’appel récursif. La récursivité terminale confère aux algorithmes une propriété intéressante :
on peut toujours les convertir en algorithme itératif (avec boucle), sans besoin de pile auxiliaire.
12 CHAPITRE 2. RÉCURSIVITÉ

Voici justement la version itérative de l’algorithme récursif précédent :


1 s t a t i c i n t fib_iter ( i n t n ) {
2 i n t u = 0;
3 i n t v = 1;
4 i n t vieux_u ;
5

6 w h i l e ( n > 1) {
7 n -= 1;
8 vieux_u = u;
9 u = v;
10 v += vieux_u ;
11 }
12 r e t u r n v;
13 }
J
Exercice 2.4 (difficile) : inventer un algorithme calculant f ib(n) en un nombre A(n) d’additions croissant de façon
logarithmique.

2.5 Récursions célèbres


D’après [Cori & Levy].

Les tours de Hanoï


On dispose de trois plots A, B et C sur lesquels peuvent s’empiler n disques de tailles distinctes. Au départ les n disques
sont empilés sur le plot A, formant une pyramide. Il faut, par une succession de déplacements élémentaires de disques,
transporter la pile de disques vers le plot C. On doit respecter la contrainte suivante : un disque ne doit jamais se trouver
posé sur un disque plus petit que lui. Voici un algorithme résolvant le problème de façon récursive, exprimé en pseudo-
code :

– si n = 1 alors déplacer le disque de A vers C.


– si n > 1 alors
– transporter les n-1 disques plus petits de A vers B,
– transporter le plus grand disque de A vers C,
– transporter les n-1 disques plus petits de B vers C.

On trouvera dans le programme qui commence page 13 une expression en Java de cet algorithme.

Les fonctions suivantes sont des exemples de récursivité imbriquée.

La fonction d’Ackermann
Ack(0, n) = n + 1
Ack(m, 0) = Ack(m − 1, 1) pour m > 0
Ack(m, n) = Ack(m − 1, Ack(m, n − 1)) pour m, n > 0
J
Exercice 2.5 Que valent Ack(1, n), Ack(2, n), Ack(3, n), Ack(4, n), Ack(5, 1) ?

La fonction 91 de Mc Carthy
f (n) = n − 10, pour n > 100
f (n) = f ( f (n + 11)), sinon
J
Exercice 2.6 Que vaut f (96) ? Donner une forme non récursive de cette fonction.
2.6. PROGRAMME JAVA 13

La fonction de Morris
g(0, n) = 1
g(m, n) = g(m − 1, g(m, n)), pour m > 0
J
Exercice 2.7 Que vaut g(1, 0) ?

2.6 Programme Java


Le fichier TestRecursivite.java
1 /*
2 * TestRecursivite.java
3 *
4 * SupAéro -- Cours Structures de Données et Algorithmes
5 */
6

7 import java . io .*;


8

9 /**
10 * Cette classe rassemble des exemples de fonctions récursives.
11 *
12 * @version 4 Août 2000
13 * @author Michel Lemaître
14 */
15 c l a s s TestRecursivite {
16 s t a t i c PrintWriter sortie ;
17

18 p u b l i c s t a t i c void main ( String [] args ) throws IOException {


19 sortie = new PrintWriter (new FileWriter (" sortie_TestRecursivite " ));
20

21 sortie . println (" fact (5) = " + fact (5));


22 sortie . println (" fact_iter (5) = " + fact_iter (5));
23 sortie . println (" fib (11) = " + fib (11));
24 sortie . println (" fib_eff (11) = " + fib_eff (11));
25 sortie . println (" fib_iter (11) = " + fib_iter (11));
26

27 sortie . println ( "\ nhanoi (’ A ’, ’ C ’, ’ B ’, 3)" );


28 hanoi (’ A ’, ’ C ’, ’ B ’, 3);
29

30 sortie . println ();


31 f o r ( i n t i = 0; i <= 10; i += 1) {
32 sortie . println (" ackermann (3," + i + ") = " + ackermann (3, i ));
33 }
34 // sortie.println("ackermann(4,1) = " + ackermann(4,1)); :: trop long !
35 // sortie.println("ackermann(4,2) = " + ackermann(4,2));
36

37 sortie . close ();


38 }
39

40 s t a t i c i n t fact ( i n t n ) {
41 r e t u r n ( ( n == 0) ? 1 : n * fact (n - 1) );
42 }
43

44 s t a t i c i n t fact_iter ( i n t n ) {
45 i n t resultat = 1;
46

47 f o r ( i n t i = 1; i <= n ; i += 1) {
48 resultat *= i;
49 }
50 r e t u r n resultat ;
51 }
52
14 CHAPITRE 2. RÉCURSIVITÉ

53 s t a t i c i n t fib ( i n t n ) {
54 r e t u r n ( (( n == 1) || ( n == 2))
55 ? 1
56 : fib (n - 1) + fib (n - 2) );
57 }
58

59 s t a t i c i n t fib_eff ( i n t n ) {
60 r e t u r n fib_eff_aux (n , 0, 1);
61 }
62

63 s t a t i c i n t fib_eff_aux ( i n t n , i n t u , i n t v ) {
64 r e t u r n ( ( n == 1)
65 ? v
66 : fib_eff_aux (n - 1, v , u + v ) );
67 }
68

69 static i n t fib_iter ( i n t n ) {
70 int u = 0;
71 int v = 1;
72 int vieux_u ;
73

74 w h i l e ( n > 1) {
75 n -= 1;
76 vieux_u = u;
77 u = v;
78 v += vieux_u ;
79 }
80 r e t u r n v;
81 }
82

83 s t a t i c void hanoi ( char de , char a , char autre , i n t n ) {


84 i f ( n == 1) {
85 sortie . println (" Déplacement de " + de + " à " + a );
86 } else {
87 hanoi ( de , autre , a , n - 1);
88 hanoi ( de , a , autre , 1);
89 hanoi ( autre , a , de , n -1);
90 }
91 }
92

93 s t a t i c i n t ackermann ( i n t m , i n t n ) {
94 r e t u r n ( ( m == 0)
95 ? n + 1
96 : ( ( n == 0)
97 ? ackermann (m - 1, 1)
98 : ackermann (m - 1, ackermann (m , n - 1)) ) );
99 }
100 }

Le fichier de sortie sortie_TestRecursivite


1 fact(5) = 120
2 fact_iter(5) = 120
3 fib(11) = 89
4 fib_eff(11) = 89
5 fib_iter(11) = 89
6
7 hanoi(’A’, ’C’, ’B’, 3)
8 Déplacement de A à C
9 Déplacement de A à B
10 Déplacement de C à B
11 Déplacement de A à C
2.6. PROGRAMME JAVA 15

12 Déplacement de B à A
13 Déplacement de B à C
14 Déplacement de A à C
15
16 ackermann(3,0) = 5
17 ackermann(3,1) = 13
18 ackermann(3,2) = 29
19 ackermann(3,3) = 61
20 ackermann(3,4) = 125
21 ackermann(3,5) = 253
22 ackermann(3,6) = 509
23 ackermann(3,7) = 1021
24 ackermann(3,8) = 2045
25 ackermann(3,9) = 4093
26 ackermann(3,10) = 8189
Chapitre 3

Analyse des algorithmes

3.1 Motivation
Il est important d’analyser les algorithmes afin d’évaluer les ressources — temps de calcul et espace mémoire — qu’ils
consomment. On parle d’analyse de complexité d’un algorithme. Analyser un algorithme, c’est donc prévoir les res-
sources nécessaires à l’exécution des programmes qui l’implémenteront.
L’analyse d’un algorithme fait partie intégrante de son exposé. Elle oblige à une compréhension fine de l’algorithme. En
outre, l’analyse permet de comparer entre eux différents algorithmes résolvant le même problème.

3.2 Analyse de complexité en temps


C’est la principale analyse de complexité d’un algorithme.
On ne peut évidemment pas prévoir le temps de calcul d’un programme implémentant un algorithme sur une instance
donnée d’un problème, parce que ce temps dépend de facteurs d’implémentation tels que le langage de programmation
utilisé, la qualité du compilateur, la puissance de l’ordinateur . . ..
Par contre, et ce qui est au fond plus intéressant, on peut calculer un taux de croissance du temps de calcul en fonction
de la taille de l’instance à résoudre. Ce taux de croissance est indépendant des facteurs d’implémentation, et caractérise
bien la qualité de l’algorithme.

Analyse détaillée

// tri du tableau t[1 .. n], tri par insertion


static void tri_ins(int[] t, int n) {
1 int i; c1 × 1
2 int j = 2; c2 × 1
3 while (j <= n) { c3 × n
4 int clé = t[j]; c4 × n−1
// insertion de t[j] dans t[1..j-1] trié
5 i = j - 1; c5 × n−1
6 while ((i > 0) && (t[i] > clé)) { c6 × ∑nj=2 t j
7 t[i + 1] = t[i]; c7 × ∑nj=2 (t j − 1)
8 i -= 1; c8 × ∑nj=2 (t j − 1)
}
9 t[i + 1] = clé; c9 × n−1
10 j += 1; c10 × n−1
}
}

F IG . 3.1 – Analyse détaillée de la complexité en temps de l’algorithme de tri par insertion.

Nous allons le montrer sur un exemple, en réalisant une analyse très fine de la complexité en temps d’un algorithme

16
3.2. ANALYSE DE COMPLEXITÉ EN TEMPS 17

de tri par insertion. Voici cet algorithme, figure 3.1. Il trie sur place le tableau d’entiers t[i], pour i de 1 à n. En face
de chaque ligne, nous indiquons d’une part un temps pour son exécution (première colonne), et le nombre de fois que
cette ligne sera exécutée, au cours de l’exécution complète de l’algorithme, pour trier un tableau de n éléments (dernière
colonne). Le temps d’exécution de la ligne i est noté ci . Chaque ligne correspond à une suite d’opérations suffisamment
élémentaires pour que l’on puisse considérer que les ci sont des constantes. Puisque nous analysons un algorithme et non
un programme, nous ne connaissons pas les valeurs précises des ci , mais nous considérons que ce sont des constantes,
pour tout programme implémentant l’algorithme.
Le nombre de fois que la boucle intérieure 6-8 est exécutée dépend de la configuration exacte de l’instance (le contenu
effectif du tableau à trier). Pour en tenir compte et faciliter notre analyse fine, nous introduisons les paramètres t j , j =
2, . . . n : t j est le nombre de fois que le test de la ligne 6 est effectué pour cette valeur de j.
Il ne nous reste plus qu’à faire la somme des produits des quantités de chaque ligne pour obtenir le temps total d’exécution
T:

n n
T = c1 + c2 + c3 n + (c4 + c5 + c9 + c10 )(n − 1) + c6 ( ∑ t j ) + (c7 + c8 )( ∑ (t j − 1))
j=2 j=2

On montre que, lorsque les nombres sont déjà triés, on n’entre jamais dans le corps de la boucle 6-8, et donc t j = 1 pour
tout j. T devient alors

T = a + bn
où a et b sont de nouvelles constantes. Le temps de calcul est dans ce cas linéaire en fonction de la taille du tableau à
trier. On résume ceci par la notation T (n) ∈ Θ(n). Cette notation est définie précisément au paragraphe suivant.
Examinons maintenant la situation inverse, celle où le tableau est au départ trié dans l’ordre inverse. Pour cet algorithme
de tri, c’est la plus mauvaise configuration, car ∀ j = 2, . . . , n : t j = j. On montre facilement que T est alors de la forme

T (n) = c + dn + en2
où c, d, e sont de nouvelles constantes, dont peu importent les valeurs précises. Dans ce cas, le temps de calcul est une
fonction quadratique de la taille du tableau à trier. On note T (n) ∈ Θ(n2 ).

Analyse rapide
En pratique, on effectue rarement une analyse aussi fine des algorithmes. On se contente souvent d’analyser le comporte-
ment de l’algorithme dans le pire cas possible. On obtient ainsi une borne supérieure du temps nécessaire. Avec un peu
d’habitude, on calcule directement sur des taux de croissance. Ainsi dans notre exemple et dans le pire cas, l’algorithme
parcourt deux boucles imbriquées englobant un bloc en temps constant (lignes 7 et 8), chaque boucle étant effectuée au
plus n fois. On a donc toujours T (n) ≤ kn2 , pour une certaine constante k, ce que l’on résume par la notation T (n) ∈ O(n2 ).
Cette notation est également définie précisément au paragraphe suivant.

Autres techniques
La technique générale d’analyse des algorithmes récursifs est exposée page 20. Cependant, pour analyser un algorithme
récursif terminal, on peut par la pensée analyser l’algorithme itératif correspondant.

Voici une technique intéressante, souvent utilisée, lorsque l’algorithme (récursif ou non) visite une structure (typiquement
un tableau, un arbre ou un graphe) : on analyse le temps maximum passé sur chaque élément de la structure (typiquement
sur chaque élément du tableau, ou chaque nœud de l’arbre ou sommet du graphe) ; ensuite on somme ces temps sur les
éléments visités.

Autres analyses
On peut s’intéresser au cas moyen au lieu du cas pire, mais cela nécessite des hypothèses sur la distribution des données,
et l’analyse est généralement bien plus difficile.

Une autre analyse de complexité est importante : la complexité en occupation mémoire. Elle est en général beaucoup plus
facile à faire. Nous en verrons des exemples dans la suite du cours.
18 CHAPITRE 3. ANALYSE DES ALGORITHMES

Résumé
En résumé, on caractérise la complexité en temps d’un algorithme par le taux de croissance du temps d’exécution en
fonction de la taille de l’instance.

3.3 Taux de croissance


Définitions
On définit ainsi les classes de fonctions O( f ), Ω( f ) et Θ( f ) :
– T (n) ∈ O( f (n)) si il existe des constantes c et n0 strictement positives telles que
0 ≤ T (n) ≤ c f (n) pour tout n ≥ n0 .
«T ne croit pas plus vite que f » : borne supérieure asymptotique.
– T (n) ∈ Ω(g(n)) si il existe des constantes c et n0 strictement positives telles que
0 ≤ cg(n) ≤ T (n) pour tout n ≥ n0
«T ne croit pas moins vite que g» : borne inférieure asymptotique.
– T (n) ∈ Θ(h(n)) si et seulement si T (n) = O(h(n)) et T (n) = Ω(h(n))
« T et h croissent de façon équivalente ».
On trouve souvent la notation (abusive mais parfois pratique) T (n) = O(n) à la place de T (n) ∈ O(n), ou bien un taux de
croissance remplaçant un terme dans une expression, par exemple : T (n) = γT (n/2) + Θ(nβ ).

c.f(n)

T(n) T(n)

c.g(n)

n n
T (n) = O( f (n)) T (n) = Ω(g(n))
c.h(n)

T(n)

d.h(n)

n
T (n) = Θ(h(n))

F IG . 3.2 – Taux de croissance O, Ω et Θ

Propriétés
f (n) ∈ O(g(n)) ⇐⇒ g(n) ∈ Ω( f (n))
f (n) ∈ Θ(g(n)) ⇐⇒ g(n) ∈ Θ( f (n))

Si T (x) est un polynôme de degré n alors T (x) ∈ Θ(xn ).


3.3. TAUX DE CROISSANCE 19

Si T1 (n) ∈ Θ( f (n)) et T2 (n) ∈ Θ(g(n)) alors


T1 (n) + T2 (n) ∈ Θ(max( f (n), g(n)))
T1 (n) ∗ T2 (n) ∈ Θ( f (n) ∗ g(n))

Règle de style : on ôte les termes d’ordre inférieur et les constantes : ne pas écrire T (n) ∈ Θ(2n2 ) ou T (n) ∈ Θ(n2 + n)
mais T (n) ∈ Θ(n2 ).

Discussion et remarques
Il y a une analogie avec la comparaison de nombres :
– f (n) ∈ O(g(n)) ≈ a ≤ b
– f (n) ∈ Ω(g(n)) ≈ a ≥ b
– f (n) ∈ Θ(g(n)) ≈ a = b.
Cependant attention : les fonctions ne sont pas toujours comparables par leurs taux de croissance ; par exemple f (n) = n
et g(n) = n1+sin n .
n3 croit plus vite que n2 , donc n2 ∈ O(n3 ) et n3 ∈ Ω(n2 ).
Soit T (n) ∈ 2n2 . Alors T (n) ∈ O(n4 ), T (n) ∈ O(n3 ), T (n) ∈ O(n2 ), T (n) ∈ Θ(n2 ) sont vrais, mais l’information intéres-
sante est bien sûr la dernière.
La notation Θ signifie que le taux de croissance donné est le meilleur (le plus étroit) possible. On s’efforcera de donner le
taux de croissance en notation Θ.
Un algorithme en Θ(n2 ) est meilleur de façon asymptotique qu’un autre en Θ(n3 ), mais pour les petites tailles le second
peut être meilleur. Exemple A1 : 100n2 ; A2 : 5n3 . A2 est meilleur tant que n < 20.

Voici des taux de croissance typiques rencontrés en analyse d’algorithmes, ordonnés en ordre croissant :
1 : constant
log n : logarithmique
n : linéaire
n log n
n2 : quadratique
n3 : cubique
nc avec c > 1 : polynomial
2n , cn avec c > 1 : exponentiel

Expressions employées pour faire vite :


« Cet algorithme est [en temps] linéaire » : T (n) ∈ O(n).
« Cet algorithme est [en temps] polynomial » : il existe c > 1 tel que T (n) ∈ O(nc ).
« Cet algorithme est [en temps] exponentiel » : il existe c > 1 tel que T (n) ∈ Ω(cn ).

Le temps d’execution d’un algorithme est en Θ(g(n)) si et seulement si son temps d’exécution dans le pire des cas est en
O(g(n)) et dans le meilleur des cas est en Ω(g(n)).

Comparaison de taux de croissance


Le tableau suivant donne le temps d’exécution T (n), mesuré en µs pour différents taux de croissance
20 CHAPITRE 3. ANALYSE DES ALGORITHMES

T (n) en µs n =10 n =20 n =30 n =40 n = 50 n =60


log n 1 µs 1,3 µs 1,5 µs 1,6 µs 1,7 µs 1,8 µs
n 10 µs 20 µs 30 µs 40 µs 50 µs 60 µs
n log n 10 µs 26 µs 44 µs 64 µs 85 µs 107 µs
n2 100 µs 400 µs 900 µs 1,6 ms 2,5 ms 3,5 ms
n3 1 ms 8 ms 27 ms 64 ms 125 ms 216 ms
n5 0,1 s 3s 24 s 1,7 mn 5 mn 13 mn
2n 1 ms 1s 18 mn 13 jours 36 ans 366
siècles
3n 60 ms 1 heure 6 ans 3900 2 ×108 1,3 ×1013
siècles siècles siècles

On constate que la croissance des fonctions exponentielles 2n et 3n devient rapidement déraisonnable. Les algorithmes
ayant de tels taux de croissance sont confrontés au phénomène dit d’«explosion combinatoire» : ils deviennent rapidement
inutilisables au fur et à mesure que l’on augmente la taille des instances à résoudre.

On s’interroge maintenant sur l’effet des améliorations technologiques : et si on disposait d’ordinateurs plus puissants ?
Soit N la taille de la plus grosse instance traitable en une heure. On se demande quelle taille on pourra traiter lorsque les
ordinateurs seront 100 et 1000 fois plus rapides.

T (n) aujourd’hui 100 fois 1000 fois


plus rapide plus rapide
n N 100 N 1000 N
n2 N 10 N 32 N
n3 N 4,6 N 10 N
n5 N 2,5 N 4N
2n N N+7 N + 10
3n N N+4 N+6

Exemple pour T (n) = n2 : aujourd’hui T (N) = kN 2 , demain T 0 (N) = kN 2 /100. On cherche donc N 0 tel que T (N) = T 0 (N 0 )
soit kN 2 = kN 02 /100 soit N 0 = 10N.
Dans les cas linéaire et polynomial, on profite pleinement de la nouvelle puissance. Dans le cas exponentiel, le surcroît de
puissance apporte peu.

3.4 Analyse des algorithmes récursifs


L’analyse de la complexité en temps des algorithmes récursifs nécessite des techniques particulières.
Prenons en exemple le tri par fusion (mergesort) d’une séquence S de n = 2m nombres :

trifusion(S,n) =
si n=1 retour S
sinon partager S en deux sous-séquences S1 et S2 ;
retour fusion(trifusion(S1,n/2),
trifusion(S2,n/2))

Pour effectuer cette analyse, nous supposerons que le partage et la fusion sont en Θ(n). Le temps de calcul T (n) du tri des
n nombres s’exprime par un système de deux équations :
T (n) = c1 , si n = 1
T (n) = 2T (n/2) + c2 n , si n > 1

Résolution des équations récurrentes


Il existe trois méthodes principales pour résoudre ce type d’équations récurrentes :
– deviner une solution f (n) et montrer que T (n) ≤ f (n) ou f (n) ≤ T (n) par induction
– expansion : substitutions successives à droite dans T (n) jusqu’à T (1)
– utilisation des solutions générales d’équations de types connus (ci-dessous).
3.5. POINT DE VUE CRITIQUE SUR L’ANALYSE DES ALGORITHMES 21

Voici la solution générale de la récurrence T (n) = γT (n/2) + Θ(nβ ), dans laquelle n/2 peut être remplacé par les parties
entières supérieures ou inférieures de n/2 :
– si γ < 2β alors Θ(nβ )
– si γ = 2β alors Θ(nβ log n)
– si γ > 2β alors Θ(nlog2 γ )
J
Exercice 3.1 Résoudre le système précédent pour le tri par fusion.

On trouvera dans [Cormen] la solution générale de la récurrence T (n) = aT (n/b) + f (n).

3.5 Point de vue critique sur l’analyse des algorithmes


Il est important de connaître la complexité en temps des algorithmes que l’on utilise. De même, c’est un bon réflexe de
déterminer la complexité en temps des algorithmes que l’on conçoit.

En pratique, on distingue deux grandes classes d’algorithmes :


– les «bons» algorithmes : les algorithmes polynomiaux,
– les autres, qui mènent au phénomène d’«explosion combinatoire», et qui doivent être évités si l’on peut.

Toutefois, il est bon de conserver du recul par rapport à la complexité théorique d’un algorithme. On doit souvent faire le
choix entre un algorithme A lent mais simple à comprendre et à programmer, et un autre algorithme B plus rapide mais
compliqué et plus difficile à programmer. D’autres phénomènes peuvent jouer : lorsque la taille des données est petite,
l’algorithme A peut suffire ; de même si l’algorithme est peu employé.
On doit se méfier aussi des effets des constantes de proportionalité cachées dans les taux de croissance, ou du fait que le
pire cas peut être statistiquement improbable. Ainsi, on connait l’exemple de l’algorithme du simplexe en programmation
linéaire : il est exponentiel en théorie, mais polynomial en pratique. Dans le même ordre d’idée, l’algorithme du tri rapide
(quicksort, voir page 69), est en Θ(n2 ) dans le pire cas, mais ce pire cas est extrêmement improbable. L’algorithme est en
moyenne et en pratique en O(n log n).
Enfin, d’autres critères de jugement entrent en ligne de compte : la consommation mémoire centrale ou mémoire secon-
daire, la clarté, la simplicité de l’algorithme.

3.6 Exercices
Exercice 3.2 L’affirmation suivante : «le temps d’exécution de l’algorithme A est au moins en O(n2 )» a-t-elle un
J

sens ?

Exercice 3.3 Montrer rigoureusement que f (n) = 3n n’est pas O(2n ).


J

Exercice 3.4 Montrer rigoureusement que f (n) = 3n n’est pas O(nc ), ∀c.
J

Exercice 3.5 Montrer que n! croit plus vite que 2n (très facile).
J

Exercice 3.6 Montrer que (n − 1)! croit plus vite que n2 2n .


J

J
Exercice 3.7 Analyser le tri par bulle (fonction tri_bulle dans le programme Java qui commence en page 72, lignes
85 à 95).
J
Exercice 3.8 Analyser le tri par sélection (fonction tri_selection dans le programme Java qui commence en page
72, lignes 97 à 110).
J
Exercice 3.9 Analyser le tri par dénombrement (fonction tri_denombrement dans le programme Java qui commence
en page 72, lignes 138 à 157). Faire l’analyse en fonction de n et k.
22 CHAPITRE 3. ANALYSE DES ALGORITHMES

J
Exercice 3.10 Quel travail accomplit la fonction récursive suivante, et quel est son intérêt ?
static double toto(double x, int n) throws Exception {
if (n < 0) {
throw new Exception("x est négatif !");
} else if (n == 0) {
return 1.0;
} else if (n % 2 == 1) { // teste si n est impair
return x * toto(x, n - 1);
} else {
return toto(x * x, n /2 );
}
}
Analysez sa complexité en temps (pire cas), en fonction de n. Aide : considérer la décomposition binaire de n.
J
Exercice 3.11 Étant donnés un texte et un motif sous forme de séquences de caractères, déterminer le nombre de fois
où le motif apparait dans le texte.
Exemples :
– le motif "elle" apparait 2 fois dans le texte "quelle belle journee",
– le motif "abcabdabc" apparait 1 fois dans le texte "abcabcabdabc",
– le motif "aa" apparait 4 fois dans le texte "aaaaa".
Voici un algorithme (en Pascal) qui résoud le problème :

1 function p0(motif,texte:string):integer ;
2 var lt,lm,i,j,n :integer ;
3 var c :bool ;
4 begin
5 lt := length_string(texte); (* longueur du texte *)
6 lm := length_string(motif); (* longueur du motif *)
7 n := 0; (* nombre d’occurences trouvees *)
8 i := 1; (* indice sur les caracteres du texte *)
9 while i <= lt-lm+1 do
10 begin
11 j := 1; (* indice sur les caracteres du motif *)
12 c := true;
13 while (j <= lm) do
14 begin
15 c := c and ( texte[i+j-1] = motif[j] ) ;
16 j := j+1
17 end ;
18 if c then n := n+1;
19 i := i+1
20 end;
21 return(n)
22 end.

Faites l’analyse de complexité de cet algorithme. Pourriez-vous l’améliorer ?


J
Exercice 3.12 Décrire un algorithme permettant de trouver le minimum et le maximum d’un ensemble quelconque de
n nombres sans faire plus de 3n/2 comparaisons. Justifiez votre réponse.
J
Exercice 3.13 Faites l’analyse de complexité du programme en C suivant, qui calcule la racine carrée entière d’un
nombre entier n, en fonction de n.

1 int rac (n)


2 int n;
3 {
4 int p,b,h,d,e,m ;
5
6 p = 1 ;
7 while (n>=(p*p)) {p = 2*p ; }
3.6. EXERCICES 23

8
9 /* ici on a 0<=n<p*p */
10
11 b = 0;
12 h = p;
13 d = p;
14
15 while (d>1) {
16 /* d = h-b b*b<=n<h*h */
17 e = d/2;
18 m = b+e;
19 if (n<(m*m)) h = m; else b = m ;
20 d = e;
21 }
22
23 return b ;
24 }
25
26 main()
27 {
28 int n;
29
30 while (1) {
31 printf("Donnez n\n ");
32 scanf("%d", &n);
33 if (n < 0)
34 printf("Illegal number\n");
35 else printf("Racine carree de %d = %d\n",n,rac(n)) ;
36 }
37 }
J
Exercice 3.14 La multiplication de deux nombres complexes utilise 4 multiplications et 2 additions ou soustractions
sur les réels. Montrer que 3 multiplications suffisent.
J
Exercice 3.15 Le thème de cet exercice est l’analyse de complexité de deux algorithmes de multiplication. On prendra
pour taille d’une multiplication le maximum du nombre de chiffres des deux nombres à multiplier, soit n.
1. Montrer par un argument rapide que la complexité en temps dans le pire cas de l’algorithme de multiplication
“ordinaire” (la multiplication à la main, apprise à l’école), est Θ(n2 ).
2. On supposera dans la suite que n est une puissance de 2. Les nombres à multiplier sont donnés sous forme d’une
suite de n bits. Cette suite est coupée en deux parties de n/2 bits, de telle sorte que, x et y étant les deux nombres à
multiplier, on a : x = a2n/2 + b et y = c2n/2 + d, a, b, c, d étant des nombres de n/2 bits.
(a) Vérifier la formule : xy = ac2n + [(a − b)(d − c) + ac + bd]2n/2 + bd.
(b) En déduire un nouvel algorithme de multiplication, que l’on énoncera informellement.
(c) Evaluer la complexité en temps dans le pire cas de cet algorithme. Est-il meilleur que le premier ?
Aides : on tiendra pour acquis que l’addition ou la soustraction de nombres de n bits est Θ(n) ; d’autre part, la multipli-
cation par 2n se fait simplement par n décalages, ce qui est aussi Θ(n).
J
Exercice 3.16 Multiplication de deux complexes.
Chapitre 4

Généralités sur les structures de données

4.1 Les deux niveaux d’organisation des données


Les algorithmes et les programmes manipulent des données, le plus souvent organisées selon deux niveaux :
– le niveau individuel,
– le niveau collectif.

Au niveau individuel
Au niveau individuel, les données sont organisées en enregistrements (figure 4.1). Dans chaque enregistrement, on trouve
– un ensemble d’informations organisées en champs
– une clé associée aux informations, et qui sert à les retrouver.

clé

champ1
champ2
. informations
.
.

F IG . 4.1 – Un enregistrement.

La clé est le plus souvent présente, mais elle peut être absente. La clé est souvent un des champs d’information.
Voici des exemples d’enregistrements :
– dans un dictionnaire de langue, les mots sont les clés, les définitions sont les informations, le couple clé-définition
constitue l’enregistrement.
– dans un annuaire téléphonique, la clé est le nom, les champs sont l’adresse et le numéro de téléphone.
– dans un agenda, la clé est la date.
– dans un dossier de sécurité sociale, la clé est le numéro de sécurité sociale, identifiant unique pour chaque individu.
En général, chaque clé est unique (c’est le cas dans nos exemples sauf pour l’annuaire téléphonique à cause des homony-
mies), et il existe un ordre total sur les clés (numérique, alphabétique ...).

Au niveau collectif
Au niveau collectif, la collection1 des enregistrements constituant les données est organisée de façon à faciliter leur
manipulation. Des opérations, telles que : retrouver un enregistrement par sa clé, modifier, ajouter ou supprimer des
1 J’emploie à dessein le terme «collection», et non «ensemble», pour signifier un groupe d’éléments, dans lequel il peut y avoir éventuellement des

éléments dupliqués. Dans un ensemble au contraire, chaque élément est unique. Un autre mot possible pour «collection» est «multi-ensemble».

24
4.2. STRUCTURE ABSTRAITE ET STRUCTURE CONCRÈTE 25

enregistrements, vont s’appliquer sur la collection et la faire évoluer au cours du temps. L’organisation de la collection,
conçue en fonction des opérations que l’on prévoit de lui faire subir pendant sa vie, est nommée structure de données.

4.2 Structure abstraite et structure concrète


Au niveau collectif d’organisation des données, on distingue deux façons de décrire des structures de données : les struc-
tures abstraites et les structures concrètes.

Structure abstraite
Une structure de donnée abstraite2 est définie par l’ensemble des opérations prévues sur la structure. Chaque opération
est décrite par sa signature, c’est-à-dire l’énoncé des types des objets qu’elle reçoit et qu’elle renvoie. On décrit les pro-
priétés que doivent respecter les effets des opérations entre elles. En somme, une structure de données abstraite est une
spécification. Éventuellement, cette spécification peut être décrite formellement dans un langage à caractère mathéma-
tique.

Structure concrète
Une structure de données concrète correspond à l’implémentation d’une structure de donnée abstraite. Une structure de
données concrète est composée d’un algorithme pour chaque opération, plus éventuellement des données spécifiques à la
structure pour sa gestion.
Une même structure de données abstraite peut donner lieu à plusieurs structures de données concrètes, avec des perfor-
mances différentes.

Le reste de ce chapitre est consacré à la présentation des structures de données abstraites dont on a le plus souvent besoin.

4.3 Collection et Ensemble


La collection regroupe un certain nombre d’enregistrements. Certains enregistements peuvent être dupliqués. Dans un
ensemble au contraire, chaque enregistrement est unique. Dans une collection ou dans un ensemble, on n’utilise pas les
clés des enregistrements, qui peuvent en être dépourvus.
Les structures de collection et d’ensemble sont définies par les quatre opérations suivantes, qui s’appliquent implicitement
à la collection ou à l’ensemble courant des enregistrements :
– EST _ VIDE() → Booléen : teste si la collection ou l’ensemble est vide.
– INSÈRE(Enregistrement ) : insère un enregistrement dans la collection ou l’ensemble.
– SUPPRIME(Enregistrement ) : supprime un enregistrement donné de la collection ou de l’ensemble.
– RECHERCHE(Enregistrement ) → Booléen : teste la présence dans la collection ou l’ensemble d’un enregistrement
donné.
Lorsque l’on insère un enregistrement dans un ensemble, on doit s’assurer que l’on ne crée pas de duplication. Selon la
structure concrète qui va réaliser l’ensemble, cette vérification est soit effectuée par l’algorithme d’insertion, soit elle reste
à la charge de l’utilisateur de la structure. Dans les deux cas, elle peut s’effectuer par l’opération RECHERCHE.

4.4 Dictionnaire
Le dictionnaire3 est une structure de donnée abstraite fondamentale. Dans un dictionnaire, les enregistrements ont chacun
une clé unique. Mathématiquement, le dictionnaire correspond à la notion de fonction, du domaine des clés vers celui des
enregistrements.
Cette structure est définie par les quatre opérations suivantes, qui s’appliquent implicitement à l’ensemble courant des
enregistrements du dictionnaire :
– EST _ VIDE() → Booléen : teste si le dictionnaire est vide.
– INSÈRE(Clé , Enregistrement ) : insère dans le dictionnaire un enregistrement de clé donnée.
2 On pourrait dire égalemement : type de données abstrait. En anglais : abstract data type, ADT.
3 Quelquefois nommé liste d’associations.
26 CHAPITRE 4. GÉNÉRALITÉS SUR LES STRUCTURES DE DONNÉES

– SUPPRIME (Clé ) : supprime un enregistrement de clé donnée du dictionnaire.


RECHERCHE (Clé ) → Enregistrement
S
– {null} : recherche dans le dictionnaire l’enregistrement de clé donnée, re-
tourne cet enregistrement4 s’il existe, et la valeur spéciale null sinon.
Chaque clé devant être unique, l’insertion d’un nouvel enregistrement possédant une clé déjà présente dans le dictionnaire
pose un problème. Selon la structure concrète qui va réaliser le dictionnaire, l’insertion d’un enregistrement avec une clé
déjà présente donnera lieu soit à un refus d’insertion avec avertissement, soit à l’écrasement de l’ancien enregistrement
de même clé et son remplacement par le nouveau. Dans le premier cas, l’insertion doit être précédée d’une suppression.

Le programme Java des pages 4.8 et suivantes donne l’exemple de la création et de l’exploitation d’un dictionnaire,
utilisant la bibliothèque collections framework.

4.5 Pile
La pile est une structure abstraite permettant de gérer une collection d’enregistrements dépourvus de clés, offrant les
opérations suivantes :
– EST _ VIDE() → Booléen : teste si la pile est vide.
– EMPILE(Enregistrement ) : insère un enregistrement au sommet de la pile.
– DÉPILE() → Enregistrement {null} : supprime de la pile le dernier enregistrement empilé, celui qui occupe le som-
S

met, et renvoie cet enregistrement. Il va donc y avoir un nouveau sommet, sauf si la pile est vide après la suppression.
– SOMMET() → Enregistrement : donne accès au sommet de la pile (le dernier enregistrement empilé), sans l’ôter de
celle-ci.
En anglais, une pile se dit stack, et les trois dernières opérations se nomment respectivement push, pop, top.
La pile applique le principe « dernier entré, premier sorti » (Last In, First Out, LIFO).

La pile, bien que très simple, a beaucoup d’usages, et spécialement (mais pas uniquement) dans le domaine de la compi-
lation des langages informatiques. Voici quelques usages classiques de la pile :
– vérification du bon équilibrage d’une expression parenthésée,
– calcul des expressions postfixées,
– conversion d’une expression en notation infixe en une notation postfixée,
– mémorisation des appels de procédures imbiquées au cours de l’exécution d’un programme, et en particulier les appels
des procédures récursives,
– parcours de structures arborescentes (voir page 40), et parcours de graphe (voir la seconde partie du cours),
– tout simplement, la pile peut servir à implémenter un ensemble d’enregistrements sans clés, jouissant des seules opéra-
tions «ajouter un élément», «ôter et renvoyer un élément», «ensemble vide ?», avec une grande efficacité. Ce peut être
une simple collection d’enregistrements que l’on doit mémoriser au fur et à mesure de leur création, et traiter chacun
une seule fois.
En fait, l’idée de pile est très liée à celle de récursion. L’usage d’une procédure récursive évite d’avoir à gérer explicitement
une pile.
J
Exercice 4.1 Proposer trois algorithmes correspondant aux trois premières applications énoncées ci-dessus (difficulté
croissante).

4.6 File
La file est une structure abstraite analogue à la pile, sauf qu’elle applique le principe « premier entré, premier sorti » (First
in, First out, FIFO). Cela donne un comportement analogue à une file d’attente devant un guichet.
En anglais, file se dit queue.

Les usages de la file sont nombreux. La plupart correspondent à l’idée simple de file d’attente. Exemples :
– gestion des travaux d’impression d’une imprimante.
– ...
– certains parcours de graphe (voir la seconde partie du cours).
4 Plus précisément, un pointeur sur l’enregistrement.
4.7. FILE DE PRIORITÉ 27

4.7 File de priorité


La file de priorité est une structure de donnée abstraite qui permet de gérer une collection d’enregistrements munis de
clés. De plus, il doit exister un ordre total sur les clés. La file de priorité offre les opérations suivantes :
– INSÈRE(Clé , Enregistrement ) : insère dans la file un enregistrement de clé donnée.
– MAXIMUM() : renvoie l’enregistrement de la file possédant la plus grande clé.
– EXTRAIRE _ MAXIMUM() : supprime et renvoie l’enregistrement de la file possédant la plus grande clé.
Le maximum pourrait être bien sûr remplacé par le minimum (on n’offre cependant pas les deux à la fois, il faut choisir
l’un ou l’autre).

On se sert de files de priorité pour gérer des tâches auquelles sont associées des priorités. L’ordonnancement des tâches
d’un ordinateur en est un exemple.
Une application majeure des file de priorité est la simulation évènementielle. Dans un simulateur temporisé (par exemple
un simulateur de réseau de transports, de théatre de combats . . .), l’évènement courant est traité ; ce traitement génère de
nouveaux évènements dans le futur ; ces nouveaux évènements, auquels on associe comme clé l’instant auquel ils auront
lieu, sont rangés dans une file de priorité. Le prochain évènement courant est extrait de la file par EXTRAIRE _ MINIMUM.
La file de priorité est utilisée également dans un algorithme de recherche du plus court chemin (algorithme de Dijkstra).
La performance de l’algorithme dépend directement des performances des opérations de la file de priorité.

Les chapitres suivants sont consacrés à l’étude des principales structures de données concrètes, qui servent à implémenter
les structures abstraites que nous venons de voir.

4.8 Programme Java : création et utilisation d’un dictionnaire


Le fichier TestDictionnaire.java
Exemple de création et d’exploitation d’un dictionnaire par utilisation d’un composant de la bibliothèque collections
framework de la plateforme Java.
La structure abstraite de dictionnaire est nommée Map en Java. Il est implémenté ici par une table de hachage (HashMap).
L’opération d’insertion est ici nommée put(Clé , Enregistrement ).
1 /*
2 * TestDictionnaire.java
3 *
4 * SupAéro -- Cours Structures de Données et Algorithmes
5 */
6

7 import java . io .*;


8 import java . util .*;
9

10 /**
11 * Exemples d’utilisation d’un dictionnaire.
12 *
13 * @version 24 Août 2000
14 * @author Michel Lemaître
15 */
16 c l a s s TestDictionnaire {
17 p u b l i c s t a t i c void main ( String [] args ) {
18

19 // Création de quelques appareils


20 Appareil a1 = new Appareil (" Concorde " , 78700, 25.6, 3, 2200);
21 Appareil a2 = new Appareil (" Boeing747 " , 174400, 59.6, 3, 940);
22 Appareil a3 = new Appareil (" Spitfire " , 2624, 11.23, 1, 562);
23

24 // Création d’un dictionnaire (en Java : un Map)


25 // implémenté en table de hachage
26 Map m = new HashMap ();
27

28 m. put ( a1 . nom , a1 );
28 CHAPITRE 4. GÉNÉRALITÉS SUR LES STRUCTURES DE DONNÉES

29 m. put ( a2 . nom , a2 );
30 m. put ( a3 . nom , a3 );
31

32 System . out . println (m. get (" Boeing747 " ));


33 System . out . println (m. get (" AirbusA340 " ));
34 }
35 }
36

37 c l a s s Appareil {
38 String nom ;
39 double poids ;
40 double envergure ;
41 i n t equipage ;
42 double vitesse ;
43

44 Appareil ( String n , double p , double en , i n t eq , double v ) {


45 nom = n;
46 poids = p;
47 envergure = en ;
48 equipage = eq ;
49 vitesse = v;
50 }
51

52 p u b l i c String toString () {
53 return
54 "< nom =" + nom +
55 "; poids =" + poids +
56 "; envergure =" + envergure +
57 "; equipage =" + equipage +
58 "; vitesse =" + vitesse + ">" ;
59 }
60 }
61

62 /*==================
63

64 >java TestDictionnaire
65 <nom=Boeing747; poids=174400.0; envergure=59.6; equipage=3; vitesse=940.0>
66 null
67

68 ================== */
Chapitre 5

Tableau

Le tableau, que tout le monde connait, et qui est disponible dans presque tous les langages de programmation, est la
structure de données concrète la plus simple. Le tableau est également à la base de toutes les structures concrètes, de
façon plus ou moins implicite. Sa propriété essentielle est la suivante : à partir d’un indice, on accède en temps constant
à l’élément du tableau correspondant. On résume cela en disant que le tableau est une structure à accès direct.
L’inconvénient du tableau, c’est sa rigidité : on est obligé de prévoir sa taille au moment de sa création. Le faire grossir
ou rétrécir dynamiquement coûte cher.

5.1 Implémentations de la collection, de l’ensemble et du dictionnaire par un


tableau
Le tableau implémente très simplement la collection ou l’ensemble : il suffit de ranger les enregistrements1 dans les
éléments du tableau.
Comment implémenter un dictionnaire avec un tableau ? Cela dépend essentiellement du domaine des clés (c’est-à-dire
l’ensemble possible des clés).
– s’il est petit et si les clés sont des entiers positifs, le tableau convient merveilleusement : les clés seront directement les
indices (voir figure 5.1). L’insertion et la recherche sont en temps constant.
– si le domaine des clés est très grand ou non entier, et s’il existe un ordre sur les clés, on rangera les enregistrements
dans l’ordre des clés. La recherche se fait alors par dichotomie, comme pour un dictionnaire ordinaire. Il faut pour cela
que le dictionnaire soit créé une fois pour toute, ou qu’il n’évolue pas trop, car l’opération d’insertion coûte cher.
J
Exercice 5.1 Écrire un algorithme de recherche par dichotomie, et évaluer sa complexité en temps en fonction du
nombre d’éléments du tableau.

1 2 clé
tableau ....... ......

enregistrements ...... ......

F IG . 5.1 – Implémentation d’un dictionnaire avec un tableau, lorsque le domaine des clés est entier positif et petit. La clé
sert à indicer directement l’enregistrement dans le tableau. Pour les clés non utilisées, le tableau contient la valeur null.

1 Plus précisément des pointeurs vers les enregistrements.

29
30 CHAPITRE 5. TABLEAU

5.2 Implémentations de la pile et de la file par un tableau


J
Exercice 5.2 Montrer comment la pile (définition page 26) s’implémente avec un tableau. Écrire les algorithmes des
différentes opérations. Analyser la complexité en temps des différentes opérations. Donner les avantages et les inconvé-
nients de cette implémentation.
J
Exercice 5.3 Montrer comment la file (définition page 26) s’implémente avec un tableau. Écrire les algorithmes des
différentes opérations. Analyser la complexité en temps des différentes opérations. Donner les avantages et les inconvé-
nients de cette implémentation.
Chapitre 6

Liste chaînée

La liste chaînée est une structure de donnée concrète très souple, qui peut servir à implémenter — plus ou moins efficace-
ment — la plupart des structures de données abstraites.
Dans une liste chaînée, l’ordre des enregistrements n’est pas déterminé par un indice comme dans un tableau, mais par
une suite de pointeurs1 (voir la figure 6.1). La liste chaînée n’est pas une structure à accès direct : pour retrouver un
enregistrement dans la liste (opération RECHERCHE), on doit suivre la séquence des pointeurs depuis le début (tête) de la
liste. La liste est une structure à accès séquentiel.
Nous allons illustrer dans ce chapitre l’utilisation de la liste chaînée pour implémenter un dictionnaire2 (définition page
25). Les structures abstraites collection et ensemble s’implémentent avec une liste chaînée essentiellement de la même
façon.

6.1 La liste chaînée la plus simple


La liste la plus simple est la liste simplement chaînée, non triée, non circulaire. La figure 6.1 en est un exemple.

Element
info
clé suivant
tête
5 12 8

F IG . 6.1 – Liste simplement chaînée de trois enregistrements de clés respectives 5, 12, et 8. Le pointeur null est représenté
par un "/".

Un programme Java de liste simplement chaînée est proposé page 33 et suivantes. La classe Liste contient un seul
champ : le champ tête de type Element. C’est par la tête de la liste qu’on pourra accéder à tous ses enregistrements.
Dans notre implémentation, nos enregistrements sont représentés par les deux champs :
– clé, ici de type int,
– info de type Element.
Le dernier champ suivant de la classe Element, de type Element lui-même, est un pointeur vers la suite de la liste. Ce
pointeur peut être la valeur spéciale null, signalant ainsi la fin de la liste.
Pour construire une liste vide, on alloue simplement un nouvel objet de type Liste en inititialisant sa tête à null (ligne
75).
L’opération EST _ VIDE est simplement un test reconnaissant une tête égale au pointeur null (ligne 92).
1 Un pointeur est simplement une adresse interne.
2 Bien que ce ne soit pas l’implémentation la plus efficace. La table de hachage (chapitre 7), ou l’arbre «rouge et noir» (chapitre 10), sont de
meilleures structures concrètes pour le dictionnaire.

31
32 CHAPITRE 6. LISTE CHAÎNÉE

L’opération RECHERCHE (lignes 95 à 105) est implémentée par une boucle while dont on sort si la fin de liste est atteinte,
ou bien si on tient l’enregistrement cherché.
L’opération INSÈRE (lignes 107 à 113) insère un nouvel élément (dont seul le champ info est supposé initialisé) en début
de liste.
L’implémentation de l’opération SUPPRIME proposée ici (lignes 115 à 133) est plus subtile qu’il n’y parait au premier
abord. Remarquer tout d’abord que la méthode supprime reçoit un argument de type Element. Cet argument peut
avoir été retrouvé auparavant par l’opération RECHERCHE. La méthode supprime a recours à une fonction auxiliaire
supprime_aux(e, t), qui supprime récursivement l’élément e de la sous-liste t, et rend la sous-liste t modifiée. On
renvoie null s’il n’y a pas eu de suppression (l’élément n’était pas dans la liste).
J
Exercice 6.1 Donner une version non récursive de SUPPRIME.

6.2 Autres listes chaînées


Dans une liste triée, il existe un ordre total sur les clés, et l’ordre des enregistrements dans la liste respecte l’ordre sur les
clés.
Dans une liste circulaire, le dernier pointeur null est remplacé par un pointeur sur la tête de liste.
Dans une liste doublement chaînée, un champ supplémentaire est ajouté à chaque enregistrement : un pointeur vers
l’enregistrement précédent dans la liste (voir la figure 6.2).

Element

clé
précédent info
tête suivant

5 12 8

F IG . 6.2 – Liste doublement chaînée de trois enregistrements de clés respectives 5, 12, et 8. Le pointeur null est représenté
par un "/". Par rapport à la liste simplement chaînée, un champ précédent a été ajouté dans Element.

6.3 Intérêts et inconvénients de la liste


La liste est une structure souple : elle peut facilement grossir et diminuer. On n’a pas besoin de lui réserver à l’avance de
la place mémoire. Cette qualité de souplesse s’oppose à la rigidité du tableau, son principal concurrent.
Tout avantage se paie : alors que l’accès à un élément d’un tableau est en temps O(1) (accès direct), le temps d’accès à un
élément d’une liste est en O(n), où n est la taille de la liste (le nombre d’éléments qu’elle contient), que la liste soit triée
ou non.
J
Exercice 6.2 Quel sont les intérêts spécifiques d’une liste triée, d’une liste doublement chaînée, d’une liste circulaire ?
J
Exercice 6.3 On implémente la collection par une liste chaînée. On veut pouvoir effectuer l’opération UNION de deux
collections en temps constant. Quelle type de liste convient ? (on peut inventer une variante). Donner l’algorithme de
l’opération UNION.

6.4 Implémentations de la pile, de la file et de la file de priorité par une liste


chaînée
J
Exercice 6.4 Proposer une implémentation de la pile avec une liste chaînée, assurant toutes les opérations sur la pile
en O(1). Comparer avec l’implémentation en tableau (exercice 5.2 page 30), et discuter les avantages et les inconvénients
des deux implémentations.
6.5. PROGRAMME JAVA 33

J
Exercice 6.5 Proposer une implémentation de la file avec une liste chaînée, assurant toutes les opérations sur la file
en O(1). Comparer avec l’implémentation en tableau (exercice 5.3 page 30), et discuter les avantages et les inconvénients
des deux implémentations.
J
Exercice 6.6 Proposer deux implémentations de la file de priorité par une liste chaînée. Analyser la complexité en
temps des opérations, et discuter des avantages et inconvénients des deux implémentations.

6.5 Programme Java


Le fichier TestListe.java
1 /*
2 * TestListe.java
3 *
4 * SupAéro -- Cours Structures de Données et Algorithmes
5 */
6

7 import java . io .*;


8

9 /**
10 * Définition et test de la liste simplement chaînée, non triée, non circulaire.
11 *
12 * @version 3 Septembre 2000
13 * @author Michel Lemaître
14 */
15 c l a s s TestListe {
16 s t a t i c PrintWriter sortie ;
17

18 p u b l i c s t a t i c void main ( String [] args ) throws IOException {


19 sortie = new PrintWriter (new FileWriter (" sortie_TestListe " ));
20

21 Element e1 = new Element (" tata " );


22 Element e2 = new Element (" titi " );
23 Element e3 = new Element (" toto " );
24 Element e4 = new Element (" tutu " );
25

26 Liste l1 = new Liste ();


27

28 l1 . insère (12, e1 );
29 l1 . insère (18, e2 );
30 l1 . insère (24, e3 );
31 sortie . println ( l1 );
32

33 sortie . println ( l1 . recherche (18));


34 sortie . println ( l1 . recherche (19));
35

36 l1 . supprime ( e2 );
37 sortie . println ( l1 );
38

39 l1 . supprime ( e3 );
40 sortie . println ( l1 );
41

42 l1 . supprime ( e1 );
43 sortie . println ( l1 );
44

45 l1 . supprime ( e4 );
46 sortie . println ( l1 );
47

48 sortie . close () ;
49 }
50 }
51

52
34 CHAPITRE 6. LISTE CHAÎNÉE

53 c l a s s Element {
54 i n t clé ;
55 Object info ;
56 Element suivant ;
57

58 /** Constructeur : on met dans l’Element juste l’info */


59 Element ( Object o ) {
60 info = o;
61 }
62

63 /** Imprimeur */
64 p u b l i c String toString () {
65 r e t u r n "( clé =" + clé + ", info =" + info + ")\ n";
66 }
67 }
68

69

70 c l a s s Liste {
71 Element tête ;
72

73 /** Constructeur : donne une liste vide */


74 Liste () {
75 tête = n u l l ;
76 }
77

78 /** Imprimeur */
79 p u b l i c String toString () {
80 String s = " Liste : (\ n";
81 Element e = tête ;
82

83 w h i l e ( e != n u l l ) {
84 s = s + e. toString ();
85 e = e. suivant ;
86 }
87 r e t u r n s + ")\ n";
88 }
89

90 /** Test de liste vide */


91 boolean est_vide () {
92 r e t u r n tête == n u l l ;
93 }
94

95 /** Recherche le premier Element de clé k dans la Liste


96 * et le retourne. Si aucun Element de clé k n’est dans
97 * la liste, on retourne null. */
98 Element recherche ( i n t k ) {
99 Element e = tête ;
100

101 w h i l e (( e != n u l l ) && ( e. clé != k )) {


102 e = e. suivant ;
103 }
104 r e t u r n e;
105 }
106

107 /** Insère un Element e de clé c en début de la Liste


108 * Seul le champ info de e est supposé initialisé */
109 void insère ( i n t c , Element e ) {
110 e. clé = c;
111 e. suivant = tête ;
112 tête = e;
113 }
114

115 /** Supprime de la Liste l’Element e, s’il y est */


116 void supprime ( Element e ) {
6.5. PROGRAMME JAVA 35

117 tête = supprime (e , tête );


118 }
119

120 /* Supprime l’Element e de la sous-liste d’Elements


121 * commençant en t, et renvoie la sous-liste résultant
122 * de la suppression, ou null s’il n’y a pas eu de suppression */
123 Element supprime ( Element e , Element t ) {
124 i f ( t == n u l l ) {
125 return null ;
126 } e l s e i f ( t == e ) {
127 r e t u r n t. suivant ;
128 } else {
129 t. suivant = supprime (e , t. suivant );
130 r e t u r n t;
131 }
132 }
133 }

Le fichier sortie_TestListe
1 Liste : (
2 (clé=24, info=toto)
3 (clé=18, info=titi)
4 (clé=12, info=tata)
5 )
6
7 (clé=18, info=titi)
8
9 null
10 Liste : (
11 (clé=24, info=toto)
12 (clé=12, info=tata)
13 )
14
15 Liste : (
16 (clé=12, info=tata)
17 )
18
19 Liste : (
20 )
21
22 Liste : (
23 )
24
Chapitre 7

Table de hachage

7.1 Présentation générale


La table de hachage est une invention1 majeure : elle est à l’informatique ce que la roue est au transport.
La table de hachage implémente le dictionnaire (définition page 25). On l’utilise typiquement lorsque le nombre d’en-
registrements du dictionnaire est entre 100 et 1 000 000, et lorsque le domaine des clés (l’ensemble des clés possibles)
est très grand, beaucoup plus grand que le nombre d’enregistrements. Le programme page 27 montre une utilisation
d’un dictionnaire implémenté en table de hachage. Un usage typique est la maintenance de la table des symboles d’un
compilateur.
Dans tout ce chapitre, n désignera le nombre d’enregistrements du dictionnaire, et U désignera le domaine des clés.
Les performances de la table de hachage sont étonnantes : l’opération RECHERCHE(Clé ) est en temps Θ(n) dans le
pire cas, mais ce pire cas est extrêmement improbable (on s’arrange pour qu’il n’arrive jamais). En pratique le temps
de recherche est constant, comme pour l’accès à un tableau ! De même pour INSÈRE(Clé , Enregistrement ) et SUP -
PRIME(Enregistrement ). Cette performance ne se fait pas au détriment de l’espace occupé, qui reste en Θ(n), même
si |U|  n. Ces performances expliquent l’usage universel de la table de hachage.

7.2 Fonctionnement

1
2 k3

15 k2 k1

F IG . 7.1 – Table de hachage contenant trois enregistrements de clés k1 , k2 et k3 , avec h(k1 ) = h(k2 ) = 15 (collision), et
h(k3 ) = 2. Le pointeur null est représenté par un "/".

Le fonctionnement de la table de hachage est très simple. Voir la figure 7.1. On utilise un tableau support t de longueur
m. C’est un tableau de listes chaînées : chaque élément du tableau est une tête de liste. Soit x un enregistrement de clé k à
insérer dans la table. Il sera inséré dans la liste d’indice t[h(k)], où h est une fonction de U dans 1, . . . , m. Cette fonction
1 La technique, inventée en 1953, est due à H. P. Luhn.

36
7.3. FONCTIONS DE HACHAGE 37

h est appelée fonction de hachage. Elle doit être déterministe2 et calculable en temps O(1).
Les listes ne sont pas triées sur les clés. L’insertion peut se faire en tête de liste, donc en temps O(1), à condition d’être
sûr de l’unicité de la clé insérée. Sinon, il faut passer par une recherche préalable. Pour rechercher un enregistrement de
clé donnée k, il suffit de le chercher dans la liste t[h(k)], ce qui prend un temps proportionnel à la longueur de la liste.
Pour une suppression, on est ramené au problème de supprimer un enregistrement dans une liste.
On déduit de ce fonctionnement que les performances des deux opérations de recherche et de suppression sont étroitement
liées à la longueur des listes : il faut qu’elles soient les plus courtes possibles. On dit qu’il y a collision lorsque deux clés
distinctes tombent dans la même liste. C’est le cas figure 7.1 pour les clés k1 et k2 . À la limite, toutes les clés pourraient
tomber dans la même liste. Pour éviter ce phénomène catastrophique, on s’arrange pour que la fonction h répartisse bien
les clés dans les listes, c’est-à-dire de façon uniforme.
On pourrait croire qu’il est possible de concevoir une fonction h telle que les listes seraient toutes de longueur 1 au
maximum (et que donc on pourrait se passer des listes). Mais le phénomène de collision est inévitable, du fait que |U|  n.

7.3 Fonctions de hachage


Une fonction de hachage parfaite vérifie l’hypothèse de hachage uniforme : chaque clé a la même probabilité que les
autres de tomber dans une liste donnée. Cela s’exprime formellement ainsi :

1
pour tout i ∈ (1, . . . , m) : ∑ P(k) =
m
{k | h(k)=i}

où P(k) est la probabilité que la clé k soit utilisée (on rappelle que |U|  n, et que m est la longueur du tableau support).
La plupart du temps les probabilités P(k) sont inconnues, et donc on ne peut pas construire de fonction de hachage parfaite.
On ne peut que vérifier empiriquement sa qualité, par observation de répartition des longueurs des listes pendant la vie
du dictionnaire. Cependant, beaucoup d’études ont été faites sur de bonnes fonctions de hachage. Nous allons en décrire
deux classiques.
Tout d’abord, on s’arrange pour transformer les clés non entières en clés entières. Par exemple si les clés sont de chaînes
de caractères codés en ASCII, la clé X1 sera transformée en l’entier 88 × 128 + 31, sachant que le code ASCII de X est
88 et celui de 1 est 31.

Méthode de la division
On prend h(k) = k mod m + 1 (reste de la division entière plus 1). On rappelle que m est la longueur du tableau support.
On évite de prendre pour m une puissance de 2, car alors h(k) serait constituée des bits droits de k, ignorant les gauches :
ça ne mélange pas assez. De même on évite de prendre une puissance de 10 (penser par exemple aux numéros de sécurité
sociale pris comme clé). Une bonne chose est de prendre pour m un entier premier proche de la longueur visée au départ.
Par exemple, on prévoit un dictionnaire de n = 2000 enregistrements, et on accepte des listes de longueur 3 en moyenne.
On prendra m = 701, premier et proche de 2000/3, et loin d’une puissance de 2.

Méthode de la multiplication
On prend h(k) = bm(kA − bkAc)c + 1, où A est un nombre réel bien choisi tel que 0 < A < 1. bxc désigne la partie entière
du nombre réel x. La quantité kA − bkAc est simplement la partie fractionnaire de kA.
L’avantage de cette fonction est qu’elle n’impose pas de contrainte sur m.

Hachage universel
J
Exercice 7.1 Un pirate informatique fantaisiste ou hostile, connaissant votre fonction de hachage, pourrait tenter
de planter votre application (un compilateur par exemple) en s’arrangeant pour créer des enregistrements dont les clés
(des noms de variables) tombent toutes dans la même liste. Comment parer cette grave menace ? (le principe est facile à
trouver, l’application plus difficile.)
2 Cela veut dire que c’est une véritable fonction : appliquée au même paramètre, elle renvoie toujours le même résultat.
38 CHAPITRE 7. TABLE DE HACHAGE

7.4 Analyse du temps de l’opération RECHERCHE


L’opération RECHERCHE(k), où k est la clé de l’enregistrement cherché, s’effectue en trois étapes :
– le calcul de i = h(k) : cela nécessite un temps constant Θ(1).
– l’accès à la tête de la liste t[i] : en temps également constant.
– la recherche dans la liste.
C’est le temps de cette dernière étape qu’il faut cerner.
Dans le pire cas, la liste dont la tête est en t[i] pourrait être de longueur n. Le pire cas est donc en temps Θ(n). Une
fonction de hachage correcte nous épargne en pratique cette calamité.
Intéressons-nous plutôt au cas moyen. Pour cela, on introduit la quantité α = mn , appellée facteur de remplissage (en
anglais : load factor ), où, on le rappelle, n est le nombre d’enregistrements du dictionnaire, et m est la longueur du
tableau support. Ce facteur de remplissage α n’est autre que la longueur moyenne des listes. Il peut être inférieur, égal ou
supérieur à 1.
Dans le cas où la clé cherchée n’est pas dans le dictionnaire, et donc absente de la liste, il faudra parcourir toute la liste, ce
qui prend un temps moyen proportionnel à la longueur moyenne des listes, soit α. Le temps total moyen de l’opération de
recherche est donc dans ce cas (y compris les deux premières étapes) Θ(1 + α). Remarque : on laisse 1 + α sans simplifier
en α car on ne fait encore aucune hypothèse sur l’ordre de grandeur de α.
Dans le cas où la clé cherchée est dans le dictionnaire et donc dans la liste, le nombre d’éléments accédés est 1 (celui que
l’on cherche) plus un certain nombre p d’autres éléments. Le nombre de clés différentes de celle cherchée est n − 1, et
donc on en trouve en moyenne p = n−1 m = α − m dans la liste examinée. On examinera en moyenne la moitié de ces p
1

éléments, ce qui nous fait en tout 1 + 2p = 1 + α2 − 2m1


éléments examinés en moyenne. Le temps total requis (y compris les
deux premières étapes) dans le cas d’une recherche réussie est donc Θ(1 + 1 + α2 − 2m 1
), soit Θ(1 + α), car m est supposé
grand. On trouve le même taux de croissance que pour une recherche infructueuse.
En résumé, la recherche dans une table de hachage requiert en moyenne et en pratique un temps constant plus un temps
proportionnel au facteur de remplissage.
Pour un bon fonctionnement de la table de hachage, on s’arrange pour que le facteur de remplissage reste «constant»,
disons proche de 1, ce qui assure un temps de recherche moyen constant. Si le facteur de remplissage augmente trop, suite
à de nombreuses insertions, on doit reconstruire la table de hachage avec un nouveau tableau support de longueur plus
grande, ce qui diminue le facteur de remplissage.
Chapitre 8

Généralités sur les arborescences

Les chapitres suivants décriront des structures de données concrètes de type arborescence1 . Ce chapitre regroupe les
notions et le vocabulaire sur les arborescences utiles pour la suite du cours.

8.1 Définition de l’arborescence


Voici une définition récursive2 : une arborescence est une collection de nœuds reliés entre eux par des arcs. La collection
peut être vide, auquel cas l’arborescence ne possède ni nœuds ni arcs. Si elle n’est pas vide, elle contient un nœud
particulier r appelé racine, et une séquence, éventuellement vide, de (sous-) arborescences non vides A1 , A2 , · · · , Ak , de
racines respectives r1 , r2 , · · · , rk . La racine r est reliée à chacun des ri , i = 1, · · · , k, par un arc orienté de r vers ri .

8.2 Vocabulaire et propriétés

b c d

e f g

h i j k l m n

o p q

F IG . 8.1 – Une arborescence. Elle est composée d’une racine, le nœud a, et de trois sous-arborescences de racines b, c et
d. Les arcs sont implicitement orientés du haut vers le bas. Les fils du nœud g sont les nœuds j, k l, m et n. Le père du
nœud j est le nœud g. Les feuilles de cette arborescence sont les nœuds h, i, f, c, j, o, p, q, l, m, n. Les ancêtres de k sont
k, g, d et a. Les descendants de b sont b, e, f, h et i. Les hauteurs respectives des nœuds b, c, d sont 2, 0, 3. La hauteur de
l’arborescence est 4.

Les ri sont les fils de r, et r est le père de chaque ri . La racine de l’arborescence est le seul nœud sans père. Les nœuds qui
n’ont pas de fils sont appelés feuilles de l’arborescence. Les nœuds qui ont au moins un fils sont appelés nœuds internes.
Tous les arcs sont orientés de la racine vers les feuilles. Une séquence de nœuds partant d’un nœud a en suivant des arcs
(selon leur orientation) jusqu’à un nœud b s’appelle un chemin de a à b. Tous les nœuds du chemin de a à b sont des
ancêtres de b et des descendants de a. On peut donc dire que les nœuds d’une sous-arborescence de racine x sont les
descendants de x (y compris x lui-même). La longueur d’un chemin a1 , a2 , · · · , a p est le nombre d’arcs sur le chemin,
c’est-à-dire p − 1. Il existe exactement un chemin de la racine jusqu’à tout nœud. La hauteur d’un nœud est la longueur
1 On dit également arbre enraciné (en anglais : rooted tree ).
2 Dans la seconde partie du cours, on définira un arbre comme un graphe non orienté, connexe et acyclique, et une arborescence comme un arbre
dans lequel un sommet – la racine – est distingué, ce qui oriente implicitement toutes les arêtes. La définition donnée ici de l’arborescence est différente,
mais équivalente.

39
40 CHAPITRE 8. GÉNÉRALITÉS SUR LES ARBORESCENCES

du plus long chemin partant de ce nœud et aboutissant à une feuille. La hauteur de toute feuille est donc 0. La hauteur
de l’arborescence est la hauteur de sa racine. L’arité d’un nœud est le nombre de ses fils. Une arborescence binaire, ou
simplement arbre binaire, est une arborescence dans lequel tout nœud possède au plus deux fils.
Un arbre binaire complet est un arbre binaire dont tous les nœuds internes ont deux fils, et dont tous les chemins de la
racine aux feuilles sont de longueur égales.

F IG . 8.2 – À gauche, un arbre binaire complet donc parfaitement équilibré. À droite, un arbre binaire possédant le même
nombre de nœuds, mais complètement déséquilibré.

J
Exercice 8.1 Quelles sont les hauteurs maximale et minimale d’une arborescence binaire de n nœuds ?

8.3 Usages des arborescences


Ils sont multiples, car ils capturent l’idée de hiérarchie. Exemples :
– découpage d’un livre en parties, chapitres, sections, paragraphes . . .,
– hiérarchies de fichiers,
– expressions arithmétiques,
– ...

8.4 Représentations des arborescences


Elles ont multiples, et s’adaptent à l’usage requis.
On peut utiliser une représentation en tableau (exemple du BE «connectivité» ; tas, chapitre 12), ou une représentation
à base de pointeurs (arbres binaires de recherche, chapitre 9 ; arbres «rouge et noir», chapitre 10). La représentation des
arborescences avec des pointeurs généralise celle des listes. Pour un arbre binaire, on prévoit pour chaque nœud, en
plus des champs d’information, deux champs qui pointent sur les deux fils, de valeur éventuellement null. Pour une
arborescence non binaire, on utilise une liste chaînée pour représenter la séquence des fils. On peut également ajouter des
pointeurs vers les pères, afin de «remonter» plus facilement dans l’arborescence.
J
Exercice 8.2 En s’inspirant du programme sur les listes chaînées page 33, écrire une classe arborescence permettant
de représenter des expressions arithmétiques. On envisagera la possibilité de représenter des opérateurs avec un nombre
quelconque d’arguments.

8.5 Parcours d’arborescences


Il s’agit d’énumérer tous les nœuds d’une arborescence. On distingue trois parcours classiques, qui peuvent se décrire de
façon récursive :
– parcours préfixe : on énumère la racine, puis récursivement les nœuds de chaque sous-arborescence.
– parcours postfixe : on énumère récursivement les nœuds de chaque sous-arborescence, puis la racine.
– parcours infixe d’un arbre binaire : on énumère d’abord les nœuds du sous-arbre gauche, puis la racine, et enfin les
nœuds du sous-arbre droit.
J
Exercice 8.3 Compléter la classe de l’exercice 8.2 par des méthodes de parcours d’arborescence.
Chapitre 9

Arbre binaire de recherche (ABR)

L’arbre binaire de recherche, en abrégé ABR, est une structure de donnée concrète arborescente servant à implémenter
les structures abstraites avec clés, lorsqu’il existe une relation d’ordre total sur le domaine des clés. L’ABR peut donc
implémenter les opérations du dictionnaire (définition page 25), ainsi que d’autres opérations exploitant l’ordre sur les
clés : MAXIMUM, MINIMUM, SUCCESSEUR et PRÉDÉCESSEUR. Elle peut servir également à implémenter la file de priorité
(définition page 27 ; voir l’exercice 9.14).
Toutes les opérations ont, dans le pire des cas, un temps d’exécution proportionnel à la hauteur de l’arborescence.
En informatique, on emploie souvent par facilité le terme «arbre» à la place du terme plus correct «arborescence». Nous
ne dérogerons pas à cette habitude.

9.1 Définition de l’arbre binaire de recherche


L’arbre binaire de recherche est une arborescence binaire (définition chapitre 8) représentée sous forme chaînée : chaque
nœud de l’arbre contient :
– un enregistrement (informations) muni de sa clé,
– deux champs droit et gauche, pointeurs vers les deux fils du nœud,
– un champ père, pointeur vers le père du nœud1 .
Lorsque les sous-arbres droit ou gauche sont vides, les champs correspondants valent null. Le nœud racine est le seul
pour lequel le champ père est null.
Voici un exemple de description d’un nœud d’ABR en Java :
1 c l a s s Noeud {
2 i n t clé ;
3 Object info ;
4 Noeud gauche ;
5 Noeud droit ;
6 Noeud père ;
7

8 /** Constructeur.
9 * On met juste l’information dans le noeud,
10 * Le reste sera garni lors de l’insertion. */
11 Noeud ( Object o ) {
12 info = o;
13 }
14

15 /** Imprimeur */
16 p u b l i c String toString () {
17 r e t u r n ( clé + "=" + info + " " );
18 }
19 }
Dans cet exemple, les clés employées sont des entiers, mais dans le cas général le domaine des clés est quelconque, pourvu
qu’il existe une relation d’ordre total sur les clés.
1 Le pointeur vers le nœud père est facultatif, mais est utile pour des implémentations efficaces.

41
42 CHAPITRE 9. ARBRE BINAIRE DE RECHERCHE (ABR)

L’ABR possède de plus une propriété fondamentale, exploitant un ordre total sur les clés. Soit x un nœud quelconque
d’un ABR. Pour tout nœud y du sous-arbre gauche de x, la clé de y est inférieure ou égale à la clé de x, et pour tout nœud
z du sous-arbre droit de x, la clé de z est supérieure ou égale à la clé de x.
La figure 9.1 représente deux exemples d’ABR. Elle montre que plusieurs ABR différents peuvent posséder la même
collection de clés. Les deux arbres (a) et (b) sont équilibrés différemment, et leurs hauteurs respectives sont différentes.

6 2

3 7 3

2 5 8 7

(a) 6 8

5 (b)

F IG . 9.1 – Deux arbres binaires de recherche ayant la même collection de clés. Les clés sont des entiers. Dans les figures
d’arbres binaires de recherche, nous représentons seulement la clé associée à chaque nœud, et non l’information associée.
De même, les pointeurs issus de chaque nœud sont implicitement représentés par les segments de droite entre nœuds. Les
pointeurs null ne sont pas représentés. Comme d’habitude, la racine de l’ABR est en haut.

L’énoncé ci-dessus de la propriété fondamentale de l’ABR utilise des inégalités non-strictes. Pour un dictionnaire, on
pourrait imposer des inégalités strictes, vu que dans ce cas les clés sont uniques. Nous conservons cependant cette défi-
nition plus large, car elle permet de décrire également l’ABR comme support de la file de priorité (pour laquelle les clés
sont ordonnées mais non nécessairement uniques).

9.2 Parcours d’ABR


Revoir les définitions des différents parcours d’arborescences page 40.
Voici l’algorithme de parcours infixe d’un sous-arbre ayant pour racine un nœud x donné :
1 void parcours_infixe ( Noeud x , PrintWriter s ) {
2 i f ( x != n u l l ) {
3 parcours_infixe (x. gauche , s );
4 s. print (x );
5 parcours_infixe (x. droit , s );
6 }
7 }
Cet algorithme est récursif. Les lignes importantes sont 3-5. Les algorithmes des parcours préfixe et postfixe s’obtiennent
simplement par permutations de ces trois lignes. Ici le traitement effectué à chaque nœud est une impression (ligne 4),
mais ce pourrait être un comptage, une mise-à-jour de l’information, ...
J
Exercice 9.1 Quelle propriété particulière les clés énumérées possèdent-elles lorsqu’on parcourt un arbre binaire de
recherche dans l’ordre infixe ?
J
Exercice 9.2 Montrer que le parcours d’un ABR, qu’il soit infixe, postfixe ou préfixe, est en O(n), où n est le nombre
de nœuds de l’ABR, pourvu que le traitement effectué en chaque nœud soit en O(1).

9.3 Recherches dans un ABR


9.3.1 Opérations MINIMUM et MAXIMUM
Voici l’algorithme de recherche du nœud de clé minimum, dans le sous-arbre de racine z :
1 Noeud minimum ( Noeud z ) {
2 Noeud x = z;
9.3. RECHERCHES DANS UN ABR 43

15
6 18

3 7 17 20

2 4 13

F IG . 9.2 – Recherches dans un arbre binaire de recherche. La clé minimum est 2. Le successeur de la racine est le nœud
de clé 17, minimum de son sous-arbre droit. Le successeur du nœud de clé 4, qui n’a pas de sous-arbre droit, est le nœud
de clé 6.

4 w h i l e ( x. gauche != n u l l ) {
5 x = x. gauche ;
6 }
7 r e t u r n x;
8 }
Cet algorithme est itératif. À partir du nœud z, on suit les fils gauches jusqu’à tomber sur un sous-arbre vide. Le nœud de
clé minimum est le dernier visité. Ce peut être z lui-même.
J
Exercice 9.3 Écrire minimum sous forme récursive.
J
Exercice 9.4 Écrire maximum.

9.3.2 Opération RECHERCHE


Voici l’algorithme de recherche du nœud de clé c dans le sous-arbre de racine z :
1 Noeud recherche ( i n t c , Noeud z ) {
2 Noeud x = z;
3

4 w h i l e (( x != n u l l ) && ( c != x. clé )) {
5 x = ( c < x. clé ) ? x. gauche : x. droit ;
6 }
7 r e t u r n x;
8 }
C’est encore une forme itérative. Depuis z, on descend un chemin dans l’arbre, à gauche ou à droite selon le résultat de la
comparaison entre la clé cherchée et celle du nœud visité. La descente s’arrête
– soit sur un pointeur null, ce qui signifie que la clé n’est pas dans le sous-arbre de racine z, on renvoie null,
– soit sur le nœud cherché : il est renvoyé.
Bien sûr, pour rechercher une clé dans l’ABR tout entier, on appelle la procédure précédente sur le nœud racine.
J
Exercice 9.5 Écrire recherche sous forme récursive.

9.3.3 Opérations SUCCESSEUR et PRÉDÉCESSEUR


Si toutes les clés sont distinctes, le successeur d’un nœud z est celui qui possède la plus petite clé parmi tous les nœuds
de clé supérieure à la clé de z.
Voici l’algorithme de recherche du nœud successeur, dans l’arbre tout entier, du nœud z :
1 Noeud successeur ( Noeud z ) {
2 i f ( z. droit != n u l l ) {
3 r e t u r n minimum (z. droit );
4 } else {
5 Noeud y = z. père ;
44 CHAPITRE 9. ARBRE BINAIRE DE RECHERCHE (ABR)

6 Noeud x = z;
7

8 w h i l e (( y != n u l l ) && ( x == y. droit )) {
9 x = y;
10 y = y. père ;
11 }
12 r e t u r n y;
13 }
14 }
Si le sous-arbre droit de z n’est pas vide (test ligne 2), alors le successeur de z est simplement le nœud de clé minimum
parmi les nœuds du sous-arbre droit (ligne 3). Par exemple, dans la figure 9.2, le successeur de la racine est le nœud de
clé 17.
Si le sous-arbre droit de z est vide (bloc qui débute ligne 5), alors le successeur de z, s’il existe, est le premier ancêtre de
z dont le fils gauche est aussi un ancêtre de z. Par exemple, dans la figure 9.2, le successeur du nœud de clé 4 est le nœud
de clé 6. Pour le trouver, on remonte dans l’arbre vers la racine à partir de z jusqu’au «premier virage à droite» (test ligne
8). Plus précisément on sort de la boucle des lignes 8-11
– soit par (y == null), auquel cas la racine est atteinte, signifiant que z était le maximum : on renvoie null,
– soit par (x != y.droit), auquel cas y est le successeur cherché.
J
Exercice 9.6 Démontrer rigoureusement la correction de l’algorithme précédent (deux cas).
Les notions de successeur et de prédécessur se généralisent facilement au cas où les clés ne sont pas toutes distinctes.
L’algorithme ci-dessus fonctionne aussi dans ce cas.
J
Exercice 9.7 Vérifier que toutes les opérations de recherche ci-dessus s’exécutent en temps O(h), où h est la hauteur
de l’arbre.

9.4 Insertion dans un ABR


Contrairement aux opérations de recherche, l’insertion et la suppression modifient l’ABR. Cette modification doit se faire
en conservant la propriété fondamentale de l’ABR.
Voici l’algorithme d’insertion dans l’ABR, d’un nouveau nœud z de clé c. On suppose que seules les informations (champ
info) du nœud ont été initialisées à la création du nœud.
1 void insère ( i n t c , Noeud z ) {
2 Noeud y = n u l l ;
3 Noeud x = racine ;
4

5 z. clé = c;
6 z. droit = n u l l ;
7 z. gauche = n u l l ;
8 w h i l e ( x != n u l l ) {
9 y = x;
10 x = ( z. clé < x. clé ) ? x. gauche : x. droit ;
11 }
12 z. père = y;
13 i f ( y == n u l l ) {
14 racine = z;
15 } e l s e i f ( z. clé < y. clé ) {
16 y. gauche = z;
17 } else {
18 y. droit = z;
19 }
20 }
L’idée est simple : comme pour une recherche, on descend dans l’arbre en suivant les fils gauches ou droits selon les
résultats des comparaisons entre c et les clés rencontrées sur le chemin (boucle des lignes 8-11). Dans cette descente, x
parcourt le chemin, et y est toujours le père de x. On s’arrête lorsque x vaut null. C’est ce null que l’on doit remplacer,
dans y, par le nouveau nœud z à insérer. L’accrochage effectif a lieu lignes 12-19. Il faut distinguer le cas d’un arbre vide
au départ (lignes 13-14) du cas général (lignes 15-19).
9.5. SUPPRESSION DANS UN ABR 45

J
Exercice 9.8 Vérifier que l’insertion ci-dessus s’exécute en temps O(h), où h est la hauteur de l’arbre.
J
Exercice 9.9 Écrire une version récursive de l’insertion.

9.5 Suppression dans un ABR


C’est l’opération la plus compliquée, car le nœud à supprimer peut se trouver «au milieu» de l’arbre, et il faut conserver
la propriété fondamentale de l’ABR. Il y a trois cas, selon que le nœud z à supprimer :
– (a) est une feuille,
– (b) n’a qu’un seul fils,
– (c) possède deux fils.
Ces trois cas correspondent respectivement aux cas (a), (b) et (c) de la figure 9.3.
Si z n’a pas de fils (cas (a)), on modifie son père pour remplacer le champ fils par null.
Si z n’a qu’un fils (cas (b)), on modifie le père de z pour que son champ fils pointe directement sur le fils de z.
Le dernier cas, lorsque le nœud z a deux fils (cas (c)), est le plus délicat. On va détacher y, le successeur de z, et le mettre
à la place de z. Les exercices suivants vous invitent à justifier par étapes cette opération.
J
Exercice 9.10 Montrer que dans un arbre binaire de recherche, si un nœud possède deux fils, son successeur n’a pas
de fils gauche, et son prédecesseur n’a pas de fils droit.
J
Exercice 9.11 On choisit de détacher y, un nœud qui possède au plus un fils. Pourquoi ?
J
Exercice 9.12 Montrer qu’en remplaçant z par son successeur, on conserve la propriété fondamentale de l’arbre.

Voici le détail de l’algorithme de suppression d’un nœud z dans l’arbre :


1 void supprime ( Noeud z ) {
2 Noeud x;
3 Noeud y;
4 Noeud p;
5

6 y = (( z. gauche == n u l l ) || ( z. droit == n u l l )) ? z : successeur (z );


7 p = y. père ;
8 x = ( y. gauche == n u l l ) ? y. droit : y. gauche ;
9 if ( x != n u l l ) {
10 x. père = p;
11 }
12 i f ( p == n u l l ) {
13 racine = x;
14 } e l s e i f ( y == p. gauche ) {
15 p. gauche = x;
16 } else {
17 p. droit = x;
18 }
19 i f ( y != z ) {
20 z. clé = y. clé ;
21 z. info = y. info ;
22 }
23 }
Ligne 6, on détermine le nœud y à détacher (c’est un nœud qui a au plus un fils) : si z a 0 ou 1 fils (cas a et b), c’est z
lui-même, sinon c’est le successeur de z. Ligne 7, on nomme p le père de y (éventuellement null). Ligne 8, on détermine
x, fils de y, qui remplacera y comme nouveau fils de p. Ce x peut être null si y n’a pas de fils. Lignes 9 à 18, on détache
y, et on rattache x à son père (dans les deux sens). Il faut tenir compte du cas limite où y est la racine elle-même (ligne
12). Lignes 19 à 21, on traite le cas (c) : le nœud z est recyclé : il prend la clé et les informations de y.
J
Exercice 9.13 Vérifier que la suppression ci-dessus s’exécute en temps O(h), où h est la hauteur de l’arbre.
46 CHAPITRE 9. ARBRE BINAIRE DE RECHERCHE (ABR)

15 15

5 16 5 16

3 12 p 20 3 12 20
z
y
10 13 18 23 10 18 23

6 x = null 6

7
(a) 7

15 p 15
z
5 16 y 5

3 12 20 x 3 12 20

10 13 18 23 10 13 18 23

6 6

(b)
7 7

15 15 15
z
5 16 y 6 5 16 6 16

3 12 20 3 12 20 3 12 20

p 10 13 18 23 10 13 18 23 10 13 18 23

y
6 7 7

x 7 (c)

F IG . 9.3 – Suppressions dans un arbre binaire de recherche.

9.6 Avantages et inconvénients de l’ABR


Nous avons vu que toutes les opérations (sauf le parcours) ont un temps d’exécution T (n) ∈ O(h), où h est la hauteur de
l’ABR. Si l’ABR est complet (donc parfaitement équilibré), alors n = 2h+1 − 1, donc h = Θ(log n) et T (n) ∈ O(log n). Si
à l’inverse l’ABR est réduit à une chaîne linéaire (donc complètement déséquilibré), alors h = n − 1 et T (n) ∈ O(n). On
montre toutefois qu’un ABR obtenu par insertions successives de n clés aléatoires uniformément distribuées a une hauteur
moyenne h ∈ O(log n), ce qui est un résultat encourageant.
Comparons l’ABR avec la liste chaînée triée pour l’implémentation d’un dictionnaire avec ordre sur les clés2 . L’insertion
2 La table de hachage ne convient pas : on ne peut pas parcourir directement les enregistrements dans l’ordre des clés.
9.7. PROGRAMME JAVA 47

et la recherche se font en O(n) pour la liste. L’ABR est donc très avantageux s’il reste équilibré. Malheureusement, on ne
peut pas toujours garantir qu’un ABR restera équilibré et ne va pas dégénérer pendant sa vie. C’est la raison pour laquelle
on a mis au point des structures arborescentes qui restent équilibrées, et dont les principales sont l’arbre rouge et noir et
le B-arbre, qui font l’objet des deux chapitres suivants.
J
Exercice 9.14 Discuter des possibilités d’utilisation de l’arbre binaire de recherche pour implémenter la file de prio-
rité. Argumenter les avantages et les inconvénients.
J
Exercice 9.15 Discuter des possibilités d’utilisation de l’arbre binaire de recherche pour construire un algorithme de
tri. Analyser la complexité en temps et en espace de l’algorithme.
J
Exercice 9.16 Dans le programme des pages suivantes, que se passe-t-il si on ajoute, ligne 71, la suppression du nœud
de clé 6, sous la forme :
a.supprime(n6) ;
Est-ce une erreur ou non ? Quelles seraient les solutions pour éviter ce comportement ?
J
Exercice 9.17 Modifier la méthode de parcours de l’ABR afin qu’on puisse lui passer comme paramètre la fonction
de traitement à effectuer sur chaque nœud. (C’est plutôt un exercice de programmation Java que d’algorithmique.)

9.7 Programme Java


Le fichier TestABR.java
1 /*
2 * TestABR.java
3 *
4 * SupAéro -- Cours Structures de Données et Algorithmes
5 */
6

7 import java . io .*;


8 import java . util .*;
9

10

11 /**
12 * Définition et test de l’arbre binaire de recherche.
13 *
14 * @version 12 Septembre 2000
15 * @author Michel Lemaître
16 */
17 c l a s s TestABR {
18 s t a t i c PrintWriter sortie ;
19

20 p u b l i c s t a t i c void main ( String [] args ) throws IOException {


21 sortie = new PrintWriter (new FileWriter (" sortie_TestABR " ));
22

23 // arbre de la figure 9.3 (a)


24 Noeud n3 = new Noeud (" trois " );
25 Noeud n5 = new Noeud (" cinq " );
26 Noeud n6 = new Noeud (" six " );
27 Noeud n7 = new Noeud (" sept " );
28 Noeud n10 = new Noeud (" dix " );
29 Noeud n12 = new Noeud (" dze " );
30 Noeud n13 = new Noeud (" trz " );
31 Noeud n15 = new Noeud (" qz " );
32 Noeud n16 = new Noeud (" sz " );
33 Noeud n18 = new Noeud (" dh " );
34 Noeud n20 = new Noeud (" vgt " );
35 Noeud n23 = new Noeud (" vt " );
36

37 ArbreBR a = new ArbreBR ();


38

39 a. insère (15, n15 );


40 a. insère (5, n5 );
48 CHAPITRE 9. ARBRE BINAIRE DE RECHERCHE (ABR)

41 a. insère (3, n3 );
42 a. insère (12, n12 );
43 a. insère (13, n13 );
44 a. insère (10, n10 );
45 a. insère (6, n6 );
46 a. insère (7, n7 );
47 a. insère (16, n16 );
48 a. insère (20, n20 );
49 a. insère (18, n18 );
50 a. insère (23, n23 );
51

52 sortie . println (a. minimum (a. racine ));


53 sortie . println (a. minimum ( n6 ));
54

55 sortie . println (a. recherche (20, a. racine ));


56 sortie . println (a. recherche (25, a. racine ));
57

58 a. parcours_infixe ( sortie );
59

60 a. supprime ( n13 );
61 a. parcours_infixe ( sortie );
62

63 a. supprime ( n16 );
64 a. parcours_infixe ( sortie );
65

66 a. supprime ( n5 );
67 a. parcours_infixe ( sortie );
68

69 a. supprime ( n15 );
70 a. parcours_infixe ( sortie );
71

72 sortie . close ();


73 }
74 }
75

76

77 c l a s s Noeud {
78 i n t clé ;
79 Object info ;
80 Noeud gauche ;
81 Noeud droit ;
82 Noeud père ;
83

84 /** Constructeur.
85 * On met juste l’information dans le noeud,
86 * Le reste sera garni lors de l’insertion. */
87 Noeud ( Object o ) {
88 info = o;
89 }
90

91 /** Imprimeur */
92 p u b l i c String toString () {
93 r e t u r n ( clé + "=" + info + " " );
94 }
95 }
96

97

98 c l a s s ArbreBR {
99 Noeud racine ;
100

101 /** Constructeur : construit un arbre vide */


102 ArbreBR () {
103 racine = n u l l ;
104 }
9.7. PROGRAMME JAVA 49

105

106 /** Impression : parcours infixe */


107 void parcours_infixe ( PrintWriter s ) {
108 s. print ( "{ " );
109 parcours_infixe ( racine , s );
110 s. println ( "}" );
111 }
112

113 /** Parcours infixe, avec impression, du sous-arbre


114 * de racine le Noeud x */
115 void parcours_infixe ( Noeud x , PrintWriter s ) {
116 i f ( x != n u l l ) {
117 parcours_infixe (x. gauche , s );
118 s. print (x );
119 parcours_infixe (x. droit , s );
120 }
121 }
122

123 /** Recherche du Noeud de clé minimum


124 * dans le sous-arbre de racine le Noeud z */
125 Noeud minimum ( Noeud z ) {
126 Noeud x = z;
127

128 w h i l e ( x. gauche != n u l l ) {
129 x = x. gauche ;
130 }
131 r e t u r n x;
132 }
133

134 /** Recherche du Noeud de clé c donnée


135 * dans le sous-arbre de racine le Noeud z.
136 * Renvoie null si la clé n’est pas dans l’arbre. */
137 Noeud recherche ( i n t c , Noeud z ) {
138 Noeud x = z;
139

140 w h i l e (( x != n u l l ) && ( c != x. clé )) {


141 x = ( c < x. clé ) ? x. gauche : x. droit ;
142 }
143 r e t u r n x;
144 }
145

146 /** Recherche du successeur du Noeud z dans l’arbre.


147 * Renvoie null si ce Noeud possède la plus grande clé
148 * de l’arbre */
149 Noeud successeur ( Noeud z ) {
150 i f ( z. droit != n u l l ) {
151 r e t u r n minimum (z. droit );
152 } else {
153 Noeud y = z. père ;
154 Noeud x = z;
155

156 w h i l e (( y != n u l l ) && ( x == y. droit )) {


157 x = y;
158 y = y. père ;
159 }
160 r e t u r n y;
161 }
162 }
163

164 /** Insertion d’un nouveau Noeud z de clé c.


165 * Seul le champ info de z est supposé initialisé */
166 void insère ( i n t c , Noeud z ) {
167 Noeud y = n u l l ;
168 Noeud x = racine ;
50 CHAPITRE 9. ARBRE BINAIRE DE RECHERCHE (ABR)

169

170 z. clé = c;
171 z. droit = n u l l ;
172 z. gauche = n u l l ;
173 w h i l e ( x != n u l l ) {
174 y = x;
175 x = ( z. clé < x. clé ) ? x. gauche : x. droit ;
176 }
177 z. père = y;
178 i f ( y == n u l l ) {
179 racine = z;
180 } e l s e i f ( z. clé < y. clé ) {
181 y. gauche = z;
182 } else {
183 y. droit = z;
184 }
185 }
186

187 /** Suppression du Noeud z */


188 void supprime ( Noeud z ) {
189 Noeud x;
190 Noeud y;
191 Noeud p;
192

193 y = (( z. gauche == n u l l ) || ( z. droit == n u l l )) ? z : successeur (z );


194 x = ( y. gauche == n u l l ) ? y. droit : y. gauche ;
195 p = y. père ;
196 if ( x != n u l l ) {
197 x. père = p;
198 }
199 i f ( p == n u l l ) {
200 racine = x;
201 } e l s e i f ( y == p. gauche ) {
202 p. gauche = x;
203 } else {
204 p. droit = x;
205 }
206 i f ( y != z ) {
207 z. clé = y. clé ;
208 z. info = y. info ;
209 }
210 }
211 }

Le fichier sortie_TestABR
1 3=trois
2 6=six
3 20=vgt
4 null
5 { 3=trois 5=cinq 6=six 7=sept 10=dix 12=dze 13=trz 15=qz 16=sz 18=dh 20=vgt 23=vt }
6 { 3=trois 5=cinq 6=six 7=sept 10=dix 12=dze 15=qz 16=sz 18=dh 20=vgt 23=vt }
7 { 3=trois 5=cinq 6=six 7=sept 10=dix 12=dze 15=qz 18=dh 20=vgt 23=vt }
8 { 3=trois 6=six 7=sept 10=dix 12=dze 15=qz 18=dh 20=vgt 23=vt }
9 { 3=trois 6=six 7=sept 10=dix 12=dze 18=dh 20=vgt 23=vt }
Chapitre 10

Arbre rouge et noir (ARN)

L’arbre rouge et noir, en abrégé ARN, est une variante d’arbre binaire de recherche (ABR, vu au chapitre précédent),
avec équilibrage automatique. C’est donc un ABR avec en plus une information booléenne supplémentaire par nœud : la
couleur, rouge ou noir. En contrôlant cette information de couleur dans chaque nœud, on garantit qu’aucun chemin ne
peut être deux fois plus long que n’importe quel autre, de sorte que l’arbre reste équilibré. Même si l’équilibrage n’est pas
parfait, on montre que toutes les opérations de recherche, d’insertion et de suppression sont en O(log n).

Nous ne décrirons pas précisément dans ce cours les algorithmes de l’ARN. Nous donnerons seulement sa définition et
nous citerons sa propriété essentielle d’équilibrage. Puis nous décrirons seulement le principe des opérations d’insertion
et de suppression. Elles utilisent essentiellement l’opération de rotation qui permet de maintenir l’équilibrage de l’ARN.

10.1 Définition de l’ARN


La définition tient en cinq point :

1. C’est un ABR. Par commodité, on remplace les pointeurs null vers des sous-arbres vides par des feuilles sans clés
(voir la figure 10.1).
2. Chaque nœud est soit rouge, soit noir.
3. Chaque feuille (voir le point 1) est noire.
4. Si un nœud est rouge, ses deux fils doivent être noirs.
5. Tous les chemins issus d’un nœud x donné et aboutissant à une feuille, contiennent le même nombre de nœuds noirs.

26

17 41
30 47
14 21

10 16 19 23 28 38

7 12 15 20 35 39

F IG . 10.1 – Un arbre rouge et noir. Les nœuds rouges sont cerclés d’un trait fin, les noirs d’un trait épais. Les feuilles
(toujours noires et sans clés) sont représentées par un petit disque noir.

10.2 Propriétés de l’ARN


J
Exercice 10.1 Montrer que l’on ne peut pas avoir deux nœuds rouges successifs le long d’un chemin d’un ARN.

51
52 CHAPITRE 10. ARBRE ROUGE ET NOIR (ARN)

J
Exercice 10.2 Montrer qu’au moins la moitié des nœuds d’un chemin d’un ARN reliant la racine à une feuille, racine
non comprise, doivent être noirs.

Voici maintemant la propriété essentielle de l’ARN : un ARN comportant n nœuds internes (n est donc le nombre d’enre-
gistrements stockés dans la structure) a une hauteur au plus égale à 2 log2 (n + 1).
J
Exercice 10.3 Démontrer la propriété essentielle de l’ARN. Aide : on appelle hauteur noire du nœud x le nombre de
nœuds noirs présents dans un chemin quelconque issu de x vers une feuille, noté hn(x). Montrer d’abord que hn(x) est
bien définie. Montrer ensuite que le sous-arbre de racine x contient au moins 2hn(x) − 1 nœuds internes (par induction sur
la hauteur de x). Utiliser enfin le résultat de l’exercice 10.2 pour établir une relation entre la hauteur de l’arbre et n, le
nombre de nœuds internes de l’ARN.

On déduit immédiatement de la propriété essentielle de l’ARN que les opérations RECHERCHE, MINIMUM, MAXIMUM,
PRÉDÉCESSEUR et SUCCESSEUR , utilisant les mêmes algorithmes que ceux de l’ABR, sont toutes en O(log n). Par contre,
les opérations INSÈRE et SUPPRIME ne peuvent pas être implémentées comme pour l’ABR puisque ce sont des opérations
qui modifient la structure de l’arbre. Il faut des algorithmes spécifiques pour ces opérations, qui préservent la définition
de l’ARN, ce que n’assurent pas les algorithmes correspondants de l’ABR. Ces nouveaux algorithmes sont basés sur la
notion de rotation.

10.3 Rotations
Une rotation est une opération locale visant à un rééquilibrage de l’arbre, tout en préservant l’ordre sur les clés. Elle
s’effectue simplement par des modifications de pointeurs (voir la figure 10.2).

y rotation droite x

x c a y
rotation gauche
a b b c

F IG . 10.2 – Rotations dans un arbre binaire de recherche. Les rotations ne changent pas la propriété fondamentale sur
l’ordre des clés : on a toujours a ≤ x ≤ b ≤ y ≤ c avant et après rotation, où a, b et c représentent n’importe quelle clé des
sous-arbres correspondants.

L’opération de rotation est réalisable en temps O(1).


La figure 10.3 montre comment une rotation permet de rééquilibrer un arbre.

7 7

4 11 4 18

3 6 9 18 3 6 11 19

2 14 19 2 9 14 22
(a)
12 17 22 12 17 20
(b)
20

F IG . 10.3 – Rééquilibrage d’un arbre par une rotation. Une rotation gauche sur le nœud de clé 11 de l’arbre (a) conduit à
un arbre (b) mieux équilibré : la hauteur de l’arbre est passée de 5 à 4.

10.4 Insertion et suppression dans un ARN


Pour l’insertion, on commence par insérer le nouveau nœud comme dans un ABR. On le peint en rouge. Si le point 4 de
la définition de l’ARN n’est plus vérifié, une succession de rotations et de coups de peinture judicieux, effectués de bas en
10.5. EXERCICES 53

haut, restaurent l’ARN. Comme ces réajustements n’affectent qu’un chemin de l’arbre, l’insertion est en temps O(log n),
comme souhaité.
La suppression est nettement plus complexe. Elle est aussi en O(log n).

10.5 Exercices
J
Exercice 10.4 Supposons que la racine d’un ARN soit rouge. Si on change cette couleur en noir, l’arbre reste-t-il un
ARN ?
J
Exercice 10.5 Montrer qu’un ABR quelconque de n nœuds peut être transformé en n’importe quel autre ABR de
mêmes clés en O(n) rotations.
Chapitre 11

B-Arbre

Le B-arbre est un arbre de recherche équilibré. Il généralise l’arbre binaire de recherche (ABR) en ce sens qu’il possède
un facteur de branchement (nombre de fils possibles d’un nœud) qui peut être très grand : en pratique plusieurs milliers.
C’est une structure de donnée destinée à implémenter en mémoire secondaire un dictionnaire avec ordre sur les clés. Par
mémoire secondaire, nous entendons un support tel qu’un disque dur, différent de la mémoire centrale de l’ordinateur.
Chaque nœud du B-arbre est contenu typiquement dans une page de disque, c’est-à-dire une quantité d’information qui
transite en une seule opération d’écriture ou de lecture entre la mémoire secondaire et la mémoire principale. C’est une
structure de donnée destinée à la gestion efficace d’énormes masses de données. La figure 11.1 donne une exemple de
B-arbre.

D H Q T X

BC FG JKL NP RS VW YZ

F IG . 11.1 – Un B-arbre. On n’a représenté que les clés. Dans ce B-arbre les clés sont des lettres.

11.1 Définition
La définition tient en 5 points.

1. Le B-arbre est une arborescence1 .


2. Chaque nœud du B-arbre contient les informations suivantes :
– clé : un tableau de clés ; les clés présentes sont triées par ordre croissant.
– enr : un tableau d’enregistrements associés à ces clés.
– n : le nombre de clés (et donc d’enregistrement) stockées dans le nœud.
– f : un booléen valant Vrai si le nœud est une feuille, Faux sinon.
– si le nœud est un nœud interne, il contient un tableau p de n+1 pointeurs p[i], pour i de 1 à n+1.
3. Chaque pointeur p[i] pointe vers un sous-arbre dont toutes les clés k sont telles que
clé[i-1] ≤ k ≤ clé[i], pour i de 2 à n,
k ≤ clé[1], pour i = 1,
clé[n] ≤ k, pour i = n+1.
4. Tous les chemins de la racine aux feuilles doivent avoir une longueur identique h (hauteur de l’arbre).
5. Il existe un nombre t ≥ 2 caractéristique du B-arbre, nommé degré minimum du B-arbre. Il fixe le nombre
minimum et maximum de clés par nœud : tout nœud autre que la racine contient un nombre de clés n tel que
t − 1 ≤ n ≤ 2t − 1. Si n = 2t − 1, le nœud est dit complet.
1 C’est-à-dire un arbre avec une racine (rappel).

54
11.2. PROPRIÉTÉ ESSENTIELLE DU B-ARBRE 55

Le point 3 de la définition implique que l’on admet des clés non uniques. Cette définition large permet de traiter l’implé-
mentation de variantes du dictionnaire dans lesquelles ce serait le cas.
Le point 5 implique que tout nœud interne possède au moins t fils, et au plus 2t fils.
Le B-arbre le plus simple est tel que t = 2. Tout nœud interne possède alors 2, 3 ou 4 fils.
J
Exercice 11.1 Combien d’enregistrements contient un B-arbre de hauteur 2 et stockant 1000 enregistrements par
nœud ? Combien faut-il d’accès disque pour accéder à n’importe quel enregistrement de ce B-arbre ?
Pour analyser les performances des opérations sur B-arbre, on ne compte plus le temps en nombre d’instructions machine,
mais en nombre d’accès disque (c’est à dire le nombre d’accès à un nœud) car le temps d’accès disque devient largement
prépondérant devant le temps CPU. Le nœud racine réside généralement en mémoire centrale et n’a pas besoin d’être relu
sans cesse.

11.2 Propriété essentielle du B-arbre


Un B-arbre de degré minimum t contenant n enregistrements possède une hauteur h au plus égale à logt n+1 2 . En consé-
quence, h ∈ O(log n).
Noter que dans l’expression précédente, la base t du logarithme est très grande en pratique, et donc h est toujours petite.
J
Exercice 11.2 Démontrez la propriété précédente. Aide : trouver le nombre minimum d’enregistrements que stocke un
B-arbre de hauteur h.
On devine que les opérations de recherche, insertion et suppression dans un B-arbre pourront s’effectuer par le parcours
d’un chemin de l’arbre, donc en un nombre d’accès disque ∈ O(h) = O(log n).

11.3 Recherche dans un B-arbre


La recherche dans un B-arbre est une généralisation de la recherche dans un arbre binaire de recherche (voir page 9.3).
Noter que l’on doit lire le nœud cherché en mémoire secondaire, puis faire une recherche (qui peut être dichotomique)
dans le nœud lui-même pour accéder à l’enregistrement recherché.
J
Exercice 11.3 Écrire l’algorithme de recherche d’un enregistrement de clé donnée dans un B-arbre. Démontrer que
le nombre d’accès disque est O(h) = O(log n), où n est le nombre d’enregistrements stockés dans le B-arbre.

11.4 Insertion dans un B-arbre


L’insertion dans un B-arbre est plus complexe que l’insertion dans un ABR. Elle utilise l’opération auxiliaire de décou-
page d’un nœud complet, que nous allons d’abord décrire.
On rappelle qu’un nœud complet contient 2t − 1 clés. Ce nombre étant impair, le nœud possède donc une clé médiane.
Le découpage consiste à éclater le nœud complet en deux nœuds, autour de cette clé médiane. La clé médiane (avec son
enregistrement) remonte dans le nœud père, qui ne doit pas être complet avant le découpage. Chacun des deux nouveaux
nœuds contient donc exactement t − 1 clés, le minimum requis. Si le nœud complet découpé était la racine (et n’a donc
pas de père) une nouvelle racine va être créée pour recueillir la clé médiane, et la hauteur va augmenter de 1. C’est ainsi
que croît le B-arbre : par la racine.
La figure 11.2 illustre l’opération de découpage d’un nœud complet différent de la racine. La figure 11.3 illustre le cas du
découpage de la racine.
La procédure d’insertion proprement dite fonctionne selon les règles suivantes :
1. On insère toujours dans une feuille.
2. Pour trouver cette feuille, on descend dans l’arbre comme pour la recherche.
3. Si au cours de la descente, on tombe sur un nœud complet, on le découpe avant de poursuivre la descente.
J
Exercice 11.4 Montrer que ces règles assurent que lors d’un découpage, la clé médiane ne remonte jamais dans un
nœud complet.
La figure 11.4 montre une succesion d’insertions dans le même B-arbre.
On montre que l’insertion dans un B-arbre nécessite O(h) = O(log n) accès à des nœuds.
56 CHAPITRE 11. B-ARBRE

.... N W .... .... N S W ....

P Q R S T U V P Q R T U V
1 2 3 4 5 6 7 8 1 2 3 4 5 6 7 8
avant après

F IG . 11.2 – B-arbre : découpage d’un nœud complet. Ce B-arbre possède un degré minimum t = 4. On peut donc
entreposer de 3 à 7 clés par nœud. La clé médiane S remonte dans l’arbre.

A D F H L N P A D F L N P
1 2 3 4 5 6 7 8 1 2 3 4 5 6 7 8
avant après

F IG . 11.3 – Découpage de la racine complète d’un B-arbre (t = 4). Une nouvelle racine est créée. La hauteur de l’arbre
augmente de 1.

11.5 Suppression dans un B-arbre


C’est une opération relativement complexe, que nous ne détaillerons pas ici. On peut deviner cependant que la plupart du
temps elle nécessite uniquement la lecture des nœuds d’un chemin jusqu’à une feuille, parce que la plupart des clés se
trouvent dans les feuilles.
La suppression dans un B-arbre s’effectue également en O(h) = O(log n) accès à des nœuds.
11.5. SUPPRESSION DANS UN B-ARBRE 57

(a) G M P X

A C D E J K N O R S T U V Y Z

(b) G M P X

A B C D E J K N O R S T U V Y Z

(c) G M P T X

A B C D E J K N O Q R S U V Y Z

P
(d)

G M T X

A B C D E J K L N O Q R S U V Y Z

(e) P

C G M T X

A B D E F J K L N O Q R S U V Y Z

F IG . 11.4 – Insertions successives dans un B-arbre. La flèche indique la nouvelle clé insérée. Ce B-arbre possède un degré
minimum t = 3. On peut donc entreposer de 2 à 5 clés par nœud.
Chapitre 12

Tas

Le tas est une bonne structure pour l’implémentation de la file de priorité (définie page 27), c’est-à-dire une collection de
liaisons clé-enregistrement. On n’exige pas que les clés soient uniques. On ne pourra pas, comme dans un dictionnaire,
retrouver n’importe quel enregistrement par sa clé, mais on aura seulement accès à un enregistrement de plus grande clé.
De même pour la suppression d’un enregistrement : seul un enregistrement de clé maximale pourra être supprimé. On
parle alors d’opération d’extraction. Par contre, il n’y a pas de restrictions sur l’insertion.
L’accès à un enregistrement de clé maximale est en temps constant, tandis que les opérations d’insertion et d’extraction
se font en O(log n), où n est la taille du tas.
La structure de tas est supportée par un tableau. Les enregistrements1 sont placés dans le tableau, à des indices déterminés
en fonction de la valeur des clés. Les relations entre ces indices sont telles que le tas est implicitement un arbre enraciné.
Dans la suite de ce chapitre, pour la commodité de l’exposé, nous allons faire comme si nos enregistrements étaient réduits
à leur clés, et ces clés seront des entiers. Cela va simplifier les dessins et les algorithmes, sans rien changer aux principes.
Si on doit implémenter un vrai tas, il convient de restaurer la gestion des liaisons clé-enregistrement, ce qui est très simple.

12.1 Définition du tas


1
16
2 3
1 2 3 4 5 6 7 8 9 10
14 10
16 14 10 8 7 9 3 2 4 1
4 5 6 7
8 7 9 3
taille = 10
8 9 10
longueur
2 4 1

F IG . 12.1 – Un tas. À gauche, la vue du tas sous forme d’arborescence, à droite le tableau qui le supporte.

Les tas est un arbre binaire tabulé, presque complet, et respectant une contrainte d’ordre particulière. Détaillons
chacun de ces points.
– arbre binaire tabulé : le tas est supporté par un tableau t. Chaque indice2 du tableau correspond à l’indice d’un nœud
dans un arbre binaire. Les liens père-fils possibles de cette arborescence sont implicitement représentés par les trois
fonctions suivantes, où i est un indice dans le tableau. père(i) donne l’indice du père de i, gauche(i) et droit(i)
donnent les indices des fils gauche et droit de i :
– père(i) = i / 2 (division entière)
– gauche(i) = 2 i
– droit(i) = 2 i + 1
Voir la figure 12.1. Par exemple, le père du nœud d’indice 5 est le nœud d’indice 2, le fils gauche du nœud d’indice 3
est le nœud d’indice 6, le fils droit du nœud d’indice 3 est le nœud d’indice 7. Par construction du système d’indices, la
1 Plus exactement des pointeurs vers les enregistrements.
2 Attention, mes indices commencent à 1 !

58
12.2. RESTAURATION DE LA CONTRAINTE D’ORDRE 59

racine de l’arborescence est toujours à l’indice 1.


– arborescence binaire presque complète : tous les niveaux de l’arborescence sont complets, sauf éventuellement le der-
nier niveau, tassé à gauche (voir la figure 12.1).
– contrainte d’ordre : les clés, qui sont rangées dans le tableau t, sont telles que t[père(i)] ≥ t[i] pour tout indice
i du tableau sauf pour la racine (i = 1). Cette contrainte peut s’exprimer de manière équivalente par chacune des trois
propositions suivantes :
– la clé d’un père est toujours supérieure ou égale à celle de ses deux fils.
– la clé d’un nœud est toujours supérieure ou égale à toutes celles de ses descendants.
– les clés sont décroissantes (au sens large) le long de tout chemin de la racine vers une feuille.
On ne confondra pas la taille du tas, c’est-à-dire le nombre d’enregistrements stockés dans le tas, avec la longueur du
tableau t qui lui sert de support. La taille du tas varie dynamiquement pendant la vie du tas, au rythme des insertions et
des extractions, tandis que la longueur du tableau est fixe et doit être prévue à la création du tas.

Exercice 12.1 Montrer que la hauteur d’un tas est Θ(log n) où n est la taille du tas.
J

12.2 Restauration de la contrainte d’ordre


1 1
16 16
2 3 2 3
4 10 14 10
4 5 6 7 4 5 6 7
14 7 9 3 4 7 9 3
8 9 10 8 9 10
2 8 1 2 8 1
(a) (b)
1
16
2 3
14 10
4 5 6 7
8 7 9 3
8 9 10
2 4 1
(c)

F IG . 12.2 – Effets de la procédure entasser(2), qui restaure la contrainte d’ordre à partir du nœud d’indice 2. (a) : un
tas dont le nœud d’indice 2 et de clé 4 marqué d’une flèche, enfreint la contrainte d’ordre avec ses deux fils. La procédure
entasser(2), appelée sur ce nœud, va en échanger la clé avec le nœud d’indice 4. (b) : ce nœud, marqué d’un flèche,
enfreint également la contrainte avec son fils droit ; il est donc échangé avec le nœud d’indice 9. (c) : l’entassement s’est
propagé jusqu’à une feuille, la contrainte d’ordre est complètement restaurée.

La procédure entasser(i) ci-dessous a un rôle central dans la manipulation des tas. Elle sert à restaurer la contrainte
d’ordre dans la sous-arborescence enracinée en i, lorsque t[i] est inférieur à l’un de ses fils. On l’emploie sous les
hypothèses suivantes : les sous-arborescences enracinées en gauche(i) et droit(i) respectent la contrainte d’ordre. Par
exemple, dans la figure 12.2, le nœud d’indice 2 et de clé 4 enfreint la contrainte d’ordre avec son fils gauche, mais les
sous-arborescences enracinées en ses deux fils (indices 4 et 5) respectent la contrainte.
1 void entasser ( i n t i ) {
2 i n t max = i;
3 i n t g = gauche (i );
4 i n t d = droit (i );
5

6 i f (( g <= taille ) && ( t[g ] > t[i ])) max = g;


7 i f (( d <= taille ) && ( t[d ] > t[ max ])) max = d;
60 CHAPITRE 12. TAS

8 // ici t[max] est le max de t[i], t[g] et t[d]


9 i f ( max != i ) {
10 // on échange t[i] et t[max]
11 i n t temp = t[i ];
12 t[i ] = t[ max ];
13 t[ max ] = temp ;
14 entasser ( max ); // appel récursif
15 }
16 }
La procédure est récursive. L’idée est de pousser vers les feuilles la clé qui enfreint la contrainte envers l’un de ses fils. Le
test g <= taille détermine si i a vraiment un fils gauche.
J
Exercice 12.2 Montrer que pour un tas de taille n, la procédure entasser a un temps de calcul en O(log n).

12.3 Construction d’un tas à partir d’un tableau quelconque

1
4
2 3
1 2 3 4 5 6 7 8 9 10
1 3
4 1 3 2 16 9 10 14 8 7
4 5 6 7
2 16 9 10
8 9 10
14 8 7

F IG . 12.3 – Début de la construction d’un tas. La boucle d’appels à entasser va commencer au nœud d’indice 5, marqué
d’une flèche.

La procédure construire ci-dessous restaure complètement la contrainte d’ordre du tas dans le tableau t contenant des
clés quelconques, sans faire d’hypothèse préalable.
1 void construire () {
2 taille = longueur ;
3 f o r ( i n t i = taille / 2; i >= 1; i -= 1) {
4 entasser (i );
5 }
6 }
L’idée est simplement d’appeler successivement la procédure entasser sur chacun des nœuds, en commençant par les
indices les plus élévés. De cette façon les hypothèses de fonctionnement pour entasser sont vérifiées à chaque appel.
On peut améliorer cette idée en remarquant que les feuilles n’ont pas besoin d’être entassées. La boucle for peut donc
commencer à taille / 2.
J
Exercice 12.3 Montrer que les nœuds d’indices taille/2 + 1 (division entière) à taille d’un tas sont des feuilles.
J
Exercice 12.4 Montrer que construire est en temps O(n log n), où n est la taille du tas.
Plus difficile : montrer que construire est en temps O(n).

12.4 Maximum et extraction du maximum


La clé maximum est bien sûr dans la racine :
1 i n t maximum () {
2 r e t u r n t [1] ;
3 }
L’extraction du maximum (procédure ci-dessous) est très ingénieuse : après avoir conservé la clé maximum au chaud pour
la renvoyer à la fin, on met à sa place (dans la racine donc) le dernier élément du tableau, puis on décrémente la taille du
12.5. INSERTION DANS UN TAS 61

tas, et on entasse la racine. Ainsi, on restaure les propriétés du tas, et on est sûr que les deux sous-arborescences de la
racine vérifient l’hypothèse de l’appel à entasser ! Malin, non ?
1 i n t extraire_max () {
2 i f ( taille < 1) { // extraction d’un tas vide :
3 r e t u r n Integer . MIN_VALUE ; // renvoie une valeur conventionnelle
4 } else {
5 i n t max = t [1];
6 t [1] = t[ taille ];
7 taille -= 1;
8 entasser (1);
9 r e t u r n max ;
10 }
11 }
J
Exercice 12.5 Montrer que extraire_max est en temps O(log n), où n est la taille du tas.

12.5 Insertion dans un tas


La procédure insérer (ci-dessous) insère une nouvelle clé dans le tas. Elle renvoie true si tout s’est bien passé, false
si le tableau était plein, rendant l’insertion impossible.
1 boolean insérer ( i n t clé ) {
2 taille += 1;
3 i f ( taille > longueur ) { // impossible, le tableau est plein !
4 return f a l s e ;
5 } else {
6 i n t i = taille ;
7 w h i l e (( i > 1) && ( t[ pere (i )] < clé )) {
8 t[i ] = t[ pere (i )];
9 i = pere (i );
10 }
11 t[i ] = clé ;
12 return true ;
13 }
14 }
L’idée est d’insérer par le bas dans une nouvelle feuille, puis de rétablir la contrainte d’ordre par échanges successifs en
remontant dans l’arbre, éventuellement jusqu’à la racine.
J
Exercice 12.6 Montrer que insérer est en temps O(log n), où n est la taille du tas.
J
Exercice 12.7 Montrer que la structure de tas implémente directement la structure de file de priorité (voir définition
page 27). Comparer les performances avec l’implémentation par une liste chaînée (exercice 6.6, page 33).

12.6 Le tri du tas


Le tas peut servir à trier un tableau (ici dans l’ordre croissant). La procédure (ci-dessous), est remarquablement simple,
une fois connues les procédures construire et entasser. L’idée générale est d’abord de transformer le tableau à trier
en tas (respect de la contrainte d’ordre), puis d’extraire les maximums successifs. On arrange tout ça pour trier sur place.
1 void tri () {
2 construire ();
3 f o r ( i n t i = longueur ; i >= 2; i -= 1) {
4 // échange t[1] et t[i]
5 i n t temp = t [1];
6 t [1] = t[i ];
7 t[i ] = temp ;
8 taille -= 1;
9 entasser (1);
10 }
62 CHAPITRE 12. TAS

11 }
Le tableau à trier (on suppose ici que c’est justement le tableau t support du tas, complètement rempli) est soumis à la
procédure construire, ce qui établit la contrainte d’ordre. t[1] étant le maximum, il doit prendre la dernière place. On
l’y met donc, par échange avec t[longueur]. On diminue la taille du tas de 1, ce qui «exclut» du tas la clé maximale
que l’on vient de bien placer, puis on rétablit la contrainte d’ordre à partir de la racine, de façon analogue à la procédure
extraire_max. Et ainsi de suite.
J
Exercice 12.8 Montrer que le tri du tas est en temps O(n log n), où n est la longueur du tableau à trier.

Exercice 12.9 Montrer que le tri du tas est en temps Ω(n log n), où n est la longueur du tableau à trier.
J

12.7 Programme Java


Le fichier TestTas.java
1 import java . io .*;
2 // import java.util.*;
3

4 /*
5 * TestTas.java
6 *
7 * SupAéro -- Cours Structures de Données et Algorithmes
8 */
9

10 import java . io .*;


11

12 /**
13 * Structure de tas et tri du tas
14 *
15 * @version 21 Août 2000
16 * @author Michel Lemaître
17 */
18 c l a s s TestTas {
19 s t a t i c PrintWriter sortie ;
20

21 p u b l i c s t a t i c void main ( String [] args ) throws Exception {


22 sortie = new PrintWriter (new FileWriter (" sortie_TestTas " ));
23

24 //===== test de tas


25

26 Tas tas1 = new Tas (20);


27 i n t m;
28

29 tas1 . inserer (8); sortie . println (" insertion : " + 8);


30 tas1 . inserer (3); sortie . println (" insertion : " + 3);
31 tas1 . inserer (16); sortie . println (" insertion : " + 16);
32 tas1 . imprime ( sortie );
33 m = tas1 . extraire_max (); sortie . println (" extraction : " + m );
34 tas1 . imprime ( sortie );
35 tas1 . inserer (5); sortie . println (" insertion : " + 5);
36 tas1 . inserer (8); sortie . println (" insertion : " + 8);
37 tas1 . inserer (9); sortie . println (" insertion : " + 9);
38 tas1 . imprime ( sortie );
39 f o r ( i n t i = 1; i <= 6; i += 1) {
40 m = tas1 . extraire_max (); sortie . println (" extraction max : " + m );
41 tas1 . imprime ( sortie );
42 }
43

44 //===== test du tri du tas


45

46 i n t n = 20;
47 i n t [] tab = new i n t [n + 1]; // mes tableaux commencent à 1
12.7. PROGRAMME JAVA 63

48

49 // remplit t par des nombres quelconques entre 1 et 10


50 remplir ( tab , n , 10);
51 imprime ( tab );
52

53 Tas tas = new Tas ( tab );


54 tas . tri ();
55 imprime ( tab );
56

57 verif ( tab );
58 sortie . close ();
59 }
60

61 s t a t i c void remplir ( i n t [] t , i n t n , i n t k ) {
62 f o r ( i n t i = 1; i <= n ; i += 1) {
63 t[i ] = aleatoire (k );
64 }
65 }
66

67 /** retourne un entier aléatoire dans [ 1 ... m ] */


68 s t a t i c i n t aleatoire ( i n t m ) {
69 r e t u r n (( i n t ) ( Math . random () * m + 1));
70 }
71

72 s t a t i c void imprime ( i n t [] t ) {
73 sortie . println ();
74 f o r ( i n t i = 1; i <= t. length - 1; i += 1) {
75 sortie . println ("t [" + i + "] = " + t[i ]);
76 }
77 sortie . println ();
78 }
79

80 s t a t i c void verif ( i n t [] t ) {
81 f o r ( i n t i = 1; i <= t. length - 2; i += 1) {
82 i f ( t[i ] > t[i +1]) {
83 sortie . println (" Erreur quelquepart " );
84 System . exit (1);
85 }
86 }
87 }
88 }
89

90

91 c l a s s Tas {
92 i n t [] t ; // le tableau support du tas
93 i n t longueur ; // la longueur du tableau support
94 i n t taille ; // la taille du tas
95

96 /** Contructeur standard : rend un tas vide. */


97 Tas ( i n t l ) {
98 longueur = l;
99 t = new i n t [ longueur +1]; // mes tableaux commencent à 1
100 taille = 0;
101 }
102

103 /** Ce constructeur sert uniquement pour le tri du tas :


104 * on n’alloue pas de nouveau tableau, mais on utilise
105 * celui que l’on doit trier. */
106 Tas ( i n t [] tab ) {
107 longueur = tab . length - 1; // mes tableaux commencent à 1
108 taille = 0;
109 t = tab ;
110 }
111
64 CHAPITRE 12. TAS

112 s t a t i c i n t pere ( i n t i ) {
113 r e t u r n i / 2;
114 }
115

116 s t a t i c i n t gauche ( i n t i ) {
117 r e t u r n 2 * i;
118 }
119

120 s t a t i c i n t droit ( i n t i ) {
121 r e t u r n 2 * i + 1;
122 }
123

124 void entasser ( i n t i ) {


125 i n t max = i;
126 i n t g = gauche (i );
127 i n t d = droit (i );
128

129 if (( g <= taille ) && ( t[g ] > t[i ])) max = g;


130 if (( d <= taille ) && ( t[d ] > t[ max ])) max = d;
131 // ici t[max] est le max de t[i], t[g], t[d]
132 if ( max != i ) {
133 // on échange t[i] et t[max] puis appel récursif
134 i n t temp = t[i ];
135 t[i ] = t[ max ];
136 t[ max ] = temp ;
137 entasser ( max );
138 }
139 }
140

141 // l’interface de la file de priorité :


142 // maximum, extraire_max, inserer
143

144 i n t maximum () {
145 r e t u r n t [1];
146 }
147

148 i n t extraire_max () {
149 i f ( taille < 1) { // extraction d’un tas vide :
150 r e t u r n Integer . MIN_VALUE ; // valeur conventionnelle
151 } else {
152 i n t max = t [1];
153 t [1] = t[ taille ];
154 taille -= 1;
155 entasser (1);
156 r e t u r n max ;
157 }
158 }
159

160 boolean inserer ( i n t clé ) {


161 taille += 1;
162 i f ( taille > longueur ) { // impossible, le tableau est plein !
163 return f a l s e ;
164 } else {
165 i n t i = taille ;
166 w h i l e ( i > 1 && t[ pere (i )] < clé ) {
167 t[i ] = t[ pere (i )];
168 i = pere (i );
169 }
170 t[i ] = clé ;
171 return true ;
172 }
173 }
174

175 void tri () {


12.7. PROGRAMME JAVA 65

176 construire ();


177 f o r ( i n t i = longueur ; i >= 2; i -= 1) {
178 // échange t[1] et t[i]
179 i n t temp = t [1];
180 t [1] = t[i ];
181 t[i ] = temp ;
182 taille -= 1;
183 entasser (1);
184 }
185 }
186

187 /** Transforme t[1..longueur] en tas. */


188 void construire () {
189 taille = longueur ;
190 f o r ( i n t i = taille / 2; i >= 1; i -= 1) {
191 entasser (i );
192 }
193 }
194

195 void imprime ( PrintWriter s ) {


196 i f ( taille == 0) {
197 s. print (" le tas est vide ." );
198 } else {
199 s. print (" le tas contient :" );
200 f o r ( i n t i = 1; i <= taille ; i += 1) {
201 s. print (" " + t[i ]);
202 }
203 }
204 s. println ();
205 }
206 }

Le fichier sortie_TestTas
1 insertion : 8
2 insertion : 3
3 insertion : 16
4 le tas contient : 16 3 8
5 extraction : 16
6 le tas contient : 8 3
7 insertion : 5
8 insertion : 8
9 insertion : 9
10 le tas contient : 9 8 5 3 8
11 extraction max : 9
12 le tas contient : 8 8 5 3
13 extraction max : 8
14 le tas contient : 8 3 5
15 extraction max : 8
16 le tas contient : 5 3
17 extraction max : 5
18 le tas contient : 3
19 extraction max : 3
20 le tas est vide.
21 extraction max : -2147483648
22 le tas est vide.
23
24 t[1] = 8
25 t[2] = 10
26 t[3] = 10
27 t[4] = 1
28 t[5] = 9
66 CHAPITRE 12. TAS

29 t[6] = 10
30 t[7] = 1
31 t[8] = 7
32 t[9] = 7
33 t[10] = 3
34 t[11] = 7
35 t[12] = 9
36 t[13] = 8
37 t[14] = 5
38 t[15] = 6
39 t[16] = 1
40 t[17] = 8
41 t[18] = 1
42 t[19] = 10
43 t[20] = 5
44
45
46 t[1] = 1
47 t[2] = 1
48 t[3] = 1
49 t[4] = 1
50 t[5] = 3
51 t[6] = 5
52 t[7] = 5
53 t[8] = 6
54 t[9] = 7
55 t[10] = 7
56 t[11] = 7
57 t[12] = 8
58 t[13] = 8
59 t[14] = 8
60 t[15] = 9
61 t[16] = 9
62 t[17] = 10
63 t[18] = 10
64 t[19] = 10
65 t[20] = 10
Chapitre 13

Tris

13.1 Le problème
Dans toute sa généralité, le problème du tri se pose ainsi :
– données : un sous-ensemble de n objets a1 , a2 , . . . , an , tirés d’un ensemble muni d’une relation d’ordre total notée  ;
paramètres : le sous-ensemble à trier.
– solution cherchée : trouver une permutation σ : {1, . . . , n} → {1, . . . , n} telle que ∀i, j : aσ(i)  aσ( j) .
Nous donnerons plusieurs algorithmes de tri. Nous étudierons et comparerons leurs performances, dans le cas particulier
suivant :
– le sous-ensemble à trier est rangé dans un tableau,
– les éléments du tableau sont des nombres entiers (on trie des nombres entiers).
Le sous-ensemble à trier pourrait être rangé dans une autre structure, une liste chaînée par exemple. Cependant, on préfère
en général trier des objets rangés au préalable dans un tableau, pour profiter de l’accès direct aux éléments, et obtenir ainsi
de bonnes performances.
Quand au choix de trier des entiers, il s’agit ici d’une facilité pour l’écriture des algorithmes. Le tri de nombres n’est pas
spécialement utile en soi. Il est bien plus utile de trier des enregistrements, en comparant leurs clés. Toutefois, que ce
soient des entiers ou des enregistrements, le principe de l’algorithme demeure inchangé (voir l’exercice 13.10).

On dira qu’un algorithme trie sur place lorsqu’il ne consomme pas de mémoire supplémentaire, sauf une place mémoire
de taille constante (ne dépendant pas de la taille du tableau à trier).
Dans tout ce chapitre, le paramètre n dénotera le nombre d’entiers à trier.

13.2 Borne inférieure de complexité en temps


des tris par comparaison
Nous allons établir une borne inférieure de complexité en temps du tri par comparaisons de n éléments. Le vocable «tri
par comparaisons» signifie que seules des comparaisons entre éléments sont effectuées. Aucune hypothèse n’est faite sur
le domaine des éléments à trier.
La première borne est évidemment Ω(n), puisqu’il faut au moins examiner chacun des éléments.
Essayons d’améliorer cette borne inférieure, en comptant le nombre minimal de comparaisons qui doivent nécessairement
avoir lieu. Nous supposerons que les éléments à trier sont tous distincts, ce qui ne fausse pas le raisonnement puisqu’on
cherche une borne inférieure.
On peut voir un algorithme de tri comme un processus qui acquiert de l’information sur les éléments à trier, au fur et
à mesure qu’il effectue des comparaisons. Peu importe la valeur précise des éléments à trier, ce qui compte c’est leur
ordre relatif de départ. Les n éléments à trier peuvent se présenter selon n! permutations distinctes. Tout algorithme de tri
par comparaisons doit engranger suffisamment d’information pour finalement déterminer quelle permutation précise se
présente, parmi les n! possibles. Cela est montré sur la figure 13.1, qui représente tous les ordonnancements possibles de
trois éléments quelconques a, b, c, sous forme d’un arbre de décision. Chaque chemin dans l’arbre, de la racine jusqu’à
une feuille, représente le comportement d’un algorithme de tri face à une permutation possible d’entrée. Au départ,
l’algorithme ne «sait» rien sur les éléments, ce qui est représenté sur la figure par le rectangle du haut, contenant les 6

67
68 CHAPITRE 13. TRIS

a<b<c
a<c<b
b<a<c
b<c<a
c<a<b
c<b<a
a<b
OUI NON

a<b<c b<a<c
a<c<b b<c<a
c<a<b c<b<a
a<c b<c
OUI NON OUI NON

a<b<c c<a<b b<a<c c<b<a


a<c<b b<c<a
b<c a<c
OUI NON OUI NON
a<b<c a<c<b b<a<c b<c<a

F IG . 13.1 – Un arbre de décision.

permutations possibles. Après la première comparaison a < b (test qui apparait dans l’ovale), il acquiert une information
nouvelle, qui lui permet d’écarter 3 permutations. À chaque nouvelle comparaison, il écarte au mieux la moitié de celles
qui restent en lisse. Et ainsi de suite jusqu’à «reconnaître» la permutation d’entrée.
Avec c comparaisons successives, on peut distinguer au plus 2c situations (feuilles de l’arbre de décision). Or nous avons
n! situations à distinguer. Il faut donc que c soit tel que n! ≤ 2c .
Pour résoudre cette équation en c, nous remarquons que n! est le produit d’au moins n/2 facteurs valant chacun au moins
n/2. Il s’en suit donc :
(n/2)(n/2) ≤ n! ≤ 2c
en passant aux logarithmes :
(n/2) log2 (n/2) ≤ c
soit
(n/2) log2 n − n/2 ≤ c
c’est-à-dire c ∈ Ω(n log n).
En résumé, nous avons établi que tout algorithme de tri qui n’utilise que des comparaisons entre éléments pour trier n
éléments nécessite Ω(n log n) comparaisons.

13.3 Tris simples


On appelle ainsi le tri de la bulle, le tri par insertion, et le tri par sélection. Ils ont pour avantage d’être faciles à programmer
(en gros, deux boucles imbriquées).
Leur complexité en temps est O(n2 ). Le plus mauvais est le tri de la bulle. Les deux autres vont très bien lorsque n est
petit (inférieur à 1000). Ce sont des tris sur place.
Les algorithmes de ces tris simples sont donnés en Java dans le programme qui commence en page 72. L’analyse du tri
par insertion est en page 16.

13.4 Tri par tas (heapsort )


Le tri par tas a été décrit page 61. Sa complexité en temps est Θ(n log n). Il trie sur place. C’est donc un très bon tri :
optimal en temps, et optimal en espace. Cependant, une bonne implémentation du tri rapide le bat en pratique, bien que
13.5. TRI PAR FUSION (MERGESORT ) 69

ce dernier soit théoriquement moins bon (mauvais pire cas).

13.5 Tri par fusion (mergesort )


Le tri par fusion d’une séquence de nombres a été présenté page 10, comme un exemple classique de programme récursif.
Il est possible d’écrire une version du tri par fusion adapté au tri d’un tableau.
La complexité en temps de cet algorithme est Θ(n log n), donc meilleure que les tris simples. Cependant, l’algorithme
nécessite un tableau auxiliaire pour les fusions1 . Ses performances moyennes se situent entre le tri rapide et le tri par tas.
C’est un bon tri
– lorsque de la place mémoire est disponible et que l’on souhaite ne prendre aucun risque sur le pire cas du tri rapide,
– pour trier des listes chaînées (accès séquentiel et non accès direct).

13.6 Tri rapide (quicksort )


La complexité en temps du tri rapide est particulière, en ce sens que le pire cas est Θ(n2 ), mais ce pire cas peut être rendu
extrêmement improbable. En moyenne, sa complexité en temps est O(n log n), avec une petite constante de proportionalité,
ce qui en fait le meilleur tri en pratique pour n grand. Le tri rapide trie sur place.

Description du tri rapide


L’algorithme du tri rapide est un bel exemple d’algorithme récursif. Il est bati sur le principe «diviser pour régner» (page
10). Pour trier le tableau t[p..r],
1. diviser : t[p..r] est partitionné en deux sous-tableaux non vide t[p..q] et t[q+1..r] tels que tout élément du
premier sous-tableau est inférieur ou égal à tout élément du second. L’indice q est calculé pendant le partitionne-
ment.
2. régner : les deux sous-tableaux sont triés récursivement.
3. combiner : il n’y a rien à faire.
On donne ci-après, la traduction de ce schéma en Java : la procédure récursive tri_rapide(int[] tab, int p, int
r) trie le sous-tableau tab[p .. r] sur place. Le tri d’un tableau t tout entier se fera par l’appel tri_rapide(t, 1,
n) où 1 et n sont les indices extrêmes du tableau.
1 s t a t i c void tri_rapide ( i n t [] tab , i n t p , i n t r ) {
2 if (p < r) {
3 i n t q = partitionner ( tab , p , r );
4 tri_rapide ( tab , p , q );
5 tri_rapide ( tab , q + 1, r );
6 }
7 }
La procédure importante est celle qui réalise le partitionnement :
1 s t a t i c i n t partitionner ( i n t [] tab , i n t p , i n t r ) {
2 i n t x = tab [p ]; // le pivot
3 i n t i = p - 1;
4 i n t j = r + 1;
5

6 while ( true ) {
7 do j -= 1; w h i l e ( tab [j ] > x ); // sort dès que tab[j] <= x
8 do i += 1; w h i l e ( tab [i ] < x ); // sort dès que tab[i] >= x
9 i f ( i < j ) { // échange tab[i] et tab[j]
10 i n t temp = tab [i ];
11 tab [i ] = tab [j ];
12 tab [j ] = temp ;
13 } else {
14 r e t u r n j;
15 }
1 Il existe une version utilisant moins de place, mais elle est plutôt complexe (voir [Sedgewick]).
70 CHAPITRE 13. TRIS

16 }
17 }
On commence, ligne 2, par choisir un pivot, c’est-à-dire un élément du sous-tableau dont la valeur servira de frontière à la
partition. Dans cette version, on choisit comme pivot le premier élément du sous-tableau, tab[p]. On positionne ensuite
(lignes 3 et 4) deux indices i et j de part et d’autre de la région à trier. Au cours du partitionnement, ces deux indices vont
progresser l’un vers l’autre jusqu’à se rejoindre. Ceci est réalisé par la boucle while lignes 6 à 16, dont on sortira lorsque
i ≥ j (par le return). Dans le corps de cette boucle, on commence par décrémenter j jusqu’à obtenir tab[j] ≤ x, puis on
incrémente i jusqu’à obtenir tab[i] ≥ x. Les nombres à l’extérieur de i et j sont correctement partitionnés par la valeur
du pivot. Si i < j, on échange tab[i] et tab[j] (lignes 10-12), ce qui permet de progresser dans le partionnement. Si i
≥ j, sous-tableau est entièrement partitionné, et j délimite la frontière : tous les nombres de tab[p..j] sont inférieurs
ou égaux au pivot, et tous les éléments de tab[j+1..r] sont supérieurs ou égaux au pivot. L’indice j est donc l’indice
renvoyé par la procédure. La figure 13.2 détaille le fonctionnement de l’algorithme de partitionnement sur un exemple.
pivot x = 5

p r p r
5 3 2 6 4 1 3 7 5 3 2 6 4 1 3 7
i j i j
(a) (a’)

p r p r
3 3 2 6 4 1 5 7 3 3 2 6 4 1 5 7
i j i j
(b) (b’)

p r p q r
3 3 2 1 4 6 5 7 3 3 2 1 4 6 5 7
i j j i
(c) (c’)

F IG . 13.2 – Fonctionnement de la procédure partitionner. Les nombres imprimés en gras sont correctement position-
nés. En (a) : le sous-tableau de départ, avec la position des indices p, r, i et j. En (a’) : état du sous-tableau après la
première séquence de déplacements des indices i et j ; on a tab[j] ≤ x ≤ tab[i]. En (b) : état du sous-tableau juste
après le premier échange. En (b’) : après la seconde séquence de déplacement des indices i et j. En (c) : après le second
échange. En (c’) : après la troisième et dernière séquence de déplacement des indices i et j. La double barre verticale
indique le partitionnement final. Tous les nombres à gauche de la barre sont inférieurs ou égaux à 5 et tous ceux de droite
sont supérieurs ou égaux à 5.

L’apparente simplicité de l’algorithme de partitionnement présenté cache quelques subtilités, sujets des exercices qui
suivent.
J
Exercice 13.1 Montrer que dans l’algorithme de partitionnement ci-dessus, i et j ne sortent jamais des limites du
sous-tableau.
J
Exercice 13.2 Que se passe-t-il dans l’algorithme de partitionnement ci-dessus si on prend tab[r] comme pivot (à
la place de tab[p]) et que tab[r] est le plus grand élément du sous-tableau ?
Exercice 13.3 Dans l’algorithme de partitionnement, il semble que les tests des lignes 7 et 8 pourraient être tab[j] ≥
J

x et tab[i] ≤ x au lieu d’une inégalité stricte, ce qui permettrait de pousser plus loin chaque séquence de déplacement,
et donc d’échanger moins. Donner deux raisons pour lesquelles on préfère conserver des inégalités strictes et donc
effectuer apparemment plus d’échanges que nécessaire (la première raison est facile à trouver ; la seconde est beaucoup
plus subtile et a rapport avec l’analyse de complexité du tri dans le cas ou les nombres à trier sont presque tous égaux).
L’algorithme de base du tri rapide a été inventé en 1960 par Hoare. La version présentée ici est celle de [Cormen]. Il existe
d’autres variantes du tri rapide en peu plus efficaces et surtout résistant mieux à des entrées «pathologiques», notamment
des nombres déjà ou presque triés. Voir [Sedgewick] et [Weiss].

Analyse du tri rapide


Partitionnement

Exercice 13.4 Montrer que le temps de partitionnement est Θ(n), où n est la longueur du sous-tableau à partitionner.
J
13.7. TRI PAR DÉNOMBREMENT 71

Analyse de l’algorithme complet

La forme récursive de l’algorithme nous permet d’écrire le temps T (n) nécessaire pour trier n nombres sous la récurrence :

T (n) = T (αn) + T ((1 − α)n) + Θ(n)

où α est la proportion de nombres dans la partition de gauche du premier partitionnement, et Θ(n) représente le temps du
partitionnement lui-même. On ne peut résoudre directement cette équation car le facteur α est un résultat propre à chaque
partitionnement. Cependant, nous pouvons analyser les pire et meilleur cas.
Le pire cas est atteint lorsque la partition est toujours complètement déséquilibrée, c’est-à-dire (1, n − 1).
J
Exercice 13.5 Réécrire la récurrence ci-dessus dans le cas d’une partition complètement déséquilibrée. Montrer que
si cela arrive systématiquement, alors T (n) = Θ(n2 ).
Examinons ce qui se passe lorsque chaque partition est systématiquement parfaitement équilibrée, c’est-à-dire α = 1/2.
La récurrence ci-dessus devient :

T (n) = 2T (n/2) + Θ(n)


J
Exercice 13.6 Utiliser la technique de la page 20 pour résoudre l’équation précédente et montrer que dans le cas
d’une partition parfaitement et systématiquement équilibrée, on a T (n) = Θ(n log n).
Le résultat de l’exercice précédent correspond au taux de croissance optimal pour un algorithme de tri par comparaison,
établi page 67. C’est donc le temps du meilleur cas.
On peut montrer la même complexité en Θ(n log n) pour des partitions toujours (1/k, (k − 1)/k, k constant.
Intuitivement, on sent bien que le pire cas, obtenu lorsque les partitions sont systématiquement et complètement déséquili-
brées, est assez «pathologique» (voir cependant l’exercice 13.7). De même, les partitions ne sont jamais systématiquement
et parfaitement équilibrées. Il nous faudrait faire ici une analyse du temps moyen. Elle est assez complexe, et nous nous
contenterons de son résultat : le temps d’exécution moyen du tri rapide est O(n log n), c’est-à-dire aussi bon asymptoti-
quement que dans le meilleur cas possible.
J
Exercice 13.7 Analyser le temps du tri rapide lorsque les nombres sont déjà triés. Comparer avec le comportement
du tri par insertion dans ce cas. Trouver un remède simple pour, dans ce cas, redonner au tri rapide un comportement
digne de son nom (aide : utiliser un tirage aléatoire).
En pratique, une bonne implémentation du tri rapide (sur la base de l’algorithme présenté mais avec quelques amélio-
rations) bat les autres tris par comparaison sur les tableaux, dès que la taille du tableau est assez grande (supérieure à
quelques centaines). Voir les expérimentations page 76.

13.7 Tri par dénombrement


Le tri par dénombrement est un tri tout-à-fait différent de ceux présentés jusqu’à maintenant. En effet, ce tri n’utilise
aucune comparaison ! De ce fait, il échappe à la borne inférieure de complexité établie pour les tris par comparaison (page
67) : le temps d’exécution de ce tri est linéaire en fonction du nombre d’éléments à trier. Bien sûr, il existe des contreparties
à ce petit miracle. Le tri par dénombrement utilise l’hypothèse suivante : les clés sont des entiers, et le domaine des clés
est l’intervalle [1..k] avec k connu à l’avance. Le nombre k doit être «raisonnable», en ce sens que l’on doit pouvoir
réserver en mémoire un tableau auxiliaire de taille k, qui servira pendant le tri. Ce n’est donc pas un tri «sur place».
L’idée de base du tri par dénombrement est on ne peut plus simple. Supposons dans un premier temps que les clés sont
distinctes. On construit, comme dans la figure 5.1 page 29, un dictionnaire implémenté par tableau, dans lequel les clés
indexent directement les éléments du tableau. Puis, en parcourant le tableau on «ramasse» les enregistrements pointés par
les clés utilisées.
L’algorithme présenté ci-dessous est une adaptation de cette idée, prenant en compte le fait que les clés ne sont pas
nécessairement uniques. Ici encore, pour faire simple, on oublie les enregistrements et on ne trie que les clés, mais ils sont
là de manière implicite.
1 s t a t i c void tri_denombrement ( i n t [] t , i n t [] b , i n t [] c , i n t n ) {
2 i n t i , j;
3

4 f o r ( i = 1; i <= k ; i += 1) {
5 c[i ] = 0;
72 CHAPITRE 13. TRIS

tri temps mémoire hypothèse commentaire


de la bulle O(n2 ) s.p.
par insertion O(n2 ) s.p. (3)
par sélection O(n2 ) s.p.
par fusion O(n log n)
par tas O(n log n) s.p.
tri rapide O(n2 ) (1) s.p. (4)
par dénombrement O(n) domaine connu (5)
(1) en moyenne O(n log n), le pire cas Θ(n2 ) est très improbable dans une bonne implémentation.
s.p. : tri sur place.
(3) le meilleur tri de tableau pour n < 1000.
(4) le meilleur tri général en pratique pour n grand.
(5) intéressant si le domaine des clés est connu à l’avance et pas trop grand.

F IG . 13.3 – Synthèse des qualités des différents tris.

6 }
7 f o r ( j = 1; j <= n ; j += 1) {
8 c[t[j ]] = c[t[j ]] + 1;
9 }
10 f o r ( i = 2; i <= k ; i += 1) {
11 c[i ] += c[i -1];
12 }
13 f o r ( j = n ; j >= 1; j -= 1) {
14 b[c[t[j ]]] = t[j ];
15 c[t[j ]] -= 1;
16 }
17 f o r ( j = 1; j <= n ; j += 1) {
18 t[j ] = b[j ];
19 }
20 }
t est le tableau à trier. b est le tableau dans lequel on trouvera le résultat, donc de même dimension que t. c est un autre
tableau auxiliaire, qui doit pouvoir être indexé de 1 à k.
La boucle des lignes 4 à 6 initialise les éléments du tableau c à 0. La boucle des lignes 7 à 9 «compte» chaque clé utilisée :
à la fin de cette boucle, c[i] contient le nombre d’enregistrements dont la clé est égale à i. Après la boucle des lignes
10 à 11, c[i] contient le nombre d’enregistrements dont la clé est inférieure ou égale à i. La boucle des lignes 13 à
16 remplit le tableau de sortie b en y plaçant tour à tour chaque élément du tableau t. Ligne 14, c[t[j]] est la bonne
position de t[j] dans b. La ligne suivante (15) traite le cas des clés égales : le prochain élément du tableau de départ qui
a aussi cette clé t[j] sera placé juste avant dans b. La dernière boucle est une facilité : elle recopie b dans t.
Exercice 13.8 Montrer que le temps d’exécution du tri par dénombrement est Θ(n + k).
J

J
Exercice 13.9 Objection d’un élève : la détermination du maximum et du minimum d’un tableau de nombres demande
un temps O(n). Il suffit donc de réserver un tableau assez grand et on pourrait trier n’importe quelle séquence de nombres
en O(n) avec un tri par dénombrement, ce qui rend obsolètes la plupart des tris. Réfutez cette objection.

13.8 Résumé des propriétés des tris


La figure 13.3 synthétise les qualités des différents tris étudiés.
J
Exercice 13.10 Modifier un algorithme de tri d’entiers pour en faire un algorithme de tri d’enregistrements (on com-
pare les clés).

13.9 Programme Java


Le fichier TestTris.java
13.9. PROGRAMME JAVA 73

1 /*
2 * TestTris.java
3 *
4 * SupAéro -- Cours Structures de Données et Algorithmes
5 */
6

7 import java . io .* ;
8

9 /**
10 * Cette classe rassemble et compare des algorithmes de tri :
11 * tri par insertion, tri bulle, tri par sélection,
12 * tri rapide (quicksort), tri par dénombrement.
13 *
14 * @version 16 Août 2000
15 * @author Michel Lemaître
16 */
17 c l a s s TestTris {
18 s t a t i c i n t nmax = 5000000; // nombre max de nombres à trier
19 s t a t i c i n t k = 1000000; // on trie des nombres de l’intervalle [1 .. k]
20

21 static PrintWriter sortie ; // pour la sortie sur fichier


22 static long ms1 , ms2 ; // chronomètre : temps CPU en millisecondes
23

24 p u b l i c s t a t i c void main ( String [] args ) throws IOException {


25 sortie = new PrintWriter (new FileWriter (" sortie_TestTris " ));
26 // le tableau à trier
27 i n t [] t = new i n t [ nmax + 1]; // mes tableaux commencent à 1
28 i n t n ; // dimension du tableau à trier pendant un essai
29

30 // deux tableaux auxiliaires pour le tri par dénombrement :


31 // je les déclare ici pour n’avoir qu’une allocation
32 i n t [] b = new i n t [ nmax + 1];
33 i n t [] c = new i n t [k + 1];
34

35 f o r ( n = 1024; n <= nmax ; n *= 2) {


36 sortie . println ( "\ n n =" + n );
37 System . out . println ( "\ n n =" + n );
38

39 i f ( n < 100000) {
40 remplir (t , n ); // remplit le tableau à trier de nbres aléatoires
41 init (" insertion " );
42 tri_insertion (t , n );
43 verif (t , n );
44 }
45

46 i f ( n < 50000) {
47 remplir (t , n );
48 init (" bulle ");
49 tri_bulle (t , n );
50 verif (t , n );
51 }
52

53 i f ( n < 100000) {
54 remplir (t , n );
55 init (" selection " );
56 tri_selection (t , n );
57 verif (t , n );
58 }
59

60 remplir (t , n );
61 init (" rapide ");
62 tri_rapide (t , 1, n );
63 verif (t , n );
64
74 CHAPITRE 13. TRIS

65 remplir (t , n );
66 init (" denombrem " );
67 tri_denombrement (t , b , c , n );
68 verif (t , n );
69 }
70 sortie . close ();
71 }
72

73 s t a t i c void tri_insertion ( i n t [] t , i n t n ) {
74 i n t i;
75

76 f o r ( i n t j = 2; j <= n ; j += 1) {
77 i n t clé = t[j ];
78 f o r ( i = j - 1; ( i > 0) && ( t[i ] > clé ); i -= 1) {
79 t[i + 1] = t[i ];
80 }
81 t[i + 1] = clé ;
82 }
83 }
84

85 s t a t i c void tri_bulle ( i n t [] t , i n t n ) {
86 f o r ( i n t i = 1; i <= n - 1; i += 1) {
87 f o r ( i n t j = n ; j >= i + 1; j -= 1) {
88 i f ( t[j - 1] > t[j ]) {
89 i n t temp = t[j - 1];
90 t[j - 1] = t[j ];
91 t[j ] = temp ;
92 }
93 }
94 }
95 }
96

97 s t a t i c void tri_selection ( i n t [] t , i n t n ) {
98 f o r ( i n t i = 1; i <= n - 1; i += 1) {
99 i n t min = t[i ];
100 i n t p = i;
101 f o r ( i n t j = i + 1; j <= n ; j += 1) {
102 i f ( t[j ] < min ) {
103 min = t[j ];
104 p = j;
105 }
106 }
107 t[p ] = t[i ];
108 t[i ] = min ;
109 }
110 }
111

112 s t a t i c void tri_rapide ( i n t [] tab , i n t p , i n t r ) {


113 if (p < r) {
114 i n t q = partitionner ( tab , p , r );
115 tri_rapide ( tab , p , q );
116 tri_rapide ( tab , q + 1, r );
117 }
118 }
119

120 static i n t partitionner ( i n t [] tab , i n t p , i n t r ) {


121 int x = tab [p ];
122 int i = p - 1;
123 int j = r + 1;
124

125 while ( true ) {


126 do j -= 1; w h i l e ( tab [j ] > x ); // sort dès que tab[j] <= x
127 do i += 1; w h i l e ( tab [i ] < x ); // sort dès que tab[i] >= x
128 i f ( i < j ) { // échange tab[i] et tab[j]
13.9. PROGRAMME JAVA 75

129 i n t temp = tab [i ];


130 tab [i ] = tab [j ];
131 tab [j ] = temp ;
132 } else {
133 r e t u r n j;
134 }
135 }
136 }
137

138 s t a t i c void tri_denombrement ( i n t [] t , i n t [] b , i n t [] c , i n t n ) {


139 i n t i , j;
140

141 f o r ( i = 1; i <= k ; i += 1) {
142 c[i ] = 0;
143 }
144 f o r ( j = 1; j <= n ; j += 1) {
145 c[t[j ]] = c[t[j ]] + 1;
146 }
147 f o r ( i = 2; i <= k ; i += 1) {
148 c[i ] += c[i -1];
149 }
150 f o r ( j = n ; j >= 1; j -= 1) {
151 b[c[t[j ]]] = t[j ];
152 c[t[j ]] -= 1;
153 }
154 f o r ( j = 1; j <= n ; j += 1) {
155 t[j ] = b[j ];
156 }
157 }
158

159 s t a t i c void imprimer ( i n t [] t , i n t n ) {


160 f o r ( i n t i = 1; i <= n ; i += 1) {
161 sortie . println ("t [" + i + "] = " + t[i ]);
162 }
163 sortie . println ();
164 }
165

166 s t a t i c void remplir ( i n t [] t , i n t n ) {


167 f o r ( i n t i = 1; i <= n ; i += 1) {
168 t[i ] = aleatoire (k );
169 }
170 // imprimer(t, n);
171 }
172

173 /** Retourne un entier aléatoire dans [ 1 ... m ] */


174 s t a t i c i n t aleatoire ( i n t m ) {
175 r e t u r n (( i n t ) ( Math . random () * m + 1));
176 }
177

178 s t a t i c void init ( String s ) {


179 sortie . print (s );
180 System . out . print (s );
181 ms1 = System . currentTimeMillis ();
182 }
183

184 s t a t i c void verif ( i n t [] t , i n t n ) {


185 ms2 = System . currentTimeMillis ();
186 // imprimer(t, n);
187 sortie . println (" : " + (( ms2 - ms1 ) / 1000.0) + "s" );
188 System . out . println (" : " + (( ms2 - ms1 ) / 1000.0) + "s" );
189 f o r ( i n t i = 1; i <= n - 1; i += 1) {
190 i f ( t[i ] > t[i + 1] ) {
191 sortie . println (" Erreur quelquepart " );
192 System . exit (1);
76 CHAPITRE 13. TRIS

193 }
194 }
195 }
196 }

Le fichier sortie_TestTris
(exécuté sur PC Pentium II 235 MH, Java 2 SDK SE 1.3)
1
2 n=1024
3 insertion : 0.0s
4 bulle : 0.11s
5 selection : 0.06s
6 rapide : 0.0s
7 denombrem : 0.16s
8
9 n=2048
10 insertion : 0.11s
11 bulle : 0.28s
12 selection : 0.05s
13 rapide : 0.0s
14 denombrem : 0.17s
15
16 n=4096
17 insertion : 0.33s
18 bulle : 0.88s
19 selection : 0.28s
20 rapide : 0.0s
21 denombrem : 0.16s
22
23 n=8192
24 insertion : 1.21s
25 bulle : 3.52s
26 selection : 0.93s
27 rapide : 0.0s
28 denombrem : 0.16s
29
30 n=16384
31 insertion : 4.67s
32 bulle : 14.01s
33 selection : 3.52s
34 rapide : 0.0s
35 denombrem : 0.16s
36
37 n=32768
38 insertion : 18.73s
39 bulle : 56.18s
40 selection : 14.01s
41 rapide : 0.11s
42 denombrem : 0.22s
43
44 n=65536
45 insertion : 74.75s
46 selection : 56.19s
47 rapide : 0.17s
48 denombrem : 0.22s
49
50 n=131072
51 rapide : 0.27s
52 denombrem : 0.32s
53
13.9. PROGRAMME JAVA 77

54 n=262144
55 rapide : 0.61s
56 denombrem : 0.44s
57
58 n=524288
59 rapide : 1.15s
60 denombrem : 0.71s
61
62 n=1048576
63 rapide : 2.36s
64 denombrem : 1.37s
65
66 n=2097152
67 rapide : 4.99s
68 denombrem : 2.53s
69
70 n=4194304
71 rapide : 10.33s
72 denombrem : 4.94s
Chapitre 14

La bibliothèque collections framework de Java

La bibliothèque nommée collections framework de Java, regroupe un ensemble de structures de données abstraites et
concrètes. Nous donnons dans ce chapitre l’essentiel de ce qu’il faut connaître de cette bibliothèque (Java 2 SDK,
Standard Edition Version 1.3). La documentation complète est accessible publiquement sur le site de Sun à l’adresse
http://java.sun.com/j2se/1.3/docs. Nous encourageons fortement l’utilisation de cette bibliothèque : elle est très
bien conçue, homogène, gratuite, portable, parfaitement intégrée à Java, et robuste1 . Pour utiliser la bibliothèque, il est
très agréable d’avoir un navigateur ouvert sur la documentation en ligne de Sun, remarquable et facile à utiliser. Malgré
tout, ce chapitre peut vous aider au début pour aller à l’essentiel, ou comme aide-mémoire.

Cette bibliothèque définit deux interfaces principales : la Collection et le Map. Au sens de Java, une interface corres-
pond d’assez près à ce que nous avons appelé «structure de données abstraite» (voir page 25).
– une Collection est simplement une collection d’objets (avec duplications possibles).
– un Map représente un ensemble d’associations entre objets (un ensemble de liens clé-valeur), une fonction au sens
mathématique.
Bien sûr, cette bibliothèque procure un floppée de structures de données concrètes implémentant ces interfaces.
Toutes les méthodes de ce chapitre font partie du package java.util2 . Tout programme qui les utilise doit donc com-
mencer par l’incantation «import java.util.*».
Remarque : les clés et les valeurs stockés dans un Map, ainsi que les objets d’une Collection ne peuvent être directement
des types primitifs (int, double, boolean, char). Il faut utiliser des wrappers : classes Integer, Double, Boolean,
Character.

14.1 Structures de données abstraites


14.1.1 Collection et ses sous-interfaces
Collection

La Collection est une interface qui représente une collection d’objets, de la façon la plus générale possible.
Les principales méthodes offertes par la Collection sont :
– boolean isEmpty() : teste si la Collection est vide.
– boolean contains(Object o) : teste si la Collection contient l’objet spécifié.
– int size() : renvoie le nombre d’éléments de la Collection.
– boolean add(Object o) : ajoute l’objet spécifié à la Collection.
– boolean remove(Object o) : ôte de la Collection l’objet spécifié, s’il y est.
– void clear() : ôte tous les objets de la Collection.
– Iterator iterator() : renvoie un itérateur, objet particulier qui permettra d’itérer sur les éléments de la Collection
(voir page 82).
Les opérations add et remove, qui modifient la Collection, renvoient true si la Collection a été modifiée, et false
sinon.

1 Conçue par une armée de professionnels, utilisée et testée par des milliers de programmeurs . . .
2 Sauf Comparable, qui fait partie du package java.lang, mais qui est importé automatiquement.

78
14.1. STRUCTURES DE DONNÉES ABSTRAITES 79

Les principales implémentations de Collection sont celles de Set et de List, c’est-à-dire HashSet, TreeSet, ArrayList
et LinkedList.

Set

Cet interface, qui étend l’interface Collection, représente un groupe non ordonné d’objets, et ne contenant pas d’objets
dupliqués. L’interface Set offre les mêmes méthodes que son super-interface Collection. Il empêche seulement add de
provoquer des duplications.

Les principales implémentations de Set sont HashSet et TreeSet.

SortedSet

SortedSet est une sous-interface de Set garantissant un ordre sur les éléments du Set. Les éléments doivent donc être
des objets comparables. L’itérateur renvoyé par la méthode iterator énumèrera les éléments du Set dans l’ordre prévu.
En plus des méthodes de sa super-interface, le SortedSet procure les méthodes suivantes :
– Object first() : renvoie le plus petit élément du SortedSet.
– Object last() : renvoie le plus grand élément du SortedSet.
L’interface SortedSet est implémenté par la classe TreeSet.

List

Cette interface, qui étend l’interface Collection, représente un groupe ordonné3 d’objets (une séquence), pouvant conte-
nir des duplications d’objets. Le terme List ne doit pas s’entendre au sens concret de liste chaînée, mais au sens abstrait
de séquence ordonnée d’éléments. Chaque élément d’une List possède un index, ou position (entier positif ou nul).
L’index du premier élément d’une liste est 0. Les éléments peuvent être manipulés par leur index.
L’interface List offre les mêmes méthodes que son super-interface Collection, mais offre de plus des méthodes de
manipulation avec index. Parmi ces méthodes supplémentaires, les plus utiles sont :
– Object get(int index) : renvoie l’objet d’index spécifié. Une erreur se produit si l’index est négatif ou supérieur ou
égal à la taille (nombre d’éléments) de la List.
– Object set(int index, Object o) : met l’objet dans la List à l’index spécifié. L’ancien contenu est écrasé. Une
erreur se produit si l’index est négatif ou supérieur ou égal à la taille (nombre d’éléments) de la List.
– void add(int index, Object o) : une version de add qui ajoute l’objet à la List, à l’index spécifié. Les objets
qui suivent voient leurs index décalés. Une erreur se produit si l’index est négatif ou supérieur à la taille (nombre
d’éléments) de la List (on a le droit d’ajouter juste après le dernier élément de la liste).
La version de add() sans argument index ajoute l’objet à la fin de la liste.
– boolean remove(int index) : une version de remove qui ôte à la List l’objet dont l’index est spécifié. Une erreur
se produit si l’index est négatif ou supérieur ou égal à la taille (nombre d’éléments) de la List.
La List offre un itérateur spécifique ListIterator, plus puissant que celui de la Collection, ainsi que la possibilité
de manipuler des sous-listes (se reporter à la documentation complète).

Les principales implémentations de List sont ArrayList et LinkedList.

14.1.2 Map et ses sous-interfaces


Map

Le Map est une interface qui représente un ensemble d’associations (des paires) entre des objets clés et des objets valeurs.
Le Map correspond à ce que nous avons appelé dictionnaire (voir la définition page 25), mais offre des services bien plus
complets et sophistiqués.
L’ensemble des clés d’un Map doit être un véritable ensemble : chaque clé d’un Map doit être unique. La collection des
valeurs n’est pas soumise à cette restriction.
Les principales opérations4 offertes par le Map sont :
3 Le groupe d’objet est ordonné, mais les objets ne sont pas nécessairement comparables entre eux.
4 En Java on parle de méthodes.
80 CHAPITRE 14. LA BIBLIOTHÈQUE COLLECTIONS FRAMEWORK DE JAVA

– boolean isEmpty() : teste si le Map est vide.


– boolean containsKey(Object clé) : teste si le Map contient la clé spécifiée.
– boolean containsValue(Object valeur) : teste si le Map contient la valeur spécifiée.
– Object get(Object clé) : renvoie la valeur associée à la clé spécifiée dans le Map. Renvoie null si la clé n’a pas
d’associé, ou si la clé est associée à null.
– int size() : renvoie le nombre d’associations du Map.
– Object put(Object clé, Object valeur) : ajoute une association clé-valeur dans le Map. Renvoie la valeur asso-
ciée précédemment à la clé (ce peut être null), ou null si la clé n’était associée à aucune valeur.
– Object remove(Object clé) : ôte du Map l’association de clé spécifiée. Renvoie la valeur associée précédemment à
la clé (ce peut être null), ou null si la clé n’était associée à aucune valeur.
– void clear() : vide le Map de toutes ses associations.
– Set keySet() : renvoie le Set de toutes les clés du Map.
– Collection values() : renvoie la Collection de toutes les valeurs du Map.
– Set entrySet() : renvoie le Set de toutes les associations du Map. Cela permet d’itérer sur les associations du Map
à l’aide d’un itérateur sur ce Set. Les objets du Set renvoyé sont du type Map.Entry, un interface qui représente une
association. L’interface Map.Entry procure les méthodes getKey, getValue et setValue.
Les collections renvoyées par les méthodes keySet, values et entrySet sont des «vues» sur le Map d’origine et non de
nouvelles collections : toute modification du Map est répercutée sur ces vues.
Les principales implémentations de Map sont HashMap et TreeMap.

SortedMap

SortedMap est une sous-interface de Map garantissant un ordre sur les clés. Les clés doivent donc être des objets compa-
rables. Les collections renvoyées par les méthodes keySet, values et entrySet seront itérées dans l’ordre des clés. En
plus des méthodes de sa super-interface, le SortedMap procure les méthodes suivantes :
– Object firstKey() : renvoie la plus petite clé du SortedMap.
– Object lastKey() : renvoie la plus grande clé du SortedMap.
L’interface SortedMap est implémenté par TreeMap.

14.2 Structures de données concrètes


14.2.1 Implémentations de Collection
HashSet

La classe HashSet implémente l’interface Set par une table de hachage (voir page 36), et donc les méthodes add,
remove et contains sont très efficaces (temps quasiment constant). En plus des méthodes de l’interface qu’il implé-
mente, HashSet procure les contructeurs suivants :
– HashSet() : le constructeur par défaut.
– HashSet(int capacité) : un constructeur permettant de spécifier le nombre approximatif d’éléments prévus, lors-
qu’on le connait à l’avance.
– HashSet(Collection c) : construit un HashSet à partir d’une Collection.

TreeSet

La classe TreeSet implémente l’interface SortedSet par un arbre rouge et noir (voir page 51), et donc les méthodes
add, remove et contains sont relativement efficaces (temps logarithmique). Les éléments du TreeSet doivent être com-
parables : soit ils implémentent naturellement l’interface Comparable (voir page 82), soit on a fourni au constructeur de
TreeSet un Comparator (voir page 82). En plus des méthodes de l’interface qu’il implémente, un TreeSet procure les
constructeurs suivants :
– TreeSet() : le constructeur par défaut.
– TreeSet(Comparator c) : le constructeur qui permet de spécifier un Comparator définissant l’ordre sur les éléments.
– TreeSet(Collection c) : construit un TreeSet à partir d’une Collection.
14.3. AUTRES INTERFACES ET CLASSES UTILES 81

ArrayList

La classe ArrayList implémente l’interface List par un tableau (ce tableau est reconstruit dynamiquement si la List
grandit ou rapetisse beaucoup) et donc les méthodes get et set sont très efficaces (en temps constant). Une ArrayList
s’utilise en fait comme un tableau à dimension automatiquement ajustable.
En plus des méthodes de l’interface qu’il implémente, une ArrayList procure les constructeurs suivants :
– ArrayList() : le constructeur par défaut.
– ArrayList(int capacité) : un constructeur permettant de spécifier le nombre approximatif d’éléments prévus, lors-
qu’on le connait à l’avance.
– ArrayList(Collection c) : construit une ArrayList à partir d’une Collection.

LinkedList

La classe LinkedList implémente l’interface List par une liste doublement chaînée. Les méthodes get et set sont donc
relativement peu efficaces (en temps linéaire). Par contre add et remove sont en temps constant. En plus des méthodes de
l’interface List, une LinkedList procure les constructeurs et méthodes spécifiques suivants :
– LinkedList() : le constructeur par défaut.
– LinkedList (Collection c) : construit une LinkedList à partir d’une Collection.
– Object getFirst() : renvoie le premier objet de la LinkedList.
– Object getLast() : renvoie le dernier objet de la LinkedList.
– void addFirst(Object o) : ajoute l’objet spécifié en début de la LinkedList.
– void addLast(Object o) : ajoute l’objet spécifié en fin de la LinkedList.
– void removeFirst() : ôte le premier élément de la LinkedList.
– void removeLast() : ôte le dernier élément de la LinkedList.

14.2.2 Implémentations de Map


HashMap

La classe HashMap implémente l’interface Map par une table de hachage (voir page 36), et donc les méthodes get et put
sont très efficaces (temps quasiment constant). En plus des méthodes de l’interface qu’il implémente, HashMap procure
les contructeurs suivants :
– HashMap() : le constructeur par défaut.
– HashMap(int capacité) : un constructeur permettant de spécifier le nombre approximatif d’associations prévues,
lorsqu’on le connait à l’avance.
– HashMap(Map m) : construit un HashMap à partir d’un autre Map.

TreeMap

La classe TreeMap implémente l’interface SortedMap par un arbre rouge et noir (voir page 51), et donc les méthodes get,
put, remove et containsKey sont relativement efficaces (temps logarithmique). Les clés doivent être comparables : soit
les clés implémentent naturellement l’interface Comparable (voir page 82), soit on a fourni au constructeur de TreeMap un
Comparator (voir page 82). En plus des méthodes de l’interface qu’il implémente, un TreeMap procure les constructeurs
suivants :
– TreeMap() : le constructeur par défaut.
– TreeMap(Comparator c) : le constructeur qui permet de spécifier un Comparator définissant l’ordre sur les clés.
– TreeMap(Map m) : construit un TreeMap à partir d’un autre Map.

14.3 Autres interfaces et classes utiles


Arrays
La classe Arrays contient des tas de méthodes utiles pour trier un tableau, rechercher un élément dans un tableau par
recherche dichotomique (en temps logarithmique) ou pour remplir un tableau. Le tri utilisé est le tri rapide (quicksort)
pour les tableaux de types primitifs, et le tri par fusion (mergesort) pour les tableaux d’objets.
82 CHAPITRE 14. LA BIBLIOTHÈQUE COLLECTIONS FRAMEWORK DE JAVA

La méthode List asList(Object[] a) jette un pont entre le tableau et la Collection : elle renvoie une «vue» List
du tableau d’objets spécifié.

Iterator
Cette interface définit des méthodes pour énumérer les objets d’une Collection (voir les exemples, page 83). Lors de
la création d’un itérateur (par la méthode iterator() de la Collection à énumérer), cet itérateur est positionné sur le
premier élément de la Collection. L’interface Iterator offre trois méthodes :
– boolean hasNext() : renvoie true s’il reste encore des éléments à énumérer, false s’il ont tous été énumérés.
– Object next() : positionne l’itérateur sur le prochain élément.
– void remove() : ôte de la Collection énumérée le dernier objet renvoyé par next.
Voir un exemple d’utilisation de l’interface Iterator dans le programme Java qui commence page 83, lignes 49 à 54.

Comparable
Cette interface5 contient une seule méthode : int compareTo(Objet o), pour comparer deux objets selon leur ordre
«naturel». La méthode renvoie un entier négatif si l’objet this est plus petit que celui passé en argument, un entier positif
si l’objet this est plus grand que celui passé en argument, et 0 si les deux objets sont égaux. Les termes «plus petit», «plus
grands» et «égaux» s’entendent selon l’ordre naturel des objets. La plupart des objets standard implémentent l’interface
Comparable. C’est le cas en particulier de tous les wrappers.
Si vous voulez définir un ordre sur des objets qui n’implémentent pas l’interface Comparable, ou définir un ordre différent
de l’ordre naturel, vous devrez définir une classe implémentant l’interface Comparator.

Comparator
Cette interface offre deux méthodes :
– int compare(Object o1, Object o2) : renvoie un entier négatif si o1 vient avant o2 dans l’ordre voulu, positif si
o1 vient après o2, et 0 si les deux objets sont équivalents relativement à l’ordre voulu.
– boolean equals(Object o) : o1.equals(o2) teste si o1 et o2 sont égaux relativement à l’ordre voulu.
Pour utiliser l’interface Comparator, par exemple pour trier, ou pour construire un SortedSet ou un SortedMap spéci-
fique, il faut construire une classe implémentant l’interface (avec au moins une implémentation de la méthode compare),
et passer à qui de droit un objet de cette classe construit par new. Souvent, la classe en question est définie localement
comme une classe interne (voir un exemple dans le programme Java qui commence page 83, lignes 127 à 155). Cela parait
compliqué au début, mais en fin de compte c’est assez simple.

Collections
Attention : Collections avec un ‘s’ !
Cette classe définit un ensemble de méthodes statiques utiles en relation avec Collection et Map. Parmi les méthodes les
plus utiles, on trouve :
– static void sort(List l) : trie sur place la liste passée en argument, selon l’ordre naturel des éléments, c’est-à-
dire celui donné par la méthode CompareTo de l’interface Comparable sensé être implémenté par les éléments de la
liste.
– static void sort(List l, Comparator c) : trie sur place la liste passée en argument, selon l’ordre spécifié par le
Comparator.
– static int binarySearch(List l, Object o) : recherche dans la List triée l’objet spécifié, et retourne l’index
de l’objet dans la liste s’il y est, et un nombre négatif sinon. La recherche est par dichotomie (temps logarithmique)
sur une ArrayList, et séquentielle (temps linéaire) sur une LinkedList. C’est la version utilisant l’ordre naturel des
objets en question.
– static int binarySearch(List l, Object o, Comparator c) : version avec Comparator.
On trouve aussi de quoi effectuer des copies de List, remplir des List, renverser une List, trouver dans une List non
ordonnée un minimum et un maximum, mélanger aléatoirement les éléments d’une liste, . . . (se reporter à la documenta-
tion).
5 Comparable appartient au package de base java.lang, importé automatiquement.
14.4. EXERCICES 83

14.4 Exercices
J
Exercice 14.1 Quelle classe du collections framework choisiriez-vous pour implémenter une pile ? une file ?
J
Exercice 14.2 Pourquoi Map fournit la méthode containsKey alors que get a priori suffirait ?
J
Exercice 14.3 Quels sont les raisons pour lesquelles on exige que les clés d’un Map forment un ensemble (clés
uniques) ? Est-ce réellement une restriction ?
J
Exercice 14.4 Quelles structures de données abstraites pourtant courantes ne sont pas implémentées directement par
le collections framework ? Pourquoi ?

14.5 Programme Java : exemples d’utilisation


Commençons par une remarque très importante. Il est parfaitement idiot d’écrire
LinkedList l = new LinkedList();
tant qu’on n’a pas besoin des méthodes spécifiques d’une LinkedList. Il est bien meilleur de déclarer un nouvel objet au
juste niveau d’abstraction, par exemple comme ceci (dans l’hypothèse ou on ne prévoit pas d’avoir besoin de méthodes
autres que celles offertes par Collection) :
Collection c = new LinkedList();
Ainsi, on profite à plein de l’orientation objet de Java : il sera bien temps plus tard de changer l’implémentation choisie
s’il le faut, et pour cela une seule ligne de code sera à modifier : cette déclaration.

Le programme Java qui suit regroupe un certain nombre d’exemples d’utilisation de méthodes du collections framework.

Le fichier TestCollectionsFramework.java
1 /*
2 * TestCollectionsFramework.java
3 *
4 * SupAéro -- Cours Structures de Données et Algorithmes
5 */
6

7 import java . io .*;


8 import java . util .*;
9

10 /**
11 * Petits exemples d’utilisation du collections framework
12 *
13 * @version 6 Septembre 2000
14 * @author Michel Lemaître
15 */
16 c l a s s TestCollectionsFramework {
17 s t a t i c PrintWriter sortie ;
18

19 p u b l i c s t a t i c void main ( String [] args ) throws IOException {


20 sortie = new PrintWriter (new FileWriter (" sortie_TestCollectionsFramework " ));
21

22 // ==================================
23 // Quelques objets qui nous serviront.
24 Object o1 = new Integer (36);
25 Object o2 = " aaaaa ";
26 Object o3 = new Double ( Math . PI );
27

28

29 // ==================================
30 // Création d’une Collection (les duplications sont permises)
31 // implémentée en liste doublement chaînée.
32 Collection c = new LinkedList ();
33
84 CHAPITRE 14. LA BIBLIOTHÈQUE COLLECTIONS FRAMEWORK DE JAVA

34 // On ajoute quelques objets à la collection ...


35 c. add ( o1 );
36 c. add ( o2 );
37 c. add ( o3 );
38 sortie . println (" c1 : " + c );
39 c. add ( o1 );
40 c. add ( o1 );
41 sortie . println (" c2 : " + c );
42 // On en retire ...
43 c. remove ( o1 );
44 sortie . println (" c3 : " + c );
45 c. remove ( o1 );
46 sortie . println (" c4 : " + c );
47

48 // Exemple d’itération sur la Collection c


49 Iterator i = c. iterator ();
50 w h i l e (i. hasNext ()) {
51 Object o = i. next ();
52 sortie . println (" c5 :------ " + o );
53 }
54

55

56 // ==================================
57 // Création d’un SortedMap, c’est-à-dire un dictionnaire
58 // dont les clés (obligatoirement des Objets) sont ordonnées,
59 // implémentée par un arbre <<rouge et noir>> (seule implémentation disponible)
60 SortedMap m = new TreeMap ();
61

62 // Insérons quelques objets.


63 m. put (new Integer (1), o1 );
64 m. put (new Integer (2), o2 );
65 m. put (new Integer (3), o3 );
66 m. put (new Integer (10), o2 );
67 sortie . println (" m1 : " + m );
68 // Plus petite et plus grande clé
69 sortie . println (" m2 : " + m. firstKey ());
70 sortie . println (" m3 : " + m. lastKey ());
71

72 // Construction d’une <<vue>> sur les valeurs :


73 Collection v = m. values ();
74 sortie . println (" m4 : " + v );
75

76 // Insertion sur un clé déjà occupée : l’ancienne valeur est écrasée


77 m. put (new Integer (3), o1 );
78 sortie . println (" m5 : " + m );
79 // La vue v sur les valeurs a aussi changé :
80 sortie . println (" m6 : " + v );
81

82

83 // ==================================
84 // Création d’une List (une séquence)
85 // implémentée par un tableau.
86 List l = new ArrayList (20); // crée toujours une List vide
87 sortie . println (" l1 : " + l );
88 // Insérons quelques objets.
89 l. add ( o1 );
90 l. add ( o2 );
91 l. add ( o3 );
92 l. add ( n u l l );
93 l. add ( n u l l );
94 l. add ( o2 );
95 sortie . println (" l2 : " + l );
96

97 // Insérons quelquechose à un indice déjà occupé :


14.5. PROGRAMME JAVA : EXEMPLES D’UTILISATION 85

98 // l’ancien contenu est écrasé.


99 l. set (2, new Boolean ( t r u e ));
100 sortie . println (" l3 : " + l );
101

102 // Ajoutons quelquechose à un indice déjà occupé,


103 // Toute la séquence est décalée (ça coûte cher).
104 l. add (2, new Double (1000.0));
105 sortie . println (" l4 : " + l );
106

107

108 // ==================================
109 // Utilisation des méthodes de Collections
110

111 // Construction d’une List (séquence) de chaînes de caractères.


112 List ll = new LinkedList ();
113 ll . add (" France " );
114 ll . add (" Royaume - Uni " );
115 ll . add (" Italie " );
116 ll . add (" Allemagne " );
117 ll . add (" Luxembourg " );
118 ll . add (" Suisse " );
119 ll . add (" Espagne " );
120 sortie . println (" ll1 : " + ll );
121

122 // Tri de la List selon l’ordre <<naturel>> des String (lexicographique)


123 Collections . sort ( ll );
124 sortie . println (" ll2 : " + ll );
125

126 // Tri d’une List de String selon un ordre particulier : leur longueur.
127 // Il faut utiliser l’interface Comparator.
128 Collections . sort ( ll , new CompStringLongueur ());
129 sortie . println (" ll3 : " + ll );
130

131 // La même chose dans l’ordre inverse, sauf que


132 // le Comparator est cette fois-ci défini par une classe anonyme,
133 // ce qui évite d’avoir à décrire une nouvelle classe comme ci-dessus.
134 Collections . sort ( ll , new Comparator () {
135 p u b l i c i n t compare ( Object o1 , Object o2 ) {
136 r e t u r n (( String ) o2 ). length () - (( String ) o1 ). length ();
137 }
138 });
139 sortie . println (" ll4 : " + ll );
140

141 sortie . close () ;


142 }
143 }
144

145 /**
146 * Une classe implémentant Comparator et définissant
147 * une méthode compare de comparaison de String
148 * selon leur longueur.
149 */
150 c l a s s CompStringLongueur implements Comparator {
151 p u b l i c i n t compare ( Object o1 , Object o2 ) {
152 r e t u r n (( String ) o1 ). length () - (( String ) o2 ). length ();
153 }
154 }

Le fichier sortie_TestCollectionsFramework
1 c1: [36, aaaaa, 3.141592653589793]
2 c2: [36, aaaaa, 3.141592653589793, 36, 36]
86 CHAPITRE 14. LA BIBLIOTHÈQUE COLLECTIONS FRAMEWORK DE JAVA

3 c3: [aaaaa, 3.141592653589793, 36, 36]


4 c4: [aaaaa, 3.141592653589793, 36]
5 c5:------ aaaaa
6 c5:------ 3.141592653589793
7 c5:------ 36
8 m1: {1=36, 2=aaaaa, 3=3.141592653589793, 10=aaaaa}
9 m2: 1
10 m3: 10
11 m4: [36, aaaaa, 3.141592653589793, aaaaa]
12 m5: {1=36, 2=aaaaa, 3=36, 10=aaaaa}
13 m6: [36, aaaaa, 36, aaaaa]
14 l1: []
15 l2: [36, aaaaa, 3.141592653589793, null, null, aaaaa]
16 l3: [36, aaaaa, true, null, null, aaaaa]
17 l4: [36, aaaaa, 1000.0, true, null, null, aaaaa]
18 ll1: [France, Royaume-Uni, Italie, Allemagne, Luxembourg, Suisse, Espagne]
19 ll2: [Allemagne, Espagne, France, Italie, Luxembourg, Royaume-Uni, Suisse]
20 ll3: [France, Italie, Suisse, Espagne, Allemagne, Luxembourg, Royaume-Uni]
21 ll4: [Royaume-Uni, Luxembourg, Allemagne, Espagne, France, Italie, Suisse]
Chapitre 15

Références

15.1 Livres
Les ouvrages qui traitent d’algorithmique et de structures de données sont légions. Voici quelques bonnes références. Les
algorithmes et les exemples présentés dans ces notes proviennent essentiellement du [Cormen].

Cori & Levy


R. Cori, J.-J. Levy,
Algorithmes et Programmation,
École Polytechnique,
http://w3.edu.polytechnique.fr/informatique/TC/polycopie-1.6
Cormen
T. Cormen, C. Leiserson, R. Rivest,
Introduction à l’algorithmique,
Dunod, 1994.
EU
article «Algorithmique» de l’Encyclopædia Universalis.
Froidevaux & Gaudel
C. Froidevaux, M.-C. Gaudel, M. Soria,
Types de données et algorithmes,
McGraw-Hill, 1990.
Gondran & Minoux
M. Gondran, M. Minoux,
Graphes et algorithmes,
Eyrolles, 1985.
Sedgewick
R. Sedgewick,
Algorithms in C++ : Fundamentals, Data Structures, Sorting, Searching,
Addison-Wesley, 1998.
Skiena
S. Skiena,
The Algorithm Design Manual,
Telos, 1997.
Weiss
M. A. Weiss,
Data Structures and Algorithm Analysis in Java,
Addison-Wesley, 1999.

87
88 CHAPITRE 15. RÉFÉRENCES

15.2 Toile
Le collections framework de Java : http://java.sun.com/j2se/1.3/docs

La bibliothèque LEDA : http://www.mpi-sb.mpg.de/LEDA/leda.html

GTL : Graph Template Library : http://infosun.fmi.uni-passau.de/GTL/

Vous aimerez peut-être aussi