143
STRUCTURES DE DONNEES ET ALGORITHMES M. Benjelloun, G. Libert 1ère bachelier en sciences de l'ingénieur (Faculté Polytechnique - UMONS) Edition: 2014-2015

STRUCTURES DE DONNEES ET ALGORITHMES · 3.8 LA STRUCTURE DE SUITE 51 CHAPITRE 4 : STRUCTURES DE DONNÉES DYNAMIQUES 53 ... qui fait les opérations de base; 2) la mémoire qui contient

  • Upload
    doanh

  • View
    227

  • Download
    0

Embed Size (px)

Citation preview

STRUCTURES DE DONNEES ET

ALGORITHMES

M. Benjelloun, G. Libert

1ère bachelier en sciences de l'ingénieur (Faculté Polytechnique - UMONS)

Edition: 2014-2015

PREFACE

L'enseignement de Structures de données et algorithmes, destiné aux étudiants de la première année de

bachelier en Ingénieur civil de la Faculté Polytechnique de l'Université de Mons (UMONS) est organisé,

en trois parties:

1. Le cours magistral qui comprend l'étude des principaux concepts de base de l'informatique logicielle,

des composants matériels de l'ordinateur et de l’exécution des algorithmes, sujets traités dans ce syllabus;

2. Les séances d'exercices qui sont consacrées à l'étude de la syntaxe d'un langage de programmation et

à l'écriture de programmes illustratifs, un syllabus est dédicacé à cela,

3. Les séances de travaux pratiques qui permettent de mettre en œuvre sur ordinateur des programmes

écrits dans un langage de programmation.

Les objectifs de cet enseignement sont donc:

1. La connaissance des principes de base pour développer des algorithmes informatiques

2. La maîtrise d'un langage de programmation et d'un poste de travail informatique.

M. Benjelloun et G. Libert

14 octobre 2014

Je propose de considérer la question: "Les

machines peuvent-elles penser ?"

Alan TURING (1912-1954)

"Computing Machinery and Intelligence",

Mind, vol. LXI, n° 236 (1950)

TABLE DES MATIERES

CHAPITRE 1 : INTRODUCTION 1 1.1 ORDINATEURS ET ALGORITHMES 1

1.2 PROGRAMMES ET LANGAGES DE PROGRAMMATION 2

1.3 LA HIERARCHIE LOGICIEL – MATERIEL 3

1.4 L'IMPORTANCE DES ALGORITHMES 4

CHAPITRE 2 : LA CONCEPTION DES ALGORITHMES 7 2.1 ALGORITHMES, PROGRAMMES, LANGAGES DE PROGRAMMATION 7

2.2 SYNTAXE ET SEMANTIQUE 8

2.3 AFFINEMENT PROGRESSIF DES ALGORITHMES 11

2.4 SEQUENCE 13

2.5 SELECTION 14

2.6 ITERATION 16

2.7 SEQUENCE, SELECTION ET ITERATION ENSEMBLE 24

2.8 MODULARITE 27

2.9 RECURSION 33

CHAPITRE 3 : STRUCTURES DE DONNEES 39 3.1 INTRODUCTION 39

3.2 LE CONCEPT DE TYPE DE DONNÉES 41

3.3 LES TYPES DE DONNÉES PRIMITIFS 43

3.4 TYPES PRIMITIFS STANDARD 44

3.5 TYPES INTERVALLE 45

3.6 LA STRUCTURE DE TABLEAU 46

3.7 LA STRUCTURE DE RECORD 48

3.8 LA STRUCTURE DE SUITE 51

CHAPITRE 4 : STRUCTURES DE DONNÉES DYNAMIQUES 53 4.1 TYPES DE DONNEES RECURSIFS 53

4.2 POINTEURS 55

4.3 LISTES LINEAIRES 57

CHAPITRE 5 : STRUCTURES DE DONNEES ELABOREES 62 5.1 TYPE ABSTRAIT ET STRUCTURE DE DONNEE 62

5.2 STRUCTURES LINEAIRES 63

5.3 ARBRES 72

CHAPITRE 6 : RECHERCHE ET TRI 84 6.1 INTRODUCTION 84

6.2 RECHERCHE DICHOTOMIQUE DANS UN TABLEAU TRIE 84

6.3 COMPLEXITE D‘UN ALGORITHME DE TRI 85

6.4 TRI SELECTION 86

6.5 TRI INSERTION 87

6.6 TRI RAPIDE 87

6.7 ARBRE BINAIRE DE RECHERCHE 91

6.8 STRUCTURE DE TAS ET TRI TAS 92

CHAPITRE 7 : TIRER PARTI DE LA REPETITION 97 7.1 LE DRAPEAU TRICOLORE DE DIJKSTRA 97

7.2 PROBLEME DU PLUS GRAND CARRE DE 1 100

CHAPITRE 8 : L'ARCHITECTURE DES ORDINATEURS 103 8.1 STRUCTURE DES ORDINATEURS 103

8.2 PHYSIQUE ET ELECTRONIQUE 104

8.3 LES COMPOSANTS 105

8.4 COMMUNIQUER AVEC LE MONDE EXTERIEUR 109

CHAPITRE 9 : L'EXECUTION DES ALGORITHMES 116 9.1 INTRODUCTION 116

9.2 TRADUCTEURS DE LANGAGE DE PROGRAMMATION 118

9.3 LES SYSTEMES D'EXPLOITATION 120

1

CHAPITRE 1 : INTRODUCTION

1.1 ORDINATEURS ET ALGORITHMES

Un ordinateur est une machine qui peut effectuer des tâches intellectuelles de routine en exécutant très

rapidement des opérations simples. La simplicité des opérations (l'addition ou la comparaison de deux

nombres en sont des exemples typiques) est compensée par la vitesse à laquelle elles sont exécutées. Le

résultat est que ces très nombreuses opérations permettent d'exécuter des travaux conséquents.

Naturellement un ordinateur ne peut accomplir que les tâches spécifiées dans les termes des opérations

simples qu'il est capable de réaliser. On doit indiquer à l'ordinateur quelles opérations il doit accomplir,

en d'autres termes, on doit lui décrire comment la tâche doit être menée à bien. Cette description est appelée

un algorithme. Un algorithme décrit une succession d'opérations qui, si elles sont fidèlement exécutées,

produiront le résultat désiré.

La notion d'algorithme n'est pas spécifique à l'informatique. Certains algorithmes décrivent toutes sortes

de tâches quotidiennes. Une recette de cuisine est un algorithme de même qu'une partition musicale. En

règle générale, la force agissante qui exécute une tâche est appelée un processeur. Un processeur peut être

une personne, un ordinateur ou tout autre dispositif électronique ou mécanique. Un processeur obéit aux

actions (les exécute) que l'algorithme lui décrit. L'exécution d'un algorithme implique l'exécution de

chacune de ses étapes constitutives.

Un ordinateur est donc simplement un processeur particulier. Naturellement c'est un processeur très

particulier, sinon il n'aurait pas eu cet impact important et rapide sur tant d'aspects de notre vie.

Un ordinateur actuel comporte trois composants essentiels qui sont:

1) l'unité centrale de traitement (UCT), qui fait les opérations de base;

2) la mémoire qui contient

a) l'algorithme spécifiant les opérations à exécuter;

b) l'information, ou les données, à partir desquelles les opérations agissent;

3) les dispositifs d'entrée et de sortie (dispositifs E/S) grâce auxquels l'algorithme et les données entrent

en mémoire et l'ordinateur communique les résultats.

Ces composants forment la partie matérielle de l'ordinateur. Ce sont les unités physiques à partir

desquelles les ordinateurs sont construits.

2

1.2 PROGRAMMES ET LANGAGES DE PROGRAMMATION

Un processeur effectue un traitement selon un algorithme approprié. Par exemple, le cuisinier doit suivre

une recette, le pianiste une partition et ainsi de suite. Dans chaque cas l'algorithme doit être énoncé d'une

manière telle que le processeur puisse le comprendre et en exécuter les instructions. On dit que le

processeur doit être capable d'interpréter l'algorithme, ce qui signifie qu'il doit être capable de

comprendre ce que chaque étape signifie et

faire l'opération correspondante.

Lorsque le processeur est un ordinateur, l'algorithme doit être exprimé sous la forme d'un programme. Un

programme est écrit dans un langage de programmation, et l'activité qui consiste à écrire un algorithme

sous la forme d'un programme est appelée la programmation. Chaque étape de l'algorithme est exprimée

par une instruction du programme. Un programme consiste donc en une séquence d'instructions, chacune

d'entre elles spécifiant les opérations que l'ordinateur doit faire.

La nature des instructions d'un programme dépend du langage de programmation utilisé. Il existe de

nombreux langages de programmation et chaque langage comporte son propre répertoire d'instructions.

Les langages les plus élémentaires, appelés les langages machine, sont conçus de manière que chaque

instruction puisse être directement interprétée par l'ordinateur: cela veut dire que l'unité centrale est

capable de comprendre chaque instruction et de faire l'opération correspondante. Cependant, puisque les

instructions sont si élémentaires (par exemple additionner deux nombres), chacune d'elle exprime

uniquement une infime partie d'un algorithme. Il faudra donc un grand nombre d'instructions pour

exprimer la plupart des algorithmes, et de ce fait la programmation en langage machine est fastidieuse.

Pour rendre la programmation plus facile, d'autres types de langages ont été développés. Ces langages

évolués sont plus pratiques que les langages machine en ceci que chaque instruction peut exprimer une

étape plus importante d'un algorithme. Naturellement, chaque énoncé devra être réinterprété par

l'ordinateur et il faudra que les capacités de l'unité centrale soient augmentées en conséquence. C'est

certainement l'augmentation des capacités de l'unité centrale qui rend possible l'interprétation des

programmes évolués, mais, en fait, il existe une solution plus économique et plus souple.

C'est la traduction des programmes évolués en langage machine avant leur exécution. La traduction

consiste à transformer chaque instruction du programme évolué en une séquence équivalente

d'instructions en langage machine. Ces instructions en langage machine peuvent alors être interprétées par

l'unité centrale. La procédure complète est illustrée par la figure 1.1.

3

programmation

traduction

interprétation par l'UCT

Le traitement souhaité est exécuté

Figure 1.1: Etapes d'exécution d'un algorithme par un ordinateur

La traduction d'un langage évolué en un langage machine est une tâche qui peut être exécutée par

l'ordinateur. Le programme qui exécute cette tâche est un traducteur de langage. Les constructeurs

d'ordinateurs fournissent des traducteurs pour la plupart des langages de programmation les plus usuels.

Les langages de programmation actuels vont des langages évolués (comme C, C++, PASCAL, Java, …)

via des niveaux intermédiaires tels que le Fortran et le Basic, jusqu'aux niveaux les plus élémentaires que

constituent les langages machine. On peut dire que les langages évolués rendent la tâche de

programmation plus facile aux dépens de l'interprétation et de la traduction. Puisque la première opération

est accomplie par l'homme et que les autres le sont par la machine, il n'y a aucune hésitation sur le choix.

1.3 LA HIERARCHIE LOGICIEL - MATERIEL

Un ordinateur exécute des programmes, lesquels sont uniquement l'expression d'algorithmes permettant

de mener à terme des traitements. On désigne souvent les programmes sous le terme générique de logiciel,

ce qui permet de les distinguer du matériel (ou équipement physique) à partir duquel est construit

l'ordinateur. Certains logiciels sont écrits par les utilisateurs eux-mêmes, mais en fait la plupart de ces

derniers ne programment pas du tout. Ils se servent de l'ordinateur en tant qu'outil pour des opérations

Algorithme

Programme en langage évolué

Programme en langage machine

4

dont les algorithmes sont bien connus et les programmes déjà écrits. Par exemple, un comptable utilisera

un ordinateur pour la mise à jour des livres, un ingénieur l'utilisera pour la conception d'un pont, un

médecin pour surveiller l'état d'un patient et un notaire pour préparer des actes juridiques.

Un ensemble de programmes écrits pour une application particulière est un ensemble de logiciels

d'application (ou "package" d'application). Il existe des ensembles logiciels pour tous les domaines

d'application que nous venons de mentionner et pour beaucoup d'autres encore. L'utilisateur d'un logiciel

d'application se le procure généralement chez le constructeur de son matériel ou dans des entreprises

spécialisées dans la vente de logiciels. L'utilisateur peut être amené à programmer, selon les circonstances,

lorsqu'il faut modifier le logiciel.

Ainsi, l'utilisateur veut souvent faire quelque chose que le logiciel ne lui permet pas de faire. Il doit alors

écrire ses propres programmes ou faire appel à quelqu'un pour les lui écrire. Ces programmes pourront

ensuite être vendus comme logiciels à d'autres utilisateurs dont les besoins sont similaires.

Les ensembles logiciels d'application (packages) et les programmes écrits par les utilisateurs sont des

logiciels d'application. Les logiciels d'application sont généralement écrits en langages évolués et

demandent de ce fait des traducteurs de langage appropriés. Les traducteurs ne sont pas les seuls

programmes sur lesquels s'appuient les logiciels d'application. Il y a de nombreux autres programmes qui

rendent d'importants services aux logiciels d'application; ces programmes, dont les traducteurs de langage,

sont appelés logiciels de base. Un des éléments notables des logiciels de base est le logiciel système qui

comprend surtout le système d'exploitation dont l'un des services comporte normalement la gestion du

dispositif des entrées et des sorties d'un ordinateur, le stockage de l'information sur une très grande période

et la possibilité d'accès à l'ordinateur par plusieurs utilisateurs de manière simultanée.

Il ressort de cette discussion qu'un système informatique peut être considéré comme une hiérarchie de

composants logiciels et matériels. Au niveau le plus élevé, on trouve le logiciel d'application qui prend

appui sur le logiciel système. A son tour le logiciel système s'appuie sur l'unité centrale de l'ordinateur et

sur les dispositifs E/S. En fait, la hiérarchisation ne s'arrête pas là puisque les composants matériels eux-

mêmes sont construits à partir de composants plus simples.

1.4 L'IMPORTANCE DES ALGORITHMES

Nous avons vu qu'afin de mener à bien un traitement sur un ordinateur on doit

1) concevoir un algorithme qui décrit comment le traitement doit être fait;

5

2) exprimer l'algorithme sous la forme d'un programme en un langage de programmation adéquat;

3) faire en sorte que l'ordinateur exécute le programme.

Le rôle de l'algorithme est fondamental. Sans algorithme, il n'y aurait pas de programme, et sans

programme, il n'y aurait rien à exécuter. Les algorithmes sont fondamentaux en un autre sens: ils sont à

la fois indépendants du langage dans lequel ils sont énoncés et de l'ordinateur qui les exécute. Par exemple,

les systèmes de réservation d'une compagnie aérienne comportent un algorithme de réservation. Dans

chaque système, l'algorithme peut être écrit en un langage de programmation différent et exécuté sur un

ordinateur différent. Pourtant, dans tous les cas, l'algorithme est fondamentalement le même.

Pour revenir au domaine technique, tous les ordinateurs peuvent globalement mener à bien les mêmes

opérations de base; il faut noter des différences de détail et de rapidité, mais en règle générale les capacités

des ordinateurs actuels sont les mêmes. Bien plus, ces capacités n'ont pas été affectées par l'innovation

technologique. Cette innovation ne changera pas ce que les ordinateurs font, même si elle affecte la

vitesse, le coût et la fiabilité. Cela signifie que les algorithmes peuvent être conçus et étudiés

indépendamment de la technologie du jour. Les résultats resteront valables malgré l'arrivée de nouveaux

ordinateurs et de nouveaux langages de programmation.

Pour les spécialistes de la science informatique, les algorithmes sont plus essentiels que les langages de

programmation et les ordinateurs. Un langage n'est qu'un moyen pratique d'énoncer un algorithme, et un

ordinateur est un simple processeur qui l'exécute. Le langage de programmation et l'ordinateur ne sont

que des moyens, le but étant l'exécution de l'algorithme et le déroulement du traitement correspondant.

Puisque les algorithmes sont essentiels en informatique, quels sont les aspects et les propriétés que les

chercheurs étudient ? Un des aspects les plus importants est la conception des algorithmes. Face à la

possibilité de nouvelles applications informatiques, comment peut-on concevoir des algorithmes pour

mener à bien les traitements concernés ? La conception d'un algorithme est une activité intellectuelle

difficile, beaucoup plus difficile que de transformer l'algorithme en programme. Les chercheurs essaient

de venir à bout de cette difficulté par l'élaboration de schémas directeurs dans lesquels le processus de

conception peut s'inscrire et se développer. Cependant, la conception de la plupart des algorithmes requiert

créativité et intuition, et pour cela on ne peut pas formuler de règles générales. En d'autres termes, il

n'existe aucun algorithme pour concevoir des algorithmes.

Cette observation soulève un certain nombre de questions intéressantes. Y a-t-il d'autres problèmes pour

lesquels il n'existe pas d'algorithme? De tels processus intellectuels correspondent-ils à ce que nous

6

appelons créativité? Etant donné un processus, comment peut-on déterminer s'il y a ou non un algorithme

qui le décrit ? La dernière question est importante, car si un processus ne peut être décrit par un algorithme,

il n'a aucune chance d'être exécuté par un ordinateur, quels que soient son importance, ses performances

et son coût. Ces questions sont globalement classées sous le titre calculabilité, c'est-à-dire l'étude de ce

qui est informatisable et de ce qui ne l'est pas.

Etant admis qu'un problème peut être décrit par un algorithme, il est pertinent de se demander combien

de ressources informatiques seront nécessaires à son exécution. En particulier, combien il faudra de temps

et de mémoire. S'il existe plusieurs algorithmes décrivant le même problème, lequel d'entre eux est le plus

"performant" au niveau de la consommation minimale de ressources informatiques? Quels sont les

moyens minimaux nécessaires pour résoudre un problème donné et quels moyens sont utilisés par

l'algorithme le plus performant possible? Existe-t-il des problèmes pour lesquels le meilleur algorithme

nécessite tant de ressources informatiques que son exécution est irréalisable ? Toutes ces questions font

référence à la notion de complexité de l'algorithme.

Un autre sujet d'intérêt pour les chercheurs est celui de l'exactitude des algorithmes. Puisque la conception

des algorithmes est si difficile, comment peut-on être certain de leur exactitude? Comment peut-on savoir

si un algorithme particulier correspond exactement au problème? Le manque de certitude a conduit à des

erreurs spectaculaires. Au fur et à mesure que le nombre des applications informatiques augmente, on

rencontre un plus grand nombre de cas dans lesquels un algorithme incorrect serait désastreux.

En résumé, la notion d'algorithme est fondamentale à l'informatique. C'est le concept unificateur de toutes

les activités dans lesquelles s'engagent les chercheurs. Nous consacrerons donc l'essentiel de cet

enseignement à la conception et l'étude des algorithmes. Les séances d'exercices seront dédiées à l'étude

d'un langage de programmation et les travaux pratiques seront utilisés pour écrire les programmes

d'algorithmes construits durant les enseignements.

7

CHAPITRE 2 : LA CONCEPTION DES ALGORITHMES

Au chapitre 1, nous avons fait porter la discussion sur la centralité de la notion d'algorithme en

informatique. Dans ce chapitre, nous allons examiner de plus près ce que sont les algorithmes en

considérant des problèmes tels que leur structure, la manière dont ils peuvent être imaginés et comment

ils peuvent être exprimés sous une forme adaptée à leur exécution.

2.1 ALGORITHMES, PROGRAMMES, LANGAGES DE PROGRAMMATION

Nous avons défini un algorithme comme la description de l'exécution d'une tâche, ou processus. Il existe

des algorithmes pour exécuter toutes sortes de procédures utiles. Dans de nombreux cas, il y a interaction

entre le processus et son environnement: il en reçoit des entrées et lui renvoie des sorties. Les processus

exécutés par ordinateur nécessitent souvent des entrées sous la forme d'information et produisent

généralement des sorties sous la forme d'information complémentaire. (Pour cette raison, on appelle

certains aspects de l'informatique "traitement de l'information".) Prenons comme exemple le traitement

informatisé du salaire mensuel qui nécessite l'entrée d'informations concernant le taux du salaire ainsi que

le nombre d'heures effectuées, pour produire en sortie des informations portant sur le salaire dû ainsi que

sur la déduction des taxes. Il faut noter que les entrées et les sorties font partie de la spécification du

processus, et font donc nécessairement partie de l'algorithme décrivant ce dernier bien qu'elles demeurent

indépendantes du processeur qui l'exécute. Dans cet exemple, le salaire de base est une information

nécessaire pour le processeur, que ce soit un ordinateur ou un employé, même si on peut l'exprimer sous

des formes différentes selon les cas, comme nous le verrons par la suite.

Comme nous l'avons mentionné au chapitre 1, un algorithme ne peut être exécuté que s'il est exprimé sous

la forme que le processeur concerné peut comprendre. Puisque l'anglais ou le français est trop complexe

pour être compris par les ordinateurs, les algorithmes exécutables doivent être écrits plus simplement.

Nous avons mentionné qu'un algorithme exprimé sous une telle forme portait le nom de programme et

qu'un programme était écrit dans un langage de programmation.

Par analogie avec les langages naturels, chaque langage de programmation a son propre vocabulaire et ses

propres règles grammaticales, qui dictent les conditions dans lesquelles le vocabulaire peut être utilisé.

Dans la plupart des cas, le vocabulaire consiste en certains symboles mathématiques, en quelques mots

anglais et en des règles grammaticales assez simples pour permettre à l'ordinateur d'interpréter des

programmes. En raison de ces différences inhérentes au vocabulaire et à la grammaire, les langages de

programmation diffèrent naturellement dans les formes d'expressions autorisées.

8

Les concepteurs de langages de programmation essaient de concilier entre eux plusieurs objectifs dont les

plus importants sont les suivants:

1) Le langage doit permettre l'expression concise et facile d'algorithmes dans le domaine d'application

pour lequel il a été conçu.

2) Le langage doit être compréhensible par l'ordinateur.

3) Les programmes écrits dans le langage doivent être rapidement compris par les utilisateurs, de telle

sorte qu'ils puissent être modifiés et corrigés là où c'est nécessaire.

4) Les programmes doivent être conçus pour réduire les risques potentiels d'erreurs lors de la

transformation d'un algorithme en programme.

5) L'examen d'un programme permet de constater que son exécution correspondra véritablement à la

procédure désirée.

Ces objectifs ne sont pas tous compatibles entre eux. La concision ou la justesse d'une expression peut

nuire à une compréhension facile (par exemple l'utilisation d'acronymes), de même qu'un langage

clairement conçu pour les gens peut ne pas être compris par un ordinateur.

Dans ce syllabus, nous aurons besoin d'exprimer de nombreux algorithmes de différentes sortes.

Heureusement, nous bénéficierons de l'avantage considérable que les algorithmes présentés n'auront pas

à être exécutés sur ordinateur mais seulement "compris" par l'utilisateur. C'est pourquoi nous nous

satisferons d'un sous-ensemble du langage PASCAL qui nous permettra d'exprimer clairement les étapes

de l'algorithme tout en nous familiarisant à des notations classiques des langages de programmation.

2.2 SYNTAXE ET SEMANTIQUE

Au chapitre 1, nous avons dit qu'un processeur devrait être capable d'interpréter un algorithme pour

exécuter le processus décrit. Ce qui signifie que le processeur doit être en mesure de

1) comprendre la forme sous laquelle l'algorithme est exprimé (par exemple, un modèle de tricot ou une

partition musicale)

2) exécuter les opérations correspondantes.

Dans cette section nous examinerons plus attentivement la première de ces étapes.

Comprendre l'écriture même de l'algorithme se décompose en deux étapes. En premier lieu, le processeur

doit être capable de reconnaître et de comprendre les symboles par lesquels est exprimé l'algorithme, que

ce soient des mots français ou anglais, des abréviations, des symboles mathématiques ou des notes dans

9

une partition musicale. Pour être en mesure de faire cela le processeur doit connaître le vocabulaire et la

grammaire du langage dans lequel l'algorithme est écrit.

L'ensemble des règles grammaticales qui régissent la manière dont les symboles dans un langage doivent

être légitimement utilisés est appelé la syntaxe du langage. Ainsi la syntaxe du français régit l'utilisation

du mot "syllabus", celle des mathématiques l'utilisation du symbole "=" et celle de la musique l'utilisation

du symbole "#". Un programme qui respecte la syntaxe du langage dans lequel il est écrit est dit

syntaxiquement correct. Tout écart de syntaxe dans le langage est appelé une erreur de syntaxe. Une

syntaxe correcte est généralement un préalable nécessaire à l'interprétation d'un programme d'ordinateur

et donc à son exécution.

La deuxième étape de la compréhension de l'écriture d'un algorithme consiste à donner une signification

à chaque énoncé en termes d'opérations que le processeur doit effectuer. Par exemple

maille à 1'envers

indique comment manipuler les aiguilles et la laine; et

coût := prix * quantité

signifie que deux nombres appelés prix et quantité doivent être multipliés pour donner un troisième

nombre appelé coût.

La signification de formes données d'expressions dans un langage est appelée la sémantique du langage.

Les langages de programmation sont conçus de telle sorte que leur syntaxe et leur sémantique soient

relativement simples et qu'un programme puisse être analysé du point de vue de la syntaxe sans référence

sémantique. Ceci est totalement différent des langages naturels dans lesquels, comme nous l'avons vu

dans la section précédente, la syntaxe et la sémantique sont très complexes et souvent corrélatives. Dans

les langages naturels, il est possible d'écrire des phrases à la syntaxe correcte mais pourtant dépourvues

de signification. La phrase

L'éléphant mangea la cacahuète

est une phrase qui a un sens alors que la phrase

La cacahuète mangea l'éléphant

n'en a pas, bien que les deux phrases soient correctes quant à la syntaxe. De même un énoncé dans un

algorithme peut être syntaxiquement correct mais dépourvu de signification. Par exemple

afficher le nom du 1er mois de l'année

et

afficher le nom du 13ème mois de l'année

10

sont des énoncés algorithmiques syntaxiquement corrects (écrits en langage naturel) mais dont seul le

premier a une signification.

La détection des incohérences sémantiques dépend de la connaissance des objets auxquels elles renvoient.

Elle dépend en particulier de la connaissance des attributs de ces objets et de leurs rapports entre eux.

C'est pourquoi les exemples absurdes présentés ci-dessus sont reconnus comme tels parce que le lecteur

sait faire la distinction entre les attributs d'un éléphant et d'une cacahuète, et ainsi de suite. Un processeur

ne pourra détecter des incohérences sémantiques dans l'écriture d'un algorithme que s'il a une connaissance

suffisante des objets auxquels renvoie l'algorithme. Sinon les incohérences n'apparaîtront que lors de

l'exécution de l'algorithme. Considérons par exemple un processeur devant la commande

afficher le nom du 13ème mois de l'année

Si le processeur sait qu'une année ne comporte que douze mois, il peut détecter l'incohérence sémantique

de la commande avant de l'exécuter. S'il est plus ignorant, il cherchera à l'exécuter probablement en

cherchant le nom de ce mois dans un calendrier. C'est seulement pendant l'exécution (ici la consultation

du calendrier) que le processeur découvrira qu'il n'y a pas de 13ème mois et que l'incohérence apparaîtra.

Certaines incohérences sémantiques sont plus subtiles, pouvant être le résultat d'un énoncé algorithmique

un peu hâtif. Par exemple l'algorithme

pensez à un nombre de 1 à 13

appelez ce nombre N

affichez le nom du Nième mois de l'année

contient une incohérence en puissance qui ne se matérialisera que si l'exécution de la première ligne donne

le nombre 13 comme résultat. Lorsqu'une incohérence provient d'un algorithme, il n'y a en général aucune

chance de la détecter avant son exécution.

Cette discussion peut être résumée ainsi: afin d'interpréter chaque énoncé algorithmique, un processeur

doit être en mesure de

1) donner un sens aux symboles dans lesquels l'énoncé est écrit;

2) donner une signification à l'énoncé en termes d'opérations à exécuter;

3) exécuter les opérations correspondantes.

Les erreurs de syntaxe peuvent être détectées au premier niveau et certaines erreurs sémantiques au

second. D'autres erreurs sémantiques ne seront pas détectées avant le troisième niveau. Quand le

processeur est un ordinateur, les niveaux 1 et 2 peuvent être franchis par un traducteur qui transforme

chaque étape du programme en une opération appropriée que le processeur peut conduire à son terme. Un

11

traducteur différent est nécessaire pour chaque langage de programmation. Lorsqu'il s'agit d'un traducteur

pour un langage de programmation spécifique, ce traducteur possède toutes les connaissances nécessaires

à la compréhension de la syntaxe du langage. (Cette condition est tout à fait réalisable puisque la syntaxe

est, dans sa conception même, un ensemble fini de règles bien définies.) Le traducteur peut donc détecter

toute erreur de syntaxe dans les programmes à exécuter. Cependant il ne peut détecter toutes les erreurs

sémantiques ou même plusieurs d'entre elles. Les erreurs qui subsistent apparaîtront seulement lors de

l'exécution du programme.

Outre des erreurs de syntaxe et de sémantique, il faut faire état d'une troisième catégorie d'erreurs. Il s'agit

de l'erreur logique. Un programme peut être correct du point de vue syntaxique et ne comporter aucune

incohérence sémantique mais peut tout simplement ne pas décrire d'une manière adéquate le traitement

désiré. Considérons par exemple l'algorithme suivant qui consiste à calculer la circonférence d'un cercle

calculer la circonférence en multipliant le rayon par

Cet algorithme est syntaxiquement et sémantiquement correct mais le résultat produit sera faux en raison

de l'erreur logique qui consiste en l'omission de la multiplication du rayon par 2.

Un processeur ne peut détecter les erreurs logiques puisqu'il n'a aucune idée du traitement que l'algorithme

est censé décrire. Les erreurs logiques ne peuvent être détectées que si l'on compare le résultat désiré au

résultat obtenu. Les erreurs logiques et les méthodes qui permettent de les éviter seront présentées dans la

section suivante.

2.3 AFFINEMENT PROGRESSIF DES ALGORITHMES

Nous avons vu que pour qu'un processeur puisse exécuter une procédure, il est nécessaire de lui fournir

un algorithme approprié. La conception d'un tel algorithme est généralement très difficile, au moins

lorsque la procédure à exécuter n'est pas courante. Les difficultés de conception d'un algorithme

augmentent lorsque le processeur est un ordinateur, dans la mesure où les ordinateurs manquent de

l'intuition ou du sens commun qui leur permettrait de voir quand l'algorithme ne décrit pas précisément la

procédure désirée. Très souvent, l'algorithme décrit est presque celui qu'on a eu l'intention d'écrire mais

pas totalement.

Un exemple pris dans la vie courante est celui de l'ami qui nous. indique l'itinéraire pour aller chez lui.

L'algorithme qu'il donne: "tourne à droite au magasin, va jusqu'au prochain croisement, puis prends la

troisième à gauche..." est presque parfait à quelques détails essentiels près (à quel magasin tourner, par

exemple) et risque fort d'entraîner son visiteur vers une toute autre rue que la sienne et même vers une

12

autre banlieue. Dans cet exemple, le processeur est une personne assez censée pour reconnaître l'erreur et

demander son chemin à un passant. Malheureusement les ordinateurs n'ont pas cette ressource.

Une autre erreur classique avec les algorithmes provient du fait que la procédure, qui normalement doit

être exécutée, ne l'est pas dans certaines circonstances (imprévisibles ou par oubli de la part du

concepteur). Prenons, par exemple, le simple algorithme suivant qui décrit comment calculer la durée d'un

vol à partir d'un tableau d'affichage d'une compagnie aérienne:

consulter l'heure de départ

consulter l'heure d'arrivée

soustraire l'heure de départ de celle d'arrivée

Le résultat obtenu sera la plupart du temps correct, excepté dans le cas où le lieu du départ et celui de

destination sont dans des fuseaux horaires différents. Pire encore s'il y a l'heure d'été à un point et pas a

l'autre: l'algorithme donnera le résultat correct en hiver et pas en été.

La morale de ces exemples est que le concepteur d'un algorithme doit vérifier attentivement que

l'algorithme décrit précisément la procédure qu'il doit exécuter et que tous les cas de figures possibles ont

bien été prévus. Si le résultat attendu est très complexe, la tâche du concepteur est extrêmement difficile.

A dire vrai le concepteur a très peu de chances de réussite à moins d'avoir une approche très

rigoureusement méthodique. Une telle approche, connue sous le nom d'affinement progressif de

l'algorithme (ou conception descendante), est décrite ci-dessous.

L'affinement progressif est une variation actuelle sur le vieux thème "diviser pour régner". L'idée consiste

à fragmenter l'ensemble du traitement à exécuter et à établir un nombre donné d'étapes dont chacune peut

être décrite par un algorithme plus petit et plus simple que celui qui concerne l'ensemble du traitement.

Etant donné que chaque sous-algorithme est plus simple que l'algorithme entier, le concepteur peut mieux

en concevoir la construction, et de ce fait entrer plus dans les détails que s'il avait essayé de manipuler

l'ensemble de l'algorithme en une seule fois. Les sous-algorithmes peuvent être divisés en parties plus

élémentaires qui, grâce à leur simplicité, peuvent de nouveau comporter plus de détails et de précisions.

L'affinement de l'algorithme se poursuit jusqu'à ce que chaque étape soit suffisamment détaillée et précise

pour que son exécution par le processeur concerné soit possible.

Quand il procède à des affinements des différentes étapes, le concepteur d'un algorithme doit

naturellement savoir où s'arrêter. Autrement dit, il doit savoir quand une étape donnée constitue une

primitive adéquate au point de ne pas avoir besoin d'affinement supplémentaire. Cela signifie

13

naturellement qu'il doit connaître quelle sorte d'étape le processeur peut interpréter. La connaissance des

capacités du processeur est nécessaire non seulement pour savoir arrêter le processeur mais aussi pour

donner une direction à la procédure d'affinement elle-même. Si un concepteur sait qu'un processeur peut

interpréter une catégorie donnée d'étapes, alors il organisera l'affinement en étapes de la même catégorie.

Cette discussion démontre que l'affinement des étapes d'un algorithme ne peut se faire dans le vide. Le

concepteur doit être au fait des capacités d'interprétation du processeur prévu, si bien qu'il peut pousser

l'affinement dans des directions données et savoir quand il doit arrêter l'affinement de chaque partie.

Quand le processeur prévu est une personne, la tâche du concepteur est compliquée du fait que les

capacités d'interprétation des gens varient considérablement. Ce qui est facilement compris par une

personne peut être tout à fait inintelligible pour une autre. Les capacités d'interprétation d'un ordinateur

sont définies avec beaucoup plus de précision: un ordinateur peut interpréter tout ce qui est exprimé d'une

manière appropriée dans un langage de programmation déterminé. De ce fait le concepteur d'un algorithme

d'informatique l'affine de sorte que les étapes puissent être écrites en un langage de programmation

approprié et arrête l'affinement quand chaque étape est exprimée dans le langage concerné.

2.4 SEQUENCE

Un tel algorithme est une séquence d'étapes, voulant dire par là que:

1) Les étapes sont traitées une par une.

2) Chaque étape n'est traitée qu'une fois, aucune n'est répétée, aucune n'est omise.

3) L'ordre dans lequel les étapes sont parcourues est le même que celui dans lequel elles ont été écrites.

4) Terminer la dernière étape implique de finir l'algorithme.

Dans ce syllabus, les étapes successives dans une séquence seront séparées par ; qui est le symbole utilisé

par le PASCAL pour séparer des instructions.

Un algorithme qui n'est qu'une séquence d'étapes est extrêmement rigide puisque le déroulement du

traitement est fixé et ne peut être modifié selon les circonstances. Comme exemple de rigidité inhérente à

une séquence d'étapes, examinons l'algorithme suivant qui décrit comment voyager du centre de Londres

à celui de New York

prendre le métro pour l'aéroport d'Heathrow de Londres;

gagner par avion l'aéroport Kennedy de New York;

prendre un taxi de l'aéroport Kennedy au centre de New York; (2.1)

14

Cet algorithme est très rigide. Il ne fournit aucune alternative si des circonstances extraordinaires se

présentent, telles qu'une grève de métro à Londres ou une grève des taxis à New York. Une séquence est

une structure algorithmique très primitive. Nous en examinerons de moins primitives dans les sections

suivantes.

2.5 SELECTION

Nous venons de voir que lorsqu'un algorithme n'a qu'une structure séquentielle, il n'est pas possible d'en

modifier le traitement selon les circonstances. Ainsi une grève des transports urbains londoniens jetterait

dans l'embarras le voyageur transatlantique. Dans ce cas, ce qui serait utile, serait la capacité d'exécuter

une étape du type

prendre un taxi pour l'aéroport d'Heathrow de Londres;

si les transports urbains sont en grève et de sauter cette étape dans le cas contraire. Une telle capacité est

appelée sélection. La sélection, dans l'étape ci-dessus, peut s'exprimer en récrivant l'affinement de l'étape

(2.1) sous la forme

(2.1.1) prendre le métro pour l'aéroport d'Heathrow de Londres;

(2.1.2) if le métro est en grève

then prendre un taxi pour l'aéroport d'Heathrow de Londres;

(2.1.3) gagner par avion l'aéroport Kennedy de New York;

(2.1.4) prendre un taxi de l'aéroport Kennedy au centre de New York;

L'étape cruciale est 2.1.2 qui exprime à la fois l'étape devant être sélectionnée (prendre un taxi pour

l'aéroport d'Heathrow de Londres) et la condition (le métro est en grève) dont dépend la sélection.

L'étape 2.1.2 est un cas particulier d'une étape de forme très générale, selon la syntaxe du PASCAL,

if condition

then étape;

où condition détermine la circonstance dans laquelle 1'étape doit être accomplie. Si la condition est vraie,

alors l'étape doit être exécutée; dans le cas inverse, elle ne doit pas l'être. Le processeur doit être en mesure

d'interpréter les conditions qui figurent dans un algorithme de la même manière qu'il est capable

d'interpréter les étapes. Les conditions doivent cependant être affinées jusqu'à ce qu'elles soient

suffisamment détaillées et précises pour permettre au processeur de les interpréter.

Dans notre exemple, la sélection est utilisée pour déterminer si une étape donnée doit être exécutée ou

non. Une extension de cette forme de sélection est une forme qui détermine laquelle de ces deux étapes

15

possibles doit être accomplie. Par exemple, l'algorithme du voyage transatlantique 2.1 peut être

considérablement amélioré par la réécriture de la première étape

if le métro est en grève

then prendre un taxi pour l'aéroport d'Heathrow de Londres

else prendre le métro pour l'aéroport d'Heathrow de Londres;

Cela est un cas particulier de sélection entre deux étapes. La forme générale d'une telle sélection est

if condition

then étape 1

else étape 2;

où condition détermine clairement laquelle des étapes (1 ou 2) doit être parcourue. Nous emploierons des

retraits (comme ci-dessus) afin que les diverses étapes possibles ressortent du texte qui les entoure.

Pour prendre un autre exemple d'utilisation de la sélection, voici un algorithme (2.2) concernant l'approche

des feux de signalisation routière:

if le signal est rouge ou orange

then s'arrêter

else continuer; (2.2)

Un algorithme un peu meilleur (2.3) admet la possibilité que les signaux ne soient pas en état de marche

if pas de signal

then avancer prudemment

else if signal rouge ou orange

then s'arrêter

else continuer; (2.3)

Cet algorithme contient deux occurrences de la sélection, la seconde étant incluse dans la première et

exécutée seulement si le signal marche. Notez combien l'usage des retraits rend tout à fait claire la lecture

des étapes de l'algorithme et à quelle partie de la sélection elles se rattachent. Sans cette forme d'écriture

l'algorithme serait beaucoup plus difficile à comprendre (2.4)

if pas de signal

then avancer prudemment

else if signal rouge ou orange

then s'arrêter

else continuer; (2.4)

16

Un autre exemple de sélection imbriquée apparaît dans l'algorithme suivant, qui décrit comment

déterminer le plus grand de trois nombres donnés alors que le processeur ne peut en comparer que deux à

la fois. Appelons les nombres X, Y, et Z. Une première version de l'algorithme peut être:

if X > Y

then choisir entre X et Z

else choisir entre Y et Z;

Le choix entre X et Z pourrait être affiné en:

if X > Z

then MAX := X

else MAX := Z;

et le choix entre Y et Z affiné de la même façon. En suivant la syntaxe du PASCAL, nous avons enregistré

notre choix en écrivant dans l'entité nommée MAX, à l'aide du symbole d'affectation :=, la valeur de X.

On obtient alors l'algorithme final suivant (2.5):

if X > Y

then if X > Z

then MAX := X

else MAX := Z

else if Y > Z

then MAX := Y

else MAX := Z; (2.5)

Notez à nouveau à quel point sont rendus clairs les énoncés de l'algorithme des différentes parties des

sélections grâce à l'utilisation des retraits.

L'effet de la sélection permet à un processeur de suivre un algorithme selon différents chemins en fonction

des circonstances. Sans la sélection, il serait impossible d'écrire un algorithme qui puisse avoir une

signification pratique quelconque.

2.6 ITERATION

Examinons le processus qui consiste à chercher, dans une liste de noms et d'adresses, l'adresse d'une

personne (le nom de la personne étant indiqué)

17

examiner le premier nom de la liste;

if ce nom est le nom donné

then extraire l'adresse correspondante S1

else examiner le nom suivant;

if ce nom est le nom donné

then extraire l'adresse correspondante S1

else examiner le nom suivant;

if ... (2.6)

L'inconvénient de cet algorithme (en dehors de l'empiétement sur la marge de droite qui se produira

inévitablement à un certain moment) tient au fait que l'auteur ne sait pas quand il doit s'arrêter d'écrire.

Plus précisément l'auteur ne sait pas combien de fois il doit écrire l'instruction S1 afin de s'assurer que le

nom est celui recherché. Un problème identique surgit dans l'algorithme (2.7) qui décrit comment calculer

le premier nombre premier qui soit plus grand qu'un nombre de "départ" (entier positif).

De nouveau il n'apparaît pas clairement combien de fois il faut écrire la séquence S2 pour être sûr que le

processeur produise un nombre premier. Par exemple S2 doit être écrite une seule fois si le nombre de

départ est 4, et quatre fois si c'est 13, mais combien de fois faudra-t-il récrire la séquence si le nombre de

départ est 7394485?

obtenir le nombre de départ appelé PREMIERPOT;

PREMIERPOT := PREMIERPOT + 1; {ajouter 1 au nombre de départ}

tester PREMIERPOT pour voir s'il est premier; S2

if PREMIERPOT est premier

then afficher PREMIERPOT

else PREMIERPOT := PREMIERPOT + 1;

tester PREMIERPOT pour voir s'il est premier; S2

if PREMIERPOT est premier

then afficher PREMIERPOT

else PREMIERPOT := PREMIERPOT + 1;

... (2.7)

Ces exemples montrent que les séquences et les sélections ne sont pas en elles-mêmes suffisantes pour

exprimer des algorithmes dont la longueur peut varier selon les circonstances. Ce qui est nécessaire, c'est

le moyen de répéter certaines instructions dans un algorithme un nombre quelconque de fois. Pour ce

18

faire, nous allons introduire les mots repeat et until dans les algorithmes, de telle sorte que notre exemple

2.6 puisse être récrit de la manière suivante:

examiner le premier nom de la liste;

repeat

if ce nom est le nom donné

then extraire l'adresse correspondante S1

else examiner le nom suivant;

until le nom soit trouvé ou la liste épuisée; (2.8)

De la même manière, l'algorithme 2.7 peut être récrit sous la forme suivante

obtenir le nombre de départ appelé PREMIERPOT;

repeat

PREMIERPOT := PREMIERPOT + 1;

tester PREMIERPOT pour voir s'il est premier;

until PREMIERPOT soit premier;

afficher PREMIERPOT; (2.9)

Ces exemples illustrent la répétition ou l'itération dont la forme générale est

repeat

partie de l'algorithme;

until condition;

ce qui signifie que la partie de l'algorithme comprise entre les mots repeat et until doit être répétée tant

que la condition spécifiée après until n'est pas vraie. Dans l'algorithme 2.8, la partie répétée est l'examen

successif des noms de la liste. La répétition cesse lorsque le nom est trouvé ou que la liste est épuisée.

Dans l'algorithme 2.9, la répétition concerne le test appliqué à des nombres successifs et s'achève dès que

le premier nombre premier est rencontré.

L'occurrence d'itération est généralement appelée une boucle et la partie de l'algorithme qui est répétée

(c'est-à-dire celle qui est comprise entre les mots repeat et until) est connue sous le nom de corps de la

boucle. La condition qui apparaît après until est appelée la condition d'achèvement de la boucle. Dans ce

syllabus, nous utiliserons les retraits pour distinguer le corps d'une boucle du texte qui l'entoure.

L'intérêt de l'itération est la description d'un processus de durée indéterminée par un algorithme de

longueur finie. Cela implique une démarche supplémentaire: celle d'assurer un achèvement effectif de

l'itération au moment prévu. L'algorithme 2.9 se termine parce qu'il existe toujours un nombre premier

19

plus grand que tout nombre donné. Dans l'algorithme 2.8, l'itération se termine parce que la condition de

clôture (nom trouvé ou liste épuisée) devient vraie. A noter cependant que l'omission de la deuxième partie

de la condition de fin (liste épuisée) peut provoquer un désastre puisque si le nom recherché n'est pas

présent, le processeur continue sa recherche au-delà de la fin de la liste. Une des erreurs les plus courantes

dans la conception des algorithmes est l'omission des conditions de fin.

Naturellement, certains processus, comme nous l'avons suggéré dans la section 2.1, ne sont pas censés se

terminer, et un algorithme décrivant un tel processus doit contenir une boucle sans fin. Une telle boucle

peut être écrite de cette manière

repeat

corps de la boucle

until condition jamais satisfaite;

Comme autre exemple d'itération, examinons à nouveau l'algorithme 2.9 concernant les nombres

premiers. La boucle contient l'instruction

tester PREMIERPOT pour voir s'il est premier;

Il est improbable que le processeur soit en mesure de voir immédiatement si un nombre donné est premier

ou non. Donc cette instruction demande un affinement supplémentaire. Par définition, un nombre est

premier s'il n'a pas d'autre diviseur que 1 ou lui-même. De ce fait, tester un nombre pour savoir s'il est

premier revient à déterminer s'il comporte des diviseurs non triviaux. Cela peut être réalisé grâce à

l'algorithme suivant dans lequel le nombre à tester est appelé premier potentiel:

diviser premier potentiel par chacun des nombres compris entre 1 et lui-même;

if aucune division n'est exacte

then le premier potentiel est premier

else le premier potentiel n'est pas premier;

La première instruction implique évidemment une itération dans laquelle le nombre premier potentiel est

divisé par les facteurs possibles à partir de 2. L'itération peut prendre fin lorsque l'une des divisions est

exacte (dans ce cas le premier potentiel n'est pas un nombre premier) ou lorsque le diviseur possible est

égal au premier potentiel (dans ce cas le nombre est réellement premier). En fait il n'est pas nécessaire de

prolonger l'itération jusque là: un peu de réflexion montre qu'il suffit d'aller jusqu'à la racine carrée du

potentiel premier. L'algorithme qui permet de tester si un nombre est premier est donc:

20

DIVPOS := 2; {poser comme diviseur possible 2}

repeat

diviser le premier potentiel par DIVPOS;

DIVPOS := DIVPOS + 1;

until la division soit exacte ou que DIVPOS > SQRT (premier potentiel);

if aucune division n'est exacte

then le premier potentiel est premier

else le premier potentiel n'est pas premier; (2.10)

Nous rappelons que l'algorithme 2.10 est un affinement de la seule instruction

tester PREMIERPOT pour voir s'il est premier;

de l'algorithme 2.9. L'algorithme 2.9 peut de ce fait être récrit de la façon suivante:

obtenir le nombre de départ appelé PREMIERPOT;

repeat

PREMIERPOT := PREMIERPOT + 1;

DIVPOS := 2;

repeat

diviser PREMIERPOT par DIVPOS;

DIVPOS := DIVPOS + 1;

until la division soit exacte ou DIVPOS > SQRT (premier potentiel);

if aucune division n'est exacte

then PREMIERPOT est premier

else PREMIERPOT n'est pas premier;

until PREMIERPOT soit premier;

afficher PREMIERPOT; (2.11)

Cet algorithme comporte deux boucles dont l'une est emboîtée dans l'autre. La boucle externe est exécutée

une fois pour chaque nombre premier potentiel, et la boucle interne est exécutée une fois pour chaque

diviseur possible d'un nombre premier potentiel. Les deux boucles sont essentielles dans cet algorithme.

Il faut également remarquer à quel point le temps de traitement peut être économisé dès lors que la racine

carrée est utilisée comme condition pour mettre fin à la boucle interne. Ceci est l'exemple d'un phénomène

courant: un tant soit peu de réflexion pendant la conception peut permettre un gain de temps substantiel

pendant l'exécution.

21

La notation exprimant l'itération repeat ... until ... est tout à fait compréhensible puisque c'est une forme

à peine stylisée du langage courant. Malgré tout, il existe de nombreux cas où des boucles repeat ne

conviennent pas pour exprimer l'itération désirée, et ce pour des raisons que nous allons à présent aborder.

Une boucle repeat comporte une condition d'achèvement après le until qui suit le corps de la boucle. Cela

implique que, dans tous les cas, le corps de la boucle est exécuté au moins une fois puisque la condition

d'achèvement ne peut intervenir qu'à la suite de son exécution. L'importance de cette remarque deviendra

évidente à partir de l'exemple suivant.

Supposons que nous ayons besoin d'un algorithme pour déterminer le nombre le plus grand d'une liste.

Un tel algorithme peut se réaliser par un examen de la liste en comparant chaque nombre avec le plus

grand déjà trouvé et en remettant cette opération à jour à chaque occurrence d'un nombre supérieur. On

peut ainsi arriver sans trop d'effort à l'algorithme 2.12.

MAX := le premier nombre de la liste;

repeat

examiner le nombre suivant;

if ce nombre > MAX

then MAX := ce nombre;

until la liste soit entièrement parcourue;

afficher MAX; (2.12)

Cet algorithme semble correct à première vue, pourtant il comporte une erreur monumentale si la liste ne

comporte qu'un seul nombre. Dans ce cas, le processeur atteint le bout de la liste dès qu'il exécute le corps

de la boucle pour la première fois. Même si la condition d'achèvement "fin de liste" est prévue, elle n'est

pas effective dans ce cas puisque le processeur n'effectue aucun test de fin avant qu'il n'ait exécuté le corps

de la boucle au moins une fois. Il faut donc mettre la condition d'achèvement au début de la boucle plutôt

qu'à la fin pour permettre au processeur d'omettre le corps de la boucle si la condition d'achèvement est

vraie. L'algorithme devient (2.13):

MAX := le premier nombre de la liste;

while la liste n'est pas entièrement parcourue do

begin

examiner le nombre suivant;

if ce nombre > MAX

then MAX := ce nombre;

end;

afficher MAX; (2.13)

22

La condition d'achèvement apparaît avant le corps de la boucle entre while et do. Le corps de la boucle

est noté après do. Pour déterminer clairement l'ensemble d'instructions constituant le corps de la boucle,

le PASCAL utilise les mots begin et end qui agissent comme des "super parenthèses" autour des

instructions à considérer et qui définissent ainsi une instruction composée. De cette manière, on constate

sans équivoque que l'instruction afficher MAX ne fait pas partie du corps de la boucle et n'est exécutée

que lorsque la boucle est terminée. Remarque: dans les algorithmes (2.6) et (2.7), des instructions

composées auraient déjà dû être utilisées pour regrouper les instructions qui dépendent des else.

La forme générale de ce type de boucle est la suivante

while condition do corps de la boucle;

avec le corps de la boucle qui est une instruction seule ou une instruction composée.

Elle signifie que le corps de la boucle doit être répété aussi longtemps que la condition est vraie. Du fait

que la condition est testée avant l'exécution du corps de la boucle, ce type de boucle est dit pré-testé. Le

type de la boucle repeat est dit post-testé. En raison des différentes positions de la condition

d'achèvement, le corps d'une boucle post-testée est toujours exécuté au moins une fois, alors que le corps

d'une boucle pré-testée peut ne pas être exécuté du tout. Une boucle pré-testée peut être considérée comme

une boucle de "précaution", alors que la boucle post-testée est une boucle d"'imprudence". Malgré cette

caractérisation peu flatteuse, la boucle post-testée est souvent utilisée et peut être employée de manière

fiable dès qu'il est admis que le corps de la boucle doit être exécuté au moins une fois. C'est le cas des

exemples de 2.8 à 2.11, où toutes des boucles post-testées étaient utilisées.

Pour consolider les notions concernant l'itération qui viennent d'être développées, nous allons étudier

rapidement un très vieil algorithme: l'algorithme d'Euclide, développé aux alentours de 300 av. J.C., pour

déterminer le plus grand commun diviseur (PGCD) de deux entiers positifs. L'algorithme d'Euclide est

fondé sur la découverte de la relation

PGCD (X, Y) = PGCD (Y, MOD (X, Y)) si Y > 0

et

PGCD (X, Y) = X si Y = 0

où X et Y sont des entiers non négatifs et MOD (X, Y), le reste de la division entière de X par Y. Pour

illustrer l'utilisation de cette relation, cherchons à déterminer le PGCD de 24 et 9:

PGCD (24, 9) = PGCD (9, 6) = PGCD (6, 3) = PGCD (3, 0) = 3

En généralisant, pour déterminer le PGCD de deux entiers non négatifs X et Y, tout ce que nous devons

faire est de répéter la division de X par Y puis de remplacer X par Y et Y par le reste. Le processus

23

continue tant que Y n'est pas égal à zéro. Quand Y égale zéro, la réponse est X. Un algorithme approprié

est donc de ce fait

while Y 0 do

begin

MODU := MOD (X, Y);

X := Y;

Y := MODU;

end;

afficher X; (2.14)

Notez que l'utilisation d'une boucle post-testée dans ce cas ne conviendrait pas puisque l'algorithme ne

fonctionnerait pas (division par zéro) si Y était à l'origine égal à zéro.

Avant d'achever cette section, il nous faut encore aborder une autre forme d'itération. C'est une forme

particulièrement simple dans laquelle le nombre de répétitions est connu avant l'exécution de la boucle.

Par exemple, un algorithme pour calculer la puissance N d'un nombre X (c'est-à-dire pour calculer XN )

comportera une boucle dans laquelle X sera multiplié par lui-même, et il est relativement clair que cette

boucle doit être exécutée N fois. Un tel algorithme peut de ce fait être énoncé ainsi (2.15)

obtenir les valeurs de X et de N;

PRODUIT := 1;

for i := 1 to N do PRODUIT := PRODUIT * X;

afficher PRODUIT; (2.15)

Un autre exemple classique est le calcul de la factorielle de N où l'on calcule le produit des N premiers

nombres entiers (2.16)

Obtenir la valeur de N;

PRODUIT := 1; {affecter 1 à produit}

for i := 1 to N do PRODUIT := PRODUIT * i;

afficher PRODUIT; (2.16)

Ces algorithmes illustrent un type d'itération de la forme générale

for i := 1 to N do corps de la boucle; (2.17)

où i est le compteur de boucle qui prend successivement les valeurs de 1 à N, un entier positif quelconque,

et permet ainsi d'accomplir N fois le corps de la boucle. Le nombre de répétitions étant connu à l'avance,

ce type d'itération s'appelle itération définie à l'avance par opposition à l'itération indéfinie dans laquelle

le nombre de répétitions dépend de ce qui arrive quand la boucle est exécutée.

24

L'itération définie peut naturellement être transformée en itération indéfinie (2.18):

NB := 0; {mettre le compteur de répétitions à 0}

while NB < N do

begin

corps de la boucle;

NB := NB + 1;

end (2.18)

La forme 2.16 est néanmoins plus concise et plus adaptée. La durée de l'itération définie est déterminée

au moment de l'entrée dans la boucle et l'achèvement est assuré. L'itération indéfinie dépend de la

réalisation de la condition d'achèvement.

2.7 SEQUENCE, SELECTION ET ITERATION ENSEMBLE

Les trois dernières sections ont décrit trois formes de base de la construction des algorithmes: la séquence,

la sélection et l'itération. Il faut remarquer que ces trois formes sont suffisantes pour concevoir tout

algorithme. Plus précisément, s'il est possible de concevoir un algorithme pour décrire un processus

donné, alors un tel algorithme peut être entièrement conçu à partir de la séquence, de la sélection et de

l'itération seulement. Il existe cependant d'autres formes de construction qui s'ajoutent utilement aux trois

formes que nous avons déjà présentées (nous les décrirons dans les sections suivantes).

Cependant, pour mettre au clair les idées déjà exposées, nous développerons un algorithme permettant de

classer une liste de noms par ordre alphabétique. La nécessité de trier l'information intervient dans de

nombreuses applications informatiques. C'est un fait que certains ordinateurs passent plus de temps à trier

qu'à toute autre opération. Parce que le tri est une activité très banale, il y a de nombreux algorithmes pour

le faire. Celui que nous allons développer ici est particulièrement simple; il est connu sous le nom de tri

à bulles. Ici nous emploierons un algorithme pour le classement de noms, mais il peut être également

utilisé pour classer toute autre sorte d'information dans un ordre déterminé quelconque.

L'idée de base consiste à parcourir la liste des noms en comparant chaque nom avec celui qui lui succède

et en les intervertissant s'ils ne sont pas dans l'ordre. Si, à la fin du parcours, tous les noms sont classés, il

n'est pas nécessaire de faire quoi que ce soit d'autre. Si ce n'est pas le cas, un autre parcours de la liste est

effectué et d'autres noms sont à nouveau changés de place si cela est nécessaire. La procédure est illustrée

par la figure 2.1 qui montre comment un nom donné monte ou descend pour atteindre la position correcte.

Un algorithme schématisant cette procédure est énoncé comme suit:

25

while liste non triée do

begin

parcourir liste;

permuter deux noms si nécessaire;

end;

liste originale liste après 1 passage

(4 permutations)

liste après 2 passages

(3 permutations)

liste après 3 passages

(2 permutations)

Jean Jean Frédéric Bertrand

Katia Frédéric Bertrand Frédéric

Frédéric Bertrand Jean Jacques

Bertrand Katia Jacques Jean

Samuel Jacques Katia Katia

Jacques Marie Marie Marie

Marie Samuel Samuel Samuel

Figure 2.1: Action du tri à bulles

On utilise la boucle pré-testée de préférence à la boucle post-testée parce que le processeur risque de

trouver la liste déjà en ordre et n'a alors plus rien à faire. (Cette décision sera réexaminée quand

l'algorithme aura été affiné.) La deuxième ligne de l'algorithme peut être affinée de la manière suivante:

commencer au début de la liste;

repeat

si le nom suit alphabétiquement le nom qui le suit then permuter nom et suivant;

examiner le nom suivant;

until la fin de la liste soit atteinte;

Si le nombre de noms est connu (soit N) avant le début de la boucle, alors la boucle indéfinie repeat peut

être remplacée par une boucle définie, compte tenu du fait qu'il y a exactement N - 1 paires de noms

adjacents à comparer. De ce fait l'algorithme complet peut être énoncé de la manière suivante:

while liste non triée do

begin

commencer au début de la liste;

for i := 1 to N – 1 do

begin

if le nom suit alphabétiquement le nom qui le suit then permuter noms;

examiner le nom suivant;

end;

end; (2.19)

26

Cet algorithme convient à la condition que le processeur soit en mesure de déterminer (à la ligne 1) si la

liste est triée ou non. S'il ne le peut pas, la condition d'achèvement de la ligne 1 exige un affinement

supplémentaire. Pour savoir si la liste est classée, on peut essayer de se rappeler si, lors du précédent

parcours, il a été nécessaire de permuter deux noms quelconques. Si ce n'a pas été le cas, tous les noms

sont sans doute dans le bon ordre et la liste est par conséquent triée. A noter qu'au moins un parcours

complet de la liste est nécessaire pour savoir si elle est triée dès le départ. Dans ce cas la boucle pré-testée

est impropre et doit être remplacée par la boucle post-testée. On obtient alors l'algorithme suivant:

repeat

commencer au début de la liste;

for i := 1 to N – 1 do

begin

if le nom suit alphabétiquement le nom qui le suit

then

begin

permuter;

se souvenir qu'une permutation a été faite ici;

end;

examiner le nom suivant;

end;

until plus de permutation pendant ce parcours; (2.20)

Remplacer la boucle pré-testée de l'algorithme 2.19 par la boucle post-testée de l'algorithme 2.20 est le

renversement de la première décision. De tels renversements ne sont pas rares pour affiner des

algorithmes. Plus on ajoute de détails, plus les premières décisions prises avant d'avoir eu une

appréhension totale de l'algorithme doivent être reconsidérées. C'est pourquoi il est important de retarder

ces décisions aussi longtemps que possible de manière qu'elles soient prises en toute connaissance de

cause et n'entraînent pas de révisions ultérieures. Cette philosophie prudente peut être résumée par le

proverbe cyniquement renversé: remettre toujours au lendemain ce qui est difficile à faire le jour même.

L'algorithme 2.20 est un algorithme de tri particulièrement simple, mais ce n'est pas toujours le plus rapide.

D'autres algorithmes donnent au processeur moins d'instructions, et de ce fait accroissent sa vitesse

d'exécution. L'algorithme 2.20 est parfait lorsque la liste est presque en ordre au départ, ce qui n'implique

que quelques parcours. Il ne l'est pas du tout si le dernier nom de la liste est le premier dans l'ordre

alphabétique. Dans ce cas, il faut N - 1 parcours de la liste pour placer ce nom en tête. On peut démontrer

que la moyenne des parcours nécessaires au tri de la liste est approximativement proportionnelle à N.

27

Puisque chaque parcours implique N -1 exécutions de la boucle interne, le nombre moyen de pas exécutés

est approximativement proportionnel à N². Le nombre de pas impliqués dans l'exécution d'un algorithme,

et par conséquent le temps nécessaire à son exécution, sont des attributs qui sont regroupés sous le terme

général de complexité.

Nous conclurons en rappelant que séquence, sélection et itération sont des formes de construction

d'algorithmes tout à fait adéquates. Dans les sections suivantes, nous ajouterons à. ces formes la

modularité et la récursivité qui, sans être absolument nécessaires, sont très pratiques.

2.8 MODULARITE

Dans les sections précédentes, nous avons montré comment développer des algorithmes grâce à des

procédures d'affinement des instructions. A chaque étape de cet affinement, l'algorithme est divisé en

composants plus petits qui peuvent être définis à leur tour de manière plus détaillée. L'affinement s'achève

lorsque chaque élément de l'algorithme est exprimé de manière telle que le processeur prévu peut

l'interpréter.

Les composants rencontrés pendant l'affinement sont souvent totalement indépendants de l'algorithme

principal au sens où ils peuvent être conçus extérieurement au contexte dans lequel ils doivent être utilisés.

De tels éléments peuvent être conçus par quelqu'un d'autre que le concepteur de l'algorithme principal et

peuvent également être utilisés comme éléments d'un autre algorithme. Ils peuvent être considérés comme

des éléments multifonctionnels et intégrables à tout algorithme auquel ils seraient nécessaires.

En guise d'illustration nous allons examiner la conception de certains algorithmes destinés à être exécutés

par un simple automate. La fonction de l'automate consiste à dessiner: il est équipé de roulettes qui lui

permettent des déplacements sur la feuille de papier et d'un stylo qui peut s'abaisser dès qu'il veut faire un

trait. L'automate peut interpréter et exécuter des commandes de la forme:

déplace (x) déplace en avant x cm

gauche (x) tourne x degrés a gauche

droite (x) tourne x degrés à droite

lève stylo ôte stylo du papier

pose stylo pose stylo sur papier

28

Supposons que nous voulions que l'automate dessine deux carrés concentriques comme le montre la figure

2.2, sa position initiale étant le point X, centre des carrés, le stylo levé. On peut esquisser l'algorithme

suivant

déplace en A;

dessine un carré de 10 cm de côté;

déplace en B;

dessine un carré de 20 cm de côté; (2.21)

B

Figure 2.2: Carrés concentriques devant être tracés par un automate à partir du point X

La seconde et la quatrième instructions de cet algorithme impliquent le dessin d'un carré, qui est une

procédure en soi indépendante du reste de l'algorithme, ce qui implique que nous pouvons concevoir un

algorithme pour dessiner un carré sans référence au contexte de l'algorithme qui l'utilise. Nous pouvons

même, si nous le désirons, demander à quelqu'un d'autre de le concevoir sans lui dire à quoi nous

l'emploierons. Une fois un tel algorithme conçu, nous pouvons l'intégrer aux endroits prévus dans

l'algorithme 2.21 ou à tout autre algorithme pour lequel le tracé d'un carré serait nécessaire.

Un algorithme qui peut être intégré à un autre algorithme est un module (dans certains langages de

programmation, ce peut être une procédure, une routine, une sous-routine ou une fonction). Un module

est un algorithme en soi et peut être conçu indépendamment du contexte dans lequel il doit être utilisé.

On dit d'un algorithme qui utilise un module qu'il l'appelle.

Pour que le module de conception du carré soit globalement utilisable, il doit être en mesure d'effectuer

un tracé de n'importe quelle taille. Lorsque le module est appelé, la taille spécifique requise doit être

indiquée. Pour l'algorithme 2.21, nous pourrions écrire

dessinercarré (10) et dessinercarré (20)

ce qui signifierait qu'un des carrés doit avoir un côté de 10 cm et l'autre, un côté de 20 cm. Ce module,

lui-même peut être énoncé de la manière suivante (2.22), en utilisant la syntaxe du PASCAL qui annonce

un module par le mot procedure et inclut toutes les instructions du module entre begin et end:

A

X

29

procedure dessinercarré (taille);

{Dessine un carré dont le côté a une longueur taille en cm. Le carré est tracé dans le sens inverse

des aiguilles d'une montre en partant de la position actuelle de l'automate. La première ligne est

tracée selon l'orientation actuelle de l'automate. L'automate revient à sa position et à son

orientation initiales dès que le stylo est levé.}

begin

poser stylo;

for i := 1 to 4 do

begin

déplacer (taille);

gauche (90);

end;

lever stylo;

end; (2.22)

La taille représente un paramètre formel du module: il est utilisé à l'intérieur du module pour définir la

taille du carré. Lorsque le module est appelé, le paramètre actuel 10 (ou 20) donne une valeur spécifique

de la taille, et détermine ainsi la grandeur du carré.

Dans ce syllabus, la notation que nous retiendrons pour les modules sera la suivante:

procedure nommodule (paramètres formels);

{Spécification du traitement que le module décrit}

begin

corps du module;

end;

Le commentaire entre accolades aide le lecteur à comprendre ce que le module fait; le corps du module

explique le détail de ce qu'il effectue. Le corps d'un module est exécuté et le commentaire ne l'est pas.

On peut appeler un module de la manière suivante:

nommodule (paramètres actuels);

C'est ce que le processeur interprète comme une directive pour exécuter le corps du module appelé dont

les paramètres formels ont été remplacés par les paramètres actuels de l'appel. Les paramètres formels

30

d'un module peuvent être considérés comme la représentation de l'information nécessaire au module

lorsqu'il est appelé.

Les paramètres actuels sont des informations pour un appel particulier. Ces informations prennent la place

des paramètres formels lorsque le module est exécuté. Il doit naturellement y avoir le même nombre de

paramètres actuels et formels.

Un algorithme construit autour d'un nombre donné de modules est appelé un algorithme modulaire.

Chaque module est un composant en soi de l'algorithme et agit comme un bloc dans la construction de

l'algorithme. L'interface entre le module et ses appelants est double:

1) l'interface explicitement exprimée dans les paramètres (par exemple la taille du carré à dessiner par

dessinercarré);

2) l'interface implicite dans les présupposés du module et de son appelant sur leur action réciproque (par

exemple que dessinercarré se termine avec le stylo levé).

Les deux formes d'interface doivent être décrites dans la spécification du module. La seconde forme,

devant être implicite, doit faire l'objet d'une description très fine.

En utilisant le module dessinercarré comme nous l'avons précédemment défini, nous pouvons affiner

l'algorithme 2.21 de la manière suivante:

gauche (45);

déplacer (SQRT(50)); {déplacer en A}

gauche (135);

dessinercarré (10); {dessiner carré intérieur}

droite (135);

déplacer (SQRT(50)); {déplacer en B}

gauche (135);

dessinercarré (20); {dessiner carré extérieur} (2.23)

Notez que toutes les instructions de l'algorithme sont des appels à des modules. Nous n'avons pas donné

les algorithmes pour les modules déplacer, gauche et droite puisque nous avons estimé qu'ils étaient

suffisamment simples pour que l'automate soit capable d'interpréter leurs appels. Si ce n'est pas le cas,

chaque module devra être défini dans les termes que l'automate peut interpréter. Ainsi le module déplacer

pourrait par exemple être défini comme (2.24)

31

procedure déplacer (x);

{L'automate se déplace de x cm en avant sans changer d'orientation. Le stylo n'est ni levé ni posé.}

begin

N := x DIV (circonférence d'une roulette); {nombre requis de rotations des roulettes}

for i := 1 to N do faire tourner toutes les roulettes à la fois;

end; (2.24)

Cette définition de déplacer présuppose que l'automate peut faire des divisions et qu'il sait comment faire

réaliser une rotation à ses roulettes. DIV est l'opérateur de division entière.

Il faut bien comprendre que le concepteur de l'algorithme 2.23 n'a pas besoin de savoir comment le module

dessinercarré travaille; tout ce qu'il doit connaître est l'effet de son exécution. En d'autres termes, il n'a

pas besoin de comprendre le corps du module mais uniquement de lire la spécification de son en-tête.

Ainsi peut-il clairement distinguer deux problèmes de conception: celui de l'algorithme 2.23 et la

conception de dessinercarré. Cela diminue la complexité de la procédure de conception tout en lui

assurant une plus grande vitesse et une meilleure fiabilité. dessinercarré peut être conçu après que

l'algorithme 2.23 est fini ou peut l'être par quelqu'un d'autre indépendamment de l'algorithme.

Inversement, si le module existe déjà parce qu'il a été nécessaire à un autre algorithme, il peut simplement

être appelé sans travail supplémentaire. Les possibilités des modules peuvent encore être illustrées par

cette approche du module dessinercarré. Puisqu'un carré n'est qu'un polygone particulier, il peut être

intéressant de concevoir un module pour dessiner des polygones et de l'appeler grâce à des paramètres

actualisés appropriés pour un carré. Un module de tracé de polygone peut s'énoncer (2.25):

procedure dessinerpolygone (taille, N);

{Dessine un polygones de N côtés de taille cm chacun. Le polygone est tracé dans le sens inverse

des aiguilles d'une montre à partir de la position actuelle de l'automate. La première ligne est

dessinée selon l'orientation actuelle de l'automate. L'automate revient à sa position et à son

orientation initiales lorsque le stylo est levé.}

begin

poser stylo;

for i := 1 to N do

begin

déplacer (taille);

gauche (360 DIV N);

end;

lever stylo;

end; (2.25)

32

Le module dessinercarré peut à présent être récrit simplement:

procedure dessinercarré (taille);

{Dessine un carré de côté taille cm,... }

begin

dessinerpolygone (taille,4);

end;

De même, on peut écrire un module pour dessiner un triangle (2.26)

procedure dessinertriangle (taille);

{Dessine un triangle équilatéral de taille cm de côté,...}

begin

dessinerpolygone (taille,3);

end; (2.26)

Les modules pour tracer des pentagones, des hexagones, etc. peuvent être écrits aussi simplement. Notez

que la dernière version de dessinercarré ne nécessite aucune modification de l'algorithme 2.23 ou d'autre

algorithme l'appelant.

Les avantages que comporte l'utilisation de modules peuvent être résumés de la manière suivante:

1) Les modules correspondent naturellement à l'affinement progressif de l'algorithme et l'on obtient ainsi

une conception descendante.

2) Un module est en soi un composant de tout autre algorithme plus important qui l'appelle. Un module

et l'algorithme appelant peuvent être conçus indépendamment l'un de l'autre, ce qui simplifie la

procédure de conception. Puisque l'écriture d'un algorithme est généralement une tâche difficile, toute

simplification est bienvenue. Les avantages de la simplification consistent en une conception plus

rapide et en une moindre probabilité d'erreurs.

3) Pour intégrer un module à un algorithme, il suffit de savoir ce que fait le module et non comment il le

fait. Ce que fait un module peut être expliqué de manière adéquate par un commentaire de début.

4) De même que les modules simplifient la conception des algorithmes, ils simplifient aussi leur

compréhension. Pour comprendre, par exemple, l'algorithme 2.23, il suffit de comprendre l'effet des

modules déplacer, dessinercarré et ainsi de suite, sans pour autant savoir comment ces résultats sont

atteints. La facilité de compréhension est très importante dans le cas fréquent où un algorithme doit

être modifié (particulièrement lorsqu'il doit l'être par d'autres que l'auteur). C'est également un facteur

d'importance pour la démonstration de la validité de l'algorithme.

5) Une fois qu'un module a été conçu, il peut être intégré à tout algorithme qui en a besoin. Il est de ce

fait possible de construire une bibliothèque de modules, tels que des modules de tri, de résolution

d'équations, de calcul des impôts, etc.

33

L'approche modulaire sera généralisée en deuxième bachelier pour conduire à la méthodologie objet qui

est à la base d'une autre méthode de conception d'algorithme: l'approche ascendante.

2.9 RECURSION

Dans la section 2.6, nous présentions un algorithme (2.16) pour calculer le produit des N premiers entiers

(la factorielle de N ). Cet algorithme utilisait une boucle dans laquelle les entiers successifs de 1 à N

étaient multipliés entre eux. Une approche différente consiste à utiliser le fait que la factorielle de N est

le produit de N par la factorielle de N – 1, c'est-à-dire, factorielle (N) = N * factorielle (N - 1).

Cela est vrai pour tout N plus grand que 1. De plus, factorielle (1) = 1. Ainsi un algorithme possible pour

calculer la factorielle de N est écrit par le module suivant:

function factorielle (N);

{Calcule la factorielle N pour tout entier N > 0}

begin

if N = 1

then factorielle := 1

else factorielle := N * factorielle (N-1);

end; (2.27)

Une fonction, notée function en PASCAL, est un module spécial dont le nom nommodule définit une

entité contenant une valeur. Une fonction est un module avec des paramètres formels et actuels (exemple:

factorielle (N – 1)) dont le résultat du calcul est contenu dans son nommodule (factorielle := 1). On utilise

une fonction plutôt qu'une procédure lorsque le calcul du module doit produire un seul résultat. Remarque:

nous avons déjà rencontré une fonction baptisée SQRT qui appliquée au paramètre N fournit la racine

carrée de N. Comme c'est une fonction, nous pouvons écrire

X := SQRT (4); et X contient alors la valeur 2.

L'avant-dernière ligne du module présuppose que le module doit être à nouveau exécuté, mais avec les

paramètres actuels moins un. La figure 2.3 illustre l'exécution du module lorsque N est égal à 3. La figure

montre comment le calcul de factorielle (3) implique le calcul de factorielle (2) qui à son tour implique

celui de factorielle (1). Les boîtes illustrent les trois exécutions requises du module et les nombres à droite

de chaque boîte représentent les résultats cumulatifs après chaque exécution.

L'algorithme 2.27 de calcul de la factorielle de N, est exprimé en termes d'un algorithme de calcul de la

factorielle de N - 1. C'est pourquoi on peut considérer que l'algorithme est exprimé en référence à lui-

34

même, son entrée N devant être remplacée par N - 1. Le terme utilisé pour cette forme d'expression est la

récursion: un algorithme récursif est un algorithme qui s'appelle lui-même.

On évite le mouvement apparent de retour sur soi-même de la récursion en s'assurant que l'entrée des

appels récursifs successifs devient progressivement plus "simple". On doit alors rencontrer un cas limite

dans lequel l'entrée est si "simple" que le traitement peut être effectué sans qu'il n'ait plus à s'appeler lui-

même. Par exemple, l'entrée N de l'algorithme 2.27 devient progressivement plus élémentaire en étant

réduite de 1 à chaque appel successif. A la limite, quand N est réduit à 1, l'exécution est directe et ne

nécessite pas d'autres appels. Le cas limite peut être considéré comme une issue de secours qui assure en

tout état de cause la fin de l'exécution. Tout algorithme récursif doit comporter une issue de secours et

l'entrée doit être simplifiée progressivement jusqu'à pouvoir atteindre cette issue de secours.

factorielle (3):

6

Figure 2.3: Calcul de factorielle (3) par l'algorithme 2.27

Voici un autre exemple de récursion avec le problème de l'inversion de l'ordre des lettres d'un mot (star

en rats). Ce problème peut être résolu en ôtant la première lettre du mot, en inversant le reste du mot et

en ajoutant ensuite la lettre ôtée. La base d'un tel algorithme peut être

ôter la première lettre

inverser le reste du mot

ajouter la lettre ôtée

L'algorithme est nettement récursif puisqu'il décrit comment inverser une séquence de lettres en termes

d'inversion d'une séquence plus courte de lettres. A chaque appel récursif, une seule lettre est ôtée de la

séquence à inverser, ce qui simplifie progressivement l'entrée de l'algorithme. Dans le cas limite, lorsque

la séquence ne comporte plus que la première lettre, l'issue réside dans le fait qu'une seule lettre à inverser

équivaut à l'écrire. D'où cet algorithme complet pour inverser une séquence de lettres.

if 3 = 1

then ... else factorielle := factorielle (2) * 3;

if 2 = 1

then ...

else factorielle := factorielle (l) * 2;

if 1=1

then factorielle := 1

else ...

2

1

6

35

procedure inverser (séquence);

{inverse la séquence donnée de lettres}

begin

if séquence ne contient qu'une lettre

then l'écrire

else

begin

ôter la première lettre de la séquence;

inverser (reste de la séquence);

ajouter la lettre ôtée;

end;

end; (2.28)

La figure 2.4 illustre l'exécution du module ayant comme entrée "star". La sortie cumulative de chaque

appel est inscrite à droite entre parenthèses.

inverser "star"

if "star" contient une lettre

then...

else ôter "s"

inverser "tar"

if "tar" contient une lettre

then...

else ôter "t"

inverser "ar"

if "ar" contient une lettre

then...

else ôter "a"

inverser "r"

if "r" contient une lettre

then l'écrire ( r )

else...

ajouter "a" ( ra )

ajouter "t" ( rat )

ajouter "s" ( rats )

Figure 2.4: Inversion du mot "star" par l'algorithme 2.28

36

Les deux algorithmes 2.27 et 2.28 peuvent être remplacés par des algorithmes itératifs, ce qui sous-entend

que la récursion n'est qu'un cas particulier d'itération. Il peut être démontré que pour tout algorithme

utilisant la récursion, il existe un algorithme équivalent qui utilise l'itération. Malgré tout, un algorithme

récursif décrivant un processus particulier est souvent beaucoup plus concis qu'un itératif, et dans de

nombreux exemples, il est beaucoup plus facile à déterminer et à comprendre. Dans les paragraphes

suivants nous en donnerons deux exemples. Le premier est un algorithme récursif plus concis que son

équivalent itératif. C'est une version récursive de l'algorithme d'Euclide permettant de trouver le plus

grand commun diviseur de deux entiers positifs: la version itérative est l'algorithme 2.14 de la section 2.6.

PGCD (x, y) = PGCD (y, reste de x DIV y) à moins que y = 0, auquel cas PGCD (x, y) = x.. Cette relation

conduit immédiatement à l'algorithme récursif suivant:

function PGCD (X, Y);

{Calcule le PGCD des entiers positifs X et Y}

begin

if Y = 0

then PGCD := X

else PGCD := PGCD (Y, MOD (X, Y);

end; (2.29)

Notez que l'exécution récursive de l'algorithme se termine lorsque Y est réduit à zéro, auquel cas le résultat

requis est X. La figure 2.5 illustre l'exécution de l'algorithme du PGCD de 24 et de 9.

calculer PGCD (24, 9)

if 9 = 0

then...

else calculer PGCD (9, 6) ( 3 )

if 6 = 0

then...

else calculer PGCD (6, 3) ( 3 )

if 3 = 0

then...

else calculer PGCD (3, 0) ( 3 )

if 0 = 0

then résultat est 3 ( 3 )

else...

Figure 2.5: Calcul du PGCD (24,9) par l'algorithme 2.29

37

Pour le second exemple, il est plus facile de dégager un algorithme récursif qu'un algorithme itératif. C'est

un cas célèbre, connu sous le nom de la tour de Hanoï. Selon la légende, des moines bouddhistes de Hanoï

furent chargés pendant plusieurs années de la mission suivante. Les prêtres disposaient de trois barres

verticales et d'un ensemble de 64 disques circulaires de tailles différentes. Chaque disque comportait un

trou central lui permettant de s'emboîter exactement à la barre. Au départ, les disques étaient empilés par

ordre de taille décroissante sur une seule barre, de telle sorte qu'ils formaient une tour comme le montre

la figure 2.6. Les prêtres avaient pour tâche de les transférer sur l'une des autres barres en ne déplaçant

qu'un disque à la fois et en s'assurant qu'aucun disque n'était posé sur un disque plus petit. Ce transfert

devait permettre une élévation spirituelle et les prêtres croyaient que son achèvement coïnciderait avec la

fin du monde !

Puisque la stratégie de déplacement des disques n'est pas évidente et puisqu'une erreur coûterait des années

de travail, les prêtres doivent se conformer scrupuleusement à un algorithme qui décrit comment résoudre

leur problème. Un tel algorithme peut être décrit de la manière suivante.

Figure 2.6: La tour de Hanoï

Appelons a, la barre qui maintient la barre initiale; b, celle sur laquelle la tour doit être transférée et c la

troisième. Etant donné les règles gouvernant le transfert des disques, il apparaît que la seule façon de

déplacer le plus grand disque de la tour initiale afin de former la base de la nouvelle tour est d'ôter les 63

disques supérieurs. La seule place que ces 63 disques puissent occuper est la troisième barre. L'algorithme

pour transférer la tour de la barre a vers b est le suivant:

transférer les 63 disques supérieurs de a à c;

déplacer le disque inférieur de a à b;

transférer les 63 disques de c à a;

L'algorithme décrit le transfert de 64 disques entre deux barres en termes du transfert de 63 disques entre

deux barres. De même, le transfert de 63 disques entre deux barres peut être décrit en termes du transfert

de 62 disques entre deux barres et ainsi de suite. En généralisant ce problème, l'algorithme permettant de

transférer N disques d'un support de départ à un support d'arrivée (étant donné que le troisième support

est appelé intermédiaire ) est

38

transférer N-1 disques du départ sur l'intermédiaire;

déplacer 1 disque du départ sur l'arrivée;

transférer N-1 disques de l'intermédiaire sur l'arrivée;

Cet algorithme est nettement récursif et le nombre de disques à déplacer à chaque appel récursif est

successivement réduit de 1. On peut sortir de ces appels successifs lorsqu'il ne reste plus qu'un seul disque

à transférer entre deux barres. Dans ce cas, il ne reste plus qu'à l'ôter et le déplacer. D'où cet algorithme

complet pour transférer N disques d'une barre de départ sur une barre d'arrivée:

procedure déplacertour (N, départ, arrivée, intermédiaire);

{déplace une tour de N disques du départ à l'arrivée en utilisant l'intermédiaire}

begin

if N = 1

then déplacer un disque du départ sur l'arrivée

else

begin

déplacertour (N - 1, départ, intermédiaire, arrivée);

déplacer un disque du départ sur l'arrivée;

déplacertour (N - 1, intermédiaire, arrivée, départ);

end;

end; (2.30)

L'exécution de l'algorithme lorsque N = 64 mène à deux exécutions avec N = 63, chacune desquelles

menant à deux exécutions avec N = 62 et ainsi de suite. C'est pourquoi le transfert total de la tour nécessite

264 - 1 exécutions de l'algorithme. Puisqu'un seul disque est déplacé à chaque exécution, le nombre total

de déplacements à effectuer est égal à 264 - 1. Si les prêtres peuvent déplacer un disque par seconde sans

jamais faire une erreur, le temps nécessaire au transfert total devra être de l'ordre de 600 000 000 000

d'années ! On peut en outre prouver qu'il n'existe aucun algorithme plus rapide.

Dès à présent, il est clair qu'il existe une stratégie utile permettant d'élaborer des algorithmes récursifs. Le

premier élément de la stratégie est de déterminer comment la procédure à exécuter peut être exprimée de

manière récursive; autrement dit comment la procédure peut être exprimée en termes de procédures

similaires qui diffèrent de la procédure initiale par une entrée simplifiée (par exemple, un nombre plus

petit ou encore une séquence plus courte). Le second élément consiste à assurer un cas limite lorsque

l'entrée est réduite au point que l'appel récursif n'est plus nécessaire. Cette limitation sert ensuite de sortie

hors de la récursion.

39

CHAPITRE 3 : STRUCTURES DE DONNEES

3.1 INTRODUCTION

Nous nous sommes principalement consacrés jusqu'à présent à la structure de contrôle des algorithmes,

c'est-à-dire aux constructions qui peuvent être utilisées pour contrôler l'ordre et les circonstances dans

lesquels les étapes individuelles d'un algorithme sont exécutées. Le choix de constructions de contrôle

appropriées est une partie essentielle du développement d'un algorithme, mais qui ne peut être accomplie

sans considérer l'information (ou les données) que l'algorithme manipule. En général, les données

manipulées par un algorithme ne sont pas simplement un ensemble quelconque d'éléments sans rapport

les uns avec les autres, mais consistent plutôt en informations reliées les unes aux autres.

Comme exemple, considérons un algorithme utilisé pour avoir une information constante sur le personnel

employé dans une entreprise. Les données manipulées par l'algorithme comportent des noms, des adresses,

des âges, des grades hiérarchiques et ainsi de suite. Ces éléments ne sont pas seulement des informations,

mais ont des relations logiques entre eux. Un rapport évident entre un nom, une adresse, un âge et une

position hiérarchique est que ces éléments concernent une même personne. Les données ne sont donc pas

des éléments isolés mais un ensemble d'enregistrements dont chacun contient toute l'information

concernant un employé donné. Ces enregistrements peuvent avoir des rapports logiques entre eux,

reflétant les rapports logiques entre les personnes qu'ils décrivent. Par exemple, certains employés (et les

enregistrements les décrivant) peuvent être associés parce qu'ils travaillent dans le même département

d'une entreprise, ou un employé peut être associé à plusieurs autres parce qu'il est leur supérieur

hiérarchique immédiat. Les données qui sont organisées pour refléter les rapports logiques entre leurs

éléments sont dites structurées. Les éléments de données et leurs relations mutuelles donnent ensemble

ce qu'on appelle une structure de données.

Nous avons déjà utilisé des structures de données précédemment sans les mentionner explicitement. Par

exemple les algorithmes de tri que nous avons développés fonctionnaient tous sur une liste de noms. Une

telle liste est une structure de données dans laquelle les noms individuels sont reliés entre eux par leur

ordre physique: un nom vient soit avant soit après d'autres noms de la liste. Les algorithmes de tri

transforment une liste en une autre en modifiant l'ordre physique des noms afin qu'ils coïncident avec leur

ordre alphabétique. Une liste constitue un exemple d'un type particulièrement commun de structure de

données, connu sous le nom de séquence. Une séquence est un ensemble d'éléments ordonné de telle

manière que tout élément à l'exception d'un seul (le premier) ait un prédécesseur. Quelques exemples:

40

1) Un mot, qui est une séquence de lettres.

2) Un texte, qui est une séquence de mots séparés par des espaces et des symboles de ponctuation.

3) Un nombre, qui est représenté sous forme écrite par une séquence de chiffres.

4) Un train, qui est une séquence de wagons (précédés d'une locomotive).

5) Un annuaire téléphonique, qui est une séquence d'enregistrements, dont chacun contient un nom, une

adresse et un numéro de téléphone.

Une séquence est une structure particulièrement utile pour des éléments devant être traités l'un après

l'autre. Un algorithme de description de tels traitements peut se présenter sous la forme globale

commencer au début de la séquence;

while la fin de la séquence n'est pas atteinte do

traiter l'élément suivant;

Si l'on sait que la séquence contient au moins un élément, la boucle while peut être remplacée par une

boucle repeat. Si le nombre d'éléments est connu, une itération indéfinie peut être remplacée par une

itération définie. Les algorithmes 2.13 et 2.19 sont des exemples d'algorithmes agissant sur des séquences

selon ce schéma général.

En général, les algorithmes décrivent des processus qui transforment une structure de données (l'entrée)

en une autre structure de données (la sortie). Parfois, comme lorsqu'il s'agit d'un tri à bulles (algorithme

2.20), la transformation peut être effectuée par simple manipulation des données à l'intérieur du cadre de

la structure de données initiale. Cependant, dans de nombreux cas, des structures de données

intermédiaires peuvent être nécessaires. Le développement de l'algorithme dans ces cas est intimement lié

au choix d'une structure de données appropriée.

Avant d'aborder la description des structures, il convient de s'intéresser aux éléments d'information qui la

composeront. Ces éléments (ces données) peuvent être de types différents comme des entiers, des réels,

des booléens, des caractères, … Les types constituent une description du format de représentation interne

des données en machine, et ils sont par là-même un outil d’abstraction, puisqu’ils libèrent le programmeur

du souci de la représentation physique des données. Ainsi, il n’est pas nécessaire de savoir comment est

représentée une variable de type char ou boolean, voire string, du moment que l’on sait la manipuler de

la manière convenue. Nous introduirons donc le concept de type de données qui nous conduira à

sélectionner des types de données primitifs grâce auxquels nous pourrons alors construire des structures

de données. Dans ce chapitre 3, nous verrons les structures de données statiques élémentaires. Le chapitre

41

4 présentera les bases des structures dynamiques. Le chapitre 5 abordera des structures de données plus

élaborées implémentées "statiquement" ou "dynamiquement".

3.2 LE CONCEPT DE TYPE DE DONNÉES

En mathématiques, on classe couramment les variables suivant un certain nombre de caractéristiques. On

peut faire une claire distinction entre les variables réelles, complexes ou logiques, ou entre les variables

représentant des valeurs individuelles, des ensembles de valeurs ou des ensembles d'ensembles, ou encore

entre les fonctions, les ensembles de fonctions, etc. Cette notion de classification est au moins aussi

importante dans le domaine du traitement des données. Nous poserons donc en principe que chaque

constante, chaque variable, expression ou fonction est d'un certain type. Ce type caractérise

essentiellement l'ensemble des valeurs auquel la constante appartient, ou qui peuvent être prises par une

variable ou une expression, ou encore qui peuvent être engendrées par une fonction.

Dans les textes mathématiques, on peut souvent déduire le type d'une variable de par la façon dont elle

est écrite, sans avoir besoin de recourir au contexte; ceci n'est pas possible en informatique où on n'utilise

qu'un seul jeu de caractères. Une règle générale est donc d'expliciter le type associé lors de la déclaration

d'une constante, d'une variable ou d'une expression, cette déclaration précédant, dans le texte, toute

manipulation de cette constante, variable ou expression. Cette règle est particulièrement importante si l'on

considère le fait qu'un compilateur doit choisir la représentation de l'objet en mémoire: la quantité de

mémoire réservée à une variable sera calculée en tenant compte de l'ensemble des valeurs que la variable

peut prendre. C'est souvent la clé de la réalisation efficace d'un algorithme. Les principales caractéristiques

du concept de type sont:

1) Un type de donnée détermine l'ensemble des valeurs auquel appartient une constante, ou qui peuvent

être prises par une variable ou une expression, ou qui peuvent être engendrées par un opérateur ou une

fonction.

2) Le type de la valeur d'une constante, d'une variable ou d'une expression peut être déduit de sa forme

ou de sa déclaration sans qu'il soit nécessaire d'exécuter un quelconque traitement.

3) Tout opérateur et toute fonction exige des arguments d'un certain type et produit un résultat d'un type

donné. Si un opérateur admet des arguments de types différents (par exemple + peut admettre des

entiers ou des réels), le type du résultat peut être déterminé à l'aide de règles spécifiques du langage.

En conséquence, un compilateur peut utiliser cette information sur les types pour vérifier la légalité de

certaines constructions. C'est ainsi que l'affectation erronée d'une valeur booléenne à une variable

42

arithmétique peut être détectée avant l'exécution du programme. Cette redondance dans le texte du

programme est extrêmement utile: c'est une aide au développement des programmes et c'est le principal

avantage des bons langages de haut-niveau par rapport au langage machine.

La théorie présentée ici implique certaines méthodes de définition des types de données. Dans la plupart

des cas, on définit de nouveaux types en termes de types déjà définis. Les valeurs de ces nouveaux types

sont souvent des agrégats de valeurs qui appartiennent à des types constituants déjà définis; on dit qu'ils

sont structurés. S'il n'y a qu'un seul type constituant, on a alors affaire à un type de base. Le nombre de

valeurs distinctes appartenant à un type T est son cardinal qu'il est utile de connaître pour calculer la

quantité de mémoire nécessaire à la représentation d'une variable X de type T, ce qu'on note X : T.

Puisque les types constituants peuvent également être structurés, on peut construire une structure

hiérarchique de types, étant entendu que les composants ultimes de la structure sont atomiques. Il est donc

nécessaire d'avoir une notation pour ces types primitifs non structurés. La méthode la plus évidente est

d'énumérer les valeurs constituant le type. Si, par exemple, on a besoin de manipuler des figures

géométriques planes, on peut définir un type primitif forme, dont les valeurs seront rectangle, carré,

ellipse et cercle. Mais en dehors de ces types définis par le programmeur, il en existe d'autres prédéfinis

comme, par exemple, pour représenter les nombres entiers et les valeurs logiques. Si un ordre est défini

sur les valeurs de ces types, on dit que le type est ordonné ou scalaire. Remarque: dans le cas d'une

énumération explicite des valeurs, l'ordre est défini par la position des valeurs dans l'énumération.

Il est donc possible de définir des types primitifs et de construire des types composés, structurés avec un

nombre quelconque d'imbrications. En pratique cependant, il ne suffit pas d'avoir une seule méthode

générale pour combiner des types constituants en une structure. Etant donné les problèmes concrets de

représentation des données, un langage de programmation offre, en général, plusieurs méthodes de

structuration. Au sens mathématique du terme, elles sont équivalentes: elles diffèrent simplement par les

opérateurs disponibles pour sélectionner les composants des structures. Les méthodes de structuration de

base que nous présentons ici sont le tableau, le record, l'ensemble et la suite.

On introduit des variables et des types de données pour faire des calculs; c'est pourquoi on doit leur

associer des opérateurs. Pour chaque type de données standard, un langage de programmation propose un

ensemble d'opérateurs standard, primitifs, de même qu'à chaque méthode de structuration correspondent

une opération spéciale et une notation spéciale de sélection d'un composant. On considère souvent que

l'art de la programmation réside essentiellement dans la composition des opérations; il deviendra vite

évident qu'une bonne composition des données est tout aussi fondamentale et essentielle.

43

Les opérateurs de base les plus importants sont la comparaison et l'affectation, c'est-à-dire le test sur

l'égalité (et sur l'ordre dans le cas de types ordonnés), et l'injonction de mettre à égalité. La différence

fondamentale entre ces deux opérations est soulignée par la distinction claire entre les deux notations

respectées tout au long de ce texte:

Test d'égalité X = Y (expression dont la valeur est Vrai ou Faux)

Affectation à X X := Y (expression qui fait que X devient égal à Y)

Ces opérateurs sont définis pour la plupart des types de données, mais on doit savoir que leur exécution

peut provoquer, dans le cas de données importantes et très structurées, un calcul assez complexe.

Pour ce qui est des types de données primitifs, nous supposerons non seulement l'existence de l'affectation

et de la comparaison, mais encore celle d'un ensemble d'opérateurs pour créer (calculer) de nouvelles

valeurs. Nous introduirons donc les opérations standard de l'arithmétique des types numériques et les

opérateurs élémentaires de la logique des propositions pour les valeurs logiques.

3.3 LES TYPES DE DONNÉES PRIMITIFS

On peut définir un nouveau type de données primitif en énumérant les valeurs distinctes qui le composent.

On a alors un type énuméré dont la définition est de la forme:

Type T = (c1, c2, ..., cn);

T est l'identificateur du nouveau type tandis que les ci sont les nouveaux identificateurs de constantes. Le

cardinal de T est card (T) = n.

Exemples:

Type forme = (rectangle, carré, ellipse, cercle);

Type couleur = (rouge, jaune, vert);

Type sexe = (mâle, femelle);

Type Boolean = (FALSE, TRUE);

Type jourdelasemaine = (lundi, mardi, mercredi, jeudi, vendredi, samedi, dimanche);

La définition de chacun de ces types n'introduit pas seulement un nouvel identificateur de type mais aussi

l'ensemble des identificateurs dénotant les valeurs de ce nouveau type. On peut dès lors utiliser ces

identificateurs comme des constantes dans tout le programme, ce qui améliore grandement la

compréhension du texte. Si, par exemple, nous définissons les variables s, d, r et b par:

44

Var s: sexe;

Var d: jour de la semaine;

Var b: BOOLEAN;

Nous pouvons écrire:

s := mâle;

d := dimanche;

b := TRUE;

ce qui est beaucoup plus compréhensible que

s := 1; d := 7; b := 2;

où l'on suppose que s, d et b sont des entiers et que l'on fait correspondre une constante de la définition à

l'ordre de son apparition dans l'énumération. De plus, un compilateur peut vérifier que l'on ne fait pas un

mauvais usage d'un opérateur. Par exemple, étant donné la définition de s que nous avons vue, l'instruction

s := s + 1 n'a aucun sens.

3.4 TYPES PRIMITIFS STANDARD

Les types primitifs standard sont ceux que l'on trouve tout faits sur la plupart des ordinateurs. Ce sont les

nombres entiers, les valeurs logiques et l'ensemble des caractères imprimables. Sur beaucoup

d'ordinateurs, on trouve aussi les nombres fractionnaires, associés aux opérations arithmétiques standard.

Nous dénoterons ces types par les identificateurs

INTEGER, CARDINAL, REAL, BOOLEAN, CHAR

Le type INTEGER comprend un sous-ensemble des nombres entiers, sous-ensemble dont la taille varie

d'une machine à une autre. Si un ordinateur utilise n bits pour représenter un entier en complément à 2,

les valeurs possibles de x doivent satisfaire -2n-1 x < 2n-1. On suppose que toutes les opérations sur les

données de ce type sont exactes et qu'elles correspondent aux lois ordinaires de l'arithmétique, tout calcul

étant interrompu lorsque le résultat est en dehors du sous-ensemble représentable (c'est ce qu'on appelle

un dépassement de capacité). Les opérateurs standard représentent les quatre opérateurs arithmétiques de

base: l'addition (+), la soustraction (-), la multiplication (*) et la division (/, DIV pour la division modulo).

Le type REAL dénote un sous-ensemble des nombres réels. Alors que l'arithmétique utilisée avec des

opérandes du type INTEGER est supposée conduire à des résultats exacts, celle qui s'applique aux valeurs

du type REAL peut conduire à des résultats légèrement faussés en raison des erreurs d'arrondi dues aux

calculs portant sur un nombre limité de chiffres.

45

Les deux valeurs du type standard BOOLEAN sont notées TRUE (vrai) et FALSE (faux). Les opérateurs

booléens sont la conjonction logique, la disjonction et la négation dont les valeurs sont définies dans la

table 3.1. La conjonction est désignée par le symbole AND, la disjonction par OR et la négation par NOT

(ou -). Notez que les comparaisons sont des opérations produisant un résultat de type BOOLEAN. On

peut donc affecter le résultat d'une comparaison à une variable, ou utiliser ce résultat comme opérande

d'un opérateur logique dans une expression booléenne. Par exemple, étant donné les variables booléennes

p et q et les variables entières x = 5, y = 8, z = 10, les deux affectations

p := x = y;

q := (x < y) AND (y < z);

donnent p = FALSE et q = TRUE.

p q p OR q p AND q NOT p

TRUE TRUE TRUE TRUE FALSE

TRUE FALSE TRUE FALSE FALSE

FALSE TRUE TRUE FALSE TRUE

FALSE FALSE FALSE FALSE TRUE

Table 3.1 Les opérateurs booléens.

Le type standard CHAR comprend l'ensemble des caractères imprimables.

3.5 TYPES INTERVALLE

On constate souvent qu'une variable doit prendre ses valeurs dans un intervalle de valeurs bien déterminé.

Ceci peut être exprimé en écrivant que la variable est d'un type intervalle comme:

Type T = [min .. max];

dans lequel min et max sont des expressions qui spécifient les limites de l'intervalle. Notez que ces

expressions n'admettent comme opérandes que des constantes.

Exemples:

Type an = [1970 .. 2010];

Type lettre = ["A" .. "Z"];

Type indice = [0 .. 2* N-1];

Etant donné les variables

Var Y : an;

Var L : lettre;

46

les affectations Y := 2000 et L := "K" sont correctes, alors que Y := 1291 et L := "9" ne le sont pas. Mais

la légalité d'une affectation ne peut être vérifiée sans exécution du programme que si la valeur affectée est

une constante. Lorsqu'on a des affectations comme Y := N et L := C, dans lesquelles N est du type

INTEGER et C du type CHAR, on ne peut rien déduire du texte du programme. Il faut alors réaliser les

vérifications à l'exécution, ce que font certains systèmes qui utilisent la redondance de l'information pour

détecter d'éventuelles erreurs. Comme nous l'avons vu, ceci est une des principales raisons de l'utilisation

des langages de haut-niveau.

3.6 LA STRUCTURE DE TABLEAU

Le tableau (array) est probablement la structure de données la mieux connue; dans certains langages c'est

même la seule disponible. Un tableau est formé de composants qui sont tous du même type: son type de

base; c'est donc une structure homogène. Le tableau est aussi une structure dite à accès aléatoire: à un

instant donné, on peut sélectionner n'importe quel composant. Pour désigner un composant, on ajoute au

nom de la structure ce qu'on appelle un indice, qui doit être une valeur du type défini comme le type

d'indice du tableau. La définition d'un type de tableau T spécifie donc à la fois un type de base T0 et un

type d'indice TI.

Type T = array [TI] of T0;

Exemples:

Type Ligne = array [1 .. 5] of REAL;

Type Carte = array [1 .. 80] of CHAR;

Type Alfa = array [0 .. 15] of CHAR;

Une valeur particulière de la variable

Var X: Ligne;

dont tous les composants satisfont l'équation Xi =2-i est donnée en figure 3.1.

0.5 0.25 0.125 0.0625 0.03125

Figure 3.1 : Tableau de type Ligne.

On sélectionne un composant individuel d'un tableau à l'aide d'un indice. Etant donné une variable tableau

X, on note un sélecteur de tableau en faisant suivre le nom du tableau par l'indice i du composant: X[i].

47

La façon la plus pratique de travailler sur des tableaux, et surtout sur des grands tableaux, est de modifier

sélectivement certains composants et non de construire des valeurs structurées complètement nouvelles.

On peut mieux exprimer ceci en considérant une variable tableau comme un tableau de variables et en

autorisant des affectations à des composants sélectionnés, comme X[i] := 0.125. Même si une mise à jour

sélective ne modifie la valeur que d'un seul composant, d'un point de vue conceptuel, on doit considérer

que la valeur composite toute entière a été modifiée.

Le fait que les indices de tableau, c'est-à-dire les noms des composants du tableau, doivent être d'un type

(scalaire) défini a une conséquence très importante: on peut calculer des indices. Il est possible de

substituer à un indice de valeur constante une expression générale d'indice, le résultat de l'évaluation de

cette expression permettant d'identifier le composant sélectionné. Cette généralité risque de provoquer

une des erreurs de programmation les plus fréquentes: l'indice ainsi calculé peut être extérieur à l'intervalle

des valeurs du type d'indice du tableau.

Le cardinal d'un type structuré, c'est-à-dire le nombre de valeurs de ce type, est le produit des cardinaux

de ses composants. Puisque tous les composants d'un type tableau T sont du même type de base T0, nous

obtenons, avec un type d'index TI, card (T) = card (T0)card(TI).

Les constituants de types tableau peuvent être eux-mêmes structurés. Une variable tableau dont les

composants sont des tableaux est appelée une matrice. C'est ainsi que

Var M : array [1 .. 10] of Ligne;

est un tableau de dix composants (lignes), chacun composé de cinq valeurs de type REAL; c'est donc une

matrice 10 x 5 de composants réels. On peut concaténer les sélecteurs, de sorte que M[i][j] désigne le

jème composant de la ligne M[i], qui est le ième composant de M. On abrège souvent cette notation en

M[i, j], et dans le même esprit la déclaration

Var M : array [l..10] of array [l..51 of REAL;

peut s'écrire de façon plus concise

Var M : array [l .. 10, 1 .. 5] of REAL;

Si l'on doit exécuter la même opération sur tous les composants d'un tableau ou sur plusieurs composants

consécutifs, on peut le souligner en utilisant l'instruction for, comme le montre l'exemple suivant dans

lequel on calcule d'abord la somme des éléments d'un tableau pour rechercher ensuite l'élément maximum

de ce tableau déclaré comme:

48

Var A : array [0 .. N - 1] of INTEGER;

begin

SOMME := 0;

for i := 0 to N-1 do SOMME := A [i] + SOMME;

K := 0;

MAX := A [0];

for i := 1 to N-1 do

if MAX < A [i] then begin K := i;

MAX := A [K];

end;

end.

Remarque: en PASCAL, un programme commence avec les définitions de types suivies des déclarations

de variables, suivies du corps de programme encadré par les mots begin et end. On note la fin du

programme avec un point (.) derrière le dernier end.

3.7 LA STRUCTURE DE RECORD

La façon la plus générale d'obtenir des types structurés est de juxtaposer des éléments de types arbitraires,

qui peuvent eux-mêmes être structurés. En mathématiques, par exemple, les nombres complexes sont

composés de 2 nombres réels, tandis que les coordonnées d'un point sont formées de deux nombres ou

plus, suivant le nombre de dimensions de l'espace considéré. En informatique, on doit souvent décrire un

individu à l'aide de certaines caractéristiques comme son nom, son prénom, sa date de naissance, …

En mathématiques, on dit d'un type composé qu'il est le produit cartésien des types de ses composants;

ceci provient du fait que l'ensemble des valeurs définies par ce type composé est formé de toutes les

combinaisons possibles des valeurs prises dans chacun des ensembles définis par chaque constituant. Le

nombre de ces combinaisons est le produit du nombre des éléments de chaque ensemble constituant, le

cardinal du type composé est donc le produit des cardinaux des types constituants.

En informatique, les types composés, comme ceux correspondant à la description de personnes ou d'objets,

se trouvent souvent dans les fichiers ou les banques de données et permettent d'enregistrer les

caractéristiques d'une personne ou d'un objet. C'est pourquoi le terme anglais record (enregistrement) s'est

imposé plutôt que produit cartésien. En général, un type record T dont les composants sont des types TI,

T2,.... Tn est défini par:

49

Type T = record

s1 : T1;

s2 : T2;

...

sn : Tn;

end;

card (T) = card (T1) * card (T2) * card (…) * card (Tn)

Exemples:

Type Complexe = record

re : REAL;

im: REAL;

end;

Type Date = record

jour : [1 .. 31];

mois : [1 .. 12];

an : [1950 .. 2050];

end;

Type Individu = record

nom, prénom : Alfa;

naissance : Date;

sexe : (homme, femme);

statutmar : (célibataire, marié, veuf);

end;

On peut alors définir, par exemple, les variables:

Var Z : Complexe;

D : Date;

P : Individu;

Les identificateurs s1, s2, ..., sn introduits dans la définition du type record sont les noms que l'on donne

aux composants individuels des variables de ce type. Comme les composants des records s'appellent des

champs, ces noms sont appelés identificateurs de champs. On les utilise dans les sélecteurs de records

appliqués aux variables structurées en records. Etant donné une variable X : T, son ième champ est noté

X.si. La modification sélective de X est réalisée en utilisant la même notation de sélecteur pour la partie

gauche d'une affectation: X.si := e;

50

dans laquelle e est une valeur (une expression) de type Ti. Si l'on a, par exemple, les variables records Z,

D et P déclarées ci-dessus, voici quelques sélecteurs de composants :

Z.im (de type REAL)

D.mois (de type [l .. 12])

P.nom (de type Alfa)

P.naissance (de type Date)

P.naissance. jour (de type [l .. 31])

L'exemple du type Individu montre qu'un composant de record peut être lui-même structuré. On peut donc

concaténer les sélecteurs. On peut aussi utiliser une imbrication de types structurés suivant différentes

méthodes. Par exemple le ième composant d'un tableau A, composant d'une variable record R est noté

R.A[i], et le composant de nom de sélecteur S du ième composant (à structure de record) du tableau A est

noté A[i].S

C'est une des caractéristiques du produit cartésien que de contenir toutes les combinaisons des éléments

des types constituants. En pratique, certaines de ces combinaisons n'ont aucun sens. C'est ainsi que le type

date comprend aussi bien le 31 avril que le 29 février 2015, deux dates qui n'existent pas.

Le programme suivant indique l'utilisation qu'on peut faire de variables de type record. On compte le

nombre de personnes représentées dans le tableau FAMILLE qui sont à la fois femme et célibataire:

Var COMPTEUR : WORD;

FAMILLE : array [1 .. N] of Individu;

begin

COMPTEUR := 0;

for i := 1 to N do

if (FAMILLE [i].sexe = femme) AND (FAMILLE [i].statutmar = célibataire) then

COMPTEUR := COMPTEUR + 1;

end.

La structure de record et la structure de tableau sont toutes les deux à accès aléatoire. Le record est plus

général puisqu'il n'implique pas que tous les types des composants soient identiques, mais le tableau est

plus souple puisque les sélecteurs de composants peuvent être des valeurs calculées, alors que les

sélecteurs des composants de record sont les identificateurs de champ.

51

3.8 LA STRUCTURE DE SUITE

La quatrième méthode élémentaire de structuration est la suite. Un type suite pourrait être déclaré par:

Type T = SEQUENCE of T0;

En PASCAL, on remplace le mot SEQUENCE par file. La forme de cette expression montre que tous les

éléments de la suite sont de même type, le type de base T0 de la suite. Nous noterons une suite S de n

éléments: S = < s0, s1, ..., sn > n étant la longueur de la suite. Cette structure paraît proche du tableau.

La différence réside dans ce que le nombre d'éléments d'un tableau est fixé lors de sa déclaration, alors

que le nombre d'éléments d'une suite est quelconque et peut varier pendant l'exécution du programme.

Bien que chaque suite ait, à tout instant, une longueur fixe et déterminée, on doit considérer que le cardinal

du type suite est infini puisqu'il n'y a pas de limite fixée à la longueur des variables de type suite. Une

conséquence de ce cardinal infini est qu'il est impossible d'allouer une quantité de mémoire donnée à ces

variables. Le stockage devra être géré pendant l'exécution du programme, au fur et à mesure que la suite

grandit, en récupérant éventuellement de l'espace mémoire lorsque la suite diminue. En tout cas, on doit

utiliser un schéma d'allocation dynamique de mémoire.

La contrainte que nous avons mentionnée consiste à n'utiliser que l'accès séquentiel. Le parcours d'une

suite se fait en passant d'un élément à son successeur immédiat, et une suite est engendrée en ajoutant

répétitivement un élément à la fin. Conséquence immédiate de cet accès séquentiel: il n'est pas possible

d'accéder directement à un élément sauf si c'est celui qui est prêt à être examiné. C'est cette discipline

d'accès qui distingue fondamentalement les suites des tableaux. Si la restriction est sérieuse, les avantages

sont grands: simplicité de la gestion de la mémoire, mais surtout possibilité d'utiliser des tampons

lorsqu'on fait passer des données de ou vers la mémoire secondaire. L'accès séquentiel nous permet

d'alimenter un flot de données dans un tube, appelé tampon (buffer), placé entre les deux différents médias

puis envoyer le contenu de ce tampon lorsqu'il est plein. Ceci permet une utilisation bien plus efficace de

la mémoire secondaire. Si l'on n'utilise que l'accès séquentiel, le tampon convient pour toutes les suites et

tous les médias. Il peut donc être construit au niveau du système pour un usage général et le programmeur

n'a pas besoin de l'incorporer dans son programme. Un tel système est souvent appelé système de fichiers

parce qu'en raison de la masse de données, les unités à accès séquentiel sont généralement utilisées pour

un stockage permanent et qu'elles conservent donc les données même lorsque l'ordinateur est éteint.

Nous résumerons ce qui précède de la manière suivante: les tableaux, les records et les ensembles sont

des structures à accès aléatoire. On les stocke dans la mémoire principale, à accès aléatoire. Les suites

sont utilisées dans les accès séquentiels à la mémoire secondaire (disques).

52

La discipline de l'accès séquentiel est renforcée par le biais d'opérateurs dont l'utilisation est obligatoire

pour accéder aux variables de type suite. Ainsi, bien que nous puissions nous référer ici au jème élément

d'une suite en écrivant sj, ce ne sera pas possible dans un programme. L'ensemble des opérateurs doit

comporter au moins un opérateur pour engendrer la suite et un autre pour l'inspecter. Une suite étant

engendrée en ajoutant des éléments à la fin et inspectée en passant à l'élément suivant, il y a donc toujours

une certaine position qui est associée à chaque suite. Voyons un ensemble d'opérateurs de base:

1) Rewrite (S) définit S comme étant la suite vide, donc la suite de longueur 0.

2) Write (S, X) ajoute un élément de valeur X à la suite S.

3) Reset (S) place la position courante au début de S.

4) Read (S, X) affecte l'élément désigné à la variable X et avance la position à l'élément suivant.

Etant donné cet ensemble d'opérateurs sur les suites, on peut faire apparaître les schémas de programmes

suivants pour l'écriture et la lecture d'un fichier séquentiel S:

Type FICHIER = file of REAL;

Var S: FICHIER;

X: REAL;

begin

Rewrite (S);

while B do

begin

P (X);

Write (S, X);

end;

Reset (S);

while not eof (S) do

begin

Read (S, X);

Q (X);

end;

end.

B est la condition qui doit être vérifiée avant que l'instruction P de calcul du prochain élément et celle de

l'ajout de cet élément en fin de fichier soient exécutées. A la lecture, Q est l'instruction que l'on applique

à chaque élément de la suite. La condition ;not eof (S) garantit qu'il y a un prochain élément à lire dans S.

53

CHAPITRE 4 : STRUCTURES DE DONNÉES DYNAMIQUES

4.1 TYPES DE DONNEES RECURSIFS

Au chapitre 3, nous avons introduit les structures de données fondamentales que sont le tableau et le

record. On dit qu'elles sont fondamentales car elles constituent les briques de base avec lesquelles on

formera des structures plus complexes et aussi parce qu'elles sont les plus fréquentes en pratique. Le but

de la définition d'un type de données et de son utilisation pour spécifier le type de variables par la suite,

est de fixer une fois pour toutes le domaine des valeurs possibles que peuvent prendre ces variables et par

conséquent leur format en mémoire. Des variables déclarées de cette façon sont dites statiques. Il existe

toutefois de nombreux problèmes qui impliquent l'usage de structures de données bien plus complexes.

Une des caractéristiques de cette classe de problèmes est que non seulement les valeurs, mais aussi les

structures des variables changent pendant le calcul. Elles sont par conséquent dites dynamiques. Bien

entendu, les composants de telles structures sont - à un niveau de résolution donné - statiques, c'est-à-dire

d'un des types de données fondamentaux. Le présent chapitre est consacré à la construction, l'analyse et

la gestion des structures de données dynamiques.

Il est intéressant de noter qu'il existe des analogies entre les méthodes utilisées pour structurer les

algorithmes et celles utilisées pour structurer les données. Comme dans toute analogie, il y a certes des

différences, mais une comparaison entre les méthodes de structuration des programmes et de structuration

des données reste néanmoins instructive. L'instruction élémentaire, non structurée, est l'affectation de la

valeur d'une expression à une variable. L'entité correspondante dans la famille des structures de données

est le type scalaire, non structuré. Tous les deux constituent les briques de base, l'un pour les instructions

structurées, l'autre pour des types de données composites. Les structures les plus simples, obtenues par

énumération ou juxtaposition, sont l'instruction composée et la structure de record. Toutes les deux sont

formées d'un nombre fini (et habituellement petit) de composants explicitement énumérés, qui peuvent

être de natures différentes les uns des autres. Si tous les composants étaient identiques, il ne serait pas

nécessaire de les écrire chacun individuellement: on utiliserait l'instruction for ou la structure de tableau

pour indiquer la répétition en un nombre de fois connu et fini. Un choix parmi deux ou plusieurs

composants est exprimé respectivement par l'instruction conditionnelle if (ou son extension, l'instruction

case), et par la structure de record avec variante présentée ci-après. Enfin une répétition en un nombre de

fois initialement inconnu (et potentiellement infini) est exprimé par l'instruction while ou repeat. La

structure de données correspondante est la suite ou fichier, qui est la forme la plus simple qui permette de

construire des types de cardinalité infinie.

54

On peut se demander s'il existe une structure de données qui corresponde de cette manière à l'instruction

d'appel de module (procedure). La plus intéressante et la plus novatrice des propriétés de la procédure

est naturellement la récursivité. Des valeurs de tels types de données récursifs contiendraient un ou

plusieurs composants appartenant au même type que lui-même, de la même manière qu'une procédure

récursive contient un ou plusieurs appels à elle-même. Tout comme les procédures, les définitions de ces

types de données pourraient être directement ou indirectement récursifs.

Un exemple simple d'un objet bien représenté par un type de données défini récursivement est l'expression

arithmétique, telle qu'on la rencontre dans les langages de programmation. La récursivité est utilisée pour

indiquer la possibilité d'imbrication, c'est-à-dire l'utilisation des sous-expressions entre parenthèses

comme opérandes des expressions. On peut définir une expression de manière informelle de la façon

suivante:

Une expression consiste en un terme, suivi par un opérateur, suivi par un terme. (Les deux termes

constituent les opérandes de l'opérateur.) Un terme est soit une variable - représentée par un

identificateur - soit une expression entre parenthèses.

Un type de données dont les valeurs représenteraient de telles expressions peut être décrit aisément avec

les outils dont nous disposons déjà, en leur ajoutant la récursivité:

Type expression = record op : opérateur;

opd1, opd2: terme;

end;

Type terme = record (4.1)

case T : BOOLEAN of

TRUE : (id: Alfa);

FALSE : (subex : expression);

end;

Toute variable du type terme consiste donc en deux composants: le sélecteur de champ T et, si T est vrai,

le champ id, sinon le champ subex qui est lui-même une expression. Un rôle important de la partie variante

devient évident: c'est le seul moyen par lequel on peut limiter la structure de donnée récursive, et c'est

donc le compagnon indispensable de toute définition récursive. L'analogie entre les concepts de

structuration de programme et de structuration de données est très forte dans ce cas; il doit nécessairement

y avoir une instruction conditionnelle ou sélective dans toute procédure récursive afin de pouvoir terminer

l'exécution de la procédure. Et la fin de l'exécution correspond évidemment à une cardinalité finie.

55

4.2 POINTEURS

La propriété caractéristique des structures récursives qui les distingue nettement des structures

fondamentales (tableaux, records, ensembles) est leur faculté de varier de taille. Il est donc impossible

d'affecter une quantité fixe de mémoire à une structure définie récursivement, et par conséquent, il est

impossible à un compilateur d'associer une adresse spécifique aux composants de telles variables. La

technique la plus utilisée pour résoudre ce problème met en œuvre l'allocation dynamique de mémoire,

c'est-à-dire l'allocation de mémoire individuellement à chaque composant au moment où il commence à

exister lors de l'exécution du programme, plutôt qu'au moment de sa traduction. Le compilateur n'alloue

qu'une quantité fixe de mémoire, juste assez pour contenir l'adresse du composant dynamiquement alloué,

à la place du composant lui-même. Par exemple, un arbre généalogique peut être représenté par des records

isolés - et peut-être non contigus -, un par personne. Ces personnes sont alors reliées par leurs adresses

affectées respectivement aux champs associés à père et mère. Graphiquement, cette situation s'exprime le

mieux au moyen de flèches ou de pointeurs (figure 4.1)

Figure 4.1: Structures reliées par des pointeurs

Pour mettre en œuvre cette possibilité, de nombreux langages de programmation ont introduit un type de

variable appelé pointeur dont le nom signifie que l'on fait référence au composant et non à la valeur du

composant.

Insistons bien sur le fait que l'usage de pointeurs n'est qu'une technique utilisée pour réaliser des structures

de données récursives. Le programmeur n'a nullement besoin d'être conscient de leur existence. La

mémoire pourrait être allouée automatiquement lors de la première référence à un nouveau composant.

Toutefois, si la technique d'utilisation de références ou de pointeurs est rendue explicite, elle ouvre la

porte à la définition de structures de données plus générales encore que celles que l'on peut obtenir par

des définitions récursives. Il est possible en particulier de définir des structures circulaires ou

56

potentiellement infinies, ainsi que d'imposer le partage de certaines structures. Il est donc devenu courant

dans les langages de programmation avancés d'offrir la possibilité de manipuler explicitement des

références aux données en plus des données elles-mêmes. Ceci impose qu'il y ait une nette distinction de

notation entre les données et les références aux données, et donc qu'on introduise des types de données

dont les valeurs soient des pointeurs (références) vers d'autres données. La notation que nous utiliserons

pour ce faire est la suivante:

Type T = ^ T0; (4.2)

La déclaration de type (4.2) exprime le fait que les valeurs du type T sont des pointeurs vers des données

du type T0. Il est extrêmement important que le type des éléments pointés ressorte clairement de la

définition de T. Nous dirons que T est lié à T0. Ce lien distingue les pointeurs des langages de haut-niveau

des adresses des langages machine et, en accroissant la redondance de la notation, joue un point important

dans la sécurité des programmes.

Les valeurs des types pointeurs sont générées chaque fois qu'un élément de donnée est alloué

dynamiquement. Nous prendrons comme convention qu'une telle circonstance est toujours explicitement

mentionnée, ceci en opposition à la technique où l'élément est alloué la première fois qu'on y fait référence.

Pour ce faire, nous introduisons une procédure new. Etant donnée une variable pointeur P de type T,

l'instruction new (P) alloue effectivement une variable de type T0 et affecte le pointeur désignant cette

variable à P (voir Figure 4.2). On fait référence à la valeur pointeur elle-même par P (c'est-à-dire comme

valeur de la variable pointeur P). Par contre, la variable à laquelle P fait référence est désignée par P^.

P : ^ T

P^ : T

Figure 4.2: Allocation de variable dynamique

Nous avons mentionné plus haut qu'il est essentiel que tout type récursif comporte un composant sous

variante de façon à garantir une cardinalité finie. Ceci s'exprime par le schéma de déclaration (4.3).

Type T = record (4.3)

case TERMINAL : BOOLEAN of

FALSE : (S (T));

TRUE : ; {vide}

end;

57

S (T) désigne une suite de définitions de champs qui contient un ou plusieurs champs de type T, assurant

ainsi la récursivité. Toutes les structures d'un type basé sur ce modèle ont une structure d'arbre ou de liste.

Une de leurs propriétés est de contenir des pointeurs vers des composants qui ne contiennent que le

sélecteur de champ, c'est-à-dire sans aucune autre information utile. La technique de réalisation au moyen

de pointeurs suggère une manière aisée d'économiser de l'espace mémoire en incluant l'information portée

par le sélecteur dans la valeur de type pointeur elle-même. La solution la plus commode est d'étendre le

domaine des valeurs de tous les types pointeurs par une valeur unique qui ne pointe sur aucun élément.

Nous désignerons cette valeur par le symbole spécial nil, et nous supposerons acquis que nil fait

automatiquement partie de tout type pointeur déclaré.

La nouvelle formulation du type de données (4.1), basée sur des pointeurs explicites, est donnée en (4.4)

Type termePtr = ^ terme; (4.4)

Type expPtr = ^ expression;

Type expression = record op : opérateur;

opdl, opd2 : termePtr;

end;

Type terme = record

case T : BOOLEAN of

TRUE : (id : Alfa);

FALSE : (subex: expPtr);

end

La section suivante est consacrée à la génération et à la manipulation d'une structure de données dont les

composants sont reliés par des pointeurs explicites: la liste linéaire (ou suite chaînée). On pourra en dériver

des règles pour la manipulation de structures plus complexes que nous aborderons dans le chapitre suivant.

4.3 LISTES LINEAIRES

La façon la plus simple de relier un ensemble d'éléments entre eux est de les aligner pour former une liste.

Dans ce cas, il n'est besoin que d'un seul lien dans chaque élément pour désigner son successeur.

Supposons que les types Nœud et Ptr soient définis comme montré en (4.5). Toute variable de ce type est

constituée de trois éléments, à savoir une clé d'identification, un pointeur vers le successeur, et

éventuellement des informations associées, omises en (4.5).

58

Type Ptr = ^ Noeud; (4.5)

Type Noeud = record cle : INTEGER;

suivant : Ptr;

info : ...;

end

Var P, Q : Ptr;

La figure 4.3 montre une liste de nœuds, avec un pointeur vers son premier élément affecté à une variable

P. L'opération la plus simple que l'on puisse effectuer sur une liste comme celle-ci est d'insérer un élément

en tête. Il faut tout d'abord allouer un élément de type Nœud, sa référence (ou pointeur) étant

temporairement affectée à une variable pointeur auxiliaire Q. Puis une simple réaffectation des pointeurs

termine l'opération, dont le programme est donné en (4.6). Notez que l'ordre de ces trois instructions est

absolument essentiel.

new (Q); (4.6)

Q^.suivant := P;

P := Q;

Figure 4.3: Exemple de liste

L'opération d'insertion d'un élément en tête de liste suggère immédiatement le moyen de générer une telle

liste: en partant d'une liste vide, on ajoute répétitivement un élément en tête. Ce processus de génération

de liste est exprimé en (4.7); ici le nombre d'éléments à relier est N.

P := nil; {liste vide au début} (4.7)

while N > 0 do

begin

new (Q);

Q^.suivant := P;

P := Q;

Q^.cle := N;

N := N - 1;

end;

59

Ceci est la manière la plus simple de former une liste. Toutefois l'ordre des éléments qui en résulte est

l'ordre inverse de leur insertion. Dans certaines applications, ceci n'est pas souhaitable et il est par

conséquent nécessaire de savoir ajouter des éléments à la queue de la liste plutôt qu'en tête. Quoiqu'on

pourrait facilement trouver la queue de la liste en la parcourant, cette façon de faire naïve impose un effort

qu'on peut s'épargner en utilisant un second pointeur, QUEUE, par exemple, qui désigne en permanence

le dernier élément. Elle a toutefois pour inconvénient que le premier élément à insérer doit être traité

différemment des autres.

L'accès explicite aux pointeurs rend certaines opérations assez simples, qui eussent été fort compliquées

autrement; parmi ces opérations portant sur une liste, on trouve les opérations d'insertion et de suppression

(ou de mise à jour sélective d'une liste), et bien sûr l'opération de parcours d'une liste.

Etudions pour commencer l'insertion dans une liste. Supposons qu'un élément désigné par une variable

pointeur Q soit à insérer dans une liste après l'élément désigné par le pointeur P. Les réaffectations de

pointeurs sont données en (4.8) et leur effet visualisé sur la figure 4.4.

Q^.suivant := P^.suivant;

P^.suivant := Q; (4.8)

Figure 4.4: Insertion en liste après P^

Si l'insertion doit avoir lieu avant l'élément désigné par P plutôt qu'après, la nature unidirectionnelle de la

chaîne de liens semble poser problème, car elle n'offre pas de moyen d'accéder au prédécesseur d'un

élément. Cependant il existe une astuce simple qui permet de s'en sortir: elle est donnée en (4.9) et illustrée

par la figure 4.5. Supposons que la clé du nouvel élément soit 8.

new (Q);

Q^ := P^; (4.9)

P^.cle := 8;

P^.suivant := Q;

60

L'astuce consiste bien évidemment à insérer le nouvel élément après P^ et à échanger ensuite les valeurs

du nouvel élément et de P^.

Figure 4.5: Insertion en liste avant P^

Considérons ensuite le processus de suppression dans une liste. Supprimer le successeur de P^ est évident.

En (4.10), on montre ceci combiné à la réinsertion de l'élément supprimé en tête d'une autre liste (désignée

par Q). La figure 4.6 illustre l'effet des instructions (4.10) et montre qu'il s'agit d'un échange cyclique de

trois pointeurs.

R := P^.suivant;

P^.suivant := R^.suivant; (4.10)

R^.suivant := Q;

Q := R;

L'élimination de l'élément désigné lui-même (plutôt que de son successeur) est plus difficile, car nous

sommes à nouveau confronté au problème rencontré lors de l'insertion: revenir au prédécesseur de

l'élément désigné est impossible. Mais supprimer le successeur après avoir déplacé sa valeur vers l'avant

est une solution simple et assez immédiate. Elle ne s'applique toutefois que lorsqu'il a un successeur, c'est-

à-dire lorsqu'il n'est pas le dernier élément de la liste.

Figure 4.6: Effacement et réinsertion en liste

61

Nous allons maintenant nous occuper de l'opération fondamentale de parcours d'une liste. Supposons qu'il

faille appliquer une opération OP à chaque élément d'une liste dont le premier élément est P^. On peut

exprimer cette tâche de la façon suivante:

while la liste désignée par P n'est pas vide do

begin

effectuer l'opération OP;

passer au successeur;

end;

Cette opération est décrite en détail par l'instruction (4.11).

while P <> nil do (4.11)

begin

OP (P^);

P := P^.suivant;

end;

Il découle des définitions de l'instruction while et de la structure du chaînage que OP sera appliqué à tous

les éléments de la liste et à aucun autre.

Une opération très fréquemment effectuée sur une liste est la recherche dans la liste d'un élément d'une

clé donnée X. A la différence d'un tableau, la recherche ne peut être ici que purement séquentielle. La

recherche se termine soit lorsqu'un élément est trouvé, soit lorsqu'on atteint la fin de la liste. Ceci est

reflété par la conjonction logique de deux termes. Nous supposerons à nouveau que la tête de la liste est

désignée par un pointeur P.

while (P <> nil) AND (P^.cle <> X) do

P := P^.suivant;

P = nil implique que P^ n'existe pas et que par conséquent l'expression P^.cle <> X est indéfinie. L'ordre

des deux termes est donc essentiel.

62

CHAPITRE 5 : STRUCTURES DE DONNEES ELABOREES

Dans ce chapitre, nous passons en revue les structures de données élaborées, à savoir les listes, les piles,

les files ainsi que les arbres binaires et les arbres binaires de recherche. Nous décrivons ensuite les files

de priorité et leur implémentation au moyen de tas.

5.1 TYPE ABSTRAIT ET STRUCTURE DE DONNEES

Une structure de données est l’implémentation explicite d’un ensemble organisé d’objets, avec la

réalisation des opérations d’accès, de construction et de modification afférentes. Un type de données

abstrait est la description d’un ensemble organisé d’objets et des opérations de manipulation sur cet

ensemble. Ces opérations comprennent les moyens d’accéder aux éléments de l’ensemble, et aussi,

lorsque l’objet est dynamique, les possibilités de le modifier. Plus formellement, on peut donner une

définition mathématique d’un type abstrait: c’est une structure algébrique, formée d’un ou de plusieurs

ensembles, munis d’opérations vérifiant un ensemble d’axiomes.

Avec ces définitions, une structure de données est la réalisation, l’implémentation explicite d’un type de

données. Décrire un type de données, le spécifier comme on dit, c’est décrire les opérations possibles et

licites, et leur effet. Décrire une structure de données, c’est expliciter comment les objets sont représentés

et comment les opérations sont implémentées. Pour l’évaluation d’algorithmes, il importe de plus de

connaître leur coût en temps et en place.

Du point de vue des opérations, un type abstrait est à un type de base d’un langage de programmation ce

que l’en-tête de procédure est à un opérateur. La procédure réalise une opération qui n’est pas élémentaire

au sens du langage de programmation; de la même manière, un type de données abstrait propose une

organisation qui n’est pas offerte par le langage. Une réalisation du type abstrait, c’est-à-dire une

implémentation par une structure de données, correspond alors à l’écriture du corps d’une procédure.

Le concept de type abstrait ne dépend pas du langage de programmation considéré, et on peut se livrer à

l’exercice (un peu stérile) de définir abstraitement les booléens, les réels, etc. En revanche, l’éventail des

types concrets varie d’un langage de programmation à un autre. Ainsi, les piles existent dans certains

langages de programmation, les tableaux ou les chaînes de caractères dans d’autres.

On peut également considérer un type abstrait comme une boîte noire qui réalise certaines opérations

selon des conventions explicites. Cette vision correspond à un style de programmation que l’on pourrait

63

appeler la programmation disciplinée. Le programmeur convient d’accéder à la réalisation d’un type de

données uniquement à travers un ensemble limité de procédures et de fonctions, et s’interdit notamment

tout accès direct à la structure qui serait rendue possible par une connaissance précise de l’implémentation

particulière. De nombreux langages de programmation facilitent ce comportement par la possibilité de

créer des modules ou unités séparés. Lorsqu’un type de données est implémenté, on peut se servir de ses

opérations comme opérations élémentaires, et en particulier les utiliser dans l’implémentation de types de

données plus complexes. Ceci conduit à une hiérarchie de types, où l’on trouve, tout en bas de l’échelle,

des types élémentaires comme les booléens, entiers, réels, tableaux, à un deuxième niveau les piles, files,

listes, arbres, puis les files de priorités, dictionnaires, graphes.

La réalisation efficace de types de données par des structures de données qui implémentent les opérations

de manière optimale en temps et en place constitue l’une des préoccupations de l’algorithmique. Le temps

requis par une opération de manipulation d’un type s’exprime en fonction du temps requis par les

opérations intervenant dans sa réalisation. Ces opérations elles-mêmes sont peut-être complexes, et

implémentées dans une autre structure de données. En descendant la hiérarchie, on arrive à des opérations

élémentaires (opérations arithmétiques ou tests sur des types élémentaires, affectation de scalaires) dont

le temps d’exécution est considéré, par convention, comme constant, et ceci quel que soit le langage de

programmation utilisé.

Pour les types de données abstraits les plus simples, plusieurs réalisations efficaces sont faciles à obtenir

et à décrire. Il en est ainsi des piles, des files, des listes, et dans une moindre mesure des dictionnaires et

des files de priorités, qui sont des ensembles munis de fonctions d’accès spécifiques. La réalisation

d’arbres ayant de bons comportements requiert en revanche un soin certain.

5.2 STRUCTURES LINEAIRES

Les structures linéaires tirent leur nom du fait que les données y sont organisées sous forme d‘une liste

dans laquelle elles sont mises les unes derrière les autres. On peut représenter une telle liste à l‘aide d‘un

tableau unidimensionnel ou sous la forme d‘une liste chaînée. Selon la nature des opérations autorisées,

on obtient différents types de listes, en particulier les piles et les files.

5.2.1 Listes

Une liste linéaire sur un ensemble E est une suite finie (e1, ..., en) d’éléments de E. Les éléments de la

suite sont soit accessibles directement, par leur indice, soit indirectement, quand la liste est représentée

64

par une suite d’objets chaînés. Dans les implémentations, un indice est représenté par une place, et on

accède à l’élément de E qui figure à cette place par une fonction particulière appelée contenu (ou parfois

clé). Cette représentation sépare proprement la position d’un élément dans la liste de l’élément lui-même.

De plus, elle permet de définir, sur une place, d’autres fonctions, comme la fonction successeur qui donne

la place suivante.

Une liste est une liste linéaire où les insertions et suppressions se font non seulement aux extrémités, mais

aussi à l’intérieur de la liste. Les listes constituent un type de données très souple et efficace. Ainsi, on

peut parcourir des listes (on ne parcourt ni les files ni les piles) sans les détruire, on peut rechercher un

élément donné, on peut concaténer des listes, les scinder, etc. La distinction entre les places et leur contenu

est importante. Une place est propre à une liste, c’est-à-dire ne peut pas être partagée entre plusieurs listes;

en revanche, un élément peut figurer dans plusieurs listes, ou plusieurs fois dans une même liste.

Une liste peut contenir zéro, un ou plusieurs éléments. La longueur d‘une liste est son nombre d‘éléments.

Une liste vide est une liste de longueur nulle. La tête de la liste est son premier élément. La définition de

la queue d‘une liste n‘est pas universelle; certains la définissent comme étant le dernier élément de la liste

et d‘autres comme la liste obtenue en enlevant la tête.

On s‘intéresse souvent à des listes d‘éléments (entiers, chaînes de caractères, objets identifiés par un

entier…) appartenant à un ensemble sur lequel existe une relation d‘ordre total. C‘est le cas quand on

cherche à trier les éléments de la liste, c'est-à-dire à permuter les éléments de la liste pour qu‘ils deviennent

placés par ordre croissant (ou décroissant).

Opérations sur les listes

Les opérations habituelles sur les listes sont les suivantes.

• Insertion d‘un élément au début, à la fin, ou entre deux éléments de la liste. En particulier, on

pourra chercher à insérer un élément dans une liste triée à une bonne place, c'est-à-dire en conservant le

fait que la liste est triée.

• Suppression d‘un élément quelconque.

• Recherche: cette opération peut renvoyer vrai ou faux selon qu‘un élément donné figure ou non

dans la liste; elle peut aussi renvoyer l‘adresse de l‘élément recherché sous forme, selon les cas, d‘un

indice d‘un tableau ou d‘un pointeur.

• Concaténation de deux listes L1 et L2 contenant des éléments d‘un même type: cette opération

donne la liste obtenue en mettant d‘abord les éléments de L1 puis ceux de L2.

65

Nous commençons par décrire les opérations de base sur les listes, et exprimons ensuite d’autres

opérations à l’aide des opérations de base. Les 5 opérations d’accès sont les suivantes:

Les 4 opérations de construction et de modification sont:

Avec ces 9 opérations primitives, on peut en réaliser d’autres; ainsi, tête(L) = contenu(premier(L), L)

donne le premier élément d’une liste L. En voici d’autres:

Cette fonction s’écrit comme suit:

On programme la fonction chercher de la même manière.

66

Plusieurs implémentations des listes sont possibles; le choix de l’implémentation dépend des opérations

effectivement utilisées. Si seules les opérations de base sont utilisées, on peut employer une

implémentation simple; pour plus de souplesse, on utilisera une implémentation par liste doublement

chaînée. C’est elle qui permet notamment de concaténer deux listes, ou de scinder une liste en temps

constant.

Implémentation d‘une liste par un tableau

On peut représenter une liste par un tableau. L‘indice du début de la liste sera alors fixé (en général, ce

sera l‘indice 0 ou l‘indice 1). Il faudra connaître et actualiser le cas échéant le nombre n d‘éléments de la

liste pour savoir d‘où à où les éléments se trouvent dans le tableau.

Si on veut insérer un nouvel élément dans la liste:

• s‘il s‘agit d‘insérer un élément à sa place dans une liste triée, il faudra décaler tous les éléments

suivants d‘une case, auquel cas la complexité dans le pire des cas est de l‘ordre de la longueur de la liste;

• si l‘ordre des éléments n‘importe pas, on insérera en général l‘élément nouveau à la fin du tableau,

la complexité étant alors de l‘ordre de 1.

Si on veut supprimer un élément de la liste:

• s‘il s‘agit de supprimer un élément d‘une liste triée, il faudra décaler tous les éléments suivants,

d‘où une complexité au pire de l‘ordre de la longueur de la liste;

• si l‘ordre des éléments n‘importe pas, le plus rapide sera de mettre à la place de l‘élément à

supprimer le dernier élément de la liste.

Si maintenant on veut concaténer deux listes L1 et L2, soit on recopie L1 puis L2 dans un nouveau tableau,

soit on recopie les éléments de L2 à la suite de ceux de L1 dans le tableau qui contenait L1; dans les deux

cas, il faut évidemment que les tableaux soient suffisamment grands. La complexité est soit de l‘ordre du

nombre total d‘éléments de L1 et L2, soit de l‘ordre du nombre d‘éléments de L2.

Un inconvénient d‘un tableau, par rapport à la liste chaînée que nous développons ci-dessous, est qu‘il

faut prendre garde à ne pas déborder du tableau. Quand le tableau est plein, il faut soit refuser les

insertions, soit prendre un tableau plus grand et y recopier la liste.

67

Certaines des fonctions sont particulièrement simples à réaliser, comme suivant ou contenu. L’insertion

et la suppression prennent un temps linéaire en fonction de la taille de la liste, puisqu’il faut déplacer toute

la partie de la liste à droite de la position considérée.

Implémentation d‘une liste par une liste chaînée

Une liste chaînée est constituée de "maillons"; les maillons peuvent contenir des données et l‘adresse d‘un

autre maillon. Les données contenues par les maillons sont du même type d‘un maillon à l‘autre. Une liste

chaînée composée des entiers 5, 2 et 7 pourra être représentée comme ci-dessous:

où début est l‘adresse d‘un maillon. Le maillon qui se trouve à l‘adresse début contient l‘élément qui

constitue la tête de la liste. Pour accéder à l‘ensemble des éléments de la liste, on part de l‘adresse début.

On peut alors accéder au premier maillon où on lit l‘adresse contenue dans celui-ci, qui est celle du

deuxième maillon. On accède ainsi au deuxième maillon pour connaître le deuxième élément de la liste

et ainsi de suite. Il est nécessaire qu‘une adresse spéciale soit mise dans le dernier maillon pour qu‘on

puisse savoir que la fin de la liste est atteinte. Cette adresse spéciale est prévue par les langages de

programmation qui permettent de manipuler les listes chaînées; elle s‘appelle en général nil, ou NULL,

ou null; c‘est toujours en quelque sorte l‘adresse qui vaut zéro, notée adresse nulle sur notre schéma.

À condition de connaître certaines adresses appropriées, on constate que l‘insertion, la suppression, la

concaténation se font avec une complexité de l‘ordre de 1. En revanche, nous verrons dans le chapitre

suivant que la recherche d‘un élément dans une liste triée de n éléments peut se faire avec une complexité

de l‘ordre de ln(n) lorsqu‘on utilise un tableau, alors que la complexité est toujours dans le pire des cas et

en moyenne de l‘ordre de n lorsque la liste est codée avec une liste chaînée.

La gestion d‘une liste chaînée se fait généralement en employant une allocation dynamique de mémoire

chaque fois qu‘un nouveau maillon est nécessaire; les adresses sont alors des adresses dans la mémoire

principale de l‘ordinateur. Néanmoins, on peut gérer une structure de liste chaînée avec un tableau de

maillons, les adresses de maillons devenant des indices du tableau; on retrouve alors l‘inconvénient de la

limitation du nombre de données au nombre de cases du tableau. Dans la suite, nous supposerons

implicitement qu‘on utilise une allocation dynamique de mémoire pour chaque nouveau maillon.

L’implémentation par une liste chaînée est plus souple. Elle permet la réalisation de chacune des 9

opérations de base en un temps constant. En revanche, d’autres opérations, comme la concaténation de

deux listes, prennent un temps non constant.

68

Implémentation d‘une liste par une liste doublement chaînée

L’implémentation par une liste doublement chaînée circulaire est la plus souple, et permet aussi la

réalisation efficace d’opérations supplémentaires sur les listes. Détaillons cette structure. Une place est un

pointeur vers un triplet formé de l’élément et de deux pointeurs, l’un vers la place précédente, l’autre vers

la place suivante. La liste est circulaire, de sorte que la première place est la place qui suit la dernière. Une

liste est un pointeur vers le premier élément de la liste de valeur nil quand la liste est vide.

En Pascal, les déclarations de type sont:

où élément est un type défini par ailleurs. Les en-têtes de procédures et fonctions se déclarent comme suit:

Voici l’implémentation des cinq fonctions d’accès:

69

Remarquons que, dans l’implémentation par une liste simplement chaînée, chaque place pointe en fait sur

une liste, à savoir sur le reste de la liste commençant à cette place. Naturellement, la place suivant la

dernière est alors la liste vide, en général représentée par nil. On peut donc parcourir une telle liste par

l’instruction while p = nil do begin I; p:=suivant(p) end.

5.2.2 Piles

Une pile (stack en anglais) est une liste dans laquelle l‘insertion ou la suppression d‘un élément s‘effectue

toujours à partir de la même extrémité de la liste, extrémité appelée le sommet de la pile. L‘action

consistant à ajouter un nouvel élément au sommet de la pile s‘appelle empiler; celle consistant à retirer

l‘élément situé au sommet de la pile s‘appelle dépiler. Une pile permet de modéliser un système régi par

la discipline "dernier arrivé - premier sorti"; on dit souvent qu‘il s‘agit d‘un traitement LIFO (last in, first

out). La hauteur de la pile est son nombre d‘éléments.

Opérations sur une pile

Les fonctions souvent définies dans l‘implémentation d‘une pile sont :

1. répondre à la question: la pile est-elle vide ?

2. éventuellement, selon l‘implémentation, répondre à la question: la pile est-elle pleine ?

3. empiler un élément, en traitant éventuellement le cas où la pile serait pleine (push en anglais),

4. dépiler un élément, en traitant le cas où la pile serait vide (pop en anglais),

5. donner la valeur de l‘élément qui se trouve au sommet de la pile, sans dépiler celui-ci,

6. vider la pile.

Implémentation d‘une pile par un tableau

Si la pile est représentée par un tableau p, on doit gérer une variable pour le sommet de la pile, variable

que nous notons sp. Supposons que les éléments de la pile soient stockés à partir de l‘indice 0 du tableau;

l‘empilement d‘un nouvel élément x consiste alors à introduire x dans la case p(sp) et à remplacer sp par

sp + 1; le dépilement consiste à supprimer l‘élément p(sp - 1) de p puis à remplacer hauteur par sp - 1. La

pile est vide si la variable sp vaut zéro. La pile est pleine si sp est égal à la dimension du tableau.

70

En toute rigueur, un test de débordement est nécessaire dans la procédure empiler, pour éviter de dépasser

les bornes du tableau p.

Implémentation d‘une pile par une liste chaînée

Si la pile est représentée par une liste chaînée, L, (Figure 5.3) à laquelle on accède par un pointeur sommet,

on ajoute un nouvel élément x de la manière suivante. On crée un nouveau maillon M destiné à contenir

x et on y insère x dans le champ de M prévu à cet effet. Dans le champ de M destiné à contenir l‘adresse

du maillon suivant de la liste après insertion de x, on met la valeur du pointeur sommet. Enfin, pour

accéder à la nouvelle liste à l‘aide du pointeur sommet, on attribue à sommet l‘adresse de M. De la même

façon, pour dépiler un élément, on extrait l‘élément contenu dans le premier maillon de L (celui auquel

on accède grâce à sommet) puis on décale sommet en le faisant pointer sur le maillon qui suit le premier

maillon; on peut alors en général libérer l‘espace en mémoire alloué au maillon contenant x.

Figure 5.3: Un pile représentée par une liste chaînée.

La pile est vide si l‘adresse sommet est égale à l‘adresse nulle. La pile n‘est jamais pleine (il faut

néanmoins ne pas dépasser la capacité en mémoire de la machine sur laquelle on travaille, ce qui peut se

produire en particulier si on empile et qu‘on dépile fréquemment et qu‘on oublie de désallouer l‘espace

en mémoire quand on dépile un élément).

La propriété fondamentale d’une pile est que la suppression annule l’effet d’une insertion: si on empile

un élément, puis on dépile, on retrouve la pile dans l’état de départ. Les opérations sur une pile dont les

éléments sont de type élément sont les suivantes:

71

5.2.3 Files

Une file est une liste dans laquelle toutes les insertions de nouveaux éléments s‘effectuent d‘un même

côté de la liste appelé fin et toutes les suppressions d‘éléments s‘effectuent toujours à partir de l‘autre

extrémité, appelée début. L‘action consistant à ajouter un nouvel élément à la fin de la file s‘appelle

enfiler; celle consistant à retirer l‘élément situé au début de la file s‘appelle défiler. Une file permet de

modéliser un système régi par la discipline "premier arrivé - premier sorti" (FIFO : first in, first out).

Opérations sur une file

Implémentation d‘une file par un tableau

Si la file est représentée par un tableau f, on doit gérer deux indices tête et queue indiquant les cases de f

entre lesquelles se trouvent toutes les données; la donnée devant sortir en premier figure dans la case

d‘indice tête, tandis que la première case libre, c'est-à-dire la case dans laquelle on placera la prochaine

donnée enfilée, est d‘indice queue + 1. Enfiler un nouvel élément x consiste donc à introduire x dans la

case f(queue + 1) et à remplacer queue par queue + 1. Défiler un élément consiste d‘autre part à remplacer

tête par tête + 1, après avoir traité l‘élément f(tête). En pratique, la dimension de f est une constante dim:

le nombre d‘éléments présents dans f ne peut donc pas excéder dim. Si d‘autre part le nombre d‘entrées

et de sorties est grand devant dim, on a intérêt à gérer f de manière cyclique pour éviter un débordement:

quand il n‘y a plus de place à la fin du tableau pour ajouter un élément, on recommence à remplir f à partir

de ses premières cases (dans ce cas, queue devient plus petit que tête).

Implémentation d‘une file par une liste chaînée

Il est commode alors de maintenir à jour deux pointeurs, tête et queue, la liste étant chaînée de tête vers

queue. Pour défiler, on décale tête en le faisant pointer sur le maillon qui suit celui sur lequel il pointe,

72

après avoir traité l‘élément du maillon supprimé. Pour enfiler un nouvel élément x, on crée un nouveau

maillon M dans lequel on place x et dans lequel l‘adresse du maillon suivant est l‘adresse nulle. Puis on

ajoute M à la fin de la liste à l‘aide du pointeur queue que l‘on décale pour le faire pointer sur M.

L’implémentation par liste circulaire est plus souple, mais un peu plus complexe (voir figure 5.4). Dans

cette implémentation, une file est repérée par un pointeur sur sa dernière place (si la file est vide, le

pointeur vaut nil). La tête de la file est donc l’élément suivant, et peut être facilement supprimée; de

même, l’insertion en fin de file se fait facilement.

Figure 5.4: Une file circulaire

5.3 ARBRES

5.3.1 Arbres généraux

Un arbre est une structure composée d‘éléments appelés nœuds; il est constitué d‘un nœud particulier

appelé racine et d‘une suite ordonnée éventuellement vide A1, A2, …, Ap d‘arbres appelés sous-arbres

de la racine. Un arbre contient donc au moins un nœud: sa racine.

Exemple: la figure suivante représente l‘arbre d‘une descendance; la racine contient Yolande; il est

constitué de trois sous-arbres (p = 3): A1, de racine Claire, contient Claire et Hortense; A2, de racine Jean,

contient Jean, Élisabeth, Jacques, Lou et Mandé; A3, de racine Colette, est réduit à Colette.

73

Plusieurs définitions sont à retenir; nous illustrons les définitions avec l‘exemple ci-dessus en identifiant

un nœud avec la chaîne de caractères qu‘il contient:

• les fils d‘un nœud sont les racines de ses sous-arbres : les fils de Jean sont Élisabeth et Jacques;

• le père d‘un nœud x autre que la racine est l‘unique nœud dont x est un fils : Jacques est le père de

Lou et de Mandé; la racine d‘un arbre n‘a pas de père;

• un nœud interne est un nœud qui a au moins un fils : Claire est un nœud interne;

• une feuille d‘un arbre est un nœud sans fils : Mandé est une feuille;

• les ancêtres d‘un nœud a sont les nœuds qui sont sur le chemin entre la racine (incluse) et a (inclus);

les ancêtres de a différents de a sont les ancêtres propres de a : les ancêtres de Jacques sont Jacques, Jean

et Yolande;

• les descendants d‘un nœud a sont les nœuds qui appartiennent au sous-arbre de racine a; les

descendants de a différents de a sont les descendants propres de a : les descendants de Jean sont Jean,

Élisabeth, Jacques, Lou et Mandé.

• la profondeur d‘un nœud est la longueur du chemin allant de la racine à ce nœud; la profondeur de

la racine est donc nulle et la profondeur d‘un nœud autre que la racine vaut un de plus que la profondeur

de son père; la profondeur d‘un nœud peut aussi être appelée niveau ou hauteur du nœud : la profondeur

d‘Élisabeth vaut 2;

• la hauteur d‘un arbre est la profondeur maximum de ses nœuds (dans l'exemple: 3).

Pour connaître un arbre, on procède généralement en mémorisant la racine (ou plus précisément son

adresse); il suffit alors que chaque nœud connaisse ses nœuds fils pour pouvoir retrouver tout l‘arbre.

5.3.2 Parcours d‘un arbre général

Parcourir un arbre, c‘est examiner une fois et une seule chacun de ses nœuds, l‘ordre des examens

dépendant d‘une certaine règle. On distingue les deux types suivants. Diverses opérations peuvent être

effectuées quand on examine un nœud (par exemple, lui attribuer un numéro).

Le parcours en préordre

Parcourir un arbre en préordre, c‘est:

• examiner la racine;

• parcourir en préordre le premier sous-arbre;

• …

• parcourir en préordre le dernier sous-arbre. Ce parcours est aussi dit parcours en ordre préfixe.

74

La complexité de ce parcours est du même ordre que le nombre de nœuds de l‘arbre.

Exemple: le parcours en préordre de l‘arbre donné en exemple considère successivement les nœuds

Yolande, Claire, Hortense, Jean, Élisabeth, Jacques, Lou, Mandé, Colette.

Le parcours en postordre

Parcourir un arbre en postordre, c‘est:

• parcourir en postordre le premier sous-arbre;

• …

• parcourir en postordre le dernier sous-arbre;

• examiner la racine.

Ce parcours est aussi dit parcours en ordre postfixe ou suffixe. Le parcours en ordre postfixe a clairement

la même complexité que le parcours en préordre, c'est-à-dire de l‘ordre du nombre de nœuds de l‘arbre.

Exemple: le parcours en postordre de l‘arbre donné en exemple considère successivement les nœuds

Hortense, Claire, Élisabeth, Lou, Mandé, Jacques, Jean, Colette, Yolande.

Remarque: on peut voir les ordres obtenus par les parcours en préordre et en postordre sur l‘ensemble des

nœuds d‘une autre façon en considérant ce qu‘on appelle "un parcours à main gauche" de l‘arbre. On part

de la racine et on suit le tracé de l‘arbre comme l‘indique la figure ci-dessous. On s‘aperçoit que l‘examen

en préordre est obtenu en examinant les nœuds à la première rencontre alors que l‘examen en postordre

est obtenu en examinant les nœuds à la dernière rencontre.

5.3.3 Arbres binaires

Un arbre binaire est soit vide, soit constitué d‘un nœud particulier appelé racine et de deux sous-arbres

binaires disjoints appelés sous-arbre gauche et sous-arbre droit. On notera donc que, formellement, un

arbre binaire n‘est pas un arbre, au sens donné ci-dessus (un arbre n‘est pas vide et n‘utilise pas la notion

de droite et de gauche). Cependant, la forte parenté qui existe entre ces deux concepts fait que l‘on est

75

souvent amené à étendre aux arbres binaires certaines définitions valables en principe seulement pour les

arbres généraux. Ce sera par exemple le cas plus bas pour les parcours en préordre ou en postordre.

La terminologie est la même que celle des arbres généraux; néanmoins, si on considère les fils d‘un nœud,

on parlera, s‘ils existent, du fils gauche et du fils droit. Sur l‘exemple, on remarque que certains nœuds

ont seulement un fils droit ou seulement un fils gauche; par exemple, le nœud e (i.e. contenant la donnée

e) admet uniquement un fils gauche, le nœud i, et pas de fils droit; si on considérait l‘exemple comme un

arbre général, on dirait que e a un seul fils, le nœud i, et le lien entre e et i serait dessiné verticalement.

Pour coder un arbre binaire, on fait correspondre à chaque nœud une structure contenant la donnée et deux

adresses, une adresse pour chacun des deux fils, avec la convention qu‘une adresse nulle indique un arbre

binaire vide. Il suffit alors de mémoriser l‘adresse de la racine pour pouvoir reconstituer tout l‘arbre.

Remarque: on code souvent un arbre général en utilisant un arbre binaire; pour cela on associe à chaque

nœud de l‘arbre initial un nœud de l‘arbre binaire; le fils gauche d‘un nœud dans l‘arbre binaire correspond

au premier fils du nœud correspondant de l‘arbre initial (on met une adresse nulle s‘il n‘y a aucun fils)

alors que le fils droit de ce nœud correspond au premier "frère cadet", c'est-à-dire au nœud de l‘arbre

initial qui a même père que le nœud considéré et qui se trouve immédiatement à sa droite (on met une

adresse nulle s‘il n‘y a aucun frère cadet). Ainsi, l‘arbre général donné en exemple précédemment et

retracé ci-dessous à gauche peut être codé avec l‘arbre binaire représenté à droite, en changeant bien

entendu l‘interprétation des liens.

76

Le parcours en ordre symétrique

Les deux parcours définis pour les arbres généraux sont aussi définis pour les arbres binaires. Pour

l‘exemple ci-dessus, le parcours en préordre donne l‘ordre: c, b, d, e, i, f, a, g, h et le parcours en postordre

donne l‘ordre: d, i, e, b, a, h, g, f, c.

On considère pour les arbres binaires un troisième ordre: l‘ordre symétrique. Parcourir un arbre binaire

en ordre symétrique, c‘est, si l‘arbre binaire est non vide, alors

• parcourir en ordre symétrique le sous-arbre gauche;

• examiner la racine;

• parcourir en ordre symétrique le sous-arbre droit.

Ce parcours est aussi dit parcours en ordre infixe ou milieu. La complexité du parcours en ordre

symétrique est, de même que pour les autres parcours, de l‘ordre du nombre de nœuds de l‘arbre. Exemple:

le parcours en ordre symétrique donné en exemple conduit à l‘ordre: d, b, i, e, c, a, f, g, h.

Remarque: on peut illustrer les trois parcours des arbres binaires à l‘aide du parcours à main gauche; il est

plus agréable pour cela de faire figurer sur le tracé de l‘arbre les sous-arbres vides comme il est fait ci-

dessous; le préordre correspond à la première fois que l‘on rencontre les nœuds, l‘ordre symétrique à la

deuxième fois et le postordre à la troisième fois.

Exemple d‘application: on peut utiliser un arbre binaire pour représenter une expression arithmétique

composée à partir des entiers et des quatre opérateurs binaires: +, -, ×, /. L‘arbre ci-dessous représente

l‘expression: (14 + (8 / 2)) × (5 - 3)

77

Par construction, dans cet arbre, tout nœud interne a un fils gauche et un fils droit.

Les trois parcours de cet arbre donnent:

• parcours en préordre: × + 14 / 8 2 - 5 3; on reconnaît la forme polonaise de l‘expression;

• parcours en ordre infixe: 14 + 8 / 2 × 5 - 3; il faudrait ajouter les parenthèses pour retrouver

l‘expression initiale;

• parcours en postordre: 14 8 2 / + 5 3 - ×; on reconnaît la forme polonaise inverse de l‘expression.

Quelques arbres binaires particuliers

Un arbre binaire est dit complet si tout nœud interne a exactement deux fils.

Un arbre binaire est dit parfait si, en appelant h la hauteur de l‘arbre, les niveaux de profondeur 0, 1, …,

h - 1 sont complètement remplis alors que le niveau de profondeur h est rempli en partant de la gauche; la

structure d‘un arbre binaire parfait est complètement déterminée par son nombre de nœuds; nous donnons

ci-après les arbres binaires parfaits à 5 nœuds et à 12 nœuds. On rencontre aussi quelquefois le qualificatif

de presque-complet pour un tel arbre.

En revanche, l‘arbre ci-dessous n‘est pas parfait.

Un arbre binaire est dit équilibré si, pour tout nœud de l‘arbre, les sous-arbres gauche et droit ont des

hauteurs qui diffèrent au plus de 1. L‘arbre ci-après est équilibré.

78

Un arbre binaire parfait est équilibré. On pourrait démontrer que la hauteur h d‘un arbre binaire équilibré

ayant n nœuds vérifie: log2(n + 1) ≤ h ≤ 1,44 log2(n + 2), d‘où on peut déduire facilement que la profondeur

moyenne des nœuds d‘un arbre binaire équilibré est du même ordre que ln n.

En ce qui concerne un arbre binaire quelconque de n nœuds, on établirait facilement que la profondeur

ainsi que la hauteur moyenne des feuilles ont toujours un ordre de grandeur compris entre ln n et n. La

hauteur de l‘ordre de n est atteinte pour des arbres qui sont "tout en hauteur" comme on peut en voir ci-

après; ces arbres seront dits dégénérés.

Des arbres binaires complets peuvent aussi avoir une hauteur de l‘ordre du nombre de nœuds comme

l‘illustrent les deux arbres ci-dessous, qualifiés aussi de dégénérés.

5.3.4 Dictionnaires et arbres binaires de recherche

Un dictionnaire est un type de données opérant sur les éléments d’un ensemble totalement ordonné,

appelés les clés et doté des opérations suivantes:

79

Les arbres binaires de recherche se prêtent particulièrement bien à l’implémentation des dictionnaires. Il

suffit ici de savoir qu’un arbre binaire de recherche est un arbre binaire dont chaque sommet est muni

d’une clé prise dans un ensemble totalement ordonné. De plus, pour chaque sommet de l’arbre, les clés

contenues dans son sous-arbre gauche sont strictement inférieures à la clé du sommet considéré et cette

clé est elle-même strictement inférieure aux clés contenues dans le sous-arbre droit. En d’autres termes,

un parcours symétrique de l’arbre donne une liste des clés en ordre croissant. Donnons d’abord les en-

têtes des opérations, qui sont les mêmes que dans un arbre :

Voici la réalisation de la recherche dans un arbre:

Pour l’insertion, on procède de manière similaire:

La suppression est plus difficile à réaliser parce qu’il faut conserver l’ordre sur l’arbre résultat. Soit x la

clé à supprimer, et soit s le sommet dont x est le contenu. Si s est une feuille, il suffit de la supprimer. Si

s n’a qu’un seul fils, on le remplace par son fils unique. Si s a deux fils, on cherche son descendant t dont

le contenu le précède dans l’ordre infixe: c’est le sommet le plus à droite dans son sous-arbre gauche. On

supprime ce sommet (qui n’a pas de fils droit), après avoir remplacé le contenu de s par le contenu de t.

(On pourrait aussi bien, symétriquement, remplacer le contenu de s par celui du sommet qui lui succède

dans l’ordre infixe.)

80

Voici une réalisation de cet algorithme:

L’appel à la fonction SUPPRIMER-MAX se fait donc dans le cas où l’arbre a deux sous-arbres non vides,

et où le contenu de la racine de a est à supprimer. La fonction SUPPRIMER-MAX réalise la suppression

du sommet de plus grande clé parmi les descendants, et retourne le contenu de ce sommet.

Plusieurs implémentations des arbres sont possibles; la plus souple utilise des pointeurs. A chaque

sommet, on associe deux pointeurs, l’un vers le sous-arbre gauche, l’autre vers le sous-arbre droit. Un

arbre est donné par un pointeur vers sa racine. Un sommet comporte en plus un champ qui contient la clé

associée. On est donc conduit à définir les types suivants:

5.3.5 Files de priorité

Les files de priorité constituent un type de données intéressant qui illustre bien comment une hiérarchie

de structures peut permettre d’implémenter un type de données. Une file de priorité est un type de données

opérant sur des clés, donc sur les éléments d’un ensemble totalement ordonné, et muni des opérations:

81

Il est commode de disposer de la combinaison de MINIMUM et de SUPPRIMER-MIN sous la forme:

Fréquemment, on convient qu’une même clé peut figurer plusieurs fois dans une file.

Si les clés sont toutes distinctes, on peut réaliser une file de priorité à l’aide d’un arbre binaire de

recherche. L’efficacité des opérations dépend alors de l’efficacité de l’insertion et de la suppression de la

plus petite clé dans un arbre binaire de recherche. On verra au chapitre 6 comment ces opérations peuvent

être réalisées en temps logarithmique, moyennant un rééquilibrage de l’arbre binaire. Nous présentons ici

une autre implémentation, au moyen d’un tas.

Un tas est un arbre tournoi parfait. Un arbre tournoi est un arbre binaire dont les sommets sont munis

d’une clé et tel que, pour tout sommet autre que la racine, la clé du sommet est plus grande que celle de

son père. En d’autres termes, les clés sur un chemin croissent de la racine vers une feuille. (La définition

avec plus petit est équivalente et, dans ce cas, les clés décroissent de la racine vers une feuille.)

Figure 5.7: Un arbre tournoi.

La figure 5.7 montre un arbre tournoi, et la figure 5.8 un tas (arbre tournoi parfait).

Figure 5.8: Un tas (tournoi parfait).

Sur les arbres parfaits, on utilise certaines des opérations des arbres binaires: EST-ARBREVIDE,

ARBREVIDE, RACINE, PERE, FILS-GAUCHE, FILS-DROIT. Par ailleurs, on définit les opérations

spécifiques suivantes:

82

Ces opérations conservent donc la perfection d’un arbre. Pour les arbres tournoi, la fonction CONTENU

donne le contenu (la clé) d’un sommet, et on fait appel à une procédure supplémentaire, ECHANGER-

CONTENU(p, q) qui échange les clés des sommets p et q.

Implémente les opérations des files de priorités au moyen des opérations des tas. Le minimum est évident:

c’est le contenu de la racine. Elle se fait donc en temps constant. Pour l’insertion d’une clé x, on crée une

feuille contenant cet élément (dernier niveau du tas); on compare son contenu à celui de son père; s’il est

plus petit, on l’échange, et on continue en progressant vers la racine du tas. Voici la procédure :

L’extraction de la plus petite clé se fait sur un canevas similaire:

On remplace donc le contenu de la racine par le contenu de la dernière feuille. Cette feuille est supprimée.

Puis, on descend de la racine vers les feuilles pour mettre la clé à une place convenable si elle ne l’est pas,

c’est-à-dire si elle est plus grande que l’une des clés des fils. Si le sommet en question a deux fils, on

échange la clé du père avec la plus petite des clés de ses fils. On obtient ainsi une correction locale; on

poursuit l’opération tant que nécessaire. Revenons à l’exemple de la figure 5.8. La suppression de la clé

1 amène la clé 7 à la racine. La descente qui s’ensuit est illustrée sur la figure 5.9. La fonction FILS-

MIN(p, a) retourne le fils de p dont le contenu est le plus petit. Elle s’écrit:

83

Figure 5.9: Mise en place de la clé 7 par comparaison aux fils.

Il est clair que la hauteur d’un tas est logarithmique en fonction du nombre de ses sommets, donc que

l’insertion et l’extraction d’une clé prennent un temps logarithmique, sous réserve que les opérations

élémentaires s’implémentent en temps constant.

Comme nous l’avons annoncé plus haut, il existe une implémentation très efficace des tas à l’aide d’un

couple formé d’un tableau a et d’un entier n donnant le nombre de clés présentes dans le tas. Les sommets

du tas étant numérotés niveau par niveau de la gauche vers la droite, chaque sommet est représenté par

l’indice d’une clé d’un tableau a. Le contenu d’un sommet p est rangé dans a[p], et est donc accessible en

temps constant. Passons en revue les opérations:

Les autres opérations se réalisent aussi simplement:

Seule la procédure ECHANGER-CONTENU(p, q) qui échange les clés des sommets p et q, demande trois

instructions. L’efficacité pratique de la réalisation des files de priorités est évidemment améliorée si l’on

travaille directement sur le tableau, et si l’on remplace les appels de procédures par les instructions

correspondantes.

84

CHAPITRE 6 : RECHERCHE ET TRI

6.1 INTRODUCTION

On considère des données appartenant à un ensemble totalement ordonné. Les trier consiste alors à les

ranger en ordre croissant ou décroissant. Quant à rechercher une donnée, c‘est soit simplement déterminer

si la donnée figure ou non, soit, dans le cas où la donnée figure, trouver l‘endroit où cette donnée est

stockée.

Les opérations de recherche et de tri consomment un pourcentage important du temps total de calcul des

ordinateurs. C‘est pourquoi les gains sur la complexité des algorithmes de tri et de recherche ont une

importance considérable: aussi ont-ils été très étudiés et perfectionnés. Dans tout ce chapitre, les données

que nous considérerons seront des entiers. Néanmoins, remarquons qu‘il est rare de trier simplement des

entiers; on rencontre plus fréquemment des tris de structures selon un champ de cette structure. On peut

trier des structures comportant un nom d‘élève et une note de cet élève par liste alphabétique des noms

des élèves ou par notes croissantes; on peut trier des factures par dates croissantes; on peut trier des cartes

à jouer par couleurs et à l‘intérieur de chaque couleur par niveaux et ainsi de suite. Il faudra considérer

que le type des données considérées est un type quelconque sur lequel est définie une relation d‘ordre total

notée avec les symboles habituels <, >, ≤, ≥.

6.2 RECHERCHE DICHOTOMIQUE DANS UN TABLEAU TRIE

On suppose ici que l‘on dispose d‘un tableau trié par ordre croissant et qu‘on souhaite savoir si un élément

fixé (i.e. une donnée) figure ou non dans la liste. On peut bien sûr utiliser une recherche séquentielle qui

consiste à regarder successivement, en partant de la gauche du tableau, tous les éléments. Néanmoins, il

sera plus astucieux d‘utiliser une recherche dichotomique.

La recherche dichotomique consiste à comparer l‘élément, ou un des deux éléments, qui se trouve au

centre de la liste avec l‘élément recherché; si cet élément central est égal à l‘élément recherché, c‘est

terminé; s‘il est supérieur à l‘élément recherché, on abandonne la moitié droite de la liste (correspondant

aux éléments plus grands que l‘élément central) et on recommence avec la liste formée par la moitié

gauche (correspondant aux éléments plus petits que l‘élément central) de la liste initiale; s‘il est inférieur

à l‘élément recherché, on abandonne la moitié gauche de la liste (correspondant aux éléments plus petits

que l‘élément central) et on recommence avec la liste formée par la moitié droite (correspondant aux

85

éléments plus grands que l‘élément central) de la liste initiale. Et ainsi de suite jusqu‘à ce que la liste

restante soit vide (recherche infructueuse) ou bien avoir trouvé l‘élément cherché (recherche fructueuse).

Écrivons le pseudo-code de cet algorithme qui renvoie vrai ou faux selon que l‘élément recherché est

trouvé ou non. Si on recherche l‘indice de l‘élément dans le cas où il figure, il faut adapter l‘algorithme.

Nous allons établir la complexité de cet algorithme. Pour chaque passage dans la boucle tant que, on fait

un nombre d‘opérations élémentaires majoré par une constante; à chaque passage dans la boucle, la

longueur de la liste est divisée par 2 (même un peu plus), ce qui fait que si on note p le nombre total de

passages dans la boucle, au bout de p passages, la longueur de la liste est majorée par n / 2p, et au moins

égale à 1: n / 2p ≥ 1, et donc: 2p ≤ n; d‘où p ≤ log2(n). La complexité est au pire de l‘ordre de log2(n) ou,

ce qui revient au même, de ln(n).

6.3 COMPLEXITE D‘UN ALGORITHME DE TRI

Comme pour tout algorithme, la complexité d‘un algorithme de recherche ou de tri peut s‘évaluer soit

dans le "pire des cas", c'est-à-dire pour un ordre initial des données spécialement défavorable pour cet

algorithme, soit dans le "meilleur des cas", soit "en moyenne". Par "en moyenne", on entend ici la

moyenne arithmétique des complexités sur toutes les permutations possibles des données, que l‘on

suppose, pour ce calcul, deux à deux distinctes.

Si un algorithme de tri doit souvent être exécuté mais qu‘il n‘est pas impératif d‘avoir un temps

d‘exécution maximum très petit, on pourra utiliser un algorithme de bonne complexité en moyenne mais

dont la complexité au pire peut être médiocre. C‘est le cas d‘un certain nombre de transactions que l‘on

peut exécuter en temps différé; le fait que, dans le "pire des cas", de tels algorithmes ne soient pas très

86

performants n‘est pas pénalisant, le pire étant peu probable en principe. En revanche, lorsqu‘on travaille

en temps réel, on s‘intéresse souvent à améliorer la complexité dans le pire des cas. En effet, même si

elles sont rares, des attentes très longues peuvent être inacceptables.

La plupart des algorithmes de tri sont fondés sur des comparaisons successives entre les données pour

déterminer la permutation correspondant à l‘ordre croissant des données. Nous appellerons tri comparatif

un tel tri. La complexité de l‘algorithme a alors le même ordre de grandeur que le nombre de comparaisons

entre les données faites par l‘algorithme. Nous ne verrons que des tris comparatifs.

Nous allons voir plus loin le tri sélection et le tri insertion de complexité en O(n2), qui seront simples à

écrire mais peu efficaces; en effet, nous verrons aussi le tri rapide et le tri par arbre binaire de recherche

qui sont en moyenne en O(n ln (n)), mais dans le pire des cas en O(n2); nous verrons enfin le tri tas qui

est dans le pire des cas en O(n ln (n)).

6.4 TRI SELECTION

Le tri sélection consiste à chercher la plus petite donnée de la liste, à mettre celle-ci en première position,

puis à chercher la plus petite donnée parmi les données autres que la première, la mettre en deuxième

position, et ainsi de suite. Dans le pseudo-code donné ci-dessous, on suppose qu‘on dispose d‘une fonction

échanger telle que, si T est un tableau et i et j deux indices de ce tableau, échanger(T, i, j) échange les

données du tableau T qui se trouvent aux indices i et j.

La complexité de ce tri est aussi bien dans le pire des cas que dans le meilleur des cas (et donc qu‘en

moyenne) en O(n2). Ce n‘est donc pas un tri très efficace, mais en revanche c‘est un tri très simple.

87

6.5 TRI INSERTION

Le principe du tri par insertion est le suivant. On souhaite trier une liste de n données; on procède par

étape; à la ie étape (i variant de 1 à n - 1), on suppose que les i premières données sont déjà triées; on

considère alors la (i + 1)e donnée que l‘on appelle clé; on la compare successivement aux données

précédentes, en commençant par la ie puis en remontant dans la liste jusqu‘à trouver la bonne place de clé

(c'est-à-dire entre deux données successives, l‘une étant plus petite et l‘autre plus grande que clé), ou bien

en tout premier si clé est plus petite que les i premières données; au fur et à mesure, on décale "d‘une case

vers la droite" les données plus grandes que clé de façon à anticiper la place de ces données après insertion

de clé; on met clé à la bonne place; à l‘issue de cette étape, les i + 1 premières données sont donc triées.

Ce tri est en O(n2) dans le pire des cas (cas où la liste est triée dans l‘ordre inverse) et en O(n) dans le

meilleur des cas (cas où la liste serait déjà triée avant application de l‘algorithme); si on considère que

toutes les permutations des données sont équiprobables, on vérifie facilement que le tri est en moyenne

en O(n2). C‘est donc encore un tri peu efficace mais qui a aussi le mérite de la simplicité. Le tri insertion

a une complexité en moyenne du même ordre que celle du tri sélection; néanmoins, si les données à trier

sont souvent presque triées (dans le sens souhaité), il vaudra mieux choisir le tri insertion.

6.6 TRI RAPIDE

Le principe du tri rapide est le suivant. On utilise une fonction nommée partition qui s‘applique à une

liste; cette fonction extrait une donnée, nommée clé, de la liste, par exemple la première (c‘est ce que nous

choisirons plus bas) puis elle réorganise la liste de sorte qu‘on trouve d‘abord les données ne dépassant

pas la valeur de clé (on appellera sous-liste gauche cette partie de la liste), puis la donnée clé, puis les

données supérieures à clé (on appellera sous-liste droite cette partie de la liste). Le tri rapide peut être

défini récursivement. Pour appliquer le tri rapide à une liste ayant au moins deux données, on commence

88

par appliquer la fonction partition, puis on applique le tri rapide à la sous-liste gauche, puis on applique

le tri rapide à la sous-liste droite.

La fonction partition peut être implémentée de différentes façons; il importe simplement que sa

complexité pour une liste de longueur n soit en O(n), pour que le tri rapide ait une bonne complexité (voir

plus bas). Nous supposons que les données sont dans un tableau T et que ce tableau contient une case

supplémentaire à la droite des données, qui contient une donnée strictement supérieure à toutes les

données de la liste; cette condition est nécessaire pour que, dans le pseudo-code donné ci-dessous, la

variable i ne risque pas de croître "indéfiniment"; il ne faudra pas l‘oublier au moment d‘une

programmation (ou bien il faudra modifier légèrement le pseudo-code ci-dessous pour éviter le

débordement sur la droite du tableau). Dans le pseudo-code suivant, partition renvoie l‘indice du tableau

où clé est rangée. La procédure échanger est définie comme plus haut.

Prenons un exemple. Les données sont les valeurs qui figurent dans la seconde ligne du tableau T suivant,

la première précisant les indices des cases de T:

On suppose qu‘on applique la fonction partition avec g = 1 et d = 12. La variable clé vaut T[1] = 9. Au

premier passage dans la boucle externe, i s‘arrête à 3, j s‘arrête à 12. On procède à l‘échange et on obtient:

89

Après quoi i passe à 4 et j à 11. Au deuxième passage dans la boucle, i s‘arrête à 4 et j s‘arrête à 10. On

procède à l‘échange et on obtient:

avec i = 5 et j = 9. Au troisième passage dans la boucle, i s‘arrête à 5 et j s‘arrête à 8. On procède à

l‘échange et on obtient:

avec i = 6 et j = 7. Au quatrième passage dans la boucle, i s‘arrête à 7 et j s‘arrête à 6. Le test "i < j ?" est

négatif car on a i ≥ j. On sort de la boucle externe "tant que i ≤ j" car on a i > j; on échange T[1] avec T[j],

i.e. T[6]; on obtient:

On retourne l‘indice 6. On remarque que la donnée de valeur 9 est à sa place définitive et qu‘avant elle,

se trouvent des données inférieures à 9 et, après, des données supérieures à 9.

On peut maintenant écrire le pseudo-code du tri rapide pour un tableau compris entre les indices g et d.

On appellera tri_rapide(1, n) pour trier des données se trouvant entre les indices 1 et n d‘un tableau.

Sur notre exemple, il reste à appliquer le tri rapide entre les indices 1 et 5 puis entre les indices 7 et 12.

L‘application de tri_rapide(1, 5) appelle partition(1, 5) qui conduit au tableau:

et renvoie la valeur 3. On applique alors tri_rapide(1, 2), qui appelle partition(1, 2) qui ne change pas le

tableau et retourne la valeur 1, puis tri_rapide(1, 0) qui ne fait rien, puis tri_rapide(4, 5) qui appelle

partition(4, 5) qui conduit au tableau:

90

et retourne l‘indice 4. Puis, tri_rapide(4, 3) ne fait rien ainsi que tri_rapide (5, 5). Le tri rapide entre les

indices 1 et 5 est terminé.

L‘application de tri_rapide(7, 12) fait appel à partition(7, 12) qui conduit au tableau:

et retourne l‘indice 11. Alors, tri_rapide(7, 10) appelle partition(7, 10) qui conduit à:

et retourne l‘indice 9. Puis, tri_rapide(7, 8) fait appel à partition(7, 8) qui ne change pas le tableau et

retourne l‘indice 7, puis tri_rapide(7, 6) ainsi que tri_rapide(8, 8) qui ne font rien. On "remonte" avec

tri_rapide(12, 12) qui ne fait rien. L‘algorithme est terminé.

On remarque que la complexité de la fonction partition est bien linéaire en la longueur de la liste

considérée. Le "gros" du travail consiste en l‘application des fonctions partition.

Le pire des cas pour cet algorithme est celui où la clé vient toujours à une extrémité du sous-tableau allant

de l‘indice g à l‘indice d pour chaque appel à la fonction partition. Ce sera par exemple le cas si on trie un

tableau déjà trié. Il sera alors fait appel à la fonction partition pour des listes de longueur n, puis n - 1, puis

n - 2, …, puis 2; la somme des complexités de ces fonctions donne un algorithme en O(n2).

Le meilleur des cas est celui où la liste est toujours partagée en deux sous-listes de même longueur. On

aura en tout:

• un appel à la fonction partition sur une liste de longueur n,

• deux appels à la fonction partition sur des listes de longueurs environ n/2,

• quatre appels à la fonction partition sur des listes de longueurs environ n/4,

• …

D‘où une complexité de l‘ordre de n pour la liste de longueur n, une complexité totale de l‘ordre de n pour

les deux sous-listes de longueur n/2, une complexité totale de l‘ordre de n pour les quatre sous-listes de

91

longueur n/4, … Comme la longueur est divisée par deux quand on passe de n à n/2, puis de n/2 à n/4, on

a en tout une complexité de l‘ordre de n ln (n).

On peut montrer sans trop de difficulté, par une formule de récurrence, que la complexité en moyenne est

aussi de l‘ordre de n ln (n). Remarquons que pour éviter d‘avoir une complexité en n2 lorsqu‘on trie une

liste déjà triée, on prend souvent, au tout début de la fonction partition, une donnée au hasard dans la liste

traitée et on l‘échange avec la donnée qui se trouve en première position, puis on continue de la façon

indiquée précédemment.

6.7 ARBRE BINAIRE DE RECHERCHE

Un arbre binaire de recherche est un arbre dont les nœuds appartiennent à un ensemble totalement ordonné

et tel que, pour tout nœud interne a, les données contenues dans le sous-arbre gauche de a sont inférieures

ou égales à la donnée contenue dans le nœud a, qui est elle-même inférieure ou égale aux données

contenues dans le sous-arbre droit de a. Exemple:

Par définition d‘un arbre binaire de recherche, un parcours en ordre symétrique de l‘arbre donne la liste

triée des données que contient l‘arbre.

Ci-dessous, on supposera que la racine de l‘arbre ainsi que les fils gauche et droit d‘un nœud sont des

adresses de nœuds. Si on a l‘adresse adr d‘un nœud, adr.donnée sera la donnée contenue par le nœud (cela

serait noté adr→donnée s‘il s‘agissait d‘un codage en langage C), adr.fils_gauche sera l‘adresse du fils

gauche et adr.fils_droit sera l‘adresse du fils droit.

Pour insérer une donnée nommée clé dans un arbre binaire de recherche, on compare clé à la donnée

contenue dans la racine; si clé est plus petite, on l‘insère dans le sous-arbre gauche; si elle est plus grande,

on l‘insère dans le sous-arbre droit. Ceci est décrit plus précisément par la fonction récursive insérer dont

on donne ci-dessous le pseudo-code.

92

La complexité d‘une insertion ou d‘une recherche dans un arbre binaire de recherche est:

• dans le pire des cas égale à la profondeur de l‘arbre binaire dans lequel se fait l‘insertion;

• en moyenne égale à la profondeur moyenne des feuilles de l‘arbre binaire.

Considérons un arbre binaire de recherche obtenu par insertions successives de n données formant une

permutation quelconque, toutes les permutations étant équiprobables. On sait qu‘en moyenne la hauteur

ainsi que la profondeur moyenne des feuilles de cet arbre sont de l‘ordre de ln(n); le pire néanmoins

correspond à un arbre dégénéré, où on obtient une hauteur de l‘arbre et une profondeur moyenne des

feuilles de l‘ordre de n. La recherche d‘une donnée dans un arbre binaire de recherche contenant n données

est donc au pire en O(n) et en moyenne en O(ln (n)).

Si on veut trier n des données en utilisant un arbre binaire de recherche, il faut:

• construire l‘arbre binaire de recherche en partant de l‘arbre vide et en insérant les données les unes après

les autres; la complexité pour l‘ensemble des insertions est de l‘ordre de la somme des profondeurs des

nœuds de l‘arbre obtenu; celle-ci a un ordre de grandeur compris entre n ln (n) et n2, et est en moyenne

sur les jeux de données possibles de l‘ordre de n ln (n);

• on lit en ordre symétrique l‘arbre binaire obtenu, opération dont la complexité est du même ordre que le

nombre de nœuds de l‘arbre, et donc inférieure à celle de la construction de l‘arbre.

Le tri par arbre binaire de recherche est donc au pire en O(n2) et en moyenne en O(n ln (n)).

6.8 STRUCTURE DE TAS ET TRI TAS

6.8.1 Tas en tableau

Pour rappel, on appelle tas un arbre binaire parfait, c'est-à-dire où tous les niveaux sont remplis sauf

éventuellement le dernier, celui-ci étant rempli de la gauche vers la droite, et dans les sommets duquel

93

figurent des éléments d‘un ensemble totalement ordonné, l‘élément stocké en un nœud quelconque étant

plus grand (la définition avec plus petit est équivalente) que les éléments stockés dans ses deux nœuds fils

s‘il a deux fils, dans son fils s‘il a un seul fils. Nous avons vu, dans la section Files de priorité, qu'un tas

pouvait être implémenté très efficacement dans un tableau. C‘est ce codage avec un tableau qui sera en

général retenu dans le traitement d‘un tas. Nous appellerons nœud d‘indice i le nœud qui est numéroté par

i, et donc qui est rangé dans la ie case du tableau.

On donne ci-dessous la représentation d'un tas dans un tableau.

6.8.2 Insertion d‘un nouvel élément dans un tas

Pour structurer une liste donnée de valeurs à partir d‘un tas vide, on insère les éléments les uns après les

autres en veillant à conserver la structure de tas. Le nouvel élément est mis à la première place disponible

dans la dernière rangée du tas si celle-ci n‘est pas encore pleine; si la dernière rangée est pleine, on en

crée une nouvelle et on insère le nouvel élément à la première place de la nouvelle rangée. C’est la

procédure INSERERTAS que nous avons vue dans la section des Files de priorité et que nous réécrivons

comme suit :

Imaginons que, partant du tas représenté ci-dessus, on veuille introduire la valeur 28. On place la valeur

28 à droite de la dernière feuille introduite, c'est-à-dire dans la case d‘indice 9 du tableau.

94

On compare 28, la nouvelle donnée insérée, avec la donnée contenue dans le nœud père, autrement dit on

compare la donnée de la case d‘indice 9 du tableau avec la donnée de la case d‘indice [9/2] = 4. Puisque

28 est plus grand que 21, on échange le 21 et le 28. En observant cet échange, on remarque qu‘en toute

généralité la seule relation définissant un tas qui peut ne pas être vérifiée est maintenant celle qui existe

entre la donnée (28) contenue par le nœud d‘indice 4 et la donnée (25) contenue par le nœud père de celui-

ci, d‘indice [4/2] = 2.

On compare la donnée 28 du nœud d‘indice 4 avec la donnée 25 contenue dans son nœud père, d‘indice

2. Puisque 28 est plus grand que 25, on échange le 25 et le 28. On remarque que la seule relation définissant

un tas qui peut ne pas être vérifiée est maintenant celle qui existe entre la donnée (28) contenue par le

nœud d‘indice 2 et la donnée (56) contenue par le nœud d‘indice [2/2] = 1.

On compare la donnée 28 de la case d‘indice 2 avec la donnée contenue dans le nœud père d‘indice 1.

Puisque 28 est plus petit que 56, l‘insertion est terminée. On a bien ainsi restauré la structure de tas.

Lorsqu‘on insère une pe donnée dans un tas, celle-ci est insérée initialement en dernier dans le tableau,

c'est-à-dire dans le niveau de profondeur h = [log2 p]; on fait alors au plus h comparaisons et échanges

d‘éléments. La complexité d’une insertion est donc logarithmique et la complexité de la création d’un tas

de p valeurs est égale à p ln (p).

95

6.8.3 Principe du tri tas

Dans un tas, le plus grand élément est à la racine. Tirons parti de cette indication pour trier des données

de façon très efficace. On échange la donnée contenue par la racine avec la donnée contenue dans la

dernière feuille du tas, c'est-à-dire avec la donnée contenue dans la dernière case du tableau représentant

le tas. La donnée contenue dans la racine avant l‘échange étant la plus grande, après l‘échange la donnée

contenue dans la dernière case du tableau est la plus grande et est donc à sa place dans un tri par ordre

croissant; on ne va plus y toucher. On va alors reconstruire un tas avec les données d‘indices compris

entre 1 et n - 1 (en notant n le nombre initial de données); la seule condition définissant un tas qui peut

être violée est la supériorité de la donnée de la racine par rapport à ses fils. Pour cela, dans une démarche

symétrique de celle de la construction du tas, on "fait descendre" la donnée mise à la racine: tant qu‘on

n‘a pas atteint une feuille du tas et que la donnée qui descend est plus petite que la plus grande des données

de ses deux fils, on l‘échange avec cette plus grande donnée.

Nous utilisons en fait la même démarche que celle suivie dans les Files de priorité avec les fonctions

EXTRAIREMIN et FILS-MIN qui sont réécrites comme suit:

Reprenons l'exemple précédent. On échange la donnée de la racine avec la dernière donnée du tas.

96

On cherche à reconstituer un tas sur les 8 premières cases du tableau. Pour cela, on cherche la plus grande

des deux données contenues dans les fils de la racine, c'est-à-dire les nœuds d‘indice 2 × 1 = 2 et 2 × 1 +

1 = 3; la plus grande de ces données vaut 28; on échange donc le contenu de la case 1 avec le contenu de

la case 2.

On cherche la plus grande des données entres les données contenues par les nœuds d‘indice 2 × 2 = 4 et

2 × 2 + 1 = 5; la plus grande de ces données vaut 25 et est plus grande que 21; on échange donc le contenu

de la case 2 avec le contenu de la case 4.

Comme on ne considère qu‘un tas sur les 8 premières cases, le nœud d‘indice 4 a un seul fils dans le tas,

qui contient la donnée 5. Comme 21 est plus grand que 5, la descente est terminée.

La complexité de la descente est au pire de l‘ordre de la hauteur de l‘arbre binaire, puisque, pour chaque

niveau de l‘arbre, on fait un nombre d‘opérations majoré par une constante (au plus deux comparaisons

et quelques affectations); la descente est donc en O(ln (n)) pour un tas de n données.

6.8.4 Pseudo-code du tri tas

Pour trier un tableau T, d'indices de 1 à n, contenant n données, on utilise la procédure tri_tas suivante:

La complexité se calcule à partir de celle des procédures montée et descente; elle est en O(n ln (n)). On

constate donc que ce tri a la complexité optimum d‘un tri comparatif.

97

CHAPITRE 7 : TIRER PARTI DE LA REPETITION

7.1 LE DRAPEAU TRICOLORE DE DIJKSTRA

Soit un tableau T [0 .. m-1] contenant NB boules bleues (B), Nb boules blanches (b) et NR boules rouges

(R) disposées aléatoirement. Nous avons donc NB + Nb +NR = m. On veut classer ces boules en

regroupant successivement les bleues, les blanches et les rouges pour reconstituer le drapeau hollandais

de E.W. DIJKSTRA. On impose que les déplacements des boules ne peuvent se faire que par des

permutations au sein du tableau.

Un solution immédiate est obtenue avec le tri à bulles dont la complexité est en O(n2). Pouvons-nous

construire un algorithme de complexité O(n) en ne regardant chaque boule qu'une et une seule fois?

Pour cela, il faut parcourir le tableau et, pour chaque boule rencontrée, compléter le drapeau

précédemment construit avec les boules rencontrées jusqu'alors, le drapeau étant délimité par les

séparations entre les couleurs. De cette manière, lorsque nous aurons analysé toutes les boules, le drapeau

sera constitué complètement.

Supposons que nous ayons déjà analysé une partie du tableau et qu'un drapeau ait été constitué avec les

boules déjà analysées. Ceci est représenté par la figure suivante:

i est l'indice de la zone des blanches;

j est l'indice de la zone des rouges;

k est l'indice de la zone à classer (partie du tableau non encore organisée en drapeau);

Si la boule rencontrée est une blanche (b)

if T[k] = b then

begin

PERMUTER (T[k] et T[j]);

j := j + 1;

k := k + 1;

end;

La boule blanche est dans sa zone et il suffit de redéfinir le début de la zone rouge et de réduire la zone à

classer.

i j k

B B B B b b b R R R b R R B b R B R B b b R B b R B b R b B

98

Si la boule rencontrée est une rouge (R)

if T[k] = R then k := k + 1 end;

La boule rouge est dans sa zone et il suffit de réduire la zone à classer.

Si la boule rencontrée est une bleue (B)

if T[k] = B then

begin

PERMUTER (T[k] et T[j]);

PERMUTER (T[j] et T[i]);

i := i + 1;

j := j + 1;

k := k + 1;

end;

La boule bleue est dans sa zone et il suffit de redéfinir le début des zones blanche et rouge et de réduire la

zone à classer.

La complexité de l'algorithme est bien en O(n) puisque chaque boule n'est regardée qu'une seule fois.

Qu'en est-il si nous nous intéressons plus précisément au nombre d'opérations plus complexes, en

l'occurrence l'opération PERMUTER?

Avec NB bleues, nous accomplissons 2 * NB permutations, avec Nb blanches, Nb permutations et avec

les rouges, aucune permutation. Le nombre total de permutation est donc:

2 * NB + Nb. Si les boules sont en nombres égaux, nous avons 2 * m/3 + m/3 = m permutations.

Est-il possible d'améliorer l'algorithme? Les boules bleues posent problème car elles doivent subir un long

"déplacement" depuis la zone à classer jusqu'à leur zone propre. Comment réduire ce "déplacement"?

Tout naturellement en positionnant la zone de départ "au milieu" des autres zones. Ceci est représenté à

la figure suivante:

Il vient alors:

Si la boule rencontrée est une blanche (b)

if T[k] = b then k := k + 1 end;

La boule blanche est dans sa zone et il suffit de réduire la zone à classer.

i k j

B B B B b b b b B b R B R B b b R B b R B b R b B R R R R R

99

Si la boule rencontrée est une rouge (R)

if T[k] = R then

begin

PERMUTER (T[k] et T[j-1]);

j := j - 1;

end;

La boule rouge est devant sa zone qu'il convient alors de redéfinir. La zone à classer commence toujours

au même endroit.

Si la boule rencontrée est une bleue (B)

if T[k] = B then

begin

PERMUTER (T[k] et T[i]);

i := i + 1;

k := k + 1;

end;

La boule bleue est dans sa zone et il suffit de redéfinir le début des zones blanche et rouge.

Avons-nous amélioré l'algorithme? Le nombre de permutations est égal à NR + NB. Si les boules sont en

nombres égaux, nous avons m/3 + m/3 = 2*m/3 permutations. Ce second algorithme est meilleur.

Toutefois, ceci n'est vrai qu'en moyenne.

Le premier algorithme est meilleur si

2 * NB + Nb < NR + NB soit si

NB + Nb (= m – NR) < NR soit si m/2 < NR. Si le tableau initial est rempli à plus de 50% de rouges, le

premier algorithme est meilleur.

100

7.2 PROBLEME DU PLUS GRAND CARRE DE 1

Soit le tableau T[0 .. m-1, 0 .. n-1] avec m > 0 et n > 0, dont les T[i, j] sont égaux à 0 ou 1. Il importe de

trouver un sous-ensemble de T dont tous les éléments sont égaux à 1 et forment un carré.

Essayer tous les carrés de dimension inférieure ou égale au Min (m, n) présenterait une complexité de

calcul beaucoup trop grande. Recherchons une solution "idéale" c'est-à-dire qui ne nécessiterait qu'une

seule lecture de toutes les valeurs de T. Il faut par exemple parcourir T, ligne par ligne et dans chaque

ligne, regarder les colonnes successivement avant de passer à la ligne suivante. L'analyse de chaque

élément doit permettre de donner la taille du plus grand carré rencontré jusqu'alors. De la sorte, lorsque le

dernier élément aura été analysé, nous connaîtrons la taille du plus grand carré de 1 dans T.

Supposons que nous ayons déjà analysé une partie de T et que nous ayons à traiter T [i, j] et qu'à ce

moment la taille du plus grand carré soit égale à dmax. La situation est illustrée par la figure suivante:

Si T [i, j] = 0, il n'y a aucune possibilité d'agrandir le plus grand carré.

Si T [i, j] = 1, trois cas sont possibles, illustrés par les figures suivantes:

a est la taille du plus grand carré dont le coin inférieur droit est T[i, j-1]

b est la taille du plus grand carré dont le coin inférieur droit est T[i-1, j]

y est la taille du plus grand carré dont le coin inférieur droit est T[i, j]

1) Si a = b

Si ? = 1 alors y := a + 1 (= b + 1)

Si ? = 0 alors y := a

0 j n-1

0 1 0 0 1 1 1 0 1 0 0

1 1 0 1 0 1 1 1 1 1

1 0 0 1 1 0 1 1 1 1

0 1 1 1 0 1 1 1 1 1

0 1 1 0 1 1 1 1 0 1

1 0 0 1 1 1 1 0 0 0

i 1 0 1 1 1 1 0 1 0 0

0 1 1 0 1 1 0 1 1 0

0 1 1 1 1 1 0 1 0 1

0 1 1 0 1 1 1 0 1 1

1 1 1 1 1 1 1 0 0 1

1 0 1 0 1 1 1 1 1 0

0 1 1 1 1 1 0 1 1 0

m-1 1 1 1 1 1 1 0 1 1 1

?

b

a

1

101

2) Si a < b alors y := a + 1

3) Si a > b alors y := b + 1

La taille du plus grand carré de 1 rencontré jusqu'alors est: dmax := max (dmax, y)

Nous pouvons simplifier ces tests comme suit

if T [i, j] = 0 then y := 0

else

begin

c := min (a, b);

if T [i - c, j - c] = 0 then y := c

else

begin

y := c + 1;

dmax := max (dmax, y)

end;

end;

Pour disposer des valeurs a et b, il suffit de mémoriser dans un tableau TC [0 .. n-1] les y obtenus pour

les éléments de la ligne i dont les indices varient de 0 à j – 1 et les y obtenus pour les éléments de la ligne

i – 1 dont les indices varient de j à n – 1. Nous ajoutons alors une place supplémentaire en TC[-1],

initialisée à 0 pour traiter le cas j = 0 comme tous les autres j.

A l'initialisation, tous les éléments de TC sont bien évidemment égaux à 0.

L'algorithme est alors le suivant:

b

1

a

b

1

a

102

i := 0;

j := 0;

dmax := 0;

k := -1;

while i < m do

begin

if T [i, j] = 0 then TC [j] := 0

else

begin

c := min (TC [j - 1], TC [j]);

if T [i - c, j - c] = 0 then TC [j] := c (1)

else

begin

TC [j] := c + 1;

dmax := max (dmax, TC [j])

end;

end;

if j < n then j := j + 1

else

begin

j := 0;

i := i + 1

end;

end;

La complexité de cet algorithme est en O(n) puisque chaque élément du tableau est regardé une seule fois

et parfois deux fois (dans le cas (1)).

103

CHAPITRE 8 : L'ARCHITECTURE DES ORDINATEURS

Dans les chapitres précédents, nous nous sommes préoccupés de la conception des algorithmes. Pour qu'un

algorithme soit utile, on doit disposer d'un processeur capable de lire, comprendre et exécuter les

instructions que contient l'algorithme. Un ordinateur est un processeur à même d'exécuter de nombreux

types différents d'algorithmes. Ce chapitre présente la conception des ordinateurs.

8.1 STRUCTURE DES ORDINATEURS

Une fois les algorithmes élaborés, les programmeurs les transcrivent dans un langage de programmation

pour qu'ils puissent être exécutés par l'ordinateur. Malheureusement, les langages de programmation

évolués, bien qu'ils soient commodes pour l'utilisateur, ne se prêtent pas à une utilisation directe par les

ordinateurs en raison du coût prohibitif que de tels ordinateurs impliquent. Les constructeurs ont donc

fabriqué des ordinateurs pour exécuter des programmes écrits dans des langages beaucoup plus simples,

appelés langages machine.

A l'aube de l'informatique, les ordinateurs étaient construits afin d'exécuter directement des programmes

en langage machine. Cependant on s'est très vite rendu compte que les différentes instructions d'un langage

machine quel qu'il soit présentaient des ressemblances les unes avec les autres. Il est possible d'isoler un

nombre restreint d'opérations fondamentales, appelées micro-instructions, et d'exprimer toute instruction

de langage machine comme un ensemble restreint de ces micro-instructions. Pour des raisons d'économie,

les ordinateurs modernes sont conçus pour exécuter les micro-instructions plutôt que les programmes

directement en langage machine. Les constructeurs fournissent également un programme appelé

interpréteur (écrit en termes de micro-instructions) qui indique à l'ordinateur comment lire, comprendre

et exécuter chaque instruction du langage machine. Par conséquent, l'ensemble ordinateur / interpréteur

est en mesure d'exécuter des programmes écrits en langage machine. Ces ordinateurs sont appelés

ordinateurs microprogrammés.

L'équipement électronique d'un ordinateur est décrit dans les sections suivantes, en partant des principes

de physique et en aboutissant aux composants de base qui le constituent et qui permettent la manipulation

des micro-instructions. L'accent est mis surtout sur la conception de la mémoire et de l'UCT. De par leur

nature, les dispositifs d'entrée et de sortie comportent parfois des composants mécaniques puisqu'ils

doivent communiquer avec le monde extérieur (par exemple, fermer une soupape dans une usine de

produits chimiques). Les dispositifs d'entrée et de sortie sont décrits dans la dernière section.

104

8.2 PHYSIQUE ET ELECTRONIQUE

8.2.1 LES SEMI-CONDUCTEURS

Lorsqu'on ouvre un ordinateur, on voit un ensemble de plaquettes rectangulaires enfichées ensemble sur

un panneau d'assemblage. Chaque plaquette contient des boîtiers en plastique reliés les uns aux autres. A

l'intérieur de chaque boîtier se trouve une fine puce de silicium dont la taille n'excède généralement pas

le quart de celle d'un ongle. Afin de comprendre ce qui se passe dans une puce il faut examiner de très

près l'une de ses infimes parties. La puce est presque exclusivement composée de monocristal de silicium,

constituant principal du sable. Près de la surface, certaines zones de la structure cristalline contiennent des

traces de phosphore et d'autres des traces de bore. Il y a également quelques fines couches de bioxyde de

silicium ainsi que de minces lamelles d'aluminium.

Pour comprendre ce qu'est une puce, il faut savoir que ce sont ses caractéristiques électriques qui

comptent. L'aluminium, comme la plupart des métaux, est un bon conducteur d'électricité parce que ses

électrons peuvent se déplacer librement. D'autre part, le silicium pur est un mauvais conducteur parce que

tous ses électrons sont étroitement liés à l'intérieur de la structure cristalline. Le silicium comportant des

traces de phosphore a quelques électrons plus mobiles et il est appelé semi-conducteur de type n. Il possède

une capacité limitée de conduire une charge électrique négative. Inversement, le silicium comportant des

traces de bore manque d'électrons dans sa structure cristalline, et c'est pourquoi il a une capacité limitée

de conduire une charge électrique positive. C'est ce qu'on appelle un semi-conducteur de type p. Enfin, le

bioxyde de silicium est un mauvais conducteur (donc un bon isolant).

8.2.2 LES TRANSISTORS

Les différents matériaux des semi-conducteurs occupent toute la puce suivant une structure régulière. La

plus petite unité de la structure est appelée transistor. Il peut y avoir plus de 1000000 transistors sur une

seule puce.

Collecteur Base Emetteur

Silicium

n

n p

Isolant en

bioxyde de silicium

Conducteur en aluminium

Collecteur

Emetteur

Base

105

Comme le montre la figure ci-dessus, il y a trois emplacements dans chaque transistor où les conducteurs

d'aluminium sont en contact avec la surface du silicium. Ce sont le collecteur, la base et l'émetteur du

transistor. Les caractéristiques électriques des semi-conducteurs permettent au transistor d'agir comme un

commutateur (un interrupteur). Lorsque la tension correcte est appliquée à la base, un courant électrique

peut circuler entre le collecteur et l'émetteur. En l'absence d'une telle tension sur la base, aucun courant

ne peut passer. Le symbole du transistor est donné avec la figure précédente.

8.2.3 LES PORTES

Des transistors peuvent être interconnectés pour former ce qui est appelé une porte.

La figure ci-dessus montre deux transistors interconnectés de telle manière qu'un courant puisse passer du

collecteur de l'un à l'émetteur de l'autre uniquement lorsque la base des deux transistors est soumise à la

tension adéquate. Une telle configuration peut être utilisée comme la base d'une porte ET, réalisant la

fonction logique AND. On conçoit alors aisément qu'il est possible de construire des portes qui réaliseront

les autres fonctions logiques OR et NOT.

Une des caractéristiques importantes des portes est qu'elles ne réagissent qu'à une tension d'entrée basse

ou haute (autrement dit, l'état 0 ou l'état 1). Une porte a des entrées, dont chacune peut avoir une tension

correspondant soit à 0 soit à 1, et une sortie qui peut correspondre à 0 ou à 1 selon la valeur des entrées.

8.3 LES COMPOSANTS

Une puce peut comporter quelques milliers de portes et un ordinateur peut être constitué de nombreuses

puces, il est donc extrêmement difficile de comprendre les ordinateurs uniquement en termes de portes.

Leur nombre considérable et la complexité de leurs interconnexions rendent impossible la visualisation

de toutes les portes d'un ordinateur en une seule fois. Pour permettre cette visualisation, les ingénieurs ont

rassemblé des groupes de portes en des composants plus grands. Ce sont ces composants qui, associés les

uns aux autres, constituent un ordinateur complet. Les composants essentiels comprennent les mémoires,

les additionneurs, les bus, les horloges et l'unité de commande.

E

n

t

r

é

e

s Sortie

106

8.3.1 LES MEMOIRES

Il est essentiel que les ordinateurs soient en mesure de se souvenir des données, que ce soit pour un court

laps de temps (lorsqu'il s'agit par exemple de résultats intermédiaires obtenus pendant des calculs) ou que

ce soit pour des périodes plus longues. On appelle bascule (ou flip-flop) un composant qui peut stocker

soit 1 soit 0. Tout ce qui peut indifféremment prendre 0 ou 1 comme valeur est dit contenir un bit (un

élément d'information binaire, binary digit en anglais). Ainsi une bascule peut stocker un bit d'information.

Huit bits sont souvent appelés un octet.

Il va de soi que les ordinateurs doivent mémoriser plus d'un bit d'information. Par exemple, un nombre

peut comporter de nombreux chiffres qui tous doivent être conservés. Les ordinateurs manipulent

généralement les données par tranches de format fixe appelées mots, bien que la taille d'un mot puisse

varier d'un ordinateur à un autre. Actuellement, la taille d'un mot est souvent égale à 64 bits. Si la taille

du mot est de N bits, N bascules sont nécessaires pour conserver un mot de données. Un ensemble de

bascules est appelé registre.

De nombreux types différents de données peuvent tenir en un seul mot. Si seuls des entiers non négatifs

sont nécessaires, ils peuvent être représentés par le système de numérotation binaire. Si la taille d'un mot

est de N bits, ce système permet à tout nombre compris entre 0 et 2N - 1 d'être représenté. En général, le

bit le plus à droite (appelé bit le moins significatif) est la colonne des unités, et plus on s'éloigne vers la

gauche plus les colonnes augmentent en importance jusqu'au bit le plus à gauche (ou le plus significatif),

dont la valeur est de 2N-1.

Les nombres négatifs peuvent être représentés de la même manière. La seule modification consiste à

attribuer au bit le plus significatif le poids –2N - 1. On déduit assez facilement que tout nombre compris

entre –2N-1 et 2N-l - l peut être représenté par cette méthode, appelée représentation en complément à deux.

Une des caractéristiques de cette représentation est que les nombres négatifs et non négatifs peuvent

facilement être distingués en consultant le bit le plus significatif. Lorsque ce bit est à 1 le nombre est

négatif; lorsqu'il est à 0 le nombre est soit zéro soit positif.

Il est souhaitable de représenter non seulement des nombres mais aussi des caractères dans un ordinateur.

Plusieurs méthodes standard de représentation ont été établies; l'une d'entre elles est la représentation

ASCII. Cette convention utilise 7 bits pour représenter un caractère, de telle sorte que 27 caractères sont

possibles dans ce codage. Actuellement, on utilise 16 bits pour les caractères (code Unicode) afin de

pouvoir représenter les caractères de la plupart des langues du monde.

107

Il existe également d'autres méthodes normalisées pour représenter des nombres réels et des nombres à

grande précision contenant plus de chiffres que ne peut en contenir un seul mot. Ces méthodes utilisent

quelques mots pour représenter chaque nombre.

Les registres, qui peuvent contenir un mot de données, sont généralement construits à partir de la

technologie la plus rapide. Ceci les rend très coûteux et ils servent essentiellement à conserver des

informations fréquemment utilisées. Cependant, les ordinateurs ont aussi besoin de stocker de grandes

quantités d'information telles que les instructions d'un algorithme en cours d'exécution et la masse des

données sur lequel l'algorithme travaille. C'est pourquoi les ordinateurs ont une mémoire très importante

dont le fonctionnement est légèrement plus lent mais moins onéreux. C'est ce qu'on appelle la mémoire

centrale dont chaque cellule a une capacité égale à la taille du mot du processeur. Pour des raisons

pratiques, ces cellules sont numérotées consécutivement à partir de zéro. Autrement dit, elles sont

numérotées 0,1,2,3... Ces numéros sont appelés adresses, et chaque cellule a une adresse unique.

Pour stocker une information dans la mémoire centrale, il est nécessaire de fournir à la mémoire une

adresse, ainsi que l'information à stocker. La mémoire conserve alors l'information dans la cellule

mémoire correspondant à l'adresse donnée. Inversement, l'information peut être retrouvée en donnant une

adresse à la mémoire centrale. La mémoire restitue alors l'information contenue à l'adresse donnée.

Une distinction très importante, car déterminante dans le fonctionnement, l'architecture et le prix des

ordinateurs, doit se faire au sein des différentes sortes de mémoire, en fonction de leur temps d'accès,

immédiat ou plus ou moins différé, en fonction de leur vitesse de transmission et de leur capacité de

stockage. Cette distinction permet de hiérarchiser les mémoires en classant en premier les mémoires les

plus rapides mais, du coup, les plus chères: registres du CPU, mémoire cache, mémoire centrale, disque

dur, mémoire d'archivage.

En dehors de mémoires spécialisées ultrarapides comme celles contenant des microprogrammes, les

mémoires les plus rapides mais aux capacités extrêmement limitées sont les registres qui forment des

mémoires de quelques octets au plus, dont les temps de réponse s'inscrivent dans les étapes atomiques du

processeur et dont la rapidité conditionne étroitement la vitesse de son fonctionnement. Elles sont

talonnées par les mémoires cache ou antémémoires du processeur, avec une capacité de l'ordre de quelques

centaines de kilooctets au plus et des temps d'accès voisins de la nanoseconde, alimentant le processeur à

la cadence de ses instructions. Suit la mémoire centrale, de plusieurs centaines de mégaoctets, avec des

temps d'accès décuplés. Arrivent ensuite, après des millisecondes de temps d'accès, les disques

magnétiques, dont la capacité s'exprime cette fois en gigaoctets. Enfin existent les mémoires d'archivage,

108

aux capacités illimitées, ou exprimées en téraoctets, parfois gérées par des serveurs d'archivage et qui

combinent stockage au moindre coût et sauvegarde permettant la remise d'un fichier dans son état antérieur

(backup).

8.3.2 LES ADDITIONNEURS

Dans le système décimal, la table d'addition est la suivante:

0+0=0, retenue 0

0+1=1, retenue 0

0+2=2, retenue 0

0+3=3, retenue 0

...

4+5=9, retenue 0

4+6=0, retenue 1

4+7=1, retenue 1

4+8=2, retenue 1

...

9+9=8, retenue 1

De la même façon, le système binaire comporte sa table d'addition:

0+0=0, retenue 0

0+1=1, retenue 0

1+0=1, retenue 0

1+1=0, retenue 1

Il faut remarquer que la retenue n'est nécessaire que lorsque les deux chiffres sont 1. C'est le cas précis où

la sortie de la porte ET est 1. Par conséquent, la porte ET est idéale pour calculer la retenue. La table

d'addition binaire montre également que la somme de deux chiffres est égale à 1 si et seulement si un

chiffre est 1 et l'autre 0. Par conséquent, la somme peut être obtenue par (premier chiffre AND NOT

second chiffre) OR (second chiffre AND NOT premier chiffre). Nous venons de construire un composant

appelé demi-additionneur qui additionne deux chiffres et produit une somme et une retenue et qui est

entièrement composé de portes.

Un ordinateur doit naturellement être capable d'additionner de grands nombres comportant de nombreux

chiffres. Si la taille du mot est de N chiffres, un additionneur de N chiffres est nécessaire. On l'obtient en

connectant judicieusement des demi-additionneurs prenant en charge les bits de chaque colonne des

chiffres à additionner et la retenue de la colonne précédente. On peut alors admettre que toutes les

opérations mathématiques et logiques peuvent être accomplies à l'aide de portes et de nombres ou de

caractères codés dans le système binaire.

109

8.3.3 LES BUS DE DONNEES

Parce qu'un ordinateur est construit autour d'un certain nombre de composants, il est essentiel qu'il soit en

mesure de transmettre les informations dans les deux sens entre la mémoire et l'additionneur. Ce

mouvement des informations est obtenu grâce aux bus de données. Un bus de données est à la base un

assemblage de fils qui connectent les différents composants entre eux. Le nombre de fils dans un bus de

données correspond généralement à la taille du mot du processeur. Ainsi les données seront transférées

par lots de dimension égale à celle que les composants peuvent manipuler. Par exemple, si l'additionneur

peut additionner des nombres de 64 bits et que la taille de la cellule mémoire est de 64 bits, il est

souhaitable que le bus de données puisse transférer 64 bits de données dans les deux sens entre

l'additionneur et la mémoire. Puisqu'un bus de données ne peut transférer qu'un mot de données à la fois,

il faut nécessairement déterminer quels sont les composants autorisés à utiliser les bus à tout moment.

Cela peut être effectué en connectant chaque composant au bus par l'intermédiaire d'une porte ET dont

l'autre entrée est un signal de contrôle qui ne libère le mot de données que si le bus est libre.

8.3.4 LES HORLOGES ET L'UNITE DE COMMANDE

Nous avons vu qu'un ordinateur consiste en divers composants interconnectés par des bus de données.

Ces composants requièrent souvent des signaux de contrôle pour régulariser leur activité. Un signal de

contrôle détermine par exemple le moment où une bascule stocke son entrée de données. Il en va de même

lorsqu'il faut déterminer le composant qui doit transférer ses données sur un bus. Les signaux de contrôle

émanent d'un composant informatique connu sous le nom d'unité de commande qui a pour fonction de

réaliser le transfert de composant à composant des données qui conviennent au moment voulu, et de

s'assurer que les opérations correctes sur les données sont effectuées.

L'horloge représente un élément important de l'unité de commande; elle produit des signaux à intervalles

réguliers. Ces signaux sont utilisés par l'unité de commande pour s'assurer que tous les composants de

l'ordinateur fonctionnent en synchronisation les uns avec les autres, et que chaque composant dispose d'un

temps suffisant pour mener son opération à terme.

8.4 COMMUNIQUER AVEC LE MONDE EXTERIEUR

Notre discussion a porté jusqu'à présent dans ce chapitre sur l'unité centrale de traitement (UCT) et sur la

mémoire centrale des ordinateurs. Naturellement, les ordinateurs peuvent aussi échanger de l'information

avec le monde extérieur.

110

8.4.1. CONNEXION DES PERIPHERIQUES

Pour réaliser leur fonction d'acquisition ou de restitution des données, les périphériques doivent être

raccordés à l'unité centrale de telle sorte qu'ils puissent recevoir des commandes, transférer les données

depuis ou vers la mémoire centrale, et annoncer la fin d'une opération ou tout autre changement dans leur

état. Le raccordement se définit en termes de câblage, de tensions électriques, de signaux de commande

et d'horloge, le tout formant l'interface E/S. Les modes de raccordement des périphériques aux ordinateurs

varient, non seulement en fonction de la nature des périphériques, mais aussi de l'époque de leur

développement.

Port: un port relie un seul périphérique au travers d'une interface généralement spécialisée pour ce type

particulier de périphérique.

Port parallèle: Le port parallèle effectue des transmissions entre unité centrale et périphérique par

envois de 8 bits en parallèle. Le port parallèle est encore présent dans la plupart des configurations de

PC, mais on tend à lui préférer le bus série USB.

Port sériel: Présents dans les ordinateurs en nombre variable (de 1 à 4) et désignés par le terme COM

suivi de leur numéro de 1 à 4, ils sont destinés principalement à la connexion de modems ou d'autres

dispositifs de télécommunication au débit limité.

Port clavier et port souris: Destinés chacun au raccordement de ces deux périphériques à faible

vitesse.

LAN: Ce port est destiné à la connexion aux réseaux locaux.

Port AGP: Le port AGP (Accelerated Graphics Port) est un port spécialisé, dédié à l'affichage

graphique, pour former une voie rapide acheminant directement la masse importante de données

nécessaires pour afficher les images animées.

Bus: à l'instar du bus interne, intégré dans l'unité centrale, les différents bus externes sont prévus pour

accueillir plusieurs périphériques pouvant être de nature différente, mais utilisant la même interface. Un

bus ne possède qu'une logique limitée, la gestion des opérations d'entrées-sorties devant être assumée pour

sa plus grande part par le périphérique lui-même. Il est dès lors capital, tant pour les constructeurs

informatiques que pour ceux des périphériques, de se mettre d'accord sur une technologie de bus destinée

à devenir le standard.

USB: Le bus USB a été conçu de manière à simplifier et à standardiser l'interface avec les multiples

périphériques. Il est utilisé indifféremment pour la connexion d'imprimantes, de modems ADSL, de

graveurs de CD, d'appareils photo numériques, de Webcams, de disques externes, pour autant que ces

périphériques présentent l'interface USB. Cela permet de limiter le nombre de connecteurs sur

111

l'ordinateur et ne subsisteront bientôt, en sortie de celui-ci, que les ports USB et ceux exigeant un débit

très important.

Firewire: Le bus Firewire trouve son origine dans le monde Apple et est le concurrent direct de l'USB.

Ces deux technologies de bus ont en commun qu'elles peuvent supporter un grand nombre de

périphériques pour un même port, qu'elles peuvent fonctionner en mode isochrone (à débit constant

comme la voix, la musique ou la vidéo) ou asynchrone, et qu'elles sont plug and play, le périphérique

étant reconnu et les pilotes associés automatiquement installés que ce soit pendant le fonctionnement ou

lors de l'allumage de l'ordinateur.

Contrôleur: un contrôleur regroupe plusieurs appareils périphériques de même nature, en économisant

ainsi le nombre de liaisons avec l'unité centrale et en gérant des fonctions communes. Doté de plus en plus

souvent d'un processeur, il s'occupe également de la gestion algorithmique de ce type de périphérique.

Contrôleur IDE: Le contrôleur IDE se présente actuellement sous le nom de EIDE (Enhanced

Integrated Drive Electronics). Il gère les unités à disques magnétiques, les disquettes, les périphériques

CD et DVD.

PCMCIA ou PC Card: Les cartes PCMCIA (Personal Computer Memory Card International

Association) sont destinées à être insérées dans un emplacement prévu à cet effet et caractéristique des

PC portables. Les PC Cards ont le format d'une carte de crédit et se présentent sous différents types

correspondant à des épaisseurs différentes. Les PC Cards peuvent contenir des mémoires flash, des

disques magnétiques miniaturisés, des connexions aux réseaux locaux ou au GSM. Leur compatibilité

de fonctionnement avec un ordinateur particulier et son système d'exploitation demande une vérification

préalable à l'achat.

Contrôleur SCSI: Sur les PC, les contrôleurs SCSI sont plutôt réservés aux configurations haut de

gamme, telles que les serveurs. Il faut considérer le contrôleur SCSI comme un super-contrôleur,

régulant à travers un bus qui lui est propre, les échanges entre des contrôleurs de périphériques et le bus

du PC. Les contrôleurs SCSI se présentent sous la forme d'une carte à enficher sur le bus du PC. Ils

offrent leurs avantages propres, particulièrement la simultanéité dans l'exécution des opérations

d'entrée-sortie et la connexion à haut débit de périphériques externes au boîtier du PC.

8.4.2 PERIPHERIQUES

8.4.2.1 Mémoires externes

Les mémoires externes ou secondaires permettent le stockage de volumes importants de données sous une

forme directement exploitable par les programmes. L'enregistrement des données repose sur les lois du

112

magnétisme dans le cas des disques durs ou sur des variations dans la réflexion de la lumière, dans le cas

du CD. Qu'elles soient de nature magnétique ou optique, les données emplissent un plateau mis en rotation

afin que chacun de ses points enregistrés soit présenté à tour de rôle devant un organe de lecture doublé

selon le cas d'un organe d'écriture et nommé tête. Les têtes elles-mêmes se déplacent radialement afin de

pouvoir balayer l'entièreté du plateau. Un autre type de mémoire de masse (mémoire flash), avec une

capacité relativement limitée, est basé sur des circuits électroniques de type statique.

Disques magnétiques

Les disques magnétiques dits aussi disques durs sont constitués d'un ou plusieurs plateaux rigides tournant

à vitesse élevée et constante. Les faces du plateau sont couvertes d'une mince couche d'un matériau

magnétisable, dont les qualités permettent une densité d'enregistrement très élevée. L'état magnétique d'un

point du plateau peut être modifié par un électro-aimant situé sur la tête de lecture-écriture. Les têtes de

lecture-écriture sont montées au bout d'un bras actionné par un servomécanisme qui leur permet de balayer

transversalement le plateau, s'immobilisant en des positions séparées par des distances infimes, de l'ordre

du micromètre. Les différentes positions transversales d'arrêt du bras déterminent sur chaque face du

plateau autant de pistes concentriques dont l'empilement sur deux faces ou plus est considéré comme un

cylindre. Le temps de déplacement du bras y compris la phase de stabilisation est au minimum de quelques

millisecondes. La longueur de ce temps de latence pour se rendre d'une piste à l'autre explique en partie

la lenteur de celui-ci et pourquoi on préfère n'y faire appel que pour d'importants transferts de données.

Chaque piste est à son tour divisée en secteurs, chaque secteur pouvant généralement contenir 512 octets.

Par exemple, un disque pourrait contenir 1024 pistes, chacune comprenant 64 secteurs. La capacité des

disques durs est en augmentation constante et aujourd'hui autour des centaines de Gigaoctets (GB ou

Gigabytes). Le compartimentage des surfaces du disque en cylindres, pistes et secteurs est une opération

nommée formatage. Elle consiste à écrire sur le disque des informations qui serviront de balises et entre

lesquelles les données pourront être écrites et retrouvées. Grâce à ces balises, le bras pourra retrouver

l'aplomb de la piste et pourra lire les secteurs qu'elles encadrent.

En cas de détérioration accidentelle de la structure physique ou logique du disque, il peut être nécessaire

de procéder à un nouveau formatage, mais il faut savoir qu'il en résultera l'effacement total de toutes les

données se trouvant sur le disque, et leur perte irrémédiable si elles n'ont pas pu être sauvegardées par

copie préalable sur un autre support.

113

CD et DVD

Le Compact Disk est un support avec une seule face contenant les données enregistrées. L'enregistrement

des données se fait par l'entremise de déformations ponctuelles de la couche du support, déformations

entravant la réflexion d'un rayon laser. Soit la lumière est réfléchie par l'absence de déformations et le bit

est à 1, soit elle est plutôt dispersée par celle-ci et le bit est à 0. La lecture d'un CD se fait par la mise en

rotation du CD et le déplacement transversal d'un dispositif optique projetant un rayon laser vers le CD et

d'un capteur qui mesure la quantité de lumière réfléchie.

Le CD-ROM (Read Only Memory) comporte une couche de données constituée par un matériau

réfléchissant dans lequel sont gravées des micro-cuvettes qui provoquent la dispersion de la lumière

incidente. Cette couche de données est pressée une fois pour toutes lors de sa création par un appareillage

spécialisé en production de masse. Le CD-ROM constitue un support bien adapté à la diffusion à grande

échelle de gros volumes d'informations telles que des musiques et des images.

Le CD-R (Recordable) est un CD inscriptible dont la couche de données peut être modifiée une seule fois

par un appareil nommé graveur de CD. Il est possible d'écrire à plusieurs reprises (sessions) sur un CD-

R, mais à des endroits différents. Une partie écrite ne peut plus être modifiée. Le CD-RW (ReWritable)

est un CD semblable au CD-R, mais dont la couche de données peut être remise dans son état original de

transparence avant d'être à nouveau modifiée. Ce processus peut être répété un grand nombre de fois par

un graveur de CD.

Le DVD (pour Digital Video Disk, et ultérieurement Digital Versatile Disk pour exprimer sa polyvalence)

reprend les principes de fonctionnement du CD en les optimisant considérablement suite aux progrès de

la technique.

Mémoires flash

Outre leurs usages pour des mémoires spécialisées au sein des ordinateurs, les mémoires flash sont

utilisées pour le stockage de données externes de deux manières différentes, qui se distinguent par la

capacité et la connectivité. Ce type de mémoire de masse est basé sur des circuits électroniques de type

statique. Le Stick USB, dont la capacité de mémorisation est sans cesse croissante, en est une application

remarquable connectable à l'ordinateur via le port USB. De plus, avec l'arrivée massive des appareils

électroniques (appareils photo, caméras vidéo, etc.) sont arrivés des supports de stockage inhérents à ces

derniers, afin de conserver en mémoire des films photo et des CD, des fichiers d'images, des fichiers de

musique, etc.

114

8.4.2.2 Autres périphériques

Le clavier

L'enfoncement et le relâchement d'une touche du clavier se traduit par la modification d'un niveau résistif

ou capacitif. Un composant électronique interne au clavier analyse des centaines de fois par seconde l'état

de chaque touche. A chaque variation d'état, et pour autant qu'elle soit significative dans le temps, il génère

un code touche qui décrit l'emplacement sur le clavier de la touche. Ensuite, il place ce code dans un

registre d'interface E/S qui est alors prélevé par un programme de l'unité centrale.

Le clavier informatique demeure le moyen le plus utilisé pour saisir et stocker des documents écrits. Une

grande variété d'appareils de lecture ou de senseurs automatiques assure de leur côté l'acquisition de

données dans des domaines allant de la médecine à l'industrie, pour des applications bien définies et au

contenu documentaire très spécifique. La reconnaissance de la voix fait d'immense progrès et la dictée de

texte ou de commandes est une réalité. La lecture par scanner d'une page imprimée, suivie de l'analyse de

chaque élément de l'image obtenue et de la transposition des caractères reconnus en codes de l'alphabet

informatique (OCR ou Optical Character Recognition), permet d'éviter la recopie de textes imprimés. La

reconnaissance de l'écriture manuscrite peut aussi, dans certains cas, supplanter le clavier.

La souris

Avec une interface graphique, les déplacements du curseur sur l'écran se font en fonction des mouvements

de la souris, et les objets sur l'écran peuvent être activés par des simples ou doubles clicks sur les boutons

de la souris. Le concept de la souris comme outil de dialogue a pris naissance dans les laboratoires de

recherche de Xerox. Apple a été le premier à mettre sur le marché des micro-ordinateurs exploitant ce

concept, Microsoft s'y ralliant ultérieurement avec l'interface Windows. Des outils de pointage plus précis

et beaucoup plus coûteux que la souris, comme les tablettes graphiques, sont utilisés pour réaliser des

dessins et des plans. D'autres outils de pointage tels les manettes de jeux sont apparentés à la souris.

L'écran

Bien que presque disparu, voyons comment fonctionne un écran à tube cathodique (Cathodic Ray Tube

ou CRT) monochrome. Sous l'effet de champs magnétiques qui provoquent sa déflection, un mince

faisceau d'électrons émis depuis le fond du tube, puis accéléré et orienté par des plaques de déflection,

balaie en une succession de lignes horizontales, composées de points appelés pixels, la surface de l'écran.

Celle-ci est couverte sur sa face interne d'un matériau phosphorescent qui émet de la lumière sous l'effet

115

de l'impact du faisceau d'électrons, l'intensité de l'émission étant fonction de celle du faisceau d'électrons.

Le matériau doit continuer à émettre de la lumière (effet de rémanence) pendant une durée égale à celle

du balayage total de l'écran. Pour obtenir une image en couleurs, il est fait usage de trois faisceaux, chacun

correspondant à chacune des trois couleurs fondamentales utilisées en mode additif. rouge, vert, bleu

(signaux Red, Green, Blue ou RGB). A chaque pixel sont associées trois taches ou sous-pixels, dont la

composition des intensités individuelles reconstitue pour l'œil humain la couleur du pixel vu comme un

ensemble. La proximité (pitch) des sous-pixels au sein d'un pixel est un des critères de qualité d'un écran.

La dimension de l'écran est exprimée par sa diagonale exprimée en pouces et détermine la quantité

d'informations qui peuvent être affichées avec une lisibilité suffisante. La résolution représente le nombre

de pixels affichés par ligne ainsi que le nombre de lignes, par exemple 640x480 pour l'ancien mode VGA,

mais aujourd'hui des résolutions de l600xl200 sont possibles.

Les écrans à cristaux liquides (Liquid crystal display ou LCD) sont aussi dits écrans plats car constitués

d'une superposition de plaques minces et ne présentant pas l'embonpoint caractéristique du tube

cathodique. Leur principe de fonctionnement repose sur la propagation de la lumière polarisée et sur la

propriété qu'ont certains cristaux de modifier, en fonction d'une charge électrique qui leur est appliquée,

le sens de la polarisation de la lumière qui les traverse.

Les imprimantes

Les imprimantes peuvent être classées en deux catégories principales: laser et jet d'encre. La résolution

linéaire des imprimantes est exprimée en points ou dots par pouce (dpi). Le point ou dot constitue la plus

petite partie imprimable et, à ce titre, correspond au pixel des écrans.

L'imprimante laser est essentiellement constituée d'un tambour dont la surface reçoit une charge électrique

en fonction de l'éclairement fourni par un rayon laser ou une source lumineuse équivalente. L'éclairement

du tambour est modulé en fonction des dots de l'image à imprimer. Le tambour est mis en rotation et se

charge de fines particules d'encre (toner) pour former l'image de la page. Les particules de toner sont

ensuite attirées vers la surface de la feuille de papier qui défile de manière synchrone au tambour. Le toner

est enfin fixé sur la feuille de papier par la chaleur d'un four.

L'imprimante à jet d'encre est composée essentiellement d'une tête d'impression se déplaçant sur un axe

transversalement par rapport à la feuille de papier. Au cours de ce déplacement, la tête d'impression

projette sur le papier de minuscules gouttelettes d'encres de différentes couleurs. Lorsque la tête arrive au

bord de la feuille, cette dernière est avancée d'un pas et la tête reprend sa course.

116

CHAPITRE 9 : L'EXECUTION DES ALGORITHMES

9.1 INTRODUCTION

L'ordinateur, en tant que machine, n'est pas très pratique, et ce pour plusieurs raisons.

1) Il n'existe pas de méthode simple pour entrer un programme dans la mémoire de l'ordinateur.

2) Les possibilités d'entrée/sortie (E/S) sont difficiles à utiliser puisque le programmeur doit connaître

les particularités de chaque périphérique. Il doit également s'assurer que son programme examine les

registres d'état des périphériques pour voir à quel moment le processus d'entrée ou de sortie s'achève;

il doit fournir des modules de traitement des interruptions. L'organisation détaillée des transactions

E/S détourne une partie importante de l'effort de programmation de la tâche la plus importante, qui est

celle de l'implantation de l'algorithme.

3) Il n'existe pas de méthode simple pour qu'un programme ou ses données dépasse la capacité de la

mémoire centrale de l'ordinateur.

4) Toutes les ressources informatiques sont consacrées à exécuter un seul programme. Le programme

pourrait n'occuper qu'une fraction de la mémoire centrale et n'utiliser qu'un ou deux périphériques,

mais pourtant le reste de la mémoire et les autres périphériques sont inactifs jusqu'à la fin de l'exécution

du programme. Si le prix de l'ordinateur est élevé, la sous-utilisation de ses ressources coûte cher.

5) L'ordinateur doit être programmé en langage machine. Pour des raisons expliquées plus loin, le

langage machine est tout à fait inapproprié pour la plupart des tâches de programmation.

Le matériel de l'ordinateur a donc besoin de possibilités supplémentaires pour rendre son exploitation plus

aisée et plus économique. Ces possibilités sont données par une série de programmes généralement fournis

par le constructeur. Les programmes font partie du système informatique au même titre que le matériel

lui-même, et sont souvent appelés logiciels de base ou programmes système. Dans ce chapitre, nous allons

décrire les composants essentiels du logiciel système, en insistant particulièrement sur deux de ses aspects.

(1) Le système d exploitation

Un ordinateur est équipé d'un seul système d'exploitation (bien qu'un constructeur puisse en offrir

plusieurs au choix), qui a trois fonctions essentielles:

1) il permet à l'utilisateur de ne plus se préoccuper de quelques-unes des caractéristiques ingrates de

l'ordinateur, telles que la capacité mémoire et les idiosyncrasies de ses possibilités E/S;

117

2) il offre une grande souplesse dans la manipulation des programmes et des données, de telle sorte qu'un

utilisateur peut, par exemple, demander qu'un programme particulier soit exécuté avec un ensemble

particulier de données en entrée, et solliciter une sortie imprimée;

3) il permet le partage des possibilités de l'ordinateur entre plusieurs utilisateurs, de manière simultanée.

Les systèmes d'exploitation seront décrits en détail dans la section 9.3.

(2) Les traducteurs de langage (compilateurs, interpréteurs, ... )

Les traducteurs de langage traduisent des programmes écrits en langages évolués (PASCAL, C, C++, ...)

dans le langage machine de l'ordinateur concerné. Un traducteur différent est nécessaire pour chaque

langage évolué utilisé. Les traducteurs sont divisés en deux catégories: les interpréteurs et les

compilateurs. Elles seront toutes deux décrites dans la section 9.2.

(3) Les éditeurs

Pendant la mise au point d'un programme, il est généralement nécessaire d'apporter des modifications

fréquentes (corrections d'erreurs, ajouts de modules, et ainsi de suite). Pour éviter une saisie trop

importante, il est d'usage de conserver le programme dans une mémoire auxiliaire et d'accomplir les

modifications in situ. Le programme système utilisé pour apporter ces modifications est appelé un éditeur.

En fait, un éditeur peut être non seulement utilisé pour modifier des programmes mais encore pour

modifier tout autre élément d'information textuelle susceptible d'être conservé en mémoire auxiliaire. La

nature de cette information varie selon les applications informatiques: une documentation de programme,

des manuels d'utilisateurs, des lettres types, des bibliographies, des catalogues et le texte de ce syllabus.

Les possibilités offertes par un éditeur permettent à l'utilisateur de localiser, de supprimer ou de remplacer

des parties spécifiques d'un texte et d'en insérer là où c'est nécessaire. La plupart des éditeurs sont utilisés

interactivement: l'utilisateur donne des commandes à l'éditeur à partir d'un terminal et examine les

modifications apportées.

(4) Les chargeurs

Certains traducteurs de langage stockent les programmes traduits dans une mémoire auxiliaire jusqu'à ce

qu'ils soient appelés pour être exécutés. Le programme système chargé du transfert des programmes à

partir de la mémoire est appelé chargeur. La plupart des chargeurs sont également capables d'associer des

modules de programmes traduits en différentes occasions à l'intérieur d'un seul programme en langage

118

machine. Cette caractéristique est particulièrement utile pour incorporer des modules "bibliothèque",

souvent utilisés dans un programme, sans avoir à les traduire à plusieurs reprises.

9.2 TRADUCTEURS DE LANGAGE DE PROGRAMMATION

La fonction d'un traducteur de langage est, comme nous venons de l'indiquer, de traduire en langage

machine des programmes écrits en langage évolué. Les programmes peuvent ensuite être exécutés par un

interpréteur microprogrammé qui transforme les instructions en langage machine en signaux de

commande pour l'exécution.

Il y a deux sortes d'entités dans un programme en langage évolué pour lesquelles une traduction est

nécessaire: les instructions et les structures de données. Les instructions d'un langage évolué sont

généralement plus puissantes que celles d'un langage machine, ainsi, un processus représenté par une seule

instruction en langage évolué en nécessitera plusieurs en langage machine. C'est pourquoi chaque

instruction en langage évolué est généralement traduite par plusieurs instructions en langage machine,

processus pouvant être considéré comme un affinement supplémentaire de l'instruction en langage évolué.

Les structures de données pouvant être manipulées par un langage évolué (par exemple des listes, des

séquences de caractères, des arbres) ne sont pas directement exploitables en langage machine. De telles

structures doivent, au contraire, être représentées sous forme de bits, de nombres et d'adresses; le

traducteur doit transformer les structures de données évoluées en une représentation au niveau machine.

Généralement parlant, deux stratégies différentes peuvent être adoptées pour la traduction de programmes

évolués: il s'agit de l'interprétation et de la compilation.

9.2.1 L'INTERPRETATION

L'interprétation consiste à traduire et à exécuter un programme évolué, instruction par instruction,

l'exécution de chaque instruction précédant la traduction de la suivante. L'interprétation est accomplie par

l'interpréteur. Dans la plupart des cas, l'interpréteur est équipé d'un ensemble de modules de série. Chacun

d'entre eux consiste en une séquence d'instructions en langage machine correspondant à un type particulier

d'instructions en langage évolué. De ce fait, pour traduire chaque instruction évoluée, l'interpréteur

l'examine simplement afin de savoir à quel type elle appartient et de choisir le module devant être appelé.

Les éléments de données manipulés par une instruction évoluée (c'est-à-dire les opérandes de l'instruction)

sont entrés dans le module comme paramètres. Lorsque le module a été exécuté, le contrôle est rendu à

119

l'interpréteur qui répète ensuite le cycle pour l'instruction suivante. L'interpréteur détermine le type de

chaque instruction évoluée en utilisant les règles syntaxiques du langage concerné. Si une instruction

comporte une erreur de syntaxe, l'interpréteur la détecte et la signale; mais puisque l'erreur rend souvent

l'instruction ambiguë, aucune tentative ne sera faite pour l'exécuter.

La vitesse de traduction dépend en grande partie de la syntaxe du langage traduit: plus complexe est la

syntaxe, plus lente est la traduction. Puisqu'un interpréteur traduit chaque instruction évoluée à chaque

fois qu'elle est exécutée, il est avantageux de conserver une syntaxe aussi simple que possible, et c'est un

des principaux objectifs de conception de la plupart des langages évolués normalement traduits par

l'interprétation. En Basic, par exemple, langage conçu dans l'esprit d'une traduction interprétative, le type

de chaque instruction n'est déterminé que par son premier mot. L'interpréteur microprogrammé gagne

également en rapidité à partir de la syntaxe très simple du langage machine qu'il interprète. Le type d'une

instruction en langage machine est déterminé par son code opération, et son unique opérande est constituée

par les données stockées dans la cellule mémoire indiquée par son adresse.

9.2.2 LA COMPILATION

Lorsqu'un programme évolué est traduit par un interpréteur, la traduction s'effectue en fonction de

l'exécution. Par conséquent, si une instruction doit être exécutée plusieurs fois (dans une boucle, par

exemple), elle doit également être traduite plusieurs fois, puisque l'exécution et la traduction de chaque

instruction sont inextricablement liées. Ceci peut constituer une source de coût inutile, puisque la

retraduction est nettement redondante. Le coût dû à une nouvelle traduction est inacceptable, en particulier

dans l'une des circonstances suivantes:

1) le temps de traduction d'une instruction dépasse celui de son exécution;

2) le programme est fréquemment exécuté sans aucune modification (par exemple, un programme de

bulletins de salaire);

3) la vitesse d'exécution est prioritaire (par exemple, quand les réponses doivent être rapides comme dans

le cas de réservations auprès de compagnies aériennes).

Pour éviter un tel coût, il faut adopter une stratégie différente de traduction et d'exécution de programmes.

Cette stratégie sépare la traduction de l'exécution en insistant sur le fait que tout le programme doit être

traduit avant son exécution. La traduction d'un programme évolué est appelée compilation. Elle est

exécutée par un programme système, le compilateur. L'exécution ne commence que lorsque le programme

évolué a été entièrement compilé. Le programme évolué initial est le programme source, et celui résultant

de la compilation est le programme objet. C'est ce dernier qui est exécuté.

120

Puisque la compilation et l'exécution constituent des processus distincts, un programme objet, lorsqu'il a

été produit par un compilateur, peut être exécuté plusieurs fois sans que soit nécessaire une autre

compilation du programme source. Il suffit de trouver un moyen de stocker le programme objet jusqu'à

ce que son exécution soit à nouveau requise. La mémoire auxiliaire d'un ordinateur est généralement

utilisée à cette fin, et le programme objet est transféré par un chargeur en mémoire lorsque l'exécution est

requise.

9.3 LES SYSTEMES D'EXPLOITATION

9.3.1 GENERALITES

Le système d'exploitation est sans doute la partie la plus importante et la plus complexe des logiciels de

base accompagnant un ordinateur. Il est chargé de fournir les dispositifs qui rendent l'utilisation de

l'ordinateur plus simple et plus rentable. Tous les ordinateurs (à l'exception de quelques microordinateurs

très rudimentaires) ont un système d'exploitation généralement fourni par le constructeur, et souvent, le

système d'exploitation est le produit d'un effort de conception et de développement comparable à celui

fourni pour l'équipement de l'ordinateur. Les raisons pour lesquelles les systèmes d'exploitation sont si

complexes apparaîtront progressivement au long de cette section.

Un système d'exploitation accomplit de multiples tâches minutieuses qui peuvent en gros être classées de

la manière suivante :

1) Il permet à l'utilisateur où à d'autres logiciels de base de ne plus se préoccuper de certaines

caractéristiques de l'équipement de l'ordinateur. Certaines des caractéristiques les plus difficiles

concernent la manipulation des dispositifs d'entrée et de sortie (E/S), et le système d'exploitation joue

un rôle fondamental en simplifiant l'utilisation de ces dispositifs.

2) Il permet de manipuler les programmes et les données avec une plus grande aisance. Le système

d'exploitation gère l'interprétation des commandes requises et organise l'ensemble complexe des

actions nécessaires pour son exécution.

3) Il permet également de partager les ressources informatiques entre plusieurs utilisateurs simultanés.

L'objectif recherché est l'augmentation de la disponibilité de l'ordinateur pour ses utilisateurs, et en

même temps l'optimisation de l'utilisation de ressources telles que le processeur, la mémoire et les

périphériques.

Pour apprécier les fonctions du système d'exploitation, il suffit de se demander comment fonctionnerait

un ordinateur qui n'en posséderait pas. Supposons, par exemple, que l'ordinateur doive exécuter différents

121

travaux pour un nombre donné d'utilisateurs. (Un travail peut consister en une série d'actions nécessaires

pour exécuter le programme d'un utilisateur. Dans un cas élémentaire, il peut impliquer une compilation

du programme source, suivie du chargement et de l'exécution du programme objet.) Si l'ordinateur n'a pas

de système d'exploitation, l'utilisateur doit passer, pour que son travail soit exécuté, par toute une série

d'étapes telles que:

1) présenter le programme source au dispositif d'entrée correspondant;

2) apprendre au compilateur à lire le programme source à partir du dispositif d'entrée, le compiler et

ranger le programme objet obtenu dans une mémoire auxiliaire;

3) apprendre au chargeur comment transférer le programme objet de la mémoire auxiliaire vers la

mémoire centrale;

4) placer les données nécessaires au programme dans le dispositif d'entrée approprié;

5) lancer l'exécution du programme objet;

6) recueillir les données en sortie sur le dispositif que le programme utilise (exemple une imprimante).

Avec cette procédure, le rendement de l'ordinateur (c'est-à-dire le nombre de tâches qu'il accomplit en un

temps donné) est limité par la rapidité à laquelle chaque utilisateur peut presser les touches appropriées et

manipuler les dispositifs E/S employés par son programme. De telles activités humaines sont d'une lenteur

fastidieuse si on les compare à la vitesse d'exécution du processeur, aussi est-il clair que l'ordinateur n'est

pas utilisé de manière rentable.

Une des premières choses à faire pour augmenter le rendement est de confier la manipulation des touches

et des dispositifs E/S à un opérateur spécialisé, mais si l'on considère la grande disparité entre l'homme et

les vitesses d'exécution de l'ordinateur, l'amélioration est encore marginale. Une mesure plus efficace

consiste à automatiser la séquence d'opérations nécessaires à l'exécution d'un travail, de telle sorte que

l'intervention humaine soit réduite au minimum. Cela peut être fait en introduisant un petit programme de

contrôle dont la fonction est de lancer le compilateur, le chargeur et le programme objet aux moments

adéquats. Il ne reste plus à l'opérateur qu'à manipuler les dispositifs E/S.

Grâce à la réduction maximale de l'intervention humaine, le rendement de l'ordinateur est maintenant

limité par la vitesse à laquelle il peut accomplir les E/S. Une augmentation du rendement ne peut être

accomplie que lorsqu'on supprime la limitation des E/S: il faut pour ce faire utiliser les interruptions de

programme, signaux envoyés par les dispositifs qui indiquent au processeur central qu'un transfert de

données est achevé. Un dispositif d'interruption permet au processeur de lancer un transfert E/S, de

poursuivre le traitement pendant que le transfert s'effectue et de recevoir une notification par le moyen

d'une interruption lorsque le transfert est achevé.

122

La capacité de faire se chevaucher des transferts E/S avec le traitement ne peut être exploitée que si le

processeur peut accomplir une autre tâche pendant chaque transfert. Ce que permet la

multiprogrammation qui est le fait de pouvoir stocker et traiter les programmes et les données de plusieurs

tâches en même temps en mémoire centrale. Pendant qu'un travail attend une opération d'entrée ou de

sortie, le processeur peut exécuter une autre tâche. La fin du transfert E/S est signalée par une interruption,

et le travail en attente peut être repris soit immédiatement soit quand le second travail sollicite à son tour

une entrée ou une sortie. C'est ce qu'illustre la figure 9.1 qui montre l'exécution de trois tâches dans un

système multiprogrammé. Pour simplifier, les trois tâches de notre exemple sont identiques, chacune

requérant un temps d'exécution d'une ms avant et après un transfert E/S qui dure aussi 1 ms. La figure

9.1(a) montre l'exécution de trois tâches en séquence, dans un système non multiprogrammé. La figure

9.1(b) montre l'effet de la multiprogrammation. Il faut remarquer que, dans ce cas, le processeur est

constamment occupé et que les trois tâches sont accomplies en 6 ms. Dans la figure 9.1(a), le processeur

est inactif pendant 3 ms, et les trois tâches prennent 9 ms. Dans ce cas, la multiprogrammation fait passer

le rendement d'un taux moyen de 333 tâches par seconde à 500 tâches par seconde, soit une augmentation

de 50%. Naturellement, on ne peut s'attendre à ce que le traitement et les exigences E/S des différentes

tâches se complètent toujours aussi facilement que dans notre exemple. Cependant, plus il y a de tâches

en mémoire, plus grande est la possibilité que l'une d'entre elles soit prête à être exécutée.

Figure 9.1: Effets de la multiprogrammation

La multiprogrammation a d'autres avantages. Elle améliore l'utilisation de certaines ressources, comme

les périphériques, puisque les tâches stockées en mémoire peuvent être choisies à tout moment, de sorte

123

que leurs besoins en ressources se complètent et utilisent toutes (ou presque) les ressources disponibles.

La multiprogrammation permet également un accès simultané à l'ordinateur par plusieurs utilisateurs.

Chaque utilisateur peut lancer des tâches à partir de son terminal et celles-ci peuvent être exécutées en

même temps que des tâches lancées par d'autres utilisateurs à partir d'autres terminaux. La progression de

chaque tâche, ainsi que les résultats qu'elle produit, peuvent être immédiatement renvoyés au terminal

d'origine. Ce mode d'opération est dit à multi-accès ou en temps partagé (ce dernier terme provient du

partage de temps processeur réparti entre les utilisateurs). L'implantation de la multiprogrammation

nécessite des ajouts importants au système d'exploitation embryonnaire décrit précédemment, en

particulier des modules pour accomplir les fonctions suivantes:

1) La distribution. Faire passer le processeur d'une tâche à une autre lorsqu'il le faut.

2) La gestion des interruptions. Interpréter chaque interruption comme un signal de terminaison du

transfert E/S, et de ce fait reprendre l'exécution de la tâche qui requérait ce transfert.

3) L'affectation des ressources. L'affectation des ressources informatiques aux tâches selon les besoins.

Les ressources comportent la mémoire, les dispositifs E/S et le processeur lui-même. Ce dernier est

cependant suffisamment important pour être étudié séparément.

4) La protection des ressources. Il faut s'assurer qu'aucune tâche ne peut accéder à une ressource qui ne

lui a pas été affectée. C'est un point essentiel si les tâches doivent être protégées des erreurs

accidentelles ou dues à la malveillance.

5) L'ordonnancement. Il s'agit de décider de la priorité de la tâche à exécuter lorsqu'une autre vient de se

terminer. Les programmes et les données en attente sont normalement stockés en mémoire auxiliaire

et transférés en mémoire centrale avant le début de l'exécution. La décision de la priorité des tâches à

effectuer est basée sur des facteurs tels que la priorité de chaque tâche, les ressources actuellement

disponibles et les ressources dont chaque tâche a besoin.

L'optimisation du rendement et de l'utilisation de ressources par la multiprogrammation n'est obtenu qu'au

prix d'une augmentation considérable de la complexité du système d'exploitation. En résumé, notre désir

d'augmenter le rendement de la partie matérielle d'un ordinateur de base nous a conduit à introduire un

système d'exploitation comportant de nombreuses fonctions. Certaines de ces fonctions facilitent

également l'utilisation de l'ordinateur. Par exemple, la gestion des interruptions décharge le programmeur

des tâches d'écriture des parties de programme destinées à détecter le moment où les transactions E/S sont

terminées. Dans les sections suivantes, nous verrons comment certaines de ces fonctions peuvent être

implantées et quels sont les algorithmes utilisés à cet effet.

124

9.3.2 LE DISTRIBUTEUR

Nous avons vu précédemment que l'exécution d'une tâche pouvait impliquer l'exécution d'un certain

nombre de programmes différents, tels qu'un compilateur, un chargeur et un programme-objet compilé.

Les fonctions proposées par le système d'exploitation comportent aussi l'exécution de programmes

différents qui font partie du système d'exploitation. L'exécution d'un programme est un processus et le

système d'exploitation est chargé de lancer et de coordonner les différents processus devant être exécutés.

Certains de ces processus exécutent des fonctions du système d'exploitation (comme l'ordonnancement et

la gestion des E/S), alors que d'autres (comme la compilation) sont lancés sur l'ordre des utilisateurs.

L'existence simultanée de plusieurs processus implique qu'ils se partagent l'UCT (Unité Centrale de

Traitement). C'est le cas même lorsque l'ordinateur n'a qu'un seul utilisateur, puisque les processus lancés

à la demande de l'utilisateur doivent partager l'UCT avec des processus qui font partie du système

d'exploitation. Lorsque la multiprogrammation est en vigueur, le degré de répartition est augmenté de

manière équivalente. Le partage est effectué en faisant passer à plusieurs reprises l'UCT d'un processus à

un autre, de sorte que chaque processus soit exécuté juste avant de perdre l'UCT au profit d'un autre

processus. Le changement est généralement si rapide (tous les cinquantièmes de seconde, par exemple)

que tous les processus semblent être exécutés simultanément, alors que naturellement l'UCT ne peut être

affectée qu'à un seul processus à la fois.

Si plusieurs UCT sont disponibles, certains processus peuvent réellement être exécutés simultanément.

Pourtant, il y a généralement moins d'UCT que de processus, et de ce fait les UCT doivent continuer à

passer d'une tâche à une autre. Les ordinateurs qui ont assez d'UCT pour en affecter une à chaque

processus sont encore assez rares sur le marché mais certains laboratoires de recherche travaillent déjà

avec des ordinateurs comptant plusieurs milliers d'UCT. Dans les sections suivantes, nous supposerons

que le partage de l'UCT est nécessaire; toutefois nous ne nous en tiendrons pas aux machines comportant

une seule UCT.

Dès qu'un processus est lancé, il peut être dans l'un des trois états suivants (voir la figure 9.2):

1) En cours d'exécution: le processus est exécuté par une UCT.

2) Prêt: le processus est exécutable et attend qu'une UCT lui soit affectée. Tous les processus prêts sont

candidats à l'affectation d'une UCT.

3) Bloqué: le processus ne peut être traité tant qu'un résultat, comme la fin d'un transfert E/S, n'apparaît

pas. Les processus bloqués ne sont pas candidats à l'affectation d'une UCT.

125

Un processus en cours d'exécution est bloqué lorsqu'il requiert un transfert E/S, et il le reste jusqu'à la fin

du transfert. A ce point, le processus est prêt et son exécution peut être reprise dès qu'une UCT lui a été

affectée. C'est pourquoi les processus qui alternent le calcul avec les E/S de manière répétitive alternent

entre les états d'exécution, de blocage et de disponibilité.

Figure 9.2: Transitions entre les trois états possibles d'un processus

Dans certains systèmes d'exploitation, le lancement d'un transfert E/S est la seule occasion qu'a l'UCT

d'être déconnectée d'un processus. Ce type de situation peut conduire à la monopolisation d'une UCT par

un seul processus qui n'accomplit des E/S que très rarement. Pour un partage plus équitable des UCT, il

est indispensable de limiter la période pendant laquelle un processus peut se dérouler de manière continue,

et de déconnecter l'UCT de tout processus atteignant la limite qui lui est impartie. Le temps alloué à un

processus pour une exécution continue est appelé quantum (ou tranche de temps). Un processus utilisant

plus que son quantum change d'état. Il passe de l'exécution à la disponibilité, libérant son UCT pour un

autre processus en attente. Le processus reçoit un nouveau quantum lorsqu'il est de nouveau sélectionné

pour être exécuté. Le choix de la taille du quantum dépend de nombreux facteurs complexes, tels que le

temps qu'a déjà utilisé le processus, celui de la distribution par le transfert et les contraintes d'équité.

La responsabilité de commutation des UCT entre les processus appartient à un module du système

d'exploitation, le distributeur. Le distributeur est normalement appelé dans les circonstances suivantes:

1) un processus en cours d'exécution demande un transfert E/S ou une ressource; il est donc bloqué;

2) le quantum d'un processus en cours d'exécution expire, indiquant que l'UCT devra être réaffectée

3) l'exécution d'un processus se termine.

Dans chacun de ces cas, le distributeur doit choisir un des processus prêts pour une prochaine exécution.

Les critères de sélection visent généralement à assurer un partage équitable des UCT entre tous les

processus, mais dans certains cas, la sélection peut se faire en faveur des processus dont la priorité a été

126

reconnue (peut-être parce qu'ils accomplissent des fonctions importantes du système ou sont associés à

des tâches urgentes). La sélection par le distributeur provoque le changement d'état du processus (de "prêt

" à en "cours d'exécution"). La figure 9.2 montre les trois états d'un processus et les transitions entre eux.

9.3.3 LA GESTION DE LA MEMOIRE

Une des fonctions les plus importantes du système d'exploitation est la gestion des ressources de mémoire

de l'ordinateur. La gestion de la mémoire a trois aspects fondamentaux:

1) Affectation: pour qu'un processus soit exécuté il doit lui être affecté une mémoire suffisante pour

stocker son programme et ses données.

2) Protection: il est important qu'aucun processus ne puisse avoir accès à une partie quelconque de

mémoire qui ne lui soit pas affectée. La protection est essentielle pour éviter que des erreurs de

programmation affectent d'autres processus et pour éviter une consultation non autorisée des données

d'autres processus, ou des interférences malveillantes dans l'exécution d'un autre processus.

3) Utilisation: la mémoire est une ressource précieuse puisque c'est le seul endroit où les programmes

d'exécution du processus peuvent résider. C'est pourquoi il est important de l'utiliser au mieux, en

l'affectant autant que possible à tout moment et en essayant de s'assurer que la mémoire comporte en

permanence des processus prêts à être exécutés.

Pour atteindre une utilisation maximale, le système d'exploitation doit être libre d'affecter à un processus

n'importe quelle partie de la mémoire disponible. Il peut d'ailleurs avoir besoin de déplacer un programme

et des données d'un processus d'une zone mémoire à une autre, de sorte que, par exemple, plusieurs zones

non affectées puissent être fusionnées à l'intérieur d'une zone plus importante et utilisable. Ceci signifie

que lorsqu'un programme est écrit et compilé, ni le programmeur ni le compilateur ne peuvent savoir où

le programme va résider en mémoire lors de l'exécution. Comment un compilateur peut-il générer des

adresses exactes alors qu'il ignore quelle zone mémoire le programme objet occupera durant l'exécution?

La réponse tient en une distinction nette entre les adresses du programme générées par un traducteur de

langage et les adresses mémoire dans lesquelles le programme objet doit par la suite résider. Le traducteur

de langage suppose que toute la mémoire est disponible et génère des adresses de programme partant de

zéro: ces adresses doivent ensuite être transformées en adresses mémoire correspondantes pour la zone de

mémoire affectée au programme objet par le système d'exploitation. La transformation des adresses du

programme en adresses mémoire est généralement accomplie par l'UCT au moment où le programme

objet est exécuté. Voyons deux méthodes couramment utilisées pour cela et le type de gestion de la

mémoire qui en résulte.

127

Les registres de base et de limite

La première méthode nécessite un registre supplémentaire dans l'UCT. Ce registre, le registre de base,

contient l'adresse de base du processus en cours d'exécution, c'est-à-dire l'adresse de la première cellule

mémoire affectée au processus. L'UCT transforme chaque adresse de programme utilisée par le processus

en l'adresse mémoire correspondante, en y ajoutant simplement le contenu du registre de base.

Soient deux processus A et B disposant de zones mémoire (figure 9.3). Les adresses programme du

processus A sont comprises entre 0 et 1199 et les cellules mémoire qui lui sont affectées sont comprises

entre 1500 et 2699. Les adresses programme du processus B sont comprises entre 0 et 799 et les cellules

mémoire entre 2800 et 3599. Lorsque le distributeur choisit de lancer l'exécution du processus A, il charge

l'adresse de base 1500 dans le registre de base (figure 9.3(a)). L'UCT calcule les adresses mémoire

correspondantes en ajoutant le contenu du registre de base à chaque adresse programme utilisée pendant

l'exécution. Chaque adresse mémoire est par conséquent supérieure de 1500 à l'adresse programme

correspondante, reflétant la position du processus A en mémoire. De même lorsque le processus B est

lancé, le registre de base est chargé avec l'adresse de base 2800 (figure 9.3(b)) et à chaque adresse

programme utilisée pendant l'exécution de B est ajouté 2800 pour produire l'adresse mémoire adéquate.

Figure 9.3: Transformation d'adresse par un registre de base

128

Nous pouvons remarquer au passage que les processus A et B peuvent utiliser les mêmes adresses de

programme, mais que celles-ci doivent être transformées de manière appropriée en adresses mémoire

différentes. L'adresse programme 135 doit être par exemple transformée en l'adresse mémoire 1635

lorsque A est exécuté et en adresse mémoire 2935 lorsque B est exécuté.

L'utilisation d'un registre de base permet de stocker le programme et les données dans une partie

disponible de la mémoire; elles peuvent être déplacées par le système d'exploitation. La seule obligation

du système d'exploitation est de se souvenir de l'adresse de base de chaque processus et de charger le

registre de base en conséquence lorsque le processus a été sélectionné pour être exécuté. Le registre de

base n'est cependant pas suffisant pour protéger un processus contre des intrusions d'autres processus dans

la partie affectée de la mémoire, qu'elles soient délibérées ou dues à des erreurs. Par exemple, si le

processus A de la figure 9.3 utilise l'adresse programme 2000, celle-ci sera transformée par l'UCT en

adresse mémoire 3500. La cellule mémoire 3500 est pourtant affectée au processus B et ne devrait par

conséquent pas être accessible au processus A. Il est indispensable d'interdire ce type d'accès.

Pour une protection plus efficace, il faut ajouter un registre supplémentaire à l'UCT, le registre de limite

ou registre de longueur. Cette limite est fixée par le système d'exploitation et correspond au nombre de

cellules mémoire affectées au processus en cours d'exécution. C'est pourquoi, par exemple, lorsque le

processus A de la figure 9.3 est lancé, le registre de limite est fixé à 1200; pour le processus B, le registre

de limite est fixé à 800. La transformation d'une adresse programme en adresse mémoire reste presque la

même, mais des vérifications sont ajoutées pour s'assurer qu'il n'est possible d'accéder qu'aux cellules

mémoire affectées.Cet algorithme est exécuté par le microprogramme de l'UCT pour transformer toute

adresse programme en adresse mémoire correspondante. Si une erreur d'adressage se produit, le contrôle

est rendu au système d'exploitation qui interrompt le processus.

Les paragraphes précédents ont présenté les mécanismes utilisés par les registres de base et de limite pour

donner au matériel le support nécessaire à la gestion de la mémoire. Nous pouvons maintenant concentrer

notre attention sur les algorithmes que le système d'exploitation utilise pour exploiter ces mécanismes.

L'objectif principal d'un système d'exploitation est de stocker en mémoire autant de processus que

possible. Si un processus est bloqué, la zone mémoire qu'il occupe est provisoirement inutilisable: cela

n'a aucune importance si la mémoire est suffisamment importante pour contenir un nombre tel de

processus que certains d'entre eux soient toujours prêts à être exécutés; mais si la capacité de mémoire est

peu importante elle risque d'être entièrement occupée par les processus bloqués ce qui entraîne un blocage

complet de l'ordinateur. Le danger peut être réduit par un processus de transfert (swapping), c'est-à-dire

par déplacement du programme et des données d'un processus bloqué vers une mémoire auxiliaire, afin

129

de faire de la place pour d'autres processus prêts pour l'exécution. Le processus déplacé de la mémoire

centrale devient postulant d'un nouveau déplacement en sens inverse dès qu'il change d'état (de bloqué à

disponible). Remarquez que lorsqu'un processus est replacé en mémoire, il n'est pas nécessaire qu'il

occupe les mêmes cellules que celles qu'il occupait avant son déplacement. Le système d'exploitation

charge simplement le registre de base avec la nouvelle adresse de base lorsque le processus doit être

exécuté. Le choix des processus à déplacer dépend de critères comme le degré de priorité du processus,

l'espace mémoire qu'il occupe, le temps dont il dispose avant d'être bloqué et le temps mémoire qu'il a

déjà utilisé. Le transfert peut augmenter considérablement l'utilisation effective de la mémoire, mais

uniquement au prix du temps pris pour déplacer et replacer les programmes et les données de la mémoire

centrale à une mémoire auxiliaire et vice versa.

A la fin d'un processus, la zone mémoire qu'il occupe peut être affectée à un nouveau processus. Comme

il est peu probable que le nouveau processus ait exactement besoin de la même taille en mémoire, une

petite zone peut être inutilisée, comme le montre la figure 9.4.

Figure 9.4. Fragmentation de la mémoire

Au terme d'un certain nombre d'échanges, la mémoire peut être fragmentée, c'est-à-dire qu'une proportion

substantielle la composant peut être composée de plusieurs zones non affectées, chacune étant trop

restreinte en elle-même pour stocker un processus. Une fragmentation excessive implique une faible

utilisation de la mémoire et le système d'exploitation doit, par conséquent, s'efforcer de réduire la

fragmentation au minimum. On peut obtenir ce résultat par une affectation prudente (plusieurs algorithmes

existent pour cela, mais ils ne font pas l'objet de cet enseignement) et par une fusion des zones adjacentes

de la mémoire qui sont inutilisées afin de constituer des zones plus importantes et donc utilisables. Ce

processus, qui est le compactage, est illustré par la figure 9.5. L'accumulation de nombreux déplacements

à l'intérieur de la mémoire et la remise en place de toutes les adresses de base constitue un travail

considérable, et le compactage se justifie donc en dernier ressort.

130

Figure 9.5: L'effet de compactage

Un inconvénient des schémas de gestion de la mémoire qui se basent sur les registres de base ou de limite

est que le programme complet ainsi que les données d'un processus doivent être simultanément présents

en mémoire lorsqu'un processus est exécuté. Cela signifie que:

1) un espace doit être trouvé pour le programme et ses données, même si le processus n'utilise qu'une

petite partie de cet espace pendant une période particulière de l'exécution; et

2) la taille du programme et des données est limitée par la capacité mémoire de l'ordinateur.

Le mécanisme suivant de gestion de la mémoire permet d'éviter cet inconvénient.

La pagination

La pagination est une technique de gestion de la mémoire qui permet à un processus d'être exécuté quand

seul un segment du programme et des données réside en mémoire centrale. Elle permet donc l'exécution

d'un processus même lorsque la totalité du programme et de ses données ne peuvent tenir à la fois en

mémoire. (Une autre technique, appelée segmentation, est aussi régulièrement utilisée)

Lorsque la pagination est employée, le programme et les données de chaque processus sont considérés

comme étant divisés en un certain nombre de pages de taille identique, et la mémoire est elle-même divisée

en un nombre de structures de page d'égale grandeur. Chaque structure de page peut contenir une page

d'un processus. Les structures de page sont affectées par le système d'exploitation, de sorte qu'à tout

moment chaque processus ait quelques pages occupant des structures de page de la mémoire, pendant que

le reste est conservé en mémoire auxiliaire.

131

La division en pages d'un programme et de ses données est très simple. Si par exemple la taille de la page

est 1000, alors la page 0 contient la partie de programme et les données dont les adresses programme sont

comprises entre 0 et 999, la page 1 contient la partie de programme et les données aux adresses programme

de 1000 à 1999, et ainsi de suite. Ce sont uniquement la partie matérielle de l'ordinateur et le système

d'exploitation qui considèrent un processus divisé de cette façon: les mécanismes de pagination sont

transparents au programmeur et aux traducteurs de langage.

L'UCT transforme une adresse programme en une adresse mémoire

1) en calculant, à partir de l'adresse programme, quelle page et quelle cellule de la page sont visées; et

2) en utilisant les informations gardées par le système d'exploitation pour déterminer quelle structure la

page occupe actuellement.

La première de ces étapes est évidente, ne nécessitant que la division de l'adresse programme par la taille

de la page. Si l'adresse du programme est 2764 et que la taille de la page est 1000, la référence est faite à

la cellule 764 de la page 2. En pratique, la taille de la page est choisie en fonction d'une puissance de 2

(généralement 512, 1024 ou 2048), ce qui rend la division (en binaire) triviale.

La seconde étape de transformation d'adresses est accomplie par l'intermédiaire d'une table de pages qui

indique quelle structure, s'il y en a une, est occupée par chacune des pages du processus. Le système

d'exploitation gère une table de pages pour chaque processus et la met à jour pour refléter l'occupation

actuelle des structures par page. Il n'y a qu'une entrée dans une table de pages d'un processus pour chacune

de ses pages. L'entrée comporte

1) une indication concernant la page actuellement en mémoire; et

2) l'adresse de la structure de page contenant la page si celle-ci est en mémoire, sinon la position de la

page en mémoire auxiliaire.

Si la page est stockée en mémoire, l'adresse de sa structure est une information suffisante pour achever la

transformation d'une adresse de programme en adresse mémoire. Il se peut, cependant, qu'un processus

renvoie à une page qui ne réside pas actuellement en mémoire. Dans ce cas (appelé un défaut de page) la

transformation d'adresse ne peut pas être faite tant que la page référencée n'a pas été transférée de la

mémoire auxiliaire à la mémoire centrale, et que la table des pages n'a pas été mise à jour. Le processus

est alors bloqué et un autre processus est activé pendant ce temps. Lorsque le transfert de page est effectué,

le processus est de nouveau prêt et il est à nouveau sélectionné afin d'être exécuté. Le processus de

transformation d'adresse en entier est alors répété avec succès. La transformation d'adresse exécutée par

l'UCT est illustrée par la figure 9.6.

132

Figure 9.6: Transformation d'adresses en pagination

Puisque la vitesse de transformation est un point crucial pour la vitesse de l'ordinateur, il est habituel de

garder un segment de la table des pages, pour le processus en cours d'exécution, dans un ensemble de

registres spéciaux de l'UCT, aussi bien qu'en mémoire.

Il faut noter que la pagination permet à un programme d'être plus grand que la mémoire de l'ordinateur,

puisqu'il ne doit pas être stocké entièrement en mémoire pour être exécuté. C'est pourquoi la pagination

donne l'illusion d'une mémoire virtuelle plus grande que la mémoire effectivement disponible. C'est cette

mémoire virtuelle qui est "vue" par le programmeur et le traducteur de langage. Ils n'ont plus à se

préoccuper des mécanismes sous-jacents qui lui permettent de fonctionner.

La protection de la mémoire contre des accès non autorisés est automatiquement garantie par l'utilisation

d'une table de pages séparée pour chaque processus. Toutes les adresses mémoire utilisées par un

processus sont interprétées en fonction de la table de pages propre au processus: il est donc impossible à

un processus d'accéder à des structures qui ne lui ont pas été affectées.

Les décisions fondamentales prises par le système d'exploitation dans un contexte de pagination se

rapportent au choix des pages du processus qui devraient être stockées en mémoire et de celles qui

devraient être renvoyées en mémoire auxiliaire. Nous avons déjà vu qu'une page doit être placée en

mémoire lorsqu'elle est référencée et, à moins qu'il existe une structure de page vide, une autre page doit

être déplacée. La question se posant au système d'exploitation est de savoir quelle page déplacer. Cette

question est importante puisque le déplacement peu judicieux d'une page peut provoquer son retour

presque immédiat. Si l'échange des pages entre la mémoire auxiliaire et la mémoire centrale est trop élevé,

on court le risque de surcharger les mécanismes d'E/S et de bloquer en mémoire la majeure partie des

processus attendant les transferts de pages. L'effet sur le fonctionnement de l'ordinateur est alors

désastreux.

133

Idéalement, le système d'exploitation devrait déplacer une page qui n'aurait pas à être référencée dans

l'immédiat. Malheureusement, il n'est pas possible d'anticiper le futur: le mieux que puisse faire le système

d'exploitation est d'anticiper en fonction du passé. Cette inférence est plus facile à réaliser, étant donné le

caractère entièrement prévisible de la plupart des processus qui, habituellement, obtiennent leurs

instructions de cellules mémoire consécutives et qui accèdent souvent à des éléments d'information

stockés de manière adjacente.

Avant de passer au paragraphe suivant, il faut noter que puisque toutes les pages et les structures sont de

même taille, la fragmentation de la mémoire ne peut se produire, et l'utilisation de la mémoire peut par

conséquent être très élevée. Contre cet avantage, il faut considérer l'espace occupé par les tables de pages

elles-mêmes et la complexité supplémentaire du microprogramme de l'UCT.

9.3.4 L'ORDONNANCEMENT ET L'AFFECTATION DE RESSOURCES

Comme nous l'avons indiqué dans la section 9.3.1, les travaux en attente de traitement sont généralement

stockés en mémoire auxiliaire jusqu'à ce que le système d'exploitation lance leur exécution. L'exécution

d'une tâche simple requiert normalement le lancement et la coordination de plusieurs processus, soit

simultanément soit en séquence (voir section 9.3.2). La responsabilité du lancement des processus revient

à un module du système d'exploitation appelé ordonnanceur.

En décidant à quel moment lancer un processus, l'ordonnanceur tient compte des facteurs suivants:

1) le nombre de ressources (par exemple: la mémoire, le temps de traitement, les dispositifs E/S) dont a

besoin le processus;

2) le nombre de ressources disponibles;

3) la priorité du travail auquel le processus est associé;

4) la longueur de l'attente du travail associé.

Le poids donné à ces facteurs varie d'un système d'exploitation à un autre, selon que l'objectif majeur est

le rendement maximal ou l'offre de services rapides à une certaine classe d'utilisateurs. Les informations

concernant les besoins en ressources d'un processus peuvent être recueillies pendant l'exécution du

processus, ou fournies par l'utilisateur en tant que partie des informations de contrôle du travail associé.

Les informations concernant les ressources disponibles sont gérées par l'ordonnanceur et mises à jour

quand les ressources sont affectées et reprises.

134

L'affectation de ressources peut être soit

1) statique, ce qui signifie que toutes les ressources nécessaires à un processus sont affectées lors de son

lancement et reprises à la fin de son exécution; soit

2) dynamique, ce qui signifie que les ressources ne sont affectées à un processus que lorsqu'elles sont

nécessaires et reprises quand le processus n'en a plus besoin.

En mode d'allocation statique, un processus ne peut être lancé que lorsque toutes les ressources nécessaires

sont disponibles; en mode d'allocation dynamique, un processus peut être lancé à tout moment, mais peut

être bloqué si une ressource est indisponible au moment voulu. L'allocation dynamique peut entraîner une

meilleure utilisation des ressources mais est plus difficile à gérer. En particulier, des mesures doivent être

prises pour en finir avec l'interblocage, situation dans laquelle chacun de deux (ou plusieurs) processus

utilise une ressource nécessaire à l'autre. Si aucun des deux ne peut rendre la ressource qu'il bloque, aucun

ne peut être exécuté et tous deux resteront bloqués indéfiniment. De nombreuses mesures peuvent être

prises pour éviter ou maîtriser l'interblocage: toutes ont des inconvénients et certaines d'entre elles peuvent

être complexes et prendre un temps considérable.

9.3.5 LA MANIPULATION DES E/S

Une des fonctions du système d'exploitation est de manipuler toutes les opérations d’E/S sur ordre des

processus de l'utilisateur et ce pour différentes raisons:

1) Les caractéristiques opérationnelles des dispositifs E/S liés à un ordinateur varient considérablement,

en particulier par leur vitesse, la quantité de données qu'ils transfèrent en une seule opération et la

manière dont les données sont représentées sur les dispositifs d'entrée et de sortie. Il est très ennuyeux

pour l'utilisateur d'avoir à s'occuper de ces détails; il est pour lui beaucoup plus pratique de s'en

remettre aux modules du système d'exploitation qui gèrent à sa place les opérations E/S.

2) Pour rendre efficace une allocation de processeur (section 9.3.2), le système d'exploitation doit savoir

à quel moment un processus est lancé et à quel moment il s'achève: ceci est beaucoup plus facile si le

système d'exploitation se charge lui-même de tous ces transferts.

3) Certains dispositifs E/S sont partageables, en ce sens qu'ils peuvent gérer des transferts successifs

d'E/S qui proviennent de processus différents. Un disque, par exemple, peut être partageable parce que

des transferts différents peuvent être dirigés vers (ou provenir de) différentes zones du disque sans

risque de confusion. D'autres dispositifs ne sont pas partageables: la façon dont ils travaillent fait que

135

les transferts successifs des différents processus conduisent à un inextricable mélange des données.

Une imprimante, par exemple, est un dispositif qu'on ne peut partager, puisque son utilisation par

différents processus au même moment provoquerait des impressions de lignes de différents processus

sur la même feuille de papier. Des dispositifs qui ne peuvent être partagés ne doivent être alloués qu'à

un seul processus à la fois et le système d'exploitation doit veiller à ce qu'un processus n'utilise que

les dispositifs qui lui ont été alloués. Ceci est beaucoup plus facile si c'est le système d'exploitation

qui gère les échanges E/S.

Un processus demande un transfert E/S en appelant le module du système d'exploitation approprié dont

les paramètres spécifient la nature du transfert requis. Ce module

1) vérifie la légitimité de la demande (par exemple, que le dispositif E/S est alloué au processus qui le

demande);

2) lance le transfert en exécutant des instructions appropriées au dispositif E/S concerné;

3) bloque le processus demandeur;

4) appelle le distributeur pour sélectionner un autre processus.

La fin du transfert est signalée par une interruption qui force l'UCT à se brancher à un endroit fixe de la

mémoire. Cet emplacement contient la première instruction d'un gestionnaire d'interruption qui est un

module du système d'exploitation chargé de la gestion des interruptions.

Le gestionnaire d'interruption

1) identifie l'origine de l'interruption (c'est-à-dire quel dispositif E/S l'a provoquée) et par conséquent le

processus qui est à l'origine du transfert;

2) change l'état du processus incriminé, du mode bloqué au mode prêt, et rend donc acceptable la reprise

du processus;

3) reprend le processus qui était en cours d'exécution lorsque l'interruption s'est produite. (Il arrive que

l'interruption permette de réallouer l'UCT, auquel cas le gestionnaire d'interruption appelle le

distributeur pour sélectionner un autre processus.)

Les détails de l'opération E/S dans son intégralité sont transparents au processus demandeur, qui doit

uniquement appeler le module E/S correspondant.

9.3.6 LE SYSTEME DE FICHIERS

Dans la plupart des systèmes informatiques, il est nécessaire de conserver les informations sous une forme

facilement accessible pendant longtemps. Le type d'informations stockées varie naturellement selon ce

136

que le système se propose d'en faire, mais il comprend généralement des programmes et des données qui

doivent être utilisés à plusieurs reprises. Ce sont des programmes système, des données qui sont le fruit

d'expérimentations scientifiques ou d'études, des programmes écrits par des utilisateurs ou des bases de

données d'applications variées. Pour accéder facilement à ces informations, il faut les stocker dans le

système. Pour des raisons techniques et économiques, le stockage à long terme de grandes quantités

d'informations est effectué sur des supports de mémoires auxiliaires comme les disques magnétiques. C'est

le rôle du système d'exploitation de gérer cette mémoire auxiliaire de manière que les informations soient

localisées et rappelées facilement.

L'information en mémoire auxiliaire est habituellement conservée sous la forme de fichiers qui peuvent

être de tailles différentes. Chaque fichier est un ensemble d'informations considéré comme une entité par

ses utilisateurs. Il peut s'agir par exemple d'un programme, des observations d'une expérimentation ou

d'une liste d'employés. Le fichier est l'unité logique qui est stockée et gérée par le système d'exploitation.

Le système de fichiers est la partie du système d'exploitation qui est responsable des fichiers. Les fonctions

spécifiques d'un système de fichiers varient selon l'application informatique, mais comportent

1) la création et la destruction de fichiers;

2) la possibilité d'accéder aux fichiers;

3) la gestion de l'espace de la mémoire auxiliaire;

4) la protection des fichiers contre des accès non autorisés (bien qu'un accès légal puisse être autorisé

entre les membres d'une même équipe);

5) la protection des fichiers contre les pertes ou les détériorations dues aux pannes matérielles.

Puisque l'emplacement physique d'un fichier ne concerne pas l'utilisateur, il est courant que son créateur

donne un nom à chacun de ses fichiers, ce qui permet au système d'y accéder simplement par leurs noms.

Le système de fichiers comporte donc un répertoire de noms de fichiers et de leurs emplacements sur le

support mémoire. Dans un contexte où plusieurs utilisateurs sont autorisés à créer et à manipuler leurs

propres fichiers, le répertoire présente généralement une structure arborescente à deux niveaux. La racine

de l'arbre est le répertoire principal avec une entrée pour chaque utilisateur. Les feuilles sont les

répertoires des utilisateurs, avec une entrée pour chacun des fichiers de l'utilisateur. Cette structure permet

à différents utilisateurs d'appeler leurs fichiers par les mêmes noms sans risque de confusion. Les

répertoires sont stockés en mémoire auxiliaire (comme les fichiers eux-mêmes) et sont transférés en

mémoire lorsque cela est nécessaire.

137

Chaque entrée du répertoire principal comporte l'information suivante au sujet de l'utilisateur:

1) le nom par lequel l'utilisateur est connu du système;

2) l'emplacement du répertoire de l'utilisateur correspondant.

Chaque entrée d'un répertoire utilisateur comporte les informations suivantes au sujet d'un fichier de

l'utilisateur:

1) le nom du fichier;

2) l'emplacement du fichier en mémoire auxiliaire;

3) la taille du fichier;

4) l'information de contrôle d'accès fournie par l'utilisateur et qui spécifie qui peut utiliser le fichier et

quel type d'accès est autorisé (l'accès peut, par exemple, être autorisé pour une consultation mais pas

pour une modification);

5) différentes informations administratives telles que la date de création du fichier, ainsi que celle de sa

dernière modification.

La structure de répertoire peut être étendue à plusieurs niveaux (figure 9.7). Cela permet à un utilisateur

de regrouper les fichiers logiquement connexes et d'accéder à chaque groupe par un répertoire différent.

Il permet aussi à une équipe de mettre au point un répertoire de jonction, avec leurs répertoires individuels

aux niveaux inférieurs de l'arbre. Certains fichiers du répertoire de jonction (par exemple les fichiers I et

J de la figure 9.8) peuvent être librement accessibles à tous les membres de l'équipe, alors que le contrôle

d'accès de ceux contenus dans les répertoires individuels (par exemple, G et K ) est plus sélectif.

Figure 9.7: Structure d'un répertoire à multi-niveaux

138

L'espace en mémoire auxiliaire est alloué par unités de taille fixe, appelées blocs (la taille d'un bloc est

par exemple équivalente à 500 cellules mémoire). Chaque fichier occupe un certain nombre de blocs,

pouvant se situer n'importe où dans la mémoire auxiliaire (il n'est pas nécessaire qu'ils soient contigus).

Le système de fichiers doit gérer les informations concernant ceux des blocs qui sont libres et ceux qui

sont alloués à des fichiers particuliers. Les algorithmes et les structures de données utilisés pour

l'allocation dépendent de la manière dont les fichiers doivent être utilisés. Ils dépendent en particulier du

mode d'accès (soit séquentiel du début à la fin, soit aléatoire à n'importe quel point) d'un fichier.

Puisque le contenu d'un fichier peut représenter plusieurs mois de travail ou des données irremplaçables,

il est important que le système dispose des mécanismes adéquats de sauvegarde et de recours en cas de

défaillance du logiciel ou du matériel. Le système doit donc conserver des copies de tous les fichiers de

façon à pouvoir les restituer après un accident malencontreux.

Il y a deux méthodes principales pour sauvegarder des fichiers:

1) Périodiquement: Le contenu total du support mémoire est copié à intervalles réguliers. La copie peut

être faite d'un disque sur un autre ou d'un disque sur une bande magnétique. Les principaux

inconvénients de cette technique sont le grand volume d'informations devant être copié et 1'éventualité

des pertes de fichiers créés ou modifiés depuis la dernière copie.

2) Au fur et à mesure: Un fichier n'est copié que lors de sa création ou d'une modification. Cette technique

réduit la quantité de copies tout en permettant une mise à jour plus fréquente. La copie au fur et à

mesure est cependant plus difficile à gérer, puisque les précédentes copies doivent être localisées et

détruites (ou reconnues et ignorées pendant la récupération succédant à une panne). Une variante de

ce type de copie consiste à ne copier que les modifications apportées à un fichier. La récupération

après une panne peut être très complexe mais la technique est pratique lorsque les modifications sont

si fréquentes que la copie du fichier en entier est irréalisable (par exemple les fichiers de réservations

d'une compagnie aérienne).

9.3.7 LE CONTROLE DES TRAVAUX

Nous conclurons notre discussion sur les systèmes d'exploitation en décrivant brièvement comment

l'utilisateur indique au système d'exploitation ce qu'il désire qu'il fasse. Généralement parlant, toutes les

opérations que demande l'utilisateur concernent la manipulation des programmes et des données pouvant

être stockées en tant que fichiers ou présentées par les dispositifs E/S. Les formes de manipulation varient

considérablement: plus courantes sont l'entrée, la sortie, la copie de données ainsi que la compilation et

l'exécution des programmes.

139

Les instructions que donne l'utilisateur au système d'exploitation sont exprimées en un langage de

commande. Les langages de commande permettent la transmission des types d'informations suivants

1) Informations opérationnelles, spécifiant quelles opérations l'utilisateur désire accomplir (par exemple

copier un fichier ou exécuter un programme).

2) Informations EIS, spécifiant d'où proviennent les programmes et/ou les données nécessaires à une

opération, et l'emplacement auquel les résultats devront être affectés (par exemple, le programme

réside dans un fichier particulier, la sortie doit se faire sur le terminal de l'utilisateur).

3) Informations ressources, donnant une estimation des ressources nécessaires pour effectuer une

opération (par exemple, la capacité de mémoire, le temps de traitement). Les informations concernant

les ressources sont utilisées par l'ordonnanceur lors du lancement des processus (section 9.3.4). Dans

les cas où l'utilisateur ne connaît pas ou ne spécifie pas quelles ressources sont nécessaires, le système

d'exploitation attribue lui-même des valeurs par défaut.

4) L'identification de l'utilisateur, spécifiant qui est l'utilisateur et établissant s'il est de bonne foi (par

son numéro et un mot de passe). Ces informations sont utilisées pour contrôler l'accès à l'ordinateur,

et peuvent également l'être pour un abonnement ou une facture.

L'interprétation des instructions de contrôle des travaux est accomplie par une partie du système

d'exploitation connue sous le nom d'interpréteur du contrôle des travaux (ou interpréteur de commande).

Il analyse chaque instruction (ou séquence d'instructions) et l'interprète. Dans certains cas, comme la

compilation d'un programme utilisateur, l'interpréteur n'est pas en mesure d'interpréter lui-même

l'instruction. Il construit cependant un fichier qui spécifie ce qui est en train d'être fait. Le fichier indique

quel processus doit être lancé et peut contenir des informations relatives aux E/S ou aux ressources

extraites de l'instruction ou données par défaut. L'ordonnanceur examine périodiquement une liste de ces

fichiers pour décider s'il doit lancer un autre processus (voir section 9.3.4).

La syntaxe de la plupart des langages de contrôle des travaux est de plus en plus simple comme l'illustre

les évolutions constantes des interfaces graphiques des systèmes d'exploitation modernes.