Vous êtes sur la page 1sur 197

Table des matières

1 Introduction 7
1.1 A qui s’adresse ce cours . . . . . . . . . . . . . . . . . . . . . . . 7
1.2 Pré-requis . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7
1.3 Connaissances et compétences des étudiants au terme de ce cours 7
1.4 Méthodologie . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8
1.5 Références . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8
1.6 Note spéciale . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8
1.7 Auteur de ces notes de cours . . . . . . . . . . . . . . . . . . . . 8
1.8 Passage au système LMD . . . . . . . . . . . . . . . . . . . . . . 9
1.9 Introduction générale . . . . . . . . . . . . . . . . . . . . . . . . . 10

2 Rappels sur la programmation et le langage Python 17


2.1 Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 17
2.1.1 L’interpréteur Python . . . . . . . . . . . . . . . . . . . . 17
2.2 Les objets en Python . . . . . . . . . . . . . . . . . . . . . . . . . 18
2.2.1 Variables, Objets et Affectations . . . . . . . . . . . . . . 18
2.2.2 Création et utilisation des objets . . . . . . . . . . . . . . 19
2.2.3 Les classes internes de Python . . . . . . . . . . . . . . . 20
2.3 Expressions et opérateurs . . . . . . . . . . . . . . . . . . . . . . 23
2.3.1 Expressions composées et priorités des opérateurs . . . . . 26
2.4 Les structures de contrôle . . . . . . . . . . . . . . . . . . . . . . 27
2.4.1 L’instruction if : structure conditionnelle par excellence . 27
2.4.2 Les boucles . . . . . . . . . . . . . . . . . . . . . . . . . . 28
2.5 Les fonctions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 31
2.5.1 Le passage des arguments . . . . . . . . . . . . . . . . . . 32
2.5.2 Les fonctions internes de Python . . . . . . . . . . . . . . 34
2.6 Les entrées et sorties simples . . . . . . . . . . . . . . . . . . . . 36
2.6.1 La console en entrée et sortie . . . . . . . . . . . . . . . . 36
2.6.2 Les fichiers . . . . . . . . . . . . . . . . . . . . . . . . . . 37
2.7 La gestion des Exceptions . . . . . . . . . . . . . . . . . . . . . . 39
2.7.1 Générer une exception . . . . . . . . . . . . . . . . . . . . 40
2.7.2 Saisir une exception . . . . . . . . . . . . . . . . . . . . . 41
2.8 Iterateur et Générateur . . . . . . . . . . . . . . . . . . . . . . . 41
2.8.1 Générateur . . . . . . . . . . . . . . . . . . . . . . . . . . 42

1
2 TABLE DES MATIÈRES

2.9 Quelques autres fonctionalités de Python . . . . . . . . . . . . . 43


2.9.1 Expressions conditionelles . . . . . . . . . . . . . . . . . . 43
2.9.2 Comprehension Syntax . . . . . . . . . . . . . . . . . . . . 43
2.9.3 Emballage et Déballage des séquences . . . . . . . . . . . 44
2.10 Portées et espace des noms . . . . . . . . . . . . . . . . . . . . . 45
2.10.1 Objets de première classe . . . . . . . . . . . . . . . . . . 45
2.11 Modules et instructions d’importation de code . . . . . . . . . . . 46
2.11.1 Modules existants . . . . . . . . . . . . . . . . . . . . . . 47
2.12 Programmation Orientée Objet en Python . . . . . . . . . . . . . 48
2.12.1 Objectifs, Principes et Patterns . . . . . . . . . . . . . . . 48
2.12.2 Développement des logiciels . . . . . . . . . . . . . . . . . 52
2.12.3 Définition des classes . . . . . . . . . . . . . . . . . . . . . 57
2.12.4 Héritage . . . . . . . . . . . . . . . . . . . . . . . . . . . . 65
2.12.5 Espace des noms . . . . . . . . . . . . . . . . . . . . . . . 69
2.12.6 Copie superficielle et profonde . . . . . . . . . . . . . . . 71

3 Introduction à l’algorithmique 75
3.1 Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 75
3.2 Les fondements mathématiques de l’algorithmique . . . . . . . . 76
3.2.1 La fonction constante . . . . . . . . . . . . . . . . . . . . 77
3.2.2 La fonction logarithme . . . . . . . . . . . . . . . . . . . . 78
3.2.3 La fonction linéaire . . . . . . . . . . . . . . . . . . . . . . 78
3.2.4 La fonction n log2 n . . . . . . . . . . . . . . . . . . . . . . 78
3.2.5 La fonction quadratique . . . . . . . . . . . . . . . . . . . 79
3.2.6 La fonction cubique et les autres polynomes . . . . . . . . 79
3.2.7 La fonction exponentielle . . . . . . . . . . . . . . . . . . 80
3.2.8 Quelques sommations . . . . . . . . . . . . . . . . . . . . 80
3.3 Efficacité d’un algorithme . . . . . . . . . . . . . . . . . . . . . . 81
3.4 Algorithmes et autres technologies . . . . . . . . . . . . . . . . . 82
3.5 Conception et analyse des algorithmes . . . . . . . . . . . . . . . 82
3.5.1 Introduction . . . . . . . . . . . . . . . . . . . . . . . . . 82
3.5.2 Conception d’un algorithme . . . . . . . . . . . . . . . . . 83
3.5.3 Analyse expérimentale . . . . . . . . . . . . . . . . . . . . 86
3.5.4 Analyse théorique . . . . . . . . . . . . . . . . . . . . . . 88
3.5.5 Notation assymptotique . . . . . . . . . . . . . . . . . . . 94

4 La Récursivité 99
4.1 Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 99
4.2 Quelques exemples illustrant la récursivité . . . . . . . . . . . . . 99
4.2.1 La fonction factorielle . . . . . . . . . . . . . . . . . . . . 99
4.2.2 La recherche binaire . . . . . . . . . . . . . . . . . . . . . 100
4.2.3 Le système de fichiers . . . . . . . . . . . . . . . . . . . . 103
4.3 Analyse d’un algorithme récursif . . . . . . . . . . . . . . . . . . 105
4.3.1 Calcul du temps d’exécution de factoriel(n) . . . . . . . . 105
4.3.2 Calcul du temps d’exécution de la recherche binaire . . . 106
TABLE DES MATIÈRES 3

4.3.3 Calcul du temps d’exécution de l’évaluation de l’espace


disque occupé par un répertoire donné . . . . . . . . . . . 107
4.4 Quelques autres exemples de récursivité . . . . . . . . . . . . . . 107
4.4.1 Introduction . . . . . . . . . . . . . . . . . . . . . . . . . 107
4.4.2 Récursivité linéaire . . . . . . . . . . . . . . . . . . . . . . 107
4.4.3 Récursivité binaire . . . . . . . . . . . . . . . . . . . . . . 109
4.4.4 Récursivité multiple . . . . . . . . . . . . . . . . . . . . . 110

5 Les Séquences Basées sur les Tableaux 111


5.1 Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 111
5.1.1 Comportements publics . . . . . . . . . . . . . . . . . . . 111
5.1.2 Détails d’implémentation . . . . . . . . . . . . . . . . . . 112
5.1.3 Analyses asymptotiques et expérimentales . . . . . . . . . 112
5.2 Les tableaux de bas-niveau . . . . . . . . . . . . . . . . . . . . . 113
5.2.1 Tableau de références . . . . . . . . . . . . . . . . . . . . 114
5.2.2 Tableaux compacts en Python . . . . . . . . . . . . . . . 118
5.3 Les tableaux dynamiques et amortissement . . . . . . . . . . . . 121
5.3.1 Introduction . . . . . . . . . . . . . . . . . . . . . . . . . 121
5.3.2 Implémentation d’un tableau dynamique . . . . . . . . . . 124
5.3.3 Analyse amortie des tableaux dynamiques . . . . . . . . . 126
5.3.4 La classe list de Python . . . . . . . . . . . . . . . . . . . 126
5.4 L’efficacité des séquences en Python . . . . . . . . . . . . . . . . 127
5.4.1 Introduction . . . . . . . . . . . . . . . . . . . . . . . . . 127
5.4.2 Les classes list et tuple de Python . . . . . . . . . . . . . 127

6 Les Piles, les Files d’Attente et


les Files d’Attente Prioritaires 129
6.1 Les piles . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 129
6.1.1 Le type de données abstrait pile ; Abstract Data Type
(ADT) pile . . . . . . . . . . . . . . . . . . . . . . . . . . 130
6.1.2 Implémentation d’une pile basée sur un tableau . . . . . . 131
6.1.3 Inverser les données à l’aide d’une pile . . . . . . . . . . . 134
6.1.4 Correspondance entre des parenthèses ou entre des balises
HTML dans un texte . . . . . . . . . . . . . . . . . . . . 135
6.2 Les files d’attente . . . . . . . . . . . . . . . . . . . . . . . . . . . 138
6.2.1 Le type de données abstrait de file d’attente ; Abstract
Data Type (ADT) Queue . . . . . . . . . . . . . . . . . . 138
6.2.2 Implémentation d’une file d’attente basée sur un tableau . 140
6.2.3 Utiliser un tableau de manière circulaire . . . . . . . . . . 141
6.2.4 Une implémentation de la file d’attente Python . . . . . . 142
6.3 Files d’attente à deux extrémités . . . . . . . . . . . . . . . . . . 143
6.3.1 Le type de données abstrait Deque ; Abstract Data Type
(ADT) deque . . . . . . . . . . . . . . . . . . . . . . . . . 144
4 TABLE DES MATIÈRES

7 Les listes chainées 147


7.1 Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 147
7.2 Les listes chainées simples . . . . . . . . . . . . . . . . . . . . . . 148
7.2.1 Insertion d’un élément en tête d’une liste chaı̂née simple . 149
7.2.2 Insertion d’un élément à la queue d’une liste chaı̂née simple150
7.2.3 Implémentation d’une pile stockant ses éléments dans une
liste chaı̂née simple . . . . . . . . . . . . . . . . . . . . . . 152
7.2.4 Implémentation d’une file d’attente avec une liste chaı̂née
simple . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 155
7.3 Les listes chainées circulaires . . . . . . . . . . . . . . . . . . . . 157
7.3.1 Implémentation d’une file d’attente avec une liste chaı̂née
circulaire . . . . . . . . . . . . . . . . . . . . . . . . . . . 158
7.4 Liste doublement chaı̂née . . . . . . . . . . . . . . . . . . . . . . 160
7.4.1 Implémentation de base d’une liste doublement
chaı̂née . . . . . . . . . . . . . . . . . . . . . . . . . . . . 161
7.4.2 Implémentation d’un Deque avec une liste doublement
chaı̂née . . . . . . . . . . . . . . . . . . . . . . . . . . . . 165
7.5 La liste positionnelle ADT . . . . . . . . . . . . . . . . . . . . . . 166
7.5.1 L’ADT liste positionnelle (ADT Positional List) . . . . . 169
7.5.2 Implémentation de la liste doublement chaı̂née . . . . . . 170
7.6 Comparaisons entre les séquences basées sur les liens et celles
basées sur les tableaux . . . . . . . . . . . . . . . . . . . . . . . . 174

8 Les arbres 177


8.1 Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 177
8.2 Définition et proriétés d’un arbre . . . . . . . . . . . . . . . . . . 177
8.3 Autres relations entre les noeuds d’un arbre . . . . . . . . . . . . 178
8.4 Bords et chemins dans un arbre . . . . . . . . . . . . . . . . . . . 179
8.5 Arbre Ordoné . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 179
8.6 L’arbre comme type de donnée abstrait . . . . . . . . . . . . . . 180
8.7 Implémentation d’un arbre . . . . . . . . . . . . . . . . . . . . . 181

9 Les Graphes 183


9.1 Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 183
9.2 Généralités . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 186
9.3 Graphes orienté . . . . . . . . . . . . . . . . . . . . . . . . . . . . 188
9.4 Représentation des graphes . . . . . . . . . . . . . . . . . . . . . 189
9.4.1 Représentation par listes d’adjacences . . . . . . . . . . . 189
9.4.2 Repésentation par matrice d’adjacences . . . . . . . . . . 190
9.5 Arbre couvrant de poids minimal . . . . . . . . . . . . . . . . . . 190
9.5.1 Construction d’un arbre couvrant de poids minimal . . . 191
9.5.2 Algorithme de Torjan . . . . . . . . . . . . . . . . . . . . 191
9.5.3 Algorithme de Kruskal . . . . . . . . . . . . . . . . . . . . 192
9.5.4 Algorithme de Prim . . . . . . . . . . . . . . . . . . . . . 192
9.6 Plus court chemin entre deux sommets . . . . . . . . . . . . . . . 195
9.6.1 Variantes du problème des plus courts chemins . . . . . . 195
TABLE DES MATIÈRES 5

10 Conclusions et Perspectives 197


6 TABLE DES MATIÈRES
Chapitre 1

Introduction

1.1 A qui s’adresse ce cours


Ce cours s’adresse aux étudiants de deuxième graduat polytechnique de l’uni-
versité de Kinshasa.

1.2 Pré-requis
Les étudiants sont censés avoir suivi un cours de programmation. Ils ont
donc des connaissances élémentaires en ce qui concerne la programmation. Ils
ont également été préparés à suivre un cours d’algorithmique. Ils devraient dans
la mesure du possible avoir suivis une introduction au langage de programmation
Python.

1.3 Connaissances et compétences des étudiants


au terme de ce cours
Au terme de ce cours, les étudiants connaissent les idiomes de la plupart
des langages de programmation, en l’occurence les boucles, les structures de
contrôle, la récursivité. Ils savent raisonner devant un problème de programma-
tion. Ils connaissent la notation assymptotique. Ils savent évaluer la complexité
d’un algorithme et exprimer son temps d’exécution en notation assymptotique.
Ils savent mesurer expérimentalement le temps d’exécution d’un algorithme. Ils
connaissent des techniques de conception des algorithmes telles que l’usage de
la force brute, la technique du diviser pour regner, l’approche gloutonne, l’ap-
proche par programmation dynamique. Ils ont pratiqué l’évaluation de la com-
plexité des algorithmes sur quelques structures de données. Ils ont reçus une
introduction aux structures de données abstraites. Ils savent ce qu’est un ”Abs-
tract Data Type” (ADT ; en français une structure de données abstraite). Ils
connaissent l’importance de ces structures des données dans les projets logiciels.

7
8 CHAPITRE 1. INTRODUCTION

Ils connaissent les structures des données les plus populaires et probablement les
plus utilisées. Il s’agit entre autre : des tableaux, des listes chainées, des piles,
des files d’attente, des arbres et des graphes. Pour chacune de ces structures
de données ils savent produire deux ou trois implémentations en Python. Le
cours se termine par la réalisation de deux projets dans lesquels les structures
de données étudiées au cours sont mises en oeuvre dans des problèmes réels.

1.4 Méthodologie
Pour atteindre ces objectifs, nous pronnons et pratiquons l’enseignement
par la pratique. Une partie du cours théorique est consacrée à la maitrise des
concepts de base par la pratique. En bref, la maitrise des notions fondemen-
tales d’algorithmique et de programmation par la pratique commence au cours
théorique pour s’intensifier aux travaux pratiques.

1.5 Références
[1] T.H. Cormen, C.E. Leiserson, R.L. Rivest, C. Stein, ”Algorithmique. Cours
avec 957 exercices et 158 problèmes.”, Dunod, 3ième édition, Juin 2010.

[2] M.T. Goodrich, R. Tamassia, Michael H. Goldwasser, ”Data Structures &


Algorithms in Python”, Jhon Wiley & Sons Inc, United States of America, 2013.

1.6 Note spéciale


Ces notes sont un brouillon des notes de cours que nous développons depuis
quelques mois. Si en termes de contenu, les choses sont plus ou moins au point,
la forme et particulièrement la présentation des figures n’est pas encore celle
souhaitée. Nous avons quand même distribué les notes aux étudiants, question
de leur offrir un support.

1.7 Auteur de ces notes de cours


Paul Olamba Kalonda a reçu sa licence en Physique en 1985 au département
de physique de l’université de Kinshasa. Il obtiendra sa thèse de doctorat en
Physique atomique et moléculaire en Septembre 1995 à l’université catholique
de Louvain. Après avoir presté trois ans au département de physique comme
assistant de recherche, Paul Olamba rejoint en 1998 Alcatel Bell pour travailler
dans les télécommunications et l’informatique. C’est au cours de cette période
qu’il décide de formaliser ses nombreuses connaissances en informatique et dans
les télécommunications. Il obtient en 1998 à l’ULB en horaire décalé le diplome
d’études spécialisées en télématique et organisation. Entre 2001 et 2002 il suit
1.8. PASSAGE AU SYSTÈME LMD 9

les post-doc en télécommunications et en Informatique organisés par Alcatel


University, les universités flamendes et un consortium d’entreprises belges du
secteur des télécommunications et de l’Informatique. En 2004, il rejoint l’Intsti-
tut d’Aéronomie Spatiale de Belgique ou il travaillera comme ”Sofftware Engi-
neer”. Depuis 2011 il enseigne l’informatique au département de génie électrique
et informatique de la faculté polytechnique de l’université de Kinshasa et fait
de la recherche en algorithmique et plus particulièrement dans le domaine de
l’optimisation des réseaux.

1.8 Passage au système LMD

L’année académique 2023-2024 est la deuxième année de l’application du


système “Licence Master Doctorat (LMD)” à la faculté polytechnique. Nous
allons donc appliquer la pédagogie de la classe inversée dans le cadre du cours
d’algorithmique et programmation. Il n’y a pas de modèle universel pour la
mise en oeuvre de la pédagogie de la classe inversée. En ce qui nous concerne
nous avons diviser le cours en 8 modules (à ne pas confondre avec l’entendement
du mot module en Python). Ces modules donnent en fait la répartition de la
matière en cours de 4 heures. Chaque cours de 4 heures se passe en deux étapes.
Une première étape au cours de laquelle les étudiants se familiarisent avec la
matière du cours par des vidéos, des audios, des tutoriaux, et des notes de cours.
Cela se passe en deux heures. Ensuite le Professeur, l’Assistant, et les Etudiants
abordent les problèmes rencontrés par les étudiants au cours de deux premières
heures. Cela se passe en deux heures. Enfin, suivent 4 heures d’exercices sur
cette matière. Ici les étudiants travaillent seuls sur les exercices, cela prend
deux heures. Enfin, ils trvaillent avec l’Assistant sur les exercices, cela prend
également 2 heures. Le tableau ci-dessus donne l’organisation des chapitres du
cours en modules.
10 CHAPITRE 1. INTRODUCTION

Nom du module Chapitres du cours Quelques liens


appartenant au module
Introduction Générale Chapitres 1. Introduction Quelques liens
MODULE I
Rappels de Python I Chapitres 2. Rappels sur la programmation Quelques liens
et le langage Python (sections 2.1 à 2.10
MODULE II
Rappels de Python II Chapitres 2. Rappels sur la programmation Quelques liens
et le langage Python (section 2.11)
MODULE III
Algorithmique I Chapitres 3. Introduction à l’algorithmique Quelques liens
MODULE IV
Algorithmique II et Chapitres 4. Récursivité Quelques liens
MODULE V
Structures des données I Chapitres 5. Les Séquences Quelques liens
basées sur le tableaux
MODULE VI
Structures des données II Chapitre 6. Les Piles, les Files d’attente Quelques liens
et les files d’attentes prioritaires
MODULE VII
Structures des données III Chapitres 7. Les listes chainées Quelques liens
MODULE VIII
Structures des données IV Chapitres 8. Les arbres Quelques liens
Révision Générale Scéance de questions réponses Quelques liens
sur l’ensemble du cours

1.9 Introduction générale


Un système informatisé est un ensemble d’ordinateurs d’origine et de puis-
sance diverses, reliés entre eux par des réseaux locaux (réseaux intra-entreprises)
et des réseaux distants (réseaux inter-entreprises), de périphériques très divers
(batteries, radars, robots, ...), qui reçcoivent de l’information de leurs environ-
nements et en restitue. Des tels systèmes opèrent sous le contrôle des machines,
ou des ressources humaines qualifiées. On distingue dans un tel système :

ˆ une partie matérielle (Hardware : ordinateurs ; modems ; commutateurs ;


capteurs, ...) dont le rôle est de fournir la puissance brute de traitement
et de relier le système au monde extérieur ;

ˆ et une partie logicielle (software) qui assure les fonctions logiques nécessaires
aux différents traitements et au stockage de l’information.

Pour résoudre un problème par la magie de l’informatique il faut d’une part


disposer du matériel adéquat et des logiciels associés et d’autre part combi-
1.9. INTRODUCTION GÉNÉRALE 11

Figure 1.1 – Types de systèmes d’information


12 CHAPITRE 1. INTRODUCTION

Figure 1.2 – Un exemple de système d’information.

ner savamment ces deux éléments et les exploiter par les bons soins des ma-
chines appropriés, ou des ressources humaines ayant les compétences requises.
Par exemple pour pouvoir confectionner un livre (Je parle ici de la saisie des
informations et de leur mise en forme), il faut disposer d’un ordinateur et d’un
programme de traitement de texte (MS word de Microsoft pour le commun des
mortels ou LaTeX pour des personnes un petit peu mieux outillées en matière
de connaissances en informatique) et d’un secrétaire qualifié.

Dans le cas ou l’on dispose d’un ordinateur et de MSWord, il faut allumer


l’ordinateur, processus qui démarre automatiquement l’exécution du système
d’exploitation (qui ici pourrait être Window 2000, Window XP, Window 2007,
linux, etc.) Puis à partir du système d’exploitation lancer MSWord soit en clic-
quant sur une icone, soit en lancant une commande selon que l’on travaille sur
un système d’exploitation de la famille Window ou linux. C’est après ces actions
qu’il devient possible de pratiquer du traitement de texte.

Windows 2000, Windows XP, Windows 2010, linux, MSWord, LaTeX sont des
logiciels. Ils ont été conçus, mis au point et distribués par des acteurs du secteur
logiciel de l’industrie informatique. Windows 2000, Windows XP, Windows 2010
et linux sont des systèmes d’exploitation alors que MSWord et LaTeX sont des
logiciels d’application.

Il convient de signaler ici qu’ au fil des années, les logiciels ont évolués dans
plusieurs directions :

ˆ les systèmes d’exploitation. Ce sont les logiciels à la base de toute exploi-


tation de l’ordinateur. Un sysème d’exploitation coordonne l’ensemble de
tâches essentielles à la bonne marche du complexe matériel et assure la
1.9. INTRODUCTION GÉNÉRALE 13

Figure 1.3 – Les différentes couches de logiciels actives dans un ordinateur


moderne.

gestion des ressources. Il facilite aussi le travail de l’utilisateur en se char-


geant de toutes les tâches fastidieuses ou compliquées, comme le contrôle
des périphériques ou le stockage et la gestion des fichiers ;

ˆ les logiciels utilitaires. Ce sont des logiciels qui aident à développer les
applications (logiciels d’application) ; ce sont les compilateurs, les in-
terpréteurs, les assembleurs, les éditeurs des liens ; les chargeurs et les
débogueurs. Ils comprennent aussi d’autres outils tels que des outils gra-
phiques, des outils de communication, etc ;

ˆ les logiciels d’application ( applications). Ce sont les logiciels qui servent à


résoudre des problèmes spécifiques. Ils peuvent être écrits par l’utilisateur
ou achetés sur le marché.

Comme vous le devinez déjà, mettre au point un logiciel n’est pas chose aisée.
Pour preuve toute une discipline de l’informatique s’attelle à cela : c’est le génie
logiciel. Sans entrer dans les détails le processus de mise au point d’un logiciel
peut être décomposé en 7 étapes :

ˆ la définition des objectifs du logiciel. Cette phase initiale du développement


logiciel donne une description et une évaluation globale des besoins que
le logiciel est censé satisfaire ;
14 CHAPITRE 1. INTRODUCTION

ˆ l’expression des besoins. C’est la phase ou l’on décrit les fonctions que le
logiciel doit effectuer, les conditions d’exploitation, le contrat de service,
la qualité requise, en faisant abstraction le plus possible de la façon dont
ces différentes fonctions vont être effectivement réalisées. On considère
le logiciel comme une boite noire dont on ne connait que les entrées et
les sorties mais que l’on veut voir se comporter d’une certaine façon en
termes de fonctionalités, de performances (consomation de ressources,
temps de réponses, débit d’informations) et de sûreté de fonctionnement
(disponibilité du système, sécurité) ;
ˆ la conception. Cette phase a comme objectif de définir de façon très
précise les fonctions et l’architecture du logiciel, à partir des besoins ex-
primés et des contraintes générales définies dans les phases précédentes. A
l’issue de cette phase, tous les choix techniques ont été effectués, les fonc-
tions sont spécifiées, les regroupements en modules sont connus, les algo-
rithmes à mettre en oeuvre sont identifiés et caractérisés de façon quan-
titative (temps de réponse, ressources consommées), les données essen-
tielles sont répertoriées et les évenèments qui déclenchent le séquencement
des opérations explicités ;
ˆ la programmation et les tests unitaires. Cette phase correspond à la pro-
grammation proprement dite des fonctions sur la base des informations
précises venant de la phase de conception. Les fonctions sont traduites
dans le ou les langages de programmation qui ont été adoptés, en respec-
tant les normes de qualité définies dans le plan de qualité du logiciel. A
l’issue de cette phase, tous les modules doivent compiler sans erreur ;
ˆ l’intégration et les tests de qualification. Cette phase correspond au re-
groupement progressif de tous les modules de façon à garantir la vérification
et la validation progressive du logiciel, jusqu’à pouvoir le faire fonction-
ner dans son environnement réel. A l’issue de cette phase on doit être
en mesure de s’assurer par l’exécution du logiciel et à l’aide de mesures
appropriées, que le logiciel satisfait aux besoins spécifiés au moment de
l’expression des besoins ;
ˆ l’installation. Cette phase correspond à la mise en fonctionnement opérationnel
du logiciel dans le contexte du client ;
ˆ l’exploitation et la maintenance. Cette phase correspond à la mise à la
disposition des utilisateurs du logiciel et ceci sans restriction.

Lors du développement des projets logiciels on fait la différence entre petits


projets et grands projets. Un petit projet n’a pas besoin de suivre toutes les
étapes ci-dessus énumérées. Par contre un grand projet exige d’être développé
dans le respect des techniques et pratiques mises au point par le génie logiciel.

L’algorithmique et la programmation dont il est question dans ce cours ap-


paraissent en phases 3(pour l’algorithmique) et 4( pour la programmation).
1.9. INTRODUCTION GÉNÉRALE 15

Figure 1.4 – Développement d’un projet logiciel, modèle en V.

Un algorithme est une procédure de calcul bien définie qui prend en entrée
une valeur, ou un ensemble de valeurs, et qui donne en sortie une valeur, ou un
ensemble de valeurs. Un algorithme est donc une séquence d’étapes de calcul
qui transforment une entrée en une sortie.

L’on peut aussi considérer un algorithme comme un outil permettant de


résoudre un problème de calcul bien spécifié. L’enoncé du problème spécifie, en
termes généraux, la relation entre l’entrée et la sortie. L’algorithme décrit une
procédure de calcul spécifique permettant d’obtenir cette relation entrée/Sortie.

L’algorithmique est donc la partie de la science informatique qui s’intéresse


aux algorithmes.

La programmation est quant à elle l’ensemble des activités ayant trait à la


mise au point de programmes. Programmer c’est agencer les données et les
instructions en vue de la résolution d’un problème.

Ce cours qui a pour objectifs l’introduction des concepts de base de la pro-


grammation et ceux de l’algorithmique se divise naturellement en deux parties :
la programmation et l’algorithmique. Les concepts de base de la programmation
ayant été abordés en premier graduat, la partie programmation de ce cours se
bornera à examiner les implémentations de ces concepts dans le langage de pro-
grammation Python. Python est le langage de programmation qui sera utilisé
pour illustrer les concepts de programmation et d’algorithmique abordés dans
ce cours. Le cours est structuré en dix chapitres. Le premier chapitre consiste en
une introduction générale du cours. Le deuxième chapitre rappelle les concepts
16 CHAPITRE 1. INTRODUCTION

fondamentaux de la programmation en Python. Le troisième chapitre introduit


l’algorithmique. Le quatrième chapitre traite du concept de la récursivité en al-
gorithmique. Le cinquième chapitre traite des séquences d’éléments basés sur les
tableaux en Python et de leurs utilisation en algorithmique. Le sixième chapitre
introduit les structures des données fondamentales en l’occurence les piles et les
files d’attentes. Le septième chapitre étudie les listes chaı̂nées et leur exploitation
dans la mise au point des structures de données. Le huitième chapitre aborde
les notions de base sur les arbres. Le neuvième chapitre présente brièvement
les graphes. Enfin, le dixième chapitre rassemble des remarques générales sur la
structuration, le contenu du cours et ses éventuelles évolutions.
Chapitre 2

Rappels sur la
programmation et le
langage Python

2.1 Introduction
Le langage Python a été développé au début des années 1990s par Guido
van Rossum. Il est devenu depuis un des langages de programmation les plus
utilisés par l’industrie et le monde de l’éducation. Python 2 a été délivré en
2000. La troisième version de Python a été délivrée quand à elle en 2008. Il faut
noté qu’il y a des fortes incompatibilités entre Python 2 et Python 3. Ces notes
de cours sont basées sur Python 3 et les versions utltérieures (la plus récente
version de Python au moment de la rédaction de ces notes est Python 3.9.0 ;
la version d’Octobre 2020). Il est possible de télécharger cette dernière version
ainsi que sa documentation sur www.python.org.
Dans ces notes nous supposons que les étudiants ont eu un premier contact
avec Python. Ces notes ne donnent donc pas une description complète de Py-
thon. Nous nous attachons plutôt à introduire tous les aspects de Python qui
apparaissent dans les illustrations de ce cours ”d’Algorithmique et Programma-
tion”.

2.1.1 L’interpréteur Python


Python est un langage interpreté. L’interpréteur reçoit une intruction, en
vérifie la conformité vis-à-vis des règles du langage, l’exécute et donne le résultat.
Dans la pratique le programmeur place une série d’instructions dans un fichier
texte que l’on appele le programme source. Pour Python, un programme source
est stocké dans un fichier ayant comme extention le suffixe ”.py” (exemple
prog1.py).

17
18CHAPITRE 2. RAPPELS SUR LA PROGRAMMATION ET LE LANGAGE PYTHON

Dans la plupart des systèmes d’exploitation l’interpréteur Python peut être


démarer en tapant la commande python à partir de la ligne de commande.
Par défaut l’interpréteur démarre en mode interactif avec un espace de tra-
vail (workspace) vide. Les instructions contenues dans un programme (prog1.py
par exemple) sont exécutées en invoquant l’interpréteur avec le nom du fichier
comme argument (exemple : python prog1.py ou python -i prog1.py).
Plusieurs environnements intégrés de developpement permettent de développer
dans un certain confort des programmes en Python. Je citerai IDLE, Py-
Charm, SublimText, VIM, etc.

2.2 Les objets en Python


Python est un langage orienté objet et les classes forment la base de tous les
types de données. Dans cette section, nous donnons les concepts clés du modèle
objet de Python et nous présentons les classes internes de Python.

2.2.1 Variables, Objets et Affectations


L’instruction la plus importante en Python est l’affectation. Lorsque l’on
écrit le bout de code suivant :

temperature = 98.6

temperature est ici une variable. A cette variable est associée l’objet située du
coté droit du signe égal. Ici il s’agit d’un objet de type float dont la valeur est
98.6. En Python les variables respectent les majuscules et les miniscules. Ainsi,

Figure 2.1 – A la variable tempertaure est associée l’objet float dont la valeur
est 98.6

Temperture et temperature sont deux variables différentes. Les noms des


variables peuvent être formés par des combinaisons de lettres, des chiffres, des
underscore et plus de caratères dans le cas du code unicode. Un nom de variable
ne peut pas avoir comme premier caractère un chiffre. Exemple 9lettres n’est
pas un nom de variable valable. Il y a 33 mots réservés qui ne peuvent pas être
des noms de variables.
2.2. LES OBJETS EN PYTHON 19

Liste des mots réservés de Python


False as continue else from in not return yield
None assert def except global is or try
True break del finally if lambda pass while
and class elif for import nonlocal raise with

Chaque variable en Python est associée à l’adresse mémoire de l’objet auquel


il fait référence. Il est possible d’affecter à une variable Python l’objet spécial
None. Python est un langage typé. Son typage est dynamique. En fait il n’y
a pas de déclartion préalable de variables avec un type donné. Chaque variable
peut se voir affecter un objet d’un type quelcoque. Plus tard, on peut affecté à
la même variable un autre objet de type différent. Il faut retenir que même si
une variable n’a pas de type, l’objet auquel il pointe, l’objet qui lui est affecté
a un type.
Un programmeur peut créer un alias en affectant à une autre variable, un
objet sur lequel pointe déjà une première variable.

Figure 2.2 – Les variables tempertaure et original sont des alias. Ils font
références tous les deux à un même objet. Il s’agit ici d’un objet de type float
dont la valeur est 98.6

Les changements qui sont apportés à notre objet float par le biais de la
variable temperature sont visibles sur la variable original. Par contre si on
affecte à une des variables de l’alias un autre objet, cela n’a aucun effet sur
l’objet sur lequel pointe l’alias. Cette opération brise l’alias.

temperature = temperature+5.0

Dans l’expression ci-dessus une nouvelle valeur float a été affectée à la variable
temperature. Cela n’a aucun effet sur la variable original comme on peut le
voir sur la figure 3.3. Il y a juste que l’alias est brisé.

2.2.2 Création et utilisation des objets


Le processus de création d’une nouvelle instance d’une classe est appelé
instantiation. En général la syntaxe pour l’instatiation d’un objet est d’invoquer
le constructeur de la classe.

w = Widget()
d = Widget(a,b,c)
20CHAPITRE 2. RAPPELS SUR LA PROGRAMMATION ET LE LANGAGE PYTHON

Figure 2.3 – Une nouvelle valeur float a été affectée à la variable tempera-
ture. Cette opération n’affecte pas la valeur référencée par la variable original,
elle brise juste l’alias.

Dans la première instruction un objet de type Widget est crée, par un construc-
teur sans argument, puis affecté à la variable w. Dans la deuxième instruction,
le contructeur reçoit trois arguments pour créer un objet de type Widget qui
est ensuite affecter à la variable d.
Plusieurs classes internes de Python acceptent la création des nouvelles ins-
tances sous forme de littéral. Par exemple l’instruction temperature = 98.6
consiste en une création d’une nouvelle instance de type float, sous forme littéral
(le littéral ici est 98.6). Ensuite l’objet crée de cette manière est affecté à la va-
riable temperature.

Appels des méthodes

Python supporte les fonctions traditionnelles qui sont invoquées selon la


syntaxe sort(data), où data est un argument envoyé à la fonction. Les classes
Python peuvent aussi définir des fonctions qui sont appelées méthodes membres
ou fonctions membres de la classe. Les méthodes membres sont invoquées en
utilisant l’opérateur ”.”. Par exemple si data est une variable de type list,
alors data.sort() est un appel de la fonction sort() sur l’objet de type list
data. Elle a pour effet de trier les éléments de la liste. Il est important de
comprendre l’effet de chaque méthode de classe. Certaines méthodes de classes
retournent des informations à propos de l’état d’un objet, mais ne changent
pas son état. Ces méthodes sont appelées ”accesseurs”, ”getters”. Les méthodes
comme sort() changent l’état d’un objet. On les appellent ”mutateurs”, ”set-
ters”, ou ”méthodes de mise à jour”.

2.2.3 Les classes internes de Python


Le tableau ci-dessous reprends les classes internes de Python les plus popu-
laires. Il indique les classes ”modifiables” et ”non modifiables”. Une classe est
”non modifiable” si chaque objet de cette classe garde la même valeur après
instanciation. Une fois que l’on a affecté une valeur à un objet de type ”non
modifiable”, on ne peut plus changer cet objet.
2.2. LES OBJETS EN PYTHON 21

Classe Description non


modifiable
(oui ou Non)
bool Valeur booléenne oui
int Entier oui
float Nombre à virgule flottante oui
list une séquence d’objets Non
tuple Une séquence d’objets oui
str Une chaine de caractères oui
set Un ensemble non ordoné d’objets distincts Non
frozenset Un ensemble ”non modifiable” oui
dict Un mapping associatif Non

Liste des classes internes de Python


Nous donnons ci-dessous une introduction à ces classes.

La classe bool
Elle est utilisée pour manipuler des booléens. Il existe seulement deux ins-
tances de cette classe qui sont exprimées comme des littéraux True et False.
Le contructeur par défaut retourne False. Toutefois il n’y a pas lieu de re-
courir à cette syntaxe dans la mesure ou il suffit d’affecter True où False à
une variable pour avoir un objet de type booléen. Python permet la création
d’un objet booléen à partir d’un type non booléen. En utilisant la syntaxe
bool(foo), si foo = 0 l’expression vaut False, dans le cas contraire elle vaut
True. Les séquences et les autres conteneurs sont évalués à False s’ils sont vides
et True s’ils ne sont pas vides. Une importante application de cette situation
est l’utilisation des valeurs non booléennes comme condition dans les structures
de contrôle.

Les classe int et float


Les classes int et float sont les types numériques de base de Python. La
classe int représente les entiers. Python choisi directement la représentation
interne appropriée à la valeur de l’entier. Dans certaines situations, il est bon
d’exprimer une valeur entière en binaire, en octal ou en hexadécimal. Dans ce cas
on commence par 0 suivi d’une lettre qui indique la base. Exemple 0b1011 pour
un entier binaire, 0o52 pour un entier octal, et 0x7f pour un entier hexadécimal.
Le constructeur de la classe int, int() retourne la valeur 0 par défaut. Ce
constructeur peut également être utilisé pour construire un entier à partir d’une
valeur existante d’un autre type. Par exemple si f est une valeur à virgule
flottante alors int(f ) produit la valeur entière de f. Ainsi, int(3.14) et int(3.99)
représentent valeur entière 3, alors que int(-3.9) donne la valeur entière −3. Le
constructeur peut également être utilisé pour convertir une chaine de caractère
en un entier. int(s) produit la valeur entière représentée par s. Ainsi int(’137’)
produit la valeur entière 137. De même int(’7f ’, 16) évalue l’entier à 127.
22CHAPITRE 2. RAPPELS SUR LA PROGRAMMATION ET LE LANGAGE PYTHON

Le constructeur float() retourne 0.0. float(2) donne le flottant 2.0. float(’3.14’)


est une tentative de convertir une chaine de caratcère en un flottant. Cela donne
lieu à une erreur.

La classe list
Une instance liste stocke une séquence d’objets. Une liste stocke une séquence
de références (voir figure 2.4). Les éléments d’une liste sont des objets arbitraires
(y compris l’objet particulier None). Les listes sont des séquences basées sur

Figure 2.4 – Représentation interne d’une liste en Python. Cette liste a été
instanciée par l’instruction primes = [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31]. L’indice
implicite de chaque élément dans la liste est donnée en-dessous du tableau.

les tableaux. Les indices des éléments d’une liste commencent à 0. Ainsi les
indices des éléments d’une liste de n éléments vont de 0 à n-1. Python utilise
les caractères [ ] comme délimiteurs pour une liste des litéraux. Ainsi [’rouge’,
’vert’, ’bleu’] est une liste contenant trois instances de chaines de caractères. [
] est une liste vide. Le constructeur list(), produit une liste vide par défaut,
toutefois le constructeur accèpte n’importe quel paramètre d’un type itérable.

La classe tuple
La classe tuple fourni une version non modifiable d’une séquence. Les
parenthèses sont utilisées pour délimiter un tuple. (17, ) est un tuple à un
élément alors que (17) représente tout simplement l’entier 17 entre parenthèses.

La classe str
La classe str de Python permet de représenter une séquence non modifiable
de caractères basés sur l’Unicode. Une chaine de caractère peut être notée ’hello’
ou ”hello”. Part contre il faut obligatoirement utilisé le double quote quand le
simple quote est utilisé comme un simple caractère. Exemple ”Don’t worry”. Il
est néamoins possible d’utiliser le backslash pour se défaire du problème. Python
supporte aussi ””” ou ””” pour commencer et finir une chaine de caractères.
Cette façon de faire permet de générer automatiquement une ligne blanche
2.3. EXPRESSIONS ET OPÉRATEURS 23

Figure 2.5 – Représentation interne d’un objet de la classe str.

Les classes set et frozenset

La classe set de Python représente la notion d’ensemble mathématique, avec


possibilité de doublons parmi les éléments et sans aucun ordre entre les éléments.
Le principal avantage de set sur list est le fait que set dispose d’une méthode
très optimisée permettant de tester si un ensemble contient un élément ou non.
Seules les instances de types non modifiable peuvent être ajoutés à un en-
semble en Python. La classe frozenset est une forme non modifiable de set.
Python utilise les accolades { } pour délimiter les ensemble. Toutefois { } ne
représente pas un ensemble vide.

La classe dict

La classe dict de python représente un dictionnaire ou un mapping d’un en-


semble de clés à des valeurs associées. Par exemple un dictionnaire peut repre-
senter un mapping entre un numéro matricule d’étudiant et un enregistrement
des données relatives à cet étudiant (nom, adresse, cotes dans différents cours).
Python implémente le dictionnaire à peu près comme un ensemble. Dans le cas
du dictionnnaire, Python stocke les clés et les valeurs associées. Le construc-
teur dict accepte une séquence des paires clé-valeur comme argument. Exemple
dict(pairs) avec pairs = [(′ ga′ ,′ Irish′ ), (′ de′ ,′ German′ )].

2.3 Expressions et opérateurs


Dans la mise au point des programmes, des valeurs existantes peuvent être
combinées dans des expressions plus complexes qui font recours à une variété
de symboles et des opérateurs. La sématique d’un opérateur dépend de ses
opérandes. Par exemple dans a+b, le symbole + signifie addition si a et b sont
des nombres, par contre si a et b sont des chaines de caractères le + signifie
alors la concaténation. Dans cette section nous allons décrire les opérateurs de
Python.

Opérateurs logiques

Les opérateurs logiques de Python sont :


24CHAPITRE 2. RAPPELS SUR LA PROGRAMMATION ET LE LANGAGE PYTHON

Opérateur Description
not opérateur unaire de négation
and et logique
or ou logique
Les opérateurs and et or sont des opérateurs court-circuit dans la mesure où
ils n’évaluent pas la seconde opérande si le résultat de l’opération peut être
déterminer par la première opérande.

Opérateurs d’égalité
Python supporte les opérateurs d’égalité suivants :
Opérateur Description
is True si les opérandes ont la même identité
is not True si les opérandes n’ont pas la même identité
== True si les opérandes sont équivalents
!= True si les opérandes ne sont pas équivalents
L’espression a is b est évaluée à True si a et b sont les alias d’un même objet.
L’expression a == b teste une notion plus générale, celle de l’équivalence. Si a et
b font reférence à un même objet, alors a == b est True. a == b est également
True si a et b font référence à des objets différents mais dont les valeurs sont
les mêmes. La notion précise d’équivalence dépend du type de la donnée. Par
exemple deux chaines de caractères sont équivalentes si elles sont les mêmes
caractère par caractère. Deux ensembles sont équivalents s’ils contiennent les
mêmes éléments.

Opérateurs de comparaison
Les structures des données définissent des relations d’ordre à partir des
opérateurs suivants :
Opérateur Description
< plus petit que
<= plus petit ou égal
> plus grand que
>= plus grand ou égal

Opérateurs Arithmétiques
Python supporte les opérateurs arithmétiques suivants :
Opérateur Description
+ addition
- soustraction
* multiplication
/ simple division
// division entière
% modulo
2.3. EXPRESSIONS ET OPÉRATEURS 25

Si dans une expression faisant intervenir l’addition, la soustraction ou la mul-


tiplication une des opérandes est de type float alors le résultat est float. Le
tableau ci-dessous nous permet de mieux comprendre les opérateurs /, // et %.

Opérateur Description
/ 27/4 = 6.75 : simple division
// 27//4 = 6 division entière
% 27%4 = 3 modulo : reste de la division entière

Opérateurs bit à bit


Python offre les opérateurs bit à bit suivants :

Opérateur Description
∼ complément
& et
— ou
ˆ ou exclusif bit à bit
<< shift à gauche en ajoutant des 0
>> shift à droite en ajoutant des 0

Opérateurs pour les séquences


Chaque classe, séquence de python (str, tuple, list) supporte les opérateurs
suivants :

Opérateur Description
s[j] élément dont l’indice est j
s[start :stop] éléments de s dont les indices
vont de start à stop-1
s[start :stop :step] éléments de s dont les indices vont de start
stop-step par pas de step
s+t concatenation des séquences
k∗s une façon d’écrire s+s+s+... (k fois)
val in s vérifie si s contient val
val not in s vérifie si s ne contient pas val

L’indice des éléments d’une séquence commence à 0. Ainsi les éléments d’une
séquence de n éléments sont indexés de 0 à n-1. On peut faire du ”slicing”.
Exemple data[3 :8] est une sous-séquence de la séquence data dont les indices
sont : 3, 4, 5, 6, 7. La notation val in s peut être utilisée pour n’importe quelle
séquence pour vérifier si la séquence s contient l’élément val.

Opérateurs pour les ensembles et les dictionnaires


Les ensembles et les ensembles figés supportent les opérateurs suivants :
26CHAPITRE 2. RAPPELS SUR LA PROGRAMMATION ET LE LANGAGE PYTHON

Opérateur Description
key in s vérifie si s contient key
key not in s vérifie si s ne contient pas key
s1 == s2 vérifie si s1 est équivalent à s2
s1 <= s2 vérifie si s1 est un sous-ensemble de s2
s1 peut être égal à s2
s1 < s2 vérifie si s1 est un sous-ensemble de s2
s1 ne peut pas être égal à s2
s1 est strictement inclu dans s2
s1 >= s2 vérifie si s1 est un ensemble contenant l’ensemble s2
s1 > s2 vérifie si s1 est un ensemble contenant l’ensemble s2
mais pas égal à lui
s1|s2 l’union de s1 et s2
s1&s2 l’intersection de s1 et s2
s1 − s2 l’ensemble des éléments de s1
qui ne sont pas dans s2
s1ŝ2 les éléments qui sont soit dans s1 est soit dans s2
mais pas dans les deux

Opérateurs d’affectation étendus

Python supporte un opérateur étendu d’affectation pour la plupart de ses


opérateurs unaires. Par exemple count+ = 5 est un raccourci de count = count+
5. Pour un type non modifiable comme string, l’opérateur étendu ne change
pas la valeur de count, mais affecte une nouvelle valeur à count.

Opérateur Description
alpha = [1, 2, 3] affectation de la liste [1, 2, 3] à alpha
beta = alpha beta devient un alias de alpha
beta +=[4, 5] étend la liste existante avec deux nouveaux
éléments. alpha et beta sont modifiés
beta =beta + [6, 7] réaffectation à beta
d’une nouvelle liste [1, 2, 3, 4, 5, 6, 7].
L’alias est cassé. alpha n’est pas modifié
print(alpha) imprime [1, 2, 3, 4, 5]

2.3.1 Expressions composées et priorités des opérateurs

Les langages de programmation doivent disposer des règles claires selon les-
quelles les expressions composées telles que 5+2∗3 doivent être évaluées. L’ordre
des priorités à suivre pour l’évaluation des expressions est donné dans le tableau
ci-dessous :
2.4. LES STRUCTURES DE CONTRÔLE 27

Priorités des opérateurs


Type Symbole
1 Accès aux membres expr.membre
2 fonction/méthode expr(...)
contenneurs, slices expr[...]
3 exponentiation **
4 opérateurs unaires +expr, -expr, ẽxpr
5 multiplication, division *, /, //, %
6 addition, soustraction +, -
7 ”bitwise shiffting” << >>
8 bitwise-and &
9 bitwise-xor ˆ
10 bitwise-or ∥
11 comparaison is, isnot, ==, ! =, <, <=, >, >=
est contenu dans in, not in,
12 non logique not expr
13 et logique and
14 ou logique ou
15 conditionel val1 if cond else val2
16 affectation =, + =, − =, ∗ = etc.

Toutefois, on peut en toute circonstance utiliser les parenthèses pour forcer


l’ordre d’évaluation des sous-expressions dans une expression.

2.4 Les structures de contrôle


Dans cette section nous allons examiner les structures de contrôle les plus
fondamentales de Python : il s’agit des instructions conditionnelles et des boucles.
Le caractère ≪:≫ est utilisé pour indiqué le début du bloc de code qui fait office
de corps de la structure de contrôle. Le code corps d’une structure de contrôle
est indenté par rapport à l’instruction de démarrage de la structure de contrôle.
Si le code corps d’une structure de contrôle n’est constitué que d’une seule ins-
truction, il peut alors être placé sur la même ligne que l’instruction de contrôle
elle même.

2.4.1 L’instruction if : structure conditionnelle par excel-


lence
L’instruction if offre la possibilité d’exécuté un bloc de code en fonction
du résultat de l’évaluation en cours de programme d’une ou de plusieurs ex-
pressions booléennes. La structure conditionnelle la plus utilisée en Python est
l’instruction if. Sa forme générale est :

if first_condition:
28CHAPITRE 2. RAPPELS SUR LA PROGRAMMATION ET LE LANGAGE PYTHON

first_body
elif second_condition:
second_body
elif third_condition:
third_body
else:
fourth_body

Chaque condition est une expression booléenne et chaque body contient une
ou plusieurs instructions à exécuter. Si la première condition est True, le pre-
mier body est exécuté. Aucune autre condition ne sera évaluée dans ce cas. Si
la première condition est false, alors le processus d’évaluation des conditions
se poursuit avec l’évalution de la deuxième condition. L’exécution de toute la
structure if vera un et un seul body s’exécuté. Il peut y avoir 0 ou plusieurs
clauses elif. La clause finale else est optionelle.
Un exemple simple d’utilisation de l’instruction if est la logique d’un robot
contrôleur :

if door_is_closed:
open_door()
advance()

Il faut noter que l’instruction finale advance() n’est pas indentée et ne fait par
conséquent pas partie du code body du if. Elle sera exécutée que la porte soit
ouverte ou fermée. Les if peuvent être imbriqués les uns dans les autres. On
peut illuster ceci avec notre robot.

if door_is_closed:
if door_is_locked:
unlock_door()
open_door()
advance()

La logique exprimée par ces if imbriqués est présentée dans l’organigramme


ci-dessous :

2.4.2 Les boucles


Python offre deux structures pour réaliser les boucles. Il s’agit : de la boucle
while et de la boucle for.

Boucle while
La syntaxe de la boucle while est la suivante :
2.4. LES STRUCTURES DE CONTRÔLE 29

Figure 2.6 – Logique des if imbriqués dans l’exemple du robot de contrôle.

while condition:
body
Comme dans le cas de l’instruction if condition est une expression booléenne
et body est le block de code à exécuté lorsque la condition est True. Après
chaque exécution de body la condition est réevaluée. Si elle est True le body
est exécuté. Lorsque la condition devient False, le programme sort de la boucle
et continue son exécution avec l’instruction juste après la boucle. Ci-dessous
l’exemple d’une boucle qui fait avancer un indice dans une chaine de caractère
jusqu’à ce qu’elle rencontre la lettre ’X’ ou la fin de la séquence.
j=0
while j<len(data) and data[j] != ’X’:
j +=1

La boucle for
La syntaxe de la boucle for de Python est plus appropriée que celle de la
boucle while lorsqu’il s’agit de parcourir les éléments d’une séquence. La boucle
for sera donc utilisée pour le traitement des structures iterable telles que list,
tuple, str, set, dict, ou file. Sa syntaxe générale est :
30CHAPITRE 2. RAPPELS SUR LA PROGRAMMATION ET LE LANGAGE PYTHON

for element in iterable


body
Comme exemple d’utilisation d’une boucle for, nous considérons la somme d’une
liste de nombres.
total=0
for val in data:
total +=val

Boucle for basée sur un indice


La simplicité de l’utilisation d’une boucle for sur une liste est magnifique.
Toutefois, une limitation de cette forme vient du fait que l’on ne connait pas la
position d’un élément dans la séquence. Au lieu de réaliser la boucle directement
sur les éléments de la liste, nous pourrions préférer de boucler sur les indices
possibles de la liste. Python offre une classe interne qui nous facilite les choses en
cette matière. Il s’agit de la classe range. range(n) génere une série de n valeurs
de 0 à n-1. Ces valeurs correspondent aux indices des éléments d’une liste de n
éléments. Ainsi un idiome de Python pour parcourir la série des indices d’une
séquence utilise la syntaxe suivante :
for j in range(len(data)):
Ici j n’est pas un élément de data, il est un entier. Mais data[j] est un élément
de data. Exemple
big_index = 0
for j in range(len(data)):
if data[j] > data[big_index]:
big_index=j

Les instructions Break et Continue


Python dispose de l’instruction break qui termine immédiatement une boucle
while ou for lorqu’elle est exécuté dans son body. En effet, lorsque break est
exécuté dans le body d’une boucle imbriquée, elle termine la boucle qui enve-
loppe immédiatement l’instruction break.

found = False
for item in data:
if item==target:
found = True
break

Ce bout de code termine la boucle for lorsqu’une valeur target est atteinte. Py-
thon supporte également l’instruction continue qui termine l’actuelle itération
du body d’une boucle, avec un déroulement normal pour les prochaines itérations
de la boucle. Ces deux instructions doivent être utilisée avec parcimonie.
2.5. LES FONCTIONS 31

2.5 Les fonctions


En Python, il est essentiel de faire la différence entre fonctions et méthodes.
En effet, nous utilisons le terme fonction pour décrire une fonction sans état,
qui est invoquée sans le contexte d’une classe particulière, comme par exemple
sorted(data). Nous utilisons le terme plus spécifique méthode pour décrire une
fonction membre qui est invoquée sur un objet en utilisant le mécanisme orienté
objet de passage de messages, comme par exemple data.sort(). Dans cette
section nous allons considérer uniquement les fonctions, les méthodes seront
traités dans un paragraphe ultérieur. Ci-dessous un exemple qui nous montre
comment définir une fonction en Python. A titre d’exemple, la fonction suivante
nous permet de compter le nombre d’apparition d’un ”target” donné dans un
data set itérable data. quelconque.
def count(data, target):
n=0
for item in data:
if item == target:
n +=1
return n
La première ligne commence avec le mot clé def. Elle sert à définir la signature de
la fonction. Cette signature précise le nom de la fonction (ici (count)), le nombre
d’arguments qu’elle reçoit et leurs noms (ici data et target). Contrairement à
Java, en Python la signature d’une fonction ne précise pas le type des arguments
ou le type de retour dans la mesure ou Python jouit d’un typage dynamique.
Le reste dans la définition de la fonction est le body, c’est-à-dire le corps de la
fonction. Chaquefois que la fonction est appelée, Python crée ce que l’on appelle
une ”activation record” qui stocke toutes les information relatives à cet appel.
Cette ”activation record” inclu aussi ce que l’on appelle ”namespace” qui
gère les noms de toutes les variables impliquées dans l’appelle de cette fonction.
Ce sont en fait des variables locales de cette fonction et tous les arguments.

L’instruction return
L’instruction return est utilisée dans le body (coprs) d’une fonction pour
indiquer que la fonction doit immédiatement terminé son exécution et qu’une
valeur passée comme argument à l’intruction return doit être retounée à l’ap-
pelant de la fonction. Si l’instruction return est exécutée sans argument, alors
la valeur None est retournée automatiquement. De même None sera retourné
si la fin de la fonction est atteinte sans qu’aucune instruction return n’aie été
exécutée. Il peut exister plusieurs instructions return dans une fonction. Les
conditions désigneront laquelle des instructions return sera exécutée. Exemple :
def contains(data, target):
for item in target:
return True
return False
32CHAPITRE 2. RAPPELS SUR LA PROGRAMMATION ET LE LANGAGE PYTHON

Si la condition dans la boucle for est satisfaite, item in target est True
indiquant que le ”target” a été atteinte. item == target est True, et le
return True est exécuté, ce qui termine la fonction. Par contre si la boucle
atteint la fin sans que item in target ne soit True alors le return False est
exécuté.

2.5.1 Le passage des arguments


Pour être un bon programmeur il faut bien comprendre les mécanismes par
lesquels le langage de programmation passe des arguments aux fonctions et
récupere des valeurs des fonctions. Dans le contexte d’une signature de fonc-
tion, les variables utilisées pour décrire les arguments attendus par la fonction
sont connus comme étant les arguments formels et les objets fournis par
l’appelant au moment de l’invocation d’une fonction sont connus comme étant
les arguments actuels. Lorsqu’une fonction est invoquée, chaque variable qui
sert d’argument formel se voit affecté l’argument actuel correspondant fourni
par l’appelant de la fonction. Par exemple :

prizes = count(grades, ’A’)

Avant l’exécution de la fonction on affecte les arguments actuels aux arguments


formels :

data = grades
target=’A’

Ces affectations font de data l’alias de grades et de target le nom du littéral


’A’. La communication d’une valeur de retour par la fonction à l’appelant est

Figure 2.7 – Comment se fait le passage des arguments de l’appelant vers la


fonction.

également implémentée comme une affectation. Ainsi par la simple instruction


prizes=count(grades, ’A’) la valeur de retour de la fonction count (argu-
ment de l’instruction return dans count est affectée à la variable prizes de
l’appelant. Les objets ne sont pas copiés lors de leur passage de l’appelant vers
la fonction ou de la fonction vers l’appelant. Ceci garanti que l’invocation de
la fonction est efficace même si l’argument ou la valeur de retour est un objet
complexe.
2.5. LES FONCTIONS 33

Les arguments ”modifiables”


Le mécanisme de passage d’arguments a d’autres caractéristiques lorsqu’un
argument est un objet modifiable. Du fait que les arguments formels sont des
alias des arguments actuels le body (corps) de la fonction peut interagir avec
l’objet de telle façon que son état change. Considérons maintenant notre simple
invocation de la fonction count. Si le corps de la fonction exécute l’instruction
data.append(’F’), la nouvelle entrée est ajoutée à la fin de la liste connue par
l’appelant comme ”grades”. Comme effet de bord nous notons que réaffecter un
paramètre formel dans le corps d’une fonction comme data= [ ], ne change pas
l’argument actuel, une telle réaffectation casse tout simplement l’alias.

Valeurs par défaut des Arguments


Python permet aux fonctions de supporter différentes signatures tout en ne
s’appuyant que sur un seul et même code. Une telle fonction est dite polymorphe.
Plus remarquable, une fonction peut déclarer une ou plusieurs valeurs par défaut
pour les arguments permettant ainsi à l’appelant d’invoquer une fonction avec
un nombre variable d’arguments actuels. Prenons comme exemple la fonction
définie ci-dessous :

def foo(a, b=15, c=27):

Elle a trois arguments. Les deux derniers arguments offrent des valeurs par
défaut. Un appellant peut appeler la fonction avec 3 arguments, comme foo(4,12,8).
Dans ce cas les valeurs par défaut des deux derniers arguments ne sont pas uti-
lisées. Si par contre l’appelant appelle la fonction en lui passant un et un seul
argument comme foo(4), la fonction s’exécutera avec les arguments a = 4,
b = 15, c = 27.
Si un appelant appelle la fonction avec deux arguments comme foo(8,20),
la fonction s’exécutera avec les arguments a = 8, b = 20, c = 27. Cependant,
il est illégal de définir une fonction avec une signature telle que bar(a, b=15,
c) où b possède une valeur par défaut et c pas. Si on définit une valeur par
défaut pour un argument, des valeurs par défaut doivent être définies pour tous
les arguments qui suivent.
Commentons ici un exemple interessant de fonction polymorhe en l’occurence
la fonction range. Techniquement il s’agit en fait du constructeur de la classe
range, mais pour des raisons pédagogiques nous allons la traiter ici comme
une simple fonction. Trois appels différents sont possibles pour cette fonction.
L’appel avec un seul paramètre range(n), qui génère une séquence d’entiers
de 0 à n-1. Un appel à deux paramètres range(start, stop) qui génère des
entiers de start à stop non inclu. Et finalement un appel à trois paramètres
range(start,stop,step) qui génère la même chose que range(start, stop),
mais avec un incrément valant step au lieu de 1.
Cette combinaisons de formes de la fonction semble violer la règle sur les
arguments par défaut. En parliculier lorsqu’un seul argument est passé à la
fonction comme range(n). Le n sert ici comme valeur du stop qui est le sécond
34CHAPITRE 2. RAPPELS SUR LA PROGRAMMATION ET LE LANGAGE PYTHON

argument. La valeur du start est 0 dans ce cas. Toutefois, cet effet peut être
obtenu par un tour de passe passe comme ci-dessous :

def range(start, stop=None, step=1):


if stop is None:
stop=start
start = 0

Du point de vue technique lorsque range(n) est invoqué, la valeur actuelle de


l’argument sera affectée à start. Dans le corps de la fonction, si un seul argument
est reçu, le start est affecté au stop.

Arguments par mots clés (Keyword Parameters)

Le traditionnel mécanisme de correspondance des arguments actuels fournis


par l’appelant avec les arguments formels déclarés dans la signature de la fonc-
tion est basé sur le concept des arguments positionnels. Par exemple avec la
signature foo(a=10, b=20, c=30), les arguments actuels fournis par l’appe-
lant correspondent avec les arguments formels. Une invocation du type foo(5)
indique que a = 5, alors que b et c correspondent aux valeurs par défaut.
Python supporte un mécanisme alternatif pour fournir des arguments ac-
tuels à une fonction qui est connu l’appellation d’argument par mot clé.
Le passage d’un argument par mot clé spécifie de manière explicite l’affection
d’une valeur actuelle à un argument formel en précisant le nom de cet argu-
ment. Dans l’exemple ci-dessus définissant la fonction foo, un appel foo(c=5)
invoquera foo avec les arguments suivants : a = 10, b = 20, c = 5.
Un auteur de fonction peut demander que certains paramètres soient fournis
à la fonction obligatoirement par le mécanisme d’argument par mot clé. A titre
d’exemple, la fonction interne de Python max accèpte un argument par mot clé
qui peut être utilisé pour faire varier la notion de maximum dont on parle.
Par défaut la fonction max opère sur base de l’ordre des éléments compte
tenu du sens de la relation < pour ce type. Cela est fait en fournissant une
fonction auxiliaire qui converti un élément naturel en un autre type d’élément
afin de réaliser la comparaison.
Supposons que nous souhaitons trouver le numérique dont la valeur absolue
est la plus grande de sorte que -30 et plus grand que +20 nous pouvons utiliser
l’appel max(a, b, key=abs). Ici la fonction interne abs est fournie elle même
comme valeur associée à l’argument par clé key. En Python les fonctions sont
les premiers objets des classes. Lorsque max(a,b, key=abs) est appelé abs(a)
est comparé à abs(b) en lieu et place de la simple comparaison entre a et b.

2.5.2 Les fonctions internes de Python


Le tableau ci-dessous donne un aperçu des fonctions automatiquement dis-
ponibles dans Python.
2.5. LES FONCTIONS 35

Les fonctions internes les plus utilisées de Python


Syntaxe d’appel Description
abs(x) retourne la valeur absolue du nombre x
all(iterable) retourne True si bool(e) est True
pour chaque élément e de iterable
any(integer) retourne True si bool(e) est True
pour au moins un élément e d’iterable
divmod(x,y) retourne (x//y, x%y) comme
un tuple si x et y sont des entiers
hash(obj) retourne un entier hash valeur de l’objet
id(obj) retourne l’unique entier servant d’identité à l’objet.
input(prompt) retourne une chaine de caractère
de l’entrée standard. Le prompt est optionnel.
isinstance(obj, cls) détermine si obj est une instance de la classe cls.
iter(iterable) retourne un objet itérateur pour l’argument.
len(iterable) retourne le nombre d’éléments de l’iterable.
map(f, iter1, iter2, ...) retourne un itérateur donnant
le résultat des appels f (e1 , e2 , ...)
pour les éléments respectifs
e1 ∈ iter1 , e2 ∈ iter2 , ...
max(iterable) retourne le plus grand élément de l’itération
max(a,b,c, ...) retourne le plus grand argument.
min(iterable) retourne le plus petit élément de l’itération
min(a,b,c, ...) retourne le plus petit des arguments
next(iterator) retourne le prochain élément rapporter par l’itérateur.
open(filename, mode) retourne le fichier filename.
mode indique le mode d’accès.
ord(char) retourne le code Unicode de char
pow(x,y) retourne la valeur de xy . Equivalent à x ∗ ∗y.
pow(x,y,z) retourne xy mod z.
print(obj1, obj2, ...) imprime les arguments séparés par des espaces.
Termine l’impression avec un passage à la ligne.
range(stop) construit une itération des valeurs allant de 0 à stop − 1.
range(start, stop) construit une itération des valeurs
allant de tart à stop − 1 par pas de 1.
range(start, stop, step) construit une itération des valeurs
allant de tart à stop − step par pas de step.
reversed(sequence) retourne la séquence inversée
round(x) retourne la valeur entière la plus proche de x
round(x,k) retourne la valeur arrondie la plus proche
de 10−k . Le retour est du même type que x
sorted(iterable) retourne une liste contenant
les éléments de l’iterable triés
sum(iterable) retourne la somme des éléments
de l’iterable (doit être numérique)
type(obj) Retourne la classe à laquelle l’objet appartient
36CHAPITRE 2. RAPPELS SUR LA PROGRAMMATION ET LE LANGAGE PYTHON

En choisissant les arguments nous utilisons les variables x, y, z pour des numériques
arbitraires, k pour un entier et a, b, c pour des types comparables arbitraires.
Nous utilisons la variable ”iterable” pour représenter une instance de n’importe
quel itérable (list, tuple, set, dict). Les fonctions dans le tableau peuvent être
regroupées en 5 catégories :
— Entrées et Sorties : print, input, et open.
— Encodage de caractères : ord et char relient les caractères avec leurs
codes entiers. Par exemple ord(’A’) vaut 65 et char(65) vaut ’A’).
— Mathématiques : abs, divmod, pow, round et sum offrent des fonc-
tionalités mathématiques usuelles.
— Ordering : max et min sont utilisables par toutes les données suppor-
tant la notion de comparaison et à toute collection. sorted peut être
utilisé pour produire une liste ordonnée, triée.
— Collections et itérations : range génère une nouvelle séquence de
nombres. len calcule la taille d’une collection (nombre d’éléments). Les
fonctions reversed, all, any et map opèrent sur des itérations arbi-
traires. iter, next sont utilisés dans les itérations.

2.6 Les entrées et sorties simples


Dans cette section nous allons aborder les fondamentaux des entrées et sor-
ties en Python. Nous decrirons le mécanisme des entrées et sorties au niveau de
la console ainsi que la façon dont Python effectue l’écriture et la lecture dans
les fichiers.

2.6.1 La console en entrée et sortie


La fonction print
La fonction interne de Python print est utilisée pour générer les sorties
standard à la console. Dans sa forme la plus simple, elle imprime sur la console
une séquence arbitraire d’arguments, séparés par des espaces et se terminant
par un passage à la ligne. En fait, print(’maroon’, 5) génère la sortie ’maroon
5’. Notons que les arguments de print sous cette forme doivent être des chaines
de caractères. Pour utiliser un argument x qui n’est pas une chaine de caractère
dans print on doit utiliser str(x). Sans aucun argument print() donne lieu à
un passage à la ligne. La fonction print() peut être personalisée par l’usage de
certains mots clés :
— Par défaut, la fonction print() insère un espace de séparation entre
les arguments à la sortie. Ce séparateur peut être personalisé en asso-
ciant une chaine de caractère au mot clé sep. Exemple print(a, b, c,
sep=’ :’). le séparateur ne doit pas être un caractère unique, il peut
être une longue chaine de caractères. sep=”permet de concatener les
arguments contigues
— Par défaut, ”\n” est placé après l’argument final. On peut également
utilisé le mot clé ”end”. end=” supprime tous les cartères de fin de
2.6. LES ENTRÉES ET SORTIES SIMPLES 37

chaine.
— Par défaut, la fonction print envoie sa sortie à la console. Toutefois cette
sortie peut être dirigée vers un fichier.

La fonction input

La fonction input() sert à lire des informations à partir de la console. Cette


fonction affiche un prompt si celui-ci lui est passé en argument. Elle attend que
l’utilisateur entre la séquence de caractères suivie d’un return. Le retour de
la fonction est la chaine de caractères qui lui a été fournie avant le return.
Pour lire un numérique introduit par l’utilisateur, le programmeur doit d’abord
utilisé la fonction input pour obtenir une chaine de caractère. Cela se fait comme
response=input(). Ensuite, en faisant int(response) il obtient un entier ou
encore float(response) pour obtenir un flottant. Souvent on combine les deux
fonctions year = int(input(’In what year were you born ?’)). D’autres
possibilités existent. Exemple :

reply = input(’Enter x and y, separated by spaces: ’)


pieces = reply.split() # retourne une list de strings, comme
# ils sont separes par des espaces
x=float(pieces[0])
y=float(pieces[1])

Exemple de programme :

age = int(input(’Enter your age in years: ’))


max_heart_rate = 206.9-(0.67*age)
target = 0.65*max_heart_rate
print(’Your target fat-burning heart rate is’, target)

2.6.2 Les fichiers


L’accès aux fichiers se fait en Python par la fonction open. Cette fonc-
tion retourne un proxy pour l’interaction avec les couches les plus basses d’un
fichier. Par exemple l’instruction fp=open(’sample.txt’) essaie d’ouvrir un
fichier nommé sample.txt. En cas de succès l’instruction retourne un proxy
qui permet d’accéder au fichier texte en mode ”read-only”. La fonction open
accepte un second argument qui détermine le mode d’accès au fichier, ’r’ pour
lecture uniquement (”read-only”), ’w’ pour écriture (write) et ’a’ pour ajouter à
la fin du fichier (”append”). Il est possible de travailler avec des fichiers binaires
en utilisant ’rb’ et ’wb’. Lors du traitement d’un fichier, le proxy maintient un
curseur comme un offset mesuré en bytes. En ouvrant un fichier en mode ’r’ ou
’w’, le curseur est à 0 au début. Si on ouvre en mode ’a’ alors le curseur est à la
fin du fichier. Ci-dessous quelques méthodes pour lire et écrire dans un fichier.
38CHAPITRE 2. RAPPELS SUR LA PROGRAMMATION ET LE LANGAGE PYTHON

Syntaxe d’appel Description


fp.read() Retourne le contenu des lignes lisibles
d’un fichier comme une chaine de caractère
fp.read(k) Retourne les prochains k bytes d’un fichier
lisible comme une chaine de caractère
fp.readline() Retourne la ligne actuelle d’un fichier
lisible comme une chaine de caractères
fp.readlines() Retournes les lignes restantes
d’un fichier lisible
for line in fp : Itère sur toutes les
lignes d’un fichier lisible
fp.seek(k) change la position actuelle qui est
positionnée au k ieme byte du fichier
fp.tell() Retourne la position actuelle,
mesurée comme byte-offset depuis le début
fp.write(string) Ecris une chaine de caractère
à la position actuelle du curseur
d’un fichier en mode ’w’
fp.writelines(seq) Ecris chaque chaine de caractère d’une
séquence donnée à partir de la position
courrante d’un fichier en mode ’w’
print(..., file=fp) Redirige la sortie du print vers le fichier

Lecture d’un fichier

L’instruction la plus élémentaire pour lire à partir d’un fichier est la méthode
read. Si elle est invoquée sous la forme suivante fp.read(k) l’instruction retour-
neles k prochains bytes du fichier. La lecture commence à partir de la position
actuelle. Sans argument fp.read() retourne le restant ou le contenu du fichier.
Pour des raisons de commodité, le fichier peut être lu ligne par ligne en utilisant
readline ou la méthode readlines pour lire le reste des lignes du fichier d’un
coup.

Ecriture dans un fichier

Lorsqu’un proxy de fichier permet l’écriture, du texte peut être écrit dans
ce fichier par les méthodes write ou writelines. Exemple : si nous définissons
fp=open(’results.txt’, ’w’), la syntaxe fp.write(”Hello World. ”) écrit
dans le fichier une ligne avec la chaine de caractère passée en argument. Toute
exception doit être prise en charge. Si elle n’est pas prise en charge elle va
entrainer l’arrêt de l’interpréteur.
2.7. LA GESTION DES EXCEPTIONS 39

2.7 La gestion des Exceptions


Les exceptions sont des évenement inattendues qui surviennent lors de l’exécution
d’un programme. Une exception peut provenir d’une erreur logique ou d’un
évenement imprévu. Les exceptions en Python également appelées erreurs sont
des objets qui sont déclenchés et lancés par des parties de code qui rencontrent
des circonstances inattendues. Une erreur déclenchée doit être détectée par le
contexte environnant qui gère l’exception d’une manière appropriée.

Les exceptions usuelles


Python dispose d’une riche hiérarchie des classes qui matérialisent différentes
exceptions. Les classes d’exceptions servent de base à d’autres types d’erreurs. Le
tableau ci-dessous reprend quelques classes d’exceptions usuelles. Par exemple
l’utilisation d’une variable non définie déclenche une exception NameError,
de même que l’utilisation du point dans foo.bar(), va générer une erreur At-
tributeError si l’objet foo ne dispose pas d’une méthode membre du nom de
bar().

Classe Description
Exception Une classe de base pour
la plupart des types d’erreurs
AttributeError Déclenchée par la syntaxe obj.foo
si obj n’a pas de membre foo
EOFError Déclenché lorsque la fin
du fichier est atteinte sur la console
ou dans un fichier input-output
IOError Déclenchée lorsque
une opération I/O se plante
IndexError Déclenchée si un index
parcourant une séquence
est en dehors des limites
KeyError Déclenchée si une clé
non existante est requise pour
un ensemble ou un dictionnaire
KeyboardInterrupt Déclenchée si l’utilisateur tape CTRL-C
NameError Déclenchée si une variable inexistente est utilisée
StopIteration Déclenchée si next(iterator)
est utilisé alors qu’il n’y a plus d’éléments
TypeError Déclenchée lorsqu’un
mauvais type de paramètre
est envoyé à une fonction
ValueError Déclenchée lorsqu’un
argument a une mauvaise valeur
ZeroDivisionError Déclenchée pour la division
par 0 (tout opérateur de division)
40CHAPITRE 2. RAPPELS SUR LA PROGRAMMATION ET LE LANGAGE PYTHON

2.7.1 Générer une exception


Une expression est déclanchée par l’exécution d’une instruction raise avec
un argument approprié, en fait une instance d’une classe exception. Par exemple
si une fonction devant calculer une racine carré reçoit un argument négatif elle
peut générer une exception :

raise ValueError(’x cannot be negative’)

Cette instruction déclenche une exception de la classe ValueError avec le mes-


sage d’erreur ’x cannot be negative’, message qui sert d’argument au construc-
teur. Si ’exception n’est pas capturée dans le corps de la fonction, l’exécution
de la fonction s’arrête imédiatement et l’exception est propagée, lancée dans
le contexte de l’appel. Par exemple la fonction sqrt de Python déclenche des
exceptions comme ci-dessous :

def sqrt(x):
if not isinstance(x, (int,float)):
raise TypeError(’x must be numeric’)
elif x<0:
raise ValueError(’x can not be negative’)
# le vrai travail se fait ici

Considerons la fonction interne de Python sum qui calcule la somme d’une


collection de nombres. Dans une implémentation rigoureuse de cette fonction,
les tests devraient apparaitre comme ci-dessous :

def sum(values):
if not isinstance(values, collections.Iterable):
raise TypeError(’parameter must be an iterable type’)
total = 0
for v in values:
if not isinstance(v, (int, float)):
raise TypeError(’elements must be numeric’)
total = total +v
return total

collections.Iterable est une classe abstraite de Python qui inclu tous les conte-
neurs itérable de python. Une implémentation plus simple de la fonction sum
pourait prendre la forme suivante

def sum(values):
total = 0
for v in values:
total += v
return total
2.8. ITERATEUR ET GÉNÉRATEUR 41

2.7.2 Saisir une exception


Il y a plusieurs façons de gérér la survenue d’une éventuelle exception au
moment de la rédaction du code. Par exemple si une division x/y doit être
effectuée, il y a alors un risque ZeroDivisionError si y prend la valeur 0. Dans
une situation idéale le programmeur devrait s’organiser pour que y ne prenne
pas la valeur 0. Mais si y dépend d’une expression ou d’une valeur externe
il reste possible que l’on puisse avoir une erreur. Une des philosphies utilisée
pour gérér les exceptions est la philosophie ”look befor leap” (réflichir avant
d’agir). L’objectif ici est d’éliminer totalement la possibilité qu’une exception
survienne. Le bout de code ci-dessous nous permet de faire cela :

if y != 0:
ratio = x/y
else:
# faire autre chose ici

Une seconde philosophie, souvent mise en oeuvre par les programmeurs Python
est que ” it is easier to ask for forgiveness than it is to get permission” ;
Grace Hopper (Il est plus facile de présenter des excuses que d’obtenir la
permission). En Python cette philosophie est implémenttée par l’usage de la
paire de mots try-except. Selon cette philosophie, le bout de code ci-dessus
peut être récrit de la manière suivante :

try:
ratio = x/y
except ZeroDivisionError:
# faire quelque chose d’autre ici

2.8 Iterateur et Générateur


La boucle for peut se mettre sous la forme

for element in iterable:

En Python il existe plusieurs types d’objets qui sont des iterable. Il s’agit
de list, tuple, set par exemple. Toutefois une chaine de caractère effectue des
itérations sur ces caractères. Des variables définies par les utilisateurs peuvent
également supporter les itérations. En Python le mécanisme des itérations est
basé sur les conventions suivantes :
— Un iterator est un objet qui peut effectuer une itération à travers une
série de valeurs. Si la variable i, identifie un iterator, alors chaque appel
de la fonction interne next avec l’argument i sous la forme next(i) pro-
duit un élément de la série avec une Exception StopIteration déclenchée
pour indiqué qu’il n’ y a plus d’éléments.
— Un iterable est un objet, obj, qui produit un iterator via la syntaxe
iter(obj).
42CHAPITRE 2. RAPPELS SUR LA PROGRAMMATION ET LE LANGAGE PYTHON

Partant de ces définitions une instance d’une liste est un iterable, mais pas
un iterator. Avec data = [1, 2, 3, 4] il n’est pas possible de faire un appel
next(data). Toutefois, il est possible il est possible de crée un iterator avec
i = iter(data). Il devient alors possible d’utilisser l’iterator de sorte que next(i)
retourne un élément de la liste. On peut créer plusieurs iterator à partir d’un
seul objet iterable.

2.8.1 Générateur
Il est possible de créer une classe dont les instances sont des itertor. Tou-
tefois la meilleure façon de créer des itertor en Python est de recourir à des
générateurs. Un générateur est implémenté avec une syntaxe très proche
de celle d’une fonction. Toutefois un générateur ne retourne pas de valeur, il
exécute plûtot une instruction yield pour indiquer chaque élément de la série.
A titre d’exemple, considérons que l’on souhaite trouver les facteurs d’un
entier positif. Le nombre a comme facteurs 1, 2, 3, 4, 5, 10, 20, 25, 50, 100.
Une fonction traditionnelle peut retourner la liste de tous les facteurs si elle est
implémentée comme ci-dessous :

def factors(n): # traditionnelle fonction


# calcul facteurs d’un nombre
results = [ ] # les facteurs sont stock\’es
# dans une nouvelle liste
for k in range(1, n+1):
if n%k == 0:
results.append(k) # ajoute \‘a
# la liste de facteurs.
return results # retourne la liste enti\‘re

Par contre une implémentation d’un générateur pour génér les facteurs se
présenterais comme ci-dessous :

def factors(n):
for k in range (1, n+1):
if n%k == 0
yield k

On peut sensiblement améliorer cette implémentation comme suit :

def factors(n):
k=1
while k*k < n:
if n%k == 0
yield k
yield n//k
k+=1
if k+k == m
2.9. QUELQUES AUTRES FONCTIONALITÉS DE PYTHON 43

On notera qu’avec la nouvelle implémentation, les facteurs ne sont pas générés


dans l’ordre. facrors(100) génère la sèrie suivante : 1, 100, 2, 50, 4, 25, 5, 20,
10.

2.9 Quelques autres fonctionalités de Python


Dans cette section, nous introduisons plusieurs fonctionalités de Python per-
mettant de rédiger du code clair et concis.

2.9.1 Expressions conditionelles


Python dispose d’une expression conditionnelle qui peut remplacer une struc-
ture de contrôle simple. Sa syntaxe est la suivante :
expr1 if condition else expr2
Cette expression composée commence par évalué expr1 si condition est True,
autrement expr2 est évaluée. A titre d’exemple nous allons pour illustrer le
fonctionnement de l’expression conditionnelle ci-dessus, considerer un problème
ou nous voulons transmettre la valeur absolue d’une variable n à une fonction.
En utilisant une structure de contrôle traditionnelle, le programme se présente
comme ci-dessous :
if n>= 0:
param =n
else:
param =-n
result = foo(param)
Si par contre l’on utilise l’expression conditionnelle on a alors la situation sui-
vante :
param = n if n>=0 else -n
result = foo(param)
En fait il n’est pas nécessaire d’affecter l’expression conditionnelle à une variable.
Elle pourait être utilisée directement comme argument d’une fonction. Dans ce
cas le programme ci-dessus devient :
result = foo(n if n>=0 else -n)
Le code est bien sur plus clair, toutefois la recommendation est de ne pas abuser
d’expressions conditionnelles.

2.9.2 Comprehension Syntax


Une tâche très fréquente de la programmation est de produire une série
de valeurs à partir d’une autre série de valeurs. Cette tâche peut être réalisée
en Python en utilisant ce qui est connu comme comprehension syntax. Ci-
dessous nous illustrons list comprehension :
44CHAPITRE 2. RAPPELS SUR LA PROGRAMMATION ET LE LANGAGE PYTHON

[expression for value in iterable if condition]

Nous notons que expression comme condition peuvent dépendre de value et


que la clause if est optionnelle. L’évaluation de la comprehension ci-dessus est
équivalente à la structure de contrôle suivante :

result = [ ]
for value in iterable:
if condition:
result.append(expression)

Plus concrètement, la liste des carrés des nombres de 1 à n qui est [1, 2, 4, 9,
16, 25,..., n2 ] , peut être produite de manière traditionnelle comme ci-dessous :

squares=[ ]
for k in range(1, n+1):
squares.append(k*k)

Avec list comprehension, la logique ci-dessus devient :

squares = [k*k for k in range(1, n+1)]

Si nous recherchons les facteurs d’un entier. Par la comprehension nous avons

factors = [k for k in range(1,n+1) if n\%k==0]

Un même problème peut être résolu par plusieurs générateurs. Ci-dessous le


calcul du carré des entiers de 1 à n avec différents générateurs :

[k*k for k in range(1,n+1)] list comprehension


{k*k for k in range(1,n+1)} set comprehension
(k*k for k in range(1,n+1)) generator comprehension
{k : k*k for k in range(1,n+1)} dictionnary comprehension

2.9.3 Emballage et Déballage des séquences


2.10. PORTÉES ET ESPACE DES NOMS 45

Expression Explication
data = 2, 4, 6, 8 affectation à la variable
data du tuple (2, 4, 6, 8)
return x,y Un seul objet contenant
le tuple (x,y) est retourné
a, b, c, d = range(7, 11) affectation à a, b, c,d des
valeurs 7, 8, 9, 10
a = 7, b = 8, c = 9, d = 10
quotient, remainder = divmod(a, b) retourne le couple de valeurs
(quotient = a//b, remainder = a%b)
for x, y in [ (7,2), (5,8), (6,4)] trois itérations
x = 7, y = 2; x = 5, y = 8; x = 6, y = 4
for k, v in mapping.items() : itération par key value
le tuple (x,y) est retourné
data = 2, 4, 6, 8 affectation à la variable
data du tuple (2, 4, 6, 8)
x, , y , z = 6, , 2 , 5 affectation collective
x = 6, y = 2, z = 5
j, k, = k, j un swap plus élaboré

2.10 Portées et espace des noms


En calculant la somme x + y en Python, les noms x et y doivent au préalable
avoir été associés avec des objets qui représentent leurs valeurs. Si une telle
association n’existe pas alors un NameError sera déclenché. Le processus de
détermination de la valeur associée au nom d’une variable est connue sous l’ap-
pellation de résolution du nom (name resolution). L’association d’une va-
leur au nom d’une variable a une portée. Les affectations top-level ont une
portée globale. Les affectations qui sont fait dans le corps d’une fonction ont
généralement une portée locale à cette fonction. Ainsi une affectation x = 5
dans le corps d’une fonction n’a aucun effet sur x en dehors de cette fonction.
Chaque portée en Python est représentée en utilisant une abstraction connue
sous l’appellation de namespace. Un namespace gère toutes les variables
définies dans une certaine portée.

2.10.1 Objets de première classe


En terminologie des langages de programmation, les objets de première classe
sont des instances d’un type auxquelles on peut affecter une variable, que l’on
peut passer comme arguments ou que l’on peut retourner par une fonction.
Tous les types que nous avons introduit jusque là sont des types de première
classe. En Python, les fonctions et les classes sont aussi traitées comme objets
de première classe. Exemple :
scream = print
scream(’Hello’)
46CHAPITRE 2. RAPPELS SUR LA PROGRAMMATION ET LE LANGAGE PYTHON

Figure 2.8 – Namespaces correspondant à l’appel de la fonction


count(data,target), voir section 2.5
.

On commence par affecté la fonction print à la variable scream. La variable


scream étant devenue un alias de la fonction print on imprime en utilisant
scream.

2.11 Modules et instructions d’importation de


code
Nous avons déjà introduit un nombre important de classes et de fonctions
internes. Ces élements sont définis dans l’espace des noms internes de Python.
Dans la version actuelle de Python il y a un peu plus de 150 définitions dans
l’espace interne des noms. Python dispose également de dizaines de miliers de va-
leurs, de fonctions et de classes qui sont organisées en librairies, connus comme
étant des modules. Ces modules peuvent être importés à partir d’où ils sont
stockés dans des programmes. Comme exemple nous pouvons considérer le mo-
dule math. On peut importer du code de plusieurs manières :
Expression Explication
from math import pi, sqrt cette instruction ajoute pi et sqrt
tels qu’ils sont définis dans le module
math dans l’espace des noms actuel
from math import * s’il faut importer plusieurs fonctions
ou du module math, il est mieux indiqué
import math d’importer toutes les fonctions

Creation d’un nouveau module


Pour créer un nouveau module, il suffit de placer les définitions requises
dans un fichier filename.py. Ces définitions peuvent être importées à partir d’un
2.11. MODULES ET INSTRUCTIONS D’IMPORTATION DE CODE 47

autre fichier .py dans le même projet. Si le fichier count.py se trouve dans le
module utility.py on peut alors importer la fonction en utilisant from utility
import count. Il existe une construction spéciale qui permet de placer dans un
module des commandes qui seront exécutées directement lorsque le module est
invoqué comme un script, mais pas lorsque le module est invoqué à partir d’un
autre script. Ces instructions doivent être placées dans le corps d’une instruction
conditionnelle if.
if __name__ == ’__main__’:

2.11.1 Modules existants


Quelques modules existants utiles pour
les structures des données et les algorithmes
Nom du module Description
array fourni un tableau compact
de stockage pour des types primitifs
collections Défini des structures de données
additionelles et des classes de base
abstraites pour les collections d’objets
copy défini des fonctions permettant
de réaliser des copies d’objets
heap fourni des fonctions basées sur
les tas (heap) pour des files d’attente
math défini des fonctions et des
constantes mathématiques usuelles
os Fourni les services nécessaires pour
l’interaction avec les systèmes d’exploitation
random fourni des fonctions de
génération des nombres aléatoires
re offre les services pour la gestion
des expressions régulières
sys Permet des interactions avec les
couches basses de l’interpréteur Python
time fourni des outils de mesure du temps ou de
retardement de l’exécution d’un programme

Génération des pseudo nombres aléatoires


Les modules aléatoires de Python fournissent des outils pour la génération
des pseudo nombres aléatoires. Un pseudo-random number generator uti-
lise une formule déterministe pour générer le prochain nombre d’une séquence
en se basant sur un ou plusieurs nombres qu’il a déjà généré. Exemple :
next = (a*current+b) % n
ou a, b, et n sont des entiers choisis de manière appropriée. Python utilise une
technique plus avancée connue sous l’appellation de Mersenne Twister.
48CHAPITRE 2. RAPPELS SUR LA PROGRAMMATION ET LE LANGAGE PYTHON

Il est possible de démontrer que les séquences générées par cette technique
sont uniformes du point de vue statistique. Ce qui est bon pour la plupart des
applications. Puisque le prochain nombre d’un générateur de pseudo-nombres
aléatoires dépend des nombres précédement générés, un tel générateur a toujours
besoin d’un point de départ que l’on appelle seed.
La séquence de nombres générés pour un seed donné est toujours la même.
On conseille pour obtenir des séquences différentes chaquefois que le programme
tourne d’utiliser un seed qui sera différent pour chaque exécution du pro-
gramme. La classe Random est la classe utilisée pour les services relatifs aux
nombres aléatoires. Toutes les méthodes supportées par la classe Random sont
aussi supportées par les fonctions stand-alone du module random. Ci-dessous
quelques unes de ces fonctions :

Syntaxe Description
seed(hashable) Initialise le générateur
des pseudo-nombres aléatoires
random() Retourne un pseudo nombre aléatoire à
virgule flottante dans l’intervalle [0.0, 1.0]
randint(a,b) Retourne un pseudo nombre aléatoire
dans l’intervalle fermé [a, b]
randrange(start, stop, step) Retourne un pseudo nombre aléatoire
dans l’intervalle commençant à start et
se terminant à stop par pas de step
choice(seq) Retourne un élément d’une séquence
choisie de manière pseudo-aléatoire
shuffle(seq) Réordonne les éléments d’une
certaine séquence pseudo-aléatoire.

2.12 Programmation Orientée Objet en Python


2.12.1 Objectifs, Principes et Patterns
Les principaux acteurs du paradigme orienté objet sont les objets. Chaque
objet est une instance d’une classe. Chaque classe présente au monde extérieur
une vue précise et consistente des objets qui sont instances de cette classe, sans
aller dans les détails internes sur le fonctionnement des objets. La définition de
la classe spécifie les variables et les constantes de la classe, aussi connues sous
l’appellation de données membres de la classe, ainsi que les méthodes de la
classe, connues aussi sous l’appellation de fonctions membres de la classe.

Les objectifs de la conception orientée objet

Les logiciels mis en oeuvre en s’appuyant sur la technologie orientée objet


doivent être Robustes, adaptables, et Réutilisables voir figure 2.9.
2.12. PROGRAMMATION ORIENTÉE OBJET EN PYTHON 49

Figure 2.9 – Objectifs de la conception orientée-objet


.

Robustesse

Tout bon programmeur veut écrire des programmes qui sont corrects. C’est-
à-dire que le programme produit une bonne sortie pour chaque entrée prévue.
En plus nous voulons que le programme soit robuste, il s’agit ici du fait qu’il
soit capable de géré des entrées non prévues. Par exemple lorsqu’un programme
attend un entier positif et qu’à la place, il reçoit comme entrée un entier négatif,
le programme doit pouvoir gérer correctement cette situation.
Plus important dans les applications où se présentent des situations de vie
où de mort, où une erreur dans un programme peut donné lieu à des blessures
où à des morts d’homme. Un logiciel qui n’est pas robuste pourrait être mortel.
Cette situation s’est présentée dans les accidents de Therac-5, une machine de
radio-thérapie qui a injecté une overdose de radiation à 6 patients entre 1985 et
1987, dont quelques uns sont décédés. Ces six accidents étaient tous dus à une
erreur dans un programme.

Adaptabilité

Les logiciels modernes tels que les navigateurs web et les moteurs de re-
cherche sur Internet impliquent des larges programmes qui sont utilisés durant
plusieurs années. Les logiciels doivent donc pouvoir évoluer pendant des nom-
breuses années au gré des demandes des utilisateurs. Ainsi une importante qua-
lité d’un logiciel est son adaptabilité, sa capacité à évoluer. A cela il faut ajouté
le concept de portabilité qui est son habilité à tourner sur différentes plate-
formes (Système d’exploitation par exemple) sans changement ou avec très peu
de modifications. Le langage Python assure la portabilité des programmes.
50CHAPITRE 2. RAPPELS SUR LA PROGRAMMATION ET LE LANGAGE PYTHON

Réutilisabilité
En parallèle avec l’adaptabilité nous avons la notion de réutilisabilité. Un
programme doit pouvoir être utilisé comme composant de différents systèmes
dans différentes applications. Il faut toutefois veiller à ce que cette volonté de
réutiliser du code ne nous conduise à des erreurs. D’aprés les enquêtes, les acci-
dents de Therac-c sont dus à la réutilisation du code.

Principes de la conception orientée objet


Les principes essentiels de la conception orientée objet sont :
— La modularité
— L’abstraction
— l’Encapsulation

Figure 2.10 – Principes de la conception orienté objet


.

Modularité
Les logiciels modernes consistent en plusieurs modules qui doivent interagir
correctement entre eux pour un fonctionnement harmonieux du logiciel. La mo-
dularité est un principe d’organisation selon lequel différentes composantes d’un
logiciel sont divisées en unités fonctionnelles. Un exemple de la modularité dans
le monde réel est celui d’une maison qui peut être regardée comme composée
de plusieurs unités fonctionnelles : électricité, chauffage, plomberie, et structure
(ou gros oeuvre). En Python nous avons les modules qui sont des collections
des fonctions liées les unes aux autres et des classes qui sont définies dans une
même unité de code à savoir un fichier.

Abstraction
La notion d’abstraction repose sur le fait de décomposé un système complexe
jusqu’à ses composants les plus fondamentaux. Partant de là on peut enfermer
certaines parties dans des blocs dont on assure la communication avec l’extérieur
2.12. PROGRAMMATION ORIENTÉE OBJET EN PYTHON 51

par des interfaces simples. Typiquement décrire les parties d’un système re-
vient à les nommés, à expliquer leurs fonctionalités. Appliqué l’abstraction à
la conception des structures des données donne naissances aux structures des
données abstraites (ADT=Abstract Data Type). Un ADT spécifie ce que chaque
opération fait, mais pas comment il le fait. On voit apparaitre la notion d’inter-
face public d’ADT. Python s’appuie sur le mécanisme connu comme abstract
base class (ABC) pour utiliser les ADT. Un ABC ne peut pas être instancié,
mais il défini une ou plusieurs méthodes que toutes les implémentations de l’abs-
traction devront avoir. Un ABC peut consiter en une ou plusieurs classes.

Encapsulation
Un autre principe important de la conception orienté objet est l’encapsula-
tion. Ici il s’agit du fait que différents composants d’un software ne doivent pas
révéler les détails internes de leurs implémentations. Le plus important avantage
de l’encapsulation est qu’elle donne au programmeur la liberté d’implémenter
des détails sans devoir tenir compte du fait que d’autres programmeurs écriront
du code qui dépend de ces décisions internes. La seule contrainte pour le pro-
grammeur est d’offrir une interface publique appropriée par laquelle le bout de
code intergira avec le monde extérieur.

Design Patterns
La conception orienté objet facilite la réutilisabilité du code, sa robustesse
et son adaptabilité. Concevoir des bons programmes exige plus que la simple
compréhension de la méthodologie orienté objet. Elle requiert un usage efficace
des techniques de conception orienté objet. Les chercheurs en informatique, les
programmeurs les plus expérimentés ont développés une variété de concepts
organisationnels et de méthodologies pour la conception des logiciels orientés
objets de qualité, concis et réutilisables. Un ”design pattern” décrit la solu-
tion d’un problème logiciel typique. Un pattern fournit un modèle général (un
template) de solution qui peut être appliqué à différentes situations. Il décrit
les principaux éléments d’une solution d’une manière abstraite qui peut être
spécialisée pour chaque problème particulier éligible à l’usage du pattern. Dans
ce cours nous discuterons des design pattern algorithmiques suivants :
— La récursivité
— ”Amortization”
— Diviser pour régner
— ”Prune and search”
— La force brute
— Programmation dynamique
— La méthode gloutonne (”greddy method”)
De même nous aborderons les ”design pattern” logiciels suivants :
— Iterator
— Adapter
— Position
52CHAPITRE 2. RAPPELS SUR LA PROGRAMMATION ET LE LANGAGE PYTHON

— Composition
— Template Method
— Locator
— Factory method

2.12.2 Développement des logiciels


Le développement traditionnel des logiciels implique plusieures étapes. Les
trois principales étapes sont :
— La conception
— L’implémentation
— Les tests et débogage
Dans cette section nous examinons brièvement le rôle de chacune de ces trois
étapes et nous introduisons les bonnes pratiques de programmation en Python
parmi lesquelles, le style de programmation, les conventions de nommage, la
documentation formelle et les tests unitaires.

Conception (Design)
Pour le développement des logiciels orienté-objet, la conception est proba-
blement la phase la plus importante. En effet, c’est dans la phase de conception
que nous décidons de la façon d’organiser notre programme en classes, nous
décidons de la manière dont les classes vont interagir les unes avec les autres,
quelles données chacune d’elle va stocké et quelles actions elle réalisera. Il est un
fait que le défi le plus important que rencontrent les nouveaux programmeurs
est le fait de décider des classes qu’ils vont définir pour leur permettre de réaliser
leur travail. Il n’existe pas des règles générales en la matière. Toutefois, il existe
quelques règles empiriques qui peuvent être utilisées au moment de décider des
classes à utiliser et des relations qui existerons entre elles :
— Responsabilités : Organiser le travail autour de différents acteurs. Cha-
cun des acteurs aura une responsabilité différente. Essayer de décrire les
responsabilités en utilisant les verbes d’action. Ces acteurs seront les
classes du programme.
— Indépendance : S’assurer du fait que le travail d’une classe soit aussi
indépendant que possible du travail des autres classes. Répartir les res-
ponsabilités entre les classes de telle façon que chaque classe soit au-
tonome pour certains aspects du programme. Donner le contrôle des
données à la classe qui a compétence sur les actions qui nécessitent l’accès
à ces données.
— Comportement : Définir soigneusement et précisement le comporte-
ment de chaque classe et s’assurer ainsi que chaque action effectuée par
la classe est bien comprise par les classes qui interagissent avec elle. Ces
comportement vont définir les méthodes de cette classe. L’ensemble des
comportement d’une classe représente l’interface de communication de
cette classe avec le monde extérieur. C’est à travers cette interface que
les autres classes interagissent avec les objets de notre classe.
2.12. PROGRAMMATION ORIENTÉE OBJET EN PYTHON 53

Concevoir les classes ainsi que leurs méthodes et données membres est le point
clé de la conception orienté objet. Un bon programmeur s’assurera de développer
des bonnes aptitudes en cette matière. Un outil commun pour développer une
conception initiale de haut niveau pour un projet est l’utilisation des cartes
CRC (Class-Responsability-Collaborator).

Il s’agit des cartes indexées qui subdivisent le travail nécessaire pour un


programme. L’idée principale derrière cet outil est d’avoir chaque carte qui
représente un composant qui finira par devenir une classe. On écrit le nom de
chaque composant dans l’en-tête de la carte. Sur le coté gauche de la carte on
écrit les responsabilités de ce composant. Sur le coté droit de la carte nous no-
tons la liste des collaborateurs de ce composant, il s’agit des autres composants
avec lesquelles ce composant devra interagir. Dans le processus de conception,
nous devons d’abord identifier une action qui est une responsabilité, ensuite
nous identifions l’acteur le plus approprié pour réaliser cette action. La concep-
tion est terminée lorsque nous avons affecter toutes les actions aux différents
acteurs.

Figure 2.11 – Diagramme de classe pour la classe CreditCard.


.

Un standard pour expliquer et documenter la conception est l’UML (Uni-


fied Modeling Language). Les diagrammes UML permettent d’exprimer et de
documenter divers aspects de la conception orientée-objet. Des logiciels existent
permettant de nous assister dans l’usage de l’UML (diagramme de calsses, dia-
grammes de collaborations, diagrammes d’activité, diagrammes de séquences,
use case diagram...). Ci-dessous nous reprenons quelques aspects de la concep-
tion de la classe Carte de crédit.
54CHAPITRE 2. RAPPELS SUR LA PROGRAMMATION ET LE LANGAGE PYTHON

Pseudo-code
Comme étape intermédiaire entre la conception et l’implémmentation, les
programmeurs sont souvent appelés à décrire les algorithmes pour les besoins
de la compréhension des humains. De telles descriptions sont appelés des pseudo-
codes. Le pseudo-code n’est pas un programme, mais il est bien plus structuré
que la simple prose. C’est une mixture du langage naturel et des structures des
langages de programmation de haut-niveau.

Style de codage et documentation


Les programmes doivent être conçus de manière à ce qu’ils soient faciles à lire
et à comprendre. Les bons programmeurs prennent soin de leur style de codage
et développent un style qui communique les aspects importants d’un programme
tant aux machines qu’aux humains. Les conventions de codage tendent à varier
entre les différentes communautés (académiques, industriels, ...). Le style de co-
dage officiel de Python est disponible sur http ://www.python.org/dev/peps/pep-
0008/. Les principaux principes que l’on adopte à ce propos sont :
— Dans du code Python, les blocs d’instructions sont indentés de 4 espaces.
Les tabulations ne sont pas utilisés pour l’indentation du code.
— Utiliser des noms significatifs pour les variables.
— Les classes devraient avoir des noms singulier et devraient avoir leur
premier caractère en majuscule. Lorsque plusieurs mots sont concaténés,
chaque mot devrait commencer par une lettre majuscule. Les mots sont
colés les uns aux autres sans aucun séparateur.
— Les fonctions, y compris les fonctions membres des classes auront leurs
noms en petits caractères. Si plusieurs mots sont utilisés dans le nom
d’une fonction, ils seront reliés par un ”underscore”. Le nom d’une fonc-
tion sera typiquement un verbe qui décrit ses effets. Si toutefois le seul
objet d’une fonction est de retourner une valeur, alors le nom de la fonc-
tion décrira cette valeur.
— Les noms qui identifient un objet individuel doivent être en miniscules
— Les variables qui représentent des constantes seront en lettres capitales
avec différents mots reliés par underscore (example : MAX SIZE). Toutes
les variables qui commencent par underscore sont supposées être pour un
usage interne à une classe ou à un module.
— Utilisé des commentaires qui ajoutent des explications au programme.
exemple : if n % 2 == 1 : # n is odd

Documentation
Python permet d’incorporé directement la documentation dans le code source
grâce au mécanisme de docstring. Formellent chaque litteral qui apparait
comme première instruction dans le corps d’un module , d’une classe ou d’une
fonction sera considéré comme un docstring. Par convention ces littéraux sont
délimités par (”””). Ci-dessous un exemple :
2.12. PROGRAMMATION ORIENTÉE OBJET EN PYTHON 55

def scale(data, factor):


"""Multiply all enties of numeric data list by given factor."""
for j in range(len(data)):
data[j] *= factor

docstring permet également l’insertion de lignes blanches dans des commentaires


de plusieurs lignes. Exemple

def scale(data, factor):


"""Multiply all entries of numeric data list by the given factor.

data an instance of any mutable s\’equence type (such as a list)

factor a number that serves as the multiplicative factor for scaling


"""
for j in range(len(data)):
data[j] *= factor

Une docstring est stockée comme une donnée membre du module, d’une fonc-
tion, ou d’une classe (ou elle est déclarée). Elle sert comme documentation et
peut être retrouvée de plusieurs manières. Exemple help(x) produit la docu-
mentation associée à l’objet x. Un outil externe nommé pydoc est distribué avec
Python et peut être utilisé pour généré la documentation formelle. Un tutorial
pour produire des docstring intéressants est disponible sur :
http ://www.python.org/dev/peps/pep-0257/.

Tests et Débogage
Les tests représentent le processus de vérification expérimentale de l’exacti-
tude d’un programme. Le debogage consiste quant à lui au processus de suivi de
l’exécution d’un programme en vue d’éliminer les erreurs qui y subsisteraient.
Les tests et le débogage sont deux activités très consomatrices de temps dans le
processus de développement d’un programme.

Tests
Un bon plan de tests est essentiel dans le développement d’un programme.
Nous savons très bien que la vérification de l’exactitude d’un programme à
l’aide de toutes les entrées possibles n’est pas réalisable. Nous devons donc nous
contenter d’exécuter le programme avec les entrées représentatives. Nous devons
au minimum nous assurer que chaque méthode d’une classe est testée au moins
une fois. Mieux encore un plan de test qui s’assure que chaque bout de code
du programme est testé au moins une fois est plus approprié. En général, les
programmes ne donnent pas les bons résultats pour les entrées spéciales. Des
tels cas doivent toujours être identifiés et testés. Par exemple lorsqu’il faut tester
une méthode qui trie une séquence d’entiers, nous devons prendre en compte les
entrées suivantes :
56CHAPITRE 2. RAPPELS SUR LA PROGRAMMATION ET LE LANGAGE PYTHON

— la séquence a une longueur zéro (0 élément) ;


— la séquence a un seul élément
— tous les éléments de la séquence sont les mêmes
— la séquence est déjà triée
— la séquence est triée dans l’ordre inverse
En plus des entrées spéciales, nous devons également prendre en compte les
conditions spéciales pour les structures de contrôle. Par exemple, si nous uti-
lisons une liste pour stocker les données, nous devons nous assurer que les cas
limites tels que l’insertion ou le retrait des données à l’entrée ou à la sortie de
la liste sont bien gérées.
Il est également bien indiqué de faire tourner le programme avec une grande
quantité de données aléatoires. Les dépendances entre les classes et les fonctions
d’un programme induisent une hiéarchie. Il y a donc deux stratégies principales
de tests : la stratégie top-down et la stratégie bottom-up. Ces deux stratégies
diffèrent par l’ordre suivant lequel les composants sont testés.
La stratégie top-down va du sommet au bas du programme. Les tests des-
cendants se déroulent du haut vers la bas de la hiéarchie du programme. Elle est
généralement utilisée avec le ”stubbing”, une technique de remplacement de
composant de bas niveau par un ”stub”, un composant qui stimule les fonctio-
nalités du code original. Par exemple lorsqu’une fonction A appelle une fonction
B pour obtenir une ligne d’un fichier, on peut au moment du test de A remplacé
B par un stub qui retourne un string.
Les tests Bottom-up travaillent des composants les plus bas pour remonter
vers les composants les plus élévés dans la hiéarchie. Par exemple, les fonctions
de bas-niveau qui n’invoquent pas d’autres fonctions sont testées avant toute
chose. Elles sont suivies par les fonctions qui qui n’appellent que les fonctions
de bas-niveau et ainsi de suite. De même une classe qui ne dépend d’aucune autre
classe est testée avant les autres. Ce type de test est connu sous l’appellation de
test unitaire.
Python offre de nombreux mécanismes de tests automatisés. Lorsque des
fonctions ou des classes sont définies dans un module le test de ce module peut
être incorporé dans le même fichier contenant le module. Le code pour faire cela
est :

if __name__ == ’__main__’:
\# perform tests

Ces tests seront exécutés lorsque Python est directement invoqué sur ce module
et non lorsque le module est importé dans un autre module ou fonction. Le
module unittest de Python offre des mécanismes plus automatisés de tests. Ce
cadre permet de grouper des test des cas individuels dans des grandes suites de
tests et offre de l’aide pour l’exécution de ces suites et l’analyse des résultats
des tests. Dans le processus de la maintenance d’un logiciel, la regression test
est utilisée. En fait tous les anciens tests sont ré-exécutés pour s’assurer que les
changements apportés aux logiciels n’ont pas introduit des nouvelles erreurs.
2.12. PROGRAMMATION ORIENTÉE OBJET EN PYTHON 57

Débogage
La plus simple technique de débogage est l’utilisation de l’instruction print.
Elle permet de suivre les valeurs de variables au cours de l’exécution du pro-
gramme. Le problème avec cette façon de faire est qu’après les tests, il faut
retirer les print ou les transformer en commentaires.
Une meilleure approche est d’utiliser un débogueur, qui est un environne-
ment spécialisé pour contrôler et suivre l’exécution d’un programme. La fonc-
tionalité de base qu’offre un débogueur est la mise en place dans le programme
des ”breakpoints” dans le code. Lorsque le programme est exécuté dans un
débogueur, il s’arrete à chaque ”breakpoint” permettant l’examen des valeurs
des variables à cet instant.
La distribution standard de Python inclu le module pdb qui offre les fonc-
tionalités du débogueur dans l’interpréteur. La plupart des IDEs dont IDLE
offrent un débogueur sous environnement graphique.

2.12.3 Définition des classes


Une classe est le premier moyen d’abstraction en programmation orientée
objet. En Python chaque donnée est représentée comme une instance d’une
classe. Une classe offre un ensemble de comportements sous forme de fonctions
membres (aussi appelées méthodes), avec des implémentations communes pour
toutes les instances de la classe. Une classe sert également de modèle pour ses
instances. Elle détermine la façon dont les informations d’état de chaque instance
sont représentées sous forme d’attributs (également appelés champs, variables
d’instance ou données membres).

Exemple : la classe carte de crédit


Nous avons donné le diagramme de classe de la carte de crédit dans la section
(...). Il s’agit d’un modèle simple pour une carte de crédit traditionnelle. Le code
commence par le mot clé class suivi du nom de la classe et du :. Ensuite vient
un bloc indenté de code qui sert comme le body (corps) de la classe. Le corps
de la classe inclu les définitions des méthodes de la classe. Ces méthodes sont
définies comme des fonctions, mais elles font usage d’un paramètre nouveau
en l’occurence le paramètre self qui sert à identifier l’instance particulière sur
laquelle un membre est invoqué.

La variable self
En Python, la variable self joue un rôle clé. Dans le contexte de la classe
CreditCard, il peut y avoir plusieurs instances de CreditCard. Chaque instance
maintient sa balance, sa limite de crédit, et ainsi de suite. Du point de vue de la
syntaxe self identifie l’instance sur laquelle la méthode est invoquée. Supposons
par exemple, que l’utilisateur de la classe CreditCard a une variable my card
qui identifie une instance de la classe CreditCard. Lorsque l’utilisateur fait l’ap-
pel my card.get balance(), la variable self, dans la définition de la méthode
58CHAPITRE 2. RAPPELS SUR LA PROGRAMMATION ET LE LANGAGE PYTHON

get balance, se réfère à la carte de crédit connue comme étant my card pour
l’appelant. L’expression, self. balance fait référence à la variable balance
stockée comme membre de cette carte de crédit particulière. Ci-dessous le code :

class CreditCard:
"""A consumer credit card."""
def __init__(self, customer, bank, acnt, limit):
"""Create a new credit card instance.
The initial balance is zero.
customer the name of the customer (e.g. Etina Jeanne)
bank the name of the bank (e.g. ’EquityBCDC’)
acnt the account identifier (e.g. ’5391 0375 9387 5309’)
limit credit limit (measured in dollars)
"""
self._customer = customer
self._bank =bank
self._account = acnt
self._limit = limit
self._balance = 0

def get_customer(self):
"""Return the name of the customer."""
return self._customer

def get_bank(self):
"""Return the bank’s name."""
return self._bank

def get_account(self):
"""Return the card identifying number (typically stored as a string)."""
return self._account

def get_limit(self):
"""Return current limit."""
return self._limit

def get_balance(self):
"""Return current balance."""
return self._balance

def charge(self, price):


"""Charge given price to the card, assuming sufficient credit limit.

Return True if charge was processed; False if charge was denied.


"""
if price +self._balance > self._limit: \# if charge would exceed limit,
2.12. PROGRAMMATION ORIENTÉE OBJET EN PYTHON 59

return False
else:
self._balance += price
return True

def make_payment(self, amount):


"""Process customer payment that reduce balance."""
return self._balance -= amount

if __name__ == ’__main__’:
wallet = []
wallet.append(CreditCard(’Etina Jeanne’,’EquityBCDC’,’5391 0375 9387 5309,2500))
wallet.append(CreditCard(’Etina Jeanne’,’RawBank’, ’3485 0399 3387 5309, 3500))
wallet.append(CreditCard(’Etina Jeanne’,’TMB’, ’5391 0375 9387 5309, 2500))
wallet.append(CreditCard(’Etina Jeanne’,’FBNBank’, ’5391 0375 9387 5309, 5000))

for val in range(1, 17):


wallet[0].charge(val)
wallet[1].charge(2*val)
wallet[2].charge(3*val)

for c in range(3):
print(’Customer = ’, wallet[c].get_customer())
print(’Bank = ’, wallet[c].get_bank())
print(’Account = ’, wallet[c].get_account())
print(’Limit = ’, wallet[c].get_limit())
print(’Balance = ’, wallet[c].get_balance())
while wallet[c].get_balance() > 100:
wallet[c].make_payment(100)
print(’New balance =’, wallet[c].get_balance())
print
Observons maintenant la différence entre les signatures des méthodes au mo-
ment de leur définition et au moment de leur utilisation par un appelant. Par
exemple, du point de vue d’un utilisateur la méthode get. balance est appelée
sans arguments. Au moment de la définition de la méthode dans la classe self
est un argument explicite. La méthode charge est définie dans la classe avec
comme arguments self et price, par contre au moment de l’appel, la méthode
ne reçoit qu’un seul argument my card.charge(200). L’interpréteur lie auto-
matiquement l’instance sur laquelle la méthode est appelée à l’argument self.

Le constructeur
Un utilisateur peut créer une instance de la classe CreditCard en utilisant
la syntaxe :
cc = CreditCard(’Bolumbu Jean’,’RawBank’,’5391 0375 9387 5309’,1000)
60CHAPITRE 2. RAPPELS SUR LA PROGRAMMATION ET LE LANGAGE PYTHON

Cette instruction se traduit en interne par un appel à la méthode spéciale


init qui sert de constructeur pour la classe. Sa responsabilité première est
d’établir l’état du nouvel objet CreditCard avec les variables appropriées. Dans
le cas de la classe CreditCard, chaque instance maintient les valeurs de 5 va-
riables qui sont : customer, bank, account, limit et balance. Les valeurs
initiales de 4 de ces 5 variables sont passées comme arguments au constructeur.
La variable balance quant à elle doit être calculée.

Test d’une classe

Dans la dernière partie du code nous avons mis en oeuvre la classe Cre-
ditCard en insérant trois cartes de crédit dans une liste nommée wallet.
Nous avons utilisé quelques boucles pour charger les cartes de crédit, effectuer
quelques paiements et imprimer les résultats de nos opérations dans la console.
Les tests sont le body de l’instruction : if name == ’ main ’. Cette
façons de faire permet d’inclure les tests dans le fichier qui contient le code de
la classe.

Encapsulation

Par convention, en mettant un underscore devant une variable membre de


classe on fait de cette variable une variable privée, c’est-à-dire, une variable à
laquelle les autres classes n’ont pas accès. Comme règle générale nous traiterons
toutes les données membres comme non publiques. Cette règle permet d’assurer
la consistence des états des instances de la classe. On peut fournir des méthodes
d’accès aux données membres pour un accès ”read only” à ces données. Si on
veut que les autres classes puisse les mettre à jour, on doit alors prévoir des
méthodes de mise à jour.

Surcharge des opérateurs en Python et méthodes spéciales

Les classes internes de Python offrent une sématique pour plusieurs opérateurs.
Par exemple a+b invoque l’addition pour les types numériques, et la concaténation
pour les types séquences. Au moment de la définition d’une nouvelle classe, nous
devons envisager si la syntaxe a+b sera définie si a ou b est une instance de la
classe.
Par défaut, l’opérateur + n’est pas défini pour une nouvelle classe. Toutefois,
il appartient à l’auteur de la classe de fournir une définition de + en utilisant
la technique de surcharge des opérateurs. Ceci est fait par l’implémentation
d’une méthode spécialement nommée. L’opérateur + est surchargée par l’implé-
mentation de la méthode add qui reçoit l’opérande de droite comme ar-
gument. Ainsi l’opération a+b est convertie en un appel sur l’objet a de la
méthode add () à laquelle on passe b comme argument. Soit a. add (b).
Des méthodes similaires existent pour les autres opérateurs. Le tableau ... donne
une liste de ces fonctions.
2.12. PROGRAMMATION ORIENTÉE OBJET EN PYTHON 61

Lorsqu’un opérateur binaire est appliqué sur deux instances de différents


types comme dans l’expression suivante : 3*’love me’, Python accorde la prio-
rité à l’opérande de gauche. Dans l’exemple précédent Python va tester si la
classe int fournit une définition de la multiplication entre un entier et une
chaine de caractères via une méthode mul . Si la classe n’implémente pas ce
comportement alors Python teste si la classe défini pour l’opérande de droite un
opérateur du type rmul . La distinction entre mul et rmul permet à
une classe de définir différentes sémantiques dans le cas où une opération est non
commutative comme dans le cas du produit matriciel (A*x est différent de x*A)
62CHAPITRE 2. RAPPELS SUR LA PROGRAMMATION ET LE LANGAGE PYTHON

Syntaxe habituelle méthode spécial


a+b a. add (b) ou b. radd (a)
a-b a. sub (b) ou b. rsub (a)
a*b a. mul (b) ou b. rmul (a)
a/b a. truediv (b) ou b. rtruediv (a)
a//b a. floordiv (b) ou b. rfloordiv (a)
a%b a. mod (b) ou b. rmod (a)
a**b a. pow (b) ou b. rpow (a)
a << b a. lshift (b) ou b. rlshift (a)
a >> b a. rshift (b) ou b. rrshift (a)
a&b a. and (b) ou b. rand (a)
a∧b a. xor (b) ou b. rxor (a)
a ∥b a. or (b) ou b. ror (a)
a+ =b a. iadd (b)
a− =b a. isub (b)
a∗ =b a. imul (b)
+a a. pos (b)
-a a. neg (b)
ã a. invert (b)
abs(a) a. abs (b)
a<b a. lt (b)
a <= b a. le (b)
a>b a. gt (b)
a >= b a. ge (b)
a == b a. eq (b)
a =! b a. ne (b)
v in a a. contains (v)
a[k] a. getitem (k)
a[k] = v a. setitem (v) ou
del a[k] a. delitem (k)
a(arg1, arg2, ...) a. call (arg1, arg2, ...)
len(a) a. len ()
hash(a) a hash ()
iter(a) a. iter ()
next(a) a. next ()
bool(a) a. bool ()
float(a) a. float ()
int(a) a. int ()
repr(a ) a. repr ()
reversed(a) a. reversed ()
str(a) a. str ()
2.12. PROGRAMMATION ORIENTÉE OBJET EN PYTHON 63

Example : classe vecteur multidimentionnel


L’implémentation de la classe Vector nous permet de mettre en oeuvre la
surcharge des opérateurs. Il s’agit d’implémenter une classe vecteur pour un
espace mutltidimensionnel. Par exemple, dans l’espace à trois dimmensions on
souhaiterait représenter un vecteur par ses coordonnées ⟨5, −2, 3⟩. On pourrait
être tenté d’utiliser une liste pour représenter un vecteur. Il faut toutefois noté
qu’une liste ne fournit pas les abstractions géométriques appropriées pour un
vecteur. Par exemple la somme de deux liste [5, −2, 3] + [1, 4, 2] donne une liste
étendue [5, −2, 3, 1, 4, 2] alors que la somme de deux vecteurs ⟨5, −2, 3⟩+⟨1, 4, 2⟩
résulte en un vecteur dont les composantes sont les sommes des composantes des
deux vecteurs ⟨6, 2, 5⟩. Ci-dessous notre implémentation de la classe Vecteur.
class Vector:
"""Represent a vector in a multidimensional space"""
def __init__(self,d):
""" Create a d-dimensional vector of zeros"""
self._coords =[0]*d

def __len__(self):
"""Return the dimension of the vector."""
return len(self._coords)

def __getitem__(self, j):


""" Return jth coordinate of vector """
self._coords[j]=val

def __setitem__(self, j, val):


""" Set jth coordinate of vector to given value"""
self._coords[j]=val

def __add__(self, other):


"""Return sum of two vectors."""
if len(self) != len(other):
raise ValueError(’dimensions must agree’)
result = Vector(len(self))
for j in range(len(self)):
result[j]=self[j]+other[j]
return result

def __eq__(self, other):


"""Return True if vector has same coordinates as other."""
return not self == other # rely on existing __eq__ definition

def __str__(self):
"""Produce string representation of vector."""
return ’<’ +str(self.coords)[1:-1]+’>’ # adapt list representation
64CHAPITRE 2. RAPPELS SUR LA PROGRAMMATION ET LE LANGAGE PYTHON

Ci-dessous une simple démonstration du comportement de Vector.


v= Vector(5) # Construit un vecteur de 5 composantes <0,0,0,0,0>
v[1]= 23 # <0,23,0,0,0> conformement a __setitem__
v[-1]= 45 # <0,23,0,0,45> conformement a __setitem
print(v[4]) # imprime sur la console 45 (via __getitem__
u= u+v # <0,46,0,0,90> (via __add__)
print(u) # imprime sur la console <0,46,0,0,90>
total = 0
for entry in v: # iteration implicite via __len__ et __getitem__
total += entry

Itérateur
L’itération est un concept important dans la conception des structures des
données. Un iterator pour une collection supporte la méthode next qui
retourne le prochain élément de la collection et déclenche une erreur StopI-
teration exception lorsqu’il n’y a plus d’éléments. Dans les faits il est rare
que l’on implémente directement une classe iterator. L’approche généralement
préférée est l’usage de la syntaxe d’un générateur qui produit un iterator.
Python aide également en fournissant une implémentation automatique d’un
iterator pour toute classe qui implémente len et getitem . Le code ci-
dessous montre l’implémentation d’un itérateur de bas-niveau qui fonctionne sur
toute collection qui supporte len et getitem . Cette classe peut être ins-
tantiée par l’instruction SequenceIterator(data). Elle conserve une référence
interne de la séquence data, aussi bien que l’indice actuel dans la séquence.
Chaquefois que next est appelé, l’indice est incrémenté jusqu’à ce que l’on
atteigne la fin de la séquence.
class SequenceIterator:
"""An iterator for any Python’s s\’equence type"""
def __init__(self, sequence):
"""Create an iterator for the given sequence."""
self._sequence # keep a reference to the underlying data
self._k = -1 # sera incr\’ement\’e \‘a 0 au premier appel
# de next

def __next__(self):
"""Return the next element, or raise StopIteration error."""
self._k +=1 # advance to next index
if self._k < len(self._seq):
return(self._seq[self._k]) # return the data element
else:
raise StopIteration() # there are no more elements

def __iter__(self):
""" By convention, an iterator must return itself as
2.12. PROGRAMMATION ORIENTÉE OBJET EN PYTHON 65

an iterator"""
return self

2.12.4 Héritage
Une façon naturelle d’organiser structurellement les composants d’un logiciel
est la hiérachisation des classes. Les classes sont organisées des plus spécifiques
aux plus génerales ou de la plus générale aux plus spécifiques. Dans le cas des
batiments on peut avoir la hiérarchie reprise dans la figure 2.12.

Figure 2.12 – Un exemple de hiérarchie de batiments. La relation entre les


clases dans cette hiérachie est le ”is a” ; ”est un”. A house is a building. A ranch
is a house....

Une conception hiéarchique est importante dans le développement de logi-


ciels. Dans ces conditions, les fonctionalités communes peuvent être regroupées
au niveau le plus général, ce qui encourage la réutilisation du code. Dans le même
temps, la différentiation des comportements est vue comme une extension du
général.
En programmation orientée objet le mécanisme qui encourage l’organisation
modulaire et hiérarchique du code est appelé héritage. Ce mécanisme permet
de définir des nouvelles classes sur base des classes existantes. Les classes qui
servent de point de départ à d’autres classes sont les classes de base, les
classes parents, ou les super-classes. Les nouvelles sont quant à elles les
sous-classes ou les classes enfants.
Il y a deux manières par lesquelles une sous-classe peut se différencier de
la superclasse. Une sous-classe peut spécialiser un comportement existant en
fournissant une implémentation qui surcharge une méthode existante. Une sous-
classe peut également étendre sa classe de base en implémentant des nouvelles
méthodes.
Un autre exemple de hierarchie de classes bassé sur l’héritage nous vient
66CHAPITRE 2. RAPPELS SUR LA PROGRAMMATION ET LE LANGAGE PYTHON

des classes des exceptions et des erreurs. La BaseException est la racine de


toute la hiérarchie. Les classes les plus spécifiques introduisent de nouveaux
types d’erreurs. Les programmeurs peuvent définir des classes d’exceptions pour
les erreurs pouvant survenir dans les applications qu’ils mettent au point. Ces
exceptions crées par les programmeurs doivent être déclarées comme des sous-
classes de la classe Exception (Voir figure 2.13).

Figure 2.13 – Hiérarchie des classes d’exceptions et d’erreurs.

Hiérarchie des progressions numériques

Un exemple d’héritage en action est la hiérarchie des classes des progressions


numériques. Une progression numérique est une séquence de nombres ou chaque
nombre dépend de 1 ou de plusieurs nombres précédents. Par exemple, une
progression arithmétique détermine le nombre suivant en ajoutant une constante
appelée raison au nombre précédent xn+1 = xn + k. La raison k = 1, 2, 3, 4, ....
Une progression géométrique détermine le prochain nombre en multipliant le
nombre précédent par une constante appelée raison : xn+1 = xn ∗ k. La raison
k = 2, 3, 4, .... La progression de Fibonacci est une progression dont chaque
terme vaut la somme des deux termes qui le précedent xn+1 = xn + xn−1 .
Pour maximiser la réutilisation du code nous développons une hiérarchie
des classes avec comme classe racine la classe Progression. Techniquement, la
classe Progression génère tous les nombres entiers : 1, 2, 3, 4, .... Cette classe
est conçue pour servir de classe de base aux autres progressions. Elle offre la
gestion de tous les comportements communs des progressions en minimisant
les responsabilités des classes enfants. Ci-dessous la hiearchie des classes de
progressions numériques que nous allons implémenter.
2.12. PROGRAMMATION ORIENTÉE OBJET EN PYTHON 67

Figure 2.14 – Hiérarchie des classes progressions numériques.

Classes abstraites

Lorsque l’on définit un groupe de classes comme faisant partie d’une hiéarchie
des classes basées sur l’héritage afin d’éviter la répétition du code, une des
techniques est de concevoir une classe de base contenant toutes les fonctionalités
pouvant être héritées. Un exemple est la hiérarchie des progressions numériques
décrite dans la section précédente. La classe Progression sert de classe de base
pour trois sous-classes : ArithmeticProgression, GeometricProgression,
et FibonacciProgression.
Quoiqu’il soit possible de créer une instance de la classe de base Progres-
sion, il y a peu d’intérêt à faire cela dans la mesure où cette instance est en fait
une instance d’une progression arithmétrique de raison 1. Le vrai objectif de
la classe Progression est de centralisé les implémentations des comportements
dont les autres progressions ont besoin, ce qui réduit le code relegué dans les
sous-classes.
En terminologie orientée objet, nous disons qu’une classe est une classe abs-
traite de base si sa seule utilité est de servir comme une classe de base dans
la hiéarchie d’héritage. Plus formellement, une classe abstraite de base est une
classe qui ne peut pas être instanciée, alors qu’une classe concrète est une classe
qui peut être instantiée. De part ces définitions, notre classe Progression est
techniquement une classe concrète même si nous l’avons conçue essentiellement
comme une classe abstraite de base. En Java et en C++, une classe abstraite
de base sert comme type permettant la définition des méthodes abstraites. Cela
permet la mise en oeuvre du polymorphisme.
C’est par ce mécanisme qu’une variable peut avoir comme type une classe
abstraite de base même si elle se réfère à une instance d’une sous-classe concrète.
Comme il n’y a pas de déclaration de type en python, ce type de polymorphisme
peut être mis en oeuvre sans qu’on ait absolument besoin d’une classe abstraite
de base. Pour cette raison il n’est pas très habituel de déclaré des classes abs-
traites de base en Python même si le language de programmation dispose des
mécanismes nécessaires pour cela.
La raison pour laquelle nous insistons sur la définition des classes abstraites
de base en Python est le fait que dans l’étude des structures des données abs-
traites le module collection de Python offre des nombreuses classes abstraites
68CHAPITRE 2. RAPPELS SUR LA PROGRAMMATION ET LE LANGAGE PYTHON

de base qui offrent des services au moment de la définition des structures des
données qui partagent une interface commune avec quelques unes des struc-
tures des données internes de Python. Ceci s’appuie sur le pattern orienté objet
connu sous l’appellation de Template method pattern. Le pattern ”Tem-
plate method pattern” consiste dans le fait qu’une classe abstraite de base offre
des comportements concrèts qui s’appuient sur l’appel d’autres comportements
abstraits.
Comme exemple concrèt la classe abstraite de base collections.Sequence
définit des comportements communs aux classes list, str, et tuple comme des
séquences qui permettent l’accès d’un élément par le biais d’un indice entier. La
classe collections.Sequence offre des implémentations concrètes des méthodes
count, index, et contains qui peuvent être héritées par n’importe quelle
classe qui offre des implémentations concrètes de len et getitem . Pour
des fins d’illustration, nous donnons un exemple d’implémentation de la classe
abstraite de base Sequence dans le bout de code ci-dessous :

from abc import ABCMeta, abstractmethod # need these definitions

class Sequence(metaclass=ABCMeta):
"""Our own version of collections.s\’equence abstract
base class."""

@abstractmethod
def __len__ (self):
"""Return the length of the sequence."""

@abstractmethod
def __getitem__ (self, j):
"""Return the element at index j of the sequence."""

def __contains__ (self, val):


"""Return True if val found in the sequence; False
otherwise."""
for j in range(len(self)):
if self[j] == val: # found match
return True
return False

def index(self, val):


"""Return leftmost index at which val is found
(or raise ValueError)."""
for j in range(len(self)):
if self[j] == val: # leftmost match
return j
raise ValueError( value not in s\’equence )
# never found a match
2.12. PROGRAMMATION ORIENTÉE OBJET EN PYTHON 69

def count(self, val):


"""Return the number of elements equal to
given value."""
k = 0
for j in range(len(self)):
if self[j] == val: # found a match
k += 1
return k

2.12.5 Espace des noms


Un espace de noms est une abstraction qui gère toutes les variables définies
dans une portée donnée, associant à chaque nom de variable sa valeur. En Py-
thon, les fonctions, les classes, et les modules sont des ”objets de première
classe”. Ainsi la valeur associée à une variable dans l’espace des noms doit en
fait être une fonction, une classe ou un module.

Espace des noms des instances et des classes


Nous commençons par examiner ce qu’est l’espace des noms d’une instance
qui gère les attributs spécifiques à un objet individuel. Par exemple chaque
instance de notre classe CreditCard gère, une balance, un numéro de compte,
une limite de crédit et ainsi de suite. Chaque instance CreditCard aura un espace
de noms qui gère ces attributs, ces variables.
Il existe également un espace de noms pour chaque classe. Cet espace de
noms est utilisé pour géré tous les membres associé à toutes les instances de
cette classe ou utilisée sans se réferer à une instance particulière. Par exemple
la méthode make payment de la classe CreditCard n’est stockée par aucune
instance de la classe. Cette fonction membre est stockée dans l’espace des noms
de la classe CreditCard. L’espace des noms de la classe CreditCard stocke
aussi les fonctions membres : init , get customer, get bank, get balance,
get limit, charge et make payment. La figure 2.15 illustre les espaces de
noms d’une classe et d’une instance.

Comment sont établies les entrées dans un espace des noms


Il est important de comprendre pourquoi un membre tel que balance réside
dans l’espace de noms d’une instance de CreditCard alors que make payement
réside dans l’espace de noms de la classe. La balance est établie dans la méthode
init au moment de la création d’une nouvelle instance de CreditCard. L’af-
fection originale utilise la syntaxe self. balance = 0, où self identifie le nouvel
objet créer. L’usage de self dans l’opération d’affectation fait que balance est
stocké dans l’espace des noms de l’instance.
L’espace des noms d’une classe comprend toutes les déclations qui sont di-
rectement faites dans le corps de la classe. Dans le body de la classe CreditCard
70CHAPITRE 2. RAPPELS SUR LA PROGRAMMATION ET LE LANGAGE PYTHON

Figure 2.15 – Une vue conceptuelle de trois espaces des noms : (a) Espace
de noms de la classe CreditCard ; (b) Espace des noms de la classe Preda-
toryCreditCard ; (c) Espace des noms d’un instance (un objet) de la classe
PredatoryCreditCard

nous avons : init , get customer, get bank, get balance, get limit, charge
et make payment.

Données membres d’une classe


Une donnée membre de classe est toujours utilisée lorsqu’il y a une valeur,
à l’instar d’une constante, qui doit être partagée par toutes les instances de la
classe. Dans un tel cas, stocké une telle valeur dans chaque instance serait une
perte de d’espace mémoire.

Classes imbriquées
Il est possible de définir une classe à l’intérieur d’une autre classe. Il s’agit
d’une construction interéssante à exploiter de temps en temps. Exemple :

class A: # classe externe


class B: # classe interne
...

Dictionnaire et déclaration des slots


Par défaut, Python représente chaque espace de nom par une instance du
dictionnaire interne de Python. Quoique la structure du dictionnaire permet une
recherche rapide de noms, elle requiert une utilisation de mémoire plus impor-
tante. Python offre un mécanisme plus direct : les instances des espaces de noms
sans avoir recours au dictionnaire. En effet, pour utiliser une représentation
simplifiée de toutes les instances d’une classe, la définition de cette classe doit
disposer d’un membre de niveau classe nommé slots auquel sont affectés
2.12. PROGRAMMATION ORIENTÉE OBJET EN PYTHON 71

une séquence de chaines de caractères devant servir de noms pour les variables
d’instances.

Résolution des noms et typage dynamique


Dans cette section, nous examinons le processus utilisé pour retrouvé un nom
et par conséquent la valeur y associée en Python. Lorsque l’opérateur ”dot” est
utilisé pour accéder à un membre comme dans obj.foo, l’intrepréteur Python
démarre un processus de résolution de nom de la manière suivante :
1. La recherche est d’abord faite dans l’espace des noms de l’instance. Si le
nom est trouvé la valeur lui associée est utilisée ;
2. Autrement, la recherche s’effectue dans l’espace des noms de la classe à
laquelle appartient l’instance. Si le nom est trouvé la valeur lui associée
est utilisée.
3. Si le nom n’est pas trouvé dans l’espace des noms de la classe immédiate,
la recherche se poursuit en suivant la relation d’héritage. Dès que le nom
est trouvé, la valeur lui associée est utilisée.
4. Si le nom n’est pas trouvé après toutes ces recherches, une AttributeEr-
ror est déclenchée.

2.12.6 Copie superficielle et profonde


Dans les chapitres précédents lorsque nous faisions une affectation du type
foo = bar, nous faisons en fait de f oo un alias de l’objet identifié par bar. Dans
cette section nous allons nous interesser à réaliser la copie d’un objet et non à
créer un alias. Cela est parfois nécessaire dans les applications où nous avons
besoin de modifier l’original ou la copie indépendament.
Considérons le scénario dans lequel nous gérons une liste de couleurs, chaque
couleur représentant une instance de la classe couleurs. Nous utilisons warmtones
comme liste des couleurs existantes tels que orange, bleu, brun. Dans cette
application, nous créons une autre liste nommée palette qui est une copie de
la liste warmtones. Nous voudrions pouvoir ajouter des nouvelles couleurs à
palette, changer quelques unes des couleurs existantes sans changer warmtones.
Si nous faisons :

palette = warmtones

Cela aurait fait de palette un alias de warmtones comme on peut le voir sur la
figure 3.17.
Malheureusement cette façon de faire ne résout pas notre problème. Chaque
modification que nous effectuons sur la liste palette est automatiquement répercutée
à la liste warmtones. Nous pouvons par contre créer une nouvelle instance de la
classe couleurs par l’instruction suivante :

palette = list(warmtones)
72CHAPITRE 2. RAPPELS SUR LA PROGRAMMATION ET LE LANGAGE PYTHON

Figure 2.16 – Deux alias pour une même liste de couleurs. palette n’est pas
une copie de warmtones.

Cette deuxième technique fait appel au constructeur de la classe list et lui passe
la liste warmtones comme argument. Une nouvelle instance de la classe liste est
alors crée et affactée à la variable palette comme on peut le voir sur la figure
3.18. Ceci est ce que l’on appelle une copie superficielle. Une nouvelle liste a

Figure 2.17 – Une copie superficielle d’une liste de couleurs.

été crée. Cette liste a été initialisée avec les mêmes couleurs que la première
liste. Compte tenu de la manière dont Python gère les listes, la nouvelle liste
est une série de références vers les mêmes éléments que ceux de la primière
liste. La situation est certes meilleure que celle avec les alias dans la mesure
ou l’on peut ajouter ou retirer des nouvelles couleurs à palette sans modifier
warmtones, mais si nous modifions une couleur de palette, nous changeons alors
le contenu de warmtones. Malgré le fait que palette et warmtones soient des
2.12. PROGRAMMATION ORIENTÉE OBJET EN PYTHON 73

listes différentes, il y a un ”aliasing” indirect. Nous devons alors passer à ce que


l’on appelle une copie profonde.
Pour réaliser une copie profonde nous devons populer la nouvelle liste en
faisant des copies explicites des couleurs de l’instance d’origine. Python offre
un module nommé ”copy” qui permet de réaliser des copies superficielles et
des copies profondes. Ce module dispose de deux méthodes, la méthode copy
qui permet d’effectuer une copie superficielle de son argument et le méthode
deepcopy qui permet d’effectuer une copie profonde de son argument. Voir
figure 3.19.

palette = copy.deepcopy(warmtones)

Figure 2.18 – Une copie profonde d’une liste de couleurs.


74CHAPITRE 2. RAPPELS SUR LA PROGRAMMATION ET LE LANGAGE PYTHON
Chapitre 3

Introduction à
l’algorithmique

3.1 Introduction
De manière informelle un algorithme se définit comme étant une procédure de
calcul qui prend en entrée une valeur, ou un ensemble de valeurs, et qui donne
en sortie une valeur ou un ensemble de valeurs. Un algorithme est donc une
séquence d’étapes de calcul qui transforme une entrée en une sortie.

On peut aussi considérer un algorithme comme un outil permettant de résoudre


un problème de calcul bien spécifié. L’énoncé du problème spécifie en termes
généraux, la relation désirée entre l’entrée et la sortie.

Pour introduire les choses nous allons considérer le problème du tri. Il pour-
rait être posé de la manière suivante :

Entrée : Une suite de n nombres {a1 , a2 , a3 , ..., an }


: à trier selon la relation d’ordre ≤
′ ′ ′ ′
Sortie : Une permutation, une réorganisation a1 , a2 , a3 , ...an
: de la suite donnée en entrée telle que
′ ′ ′ ′
: a1 ≤ a2 ≤ a3 ≤ ... ≤ an
Ainsi à partir de la suite { 31, 89, 57, 63, 20 }, un algorithme de tri (triant
selon la relation d’ordre ≤) produit la suite { 20, 31, 57, 63, 89 }. La suite
donnée en entrée est appelée instance du problème de tri.

Le tri est une opération fréquente en informatique. De nombreux programmes


l’emploient. L’algorithme optimal pour une application donnée dépend entre
autre du nombre d’éléments à trier, de la façon dont les éléments sont plus ou
moins trier initialement, des restrictions potentielles concernant les valeurs des

75
76 CHAPITRE 3. INTRODUCTION À L’ALGORITHMIQUE

éléments, de l’architecture de l’ordinateur, ainsi que du type de périphérique de


stockage utilisé.

Un algorithme est dit correct si, pour chaque instance en entrée, il se termine
en produisant la bonne sortie. Un algorithme correct résout le problème donné.
Un algorithme incorrect risque de ne pas se terminer pour certaines instances
en entrée, voire de se terminer sur une réponse autre que celle désirée. Dans
certains cas, un algorithme incorrect peut s’avérer utile.

Quels sont les types de problèmes susceptibles d’ être résolus par des
algorithmes
Le tri n’est pas l’unique problème pour lequel ont été développé des al-
gorithmes. Les applications concrètes des algorithmes sont innombrables et
couvrent tous les dommaines des problèmes solubles par ordinateur. Entre autres :
ˆ Le projet du génome humain ; l’identification des 100 000 gènes de l’ADN ;
la détermination des séquences de 3 milliards de paires de bases chimiques
qui constituent l’ADN ; le stockage de ces informations, etc. ;
ˆ les algorithmes de routage très utilisés sur Internet ;
ˆ le commerce électronique ;
ˆ L’industrie et le commerce ;
Cette énumération est loin d’être exhaustive, mais témoigne de deux caractéristiques
que l’on retrouve dans bon nombre de problèmes algorithmiques intéressants :
1. Il existe beaucoup de solutions candidates, mais la plupart d’entre elles
ne résolvent pas le problème. Trouver une solution qui convienne, ou une
qui est la meilleure, voilà qui n’est pas toujours évident ;
2. Ils ont des applications concrètes. Exemple dans le cas de la recherche
du chemin le plus court on peut donner l’exemple de la recherche d’un
itinéraire sur le web, ou celle de l’utilisation d’un GPS pour trouver son
chemin. En tout cas, le temps machine comme la mémoire des ordinateurs
restent des ressources à gerer avec parcimonie et les algorithmes perfor-
mants en termes de durée et d’encombrement restent un outil essentiel
dans cette gestion.
Si les ordinateurs étaient infinement rapides et leurs mémoires gratuites, on ne
se poserait pas la question de la nécessité des algorithmes. Toutefois il est clair
qu’en toutes circonstances les algorithmes sont importants ne serait ce que pour
montrer que la solution ne boucle pas indéfinement et qu’elle se termine avec la
bonne réponse.

3.2 Les fondements mathématiques de l’algorith-


mique
L’analyse des algorithmes s’appuie sur les fonctions mathématiques. Les prin-
cipales fonctions mathématiques que l’on retrouve en algorithmique sont : la
3.2. LES FONDEMENTS MATHÉMATIQUES DE L’ALGORITHMIQUE 77

fonction constante, la fonction logarithme, la fonction linéaire, la fonction n log n,


la fonction quadratique, la fonction cubique, les autres polynomes et la fonction
exponentielle (voir figure 4.1).

Figure 3.1 – Croissance de quelques unes des fonctions mathématiques utilisées


dans la notation assymptotique.

3.2.1 La fonction constante

f (n) = c (3.1)

pour des constantes telles que c = 5, c = 30, c = 230 .


Quelque soit la valeur de n, f (n) = c. Comme on s’intéresse aux constantes
entières, la fonction constante la plus fondamentale est g(n) = 1. Toute autre
fonction constante f (n) = c peut se mettre sous la forme :

f (n) = c g(n) (3.2)

La fonction constante est importante en algorithmique, car elle permet de ca-


ractériser le nombre de fois qu’il faut exécuter une opération élémentaire à l’aide
d’un ordinateur pour obtenir un résultat recherché.
78 CHAPITRE 3. INTRODUCTION À L’ALGORITHMIQUE

3.2.2 La fonction logarithme


Un aspect important dans l’analyse des algorithmes est la présence quasi-
permanente de la fonction logarithme

f (n) = logb n (3.3)

pour b > 1.
Cette fonction est définie par :

x = logb n ⇔ bx = n (3.4)

logb 1 = 0 car b0 = 1 ; b est la base du logarithme. La base la plus interressante


pour les ordinateurs est la base 2. Dans les calculateurs on utilise aussi souvent
log10 n. Quelques règles utiles pour les fonctions logarithmes

logb (ac) = logb a + logb c (3.5)


a
logb ( ) = logb a − logb c (3.6)
c
logb ac = clogb a (3.7)
logd a
logb a = (3.8)
logd b
logd a
b = alogd b (3.9)
c c
log n = (logn) (3.10)
log10 n
log2 n = (3.11)
log10 2

3.2.3 La fonction linéaire

f (n) = n (3.12)
Cette fonction apparait dans les algorithmes chaquefois que nous devons réaliser
une simple opération pour chacun de n éléments d’un ensemble. Par exemple
comparer un nombre x à chaque élément d’un tableau de n éléments exigera n
comparaisons.

3.2.4 La fonction n log2 n

f (n) = nlog2 n (3.13)


Cette fonction croit un peu plus vite que la fonction linéaire et moins vite que la
fonction quadratique. En diverses occasions, il est possible de ramener le temps
nécessaire pour résoudre un problème d’une fonction quadratique à une fonction
n log2 n. Ce qui peut représenter un gain non négligeable de temps.
3.2. LES FONDEMENTS MATHÉMATIQUES DE L’ALGORITHMIQUE 79

3.2.5 La fonction quadratique

f (n) = n2 (3.14)
La raison principale pour laquelle la fonction quadratique apparait dans l’ana-
lyse des algorithmes est due au fait qu’il y a plusieurs algorithmes qui exploitent
des boucles imbriquées. Soit n opérations pour la boucle interne, n . n opérations
pour la boucle externe, ce qui fait n2 opérations en tout.

La fonction quadratique peut également apparaitre dans l’analyse des algo-


rithmes lorsque dans le contexte de boucles imbriquées, la première itération
nécessite une opération, la séconde nécessite deux opérations, la troisième nécessite
trois opérations et ainsi de suite. Le nombre d’opérations est alors :

1 + 2 + 3 + ... + (n − 2) + (n − 1) + n (3.15)

C’est le total d’opérations exécutées par la boucle externe lorsque le nombre


d’opérations de la boucle interne crois de 1 à chaque opération de la boucle
externe.

Cette quantité a une histoire. En effet, en 1787, un professeur allemand a


décidé de garder ses élèves de 9 à 10 ans occupés en leur demandant d’ajouter
des entiers de 1 à 100. Mais aussitôt après sa demande un des élèves avait la
réponse : 5050 ; cet élève était Carl Gauss qui est devenu le mathématicien à qui
nous devons les nombreux théorèmes de Gauss. La rapidité avec laquelle Gauss
a trouvé la réponse fait penser qu’il a utiliser la formule ci-dessous :

n(n + 1)
1 + 2 + 3 + 4 + ... + (n − 2) + (n − 1) + n = (3.16)
2
Cette fonction est une fonction quadratique car pour n grand assez on peut
négliger 1/2 par rapport à n/2

3.2.6 La fonction cubique et les autres polynomes

f (n) = n3 (3.17)
Cette fonction apparait moins souvent que les fonctions constante, linéaire et
quadratique mentionnées précédement. Toutefois, elle apparait de temps en
temps.

Toutes les fonctions que nous avons étudié précédement peuvent être considérées
comme faisant partie de la classe des fonctions polynomiales. Une fonction po-
lynomiale est une fonction de la forme

f (n) = a0 + a1 n + a2 n2 + a3 n3 + ... + ad nd (3.18)


80 CHAPITRE 3. INTRODUCTION À L’ALGORITHMIQUE

où a0 , a1 , a2 , ..., ad sont des constantes appelées coefficients du polynome ;


ad ̸= 0. L’entier d qui est la puissance la plus élevée du polynome est appelée
le dégré du polynome. A titre d’exemple, toutes les fonctions suivantes sont des
polynomes :

f (n) = n2 + 5n + 2 (3.19)
f (n) = n3 + 1 (3.20)
f (n) = 1 (3.21)
f (n) = n (3.22)
2
f (n) = n (3.23)
(3.24)

3.2.7 La fonction exponentielle


Une autre fonction souvent utilisée dans l’analyse des algorithmes est la
fonction exponentielle
f (n) = bn b > 0 (3.25)
b est la base et n l’exposant. La base qui apparait le plus souvent dans l’analyse
des algorithmes est la base 2. Si par exemple nous avons une boucle qui effectue
une opération, puis double le nombre d’opérations à chaque itération alors le
nombre d’opérations effectuées à la nieme itération est 2n . Quelques propriétés :

(ba )c = bac (3.26)


a c a+c
b b =b (3.27)
ba
= ba−c (3.28)
bc

3.2.8 Quelques sommations


La sommation suivante apparait de nombreuses fois dans l’analyse des algo-
rithmes
X b
f (i) = f (a) + f (a + 1) + f (a + 2) + .... + f (b) (3.29)
i=a

ou a et b sont des entiers avec a ≤ b


n
X n(n + 1)
i= (3.30)
i=1
2

Un polynome f (n) de degré d dont les coefficients sont a0 ...ad peut être écrit
sous la forme ci-dessous :
Xd
f (n) = ai ni (3.31)
i=0
3.3. EFFICACITÉ D’UN ALGORITHME 81

Progression géométrique ∀ n ≥ 0 et ∀ réel a > 0 et a ̸= 1


Pn i
i=1 a = 1 + a + a2 + a3 + ... + an−1 + an
n+1
= a a−1−1

3.3 Efficacité d’un algorithme


Il arrive que des algorithmes différents conçus pour résoudre le même problè-
me diffèrent fortement entre eux en termes d’efficacité. Ces différences bien
souvent sont plus importantes que celles dues au matériel et au logiciel. Elles
proviennent donc de la nature même des algorithmes.
C’est en analysant expérimentalement ou théoriquement cette valeur in-
trinsèque des algorithmes que nous pourrons identifier les algorithmes les plus
efficaces. L’efficacité ici est synonyme de temps d’exécution minimal comme on
le vera dans les paragraphes qui suivent. Du point de vue de la théorie de la com-
plexité, on dit que différents algorithmes possèdes différents temps d’exécution
ce qui les rends utiles ou pas compte tenu du problème à résoudre et des moyens
disponibles pour cela.
Toutefois les spécialistes de l’informatique travaillent souvent avec deux
types de temp d’exécution pour les algorithmes. En effet, ils font la différence
entre les algorithmes à temps d’exécution polynomial et ceux à temp d’exécution
exponentiel. La différence entre ces deux types d’algorithmes prend une signifi-
cation particulière lorsque l’on considère les solutions des grandes instances des
problèmes (grande taille de l’entrée). Le tableau ci-dessous illustre la différence
de croissances des fonctions utilisées pour exprimer les temps d’exécution des
algorithmes :
Taille n de l’entrée
f (n) 10 20 30 40 50 60
n 0.00001 0.00002 0.00003 0.00004 0.00005 0.00006
secs secs secs secs secs secs
n2 0.0001 0.0004 0.0009 0.0016 0.0025 0.0036
secs secs secs secs secs secs
n3 0.001 0.008 0.027 0.064 0.125 0.216
secs secs secs secs secs secs
5
n 0.1 3.2 24.3 1.7 5.2 13.0
secs secs secs mins mins mins
2n 0.001 1.0 17.9 12.7 35.7 366
secs secs mins jous anns siècs
3n 0.059 58 6.5 3855 2 × 108 1.3 × 1013
secs mins anns siècs siècs siècs
Comparaison des temps d’exécution
de quelques fonctions polynomiales
et exponentielles. secs :secondes ; mins :minutes ;
jous :jours ; anns : années ; siècs : siècles.
82 CHAPITRE 3. INTRODUCTION À L’ALGORITHMIQUE

3.4 Algorithmes et autres technologies


L’on peut se demander si les algorithmes sont vraiment importants en infor-
matique moderne compte tenu de l’état d’avancement des autres technologies
telles que :
ˆ architectures et technologies de fabrication ;
ˆ interface utilisateur convivial et intuitif ;
ˆ systèmes orientés objet ;
ˆ technologies web ;
ˆ technologies réseaux.
La réponse est oui. Certaines applications web simples n’exigent pas d’algo-
rithmes. Toutefois, la plupart d’applications en ont besoin. Prenons le cas d’une
application basée sur le web qui détermine des trajets pour aller d’un endroit à
un autre. Sa mise en oeuvre exige du matériel rapide, une interface utilisateur
graphique, des technologies réseau et des algorithmes efficaces pour certains trai-
tement tels que la recherche d’itinéraires, le dessin de cartes et l’interpolation
d’adresses.

En plus, même une application qui à première vue n’emploie pas d’algorithmes
s’appuie, souvent indirectement, sur une foule d’algorithmes. En effet, l’appli-
cation tourne sur du matériel performant dont la conception est basée sur des
algorithmes. L’application possède une interface graphique utilisateur dont la
conception repose sur de puissants algorithmes. L’application exploite le réseau
et le routage s’appuie fondamentalement sur des algorithmes. Les algorithmes
sont donc au coeur de la plupart des technologies employées dans les ordina-
teurs modernes. Posséder une base solide en algorthmique ou pas fait souvent
la différence entre les programmeurs.

3.5 Conception et analyse des algorithmes


3.5.1 Introduction
Un algorithme est une procédure, permettant, étape par étape, de résoudre
un problème dans un intervalle de temps fini. L’étude des algorithmes est généralement
associée à l’étude de structures des données. Une structure de données est une
façon systématique d’organiser et d’accéder aux données.

Pour être capable d’identifier les bons algorithmes et les bonnes structures
des données, nous devons disposer de bonnes méthodes d’analyse.

Les premiers outils d’analyse d’algorithmes s’appuient sur la mesure du temps


d’exécution des algorithmes et des structures des données. L’exploitation de la
mémoire par les algorithmes est aussi un paramètre important de la mesure de
la qualité des algorithmes.
3.5. CONCEPTION ET ANALYSE DES ALGORITHMES 83

Toutefois, le temps d’exécution est le paramètre matériel le plus utilisé pour


la mesure de l’efficacité des algorithmes. En effet, le temps est une ressource
précieuse et les algorithmes doivent permettre la solution des problèmes sur
ordinateurs dans les délais les plus courts possibles.

En général, le temps d’exécution d’un algorithme ou celui de manipulation


d’une structure de données croit avec la taille de l’entrée, même s’il peut varier
pour des entrées de même taille.

Le temps d’exécution dépend également du Hardware(vitesse du processeur,


architecture de l’ordinateur, la vitesse de la mémoire, ...) et du software(système
d’exploitation, langage de programmation, compilateur, interpréteur, etc.), bref
de l’environnement dans lequel l’algorithme est implémenté et exécuté.

Toutes autres choses restant égales, le temps d’exécution sera plus petit pour
un processeur plus rapide ou si l’implémentation est faite dans un programme
compilé (de façon optimal) plûtot qu’exécuter sur un interpréteur, ou une ma-
chine virtuelle.

En dépit des variations qui proviennent de l’environnement dans lequel l’al-


gorithme tourne, nous allons nous focaliser sur l’analyse des algorithmes avec
la relation entre le temps d’exécution et la taille de l’entrée comme paramètre
principal d’analyse. Dans la littérature on parle de l’analyse de la complexité
d’un algorithme. Dans ce contexte, tout algorithme peut être analyser, soit ex-
perimentalement, soit théoriquement. Le bon algorithme, mieux l’algorithme
efficace est celui qui résout le problème en un temps minimal.

3.5.2 Conception d’un algorithme


Il existe de nombreuses techniques pour concevoir un algorithme. Nous men-
tionnerons entre autre la méthode de la force brute, la méthode gloutonne, la
méthode du diviser pour regner, la méthode probabiliste, et l’approche par la
programmation dynamique.
La méthode de la force brute est une approche qui consiste à essayer toutes
les solutions possibles. Par exemple pour trouver le maximum d’un un ensemble
de nombres, on essaye tous les nombres jusqu’à ce que l’on obtienne le maximum.
Dans la méthode gloutonne on construit une solution de manière incrémentale
en optimisant de manière aveugle un critère local. Un algorithme glouton est un
algorithme qui étape par étape fait le choix d’un optimum local. Dans certains
cas, cette approche permet d’arriver à un optimum global, mais dans le cas
général c’est une heuristique.
Dans l’approche du diviser pour regnér, le problème à résoudre est diviser en
sous-problèmes semblables au problème initial, mais de taille moindre. Ensuite
les sous-problèmes sont résolus de manière récursive et enfin les solutions des
sous-problèmes sont combinées pour avoir la solution du problème original. Le
84 CHAPITRE 3. INTRODUCTION À L’ALGORITHMIQUE

paradigme du diviser pour régner implique trois étapes à chaque niveau de


recursivité, à savoir : diviser, régner et combiner.
La méthode probabiliste fait appel aux nombres aléatoires. Un algorithme
est dit probabiliste lorsqu’il fait des choix aléatoires au cours de son exécution.
Un tel algorithme fait appel à un ou plusieurs générateurs de nombres aléatoires.
L’approche par programmation dynamique : la solution optimale est trouvée
en combinant des solutions optimales d’une série de sous-problèmes qui se che-
vauchent.
On matérialise un algorithme par la production d’un pseudo-code, d’un or-
ganigrame ou d’une implémentation dans un langage de programmation.

Le pseudo-code
Le pseudo-code appelé également Langage de Description d’Algorithmes
(LDA) est une façon de décrire un algorithme sans référence à un langage de
programmation particulier. L’écriture du pseudo-code permet souvent de bien
prendre toute la mesure de la difficulté de la mise en oeuvre de l’algorithme et de
développer une démarche structurée dans la conception de celui-ci. Le pseudo-
code est destiné aux humains, même s’il existe des langages de spécifications qui
au départ du pseudo-code et des diagrammes de toutes sortes peuvent générer
du code.
Le tri par insertion est un algorithme de tri qui pourrait être expliqué en
partant de la manière dont les gens tiennent les cartes à jouer. Au départ la main
gauche du joueur est vide et ses cartes sont posées sur la table. Il prend alors
les cartes de la table une par une, pour les placer dans la main gauche. Pour
savoir ou placer une carte, le joueur la compare avec chacune des cartes déjà
présentes dans sa main gauche en examinant les cartes de droite vers la gauche.
Ci-dessous un exemple de pseudo-code pour l’algorithme du tri par insertion.
1 Pour j=2 à A.longueur
2 clé = A[j]
3 // Insère A[j] dans la séquence triée A[1, ..j-1]
4 i=j−1
5 tant que i > 0 et A[i] >clé
6 A[i + 1] = A[i]
7 i=i−1
8 A[i + 1] =clé

Un organigramme
Un organigramme, également appelé algorigramme, logigramme ou ordi-
nogramme est une représentation graphique normalisée des opérations et des
décisions effectuées par un ordinateur. La norme ISO 5807 décrit en détails les
différents symboles à utiliser pour représenter un programme informatique de
manière normalisée.
3.5. CONCEPTION ET ANALYSE DES ALGORITHMES 85

Figure 3.2 – Mécanisme du tri par insertion. Les cartes sont prises de la table
puis comparées à celles déjà dans la main gauche en partant de la droite vers la
gauche. On s’arrête lorsqu’on a trouvé la position de la carte en fonction de la
relation d’ordre spécifiée par le tri.

Une implémentation en langage de programmation


Le but final d’un algorithme est soit d’être implémenter dans du hardware,
soit dans du software. Dans le cadre de ce cours nous nous limitons au software. Il
est parfois naturel de concevoir un algorithme en présentant une implémentation
dans un langage de programmation. Dans le cadre de ce cours nous présenterons
parfois certains algorithmes directement en Python. Ci-dessous nous présentons
une implémentation de l’algorithme de tri par insertion en Python :

def insertionSort(liste):
for index in range(len(liste)): # on parcourt la liste
item = liste[index] # un element de la liste
j=index
while j>0 and liste[j-1]>item:
liste[j] = liste[j-1]
j=j-1
liste[j] = item # on insere item en place
# Nous allons maintenant tester la fonction
if __name__ == ’__main__’:
myliste = [10, 5, 9, 2, 7, 1]
insertionSort(myliste)
print(myliste)
# On obtient comme resultat l’impression de la liste triee.
[1, 2, 5, 7, 9, 10]

Pseudo-code, organigramme, ou implémentation en langage de pro-


grammation
Selon le contexte, les objectifs de la démarche, les compétences du program-
meur et les attentes du public cible, on concevra et on présentra les algorithmes
selon l’une des trois façons ci-dessus mentionnées. Dans le cadre de ce cours nous
86 CHAPITRE 3. INTRODUCTION À L’ALGORITHMIQUE

Figure 3.3 – Un organigramme exprimant le déroulement de l’algorithme du


tri par insertion.

utiliserons le pseudo-code et l’implémentation en langage de programmation. Il


est bien entendu que les algorithmes sont appelés a être implémenter dans un
langage ou un autre selon les besoins du programmeur.

3.5.3 Analyse expérimentale


Une fois qu’un algorithme est implémenté, nous pouvons étudier son temps
d’exécution en fonction de la taille de l’entrée en mesurant expérimentalement
le temps d’exécution. Des telles mesures sont possibles grâce aux fonctions dis-
ponibles dans les langages de programmation et les systèmes d’exploitation.

Les mesures expérimentales ont trois limitations principales :


— les expériences ne peuvent être faites que sur un nombre limité d’entrées
(d’autres entrées pouvant se réveler importantes sont laissées de coté) ;
— il est difficile de comparer les temps d’exécution expérimentaux de deux
algorithmes sauf si les expériences ont été menées sur les mêmes environ-
nements (Hardware et Software) ;
— On est obligé d’implémenter et d’exécuter un algorithme en vue d’étudier
ses performances. Cette dernière limitation est celle qui requiert le plus
de temps lors d’une étude expérimentale d’un algorithme.
3.5. CONCEPTION ET ANALYSE DES ALGORITHMES 87

Figure 3.4 – Le temp d’exécution expérimental du tri par insertion.

Cette méthodologie associe à chaque algorithme étudier une fonction f(n), qui
caractérise son temps d’exécution en fonction de la taille n de l’entrée. Cette
fonction s’obtient par un fitting approprié des données expérimentales.

Exemple : tri par insertion

Pour mesurer le temps d’exécution, nous avons implémenter l’algorithme


en Python. Puis nous avons fait tourner le programme pour des entrées allant
de 1000 à 400000 nombres entiers générés de manière aléatoire. Les résultats
obtenus sont repris dans le tableau ci-dessous et donnent lieu à la figure 3.4.
Comme on le voit cette figure est une parabole et donc peut etre exprimé par
une équation du type a n2 +b n+c. Ce qui nous fait dire que le temps d’exécution
du tri par insertion est du type Θ (n2 ). Un fitting des données expérimentales
du tableau confirme l’allure quadratique des données.
88 CHAPITRE 3. INTRODUCTION À L’ALGORITHMIQUE

Taille entrée Temps exécution (ms)


1000 31
5000 81
10000 243
15000 483
20000 999
25000 1397
30000 2262
50000 5277
60000 7477
70000 10075
90000 18217
100000 20214
150000 48083
200000 80261
300000 180171
400000 322896

Ci-dessous le code Python utilisé pour produire les résultats du tableau (voir
exercice) :

3.5.4 Analyse théorique


L’analyse expérimentale est valable, mais présente des limites. Si nous sou-
haitons analyser un algorithme particulier sans procéder à des expériences sur
son temps d’exécution, nous pouvons effectuer l’analyse de son pseudo-code. Il
s’agit de l’analyse théorique également appelée analyse des opérations primitives
ou analyse de la complexité. Ici, on s’appuie sur des opérations de base telles
que :
— assigner une valeur à une variable ;
— effectuer une opération arithmétique (exemple : additionner deux nombres) ;
— comparer deux nombres ;
— indexer un tableau ;
— suivre la référence d’un objet ;
— sortir d’une méthode.

En fait, une opération primitive correspond à une instruction de bas-niveau


avec un temps d’exécution constant. Pour déterminer le temps d’exécution d’un
algorithme il suffit de compter le nombre d’opérations primitives exécutées
et pour chaque opération primitive de multiplier ce nombre par son temps
d’exécution constant.
3.5. CONCEPTION ET ANALYSE DES ALGORITHMES 89

Figure 3.5 – Le temp d’exécution de l’algorithme est situé entre une borne
inférieure et une borne supérieure

Il y aurra une correlation entre ce temps d’exécution présumé et le temps


d’exécution sur une machine spécifique. A chaque opération primitive corres-
pond un temps d’exécution constant et il n’y a qu’un nombre limité d’opérations
primitives. Ainsi le nombre d’opérations primitives exécutées par un algorithme
sera proportionel au temps d’exécution de cet algorithme.

En représentant le temps d’exécution d’un algorithme en fonction de la taille


des entrées, on constate que le temps d’exécution se situe entre une borne
supérieure (le plus mauvais cas, cas le plus défavorable) et une borne inférieure
( le cas le plus favorable). voir figure 3.5.

Une analyse basée sur le cas moyen exige que nous puissions évaluer les temps
d’exécution d’une distribution d’entrées, ce qui implique des calculs probabilis-
tiques compliqués. Bien souvent, on base l’analyse sur l’étude du plus mauvais
cas (tous les autres cas sont meilleurs que celui là). L’analyse basée sur le plus
mauvais cas exige que l’on puisse identifier l’entrée correspondant au plus mau-
vais cas et cela est souvent simple à faire. Cette approche mène le plus souvent
au meilleur algorithme.

Exemple : tri par insertion

Approfondissons maintenant l’analyse d’un algorithme en l’occurence l’ago-


rithme du tri par insertion.
90 CHAPITRE 3. INTRODUCTION À L’ALGORITHMIQUE

Figure 3.6 – Illustration du tri par insertion

Entrée : Une suite de n nombres a1 , a2 , a3 , ...an


: ≤ est ici la relation d’ordre selon laquelle se fait le tri.
: Il pourrait s’agir d’une autre relation ≥, <, > ; ...
Sortie : une permutation(une réorganisation) de la suite
′ ′ ′ ′
: a1 , a2 , a3 , ...an telle que
′ ′ ′ ′
: a1 ≤ a2 ≤ a3 ≤ ... ≤ an

Sur le plan théorique, nous trions une suite, mais l’entrée se présente sous la
forme de n éléments. Le tri par insertion s’inspire de la manière dont la plupart
des gens tiennent des cartes à jouer. Au début la main gauche du joueur est
vide et ses cartes sont posées sur la table (voir figure 3.4). Il prend alors des
cartes sur la table une par une, pour les placer dans la main gauche. Pour savoir
oú placer une carte dans son jeu, le joueur la compare avec chacune des cartes
déjà présente dans sa main gauche, en examinant les cartes de la droite vers la
gauche. Ainsi à tout moment les cartes qu’il tient à la main gauche sont triées.
En pseudo-code l’algorithme que nous venons de décrire peut se résumé comme
ci-dessous :
3.5. CONCEPTION ET ANALYSE DES ALGORITHMES 91

1 Pour j=2 à A.longueur


2 clé = A[j]
3 // Insère A[j] dans la séquence triée A[1, ..j-1]
4 i=j−1
5 tant que i > 0 et A[i] >clé
6 A[i + 1] = A[i]
7 i=i−1
8 A[i + 1] =clé
Nous allons analyser cet algorithme. Il est bien évident que le tri de 1000
nombres prendra plus de temps que celui de 5 nombres. En plus le tri peut
demander des temps différents pour trier deux entrées de même taille, selon
qu’elles sont déjà plus ou moins triées. En général le temps d’exécution d’un
algorithme croit avec la taille de l’entrée. On a donc pris l’habitude d’exprimer
le temps d’exécution d’un algorithme en fonction de la taille de son entrée. Reste
à définir “temps d’exécution” et “taille de l’entrée”.

Le sens exact de “taille d’une entrée” dépend du problème à résoudre. Pour


de nombreux problèmes tels que le tri, le calcul de la transformée de Fourrier
discrète, le sens le plus naturel pour “taille d’entrée” est le nombre d’éléments
constituant l’entrée, par exemple la longueur n du tableau à trier.

Pour beaucoup d’autres problèmes tels que la multiplication de deux entiers,


la meilleure mesure de la taille de l’entrée est le nombre total de bits nécessaires
à la représentation de l’entrée dans la notation binaire habituelle. Parfois il est
plus approprié de décrire une entrée avec deux nombres au lieu d’un seul. Par
exemple si l’entrée d’un algorithme est un graphe, on pourra décrire la taille
de l’entrée par le nombre de sommets et le nombre d’arcs. Ainsi pour chaque
problème nous indiquerons la mesure utilisée pour exprimer la taille de l’entrée.

Le temps d’exécution d’un algorithme pour une entrée particulière est le


nombre d’opérations élémentaires, ou “étapes”, exécutées. Il est commode de
définir la notion d’étape de façon qu’elle soit la plus indépendante possible de la
machine. Pour le moment nous adopterons le point de vue suivant : l’exécution
d’une ligne de pseudo-code demande un temps constant. Deux lignes différentes
peuvent prendre des temps différents, mais chaque i-ème ligne prend un temps
ci , ci étant une constante.

Nous pouvons maintenent reprendre le pseudo-code de l’algorithme du tri par


insertion en y associant cette fois-ci des paramètres relatifs au temps d’exécution.
92 CHAPITRE 3. INTRODUCTION À L’ALGORITHMIQUE

Ligne Instruction Coût Nombre de fois


d’exécution
de l’instruction
1 Pour j=2 à A.longueur c1 n
2 clé = A[j] c2 n-1
3 // Insère A[j] dans 0 n-1
la séquence triée A[1, ..j-1]
4 i=j−1 c4 Pn n-1
5 tant que i > 0 et A[i] >clé c5 j=2 tj
Pn
6 A[i + 1] = A[i] c6 (t j − 1)
Pj=2
n
7 i=i−1 c7 j=2 (tj − 1)
8 A[i + 1] =clé c8 n-1

Le temps d’exécution de l’algorithme est la somme des temps d’exécution


de toutes les instructions. Une instruction qui demande un coût ci et qui est
exécutée n fois compte pour n ci dans le temps d’exécution total. Ainsi le temps
total d’exécution T(n) pour le tri par insertion est donné par :
n
X n
X n
X
T (n) = c1 n+c2 (n−1)+c4 (n−1)+c5 tj +c6 (tj −1)+c7 (tj −1)+c8 (n−1)
j=2 j=2 j=2
(3.32)

Même pour des entrées ayant la même taille, le temps d’exécution d’un al-
gorithme peut dépendre de l’entrée particulière ayant cette taille. Dans le cas
du tri par insertion, le cas le plus favorable est celui ou le tableau en entrée est
déjà trié. Dans ce cas pour j = 2,3, ....n, on trouve que A[j] ≤ clé en ligne 5
quand i prend sa valeur initiale de j-1. Donc tj = 1 pour j = 2,3, ...n. Le temps
d’exécution associé à ce cas optimal est donc :

T (n) = c1 n + c2 (n − 1) + c4 (n − 1) + c5 (n − 1) + c8 (n − 1) (3.33)
= (c1 + c2 + c4 + c5 + c8 )n − (c2 + c4 + c5 + c8 ) (3.34)

Ce temps d’exécution peut être exprimé sous la forme an + b, a et b étant


des constantes dépendant des coûts ci des instructions ; c’est donc une fonction
linéaire de n.

Si le tableau est trié dans l’ordre décroissant alors c’est le cas le plus défavorable.
On doit comparer chaque élément A[j] avec chaque élément du sous tableau trié
A[1..j − 1], et donc tj = j pour j = 2,3,...,n. Si l’on se souvient du fait que :
n
X n(n + 1)
j = −1 (3.35)
j=2
2
3.5. CONCEPTION ET ANALYSE DES ALGORITHMES 93

n
X n(n − 1)
(j − 1) = (3.36)
j=2
2

Alors le temps d’exécution pour le tri par insertion dans le cas le plus défavorable
est donné par :

n(n + 1) n(n − 1)
T (n) = c1 n + c2 (n − 1) + c4 (n − 1) + c5 ( − 1) + c6 ( )+
2 2
n(n − 1)
c7 ( ) + c8 (n − 1)
2
c5 c6 c7
= ( + + )n2 + (c1 + c2 + c4 +
2 2 2
c5 c6 c7
− − + c8 )n − (c2 + c4 + c5 + c8 )
2 2 2

Le temps d’exécution du cas le plus défavorable s’exprime sous la forme an2 +


bn + c ou a, b, c sont des constantes dépendants des coûts ci des instructions ;
c’est donc une fonction quadratique de n.

Généralement, le temps d’exécution d’un algorithme est constant pour une


entrée donnée. Il existe toutefois des cas ou cette règle n’est pas respectée comme
nous venons de le voir.

Analyse du cas le plus défavorable ou du cas moyen


Dans notre analyse du tri par insertion, nous nous sommes interessés aussi
bien au cas le plus favorable, celui où l’entrée est déjà trié, qu’au cas le plus
défavorable, celui où le tableau en entrée est trié en ordre inverse. Dans la
suite du cours cependant, nous aurons pour objectif de déterminer le temps
d’exécution dans le cas le plus défavorable, c’est-à-dire le temps d’exécution
maximal pour une quelconque entrée de taille n. En faveur de cette philosophie
nous pourrions retenir les arguments suivants :
— Le temps d’exécution associé au cas le plus défavorable est une borne
supérieure du temps d’exécution associé à une entrée quelconque. Connaitre
cette valeur nous permettra donc d’avoir la certitude que l’algorithme ne
mettra jamais plus de temps que cette limite ;
— Pour certains algorithmes, le cas le plus défavorable survient assez sou-
vent. Par exemple la recherche dans une base de données d’une informa-
tion qui ne s’y trouve pas ;
— Il n’est pas rare que le “cas moyen” soit aussi mauvais que le cas le plus
défavorable.

Ordre de grandeur
Nous avons utilisé des hypothèses simplificatrices pour faciliter notre analyse
de la procédure tri-insertion. D’abord nous avons ignoré le coût réel de chaque
instruction en employant des constantes ci pour représenter ces coûts. Ensuite,
94 CHAPITRE 3. INTRODUCTION À L’ALGORITHMIQUE

nous avons observé que même ces constantes nous donnent plus de détails que
nécessaires : le temps d’exécution du cas le plus défavorable est an2 + bn + c,
a, b, c étant des constantes qui dépendent des coûts ci des instructions. Nous
avons non seulement ignoré les coûts réels des instructions, mais aussi les coûts
abstraits ci .

Nous allons introduire une simplification supplémentaire. Ce qui nous intéresse


vraiment, c’est le taux de croissance, ou ordre de grandeur du temps d’exécution.
On ne considérera donc que le terme dominant d’une formule (Par exemple de
an2 + an + b, on ne retiendra que an2 ), puisque les termes d’ordre inférieur de-
viennent moins significatifs lorsque n devient très grand. On ignorera également
le coefficient constant du terme dominant, puisque les facteurs constants sont
moins importants que l’ordre de grandeur pour ce qui est de la détermination
de l’efficacité du calcul pour les entrées volumineuses. Dans le cas du tri en igno-
rants les termes d’ordre inférieur et le coefficient constant du terme dominant,
il nous reste le facteur n2 . On écrira donc que le tri par insertion a, dans le cas
le plus défavorable, un temps d’exécution Θ(n2 ).

On considère généralement qu’un algorithme est plus efficace qu’un autre si


le temps d’éxecution de son cas le plus défavorable a un ordre de grandeur
inférieur. Compte tenu des facteurs constants et des termes d’ordre inférieur,
un algorithme dont le temps d’exécution a un ordre de grandeur supérieur peut
prendre moins de temps pour des entrées de petite taille qu’un algorithme dont
le temps d’exécution a un ordre de grandeur inférieur. Par contre pour des
entrées de grande taille un algorithme en Θ(n2 ) s’exécutera toujours plus vite
qu’un algorithme en Θ(n3 ).

3.5.5 Notation assymptotique


Nous utiliserons la notation assymptotique pour décrire les temps d’exécution
des algorithmes.

Dans le cas du tri par insertion par exemple, le temps d’exécution est une
fonction quadratique de la taille n de l’entrée pour le cas le plus défavorable.

T (n) = Θ (n2 ) (3.37)

Ici Θ (n2 ) est une représentation assymptotique de la fonction a n2 + b n + c,


qui exprime le temps d’exécution du cas le plus défavorable.

Les fonctions auxquelles nous appliquerons la notation assymptotique ca-


ractérisent généralement le temps d’exécution d’un algorithme. Elles pourraient
caractériser un autre aspect des algorithmes (par exemple l’exploitation de la
mémoire).
3.5. CONCEPTION ET ANALYSE DES ALGORITHMES 95

Même quand on applique la notation assymptotique pour caractériser le temps


d’exécution, il est bon de savoir s’il s’agit du temps du cas le plus défavorable,
de celui du cas le plus favorable ou si nous cherchons un énoncé général qui
englobe toutes les entrées.

Notation Θ

Pour le tri par insertion

T (n) = Θ(n2 ) (3.38)


Définissons le sens de cette notation. Pour une fonction donnée g(n), on note
Θ (g(n)) l’ensemble de fonctions suivantes :
Θ(g(n)) = {f (n) : il existe des constantes c1 , c2 et n0 (3.39)
telles que 0 ≤ c1 g(n) ≤ f (n) ≤ c2 g(n) pour tout n ≥ n0 }
Comme Θ (g(n)) est un ensemble, on pourrait écrire f (n) ε Θ (g(n)) pour indi-
quer l’appartenance de f(n) à un ensemble. On préfere toutefois écrire
f (n) = Θ(g(n)) (3.40)
La figure 3.5 schématise cette situation. Pour toutes les valeurs de n > n0
f(n) est supérieur ou égal à c1 g(n) et inférieur ou égal à c2 g(n). Autrement dit,
pour toutes les valeurs n ≥ n0 , f(n) est égal à g(n) à un facteur constant près. On
dit que g(n) est une borne assymptotiquement approchée de f(n). La définition
de Θ (g(n)) impose que chaque f (n) ε Θ (g(n)) soit assymptotiquement positive.
Une fonction assymptotiquement positive est une fonction qui est strictement
positive pour n suffisament grand.

Notation O

La notation Θ borne assymptotiquement une fonction à la fois par excès


et par défaut. Quant on ne dispose que d’une borne supérieure , on utilise la
notatation O. Pour une fonction g(n), on note O(g(n)) l’ensemble de fonctions
suivantes :
O(g(n)) = {f (n) : il existe des constantes c et no telles que (3.41)
0 ≤ f (n) ≤ cg(n) pour tout n ≥ n0 } (3.42)

La notation O sert à majorer une fonction à un facteur constant près. Pour


indiquer que f(n) est membre de O(g(n)) on écrit f (n) = O(g(n)). La figure 3.7
schématise cette situation.

Propriété :

f (n) = Θ(g(n)) ⇒ f (n) = O(g(n)) (3.43)


96 CHAPITRE 3. INTRODUCTION À L’ALGORITHMIQUE

Figure 3.7 – Notation assymptotique O

Puisque la notation O décrit un majorant, quant on l’utilise pour borner le


temps d’exécution du cas le plus défavorable d’un algorithme, on borne donc
aussi le temps d’exécution de cet algorithme pour toute entrée quelconque.

Notation Ω

De même que la notation O fournit une borne supérieure assymptotique pour


une fonction, la notation Ω fournit un minorant assymptotique. La figure 3.8
illustre cette situation.
Ω(g(n)) = {f (n) : il existe des constantes positives c et n0 (3.44)
telles que 0 ≤ cg(n) ≤ f (n) pour tout n ≥ n0 } (3.45)
Théorème
Pour deux fonctions f(n) et g(n), on a
f (n) = Θ(g(n)) ⇔ f (n) = O(g(n)) et f (n) = Ω(g(n)) (3.46)

Notation o

La borne supérieure assymptotique fournie par la notation O peut être ou


non assymptotiquement serée. En notant f (n) = O(g(n)) on veut tout simple-
ment dire qu’un multiple c g(n) de g(n) est majorant de f(n) sans indiquer quoi
que ce soit sur le dégré d’approxtimation. La borne 2 n2 = O(n2 ) est assympto-
tiquement serée, mais la borne 2 n = O(n2 ) ne l’est pas. On utilise la notation
o pour noter une borne supérieure qui n’est pas assymptotiquement serée.

o(g(n)) = {f (n) : pour toute constante c > 0, il existe une (3.47)


3.5. CONCEPTION ET ANALYSE DES ALGORITHMES 97

Figure 3.8 – Notation assymptotique Ω

constante no > 0 telle que 0 ≤ f (n) ≤ cg(n) pour tout n ≥ n0 }

Les définitions O(g(n)) et o(g(n)) se ressemblent. La différence principale


réside dans le fait que dans f (n) = O(g(n)), la borne 0 ≤ f (n) ≤ cg(n) est
valable pour une certaine constante c > 0, alors que dans f (n) = o(g(n)) la
borne 0 ≤ f (n) ≤ cg(n) est valable pour toutes les constantes c > 0.

Notation w

Par analogie, la notation w est à la notation Ω ce que la notation o est à


la notation O. On utilise la notation w pour indiquer une borne inférieure qui
n’est pas astymptotiquement serée. Une façon de la définir est

f (n) ε wg(n) ⇔ g(n) ε o(f (n)) (3.48)

w(g(n)) = {f (n) : pour toute constante c > 0, il (3.49)


existe une constante n0 > 0
telle que 0 ≤ cg(n) ≤ f (n) pour tout n ≥ n0 }

La relation f (n) ε w(g(n)) implique limn→∞ fg(n)


(n)
= ∞. C’est-à-dire que f(n)
devient arbitrairement grande devant g(n) lorsque n → ∞.
98 CHAPITRE 3. INTRODUCTION À L’ALGORITHMIQUE
Chapitre 4

La Récursivité

4.1 Introduction
Une façon de réaliser une répétition dans un ordinateur est de recourir à
une boucle. En Python nous avons les boucles while et for. Une autre manière
totalement différente de réaliser une répétition est de recourir à la récursivité.
La récursivité est un processus par lequel une fonction s’appelle elle même
au cours de son exécution. Ce processus se manifiste également lorsque pour
construire une structure de données on commence par la décomposer, une ou
plusieurs fois, en structures identiques à la structure de départ, mais plus fa-
ciles à manipuler. Ensuite on résout le problème pour les petites structures
avant d’obtenir la solution du problème pour la structure de départ en combi-
nant les solutions obtenues pour les petites structures. Ainsi en informatique la
récursivité nous offre une manière élégante d’effectuer des tâches répétitives. La
récursivité est une technique importante pour l’étude des structures des données.
Dans ce chapitre, nous allons illustré la recursivité par quelques exemples :
— La fonction factorielle
— La recherche binaire
— Le Système de fichiers
Nous étudierons ensuite comment effectuer une analyse formelle du temps d’exécution
d’un algorithme récursif avant d’examiner certains pièges potentiels lors de la
mise en oeuvre des processus récursifs.

4.2 Quelques exemples illustrant la récursivité


4.2.1 La fonction factorielle
Le factoriel d’un entier positif n se note n ! et est défini comme étant le
produit de tous les entiers de 1 à n. Si n=0, alors le factoriel de n vaut 1. De

99
100 CHAPITRE 4. LA RÉCURSIVITÉ

façon plus formelle pour tout entier n ≥ 0 :



1 si n = 0
n! =
n . (n − 1) . (n − 2) ... 3 . 2 . 1 si n ≥ 1
Exemple 5! = 5 . 4 . 3 . 2 . 1 = 120. La fonction factorielle est importante dans la
mesure ou elle représente le nombre de permutations possible de n objets. Par
exemple le nombre de permutations possible de trois caractères abc est 3! = 6.
Soit : abc, acb, bac, bca, cab, cba. Il existe une définition récursive de la
fonction factorielle. Il suffit de remarquer que 5! = 5. (4 . 3 . 2 . 1) = 5 . 4! pour
arriver à cette définition. En effet, de façon générale on peut dire que :

1 si n = 0
n! =
n . (n − 1)! si n ≥ 1
Ci-dessous, une implémentation récursive de la fonction factorielle.
def factoriel(n):
if n == 0:
return 1
else:
return n*factoriel(n-1)
Comme on le voit l’implémentation est simple, facile à comprendre et même
élégante. Elle n’utilise pas de boucle. La répétition est obtenue par l’appel
récursif de la fonction. Ci-dessous une implémention non récursive de facto-
riel. Comme on peut le remarquer, elle est plus compliquée que celle basée sur
la récursivité.
def factoriel(n):
if n == 0:
return 1
else:
f=1
for k in range(2,n+1):
f = f *k
return f

4.2.2 La recherche binaire


Dans cette section, nous allons aborder un algorithme récursif classique, en
l’occurence la recherche binaire. Cet algorithme est utilisé pour rechercher un
objet dans une séquence triée d’objets. Il s’agit d’un des plus importants algo-
rithme en usage. C’est d’ailleurs pourquoi on a tendance à stocker les données
dans un certain ordre.

0 1 2 3 4 5 6 7 8 9 10 11 12 13 Indice
2 4 5 7 8 9 12 14 17 19 22 25 27 28 Valeur
4.2. QUELQUES EXEMPLES ILLUSTRANT LA RÉCURSIVITÉ 101

La première ligne du tableau ci-dessus nous indique l’indice de chaqu’élément


dans le tableau. Dans la seconde ligne nous avons les éléments du tableau qui
sont ici des entiers. Ils sont triés comme vous pouvez le constater. Lorsque la
séquence d’éléments n’est pas triée, l’approche habituelle pour rechercher un
élément est d’utiliser une boucle pour examiner chaque élément jusqu’à ce que
l’on trouve l’élément rechercher ou que l’on atteigne la fin de la séquence. Cet
algorithme est connu sous l’appellation d’algorithme de recherche séquentielle.
Son temps d’exécution est O(n) parce que dans le plus mauvais cas, chaque
élément est examiné.

Lorsque la séquence est triée et indexable il existe un bien meilleur algo-


rithme. En effet, pour chaque indice j nous savons que tous les éléments dont
l’indice est compris en low et j-1 sont inférieurs ou égaux à l’élément d’indice j.
De même nous savons que les éléments dont les indices sont compris entre j+1
et n-1 sont supérieurs ou égaux à l’élément d’indice j. Cette observation nous
permet de formuler rapidement notre algorithme. L’algorithme doit maintenir
deux paramètres low et high de telle sorte que chaque candidat à la compa-
raison possède un indice qui est au minumum low et au maximum high. Au
commencement de la recherche low = 0 et high = n − 1. Nous commencons
la recherche en comprarant l’élément rechercher avec l’élément médian qui est
data[mid] avec
mid = [(low + high)/2] (4.1)
Nous considérons trois cas :
— si l’élément rechercher égal à data[mid], nous avons alors trouver l’objet
de notre recherche et la recherche s’arrête ;
— si l’élément rechreché est plus petit que data[mid], alors nous savons que
nous devons désormais limiter notre recherche dans la première moitié
de notre séquence. Il s’agit de l’intervalle dont les indices sont compris
ente low = 0 et mid − 1 ;
— si l’élément recherché est supérieur à data[mid], alors nous devons cherché
notre élément dans l’autre moitié de notre sequence. Il s’agit de la séquence
dont les indices des éléments vont de data[mid + 1] à high = n − 1.
Une recherche infructueuse peut arriver lorsque low > high, ce qui peut se pro-
duire lorsque l’intervalle [low, high] est vide. L’algorithme ci-dessus est connu
comme l’algorithme de la recherche binaire. Nous présentons ci-dessous une
impléntation de la recherche binaire en Python.

def binary_search(data, target, low, high):


""" Return True if target is found in
indicated portion of the Python list.
The search only considers the portion
from data[low] to data[high] inclusive.
102 CHAPITRE 4. LA RÉCURSIVITÉ

"""
if low > high:
return false
else:
mid = (low+high)//2
if target == data[mid]:
return True
elif target < data[mid]:
# recur on the portion left of the midle
return binary_search(data,target, low, mid-1)
else:
# recur on the portion right of the middle
return binary_search(data, target, mid+1, high)

Le tableau ci-dessous nous indique comment se fait la recherche du nombre 22


dans la séquence d’entiers que nous avons donné en exemple au début de la
section.

0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15

2 4 5 7 8 9 12 14 17 19 22 25 27 28 33 37
↑ ↑ ↑
l m h
2 4 5 7 8 9 12 14 17 19 22 25 27 28 33 37
↑ ↑ ↑
l m h
2 4 5 7 8 9 12 14 17 19 22 25 27 28 33 37
↑ ↑ ↑
l m h
2 4 5 7 8 9 12 14 17 19 22 25 27 28 33 37

l=
m=
h

La première ligne de ce tableau nous donne les indices des différents éléments
du tableau. l=low ; m=mid ; h=high. Les flêches vers le haut ainsi que l, m, et
h nous permettent de suivre la recherche de l’élément 22 au fur et à mesure des
appels récursifs de la fonction.
4.2. QUELQUES EXEMPLES ILLUSTRANT LA RÉCURSIVITÉ 103

Figure 4.1 – Une vue de l’arborescence du système de fichiers d’un système


d’exploitation moderne.

4.2.3 Le système de fichiers


Les systèmes d’exploitation modernes définissent un système de gestion des
fichiers et des répertoires récursif. Un tel système consiste en général en un
répertoire racine qui contient des fichiers et d’autres répertoires. Le système
d’exploitation permet aux répertoires de contenir d’autres répertoires et cela
sans aucune limitation (la seule limitation ici est la mémoire de masse de l’ordi-
nateur). La figure 4.1 donne une illustration d’un tel système de fichiers. Etant
donné le caractère récursif du système de fichiers, il n’est pas surprennant que
certaines de commandes du système d’exploitation telle que la copie ou la sup-
pression d’un répertoire soient implémentées par des algorithmes récursifs. Dans
ces notes de cours nous allons présenter un algorithme qui permet de calculer
l’espace disque occupé par l’ensemble de répertoires et fichiers.
La figure 4.2 présente l’espace disque utilisé par chacune des entrée dans
notre système de fichiers. Nous faisons la différence entre l’espace disque occupé
par chaque entrée et l’espace disque occupé par cette entrée et tous les éléments
qu’elle contient. Par exemple le répertoire cs016 occupe 2K, mais pris avec
les autres répertoires et fichiers qu’il contient ce répertoire occupe 249 K. Le
pseudo-code de notre algorithme se présente de la manière suivante :
Algorithm DiskUsage(path):
Input: Une chaine de caractere indiquant
104 CHAPITRE 4. LA RÉCURSIVITÉ

Figure 4.2 – Une vue du système de fichier avec l’espace occupé par chaque
répertoire ou fichier. Dans le bloc représentant un élément est mentionné l’espace
disque qu’il occupe. Au-dessus de chaque bloc est mentionné l’espace occupé par
ce bloc et tous les répertoires et fichiers qu’il contient.

le nom du systeme de fichiers


Output: L’espace occupe par ce systeme
de fichiers et tous les repertoires
et fichiers qu’il contient
total = size(path)
#espace occupe par le repertoire principal
if path represents a directory then
for each child entry stored within directory path do
total=total+DiskUsage(child)
#appel recursif de DiskUsage
return total

Pour obtenir une implémentation de notre algorithme en python nous allos nous
appuyer sur le module os de Python. Ce module est une grande librairie, mais
nous allons utiliser seulement les fonctions suivantes :
— os.path.getsize(path). Retourne l’espace occupé par le fichier ou le
répertoire identifé par la chaine de caractère path en byte (example
/usr/rt/courses).
— os.path.isdir(path). Retourne True si l’entrée désignée par path est un
répertoire ; et False dans le cas contraire.
— os.listdir(path). Retourne la liste de chaines de caractères qui sont les
4.3. ANALYSE D’UN ALGORITHME RÉCURSIF 105

noms de tous les répertoire et fichiers contenues dans l’entrée path.


— os.path.join(path, filename). Compose la chaine de caractère path
avec filename en utilisant les séparateurs appropriés du système d’exploi-
tation. Retourne une chaine de caractère indiquant le nom complet du
fichier (tout le chemin).
Ci-dessous l’implémentation de l’algorithme.

import os

def disk_usage(path):
""" Return the number of bytes used by
the file or the folder and descandants
"""
total = os.path.getsize(path)
# account for direct usage
if os.path.isdir(path):
# if this is a directory
for filename in os.listdir(path):
# then for each child
childpath = os.path.join(path, filename)
total += disk_usage(childpath)
# appel r\’ecursif
print(’{0:7}.format(total), path)
return total

4.3 Analyse d’un algorithme récursif


Nous avons déjà introduit la technique mathématique d’évaluation de l’effi-
cacité d’un algorithme basé sur l’estimation du nombre d’opérations primitives
exécutées par l’algorithme. Nous allons appliquer cette technique à l’analyse
d’un algorithme récursif. Dans ce cas on a besoin de la trace d’activation de
la fonction récursive. Pour chaque activation, il suffit de compter les opérations
primitives nécessaires pour la paramétrisation et celles qui sont exécutées dans le
corps de la fonction. On pourra obtenir le nombre total d’opérations primitives
en sommant les opérations primitives exécutées pour toutes les activations de la
fonction récursive. Examinons les trois fonctions récursives données en exemple
pour montrer concrètement comment cela se passe.

4.3.1 Calcul du temps d’exécution de factoriel(n)


Dans le cas du calcul de factoriel(4). On commence le calcul avec n = 4.
On teste si n == 0, la réponse est False. On passe donc au calcul du factoriel.
106 CHAPITRE 4. LA RÉCURSIVITÉ

4! premier appel de factoriel


4! = 4 . 3! deuxième appel de factoriel
4! = 4 . 3 . 2! troisième appel de factoriel
4! = 4 . 3 . 2 .1! Quatrième appel de factoriel
4! = 4 . 3 . 2 . 1 . 0! cinquième appel de factoriel
4! = 4 . 3 . 2 . 1 . 1

Comme on peut le constater, il y a 5 appels de la fonction récurive. Soit n+1


appels. En fait, on part de n à 0 en descendant par pas de 1. Chaque appel de
factoriel exécute un nombre constant d’opérations. Ainsi, on peut conclure que
le nombre total d’opérations primitives pour calculer factoriel(n) est O(n) car il
y a n+1 activations s’exécutant chacune en un temps O(1).

4.3.2 Calcul du temps d’exécution de la recherche binaire


Dans le cas de l’algorithme de recherche binaire nous constatons qu’un
nombre constant d’opérations primitives est exécuté à chaque appel de la fonc-
tion binary search(). Ainsi, le temps d’exécution est proportionnel au nombre
d’appels récursifs effectués. On va montrer qu’au moins [log n]+1 appels récursifs
ont été effectuées au cours de la recherche d’un élément dans une séquence de
n éléments par binary search(). On peut donc affirmer que la fonction bi-
nary search() a un temps d’exécution de O(log n) lorsque la recheche s’effectue
sur une séquence, déjà triée, de n éléments . Pour preuve de cette affirmation,
nous partons du fait que pour chaque appel récursif le nombre de candidats
(élément recherché) vaut :
high − low + 1 (4.2)

Initialement le nombre de candidats est n ; après un appel de binary search()


il passe à n/2 ; après un second appel il passe à n/4 ; et ainsi de suite. En général
après le j ième appel de binary search(), le nombre de candidat est n/2j . Dans
le plus mauvais cas, les appels de la fonction récursive s’arrêtent lorsqu’il n’y a
plus de candidats. Ainsi, le nombre maximum d’appels récursifs est le plus petit
entier r tel que
n
<1 (4.3)
2r
Si nous nous souvenons que la base de notre logarithme est 2, on a r > log n.
En d’autres mots
r = [log n] + 1 (4.4)

Ce qui implique que le temps d’exécution de binary search() est O(log n).
4.4. QUELQUES AUTRES EXEMPLES DE RÉCURSIVITÉ 107

4.3.3 Calcul du temps d’exécution de l’évaluation de l’es-


pace disque occupé par un répertoire donné
Notre dernier exemple était celui permettant de calculer l’espace disque oc-
cupé par une portion d’un système de fichiers. Pour caractériser la taille du
problème nous avons utiliser le nombre n d’entrées dans le système de fichier
(dans la figure 4.1 nous avons 19 entrées).
Pour estimer le temps d’exécution de l’appel initial de disk usage() nous
devons compter le nombre total d’appels récursifs faits ainsi que le nombre
d’opérations primitives exécutées dans chaque appel. Nous pourions dire que
le temps d’exécution de l’algorithme est O(1) pour chaque invocation de la
fonction, mais malheureusement ce n’est pas le cas.
Bien qu’il existe un nombre constant d’opérations primitives dans l’appel de
os.path.getsize() pour calculer l’espace disque utilisé directement à un niveau
de l’entrée, lorsque l’entrée est un répertoire, le corps de la fonction de calcul
de l’espace utilisé comprend une boucle for qui parcours toutes les entrées de
ce répertoire. Dans le pire des cas, il est possible qu’une entrée contienne n − 1
entrées. Ainsi nous pouvons conclure que nous avons O(n) appels récursifs,
chaque appel récursif ayant un temps d’exécution O(n), nous pouvons conclure
que le temps d’exécution total est O(n) ∗ O(n) = O(n2 ).

4.4 Quelques autres exemples de récursivité


4.4.1 Introduction
Pour mieux clarifier ce problème de la récursivité qui est très important en
informatique, nous présentons quelques autres exemples d’algorithmes récursifs.
Nous avons organisé la présentation en nous basant sur le nombre d’appels
récursifs qui sont effectués par le corps de la fonction lors d’une seule activation
de ladite fonction :
— Si l’appel d’une fonction récursive déclenche un et un seul autre appel de
la même fonction récursive lors de son exécution on parle de récursivité
linéaire.
— Si l’appel d’une fonction récursive déclenche deux autres appels de la
même fonction récursive au cours de son exécution, on parle de récursivité
binaire.
— Si l’appel d’une fonction récursive déclenche au moins trois autres appels
de la même fonction récursive au cours de son exécution, on parle de
récursivité multiple .

4.4.2 Récursivité linéaire


Si une fonction récursive est conçue pour que chaque invocation du corps
fasse au plus un nouvel appel récursif, on parle alors de récursivité linéaire.
Parmi les algorithmes récursifs que nous ont vu jusqu’à présent, la mise en
oeuvre de la fonction factorielle et la bonne fonction de Fibonacci sont des
108 CHAPITRE 4. LA RÉCURSIVITÉ

exemples clairs de récursivité linéaire. Plus intéressant encore, l’algorithme de


recherche binaire est également un exemple de récursivité linéaire, malgré la
terminologie Â≪ binaire Â≫ dans le nom. Le code pour la recherche binaire
comprend une analyse de cas avec deux branches qui conduisent à deux appels
récursifs, mais un seul de ces appels peut être atteint au cours d’une exécution
du corps de la fonction.
Une conséquence de la définition de la récursivité linéaire est que toute
trace de récursivité apparaı̂tra comme une seule séquence d’appels, comme nous
l’avons initialement décrit pour la fonction factorielle. Ci-dessous le code de la
fonction fibonacci :

def bad_fibonacci(n):
""" Return nth Fibonacci number """
if n<=1:
return n
else:
return bad_fibonacci(n-2)+bad_fibonacci(n-1)

Malheureusement une telle implémentation directe de la formule de Fibo-


nacci résulte en une fonction terriblement inefficace. Calculer le nième nombre
de Fibonacci de cette manière nécessite un nombre exponentiel d’appels de la
fonction. Si nous notons cn le nombre d’appels effectués dans l’exécution de
bad fibonacci(n). Nous avons les valeurs suivantes pour cn :

c0 = 1
c1 = 1
c2 = 1 + c0 + c1 =1+1+1=3
c3 = 1 + c1 + c2 =1+1+3=5
c4 = 1 + c2 + c3 =1+3+5=9
c5 = 1 + c3 + c4 = 1 + 5 + 9 = 15
c6 = 1 + c4 + c5 = 1 + 9 + 15 = 25
c7 = 1 + c6 + c7 = 1 + 15 + 25 = 41
c8 = 1 + c8 + c9 = 1 + 25 + 41 = 67

Si on observe le nombre d’appels de la fonction récursive, on observe que


c4 fait plus de trois fois le nombre d’appels requis pour c2 . c5 demande trois
n
fois plus d’appels que c3 et ainsi de suite. Ainsi cn > 2 2 . Ce qui veut dire que
bad fibonacci(n) fait un nombre d’appels exponentiel en n. Nous pouvons
corriger cette implémentation en donnant une implémentation moins couteuse
en nombre d’opérations primitives exécutées.

def good_fibonacci(n):
4.4. QUELQUES AUTRES EXEMPLES DE RÉCURSIVITÉ 109

"""Return pair of Fibonacci numbers, F(n) and F(n-1)."""


if n<=1:
return(n,0)
else:
(a,b) = good_fibonacci(n-1)
return (a+b, a)

En termes d’efficacité la différence entre bad fibonacci() et good fibonacci()


est comme le jour et la nuit. Nous pouvons affirmer que good fibonacci(n)
s’exécute en un temps O(n). Chaque appel à good fibonacci(n) fait décroitre
l’argument n de 1, ce qui indique qu’il y aura n appels de la fonction récursive.
Comme les préparations à l’appel de la fonction récursive prennent un temps
constant, le temps d’exécution total est donc O(n).

4.4.3 Récursivité binaire


Lorsqu’une fonction effectue deux appels récursifs, on dit qu’elle utilise la
récursivité binaire. Nous avons déjà vu quelques exemples de récursivité binaire,
notamment la mauvaise fonction de Fibonacci de la section précédente. Comme
autre application de la récursivité binaire, revenons sur le problème de l’ad-
dition de n éléments d’une séquence, S, de nombres. Calculer la somme d’un
ou zéro élément est trivial. Avec deux éléments ou plus, nous pouvons calculer
récursivement la somme de la première moitié et la somme de la seconde moitié,
et additionner ces sommes pour obtenir la somme recherchée.
Notre implémentation d’un tel algorithme, est donnée ci-dessous :
def binary_sum(S, start, stop):
"""Return the sum of the numbers
in implicit slice S[start:stop]."""
if start >= stop: # zero element dans l’intervalle
return 0
elif start == stop-1: #un element dans l’intervalle
return S[start]
else:
mid = (start + stop)//2
return binary_sum(S, start, mid) +
binary_sum(S, mid+1, stop)

Pour analyser la somme binaire, nous considérons par souci de simplicité le cas
ou n est une puissance de deux. La figure 4.3 montre la trace de récursivité
d’une exécution de la somme de 0 à 8. Nous avons étiqueté chaque cas avec les
valeurs des paramètres start :stop pour cet appel de la fonction. La taille de
la plage est divisée en deux à chaque appel récursif et donc la profondeur de
la récursivité est 1 + [log2 n] par conséquent, la somme binaire utilise O(log n).
Toutefois, comme il y a (2n − 1) appels de la fonction chacun nécessitant un
temps constant, le temps d’exécution de binary sum est O(n).
110 CHAPITRE 4. LA RÉCURSIVITÉ

Figure 4.3 – Processus de calcul de binary sum(0,8). Calcule la somme de 0 à


8 selon les étapes indiquées.

4.4.4 Récursivité multiple


En généralisant à partir de la récursivité binaire, nous définissons la récursivité
multiple comme un processus dans lequel une fonction peut effectuer plus de
deux appels récursifs. Notre fonction pour l’analyse de l’utilisation de l’es-
pace disque d’un système de fichiers est un exemple de récursivité multiple,
car le nombre d’appels récursifs effectués au cours d’une invocation était égal
au nombre d’entrées dans un répertoire donné du système de fichiers. Il fau-
dra donc pour chaque cas évaluer le nombre d’appels rércusifs et compter le
nombre d’opérations primitives dans chaque appel recursif pour obtenir le temps
d’exécution de la fonction. Il vaut généralement le produit du nombre d’appels
récursifs par le nombre d’opérations primitives exécutées lors d’un appel.
Chapitre 5

Les Séquences Basées sur


les Tableaux

5.1 Introduction
Dans ce chapitre, nous explorons les différentes classes de séquences de Py-
thon, à savoir : les classes list, tuple et str. Il existe des points communs im-
portants entre ces classes : chacune prend en charge l’indexation pour accéder à
un élément individuel de la séquence, en utilisant une syntaxe telle que seq[k].
Chacune utilise un concept de bas niveau connu sous l’appellation de tableau
pour représenter la séquence. Cependant, il existe des différences significatives
entre les abstractions que représentent ces classes d’une part, et la manière dont
les instances de ces classes sont représentées en interne par Python d’autre part.
Parce que ces classes sont utilisées largement dans les programmes Py-
thon, et parce qu’elles deviendront des blocs de construction sur lesquelles nous
développerons des structures de données plus complexes, il est impératif que
nous ayons une compréhension claire à la fois du comportement public et du
fonctionnement interne de ces classes (voir figure 5.1).

5.1.1 Comportements publics


Une bonne compréhension de la sémantique extérieure d’une classe est une
nécessité pour un bon programmeur. Alors que l’utilisation de base des listes, des
chaı̂nes de caractères et des tuples peut sembler simple, il existe plusieurs subti-
lités importantes concernant les comportements associés à ces classes (Exemple :
ce que signifie faire une copie d’une séquence, ou prendre une tranche d’une
séquence). Avoir une mauvaise compréhension du comportement d’une classe
peut facilement conduire à des erreurs dans un programme. Par conséquent,
nous devons établir un modèle précis pour chacune de ces classes. Ces images
nous aiderons à explorer des utilisations plus avancées, telle que la représentation
d’un ensemble de données multidimensionnel sous la forme d’une liste de listes.

111
112 CHAPITRE 5. LES SÉQUENCES BASÉES SUR LES TABLEAUX

Figure 5.1 – Les structures de données abstraites reposent sur les


représentations séquentielles et les représentations sous forme des listes chainées.
Les représentations séquentielles s’apuient sur les tableaux et les chaines de ca-
ractères. Les représentations sous forme de listes chainées s’appuient quant à
elles sur les représentations implicites des pointeurs et les tableaux parallèles

5.1.2 Détails d’implémentation

Se concentrer sur les implémentations internes de ces classes semble aller à


l’encontre d’un principe essentiel de la programmation orientée objet. Il s’agit
du principe de l’encapsulation. Selon ce principe l’utilisateur d’une classe n’a
pas besoin de connaı̂tre les détails internes de sa mise en oeuvre. S’il est vrai
qu’il suffit de comprendre la syntaxe et la sémantique de l’interface publique
d’une classe pour l’utiliser, il est également vrai que l’efficacité d’un programme
dépend fortement de l’efficacité des composants sur lesquels il repose. Il faut
donc trouver un équilibre.

5.1.3 Analyses asymptotiques et expérimentales

Dans notre quête de description de l’efficacité de diverses opérations pour


les classes de séquences de Python, nous nous appuierons sur les notations
formelles d’analyse asymptotique. Nous effectuerons également des analyses
expérimentales des opérations primitives pour fournir des preuves empiriques
cohérentes avec avec les analyses asymptotiques plus théoriques.
5.2. LES TABLEAUX DE BAS-NIVEAU 113

Figure 5.2 – Une représentation d’une partie de la mémoire d’un ordinateur.


Chaque octet est étiqueté par son adresse mémoire.

5.2 Les tableaux de bas-niveau


Pour décrire avec précision la manière dont Python représente les types de
séquences, nous devons d’abord discuter des aspects de l’architecture informa-
tique de bas niveau. Rappelons que la mémoire d’un ordinateur est composée de
bits. Ces bits sont généralement regroupés en unités plus grandes qui dépendent
de l’architecture précise du système. Une telle unité typique est un octet, ce qui
équivaut à 8 bits.
Un système informatique aura un grand nombre d’octets de mémoire. Pour
retrouver l’information stockée dans un octet donné, l’ordinateur utilise une
abstraction connue sous le nom d’adresse mémoire. En effet, chaque octet de
mémoire est associé à un numéro unique qui lui sert d’adresse (plus formelle-
ment, la représentation binaire du numéro sert d’adresse). De cette façon, le
système informatique peut se référer aux données de l’octet #2150 par rap-
port aux données de l’octet #2157, par exemple. La figure 5.2 fournit un tel
diagramme, avec l’adresse mémoire désignée pour chaque octet.
Malgré la nature séquentielle du système de numérotation, le matériel in-
formatique est conçu, en théorie, pour que n’importe quel octet de la mémoire
principale puisse être efficacement accédé en fonction de son adresse mémoire.
En ce sens, on dit que la mémoire principale d’un ordinateur fonctionne comme
mémoire vive (RAM). C’est-à-dire qu’il est tout aussi facile de récupérer l’octet
#8675309 que de récupérer l’octet #309. En utilisant la notation asymptotique,
nous disons que tout octet individuel de mémoire peut être stocké ou récupéré
en un temps O(1).
En général, un langage de programmation garde une trace de l’association
entre un identifiant et l’adresse mémoire dans laquelle est stockée la valeur
associée. Par exemple, l’identifiant x peut être associé à une valeur stockée
en mémoire, tandis que y est associé à une autre valeur stockée en mémoire.
Une tâche de programmation commune est de garder une trace d’une séquence
d’objets liés. Par exemple, nous pouvons vouloir qu’un jeu vidéo garde une trace
des dix meilleurs scores. Plutôt que d’utiliser dix variables pour cette tâche, nous
préférerions utiliser un nom unique pour le groupe et utiliser un numéro d’indice
pour faire référence aux scores les plus élevés dans ce groupe.
Un groupe de variables liées peut être stocké dans un partie de la mémoire
de l’ordinateur. Nous désignerons une telle représentation sous le nom de ta-
bleau. A titre d’exemple tangible, une chaı̂ne de texte est stockée sous la forme
114 CHAPITRE 5. LES SÉQUENCES BASÉES SUR LES TABLEAUX

d’une séquence ordonnée d’éléments individuels. Cette séquence est un tableau


de caractères. En Python, chaque caractère est représenté à l’aide du jeu de
caractères UNICODE. Comme la plupart des systèmes informatiques, Python
représente en interne chaque caractère Unicode avec 16 bits (c’est-à-dire 2 oc-
tets). Par conséquent, une chaı̂ne de six caractères, telle comme SAMPLE, sera
stocké dans 12 octets consécutifs de mémoire, comme schématisé dans la fi-
gure 5.3. Nous décrivons cela comme un tableau de six caractères, même s’il

Figure 5.3 – Une chaı̂ne de caractères Python inscrite dans la mémoire sous
forme d’un tableau de caractères. Nous avons supposé que chaque caractère
unicode de la chaı̂ne nécessite deux octets de mémoire. Les nombres sous les
entrées sont les indices des éléments du tableau.

nécessite 12 octets de mémoire. Nous ferons référence à chaque emplacement


dans un tableau en tant que cellule et utiliserons un indice entier pour décrire
son emplacement dans le tableau, avec des cellules numérotées à partir de 0, 1,
2, etc. Par exemple, dans la figure 5.3, la cellule d’indice 4 du tableau a comme
contenu un L et est stockée dans les octets 2154 et 2155 de la mémoire.
Chaque cellule d’un tableau doit utiliser le même nombre d’octets. Cette
exigence est ce qui permet d’accéder à une cellule arbitraire du tableau en
temps constant en fonction de son indice. En particulier, si l’on connaı̂t l’adresse
mémoire à laquelle démarre un tableau (par exemple, 2146 dans la figure 5.3),
le nombre d’octets par élément (par exemple, 2 pour un caractère Unicode ),
l’adresse mémoire appropriée peut être calculée en utilisant le calcul, start +
indice de taille de cellule. Par cette formule, la cellule à l’indice 0 commence
précisément au début du tableau, la cellule à l’indice 1 commence précisément
deux octets après le début du tableau, et ainsi de suite. Par exemple, la cellule 4
de la figure 5.3 commence à l’emplacement mémoire 2146 + 2 × 4 = 2146 + 8 =
2154.
Bien sûr, l’arithmétique pour calculer les adresses mémoire dans un tableau
peut être gérée automatiquement. Par conséquent, un programmeur peut envisa-
ger une abstraction de haut niveau d’un tableau de caractères comme schématisé
dans la figure 5.4.

5.2.1 Tableau de références


Comme autre exemple motivant, supposons que nous voulons un système
d’information médicale pour garder une trace des patients actuellement affectés
aux lits dans un certain hôpital. Si nous supposons que l’hôpital a 200 lits, et
5.2. LES TABLEAUX DE BAS-NIVEAU 115

Figure 5.4 – Abstraction de haut niveau d’un tableau de caractères.

que ces lits sont numérotés de 0 à 199, nous pourrions envisager d’utiliser une
structure basée sur un tableau pour maintenir les noms des patients actuelle-
ment affectés à ces lits. Par exemple, en Python nous pourrions utiliser une liste
de noms, tels que :

[’Lumumba’, ’Kasavubu’, ’Mobutu’, ’Kabila LDK’, ...]

Pour représenter une telle liste avec un tableau, Python doit respecter l’exi-
gence selon laquelle chaque cellule du tableau utilise le même nombre d’octets.
Pourtant les éléments sont des chaı̂nes de caractères, et les chaı̂nes de caractères
ont naturellement des longueurs différentes. Python pourrait tenter de réserver
suffisamment d’espace pour chaque cellule pour contenir la chaı̂ne de longueur
maximale (pas seulement les chaı̂nes actuellement stockées, mais de n’importe
quelle chaı̂ne que nous pourrions vouloir stocker), mais ce serait du gaspillage.
Au lieu de cela, Python représente une liste ou une instance de tuple utilisant
comme stockage interne le mécanisme d’un tableau de références d’objets. Au
niveau le plus bas, ce qui est stocké est une séquence consécutive d’adresses
mémoire dans lesquelles les éléments de la séquence résident. Un diagramme de
haut niveau d’une telle liste est illustré à la figure 5.5.

Figure 5.5 – Un tableau stockant des références vers des chaines de caractères.

Bien que la taille relative des éléments individuels puisse varier, le nombre
de bits utilisés pour stocker l’adresse mémoire de chaque élément est fixe (par
116 CHAPITRE 5. LES SÉQUENCES BASÉES SUR LES TABLEAUX

exemple, 64 bits par adresse). De cette façon, Python peut prendre en charge
en un temps constant l’accès à une liste ou à un élément d’un tuple en fonction
de son indice. Dans la figure 5.5, nous caractérisons une liste de chaı̂nes qui
sont les noms des patients dans un hôpital. Il est plus probable qu’un système
d’information médicale gère des informations plus complètes sur chaque patient,
peut-être représentées comme une instance d’une classe Patient. Du point de
vue de la mise en oeuvre de la liste, le même principe s’applique : la liste
conservera simplement une séquence de références à ces objets. Notez également
qu’une référence à l’objet N one peut être utilisée comme élément de la liste pour
représenter un lit vide à l’hôpital.
Le fait que les listes et les tuples soient des structures référentielles est impor-
tant pour la sémantique de ces classes. Une seule instance de liste peut inclure
plusieurs références au même objet, et il est possible pour un seul objet d’être
un élément de deux ou plusieurs listes, car ces listes stockent simplement des
références à cet objet. Par exemple, lorsque vous calculez une tranche d’une
liste, le résultat est une nouvelle instance liste, mais cette nouvelle liste a des
références aux mêmes éléments qui sont dans la liste originale, comme le montre
la figure 5.6.

Figure 5.6 – Le résultat de la commande temp=primes[3 :6].

Lorsque les éléments de la liste sont des objets non modifiables, comme pour
les instances entières dans la figure 5.5, le fait que les deux listes partagent des
éléments n’est pas si significatif, car aucune des listes ne peut modifier l’objet
partagé. Si, par exemple, la commande temp[2] = 15 a été exécutée à partir
de cette configuration, cela ne modifie pas l’objet entier existant ; cela change
la référence dans la cellule 2 de la liste temporaire pour référencer un objet
différent. La configuration résultante est illustrée à la figure 5.7.
La même sémantique est démontrée lors de la création d’une nouvelle liste
en tant que copie d’une liste existante, avec une syntaxe telle que backup =
5.2. LES TABLEAUX DE BAS-NIVEAU 117

Figure 5.7 – Le résultat de la commande temp[2]=15.

list(primes). Cela produit une nouvelle liste qui est une copie superficielle, en
ce qu’elle fait référence aux mêmes éléments que dans la première liste. Avec
des éléments non modifiables, ce point est sans objet. Si le contenu de la liste
était de type modifiable, une copie profonde, c’est-à-dire une nouvelle liste avec
de nouveaux éléments, peut être produite en utilisant la fonction deepcopy du
module de copie.
Comme exemple plus frappant, c’est une pratique courante en Python d’ini-
tialiser un tableau d’entiers en utilisant la syntaxe counters = [0] * 8. Cette
syntaxe produit une liste de huit éléments, les huit éléments ayant chacun la
valeur zéro. Techniquement, toutes les huit cellules de la liste font référence au
même objet, comme le montre la figure 5.8. à première vue, le niveau extrême

Figure 5.8 – Le résultat de la commande counters=[0]*8.

d’aliasing dans cette configuration peut sembler alarmant. Cependant, nous nous
appuyons sur le fait que l’entier référencé est non modifiable. Même une com-
mande telle que counters[2] += 1 ne change pas techniquement la valeur de
l’instance entière existante. Ceci calcule un nouvel entier, avec la valeur 0+1, et
définit la cellule 2 pour référencer la valeur nouvellement calculée. La configu-
ration résultante est illustrée par la figure 5.9.
118 CHAPITRE 5. LES SÉQUENCES BASÉES SUR LES TABLEAUX

Figure 5.9 – Le résultat de la commande counters [2] +=1.

Comme dernière manifestation du caractère référentiel des listes, on note que


La commande extend est utilisée pour ajouter tous les éléments d’une liste à
la fin d’une autre liste. La liste étendue ne reçoit pas de copies de ces éléments,
elle reçoit des références à ces éléments. La figure 5.10 illustre l’effet d’un appel
à étendre une liste.

Figure 5.10 – L’effet de la commande primes.extend(extras), est montré en


gris.

5.2.2 Tableaux compacts en Python


Dans l’introduction de cette section, nous avons souligné que les chaı̂nes sont
représentées en utilisant un tableau de caractères (pas un tableau de références).
Nous reviendrons plus directement sur la représentation sous forme de tableau
compact car le tableau stocke les bits qui représentent les données primaires
(caractères, dans le cas des chaı̂nes).
Les tableaux compacts présentent plusieurs avantages par rapport aux struc-
tures référentielles en termes des performances de calcul. Plus important encore,
l’utilisation globale de la mémoire sera beaucoup plus faible pour une structure
5.2. LES TABLEAUX DE BAS-NIVEAU 119

Figure 5.11 – Un tableau compact représentant une chaı̂ne de caractères en


Python.

compacte car il n’y a pas de surcoût consacré au stockage explicite de la séquence


de références en mémoire (en plus des données). C’est-à-dire qu’une structure
référentielle utilisera généralement 64 bits pour mémorisé l’adresse stockée dans
le tableau, en plus du nombre de bits utilisés pour représenter l’objet qui est
considéré comme l’élément. De plus, chaque caractère Unicode stocké dans un
tableau compact, dans une chaı̂ne nécessite généralement 2 octets. Si chaque ca-
ractère était stocké indépendamment sous la forme d’une chaı̂ne de un caractère,
il y aurait beaucoup plus d’octets utilisés.
Comme autre cas d’étude, supposons que nous souhaitions stocker une séquence
d’un million, d’entiers de 64 bits chacun. En théorie, on peut espérer n’utiliser
que 64 millions de bits. Cependant, nous estimons qu’une liste Python utili-
sera quatre à cinq fois plus de mémoire. Chaque élément de la liste entraı̂nera
le stockage d’une adresse mémoire de 64 bits dans le tableau principal, et une
instance int stockée ailleurs dans la mémoire.
Python vous permet d’obtenir le nombre réel d’octets utilisés pour le sto-
ckage principal de tout objet. Cette opération se fait à l’aide de la fonction
getsizeof() du module sys. Sur certains systèmes, la taille d’un objet int ty-
pique nécessite 14 octets de mémoire (bien au-delà des 4 octets nécessaires pour
représenter un int de 64 bits). Au total, la liste utilisera 18 octets par entrée,
plutôt que les 4 octets qu’une liste compacte d’entiers exigerait.
Un autre avantage important d’une structure compacte pour le calcul haute
performance est que les données primaires sont stockées consécutivement dans
la mémoire. Notez bien que ce n’est pas le cas pour une structure référentielle.
Autrement dit, même si une liste maintient un ordre minutieux de la séquence
d’adresses mémoire, les adresses exactes où résident ces éléments en mémoire ne
sont pas déterminées par la liste. En raison du fonctionnement du cache et de la
hiérarchie des mémoires des ordinateurs, il est souvent avantageux d’avoir des
données stockées dans une mémoire à proximité d’autres données qui pourraient
être utilisées dans les mêmes calculs.
Malgré les inefficacités apparentes des structures référentielles, nous allons
généralement nous contenter de la commodité des listes et des tuples de Python
dans ces notes de cours. Python fournit plusieurs moyens pour créer des tableaux
compacts de divers types. La prise en charge principale des tableaux compactes
se trouve dans le module nommé array. Ce module définit une classe, également
nommée array, fournissant un stockage compact pour les tableaux de types de
données primitives. Une représentation d’un tel tableau d’entiers est illustrée à
120 CHAPITRE 5. LES SÉQUENCES BASÉES SUR LES TABLEAUX

la figure 5.12.

Figure 5.12 – Des entiers triés comme éléments d’un tableau compact de Py-
thon.

L’interface publique de la classe tableau compact est principalement conforme


à celle d’une liste Python. Cependant, le constructeur de la classe tableau com-
pact nécessite un code de type comme premier paramètre, qui est un caractère
qui désigne le type de données qui seront stockées dans le tableau. A titre
d’exemple tangible, le code de type, ’i’ , désigne un tableau d’entiers (signés),
généralement représenté en utilisant au moins 16 bits chacun. Nous pouvons
déclarer le tableau montré dans la figure 5.12 comme ci-dessous :
primes = array(′ i′ , [2, 3, 5, 7, 11, 13, 17, 19]) (5.1)
Le code de type permet à l’interpréteur de déterminer avec précision com-
bien de bits sont nécessaires par élément du tableau. Les codes de type pris
en charge par la classe tableau compact, comme le montre l’assignatiom (5.1),
sont formellement basés sur les types de données natifs utilisés par le langage
de programmation C (le langage dans lequel la distribution la plus largement
utilisée de Python est implémentée). Le nombre précis de bits pour les types de
données C dépend du système, mais les plages typiques sont indiquées dans le
tableau.

Code Type de donnée en C Nombre de bytes typique


’b’ signed char 1
’B’ unsigned char 1
’u’ Unicode char 2 ou 4
’h’ signed short int 2
’H’ unsigned short int 2
’i’ signed int 2 ou 4
’I’ unsigned int 2 ou 4
’l’ signed long int 4
’L’ unsigned long int 4
’f’ float 4
’d’ float 8

La classe de tableau compactes ne prend pas en charge la création de tableaux


compactes contenant des objets définis par l’utilisateur. Des tableaux compacts
5.3. LES TABLEAUX DYNAMIQUES ET AMORTISSEMENT 121

de telles structures peuvent être créés au niveau inférieur par le module nommé
ctypes.

5.3 Les tableaux dynamiques et amortissement


5.3.1 Introduction
Lors de la création d’un tableau de bas niveau dans un système informatique,
la taille précise de ce tableau doit être explicitement déclarée pour que le système
réserve correctement une partie de la mémoire pour son stockage. Par exemple,
la figure 5.13 affiche un tableau de 12 octets stockés dans les emplacements
mémoire allant de la cellule 2146 à la cellule 2157.

Figure 5.13 – Un tableau de 12 bytes stockés dans les cellules mémoires allant
de 2146 à 2157.

Parce que le système peut dédier des emplacements de mémoire voisins pour
stocker d’autres données, la capacité d’un tableau ne peut pas être augmentée
de manière triviale en l’étendant aux cellules mémoire voisines. Dans le contexte
de la représentation d’un tuple Python ou d’une instance str, cette contrainte
n’est pas un problème. Les instances de ces classes sont non modifiables, donc
la bonne taille d’un tableau peut être fixée lorsque l’objet est instancié.
La classe list de Python présente une abstraction plus intéressante. Bien
qu’une liste ait une longueur particulière lorsqu’elle est construite, la classe nous
permet d’ajouter des éléments à la liste, sans limite apparente pour la capacité
globale de la liste. Pour fournir cette abstraction, Python s’appuie sur un tour
de passe-passe algorithmique connu sous l’appellation de tableau dynamique.
La première clé pour fournir la sémantique d’un tableau dynamique est
qu’une instance de list maintient un tableau sous-jacent qui a souvent une
capacité supérieure à la longueur actuelle de la liste. Par exemple, alors qu’un
utilisateur peut avoir créé une liste avec cinq éléments, le système peut avoir
réservé un tableau sous-jacent capable de stocker huit objets références (plutôt
que cinq).
Cette capacité supplémentaire permet d’ajouter facilement un nouvel élément
à la liste en utilisant la prochaine cellule disponible du tableau. Si un utilisa-
teur continue d’ajouter des éléments à une liste, toute capacité réservée finira
par s’épuiser. Dans ce cas, la classe demande un nouveau tableau plus grand
au système, et initialise le nouveau tableau afin que son préfixe corresponde à
celui du tableau plus petit existant. A ce moment-là, l’ancien tableau n’est plus
nécessaire, il est donc récupéré par le système.
122 CHAPITRE 5. LES SÉQUENCES BASÉES SUR LES TABLEAUX

Nous donnons des preuves empiriques que la classe list de Python est basée
sur une telle stratégie. Le code source de notre expérience est affiché dans le
fragment de code suivant :

import sys # provide getsizeof() function


data = []
for k in range(n):
a = len(data) # number of elments
b = sys.getsizeof(data) # actual size in bytes
print(’Length: {0:3d}; Size in bytes: {1: 4d}’.format(a,b))
data.append(None)

En exécutant ce programme on obtient le résultat suivant :

Length : 0; Size in bytes : 72


Length : 1; Size in bytes : 104
Length : 2; Size in bytes : 104
Length : 3; Size in bytes : 104
Length : 4; Size in bytes : 104
Length : 5; Size in bytes : 136
Length : 6; Size in bytes : 136
Length : 7; Size in bytes : 136
Length : 8; Size in bytes : 136
Length : 9; Size in bytes : 200
Length : 10 ; Size in bytes : 200
Length : 11 ; Size in bytes : 200
Length : 12 ; Size in bytes : 200
Length : 13 ; Size in bytes : 200
Length : 14 ; Size in bytes : 200
Length : 15 ; Size in bytes : 200
Length : 16 ; Size in bytes : 200
Length : 17 ; Size in bytes : 272
Length : 18 ; Size in bytes : 272
Length : 19 ; Size in bytes : 272
Length : 20 ; Size in bytes : 272
Length : 21 ; Size in bytes : 272
Length : 22 ; Size in bytes : 272
Length : 23 ; Size in bytes : 272
Length : 24 ; Size in bytes : 272
Length : 25 ; Size in bytes : 272
Length : 26 ; Size in bytes : 352

Nous nous appuyons sur une fonction nommé getsizeof qui est disponible
à partir du module sys. Cette fonction rapporte le nombre d’octets utilisés
pour stocker un objet en Python. Pour une liste, il rapporte le nombre d’oc-
tets consacrés au tableau et aux autres variables d’instance de la liste, mais pas
l’espace consacré aux éléments référencés par la liste.
5.3. LES TABLEAUX DYNAMIQUES ET AMORTISSEMENT 123

En évaluant les résultats de l’expérience, nous attirons l’attention sur la


première ligne du résultat de l’exécution de notre programme. Nous voyons
qu’une instance de list vide nécessite déjà un certain nombre d’octets de mémoire
(72 sur notre système). En fait, chaque objet dans Python maintient un état,
par exemple, une référence pour désigner la classe à laquelle il appartient. Bien
que nous ne puissions pas accéder directement aux variables d’instance privées
pour une liste, nous pouvons supposer que, sous une certaine forme, il conserve
des informations d’état similaires à :

n Le nombre d’éléments actuel de la liste.


capacity Le nombre maximum d’éléments qui peut être stocké
dans l’espace mémoire actuellemenet réservé au tableau
A La référence actuelle du tableau (initialement : None)

Dès que le premier élément est inséré dans la liste, nous détectons un chan-
gement dans le taille sous-jacente de la structure. En particulier, on voit le
nombre d’octets sauter de 72 à 104, soit une augmentation d’exactement 32
octets. Notre expérience s’est déroulée sur une machine 64 bits, ce qui signifie
que chaque adresse mémoire est un nombre 64 bits (c’est-à-dire 8 octets). Nous
supposons que l’augmentation de 32 octets reflète l’allocation de un tableau
sous-jacent capable de stocker quatre références d’objets. Cette hypothèse est
cohérente avec le fait que nous ne voyons aucun changement sous-jacent dans
la mémoire utilisée après avoir inséré le deuxième, le troisième ou le quatrième
élément dans la liste.
Une fois que le cinquième élément a été ajouté à la liste, nous voyons le
saut d’utilisation de la mémoire de 104 octets à 136 octets. Si nous supposons
l’utilisation de base d’origine de 72 octets pour la liste, le total de 136 suggère 64
= 8 x 8 octets supplémentaires qui fournissent de la capacité de stocker jusqu’à
huit références d’objets. Encore une fois, cela est cohérent avec l’expérience, car
l’utilisation de la mémoire n’augmente plus jusqu’à la neuvième insertion. A ce
moment, les 200 octets peuvent être considérés comme les 72 d’origine plus un
tableau supplémentaire de 128 octets pour stocker 16 références d’objets. La 17e
insertion pousse l’utilisation globale de la mémoire à 272 = 72+200 = 72+25×8,
soit suffisament de mémoire pour stocker jusqu’à 25 références d’éléments. Parce
qu’une liste est une structure référentielle, le résultat de getsizeof() pour une
instance de list n’inclut que la taille pour représenter sa structure principale ; il
ne tient pas compte de la mémoire utilisée par les objets qui sont des éléments
de la liste.
Si nous devions continuer une telle expérience pour d’autres itérations, nous
pourrions essayer de discerner le modèle de la taille d’un tableau que Python crée
à chaque fois que la capacité du tableau précédent est épuisée. Avant d’explorer
la séquence précise des capacités utilisées par Python, nous continuons dans
124 CHAPITRE 5. LES SÉQUENCES BASÉES SUR LES TABLEAUX

cette section à décrire une approche générale pour implémenter des tableaux
dynamiques et pour effectuer une analyse asymptotique de leurs performances.

5.3.2 Implémentation d’un tableau dynamique


Bien que la classe list de Python fournisse des possibilités hautement op-
timisées d’implémentation des tableaux, sur lesquels nous nous appuyons pour
le reste de ces notes de cours, il est instructif de voir comment une telle classe
pourrait être implémentée. La clé est de fournir les moyens de développement du
tableau A qui stocke, au plus bas niveau, les éléments d’une liste. Bien sûr, nous
ne pouvons pas réellement développer ce tableau, car sa capacité est fixe. Tou-
tefois, si un élément est ajouté à une liste à un moment où le tableau sous-jacent
est plein, nous résolvons le problème en effectuant les étapes suivantes :
1. Allouez un nouveau tableau B avec une plus grande capacité.
2. Définir B[i] = A[i], pour i = 0, ..., (n − 1), où n désigne le nombre actuel
d’éléments de la liste.
3. Assigner B à A, A = B, c’est-à-dire que nous utilisons désormais B
comme tableau supportant la liste.
4. Insérez le nouvel élément dans le nouveau tableau.
Une illustration de ce processus est présentée à la figure ??.

Figure 5.14 – Une illustration de trois étapes nécessaires pour l’extention


d’un tableau dynamique : (a) création d’un nouveau tableau B ; (b) stockage
des éléments de A dans B ; (c) assigner au tableau A la référence du nouveau
tableau B. Pas montré dans l’ilustration, la mise dans le ”garbage collector” du
tableau A devenu initule .

Le dernier problème à considérer est la taille du nouveau tableau à créer.


La règle utilisée est que le nouveau tableau ait deux fois la capacité du tableau
existant qui est rempli. Dans le fragment de code ci-dessous, nous proposons
une implémentation concrète de tableaux dynamiques en Python. Notre classe
DynamicArray est conçue en utilisant les idées décrites dans cette section.
import ctypes
class DynamicArray:
""" A dynamic array class as a simplified Python list """
def __init__(self):
5.3. LES TABLEAUX DYNAMIQUES ET AMORTISSEMENT 125

"""Create an empty array."""


self._n = 0 # count actual elements
self._capacity = 1 # default array capacity
self._A = self._make_array(self._capacity) # low-level array

def __len__(self):
"""Return number of elements stored in the array."""
return self._n

def __getitem__(self, k)
"""Return the item at position k"""
if not 0 <= k < self._n:
raise IndexError(’invalid index’)
return self._A[k] # retreive from array

def append(self, obj):


"""Add object to end of the array."""
if self._n == self._capacity: # not enough room
self._resize(2*self._capacity) # so double capacity
self._A[self._n] = obj
self._n += 1

def _resize(self, c):


"""Resize internal array to capacity c"""
B= self._make_array(c)
for k in range(self._n):
B[k] = self._A[k]
self._A = B
self._capacity = c

def _make_array(self,c):
"""Return new array with capacity c."""
return (c*ctypes.py_object)()

Bien que cohérent avec l’interface de la classe list de Python, nous ne four-
nissons que quelques fonctionnalités limitées sous la forme de quelques méthodes
d’ajout, et les accesseurs len. La prise en charge de la création de tableaux de
bas niveau est fournie par un module nommé ctypes. Parce que nous n’utili-
serons généralement pas une structure de niveau aussi bas dans le reste de ces
notes de cours, nous omettons une explication détaillée du module ctypes. Ici
nous nous sommes contentés d’encapsuler la commande nécessaire pour déclarer
le tableau brut dans un utilitaire privé, la méthode make array(self,c).
126 CHAPITRE 5. LES SÉQUENCES BASÉES SUR LES TABLEAUX

5.3.3 Analyse amortie des tableaux dynamiques


Dans cette section, nous effectuons une analyse du temps d’exécution des
opérations sur les tableaux dynamiques. Nous utilisons la notation big-O pour
donner une borne inférieure asymptotique sur le temps d’exécution d’un algo-
rithme ou d’une étape à l’intérieur de celui-ci. La stratégie de remplacement d’un
tableau par un nouveau tableau plus grand peut sembler à première vue lente,
car une seule opération d’ajout peut nécessiter un temps d’exécution Ω(n), où
n est le nombre actuel d’éléments dans le tableau. Il y lieu de noté cependant,
qu’en doublant la capacité lors d’un remplacement d’un tableau par un tableau
plus grand, il est possible d’ajouter n nouveaux éléments avant que le tableau
ne doive être remplacé à nouveau. Nous n’entrons pas en détail dans l’analyse
amortie des tableaux dynamiques. Nous nous contenterons d’énoncer quelques
propositions.
Proposition : Soit une séquence S implémentée au moyen d’un tableau
dynamique avec une capacité initiale de un, en utilisant la stratégie permettant
de doubler la taille du tableau lorsque celui-ci est plein. Le temps total nécessaire
pour effectuer une série de n opérations d’ajout dans S, partant d’un S vide est
de O(n).
Proposition : Effectuer une série de n opérations d’ajout sur un tableau
dynamique utilisant un incrément fixe à chaque redimensionnement prend Ω(n2 )
temps.
Une leçon à tirer des propositions précédentes est qu’une subtile différence
dans la conception de l’algorithme peut produire des différences drastiques dans
les performances asymptotiques, et qu’une analyse minutieuse peut fournir des
informations importantes sur la conception d’une structure de données.

5.3.4 La classe list de Python


Les expériences des fragments de code, au début de la section 5.3, four-
nissent des preuves empiriques du fait que la classe list de Python utilise une
forme de tableau dynamique pour son stockage. Pourtant, un examen atten-
tif des capacités intermédiaires suggère que Python n’utilise ni une progres-
sion géométrique, ni une progression arithmétique. Cela dit, il est clair que
l’implémentation par Python de la méthode append présente un comportement
à temps amorti constant. Nous pouvons démontrer ce fait expérimentalement.
Une seule opération d’ajout s’exécute généralement si rapidement qu’il serait
difficile pour nous de mesurer avec précision le temps écoulé à cette granularité.
Nous pouvons obtenir une mesure plus précise du coût amorti par opération en
effectuant une série de n opérations d’ajout sur une liste initialement vide et
en déterminant le coût moyen de chaque opération. Une fonction pour effectuer
cette expérience est donnée dans le fragment de code ci-dessous :

from time import time # import time function from time module
def compute_average(n):
"""Perform n appends to an empty list and return average time elapsed"""
5.4. L’EFFICACITÉ DES SÉQUENCES EN PYTHON 127

data = []
start = time()
for k in range(n):
data.append(None)
end = time()
return (end-start)/n

Techniquement, le temps écoulé entre le début et la fin comprend le temps


pour gérer l’itération de la boucle for, en plus des appels d’ajout. Les résultats
de l’expérience, pour des valeurs de plus en plus grandes de n, sont présentés
dans le tableau ci-dessous :

n 100 1.000 10.000 100.000 1.000.000 10.000.000 100.000.000


µs 0.219 0.158 0.164 0.151 0.147 0.147 0.149

Nous constatons un coût moyen plus élevé pour les ensembles de données plus
petits, peut-être en partie en raison des frais généraux de la plage de boucle.
Il existe également un écart naturel dans la mesure du coût amorti de cette
manière, en raison de l’impact de l’événement de redimensionnement final par
rapport à n. Pris dans l’ensemble, il semble évident que le temps amorti pour
chaque opération d’ajout est indépendant du nombre n d’ajouts.

5.4 L’efficacité des séquences en Python


5.4.1 Introduction
Dans la section précédente, nous avons commencé à explorer les fondements
de la classe list de Python, en termes de stratégies de mise en oeuvre et d’ef-
ficacité. Nous continuons dans cette section avec l’examen des performances de
toutes les de séquences de Python.

5.4.2 Les classes list et tuple de Python


Les comportements non mutatable de la classe list sont précisément ceux qui
sont supportés par la classe tuple. Nous notons que les tuples sont généralement
plus efficaces en mémoire que listes parce qu’elles sont non modifiables ; par
conséquent, il n’y a pas besoin d’un réseau dynamique sous-jacent avec une
capacité excédentaire. Nous résumons l’efficacité asymptotique des classes list
et tuple dans le tableau 5.3.
128 CHAPITRE 5. LES SÉQUENCES BASÉES SUR LES TABLEAUX

Operation Temps d’exécution


len(data) O(1)
data[j] O(1)
data.count(value) O(n)
data.index(value) O(k + 1)
value in data O(k + 1)
data1 == data2
O(k + 1)
(similarly! =, <, <=, >, >=)
data[j :k] O(k − j + 1)
data1 + data2 O(n1 + n2 )
c * data) O(cn)
Chapitre 6

Les Piles, les Files


d’Attente et
les Files d’Attente
Prioritaires

6.1 Les piles


Une pile est un ensemble d’objets qui sont insérés et supprimés selon le prin-
cipe du dernier entré, premier sorti (LIFO ; Last In, First Out). Un utilisateur
peut insérer des objets dans une pile à n’importe quel moment, mais ne peut
accéder ou supprimer que l’objet inséré en dernier qui reste au sommet de la
pile.
Un exemple amusant est un distributeur de bonbons PEZ, qui stocke les
bonbons à la menthe dans un récipient à ressort qui fait sortir le bonbon le plus
haut dans la pile lorsque le haut du distributeur est soulevé (voir figure 6.1).
Les piles sont des structures de données fondamentales. Elles sont utilisées dans
de nombreuses applications.

Exemple d’utilisation de pile 6.1


Les navigateurs Web stockent les adresses des sites récemment visités dans
une pile. Chaque fois qu’un utilisateur visite un nouveau site, l’adresse de ce site
est poussée sur le pile d’adresses des sites visités. Le navigateur permet alors à
l’utilisateur de revenir au dernier site visité à l’aide du bouton retour.

Exemple d’utilisation de pile 6.2


Les éditeurs de texte fournissent généralement un mécanisme d’annulation
qui supprime les opérations d’édition et revient aux états antérieurs d’un do-

129
130CHAPITRE 6. LES PILES, LES FILES D’ATTENTE ETLES FILES D’ATTENTE PRIORITAIRES

cument. La suppression d’une opération d’annulation peut être accomplie en


conservant les modifications de texte dans une pile.

Figure 6.1 – Distributeur de bobons fonctionant selon le principe d’une pile.

6.1.1 Le type de données abstrait pile ; Abstract Data


Type (ADT) pile
Les piles sont les plus simples de toutes les structures de données, mais elles
sont aussi parmi les plus importantes. Elles sont utilisées dans une multitude
d’applications différentes. Elles sont également utilisées comme outils pour de
nombreuses structures de données et des algorithmes plus raffinés. Formelle-
ment, une pile est en résumé un ADT tel qu’une instance S prend en charge les
deux méthodes suivantes :

S.push(e) : ajouter l’élément e au sommet de la pile S.


S.pop() : Supprime et renvoie l’élément situé au sommet
de la pile. Une erreur se produit si la pile est vide.

Pour plus de commodité, nous définisssons également les méthodes d’accès


suivantes :

S.top() : renvoie une référence à l’élément


situé au top de la pile S, sans le retirer ;
une erreur se produit si la pile est vide.
S.is empty() : Renvoie True si la pile S ne
contient aucun élément.
len(S) : renvoie le nombre d’éléments
dans la pile S.
6.1. LES PILES 131

Par convention, nous supposons qu’une pile nouvellement créée est vide, et qu’il
n’y a pas à priori de limite à la capacité de la pile. Les éléments ajoutés à la
pile peuvent avoir un type arbitraire.

Exemple 6.3
Le tableau suivant montre une série d’opérations sur une pile et leur effets
sur la pile.

Operation Valeur retournée Contenu de la pile


S.push(5) - [5]
S.push(3) - [5, 3]
len(S) 2 [5, 3]
S.pop() 3 [5]
S.is empty() False [5]
S.pop() 5 []
S.is empty() True []
S.pop() ”error” []
S.push(7) - [7]
S.push(9) - [7, 9]
S.top() 9 [7, 9]
S.push(4) - [7, 9, 4]
len(S) 3 [7, 9, 4]
S.pop() 4 [7, 9]
S.push(6) - [7, 9, 6]
S.push(8) - [7, 9, 6, 8]
S.pop() 8 [7, 9, 6]

6.1.2 Implémentation d’une pile basée sur un tableau


Nous pouvons implémenter une pile assez facilement en stockant ses éléments
dans une liste Python. La classe list prend déjà en charge l’ajout d’un élément
à la fin de la liste avec la méthode append(), et permet de supprimer élément
qui est sur le top de la liste avec la méthode pop(). Il est donc naturel d’aligner
le haut de la pile à la fin de la liste, comme le montre la figure 6.2.

Figure 6.2 – Implémentation d’une pile avec une liste Python. L’élément du
top de la pile est situé le plus à droite, ce qui correspond à la fin de la liste.

Bien qu’un programmeur puisse utiliser directement la classe List à la place


d’une classe Stack, les listes incluent également des comportements (par exemple,
132CHAPITRE 6. LES PILES, LES FILES D’ATTENTE ETLES FILES D’ATTENTE PRIORITAIRES

l’ajout ou la suppression d’éléments à de positions arbitraires) qui briseraient


l’abstraction que l’ADT Stack représente. De plus, la terminologie utilisée par
la classe list ne correspond pas précisément à la terminologie traditionnelle de
l’ADT Stack (pile), en particulier la distinction entre append() et push(). Au
lieu de cela, nous montrons comment utiliser une liste pour le stockage interne
tout en fournissant une interface publique cohérente avec une pile.

Le pattern adaptateur (Adapter)


Le design pattern Adapter s’applique à tout contexte où nous voulons effec-
tivement modifier une classe existante pour que ses méthodes correspondent à
celles d’une classe apparentée, mais différente (classe ou interface). Une manière
générale d’appliquer le pattern Adapter consiste à définir une nouvelle classe
de telle sorte qu’elle contienne une instance de la classe existante en tant que
donnée membre, puis d’implémenter chaque méthode de la nouvelle classe en
recourant à une interface où à une classe abstraite. En mettant en oeuvre le
pattern Adapter de cette manière, nous créons une nouvelle classe qui remplit
certaines des fonctions d’une classe existante, mais reconditionné de manière
plus pratique. Dans le cadre de l’ADT Stack, on peut adapter la classe de list
de Python en utilisant les correspondances indiquées dans le tableau ci-dessous.

Méthode de la Equivalent dans la


classe Stack classe list de Python
S.push(e) L.append(e)
S.pop() L.pop()
S.top() L[-1]
S.is empty() len(L) == 0
len(S) len(L)

Implémentation d’une pile à l’aide d’une liste Python


Nous utilisons le pattern Adapter pour définir une classe ArrayStack qui
utilise une Liste Python pour le stockage des éléments. Nous avons choisi le
nom ArrayStack pour souligner que le stockage sous-jacent est intrinsèquement
basé sur un tableau. Une question qui demeure est ce que notre code doit faire
si un utilisateur appelle pop() ou top() lorsque la pile est vide. Notre ADT
Stack suggère qu’une erreur se produise, mais nous devons décider quel type
d’erreur. Lorsque pop() est appelé sur une liste Python vide, il se produit
formellement une IndexError, car les listes sont des séquences basées sur des
indices. Ce choix ne semble pas approprié pour un stack, puisque il n’y a pas
d’hypothèse d’indices. Au lieu de cela, nous pouvons définir une nouvelle classe
d’exception qui est plus approprié. Le fragment de code ci-dessous définit une
telle classe vide comme une sous-classe de la classe Exception de Python.
class Empty(Exception):
"""Error attempting to access an
element from an empty container"""
6.1. LES PILES 133

pass

La définition formelle de notre classe ArrayStack est donnée dans le fragment


de code ci-dessous :

class ArrayStack:
""" LIFO stack implementation using
a Python list as underlying storage"""

def __init__(self):
""" Create an empty stack"""
self._data = []

def __len__(self):
"""Return the number of
elements in the stack"""
return len(self._data)

def is_empty(self):
"""Return True if the stack is empty"""
return len(self._data) == 0

def push(self, e):


"""Add element e to top of the stack"""
self._data.append(e)

def top(self):
"""Return (but not remove) the
element at the top of the stack"""
Raise Empty Exception if the stack is empty"""

if self.is_empty():
raise Empty(’Stack is empty’)
return self._data[-1]

def pop(self):
"""Remove and return the element
from the top of the stack (i.e. LIFO).
Raise Empty exception if the stack is empty."""

if self.is_empty():
raise Empty(’Stack is empty’)
return self._data.pop()
134CHAPITRE 6. LES PILES, LES FILES D’ATTENTE ETLES FILES D’ATTENTE PRIORITAIRES

Analyse de l’implémentation de la pile basée sur un tableau


Le tableau ci-dessous montre les temps d’exécution des méthodes
d’ArrayStack. L’analyse reflète directement l’analyse de la classe list. Les
implémentations de top(), is empty(), et len() s’exécutent en un temps constant
dans le pire des cas. Le temps O(1) pour push() et pop() sont des bornes amor-
ties. Un appel typique de ces méthodes utilise un temps constant, mais parfois
un temps O(n) dans le pire cas, où n est le nombre actuel d’éléments dans la pile.
Cette situation se produit lorsqu’une opération oblige le redimensionnement du
tableau interne stockant les éléments.

Méthode de la Equivalent dans la


classe Stack classe list de Python
S.push(e) O(1)∗
S.pop() O(1)∗
S.top() O(1)
S.is empty() O(1)
len(S) O(1)

Dans le tableau ci-dessus, nous utilisons l’exposant (*) pour indiquer que le
résultat s’appuie sur la technique d’ammortissement.

6.1.3 Inverser les données à l’aide d’une pile


En conséquence du fait qu’elle utilise le protocole LIFO, une pile peut
être utilisée comme outil général pour inverser une séquence de données. Par
exemple, si les valeurs 1, 2 et 3 sont poussées dans une pile dans cet ordre, elles
seront extraites de la pile dans l’ordre 3, 2, puis 1. Cette idée peut être exploitée
dans une variété de contextes. Par exemple, nous pourrions souhaiter imprimer
les lignes d’un fichier dans l’ordre inverse afin d’afficher un ensemble de données
en ordre décroissant plutôt que dans l’ordre croissant. Cela peut être accompli
en lisant chaque ligne et en la poussant dans une pile. Une implémentation d’un
tel processus est donnée dans le fragment de code ci-dessous :

def reverse_file(filename):
"""Overwrite given file with its contents
line-by-line reversed"""
S = ArrayStack()
original = open(filename)
for line in original:
S.push(line.rstrip(’\n’)
original.close()

# now we overwrite with contents in LIFO order


output = open(filename, ’w’)
while not S.is_empty():
6.1. LES PILES 135

output.write(S.pop() + ’\n’)
output.close()

Un détail technique à noter est que nous supprimons intentionnellement les


fins des lignes à partir des lignes au fur et à mesure qu’elles sont lues, puis
nous réinsérons une nouvelle fin de ligne après l’écriture de chaque ligne dans
le fichier résultant. La raison pour laquelle nous faisons cela est de nous assurer
de traiter le cas particulier dans lequel le fichier d’origine n’a pas de retour à la
ligne pour la dernière ligne. L’idée d’utiliser une pile pour inverser un ensemble
de données peut être appliquée à d’autres types de séquences.

6.1.4 Correspondance entre des parenthèses ou entre des


balises HTML dans un texte
Dans cette sous-section, nous explorons deux applications connexes des piles.
Toutes les deux impliquent le test des paires de délimiteurs correspondants. Dans
notre première application, nous considérons des expressions arithmétiques qui
peuvent contenir diverses paires de symboles de groupement, telles que
— Les parenthèses : ”(” et ”)”
— Les accolades : ”{” et ”}”
— Les crochets : ”[” et ”]”
Chaque symbole d’ouverture doit être fermé par le symbole de fermeture cor-
respondant. Par exemple, un crochet gauche, ”[,” doit être fermé par un crochet
droit ”]” comme dans l’expression [(5+x)-(y+z)]. Les exemples suivants illus-
trent davantage ce concept :
— Séquence de délimiteurs correcte : ( )(( ))([( )])
— Séquence de délimiteurs correcte : ((( )(( ))([( )])))
— Séquence de délimiteurs incorrecte : )(( ))([( )])
— Séquence de délimiteurs incorrecte : ([ ])
— Séquence de délimiteurs incorrecte : (

Un algorithme pour faire correspondre les délimiteurs


Une tâche importante lors du traitement des expressions arithmétiques est
de s’assurer que leurs symboles de délimitation correspondent correctement.
Le fragment de code ci-dessous présente une implémentation Python d’un tel
algorithme.
def is_matched(expr):
"""Return true if all delimiters are
properly match; False otherwise."""
lefty =’({[’ #opening delimiters
reighty = ’)}]’ # respective closing delimiters
S = ArrayStack()
for c in expr:
if c in lefty:
136CHAPITRE 6. LES PILES, LES FILES D’ATTENTE ETLES FILES D’ATTENTE PRIORITAIRES

S.push(c)
elif c in righty:
if S.is_empty():
return False
if righty.index(c) != lefty.index(S.pop()):
return False
return S.is_empty()
Nous supposons que l’entrée est une séquence de caractères, telle que [(5+x)-
(y+z)] . Nous effectuons un balayage de gauche à droite de la séquence originale,
en utilisant une pile S pour faciliter la correspondance entre les symboles d’ou-
verture et de fermeture de regroupement. Chaque fois que nous rencontrons un
symbole d’ouverture, nous poussons ce symbole dans S, et chaque fois que nous
rencontrons un symbole de fermeture, nous faisons expulser un symbole de la
pile S (en supposant que S n’est pas vide). Nous vérifieons ensuite que ces deux
les symboles forment une paire valide. Si nous atteignons la fin de l’expression
et que la pile est vide, alors l’expression d’origine était bien formée avec les
paires de délimiteurs correctement balancés. Sinon, il doit y avoir un délimiteur
d’ouverture sur la pile sans symbole de fermeture correspondant. Si la taille
de l’expression originale est n, l’algorithme fera au plus n appels à push() et
n appels à pop(). Ces appels s’exécutent en un temps total de O(n), même
en considérant le caractère amorti du temps O(1) lié à ces méthodes. Etant
donné que notre sélection de délimiteurs possibles, ({[, a une taille constante,
des tests auxiliaires tels que c dans lefty et righty.index(c) s’exécutent chacun en
un temps O(1). En combinant ces opérations, l’algorithme de correspondance
des délimiteurs s’exécute en un temps O(n) pour une séquence de longueur n .

Correspondance entre les tags d’un ”Markup Language”


Dans un document HTML, des portions de texte sont délimitées par des ba-
lises HTML. Une simple balise HTML d’ouverture a la forme Â≪<nom>Â≫ et
la balise de fermeture correspondante a la forme ”</nom>”. Par exemple, nous
voyons la balise <body> sur la première ligne de la figure 6.3(a), et la balise
</body> correspondante à la fin de ce document. Les balises HTML couram-
ment utilisées dans cet exemple incluent :
Idéalement, un document HTML devrait avoir des balises correspondantes,
bien que la plupart des navigateurs tolérent un certain nombre de balises discor-
dantes. Dans le fragment de code ci-dessous, nous donnons une Fonction Python
qui correspond aux balises d’une chaı̂ne représentant un document HTML.
def is_matched_html(raw):
"""Return True if all HTML tags are
properly match; False otherwise."""
S = ArrayStack()
j = raw.find(’<’)
while j != -1:
k = raw.find(’>’, j+1)
6.1. LES PILES 137

Figure 6.3 – Illustration des tags HTML. (a) Un document HTML ; (b) Le
texte formater.

if k == -1:
return False
tag = raw[j+1:k]
if not tag.startswith(’/’):
S.push(tag)
else:
if S.is_empty():
return False
if tag[1:] != S.pop():
return False
j=raw.find(’<’, k+1)
return S.is_empty()

Nous faisons un passage de gauche à droite à travers la chaı̂ne brute, en uti-


lisant l’indice j pour suivre notre progrèssion. Nous utilisons la méthode find()
de la classe str pour localiser les caractères < et > qui définissent les balises.
Les balises d’ouverture sont poussées dans la pile et comparées aux balises de
fermeture qui en sont extraites, tout comme nous l’avons fait dans le fragment
de code traitant de la correspondance des délimiteurs. Par une analyse similaire,
on démontre que cet algorithme s’exécute en temps O(n), où n est le nombre
de caractères dans la source HTML brute.
138CHAPITRE 6. LES PILES, LES FILES D’ATTENTE ETLES FILES D’ATTENTE PRIORITAIRES

6.2 Les files d’attente


Une autre structure de données fondamentale est la file d’attente. C’est un
proche ”cousin” de la pile, car une file d’attente est une collection d’objets
qui sont insérés et supprimés selon le principe du premier entré, premier sorti
(FIFO : First In, First Out). C’est-à-dire que des éléments peuvent être insérés à
tout moment, mais seul l’élément qui a été dans la file d’attente le plus longtemps
peut être ensuite supprimé.
Nous disons généralement que les éléments entrent dans une file d’attente
par l’arrière et sont retirés par le devant. Une métaphore de cette terminologie
est une file de personnes attendant d’entrer dans un manège d’un parc d’attrac-
tions. Les personnes en attente d’un tel trajet entrent en queue de peloton et
sont débarquées en tête de file. Il existe de nombreuses autres applications de
files d’attente (voir Figure 6.4). Magasins, théâtres, centres de réservation et
autres services similaires traitent généralement les demandes des clients selon le
principe FIFO.
Une file d’attente serait donc un choix logique pour une structure de données
appelée à gérer les appels à un centre de service à la clientèle ou une liste
d’attente dans un restaurant. Les files d’attente FIFO sont également utilisées
par de nombreux périphériques informatiques, tels qu’une imprimante en réseau
ou un serveur Web répondant aux demandes des clients.

6.2.1 Le type de données abstrait de file d’attente ; Abs-


tract Data Type (ADT) Queue
Formellement, le type de données abstrait file d’attente définit une collec-
tion qui conserve les objets dans une séquence, où l’accès et la suppression
des éléments sont limités au premier élément dans la file d’attente et l’inser-
tion d’éléments est limitée à la fin de la séquence. Cette restriction applique la
règle selon laquelle les éléments sont insérés et supprimés dans une file d’attente
selon le principe du premier entré, premier sorti (FIFO). Le type de données
abstrait file d’attente (ADT) prend en charge les deux méthodes fondamentales
suivantes :

Q.enqueue(e) : ajouter l’élément e à la fin de la file d’attente Q.


Q.dequeue( ) : supprime et renvoie le premier élément de la file d’attente Q.
Une erreur se produit si la file d’attente est vide.
L’ADT file d’attente inclut également les méthodes de prise en charge sui-
vantes :

Q.first() : Renvoie une référence à l’élément à l’avant de la file Q,


sans l’enlever ; une erreur se produit si la file d’attente est vide.
Q.is empty( ) : Renvoie True si la file d’attente Q ne contient aucun élément.
len(Q) : renvoie le nombre d’éléments dans la file Q ; en Python,
nous l’implémentons avec la méthode spéciale len .
6.2. LES FILES D’ATTENTE 139

Figure 6.4 – Exemples de files d’attente. Le premier entré est le premier sorti.
(a) Des personnes attendent dans une file d’attente pour acheter des billets. (b)
Des appels téléphoniques sont acheminées vers un centre de service client dans
une file d’attente.

Par convention, on suppose qu’une file d’attente nouvellement créée est vide, et
qu’il n’y a pas a priori de limite à la capacité de la file d’attente. Les éléments
ajoutés à la file d’attente peuvent être d’un type arbitraire. Le tableau suivant
montre une série d’opérations dans une file d’attente et leurs effets sur la file
initialement vide. La file d’attente nommée ici Q contient des entiers.
140CHAPITRE 6. LES PILES, LES FILES D’ATTENTE ETLES FILES D’ATTENTE PRIORITAIRES

Opération Return Value first ←− Q ←− last


Q.enqueue(5) - [5]
Q.enqueue(3) - [5, 3]
len(Q) 2 [5, 3]
Q.dequeue() 5 [3]
Q.is empty() False [3]
Q.dequeue() 3 []
Q.is empty() True []
Q.dequeue() ”error” []
Q.enqueue(7) - [7]
Q.enqueue(9) - [7, 9]
Q.first() 7 [7, 9]
Q.enqueue(4) - [7, 9, 4]
len(Q) 3 [7, 9, 4]
Q.dequeue() 7 [9, 4]

6.2.2 Implémentation d’une file d’attente basée sur un ta-


bleau
Pour l’ADT pile, nous avons créé une classe adaptatrice très simple qui uti-
lisait une liste Python comme stockage sous-jacent. Il peut être très tentant
d’utiliser une approche similaire dans le cas de l’ADT file d’attente. Nous pour-
rions mettre en file d’attente l’élément e par un appel d’append(e) qui va
l’ajouter à la fin de la liste. On pourrait utiliser la syntaxe pop(0), par opposi-
tion à pop(), pour supprimer intentionnellement le premier élément de la liste
lors de la suppression de la file d’attente.
Aussi facile à mettre en oeuvre que cela puisse être , l’opération est tra-
giquement inefficace. Comme nous avons discuté précédement, lorsque pop()
est appelé sur une liste avec un indice différent de celui par défaut, une boucle
est exécutée pour déplacer tous les éléments au-delà de l’indice spécifié vers
la gauche, de manière à combler les trous causés par chaque pop() dans la
séquence . Par conséquent, un appel à pop(0) provoque toujours dans le pire
des cas un temps d’exécution Θ(n). Nous pouvons améliorer la stratégie ci-dessus
en évitant complètement l’appel à pop(0). Nous pouvons remplacer l’entrée re-
tirée de la file d’attente dans le tableau par une référence à None, et maintenir
une variable explicite f pour stocker l’indice de l’élément qui est actuellement au
devant la file d’attente. Un tel algorithme de sortie de file d’attente s’exécuterait
en un temps O(1). Après plusieurs opérations de mise en file d’attente, cette
approche peut conduire à la configuration décrite dans la figure 6.5.
Malheureusement, il reste un inconvénient à l’approche révisée. Dans le cas
d’une pile, la longueur de la liste était précisément égale à la taille de la pile
(même si le tableau sous-jacent de la liste était légèrement plus grand). Avec la
conception de la file d’attente que nous avons envisagée, la situation est pire.
Nous pouvons construire une file d’attente qui a relativement peu éléments,
mais qui sont stockés dans une liste arbitrairement grande. Cela se produit, par
6.2. LES FILES D’ATTENTE 141

Figure 6.5 – Permettre à l’avant de la file d’attente de s’éloigner de l’indice 0.

exemple, si nous procédons à l’ajout à plusieurs reprises d’un nouvel élément


dans la file d’attente. Au fil du temps, la taille de la liste sous-jacente augmente-
rait et le temps d’exécution de son processus d’élargissement finit par atteindre
un temps O(m) où m est le nombre total d’opérations de mise en file d’attente
depuis sa création, plutôt que le nombre actuel d’éléments dans la file d’attente.
Cette conception aurait des conséquences néfastes dans les applications dans
lesquelles les files d’attente ont des tailles relativement modestes, mais qui sont
utilisés pendant de longues périodes. Par exemple, la liste d’attente d’un res-
taurant peut ne jamais avoir plus de 30 entrées en une seule fois, mais au cours
d’une journée (ou d’une semaine), le nombre total d’entrées serait nettement
plus grand.

6.2.3 Utiliser un tableau de manière circulaire


Pour obtenir une implémentation plus robuste de notre file d’attente, nous
autorisons son début à dériver vers la droite, et nous permettons à son contenu
de Â≪ boucler Â≫ à la fin du tableau sous-jacent. Nous supposons que notre
tableau sous-jacent à une longueur fixe N qui est supérieure au nombre réel
d’éléments dans la file d’attente. Les nouveaux éléments sont mis en file d’attente
vers la Â≪ fin Â≫ de la file d’attente en cours, en progressant du début vers
l’indice N − 1 et continuant à l’indice 0, puis 1. La figure 6.6 illustre une telle
file d’attente avec le premier élément E et le dernier élément M.

Figure 6.6 – File d’attente dont les éléments sont stockés dans un tableau
circulaire.

La mise en oeuvre de cette vue circulaire n’est pas difficile. Quand on retire
un élément de la file d’attente et que l’on veut Â≪ faire avancer Â≫ l’indice du
front, on utilise l’arithmétique f = (f + 1)%N . Rappellons que l’opérateur %
en Python désigne l’opérateur modulo, qui est calculé en prenant le reste après
une division intière. Par exemple, 14%3 = 2. A titre d’exemple concret, si nous
142CHAPITRE 6. LES PILES, LES FILES D’ATTENTE ETLES FILES D’ATTENTE PRIORITAIRES

avons une liste de longueur 10, et un indice de front 7, on peut avancer le front
en calculant formellement (7 + 1)%10, qui est simplement 8, car 8 divisé par 10
est 0 avec un reste de 8. De même, l’avancement de l’indice 8 entraı̂ne l’indice 9.
Mais lorsque nous avançons à partir de l’indice 9 (le dernier élément du tableau),
nous calculons (9 + 1)%10, ce qui donne l’indice 0 (comme 10 divisé par 10 égal
à un avec un reste de zéro).

6.2.4 Une implémentation de la file d’attente Python


Une implémentation complète de l’ADT file d’attente utilisant une liste Py-
thon de manière circulaire est présentée dans les fragments de code ci-dessous.
En interne, la classe de file d’attente maintient les trois variables d’instance
suivantes :
data : est une référence à une instance de list
avec une capacité fixe.
size : est un entier représentant le nombre actuel
d’éléments stockés dans la file d’attente
(par opposition à la taille de la liste de données).
front : est un entier qui représente l’indice des données
du premier élément de la file d’attente
(en supposant que la file d’attente n’est pas vide).
Nous réservons initialement une liste de taille modérée pour le stockage des
données, bien que la file d’attente a formellement la taille zéro. Par souci de
technicité, nous initialisons l’indice avant à zéro. Lorsque front() ou dequeue()
sont appelés sans élément dans la file d’attente, nous levons une instance de
l’exception Empty, définie dans le fragment de code de l’implémentation de notre
pile. Ci-dessous une implémentation de la file d’attente basée sur un tableau,
d’ou l’appellation ArrayQueue pour la classe.

class ArrayQueue:
""" FIFO queue implementation using a Python
list as underlying storage"""
DEFAULT_CAPACITY = 10

def __init__(self):
"""Create an empty queue"""
self._data = [None] * ArrayQueue.DEFAULT_CAPACITY
self._size = 0
self._front = 0

def __len__(self):
"""Return the number of elements in the queue"""
return self._size

def is_empty(self):
6.3. FILES D’ATTENTE À DEUX EXTRÉMITÉS 143

"""Return True if the queue is empty"""


return self._size == 0

def first(self):
"""Return (but not remove) the
element at the front of the queue.
Raise Empty exception if the queue is empty.
"""

if self.is_empty():
raise Empty(’Queue is Empty’)
return self._data[self._front]

def dequeue(self):
"""Remove and return the first element
of the queue (i.e. FIFO).
Raise Empty exception if the queue is empty

if self.is_empty():
raise Empty(’Queue is empty’)
answer = self._data[self._front]
self._data[self._front] = None
self._front = (self._front + 1) % len(self._data)

Les mises en oeuvre de len et is empty sont triviales, étant donné la


connaissance de la taille. La mise en oeuvre de la méthode front est également
simple, car l’indice de front nous indique précisément où se trouve l’élément
souhaité dans la liste data, en supposant que la liste n’est pas vide.

Analyse de l’implémentation de la file d’attente basée sur un tableau


A l’exception de l’utilitaire de redimensionnement du tableau sou-jacent,
toutes les méthodes reposent sur un nombre constant d’opérations primitives
impliquant des opérations arithmétiques, des comparaisons, et des devoirs. Par
conséquent, chaque méthode s’exécute dans le pire des cas en un temps O(1),
sauf pour l’insertion en file d’attente et la suppression de la file d’attente, qui
ont des limites amorties de temps O(1).

6.3 Files d’attente à deux extrémités


Nous considérons ici une structure de données de type file d’attente qui
prend en charge l’insertion et la suppression à l’avant comme à l’arrière de la
file d’attente. Une telle structure est appelée une double file d’attente, ou deque,
qui se prononce généralement deck pour éviter toute confusion avec la méthode
de mise en file d’attente de l’ADT file d’attente , qui se prononce comme le
abréviation ”D.Q.”. Le type de données abstrait deque est plus général que la
144CHAPITRE 6. LES PILES, LES FILES D’ATTENTE ETLES FILES D’ATTENTE PRIORITAIRES

pile et l’ADT file d’attente. La généralité supplémentaire peut être utile dans
certaines applications. Par exemple, lorsque nous utilisons une file d’attente
pour maintenir la liste d’attente d’un restaurant. Occasionnellement, la première
personne d’une file d’attente peut être supprimée de la file d’attente uniquement
pour découvrir qu’une table n’est pas disponible ; généralement, le restaurant
réinsérera la personne en première position dans la file d’attente. Ce peut-être
aussi qu’un client en fin de file d’attente s’impatiente et menace de quitter le
restaurant. Nous aurons besoin d’une structure de données encore plus générale
si nous voulons modéliser également des clients quittant la file d’attente depuis
d’autres positions.

6.3.1 Le type de données abstrait Deque ; Abstract Data


Type (ADT) deque

Pour fournir une abstraction symétrique, l’ADT deque est défini de telle
sorte que deque prenne en charge les méthodes suivantes :

D.add first(e) : Ajoute l’élément e au début de deque D.


D.add last(e) : Ajoute l’élément e à l’arrière de deque D.
D.delete first( ) : Supprime et retourne le premier élément de deque D ;
une erreur se produit si le deque est vide.
D.delete last( ) : Supprime et retourne le dernier élément de deque D ;
une erreur se produit si le deque est vide.

De plus, l’ADT deque inclura les accesseurs suivants :

D.first() : Retourne (mais ne supprime pas) le premier


élément du deque D ;
une erreur se produit si le deque est vide.
D.last() : Retourne (mais ne supprime pas)
le dernier élément du deque D ;
une erreur se produit si le deque est vide.
D.is empty( ) : Renvoie True si deque D
ne contient aucun élément.
len(D) : renvoie le nombre d’éléments
dans deque D ; en Python, nous
l’implémentons avec la méthode spéciale len .

Le tableau ci-dessous montre une série d’opérations et leurs effets sur un


deque D d’entiers vide au départ.
6.3. FILES D’ATTENTE À DEUX EXTRÉMITÉS 145

Opération Return Value deque D


D.add last(5) - [5]
D.add first(3) - [3, 5]
D.add first(7) - [7, 3, 5]
D.first() 7 [7, 3, 5]
D.delete last() 5 [7, 3]
len(D) 2 [7, 3]
D.delete last() 3 [7]
D.delete last() 7 []
D.add first(6) - [6]
D.last() 6 [6]
D.add first(8) - [8, 6]
D.is empty() False [8, 6]
D.last() 6 [8, 6]
146CHAPITRE 6. LES PILES, LES FILES D’ATTENTE ETLES FILES D’ATTENTE PRIORITAIRES
Chapitre 7

Les listes chainées

7.1 Introduction
Dans le chapitre 5, nous avons examiné la classe list de Python, qui utilise
un tableau pour stocké ses éléments. Dans le chapitre 6, nous avons montré
l’utilisation de cette classe dans l’implémentation d’une pile classique, d’une
file d’attente et de l’ADT file d’attente. Cette classe list de Python est haute-
ment optimisée et est souvent un excellent choix pour le stockage. Cela dit, elle
présente quelques inconvénients majeurs :
— La taille d’un tableau dynamique peut être plus importante que ce qui
est requis pour stocker le nombre réel d’éléments.
— Les limites amorties pour les opérations peuvent être inacceptables dans
des systèmes temps réel.
— Les insertions et les suppressions aux positions intérieures d’un tableau
sont coûteuses.
Dans ce chapitre, nous introduisons une structure de données appelée liste
chaı̂née, qui fournit une alternative à une séquence basée sur un tableau (telle
qu’une liste Python). Les séquences basées sur des tableaux, comme les listes
chaı̂nées conservent les éléments dans un certain ordre, mais en utilisant des
types d’organisations très différents.
Un tableau fournit la représentation la plus centralisée. Il dispose d’un es-
pace mémoire capable d’accueillir des références à de nombreux éléments. Une
liste chaı̂née, en revanche, repose sur une représentation plus distribuée dans la-
quelle un objet léger, appelé noeud, est alloué à chaque élément. Chaque noeud
conserve une référence à son élément et une ou plusieurs références aux noeuds
voisins afin de représenter collectivement l’ordre linéaire de la séquence.
Nous mettrons en évidence les avantages et les inconvénients des séquences
basées sur des tableaux et de celles basées sur les listes chaı̂nées lorsque nous les
comparerons. Les éléments d’une liste chaı̂née ne peuvent pas être efficacement
accessibles par un indice numérique k, et nous ne pouvons pas dire simplement
en examinant un noeud s’il s’agit du deuxième, du cinquième ou du vingtième

147
148 CHAPITRE 7. LES LISTES CHAINÉES

noeud de la liste. Cependant, les listes chaı̂nées évitent les trois inconvénients
majeurs associés aux séquences basées sur les tableaux à savoir :
— La taille d’un tableau dynamique peut être beaucoup plus importante
que le nombre d’éléments à stocker ;
— Les temps d’exécution des opérations dans un tableau par la méthode de
l’amortissement peuvent se révéler inacceptables dans certains cas ;
— L’insertion et la suppression des éléments à l’intérieur d’un tableau sont
très couteuses (temps d’exécution inacceptable dans certains cas).

7.2 Les listes chainées simples


Une liste chaı̂née simple, dans sa forme la plus élémentaire, est un ensemble
de noeuds qui, collectivement, forment une suite linéaire. Chaque noeud stocke
une référence à un objet qui est un élément de la séquence, ainsi qu’une référence
au noeud suivant de la liste (voir Figures 7.1 et 7.2).

Figure 7.1 – Exemple d’une instance noeud faisant partie d’une liste chaı̂née
simple. Le noeud fait référence à un objet arbitraire de la liste et au noeud sui-
vant. Ici le noeud fait référence à l’objet ”MSP” de la liste. Il dispose également
d’une reférence vers le noeud qui vient juste après lui. Le noeud suivant fait
référence à lui même et au prochain élément de la liste, ou à N one s’il n’y a
plus d’éléments dans la liste.

Le premier et le dernier noeud d’une liste chaı̂née sont appelés tête et queue
de la liste, respectivement. En commençant par la tête, et en passant d’un noeud
à un autre en suivant la prochaine référence de chaque noeud, nous pouvons
atteindre la fin de la liste. Ce processus est communément appelé le parcourt
de la liste chaı̂née.
Nous pouvons identifier la queue comme le noeud ayant N one comme noeud
suivant. Parce que la prochaine référence d’un noeud peut être considéré comme
un lien ou un pointeur vers un autre noeud, le processus de traversée d’une liste
est également connu sous le nom de saut de lien ou de saut de pointeur. La
représentation en mémoire d’une liste chaı̂née repose sur la collaboration de
plusieurs objets.
Chaque noeud est représenté comme un objet unique, cette instance stocke
une référence à son élément et une référence au noeud suivant (ou à N one).
Un autre objet représente la liste chaı̂née dans son ensemble. Au minimum,
7.2. LES LISTES CHAINÉES SIMPLES 149

Figure 7.2 – Exemple d’une liste chaı̂née simple dont les éléments sont des
chaines de caractères correspondants aux codes d’aéroports. L’intance de la
liste maintient un élément appelé tête de liste (head) qui correspond au premier
élément de la liste et dans certaines applications un élément appelé queue de
liste qui correspond au dernier noeud de la liste.

l’instance de liste chaı̂née doit conserver une référence à la tête de liste. Sans
référence explicite à la tête, il n’y aurait aucun moyen de localiser ce noeud (et
indirectement, tout autre).
Il n’y a pas de nécessité absolue de stocker une référence directe à la fin de
la liste, car cela pourrait autrement être localisé en commençant par la tête et
en parcourant le reste de la liste. Pourtant, stocker une référence explicite au
noeud de queue est une commodité courante.
De la même manière, il est courant qu’une instance de liste chaı̂née garde
un décompte du nombre total de noeuds qui composent la liste (généralement
décrit comme la taille de la liste), pour éviter d’avoir à parcourir la liste pour
compter les noeuds.
Pour le reste de ce chapitre, nous continuons à illustrer les noeuds en tant
qu’objets, et la référence Â≪ suivante Â≫ de chaque noeud en tant que pointeur.
Cependant, par souci de simplicité, nous illustrons l’élément d’un noeud intégré
directement dans la structure du noeud, même bien que l’élément soit, en fait, un
objet indépendant. Par exemple, la figure 7.3 est une illustration plus compacte
de la liste chaı̂née de la figure 7.2.

Figure 7.3 – Une représentation plus compact de la liste chaı̂née.

7.2.1 Insertion d’un élément en tête d’une liste chaı̂née


simple
Une propriété importante d’une liste chaı̂née est qu’elle n’a pas de taille
fixe prédéterminée. Elle utilise l’espace proportionnellement à son nombre ac-
150 CHAPITRE 7. LES LISTES CHAINÉES

tuel d’éléments. Lors de l’utilisation d’une liste chaı̂née simple, nous pouvons
facilement insérer un élément en tête de liste, comme indiqué dans la figure 7.4,
et décrit avec un pseudo-code ci-dessous :

Algorithm add_first(L,e):
newest = Node(e) # creer un nouveau noeud
newest.next = L.head # l’ancien head devient
# le next du nouveau noeud
L.head = newest # le nouveau noeud devient le head
L.size = L.size + 1 # incrementer le nombre
# d’elements de la liste

L’idée principale est que nous créons un nouveau noeud. Nous définissons
son élément et nous définissons son prochain comme étant l’actuelle tête de liste.
Enfin, nous faisons pointé la tête de la liste sur le nouveau noeud.

Figure 7.4 – Insertion d’un élément en tête d’une liste chaı̂née simple. (a)
Avant insertion ; (b) Après création d’un nouveau noeud ; (c) Après affectation
de la référence de la tête de liste au nouveau noeud.

7.2.2 Insertion d’un élément à la queue d’une liste chaı̂née


simple
On peut aussi facilement insérer un élément en queue de liste, à condition
de garder la référence du noeud de queue, comme le montre la figure 7.5.
Dans ce cas, nous créons un nouveau noeud, attribuons la référence N one à son
next, faisons pointer la queue actuelle de la liste vers ce nouveau noeud, puis
nous mettons à jour la référence de queue elle-même à ce nouveau noeud. Nous
donnons les détails dans le fragment de code ci-dessous.
7.2. LES LISTES CHAINÉES SIMPLES 151

Algorithm add_last(L,e):
newest = Node(e) # creer un nouveau noeud
newest.next = None # le next du nouveau noeud est None
L.tail.next = newest # ancienne queue pointe vers nouveau noeud
L.tail = newest # le nouveau noeud devient le tail
L.size = L.size + 1 # incrementer le nombre
# d’elements de la liste

Figure 7.5 – Insertion d’un élément en queue d’une liste chaı̂née simple. (a)
Avant l’insertion ; (b) Après création d’un nouveau noeud ; (c) Après affectation
de la reférence de la queue de la liste au nouveau noeud.

Suppression d’un élément d’une liste chaı̂née simple


Supprimer un élément de la tête d’une liste chaı̂née est essentiellement l’in-
verse opération d’insertion d’un nouvel élément en tête. Cette opération est
illustrée dans Figure 7.6 et détaillée dans le fragment de code ci-dessous.

Algorithm remove_first(L):
if L.head is None then
Indicate an error: the list is empty.
L.head = L.head.next # head pointe vers le nouveau noeud
L.size = L.size - 1 # d\’ecrementer le nombre
# d’elements de la liste

Malheureusement, nous ne pouvons pas facilement supprimer le dernier noeud


d’une liste chaı̂née simple. Même si nous maintenons une référence du noeud de
queue directement, nous devons pouvoir accéder au noeud avant le dernier noeud
afin de supprimer le dernier noeud. Mais nous ne pouvons pas atteindre le noeud
152 CHAPITRE 7. LES LISTES CHAINÉES

Figure 7.6 – Suppresion d’un élément en tête d’une liste chaı̂née simple. (a)
Avant la suppression ; (b) Après avoir déplacé la tête de liste vers le noeud
suivant celui à supprimer ; (c) Configuration finale.

avant la queue en suivant les prochains liens de la queue. Le seul moyen d’accéder
à ce noeud est de commencer à partir de la tête de la liste et de parcourir tout
le chemin à travers la liste. Mais une telle séquence d’opérations de saut de lien
pourrait prendre beaucoup de temps. Si nous voulons soutenir efficacement une
telle opération, nous devrons liée doublement notre liste (comme cela est fait
dans la section 7.3).

7.2.3 Implémentation d’une pile stockant ses éléments dans


une liste chaı̂née simple
Dans cette section, nous montrons l’utilisation d’une liste chaı̂née simple
dans l’implémentation de l’ADT pile en Python. Dans notre conception, nous
devons décider s’il faut modéliser le sommet de la pile en tête ou en queue de
liste. Le meilleur choix ici est de faire coincider la tête de la liste avec le sommet
de la pile. Dans ces conditions, nous pouvons insérer efficacement et supprimer
des éléments en tête de liste en un temps constant. Pour représenter les noeuds
individuels de la liste, nous développons une classe Node simple. Cette classe
ne sera jamais directement exposée à l’utilisateur de notre classe Stack, nous
allons donc la définir formellement comme une classe imbriquée non publique de
notre classe Stack. La définition de la classe Node est donnée dans le fragment
de code ci-dessous :

classe _Node:
""" Lightweight, nonpublic class
for storing a singly linked node."""
7.2. LES LISTES CHAINÉES SIMPLES 153

__slots__ = ’_element’, ’_next’


def __init__(self, element, next):
self._element = element
self._next = next
Un noeud n’a que deux variables d’instance : element et next. Nous avons
intentionnellement définit des emplacements pour rationaliser l’utilisation de la
mémoire, car il peut potentiellement y avoir de nombreuses instances de noeuds
dans une seule liste. Le constructeur de la classe Node est conçu pour nous
permettre de spécifiez les valeurs initiales pour les deux champs d’un noeud
nouvellement créé. Une implémentation complète de la classe LinkedStack est
donnée dans le fragment de code ci-dessous :
class LinkedStack:
"""LIFO Stack implementation using
a singly linked list for storage"""
# ------------ nested _Node class -----------
class _Node:
""" Lightweight, nonpublic class
for storing a singly linked node."""
__slots__ = ’_element’, ’_next’
def __init__(self, element, next):
self._element = element
self._next = next
# stack methods
def __init__(self):
""" Create an empty stack."""
self._head = None
self._size = 0
def __len__(self):
"""Return the number of elements in the stack."""
return self._size
def is_empty(self):
’’’’’’ Return True if the stack is empty’’’’’’’
return self._size == 0
def push(self, e):
’’’’’’ Add element e to the top of the stack ’’’’’’
self._head =self._Node(e, self._head) #create and link a new node
self._size +=1

def top(self):
""" Return (but not remove) the element
at the top of the stack
Raise Empty exception if the stack is empty."""
if self.is_empty():
raise Empty(’Stack is empty’)
return self._head._element
154 CHAPITRE 7. LES LISTES CHAINÉES

def pop(self):
"""Remove and return the element
at the top of the stack (LIFO)"""
Raise Empty exception if the stack is empty."""
if self.is_empty():
raise Empty(’Stack is empty’)
answer = self._head._element
self._head = self._head._next
self._size -= 1
return answer

Chaque instance de la pile conserve deux variables. Le membre principal est


une référence au noeud en tête de liste (ou à N one, si la pile est vide). Nous
gardons une trace du nombre actuel d’éléments avec la variable d’instance size,
car sinon nous serions obligés de parcourir toute la liste pour compter le nombre
d’éléments lors de la déclaration de la taille de la pile. La mise en oeuvre de push
reflète essentiellement le pseudo-code pour l’insertion en tête d’une liste chaı̂née
simple. Quand on pousse un nouvel élément e dans la pile, nous effectuons les
changements nécessaires en appelant le constructeur de la classe Node comme
suit :

self._head = self._Node(e, self._head)

Lors de l’implémentation de la méthode top, le but est de retourner l’élément


qui est au sommet de la pile. Lorsque la pile est vide, nous levons une exception
”Stack is Empty”. Lorsque la pile n’est pas vide, self. head est une référence
au premier noeud de la liste chaı̂née. L’élément situé au top de la liste peut être
identifié comme self. head.element. Notre implémentation de pop() reflète
essentiellement le pseudo-code que nous avons donné à cet effet, sauf que nous
maintenons une référence locale à l’élément qui est stocké au noeud qui est
supprimé, et nous renvoyons cet élément à l’appelant de pop(). L’analyse des
opérations de la classe LinkedStack est donnée dans le tableau ci-dessous :

Opération Temps d’exécution


S.push(e) O(1)
S.pop() O(1)
S.top() O(1)
len(S) O(1)
S.is empty() O(1)

Comme on peut le constater, toutes les méthodes se terminent dans le pire


des cas en un temps constant. Ceci contraste avec les limites d’amortissement
des opérations de la classe ArrayStack.
7.2. LES LISTES CHAINÉES SIMPLES 155

7.2.4 Implémentation d’une file d’attente avec une liste


chaı̂née simple
Comme nous l’avons fait pour l’ADT pile, nous pouvons utiliser une liste
chaı̂née simple pour implémenter l’ADT file d’attente en nous assurant d’avoir
un temps d’exécution O(1) pour chacune des opérations dans le pire des cas.
Parce que nous avons besoin d’effectuer des opérations aux deux extrémités de
la file d’attente, nous maintiendrons explicitement à la fois une référence de tête
et une référence de la queue en tant que variables d’instance pour chaque file
d’attente. L’orientation naturelle pour une file d’attente est d’aligner l’avant de
la file d’attente avec la tête de la liste, et l’arrière de la file d’attente avec la
queue de la liste, car il faut pouvoir insérer les éléments en file d’attente par
l’arrière et les retirer de la file d’attente par l’avant. Notre implémentation de
la classe LinkedQueue est donnée dans les fragments de code ci-dessous :

class LinkedQueue:
"""FIFO queue implementation using
a singly linked list for storage """

classe _Node:
""" Lightweight, nonpublic class
for storing a singly linked node."""
__slots__ = ’_element’, ’_next’
def __init__(self, element, next):
self._element = element
self._next = next

def __init__(self):
"""Create an empty queue"""
self._head = None
self._tail = None
self._size = 0

def __len__(self):
"""Return the number of
elements in the queue"""
return self._size

def is_empty():
"""Return True if the
queue is empty """
return self._size == 0

def first(self):
"""Return (but not remove)
156 CHAPITRE 7. LES LISTES CHAINÉES

the element at the front of the queue"""


if self.is_empty():
raise Empty(’Queue is empty’)
return self._head._element

def dequeue(self):
’’’’’’Remove and return the first element of the queue (i.e. FIFO)
Raise Empty Exception if the queue is empty.’’’’’’
if self.is_empty():
raise Empty(’Queue is empty’)
answer = self._head._next
self._size -=1
if self.is_empty():
self._tail = None
return answer

def enqueue(self,e):
’’’’’’Add an element to the back of the queue’’’’’’
newest = self._Node(e,None)
if self.is_empty():
self._head = newest
else:
self:_tail._next = newest
self._tail = newest
self._size +=1

De nombreux aspects de notre implémentation sont similaires à ceux de


la classe LinkedStack. A titre d’exemple la définition de la classe imbriquée
Node. Notre mise en oeuvre de dequeue pour LinkedQueue est similaire
à celle de pop pour LinkedStack, car les deux suppriment en tête de la liste
chaı̂née. Cependant, il y a une différence subtile parce que notre file d’attente
doit maintenir avec précision la référence de queue (aucune variable de ce type
n’a été maintenue pour notre pile).
En général, une opération à la tête n’a aucun effet sur la queue, mais quand
dequeue est invoqué sur une file d’attente avec un élément, nous supprimons
simultanément la queue de la liste. On affecte alors N one à self. queue pour
maintenir la cohérence. Il y a une complication similaire dans notre implémentation
de la méthode enqueue. Le plus récent noeud devient toujours la nouvelle
queue. Pourtant, une distinction est faite si ce nouveau noeud est le seul noeud
de la liste. Dans ce cas, il devient également la nouvelle tête de liste.
En termes de performances, la classe LinkedQueue est similaire à la classe
LinkedStack dans la mesure où toutes les opérations s’exécutent dans le pire
des cas en un temps constant, et l’utilisation de l’espace est proportionnellement
linéaire au nombre actuel d’éléments.
7.3. LES LISTES CHAINÉES CIRCULAIRES 157

7.3 Les listes chainées circulaires


Dans le cas des listes chaı̂nées, nous pouvons faire en sorte que la queue de
la liste utilise sa prochaine référence pour pointer vers la tête de la liste, comme
le montre la figure 7.7. Nous appelons une telle structure une liste chaı̂née
circulaire.

Figure 7.7 – Exemple d’une liste chaı̂née simple et circulaire.

Une liste chaı̂née circulaire fournit un modèle plus général qu’une liste chaı̂née
simple. Il s’agit d’une liste dans laquelle les données sont chainées de manière
circulaire.

Figure 7.8 – Exemple de liste chaı̂née circulaire avec un noeud courant (current
node). Ce noeud désigne la référence au noeud actuellement sélectioné.

Dans une telle liste les données se suivent de manière cyclique, c’est-à-dire
qu’elles n’ont pas de notion particulière de début et de fin. La figure 7.8 fournit
une illustration plus symétrique de la liste chaı̂née circulaire présentée par la
figure 7.7.
Une vue circulaire similaire à la figure 7.8 pourrait être utilisée, par exemple,
pour décrire l’ordre des arrêts de train dans la boucle de Chicago, ou l’ordre
dans lequel les joueurs se relaient pendant un match. Même si une liste chaı̂née
circulaire n’a ni début ni fin, nous devons toutefois maintenir une référence à
un noeud particulier afin d’utiliser la liste. Nous utilisons l’identifiant courant
158 CHAPITRE 7. LES LISTES CHAINÉES

(”current”) pour décrire un tel noeud. En définissant current = current.next,


nous pouvons avancer efficacement à travers les noeuds de la liste.

7.3.1 Implémentation d’une file d’attente avec une liste


chaı̂née circulaire
Pour implémenter l’ADT file d’attente en utilisant une liste chaı̂née circu-
laire, nous nous appuyons sur l’intuition de la figure 7.7, dans laquelle la file
d’attente a une tête et une queue, mais en nous orgonanisant pour que la queue
de liste soit liée à la tête de la liste qui devient son next. Compte tenu d’un
tel modèle, il n’est pas nécessaire pour nous d’expliciter comment stocker des
références à la fois de la tête et de la queue ; tant que nous gardons une référence
à la queue, on peut toujours trouver la tête en suivant la prochaine référence
de la queue. Le fragment de code ci-dessous fournit une implémentation de la
classe CircularQueue.

class CircularQueue:
"""Queue implementation using
circularly linked list for storage"""

class _Node:
"""Lightweight, nonpublic class
for storing a singly linked node"""
(omitted here; identical to that
of LinkedStack._Node)
def __init__(self):
""" Create an empty queue"""
self._tail = None
self._size = 0

def __len__(self):
"""Return the number of
elements in the queue"""
return self._size

def is_empty(self):
"""Return True if the
queue is empty."""
return self._size == 0

def first(self):
"""Return (but not remove)
the element at the front of
the queue. Raise Empty
exception if the queue is empty."""
7.3. LES LISTES CHAINÉES CIRCULAIRES 159

if self.is_empty():
raise Empty(’Queue is empty’)
head = self._tail._next
return head._element

def dequeue(self):
"""Remove and return the first
element of the queue (FIFO).
Raise Empty exception if the
queue is empty."""
if self.is_empty():
raise Empty(’Queue is empty’)
oldhead = self._tail._next
if self._size == 1:
self._tail = None
else:
self._tail._next = oldhead._next
self._size -= 1
return oldhead._element

def enqueue(self, e):


"""Add an element to the back
of the queue."""
newest = self._Node(e, None)
if self.is_empty():
newest._next = self._tail._next
self._tail_next = newest
self._tail = newest
self._size += 1

def rotate(self):
"""Rotate front element to
the back of the queue."""
if self._size >0:
self._tail = self._tail._next

Les deux seules variables d’instance sont tail, qui est une référence au noeud
de queue (ou à N one lorsque la liste est vide) et la taille (size), qui est la
valeur actuelle du nombre d’éléments dans la file d’attente. Lorsqu’une opération
implique l’avant de la file d’attente, nous reconnaissons self. tail. next comme
la tête de la file d’attente. Lorsque la méthode enqueue est appelèe, un nouveau
noeud est placé juste après la queue de la liste, mais avant la tête actuelle. Ainsi
le nouveau noeud devient la queue de la file d’attente.
En plus des opérations de file d’attente traditionnelles, la classe Circular-
Queue prend en charge une méthode rotate qui met en oeuvre plus effica-
160 CHAPITRE 7. LES LISTES CHAINÉES

cement la combinaison de l’enlèvement d’un élément de l’avant de la file d’at-


tente et sa réinsértion à l’arrière de la file d’attente. Avec une liste circulaire
comme support de stockage des éléments, nous effectuons l’affectation suivante
self. tail = self. tail. next pour transformer l’ancienne tête de la file d’attente
en sa nouvelle queue.

7.4 Liste doublement chaı̂née


Dans une liste chaı̂née simple, chaque noeud conserve une référence vers le
noeud qui vient immédiatement après lui. C’est son next. Nous avons montré
dans divers exemples l’utilité d’une telle représentation dans la gestion d’une
séquence d’éléments. Cependant, il existe pour des tels types de listes des limites
qui découlent de l’asymétrie d’une liste chaı̂née simple. Il est facile d’insérer
efficacement un noeud à chaque extrémité d’une liste chaı̂née simple. De même,
on peut sans trop de difficultés supprimer un noeud en tête de liste. Nous sommes
toutefois incapables de supprimer efficacement un noeud en queue de liste.
Plus généralement, on ne peut pas supprimer efficacement un noeud arbi-
traire à partir d’une position intérieure de la liste si l’on ne connait que le
reférence à ce noeud. En effet, dans ces conditions, il nous est impossible de
déterminer le noeud qui précède immédiatement le noeud à supprimer alors que
ce noeud doit subir une mise à jour de son next. Pour avoir une plus grande
symétrie, nous pouvons définir une liste chaı̂née dans laquelle chaque noeud
conserve une référence explicite au noeud qui le précède et une référence au
noeud qui le suit. Une telle structure est connue sous le nom de liste double-
ment chaı̂née.
Les listes doublement chaı̂nées permettent une plus grande variété d’opérations
de mise à jour en un temps d’exécution O(1). Les insertions, comme les sup-
pressions à des positions arbitraires au sein des telles listes sont grandement
simplifiées. Nous continuons à utiliser le terme Â≪ next Â≫ pour la référence à
un noeud qui suit un autre, et nous introduisons le terme Â≪ previous (prev)
Â≫ pour la référence au noeud qui le précède.

Sentinelles d’en-tête et de queue

Afin d’éviter certains cas particuliers lorsque l’on opère près de la tête ou
de la queue d’une liste doublement chaı̂née, il est utile d’ajouter deux noeuds
spéciaux dans la liste : un noeud d’en-tête au début de la liste et un noeud de
queue à la fin de la liste. Ces noeuds Â≪ fictifs Â≫ sont appelés sentinelles (ou
gardes), et ils ne stockent pas d’éléments de la séquence. Une liste doublement
chaı̂née avec de telles sentinelles est illustrée à la figure 7.9.
Lors de l’utilisation de noeuds sentinelles, une liste vide est initialisée de
sorte que l’en-tête (header) ait comme next le trailer et la queue (trailer) ait
comme prev le header.
7.4. LISTE DOUBLEMENT CHAÎNÉE 161

Figure 7.9 – Une liste doublement chaı̂née contenant la séquence {(JFK, PVD,
SFO)} et utilisant une sentinelle d’en tête (header) et une sentinelle de queue
(trailer).

Avantage d’utiliser des sentinelles


Bien que nous puissions implémenter une liste doublement chaı̂née sans
noeuds sentinelles, le léger espace supplémentaire consacré aux sentinelles sim-
plifie grandement la logique de nos opérations. Plus particulièrement, l’en-tête
(header) et la queue (trailer) ne changent jamais, seuls les noeuds entre eux
changent. Par ailleurs, nous pouvons traiter toutes les insertions de manière
unifiée, car un nouveau noeud sera toujours placé entre une paire de noeuds
existants. De la même manière la position de chaque élément devant être sup-
primé est connue avec précision. En effet, ce noeud est situé entre son prev et
son next.

Insertion et suppression dans une liste doublement chaı̂née


Chaque insertion dans notre représentation en liste doublement chaı̂née aura
lieu entre une paire de noeuds existants, comme schématisé sur la Figure 7.10.
Par exemple, lorsqu’un nouvel élément est inséré au début de la séquence, nous
allons simplement ajouter le nouveau noeud entre l’en-tête (header) et son
(next).
La suppression d’un noeud, représentée sur la figure 7.11, se déroule de la
manière inverse d’une insertion. Les deux voisins du noeud à supprimer sont
liés directement l’un à l’autre, contournant ainsi le noeud à supprimer. En
conséquence, ce noeud cesse de faire partie de la liste et il peut être récupéré
par le système (”Garbage collector”). Du fait de l’utilisation des sentinelles, la
même implémentation peut être utilisée lors de la suppression du premier ou
du dernier élément d’une séquence, car même un tel élément est stocké dans un
noeud situé entre deux autres dont la localisation est connue.

7.4.1 Implémentation de base d’une liste doublement


chaı̂née
Nous mettons en oeuvre ici une version préliminaire d’une liste doublement
chaı̂née, sous la forme d’une classe nommée DoublyLinkedBase. Nous nom-
mons intentionnellement la classe avec un trait de soulignement car nous n’en-
tendons pas qu’elle fournisse une interface publique cohérente à usage général.
162 CHAPITRE 7. LES LISTES CHAINÉES

Figure 7.10 – Ajout d’un élément dans une liste doublement chaı̂née ayant
une sentinelle en tête (header) et une sentinelle en queue (trailer). (a) Avant
l’opération ; (b) Après création du nouveau noeud ; (c) Après avoir lié le nouveau
noeud à ses voisins.

Nous verrons que les listes doublement chaı̂nées peuvent supporter des in-
sertions et suppressions générales ayant un temps d’exécution O(1)dans le pire
des cas. Avec les séquences basées sur des tableaux, un indice entier était un
moyen pratique pour décrire une position dans une séquence. Cependant, un in-
dice n’est pas pratique pour les listes chaı̂nées car il n’y a pas de moyen efficace
de trouver le j ième élément ; il semblerait qu’il faille parcourir une partie de la
liste.
Lorsque l’on travaille avec une liste chaı̂née, la façon la plus directe de décrire
l’emplacement d’une opération consiste à identifier un noeud pertinent de la
liste. Cependant, nous préférons encapsuler le fonctionnement interne de notre
structure de données en évitant que des utilisateurs aient directement accès aux
noeuds de notre liste. Dans la suite de ce chapitre, nous développerons deux
classes publiques qui héritent de notre classe DoublyLinkedBase pour offrir
des abstractions plus cohérentes.
Notre classe DoublyLinkedBase de bas niveau repose sur l’utilisation
d’une classe N ode non publique similaire à celle que nous avons utilisé pour
l’implémentation d’une liste chaı̂née simple. Toutefois, la version de la classe
N ode utilisée pour l’implémentation d’une classe doublement chaı̂née inclut un
attribut prev, en plus des attributs next et element, comme indiqué dans
le fragment de code ci-dessous :

class _Node:
"""Ligthweight, nonpublic class
7.4. LISTE DOUBLEMENT CHAÎNÉE 163

Figure 7.11 – Suppression de l’élément (PVD) de la liste doublement chaı̂née.


(a) Avant la suppression ; (b) Après suppression des liens du noeud à supprimé ;
(c) Après avoir liés entre eux le prev et le next du noeud supprimé.

for storing q doubly linked node"""


__slots__ = ’element’, ’\_prev’, ’\_next’

def __init__(self, element, prev, next):


self._element = element
self._prev = prev
self._next = next
La classe DoublyLinkedBase complète est repris ci-dessous :
class _DoublyLinkedBase:
""" A base class providing a doubly
linked list representation"""

class _Node:
"""Ligthweight, nonpublic class
for storing q doubly linked node"""
__slots__ = ’element’, ’\_prev’, ’\_next’

def __init__(self, element, prev, next):


self._element = element
self._prev = prev
self._next = next

def __init__(self):
164 CHAPITRE 7. LES LISTES CHAINÉES

"""Create an empty list."""


self._header = self._Node(None, None, None)
self._trailer = self._Node(None, None, None)
self._header._next = self._trailer
self._trailer._prev = self._header
self._size = 0

def __len__(self):
"""Return the number of elements in the list"""
return self._size

def is_empty(self):
"""Return True if list is empty."""
return self._size == 0

def _insert_between(self, e, predecessor, successor):


"""Add element e between two existing
nodes and return new node."""
newest = self._Node(e, predecessor, successor)
predecessor._next = newest
successor._prev = newest
self._size +=1
return newest

def _delete_node(self, node):


"""Delete nosentinel node from
the list and return its element."""
predecessor = node._prev
successor = node._next
predeccessor._next = successor
successor._prev = predecessor
self._size -= 1
element = node._elepment
node._prev = node._next = node._element = None

Le constructeur de la classe DoublyLinkedBase instancie les deux noeuds


sentinelles et les relie l’un à l’autre. Nous maintenons une donnée membre size
et fournissons un soutien public aux méthodes len et is empty.
Les deux autres méthodes de notre classe sont les utilitaires non publics,
insert between et delete node qui permettent l’insertion et la suppression
d’un noeud. Ceux-ci fournissent un support générique pour les insertions et les
suppressions, respectivement, mais nécessitent une ou plusieurs références de
noeuds en tant que paramètres.
La mise en oeuvre de la méthode insert between est basée sur l’algorithme
illustré à la figure 7.10. Tout commence par la création d’un nouveau noeud.
7.4. LISTE DOUBLEMENT CHAÎNÉE 165

Ensuite les champs de ce nouveau noeud sont liés à ceux des noeuds voisins
spécifiés. Pour plus de commodité plus tard, la méthode renvoie une référence
au noeud nouvellement créé.
L’implémentation de la méthode delete node est basée sur l’algorithme
illustré à la figure 7.11. Les voisins du noeud à supprimer sont liés directe-
ment l’un à l’autre, contournant ainsi le noeud à supprimé de la liste. A titre
de formalité, nous réinitialisons intentionnellement les champs prev, next et
element du noeud supprimé à N one (après enregistrement de l’élément à re-
tourner). Bien que le noeud supprimé soit ignoré par le reste de la liste, définir
ses champs sur N one est avantageux car il peut aider le ramasse-miettes de
Python, car les liens inutiles sont éliminés. Nous nous appuierons également sur
cette configuration pour reconnaı̂tre un noeud comme ”deprecated” lorsqu’il ne
fait plus partie de la liste.

7.4.2 Implémentation d’un Deque avec une liste double-


ment chaı̂née
L’ADT file d’attente à deux extrémités (deque) a été introduite avec une
implémentation basée sur un tableau. Dans ce cas, nous avons réalisé toutes
les opérations en un temps O(1) amorti, en raison du besoin occasionnel de
redimensionnement du tableau. Avec une mise en oeuvre basée sur une liste
doublement chaı̂née, nous pouvons réaliser toutes les opérations de deque dans
le pire des cas en un temps O(1). Nous fournissons, une implémentation d’une
classe LinkedDeque dans le fragment de code ci-dessous :

class LinkedDeque(_DoublyLinkedBase):
"""Double-ended queue implementation
based on a doubly linked list """

def first(self):
"""Return (but not remove) the element
at the front of the deque."""
if self.is_empty():
raise Empty("Deque is empty")
return self._header._next._element

def last(self):
"""Return (but not remove) the
element at the back of the deque."""
if self.is_empty():
raise Empty("Deque is empty")
return self._trailer._prev._element

def insert_first(self, e):


"""Add an element to the front of the deque."""
self._insert_between(e, self._header, self._header._next)
166 CHAPITRE 7. LES LISTES CHAINÉES

def delete_first(self):
"""Remove and return the element from the
front of the deque.
Raise Empty exception if deque is empty."""
if self.is_empty():
raise Empty("Deque is empty")
return self._delete._node(self._header._next)

def delete_last(self):
"""Remove and return the element from the
back of the deque.
Raise Empty exception if deque is empty."""
if self.is_empty():
raise Empty("Deque is empty")
return self._delete_node(self._trailer._prev)

LinkedDeque hérite de la classe DoublyLinkedBase de la section précédente.


Nous ne fournissons pas de méthode explicite d’initialisation init pour la
classe LinkedDeque, car la version héritée suffit pour initialiser une nouvelle
instance. Nous nous appuyons également sur les versions héritées des méthodes
len et is empty pour répondre aux besoins de l’ADT deque.
Avec l’utilisation de sentinelles, la clé de notre mise en oeuvre est de se
rappeler que l’en-tête ne stocke pas le premier élément du deque. C’est le
noeud juste après l’en-tête qui stocke le premier élément (en supposant que
deque n’est pas vide). De la même manière, c’est le noeud juste avant la queue
qui stocke le dernier élément du deque.
Nous utilisons la méthode d’insertion héritée insert between pour effec-
tuer une quelconque opération d’insértion. A noter que ces opérations réussissent,
même lorsque le deque est vide ; dans une telle situation, le nouveau noeud
est placé entre les deux sentinelles. Lors de la suppression d’un élément d’un
deque non vide, nous nous s’appuyons sur la méthode de suppression héritée
delete node, sachant que le noeud désigné connait ses voisins de gauche et de
droite.

7.5 La liste positionnelle ADT


Les types de données abstraits que nous avons considérés jusqu’à présent,
à savoir les piles, les files d’attente, et les files d’attente à deux extrémités,
n’autorisent que les opérations de mise à jour qui se produisent à une des
extrémités d’une séquence. Nous souhaitons avoir une abstraction plus générale.
Par example, bien que nous ayons motivé la sémantique FIFO d’une file d’at-
tente comme modèle pour les clients qui attendent de parler à un représentant
du service client, ou des fanatiques qui font la file pour acheter des billets pour
un spectacle, l’ADT file d’attente est trop limitée.
7.5. LA LISTE POSITIONNELLE ADT 167

Et qu’est-ce qui se passerait si un client dans une file d’attente décide de


raccrocher avant d’atteindre le devant de la file ? Qu’est-ce-qui se passerait si
quelqu’un qui fait la file d’attente pour acheter des billets autorise un ami de
”couper” la file à sa position ? Nous aimerions concevoir un ADT qui fournit
à un utilisateur un moyen de se référer à des éléments n’importe où dans une
séquence, et d’effectuer des insertions et des suppressions à des positions arbi-
traires.
Lorsque l’on travaille avec des séquences basées sur des tableaux (telles
qu’une liste Python), les indices entiers fournissent un excellent moyen pour
déterminer l’emplacement d’un élément, ou l’emplacement auquel une insertion
ou une suppression doit avoir lieu. Cependant, les indices numériques ne sont
pas un bon choix pour déterminer des positions dans une liste chaı̂née parce que
nous ne pouvons pas accéder efficacement à un élément en ne connaissant que
l’indice de sa position.
Trouver un élément à un indice donné dans une liste chaı̂née nécessite de
parcourir la liste de manière séquentielle à partir de son début ou de sa fin, en
comptant les éléments au fur et à mesure. De plus, les indices ne sont pas une
bonne abstraction pour déterminé une position locale dans certaines applica-
tions, car l’indice d’un élément change au fil du temps en raison des insertions
ou des suppressions qui se produisent dans la séquence.
Nous préférons une abstraction, comme illustrée par la figure 7.12, dans
laquelle il existe d’autres moyens pour déterminer une position. On souhaite
alors modéliser des situations comme lorsqu’une personne identifiée quitte la
ligne avant d’atteindre le front, ou des situations où une nouvelle personne est
ajoutée à une ligne immédiatement derrière une autre personne identifiée.

Figure 7.12 – Nous souhaitons pouvoir identifier la position d’un élément dans
une séquence sans utiliser un indice.

Autre exemple, un document texte peut être considéré comme une longue
séquence de personnages. Un traitement de texte utilise l’abstraction d’un cur-
seur pour déterminer une position dans le document sans utilisation explicite
d’un indice entier. Cette abstraction devrait permettre des opérations telle que
supprimer le caractère au niveau du curseur, ou insérer un nouveau caractère
168 CHAPITRE 7. LES LISTES CHAINÉES

juste après le curseur. De plus, nous devons être en mesure de faire référence à
une position inhérente dans un document, comme le début d’une section parti-
culière, sans s’appuyer sur un indice (ou même un numéro de section) qui peut
changer au fur et à mesure de l’évolution du document.

Une référence de noeud en tant que position

L’un des grands avantages d’une liste chaı̂née est qu’il est possible d’effectuer
des insertions et des suppressions en un temps O(1) à des positions arbitraires
de la liste, dès que nous recevons une référence à un noeud pertinent de la liste.
Il est donc très tentant de développer un ADT dans lequel une référence de
noeud sert de mécanisme pour déterminer une position. En fait, notre classe
DoubleLinkedBase a des méthodes insert between et delete node qui
acceptent les références de noeuds comme paramètres. Cependant, une telle
utilisation directe des noeuds violerait les principes d’abstraction et d’encap-
sulation qui sont de rigueur dans la conception orientée objet. Il y a plusieurs
raisons de préférer que nous encapsulions les noeuds d’une liste chaı̂née, tant
pour notre propre protection que pour la protection des utilisateurs de notre
abstraction :
— Ce sera plus simple pour les utilisateurs de notre structure de données
s’ils ne sont pas dérangés par détails inutiles de notre implémentation,
tels que la manipulation de bas niveau de noeuds, ou notre dépendance
à l’utilisation de noeuds sentinelles. Notez que pour utiliser la méthode
insert between de notre classe DoubleLinkedBase pour ajouter un
noeud au début d’une séquence, l’en-tête sentinelle doit être passée en
argument.
— Nous pouvons fournir une structure de données plus robuste si nous ne
permettons pas aux utilisateurs d’accéder directement aux noeuds. De
cette façon, nous nous assurons que les utilisateurs ne peuvent pas inva-
lider la cohérence d’une liste en gérant mal l’enchaı̂nement des noeuds.
Un problème plus subtil se pose si un utilisateur est autorisé à appeler
les méthodes de gestion de noeud de notre classe DoubleLinkedBase,
insert between et delete node en passant comme argument un noeud
qui n’appartient pas à la liste.
— En encapsulant mieux les détails internes de notre implémentation, nous
avons une plus grande flexibilité pour reconcevoir la structure des données
et améliorer ses performances. En fait, avec une abstraction bien conçue,
nous pouvons donner l’impression de ne pas utiliser le concept de position
numérique, même si nous utilisons une séquence basée sur un tableau.
Pour ces raisons, au lieu de s’appuyer directement sur les noeuds, nous intro-
duisons une abstraction de position pour désigner l’emplacement d’un élément
dans une liste, et puis une ADT liste positionnelle complète qui peut encapsuler
une liste doublement chaı̂née (ou même une séquence basée sur un tableau).
7.5. LA LISTE POSITIONNELLE ADT 169

7.5.1 L’ADT liste positionnelle (ADT Positional List)

Pour fournir une abstraction générale d’une séquence d’éléments avec la ca-
pacité d’identifier l’emplacement d’un élément, nous définissons un ADT posi-
tional list ainsi qu’un ADT position plus simple pour déterminer un emplace-
ment dans une liste. Une position agit comme un marqueur ou un jeton dans une
liste de positions. Une position p n’est pas affectée par des changements ailleurs
dans une liste ; la seule façon dont une position devient invalide est qu’une com-
mande explicite soit émise pour la supprimer. Une instance de position est un
objet simple, prenant en charge uniquement la méthode suivante :

p.element() : Renvoie l’élément stocké à la position p.

Dans le cadre de l’ADT position list, les positions servent d’arguments à cer-
taines méthodes et comme valeurs de retour pour d’autres méthodes. Ci-dessous,
les méthodes d’accès supportées par une ADT liste positionelle L :

L.first( ) : Renvoie la position du premier


élément de L, ou N one si L est vide.
L.last( ) : Renvoie la position du dernier
élément de L, ou N one si L est vide.
L.before(p) : Renvoie la position de L immédiatement
avant la position p, ou N one
si p est la première position.
L.after(p) : renvoie la position de L immédiatement
après la position p, ou N one si
p est la dernière position.
L.est vide( ) : Renvoie True si la liste L
ne contient aucun élément.
len(L) : Renvoie le nombre
d’éléments dans la liste.
iter(L) : Renvoie un itérateur sur
les éléments de la liste

L’ADT liste positionnelle comprend également les méthodes de mise à jour


suivantes :
170 CHAPITRE 7. LES LISTES CHAINÉES

L.add first(e) : insère un nouvel élément e


devant L. Retourne la position
du nouvel élément.
L.add last(e) : insère un nouvel élément e
à l’arrière de L. retourne
la position du nouvel élément.
L.add before(p, e) : insère un nouvel élément e
juste avant la position p dans. Retourne
la position du nouvel élément.
L.add after(p, e) : Insère un nouvel élément e
juste après la position p dans L. Retourne
la position du nouvel élément.
L.replace(p, e) : Remplace l’élément à
la position p par l’élément e. Retourne
l’élément anciennement à la position p.
L.delete(p) : Supprime et renvoie l’élément
à la position p dans L. Invalide
la position.

Pour les méthodes de l’ADT qui acceptent une position p comme argument, une
erreur se produit si p n’est pas une position valide pour la liste L.

7.5.2 Implémentation de la liste doublement chaı̂née


Dans cette section, nous présentons une implémentation complète d’une
classe PositionalList en utilisant une liste doublement chaı̂née qui satisfait
la proposition suivante.

Proposition :
Chaque méthode de l’ADT liste positionnelle s’exécute dans le pire des cas
en un temps O(1) lorsque l’ADT est mis en oeuvre avec une liste doublement
chaı̂née. Nous nous appuyons sur la classe DoubleLinkedBase pour notre
implémentation. La principale responsabilité de notre nouvelle classe est de four-
nir une interface publique conforme à l’ADT liste positionnelle. Nous donnons
notre définition de la classe PositionalList dans le fragment de code ci-dessous.
La définition de la classe de Position est imbriquée dans notre classe Positio-
nalList.

class PositionalList(_DoublyLinkedBase):
"""A sequential container of elements
allowing positional access"""
#------------------------nested Position class------
class Position:
"""An abstraction representing the
7.5. LA LISTE POSITIONNELLE ADT 171

the location of a single element."""


def __init__(self, cont ainer, node)$
"""Constructor should not be invoked by user"""
self._container = container
self._node = node

def element(self):
"""Return the element stored
at this position"""
return self._node._element

def __eq__(self, other):


"""Return True if other is a Position
representing the same location"""
return not (self == other)

def _validate(self, p):


"""Return position’s node, or
raise appropriate error if valid"""
if not isintance(p, self.Position):
raise TypeError(’p must be proper
Position type’)
if p._container is not self:
raise ValueError(’p does not belong
to this container’)
if p._node._next is None:
raise ValueError(’p is no longer valid’)
return p._node

def _make_position(self, node):


"""Return Position instance for
given node (or None Sentinel)."""
if node is self._header or is self._trailer:
return None
else:
return self.Position(self,node)
#---------------accessors--------------------------
def first(self):
"""Return Position instance for
given node (or None sentinel)."""
if node is self._header or node is self._trailer:
return None
else:
return self.Position(self, node)

#----------------accessors---------------------------
172 CHAPITRE 7. LES LISTES CHAINÉES

def first(self):
"""Return the first Position in the
list (or None if list is empty)."""
return self._make_position(self._header._next)

def last(self):
"""Return the last Position in the list
(or None if list is empty)."""
node = self._validate(p)
return self._make_position(node._prev)

def before(self, p):


"""Return the Position just before Position p
(or None if p is first)."""
node = self._validate(p)
return self._make._position(node._prev)

def after(self, p):


"""Return the Position just after
Position p (or None if p is first)"""
node = self._validate(p)
return self._make_position(node._next)
def __iter__(self):
"""Generate a forward iteration
of the elements of the list"""
cursor = self.first()
while cursor is not None:
yield cursor.element()
cursor = self.after(cursor)
#-------------mutators--------------------------
# override inherited version to return Posion
# rather than Node
def _insert_between(self, e, predecessor, successor):
"""Add element between existing nodes
and return new Position"""
node = super()._insert_between
(e, predeccessor, successor)
return self._make_position(node)

def add_first(self, e):


"""Insert element e at the front of the list
and return new Position"""
return self._insert_between
(e, self._header, self._header._next)

def add_last(self, e):


7.5. LA LISTE POSITIONNELLE ADT 173

"""Insert element e at the back of the list


and return new Position"""
return self._insert_between
(e, self._header, self._header._next)

def add_before(self, p, e):


"""Insert element e into list before Position p
and return new Position"""
original = self._validate(p)
return self._insert_between
(e, original._prev, original)

def add_after(self, p, e):


"""Insert element e into list after Position p
and return new Position"""
original = self._validate(p)
return self._insert_between
(e, original, original._next)

def delete(self, p):


"""Remove and return the element at Position p."""
original = self._validate(p)
return self._delete_node(original)

def replace(self, p, e):


"""Replace the element at Position p with e.
Return the element formely at Position p."""

original = self._validate(p)
old_value = original._element
original._elment = e
return old_value

Les instances de position seront utilisées pour représenter les emplacements


d’éléments dans la liste. Les différentes méthodes de PositionalList peuvent
finir par créer des instances de Position redondantes qui font référence au
même noeud sous-jacent (par exemple, quand le premier et le dernier noeuds
sont identiques). Pour cette raison, notre classe Position définit les méthodes
spéciales eq et ne de sorte qu’un test p == q est évalué à vrai lorsque
deux positions font référence au même noeud.

Validation des positions


A chaque fois qu’une méthode de la classe PositionalList accepte une po-
sition comme argument, nous devons vérifier que la position est valide, et si
174 CHAPITRE 7. LES LISTES CHAINÉES

oui, identifier le noeud sous-jacent associé à la position. Cette fonctionnalité


est implémentée par une méthode non publique nommée validate. En interne,
une position maintient une référence au noeud associé de la liste chaı̂née, ainsi
qu’une référence à l’instance de liste qui contient le noeud spécifié.

7.6 Comparaisons entre les séquences basées sur


les liens et celles basées sur les tableaux
Nous terminons ce chapitre en réfléchissant sur les avantages et les in-
convénients relatifs aux structures de données basées sur des liens et sur les
tableaux qui ont été introduites jusqu’à présent. Il n’y a pas de solution uni-
verselle, car chaque type de structure de données offre des avantages et des
inconvénients.

Avantages des séquences basées sur les tableaux

— Les tableaux fournissent un accès en un temps O(1) à un élément par


l’utilisation d’un indice entier. La possibilité d’accéder à l’élément k pour
n’importe quel k en un temps O(1) est un des avantages des tableaux.
En revanche, localiser le k ième élément dans une liste chaı̂née nécessite
un temps O(k) car il faut parcourir la liste depuis le début, ou éventuel-
lement un temps O(n − k), dans le cas où il faut parcorir la liste à partir
de sa fin.
— Les opérations avec des bornes asymptotiques s’exécutent généralement
un facteur constant plus efficacement pour les structures basées sur les
tableaux par rapport à celles basées sur les listes chaı̂nées.
— Les représentations basées sur un tableau utilisent proportionnellement
moins de mémoire que structures liées. Cet avantage peut sembler contre-
intuitif, d’autant plus que la taille d’un tableau dynamique peut être
plus importante que ce qui est nécessaire pour le nombre d’éléments
qu’il stocke. Les listes basées sur des tableaux et les listes chaı̂nées sont
des structures référentielles, donc la mémoire principale pour stocker les
objets réels qui sont des éléments est la même pour l’une ou l’autre struc-
ture. Ce qui diffère, ce sont les quantités auxiliaires de mémoire qui sont
utilisées par les deux structures. Pour un conteneur basé sur un tableau
de n éléments, le pire des cas typique peut être qu’un tableau dynamique
récemment redimensionné a une mémoire allouée pour 2n références d’ob-
jets. Avec les listes chaı̂nées, la mémoire doit être consacrée non seule-
ment à stocker une référence à chaque objet contenu, mais aussi pour
stocker des références explicites qui relient les noeuds. Donc une liste
chaı̂née simple de longueur n nécessite déjà 2n références (une référence
d’élément et la référence suivante pour chaque noeud). Avec une liste
doublement chaı̂née, il y a 3n références.
7.6. COMPARAISONS ENTRE LES SÉQUENCES BASÉES SUR LES LIENS ET CELLES BASÉES SUR LES TAB

Avantages des séquences basées sur des liens


— Les structures basées sur des liens fournissent des limites temporelles
dans le cas le plus défavorable pour leurs opérations. Cela contraste avec
les limites amorties associées à l’expansion ou contraction d’un tableau
dynamique. Lorsque de nombreuses opérations individuelles font partie
d’un calcul plus vaste, et que nous ne nous soucions que du temps total
de ce calcul, une borne amortie est aussi bonne que le pire des cas des
listes chaı̂nées parce qu’elle donne une garantie sur la somme du temps
consacré aux opérations individuelles. Cependant, si des structures de
données sont utilisées dans un système temps réel conçu pour fournir des
réponses plus immédiates (par exemple, un système d’exploitation, Web
serveur, système de contrôle du trafic aérien), un long retard causé par
une seule opération(amortie) peut avoir un effet néfaste.
— Les structures basées sur des liens prennent en charge les insertions et les
suppressions en un temps O(1) à des positions arbitraires. La possibilité
d’effectuer une insertion ou une suppression en un temps constant avec la
classe PositionalList, en utilisant une classe Position pour déterminer
efficacement l’emplacement de l’opération, est peut-être l’avantage le plus
important des listes doublement liées. Ceci est en contraste frappant
avec une séquence basée sur un tableau. Ignorer la question du redimen-
sionnement d’un tableau, insérer ou supprimer un élément à n’importe
quelle position peut être fait en temps constant pour une liste double-
ment chainée. Cependant, des insertions plus générales et les suppressions
coÃ≫tent cher dans le cas des tableaux. Par exemple, avec la classe de
liste basée sur un tableau de Python, un appel pour insérer ou un pop()
avec l’index k nécessitent un temps O(n − k + 1) à cause de la boucle
pour décaler tous les éléments suivants. Comme exemple d’application,
considérons un éditeur de texte qui gère un document comme suite de ca-
ractères. Bien que les utilisateurs ajoutent souvent des caractères à la fin
du document, il est également possible d’utiliser le curseur pour insérer
ou supprimer un ou plusieurs caractères à une position arbitraire dans le
document. Si les caractères ont été stockés dans une séquence basée sur
un tableau (comme une liste Python), chacune de ces opérations d’édition
peut nécessiter le décalage linéaire de nombreux caractères, conduisant
à des performances O(n) pour chaque opération d’édition. Avec une
représentation en liste doublement chaı̂née, une opération d’édition ar-
bitraire (insertion ou suppression d’un caractère au curseur) peut être
effectuée en un temps O(1) dans le pire des cas, en supposant que nous
disposons de la position actuelle, qui représente l’emplacement du cur-
seur.
176 CHAPITRE 7. LES LISTES CHAINÉES
Chapitre 8

Les arbres

8.1 Introduction
Les arbres représentent un important développement dans l’organisation des
données, dans la mesure ou ils nous permettent d’implémenter des algorithmes
plus rapides que ceux basés sur les structures de données linéaires comme les
listes chainées.

Pour de nombreux experts, l’innovation vient du fait que l’arbre est une struc-
ture de données non linéaire. Par non linéaire on entend le fait que la relation
entre les éléments d’un arbre est plus riche que le simple fait de se retrouver
avant ou après un élément.

La relation entre les éléments d’un arbre est hiérarchique avec quelques objets
situés au dessus et quelques autres en dessous d’un objet pris au hasard dans
la structure. Pour décrire la relation entre les éléments d’un arbre, on trouve
les mots tels que : “parent”, “enfant”, “ancêtre”, “descendant”. La figure 8.1
montre un arbre tel qu’on le trouve dans la nature. L’arbre informatique quant
à lui occupe une position inversée par rapport à l’arbre naturel. La figure 8.2
en présente un.

8.2 Définition et proriétés d’un arbre


Un arbre est une structure de données abstraite qui stocke les données de
façon hiérarchique. A l’excèption de l’élément situé tout au-dessus de l’arbre,
chaqu’élément a un parent et zéro ou plusieurs enfants. L’élément situé tout
au-dessus de l’arbre est appelé la racine (voir figure 8.2).

Notons que les éléments sont disposés sur l’abre informatique de façon inverse
par rapport à l’arbre botanique.

177
178 CHAPITRE 8. LES ARBRES

Figure 8.1 – Exemple d’un arbre tel qu’on en trouve dans la nature.

Définition formelle
De façon plus formelle, un arbre T est un ensemble de noeuds stockant des
éléments de telle sorte que les noeuds ont entre eux une relation “parent-enfant”
qui satisfait aux propriétés suivantes :
— Si T n’est pas vide, il a un noeud spécial, appelé racine qui n’a pas de
parent ;
— Chaque noeud de T différent de la racine a un parent unique ; chaque
noeud ayant un parent est appelé enfant de celui-ci.
Selon notre définition, un arbre peut être vide, ceci revient à dire qu’il n’a pas
de noeuds. Cette convention nous permet de définir un arbre en nous appuyant
sur la récursivité : un arbre est donc soit vide, soit constitué d’un noeud appelé
racine et d’un ensemble d’arbres (vides ou pas) dont les racines sont les enfants
de la racine de l’arbre de départ.

8.3 Autres relations entre les noeuds d’un arbre


Deux noeuds qui sont enfants d’un même parent sont frères. Un noeud est
dit externe s’il n’a pas d’enfants. Un noeud est dit interne s’il a un ou plusieurs
enfants. Les noeuds externes sont appelés feuilles de l’arbre.

Exemple

Dans presque tous les systèmes d’exploitation, les fichiers sont organisés hiérar-
chiquement dans des répertoires qui sont présentés à l’utilisateur sous forme d’un
arbre. Les noeuds internes de l’arbre sont associés aux répertoires (directories)
et les noeuds externes aux fichiers. Voir schéma. Un noeud u est un ancêtre d’un
noeud v si u = v ou si u est un ancêtre du parent de v. Dans le schéma présenté
ci-dessus cs252/ est un ancêtre de papers/ et pr3 est un descendant de cs016/.
Le sous-arbre dont la racine est v est un arbre formé de tous les descandants de
v y compris v lui même.
8.4. BORDS ET CHEMINS DANS UN ARBRE 179

Figure 8.2 – L’abre informatique occupe une position inversée par rapport à
l’arbre naturel. La racine se situe au-dessus et les feuilles en-dessous. Ici nous
présentons le système de fichier du système d’exploitation Unix sous forme d’un
arbre.

8.4 Bords et chemins dans un arbre


Le bord d’un arbre est une paire de noeuds (u,v) tels que u est parent de
v ou vice-versa. Un chemin dans l’arbre est une suite de noeuds tels que deux
noeuds consécutifs quelconques forment un bord. Dans la figure 4.2 cs252/,
peojects/, demos/, market/ forment un chemin.

La relation d’héritage entre les classes dans un langage de programmation


orienté objet peut être implémentée par un arbre. Chaque classe est un descen-
dant de la racine principale et est la racine d’un sous-arbre formé de classes qui
lui sont externes.

8.5 Arbre Ordoné


Un arbre est dit ordonné s’il existe une relation d’ordre linéaire entre les
enfants de chaque noeud. Une telle relation d’ordre est visualisée en présentant
les frères de gauche à droite en fonction de la relation d’ordre. Les arbres ordonés
listent généralement les éléments d’un arbre dans le bon ordre.

Les composantes d’un document structuré, tel qu’un livre, sont hiérarchi-
quement organisés en un arbre dont les noeuds internes sont les parties du livre,
les chapitres, les sections, et dont les neouds externes sont des paragraphes, les
180 CHAPITRE 8. LES ARBRES

tables et les figures. La racine de cet arbre est le livre lui même. Nous pourions
continuer le développement de l’arbre dans la mesure ou les paragraphes sont
constituées des phrases, les phrases des mots et les mots de caractères, mais il
faut bien s’arreté quelque part. Un tel arbre est un arbre ordoné, parce-qu’il
existe une relation d’ordre bien définie entre les enfants de chaque noeud.

8.6 L’arbre comme type de donnée abstrait


Les éléments d’un arbre sont des noeuds. Ces noeuds sont répérés les uns par
rapport aux autres par leur position dans l’arbre. On se sert ici de la relation
d’ordre existant entre les enfants d’un noeud. Donc on identifiera les éléments
d’un arbre comme étant des noeuds ou des positions. Un objet position a utilisé
pour l’implémentation d’un arbre devrait avoir les méthodes suivantes :

element() : retourne l’objet stocker à la position en cours.


root() : retourne la racine ; une erreur survient si l’arbre est vide.
parent(v) : retourne le parent de v ; une erreur survient si v est la racine.
children(v) : retourne une collection itérable contenant les enfants du noeud v.

Si T est un arbre ordoné, alors la collection itérable des enfants de v est or-
donnée. Si v est un noeud externe alors la collection de ses enfants est vide.
En plus des méthodes ci-dessus l’objet position devrait supporter les méthodes
suivantes qui sont des questions :

isInternal(v) : test si v est un noeud interne.


isExternal(v) : test si v est un noeud externe.
isRoot(v) : test si v est la racine.

Ces méthodes facilitent la programation avec les arbres, dans la mesure ou


l’on peut les utiliser dans la condition d’une boucle. Il y a également un certain
nombre de méthodes génériques :

size() : retourne le nombre de noeuds de l’arbre.


isEmpty() : test si l’arbre est vide.
iterator() : retourne un itérateur de tous
les éléments stockés aux noeuds d’un arbre.
positions() : retourne une collection itérable de
tous les noeuds de l’arbre.
replace(v,e) : remplace v par e et retourne
l’élément stocké en v.
8.7. IMPLÉMENTATION D’UN ARBRE 181

Toute méthode qui reçoit comme argument une position, doit générer une er-
reur si la position est invalide. Jusqu’à ce niveau nous n’avons pas implémenter
de méthode de mise à jour de l’arbre. Dans la pratique on décrit les méthodes
de mise à jour d’un arbre en même temps que les méthodes spécifiques de l’ap-
plication en cours de développement.

8.7 Implémentation d’un arbre


Une structure chainée pour une représentation générale d’un arbre

Une façon naturelle de représenter un arbre est d’utilisé une structure chainée
ou nous représentons chaque noeud u de T par un objet position comprenant
les champs suivants :
— une réfrérence à l’élément stocké en u ;
— un lien vers le parent de u ;
— et une collection permettant de stocker les liens vers les enfants de u.
— une référence vers la racine ;
— le nombre de noeuds de T.

Figure 8.3 – Arbre généalogique montrant les descandants d’Abraham. Source


Sainte Bible. Genèse 25 :36.

(Suite du chapitre ...)


182 CHAPITRE 8. LES ARBRES
Chapitre 9

Les Graphes

9.1 Introduction
La connection par paires des objets joue un role important dans bon nombre
de problèmes informatiques. Nombreuses sont les questions qui viennent à l’es-
prit du fait de cette relation entre paires d’objets. Est-il possible de connecter
un objet à un autre en suivant cette relation ? Combien d’objets pourrait-on
connecter à un un objet donné ?
Pour modéliser des telles situations, nous utilisons des objets mathématiques
abstraits appelés graphes. La théorie des graphes est une discipline majeure
des mathématiques qui a été étudiée intensivement depuis plusieurs centaines
d’années. De nombreuses propriétés importantes des graphes ont été découvertes,
de nombreux algorithmes importants ont été développés en se servant des graphes
et plusieurs problèmes compliqués sont encore activement étudiés.
Dans ce cours nous allons étudier quelques algorithmes importants sur les
graphes qui sont utilisés dans le développement et l’optimisation des réseaux.
Pour illustrer la diversité des applications dans lesquelles la théorie des graphes
est utilisée, nous allons commencer par donner quelques exemples de ces appli-
cations.

Les cartes

Une personne souhaitant effectuer un déplacement se pose les questions du


type ”Quel est le plus court chemin pour aller de la Gombe à Lemba ?”. Un
conducteur un peu plus expérimenté qui est au fait du problème des embou-
teillages dans la ville de Kinshasa se posera lui des questions du type ”Quel est
le chemin entre la Gombe et Lemba qui présente le moins d’embouteillages ?”
ou encore ”Par quel chemin puis-je rallier Lemba en partant de la Gombe avec
comme contrainte de mettre le temps minimum ?”. Pour répondre à ce type de
questions, nous traitons l’information à propos des connections (chemins) entre
ces deux communes de la ville de Kinshasa.

183
184 CHAPITRE 9. LES GRAPHES

Le contenu du web
Lorsque nous naviguons sur le web, nous rencontrons les pages qui contiennent
des liens vers d’autres pages et nous passons d’une page à une autre en clickant
sur ces liens. Le web dans son entierté est un graphe dont les sommets sont les
pages web et les connections entre les pages les liens entre elles. Le traitement
des algorithmes sur les graphes sont une composante essentielle de la recherche
des informations sur le web.

Les circuits électroniques


Un circuit électronique comprend des transistors, des diodes, des résistances,
des capacités qui sont intimement connectés les un aux autres en vue de rendre
un ou plusieurs services. Lorsqu’on utilise des ordinateurs pour contrôler des
machines fabricants des circuits, nous souhaitons répondre à des questions du
type ”y-a-t-il un court-circuit ?” ou même à des questions plus compliquées du
type ”pouvons nous mettre en place toutes les connections entrent les différents
éléments du circuit sans que des cables se croisent, se chevauchent et en mi-
nimisant le coût de l’opération ?”. La réponse à la première question dépend
uniquement des propriétés des connections alors que la réponse à la deuxième
question fait intervenir les informations à propos des cables, des éléments du
circuit, de la manière dont ils sont interconnectés et des contraintes physiques
sur le circuit. Une fois de plus le circuit peut être modéliser comme un graphe
dont les sommets sont les broches des composants à interconnecter et les arêtes
les liens entre les broches.

La planification
Un processus industriel requiert une variété de corps de métiers pour être
exécuté, sous un ensemble de contraintes qui indiquent par exemple que cer-
taines tâches ne peuvent être commencées sans que d’autres ne soient achevées.
Comment organisons nous les tâches pour que nous puissions à la fois satisfaire
toutes les contraintes et en même temps réaliser le processus en prenant le moins
de temps possible ?

Les réseaux d’ordinateurs


Un réseau d’ordinateurs consiste en un ensemble de sites interconnectés qui
s’échangent diverses informations. Comment interconnecter les sites pour mini-
miser le coût d’un réseau rendant un service donné sans dégradé la qualité du
service ? Comment s’assurer que le réseau restera correctement dimensionner
jusqu’à un volume donné de messages entre les sites ?

Les réseaux électriques


Les réseaux électriques interconnectent les sites de production pour former
un réseau de distribution, ensuite ils intègrent des éléments de conversion des
9.1. INTRODUCTION 185

hautes tensions vers les basses et moyennes tensions avant que l’électricité ne soit
enfin transportée et distribuée aux consomateurs industriels et domestiques à
travers un réseau de distribution. Comment interconnecter les différents postes
du réseau pour en minimiser le coût ? Comment organiser ces éléments pour
minimiser les pertes ohmiques et bien d’autres problèmes obligent les électriciens
à recourir aux graphes.

Les logiciels

Un compilateur par exemple construit des graphes pour représenter les liens
entre différents modules d’un gros logiciel. Les objets en jeu sont ici les différentes
classes ou modules constituant le système et les connections sont associées aux
échanges de messages entre les classes ou modules. Une analyse du graphe de
relations entre les éléments du logiciel est nécessaire pour déterminer comment
allouer les ressources le plus efficacement possible aux éléments du logiciel.

Les réseaux sociaux

Lorsque vous utilisez un réseau social, vous établissez des connections avec
des amis, des connaissances. Ici les objets en jeu sont des personnes et les connec-
tions entre ces personnes correspondent au fait qu’elles sont amies ou ”follo-
wers”. Une bonne compréhension des relations entre les amis passe par l’usage
des graphes et interesse les politiques, les entreprises et les agences d’intelligence.
Le tableau ci-dessous résume la situation de ces quelques exemples men-
tionnés ci-dessus.

Application Objets utilisés Connections


entre objets
Cartes lieux (cités chemins ou
intersections routes entre
des chemins) les lieux
Contenu du web pages liens entre pages
Circuit composants cables reliants
électrique du circuit les composants
Planification jobs contraintes
Réseaux sites des connections
d’ordinateurs terminaux entre les sites
Réseaux sites de production, connections
électriques cabines HT/BT, abonnés entre les sites
Logiciels classes, appels des
modules méthodes
Réseaux personnes et/ou amis et/ou
sociaux organisations ”followers”
186 CHAPITRE 9. LES GRAPHES

9.2 Généralités
Un graphe est un ensemble de sommets dont certaines paires de sommets ou
toutes sont reliés par des arêtes. Nous représentons graphiquement les graphes
par des cercles pour les sommets et des lignes qui les relient pour illustrés les
arêtes. Une telle représentation matérialise intuitivement un graphe, mais elle
peut également désorienter en fournissant des informations fausses sur le graphe.
Par exemple, les sommets sont numérotés de 1 à n, ce qui pourrait faire penser
à un certain ordre alors qu’il n’y en réalité aucun ordre entre les sommets d’un
graphe. Le seul fait est que deux sommets peuvent être reliés par une ou plusieurs
arêtes.
Un graphe pour lequel l’arête reliant les sommets u et w peut être notée
(u, w) ou (w, u), est un graphe non orienté. Dans le cas d’un graphe orienté (u, w)
sont (w, u) deux arêtes différentes. La figure 9.1 donne deux représentations
d’un même graphe. Lorsqu’un sommet est relié à lui même par une arête, on
parle d’une boucle. Deux arêtes qui interconnectent les mêmes sommets sont
dits parallèles. La figure 9.2 illustre une boucle et des arêtes parallèles dans
un graphe. Pour les mathématiciens un graphe contenant des arêtes parallèles
est un multigraphe, alors qu’un graphe sans arêtes parallèles ni boucles est un
graphe simple.
Dans le présent cours nous ne parlerons que de graphes simples et n’aborde-
rons les multigraphes qu’en le spécifiant expressément. Lorsque deux sommets
(on parle parfois de noeuds) d’un graphe sont interconnectés par une arête, on
dit qu’ils sont adjacents l’un et l’autre et l’arête est dite incidente aux deux
sommets. Le dégré d’un sommet est le nombre d’arêtes qui lui sont incidents.
Un sous-graphe est un sous-ensemble d’un graphe ayant lui même les pro-
priétés d’un graphe. Beaucoup de tâches de calcul reviennent à identifier un
sous-graphe ayant des propriétés particulières. Un chemin dans un graphe est
une séquence de sommets connectés par des arêtes. Un chemin simple est un
chemin dans lequel aucun sommet ne se répète. Un cycle est un chemin avec au
moins une arête pour laquelle le premier et le dernier sommets sont identiques.
Un cycle simple est un cycle sans répétition d’arête ou de sommet à l’exception
de la répétition du premier et du dernier sommets.
La longueur d’un chemin ou d’un cycle est le nombre de ses arêtes. Nous
disons qu’un sommet est connecté à un autre s’il existe un chemin qui contient
les deux sommets. Pour représenter le chemin allant de u à x, on utilise la
notation u − v − w − x. u − v − w − x − u est un cycle qui commence et se termine
au sommet u.
Un graphe est dit connecté s’il existe un chemin partant d’un sommet quel-
conque vers tout autre sommet. Un graphe qui n’est pas connecté consiste en
un ensemble de composants non connectés. Un graphe acyclique est un graphe
sans cycles. Plusieurs des algorithmes que nous allons analyser et implémenter
se contentent de trouvé un graphe acyclique satisfaisant certaines propriétés.
Un arbre est un graphe acyclique connecté. Un ensemble d’arbres disjoints
est appelé une forêt. L’arbre couvrant d’un graphe connecté est un sous-graphe
contenant tous les noeuds du graphe et un arbre unique. La forêt couvrante
9.2. GÉNÉRALITÉS 187

Figure 9.1 – Deux représentations d’un même graphe.

Figure 9.2 – Illustration d’un cycle et des arrêtes parallèles.


188 CHAPITRE 9. LES GRAPHES

Figure 9.3 – Deux graphes avec le même nombre de sommets. Celui de 200
arrêtes n’est pas dense et cellui de 1000 arrêtes est dense.

d’un graphe est l’union des arbres couvrants de ses composantes connectées.
Un graphe G de S sommets est un arbre si seulement si il satisfait l’une des
conditions suivantes :
— G a S − 1 arêtes et pas de cycles.
— G a S − 1 arêtes et est connecté.
— G est connecté, mais la suppression de n’importe quelle arête le déconnecte.
— Exactement un simple chemin connecte n’importe quelle paire de som-
mets de G.
La densité d’un graphe est la proportion de paires de sommets connectés
par des arêtes. Un graphe peu dense a peu d’arêtes connectant les sommets.
Un graphe dense a peu d’arêtes manquants. Généralement on dit d’un graphe
qu’il est peu dense si le nombres de ses arêtes A vaut son nombre de sommets
multiplié par un facteur constant petit. Dans le cas contraire, le graphe est dit
dense. La figure 9.3 illustre un graphe dense et un graphe peu dense.
Un graphe bipartite est un graphe dont les sommets peuvent êtres répartis
en deux ensembles tels que chaque arête connecte un sommet d’un des ensembles
vers un sommet de l’autre ensemble.

9.3 Graphes orienté


Dans un graphe orienté, les arêtes ont une direction. La paire de sommets qui
définit une arête est une paire ordonnée. Plusieurs applications (par exemple les
graphes qui représentent le web ou les appels téléphoniques) sont naturellement
exprimés en graphes orientés. Nous devons adapter les défintions des arêtes,
chemins, dégré d’un sommet et autres que nous avons donné pour les graphes
non orientés au cas des graphes orientés. Un graph orienté (dirgraph) est un
9.4. REPRÉSENTATION DES GRAPHES 189

Figure 9.4 – Un graphe non orienté avec ses représentation en liste d’adjacence
et matrice d’adjacence.

ensemble de sommets et une collection d’arêtes unidirectionnelles. Chaque arête


unidirectionnelle connecte une paire ordonnée de sommets. Le dégré sortant d’un
sommet est le nombre d’arêtes sortant de ce sommet. Le dégré entrant d’un
sommet est le nombre d’arêtes entrant dans ce sommet. Le premier sommet
d’une arête dans un graphe orienté est la tête et le deuxième sommet est la
queue. On utilise la notation u −→ w pour l’arête qui pointe de v à w dans un
graphe orienté.
Un chemin orienté dans un graphe orienté est une séquence de sommets
dans laquelle il a une arête orientée pointant de chacun des sommets vers son
successeur dans la séquence. Un cycle orienté est un chemin orienté avec au
moins une arête dont le premier et le dernier sommet coincident. Un cycle
simple est un cycle sans sommet ou arête qui qui se répète (excepté le pre-
mier et le dernier sommet). La longueur d’un chemin ou d’un cycle est son
nombre d’arêtes. Comme pour les graphes non orientés, nous travaillerons avec
des graphes simples sauf indication explicite du contraire. Nous disons qu’un
sommet w peut être atteint partant du sommet v s’il existe un chemin orienté
partant de v vers w. Nous convennons du fait que chaque sommet peut être
atteint au départ de lui-même.

9.4 Représentation des graphes


Il existe deux façons classiques de représenté un graphe G = (S, A) :
— Par un ensemble de listes d’adjacences.
— Par une matrice d’adjacence.

9.4.1 Représentation par listes d’adjacences


La représentation par liste d’adjacences d’un graphe G = (S, A) consiste
en un tableau Adj de |S| listes, une pour chaque sommet de S. Pour chaque
sommet u ∈ S, la liste d’adjacences Adj[u] est une liste de sommets v tels que
(u, v) ∈ S. Autrement dit Adj[u] est constitué de tous les sommets adjacents de
190 CHAPITRE 9. LES GRAPHES

Figure 9.5 – Un graphe orienté avec ses représentation en liste d’adjacence et


matrice d’adjacence.

u dans G. Cette représentation est préférée à celle par matrice d’adjacence, car
elle fournit un moyen compact de représenter les graphes peu denses (ceux pour
lesquels |A| est largement inférieur à |S|. La figure 9.4 montre un graphe non
orienté représenté par liste d’adjacence, puis par matrice d’adjacences.

9.4.2 Repésentation par matrice d’adjacences


La représentation par matrice d’adajacence d’un graphe G = (S, A) consiste
en une matrice |S| × |S|, M = (aij ) telle que :


1 si (i, j) ∈ A
aij =
0 si non

9.5 Arbre couvrant de poids minimal


Lors de la connection des circuits électroniques, on a souvent besoin de relier
entre elles des broches de plusieurs composants. Pour interconnecter un ensemble
de n broches, on peut utiliser un arrangement de n − 1 branchements chacun
reliant deux broches. Parmi tous les arrangements possibles, celui qui utilise
une longueur de branchement minimale est souvent le plus souhaitable (dans
la mesure ou il signifie, quantité de câble minimale et par conséquent coût
minimum).
On peut modéliser ce problème de cablage à l’aide d’un graphe non orienté
connexe G = (S, A), où S est l’ensemble de broches (il comprend N éléments)
et A l’ensemble des interconnexions possible (il comprend M éléments) entre
paires de broches, et où pour chaque paire (u, v) ∈ A est caractérisée par un
poids ω(u, v) qui spécifie le coût (longueur de cable et de travaux de génie civil)
pour connecter u et v. On souhaite alors trouver un sous-ensemble acyclique
9.5. ARBRE COUVRANT DE POIDS MINIMAL 191

T ⊆ A qui connecte tous les sommets et dont le poids total


X
ω(u, v) (9.1)
(u,v)⊆T

soit minimal. Puisque T est acyclique et connecte tous les sommets, il doit
former un arbre, que l’on appelle arbre couvrant car il couvre le graphe G. Si
en plus son poids est minimal, alors il est un arbre couvrant de poids minimal.

9.5.1 Construction d’un arbre couvrant de poids minimal


Nous partons d’un graphe connexe, orienté et simple. Soit N le nombre
des sommets du graphe et M le nombre de ses arêtes. Rappelons à toutes fins
utiles quelques définitions. Un chemin est une succession d’arêtes partants d’un
sommet u vers un sommet v sans cycle. Un graphe est dit connexe lorsqu’il existe
un chemin entre toute paire d’arêtes dans le graphe. Un cycle est un chemin qui
part d’un sommet et revient au même sommet. Les arbres sont en fait une famille
particulière des graphes. Un arbre est un graphe connexe de N − 1 arêtes (N
étant le nombre de sommets du graphe). Un arbre est un graphe connexe tel
que si on lui enlêve une arête, on le déconnecte. Un arbre est un graphe connexe
tel que si on lui ajoute une arête, on introduit un et un seul cycle.
′ ′
Soit un graphe connexe G(S, A), G (S, A ) est un graphe partiel de G si et

seulement si A ⊆ A. T est un arbre couvrant de G(S, A) si et seulement si
T est un graphe partiel de G. Un graphe pondéré est un graphe G(S, A, ω) ou
une constante ω est associée au poids de chaque arête. Le poids d’un graphe est
égal à la somme des poids de ses arêtes. L’arbre couvrant de poids minimum
est tout graphe partiel de G dont le poids est minimum. Pour rappel, un graphe
n’admet un arbre couvrant que si et seulment si il est connexe. L’arbre couvrant
de poids minimum n’est pas unique lorsque le poids des arêtes ne sont pas
distincts deux à deux. Plusieurs algorithmes existent pour obtenir, au départ
d’un graphe connexe, un arbre couvrant de poids minimal. Nous examinerons
ici les algorithmes de Torjan, Kruskal, et Prim.

9.5.2 Algorithme de Torjan


L’algorithme de Torjan est basé sur le coloriage des arêtes. Ce coloriage se
fait selon deux règles : la règle bleu et la règle rouge. L’arbre couvrant de poids
minimum s’obtient par application autant de fois que nécessaire de l’une ou des
deux règles dans un ordre quelconque.

Règle bleu
La règle bleu est basée sur la notion de coupe. Une coupe est un ensemble
d’arêtes qui relie deux groupes de sommets du graphe. Une coupe disponible est
une coupe dont aucune arête n’est colorié en bleu. La règle bleu stipule : prendre
une coupe disponnible et colorié en bleu l’arête de poids minimum. Cette règle
192 CHAPITRE 9. LES GRAPHES

exprime le fait que l’arête de poids minimum d’une coupe fera partie de l’arbre
couvrant de poids minimum.

Règle rouge
La règle rouge est basée sur la notion de cycle disponible. Un cycle dispo-
nible est un cycle dans lequel aucune arête n’est coloriée en rouge. La règle
rouge stipule : prendre un cycle disponible et colorié en rouge l’arête de poids
maximum. Cette règle exprime en fait la propriété selon laquelle l’arête de poids
maximum d’un cycle disponnible ne fera jamais partie d’un arbre couvrant de
poids minimum.

9.5.3 Algorithme de Kruskal


L’algorithme de Kruskal est un des algorithmes les plus utilisés pour la
construction d’arbre couvrant de poids minimum. Pour construire un arbre cou-
vrant de poids minimum en utilisant l’algorithme de Kruskal nous devons avoir
à notre disposition un graphe connexe non orienté et pondéré. Cela étant, on
procède de la manière suivante (voir figure 9.6) :
— ranger les arêtes selon l’ordre croissant de leur poids ;
— incorporer dans l’arbre qui est au départ vide l’arête avec le poids mini-
mum ;
— le prochain arête à incorporer dans l’arbre est celui ayant le plus petit
poids parmi ceux restants ;
— si l’incorporation d’une arête donne lieu à la formation d’un cycle, alors
cette arête est rejetée ;
— on arrête le processus lorsque l’on a dans l’arbre en construction N − 1
arêtes, N étant le nombre de sommets du graphe.
Le poids de l’arbre ainsi obtenu est la somme des poids de ses arêtes.

9.5.4 Algorithme de Prim


Pour appliquer l’algorithme de Prim, nous devons de même que pour l’algo-
rithme de Kruskal, avoir à notre disposition un graphe connexe non orienté et
pondéré. Cela étant, on procède de la manière suivante (voir figure 9.7) :
— choisir un sommet au hasard dans le graphe ;
— identifier toutes les arêtes incidentes à ce sommet et marquer l’arête de
poids minimum ;
— considérer les deux sommets reliés par cette arête comme un groupe ;
— identifier toutes les arêtes incidentes à ce groupe de sommets et marquer
parmi elle l’arête de poids minimum ;
— l’arête dont le marquage créer un cycle est rejetée ;
— on arrête le processus lorsque l’on a dans l’arbre en construction N − 1
arêtes, N étant le nombre de sommets du graphe.
Le poids de l’arbre ainsi obtenu est la somme des poids de ses arêtes.
9.5. ARBRE COUVRANT DE POIDS MINIMAL 193

Figure 9.6 – Exemple de construction d’un arbre couvrant de poids minimum


par l’algorithme de Kruskall.
194 CHAPITRE 9. LES GRAPHES

Figure 9.7 – Exemple de construction d’un arbre couvrant de poids minimum


par l’algorithme de Prim.
9.6. PLUS COURT CHEMIN ENTRE DEUX SOMMETS 195

9.6 Plus court chemin entre deux sommets


Un automobiliste souhaite trouver le plus court chemin possible entre Ban-
dalungua et Ngaba. Etant donné une carte routière de Kinshasa renseignant les
distances entre différents points de la ville (les points ici sont les croisements
des routes), comment peut-il déterminer l’itinéraire le plus court ? Une possi-
bilité consiste à énumérer tous les itinéraires entre Bandalungua et Ngaba et à
calculer la longueur de chacun afin de choisir le plus court. Toutefois, on se rend
vite compte que même en n’autorisant pas les routes qui contiennent des cycles,
il existe une foule de possibilités, dont la plupart ne valent même pas la peine
d’être considérées. Par exemple l’itinéraire allant de Bandalungua à Ngaba en
passant par Nsele est manifestement une mauvaise option.
Dans un problème de plus court chemin, on a en entrée un graphe orienté
pondéré G(S, A, ω) , ω étant une fonction qui fait correspondre à chaque arête
un poids à valeur réelle. Le poids ω(p) du chemin p = (u0 , u1 , u2 ...., uk ) est la
somme des poids des arêtes qui le consitue :
k
X
ω(p) = (ui−1 , ui ) (9.2)
i=1

On définit le poids du plus court chemin δ(u0 , uk ) entre les sommets u0 et uk


par :
min{ω(p) : u0 −→ uk } s′ il existe un chemin entre u0 et uk

δu0 ,uk =
∞ sinon
Le plus court chemin d’un sommet u0 à un sommet uk est alors défini comme
étant un chemin quelconque p de poids ω(p) = δ(u0 , uk ). Dans l’exemple de la
recherche du plus court chemin de Bandalungua à Ngaba, on peut modéliser
la carte routière par un graphe dont les sommets sont des intersections des
portions des routes et les poids des distances. L’objectif est de trouver le plus
court chemin entre une intersection donnée à Bandalungua et une intersection
donnée à Ngaba. Les poids des arêtes peuvent mesurer autre chose que les
distances, par exemple une durée, un coût, ou toute autre quantité.

9.6.1 Variantes du problème des plus courts chemins


Bien souvent on se restreint au problème de la recherche du plus court chemin
à origine unique : étant donné un graphe G(S, A), on souhaite trouver un plus
court chemin depuis un sommet origine donné u ∈ S vers tout autre sommet
v ∈ S. Plusieurs autres problèmes peuvent être résolus par l’algorithme à origine
unique, notamment les variantes des sous-sections suivantes.

Problème du plus court chemin à destination unique


Ici, il s’agit de trouver un plus court chemin vers un sommet destination
donné u depuis tout autre sommet v ∈ S. En inversant le sens de chaque arête
du graphe, on peut ramener ce problème à un problème à origine unique.
196 CHAPITRE 9. LES GRAPHES

Problème du plus court chemin pour un couple de sommets donné


Ici, il s’agit de trouver le plus court chemin entre chaque couple de sommets
u et v. Résoudre le problème pour chaque sommet u du graphe pris comme
sommet origine revient à résoudre ce problème. Par ailleurs on ne connait aucun
algorithme dont la durée d’exécution est assymptotiquement meilleure dans le
cas le plus défavorable, que les meilleurs algorithmes à origine unique.

Problème du plus court chemin pour tout couple de sommets


Ici, il s’agit de trouver le plus court chemin de u à v pour tout couple de
sommets u et v. Ce problème peut être résolu via un algorithme à origine unique
depuis chaque sommet, mais on peut généralement le résoudre plus rapidement.
Chapitre 10

Conclusions et Perspectives

Nous n’avons pas abordé toutes les structures de données possibles, la chose
étant tout simplement impossible dans le cadre de ce cours. Nous nous sommes
contentés de présenter quelques unes en l’occurence les plus populaires d’entre
elles. Nous nous efforcerons d’inclure dans ces notes d’autres structures des
données telles que les dictionnaires, les tables de hâchage. Toutefois, une compré-
hension approfondie de celles qui sont présentées permet déjà de résoudre bon
nombre de probèmes avec élégance et efficacité. Nous sollicitons l’indulgence du
lecteur dans la mesure où ces notes de cours sont encore incomplètes et n’ont pas
atteint le niveau de finition souhaité. Les illustrations sont encore peu abouties
et quelques fautes d’orthographe subsistent. Nous avons toutefois livré ces notes
aux étudiants pour leur assurer un support clair dans la mesure ou le cours est
difficile à suivre sans support. Nous pensons délivrer une version plus aboutie
de ces notes de cours dès que possible.

197

Vous aimerez peut-être aussi