Arbres binaires de recherche, Exercices de Mathématiques pour l'informatique
Christophe
Christophe3 March 2014

Arbres binaires de recherche, Exercices de Mathématiques pour l'informatique

PDF (177.4 KB)
29 pages
215Numéro de visites
Description
Exercices d’informatique sur les structures arborescentes - 2° partie. Les principaux thèmes abordés sont les suivants: Implémentations de types, Parcours d’arbres, Arbres binaires de recherche.
20points
Points de téléchargement necessaire pour télécharger
ce document
Télécharger le document
Aperçu3 pages / 29
Ceci c'est un aperçu avant impression
Chercher dans l'extrait du document
Ceci c'est un aperçu avant impression
Chercher dans l'extrait du document
Aperçu avant impression terminé
Chercher dans l'extrait du document
Ceci c'est un aperçu avant impression
Chercher dans l'extrait du document
Ceci c'est un aperçu avant impression
Chercher dans l'extrait du document
Aperçu avant impression terminé
Chercher dans l'extrait du document
notes.dvi

30 CHAPITRE 2. MODÈLE DE CALCUL

peut ainsi être représenté par la structure : indiceRacine = 8 élément = (84, 55, 30, 47, 21, 45, 78, 10, 40, 25) indFGauche = (64, 29, 0, 87, 41, 65, 77, 9, 0, 54) indFDroit = (74, 49, 0, 17, 28, 23, 45, 3, 0, 94)

Nous verrons dans un chapitre dédié à leurs études que cette représentation par châınage permet de supprimer ou d’ajouter des éléments rapidement c.a.d en Θ(log(n)).

2.5.1 Représentation d’une séquence par châınage

On peut étendre la représentation par châınage d’un arbre à la séquence : une séquence peut être considérée comme (représentée par) un arbre dans lequel aucun élément n’a de fils gauche.

Exemple 13

40

\

(40,20,30) 20

\

30

En s’inspirant de de la représentation d’un arbre par châınage, voici un exemple de représentation d’une séquence par châınage :

Exemple 14 La séquence (20, 40, 30) peut être représentée par la structure : indicePremier := 8 élément := (84, 55, 40, 47, 21, 30, 78, 20, 40, 25) indSucc := (64, 29, 06, 87, 41, 00, 77, 06, 00, 54)

2.6 Conclusion

Nous avons fourni dans ce chapitre quelques idées générales pour représenter un objet en mémoire. A la question : “Quelle est la meilleure représentation ?”, il n’y pas de réponse. Tout dépend de l’algorithme, des fonctions requises, des instances mêmes sur lesquelles sont exécutées l’algorithme et qui nécessiteront plus d’appel à une fonction qu’à une autre. Cela dépend aussi de la préférence que l’on a ou non d’accorder plus d’espace plus accélérer les calculs.

Afin, de réaliser ces différents choix ou arbitrages, il nous définir et présenter davantage ce qu’est un type abstrait. C’est l’objet du prochain chapitre.

Chapitre 3

Types abstraits

Intuitivement un type abstrait est un ensemble d’opérations pour lesquelles on définit une syntaxe et une sémantique. Plus formellement un type abstrait est la donnée :

– de sa signature décrivant la syntaxe du type, les noms des types utilisés pour sa définition) ainsi que le nom des opérations et le type de leurs arguments.

– d’un ensemble d’axiomes définissant les propriétés des opérations.

La définition d’un tel ensemble d’axiomes est un exercice parfois délicat car cet ensemble doit être à la fois :

– consistant c’est à dire les axiomes ne doivent pas être contradictoires. – complet, c’est à dire les axiomes sont suffisants pour décrire l’ensemble des propriétés du type abstrait.

Remarque 2 Le plus célèbre des théorèmes en informatique est le théorème dit d’incomplétude de Gödel qui indique qu’aucun ensemble fini d’axiomes n’est suff- isant pour prouver l’ensemble des propriétés du type entier. Ainsi, formellement il est impossible de définir un type abstrait entier qui soit universel. Cependant, rassurez-vous. Le type entier ou tout autre type que vous serez amenez à définir doit l’être dans le contexte de quelques algorithmes à écrire dont la correction ou la terminaison nécessite un nombre fini de propriétés sur ces entiers, et donc, d’un nombre fini d’axiomes.

3.1 Un exemple de type abstrait : le type en-

semble

Définir un type abstrait est réalisé en fonction du contexte, c’est à dire en fonction du problème à résoudre voire de l’algorithme le résolvant. Définir un type abstrait consiste à choisir un ensemble d’opérations. Réaliser un tel choix nécessite un arbitrage entre différents critères :

31

32 CHAPITRE 3. TYPES ABSTRAITS

1. faire des choix conformes avec des définitions courantes. Ainsi, la fonction appartient:élément*ensemble->booléen est la fonc- tion qui associe à un élément e et à un ensemble E le booléen e ∈ E. Si vous voulez définir une autre fonction, il vous faut utiliser un autre nom.

2. simplicité de la définition du type : que ce soit en ce qui concerne le nombre d’opérations, ou la définition des axiomes.

3. simplicité de la définition de l’algorithme.

4. optimisation de l’algorithme en ce qui concerne sa complexité en temps ou en espace.

3.1.1 Quelles opérations choisir ?

Considérons le type ensemble. Pour des raisons de simplicité, l’ensemble de ces opérations doit être minimal tout en étant suffisant pour permettre l’écriture de l’algorithme. Une première définition (assez universelle) pourrait être :

Nom ensemble Utilise élément , booléen Opérations estVide : ensemble → booléen ensembleVide : → ensemble appartient : élément × ensemble → booléen ajouter : élément × ensemble → ensemble enlever : élément × ensemble → ensemble choisir : ensemble → élément

Considérons un algorithme qui nécessite de calculer régulièrement la cardi- nalité d’un ensemble. Cette fonction peut être définie à l’aide des opérations précédentes :

fonction cardinalité(E:ensemble):entier

si estVide(E) alors

retourner 0

sinon

retourner 1 + cardinalité(enlever(choisir(E),E)) ;

Cependant, la complexité en temps de l’exécution de cette fonction (ici linéaire) peut s’avérer excessive alors que cette opération peut s’exécuter en temps con- stant par la simple présence dans la structure implémentant le type ensemble d’un entier indiquant sa cardinalité. Aussi, dans le cas où l’algorithme considéré requiert de calculer souvent la taille de l’ensemble ou, plus encore, si sa com- plexité en temps dépend de l’exécution de cardinalité en temps constant, il est préférable d’ajouter l’opération cardinalité : ensemble -> entier dans la définition même du type.

3.1. UN EXEMPLE DE TYPE ABSTRAIT : LE TYPE ENSEMBLE 33

3.1.2 Quels axiomes choisir ?

Que signifient les opérations suivantes ?

ajouter élément×ensemble → ensemble enlever élément×ensemble → ensemble

Quatre significations s’offrent à nous selon que l’on accepte ou non l’exécution de l’opération ajouter sur un élément contenu dans l’ensemble et selon que l’on accepte ou non l’exécution de l’opération enlever sur un élément n’appartenant pas à l’ensemble.

Exemple 15 Supposons que nous souhaitions écrire un algorithme réalisant l’u- nion de deux ensembles. Si l’opération ajouter peut s’exécuter sur un élément déjà présent dans l’ensemble, l’algorithme s’écrit simplement :

fonction union1(A,B:ensemble):ensemble

tantque ¬(estVide(B)) b ← choisir(B) ; B ← enlever(b,B) ; A ← ajouter(b,A) ;

retourner A ;

Si l’opération ajouter ne peut pas s’exécuter sur un élément déjà présent dans l’ensemble, l’algorithme s’écrit simplement :

fonction union2(A,B:ensemble):ensemble

tantque ¬(estVide(B)) b ← choisir(B) ; B ← enlever(b,B) ; si ¬(appartient(a,A)) alors

A ← ajouter(b,A) ;

retourner A ;

Si l’on considère les deux critères que sont la simplicité de l’algorithme et sa complexité en temps la préférence va à la première solution : le code est plus simple, la complexité en temps est au moins aussi bonne que la seconde.

Exemple 16 Supposons que l’on souhaite ne retenir d’un ensemble que les en- tiers pairs. Une solution à ce problème est :

34 CHAPITRE 3. TYPES ABSTRAITS

fonction impairs(A:ensemble):ensemble

B ← ensVide() ;

tantque ¬(estVide(A) a ← choisir(A) ; A ← enlever(a,A) ; si estPair(a) alors

B ← ajouter(a,B) ;

retourner B ;

Ici, nous savons que tout élément candidat à être ajouter dans l’ensemble ne peut pas y appartenir. Nous avons donc tout intérêt à définir l’opération ajouter en interdisant l’ajout d’un élément déjà présent, et ce pour au moins deux raisons :

1. en restreignant la puissance opératoire de ajouter, son implémentation peut être moins coûteuse en temps. Si l’ensemble est représentée par une liste sans répétition, l’ajout d’un élément non présent est fait en temps constant : on l’insère en première position. Par contre, l’ajout d’un élément éventuellement déjà présent nécessite de vérifier sa présence avant de l’y insérer.

2. en restreignant la puissance opératoire de ajouter, on détecte d’éventuels erreurs dans l’écriture d’un exécutable. Vous pourrez demander lors de l’exécution de votre programme de vous signaler toute tentative d’ajouter un élément déjà présent et utiliserez ce signal pour détecter une erreur soit dans le programme soit dans l’algorithme lui-même.

3.1.3 Écriture formelle des axiomes

Définir le sens des opérations est réalisé à l’aide d’axiomes, c’est à dire de formules logiques. Contrairement à une définition mathématique où l’on restreint la portée d’une fonction en restreignant les domaines des arguments, lors d’une définition axiomatique on restreint la portée d’une fonction en définissant le sens que pour des valeurs appartenant à des domaines précis.

Exemple 17 Considérons l’opération ajouter que l’on autorise à opérer sur un élément b n’appartenant pas éventuellement à un ensemble A.

L’opération ensembliste mathématique sous-jacente est la fonction qui à tout ensemble A et à tout élément a associe l’ensemble à A ∪ {a}. Sa définition ax- iomatique est :

∀a : élément ∀b : élément ∀A : ensemble appartient(a,ajouter(b,A)) = (a = b) ∨ appartient(a,A)

3.1. UN EXEMPLE DE TYPE ABSTRAIT : LE TYPE ENSEMBLE 35

Exemple 18 Considérons l’opération ajouter qui ne peut ajouter un élément qui n’appartient pas à l’ensemble considéré.

Définir mathématiquement une telle fonction ne présente aucune difficulté : c’est la fonction qui à tout ensemble A et à tout élément a 6∈ A associe l’ensemble ajouter(a,A) égal à A∪{a}. Ainsi, le mathématicien considèrera que l’expression ajouter(a,ajouter(a,{})) est incorrect.

La définition axiomatique de ajouter est :

∀a : élément ∀b : élément ∀A : ensemble ¬(appartient(a,A)) ⇒ ( appartient(b,ajouter(a,A)) = (b = a) ∨ appartient(b,A) )

L’axiomatique garantit des propriétés dans la mesure où les spécifications des fonctions ont été respectées : ici, on ne peut ajouter un élément à un ensemble qu’à la condition qu’il n’y appartienne pas. Si cette condition n’est pas respectée, l’algorithme peut s’exécuter mais sa correction n’est pas garantie.

Par exemple, l’informaticien admettra éventuellement que l’algorithme suiv- ant puisse s’exécuter :

fonction test(): booléen

A ← ajouter(1,ensembleVide()) ; A ← ajouter(1,A) ; retourner appartient(1,enlever(1,A)) ;

Cependant, il ne garantira pas que le booléen évaluant l’expression appartient(1,enlever(1,A)) soit faux !

Ceci n’est pas une coquetterie obscure. Démontrons qu’une implémentation correcte, naturelle et efficace du type ensemble a pour conséquence que test() retourne vrai.

Implémentons le type ensemble à l’aide d’une liste d’éléments sans répétition et ce sans se soucier de l’ordre des éléments. Une implémentation efficace de ajouter(a, A) est d’insérer a à la première position de la liste (représentant) A. Une implémentation efficace de enlever(1, A) est de supprimer la première oc- currence de 1 dans la liste A : puisque aucune répétition n’est admise dans la liste, pour supprimer toutes les occurrences de 1 il suffit de supprimer la première !. Ainsi, lors de l’exécution de test l’ensemble A est successivement représenté par :

(1) (1, 1)

L’évaluation de appartient(1,enlever(1,A)) fournit de façon aberrante donc le booléen vrai.

36 CHAPITRE 3. TYPES ABSTRAITS

3.1.4 Respect des axiomes

Parfois, lorsque les algorithmes et les programmes sont mal écrits. L’exécution se passe mal : la machine refuse d’exécuter l’instruction. C’est par exemple le cas quand on essaie d’écrire dans une zone mémoire où vous n’avez aucun droit. Cette situation d’échec pour le programme est en réalité heureuse pour le programmeur, qui est forcé d’observer l’existence d’une erreur de programmation.

Souvent, le programme s’exécute et retourne, , à l’image de l’exemple précédent, un résultat totalement erroné. Cette situation est plus fâcheuse pour l’informati- cien qui pourrait considérer alors le programme et le résultat corrects.

Il est possible, lorsque l’on écrit un programme de vérifier si toute fonction est appelée sur des entrées vérifiant les spécifications (appelées aussi préconditions) et de vérifier de même les postconditions sur les sorties. Ici, par exemple, un programme auxiliaire pourrait tester la non appartenance de l’élément a dans l’ensemble A à chaque appel de ajouter sur deux entrées a et A. Ces tech- niques existent et permettent d’écrire des algorithmes robustes. Elles ne seront pas présentées en cours d’algorithme mais en cours de programmation.

3.2 Quelques types abstraits “séquence”

Il existe plusieurs types abstraits “séquences”, c’est à dire plusieurs façons de manipuler une séquence au travers de fonctions. Nous en présenterons 5 princi- pales :

– le tableau – la structure – la pile – la file – la liste

Cette liste n’est pas exhaustive. Vous serez parfois amené à en définir de nouvelles inspirés ou non de celles-ci. De même, vous serez parfois amené à enrichir l’une d’entre elles en ajoutant de nouvelles primitives.

3.2.1 Tableau et structure

Nous ne rappelons pas ici leurs définitions : ces deux types ont été présentées dans le chapitre précédent. Rappelons simplement que la caractéristique com- mune à ces deux types est que la longueur de la séquence est fixe ; la distinction entre tableau et structure est que les éléments d’un tableau ont nécessairement un même type.

La conséquence de cette longueur fixe est double :

1. il est facile d’implémenter ces types et obtenir des opérations de lecture/écriture utilisant l’indice en temps constant.

3.2. QUELQUES TYPES ABSTRAITS “SÉQUENCE” 37

2. aucun opération ne modifie, par définition, la longueur. Ainsi, toute opération d’écriture est un opération de remplacement d’un ancien élément par un nouveau.

3.2.2 La Pile

Une pile est une séquence dont toutes les opérations se font à une extrémité appelé la tête.

Signature

La signature du type pile est la suivante :

Nom pile Utilise élément Opérations estVide : pile → booléen pileVide : → pile tete : pile → élément empiler : pile × élément → pile dépiler : pile → pile

Axiomatique

Voici quelques axiomes définissant dans un langage logique l’ensemble des primitives :

estVide(pileVide()) = vrai()

∀p : pile ∀e : élément estVide(empiler(p,e)) = faux ∀p : pile ∀e : élément tete(empiler(p,e)) = e ∀p : pile ∀e : élément p = dépiler(empiler(p,e))

Exemples

La suite d’instructions suivantes :

p ← empiler(pileVide(),10) ; a ← tete(p) ; p ← empiler(p,20) ; b ← tete(p) ; p ← empiler(p,30) ; c ← tete(p) ; p ← dépiler(p) ; d ← tete(p) ;

38 CHAPITRE 3. TYPES ABSTRAITS

a pour conséquence de créer une pile p et un entier a de valeurs successivement égales à :

p = (10) a = 10

p = (20,10) b = 20

p = (30,20,10) c = 30

p = (20,10) d = 20

Remarques

En fonction des problèmes à résoudre ou des algorithmes à écrire, on peut ajouter ou remplacer des primitives par de nouvelles. À titre d’exemple, on peut supposer l’existence d’une opération fournissant la longueur de de la séquence :

taille : pile → entier

ou une autre qui retire la tête de la pile :

extraireTête : pile → élément × pile

dont la définition algorithme pourrait simplement être :

fonction extraireTête(p:pile) : élément × pile

retourner(tête(p),dépiler(p))

3.2.3 La File

Une file est une séquence dont toutes les opérations se font aux deux extrémités : – la suppression et la lecture à la première extrémité. – l’ajout à la dernière extrémité.

Signature

La signature du type file est la suivante :

Nom file Utilise élément, booléen Opérations estVide : file → booléen fileVide : → file 1erElément : file → élément défiler : file → file enfiler : file× élément → file

3.2. QUELQUES TYPES ABSTRAITS “SÉQUENCE” 39

Axiomatique

Voici quelques axiomes définissant dans un langage logique l’ensemble des primitives :

estVide(fileVide()) = vrai()

∀e : élément 1erElément(enfiler(fileVide(),e))= e ∀e : élément estVide(défiler(enfiler(fileVide(),e))) = vrai() ∀f : file ∀e : élément

non(estVide(f)) ⇒ défiler(enfiler(f,e))=enfiler(défiler(f),e) ∀f : file ∀e : élément

estVide(enfiler(f,e))=faux()

Exemples

La suite d’instructions suivantes :

f ← enfiler(fileVide(),10) ; a ← 1erElément(f) ; f ← enfiler(f,20) ; b ← 1erElément(f) ; f ← enfiler(f,30) ; c ← 1erElément(f) ; f ← défiler(f) ; d ← 1erElément(f) ;

a pour conséquence de créer une file f et des entiers de valeurs successivement égales à :

f = (10) a = 10

f = (10,20) b = 10

f = (10,20,30) c = 10

f = (20,30) d = 20

3.2.4 La Liste

Une liste est une séquence munie d’opérations qui permettent d’accéder à chacun des éléments à partir de leurs positions et qui modifient la séquence à partir d’insertion ou de suppression.

Signature

La signature du type liste est la suivante :

40 CHAPITRE 3. TYPES ABSTRAITS

Nom liste Utilise élément, entier, booléen Opérations estListeVide: liste → booléen listeVide : → liste ièmeElmt : liste × entier → élément insérer : élément × entier × liste → liste supprimer : entier × liste → liste longueur : liste → entier

Axiomatique

Voici quelques axiomes définissant dans un langage logique l’ensemble des primitives :

estListeVide(listeVide()) = vrai()

∀l : liste, ∀e : élément, ∀i ∈ N 1≤i≤longueur(l)+1 ⇒ taille(insérer(e,i,l)))= taille(l)+1

∀l : liste, ∀i ∈ N 1≤i≤longueur(l) ⇒ taille(supprimer(i,l)))= taille(l)-1

∀l : liste, ∀e : élément, ∀(i, j) ∈ N2

1≤j<i≤longueur(l)+1 ⇒ IèmeElmt(insérer(e,i,l),j) = IèmeElmt(l,j) 1≤i=j≤longueur(l)+1 ⇒ IèmeElmt(insérer(e,i,l),j) = e 1≤i<j≤longueur(l)+1 ⇒ IèmeElmt(insérer(e,i,l),j) = IèmeElmt(l,j-1)

∀l : liste, ∀(i, j) ∈ N2

1≤j<i≤longueur(l) ⇒ IèmeElmt(supprimer(i,l),j) = IèmeElmt(l,j) 1≤i≤j≤longueur(l)-1 ⇒ IèmeElmt(supprimer(i,l),j) = IèmeElmt(l,j+1)

Exemples

La suite d’instructions suivantes :

l ← insérer(10,1,listeVide()) ; l ← insérer(20,2,l) ; l ← insérer(30,3,l) ; l ← insérer(40,2,l) ; a ← IèmeElmt(l,1) ; b ← IèmeElmt(l,2) ; l ← supprimer(3,l) ;

a pour conséquence de créer une file l et des entiers de valeurs successivement égales à :

l = (10)

l = (10,20)

3.3. CONCLUSION 41

l = (10,20,30)

l = (10,40,20,30)

a = 10

b = 40

l = (10,40,30)

3.3 Conclusion

Nous avons présenté dans ce cours des types comme des ensembles d’opérations définies logiquement. Nous verrons dans le prochain chapitre comment les implémenter à l’aide des deux types fournis par la machine le type tableau et le type structure.

42 CHAPITRE 3. TYPES ABSTRAITS

Chapitre 4

Implémentations de types

Dans les chapitres prédédents, nous avons fourni une définition des types ab- straits, c’est à dire un ensemble de fonctions spécifiées logiquement. Nous allons voir comment fournir une implémentation, c’est à dire une définition algorith- mique de ceux-ci en utilisant les deux types “premiers“ tableau et structure fournis par la machine.

4.1 Types primitifs et effets de bord

4.1.1 Types primitifs

Les types primitifs correspondent à des objets pouvant être codées sur des blocs mémoire de taille fixe. Ils seront supposés permettre de représenter les quantités numériques que sont les booléens, les entiers, les réels. Conséquence de la taille fixe de leurs représentations en mémoire, toutes les opérations les concernant (∨, 6=, ∧, +, ·, , log, sin, etc. . .) sont supposés être de complexité en temps constant.

4.1.2 Types non primitifs : effet de bord

Seront considérés comme non primitifs les types tableau, structure et tous les types construits à partir de ceux-ci. Nous avons défini pour ces objets de grande taille des opérations permettant de les modifier (par exemple dépiler pour la pile, ajouter pour l’ensemble, insérer pour une liste). Il nous faut préciser dans notre modèle de calcul, le mode de passage de paramètres en entrées d’une fonction. Une alternative s’offre à nous :

Passage par valeur : complexité en temps linéaire et effet de bord

Une première solution qui garantit les axiomes est le passage par valeurs : tous les objets de types primitifs ou non sont, lors d’un appel de fonction, recopiés sur

43

44 CHAPITRE 4. IMPLÉMENTATIONS DE TYPES

une zone mémoire nouvelle avant d’être manipulés par la fonction. Cette hypothèse a l’avantage de simplifier l’étude de la correction des pro-

grammes mais a pour désavantage un coût en temps et en espace : la copie d’un objet de taille n est de complexité en temps et en espace Θ(n). Ce point est illustré par l’exemple suivant :

Exemple 19 L’algorithme suivant décide l’appartenance d’un élément e à une pile p de taille notée n.

fonction appartient(e:élément ; p : pile) : booléen

tantque non(estVide(p))

si e = tete(p) alors

retourner vrai() ;

p ← dépiler(p);

retourner faux()

Si l’on supposait un passage par valeurs, chaque exécution de dépiler étant de complexité en temps la taille de la pile, la somme des complexités en temps de ces éventuels n appels serait

1≤i≤n i et donc Θ(n 2).

Passage par adresse : complexité en temps constante

Nous supposerons lors d’un appel de fonction, que seules les références (ou adresses) aux objets sont fournies en entrée. Ceci concerne les objets de type non primitifs, en ce qui concerne les objets de type primitif de taille par définition con- stante (booléen, entier, réel, etc), nous supposons qu’ils sont passés par valeurs : le coût est le même, il est constant.

Cet avantage du point de vue de la complexité a un prix : l’effet de bord. Conséquence de la non recopie sur une zone mémoire de l’objet fourni en entrée, plusieurs objets peuvent partager une même zone mémoire. Aussi, la modification d’un des objets entraine la modification de tous les autres.

Exemple 20 Conséquence du passage par adresse et del’effet de bord induit, nous verrons pourquoi test retourne le booléen faux. Considérons l’algorithme suivant utilisant l’algorithme appartient défini précédemment :

fonction test():booléen

q ← empiler(1,pileVide());

si appartient(2,q) alors

retourner faux() ;

sinon si non(appartient(1,q)) alors

4.1. TYPES PRIMITIFS ET EFFETS DE BORD 45

retourner faux() ;

sinon

retourner vrai() ;

Les deux variables q et p désignent la même pile : toute modification de p est donc une modification de q. Or, à la fin de l’exécution de appartient la pile p est vide. Ainsi, lors du test non(appartient(1,q)), la pile q est vide. La fonction test retourne donc faux.

Conscient de cet effet de bord, nous devons et pouvons corriger cet algorithme. Et ce, au moins de deux façons :

1. On utilise une copie de la pile lors de la recherche. Sachant que l’algorithme appartient modifie et même “détruit” la pile fournie en entrée, une solution est de lui fournir une copie de q (sur une zone mémoire distincte). L’algorithme test2 est obtenu à partir de test en rem- plaçant la ligne si appartient(2,q) alors par la ligne si appartient(2,copie(q)) alors.

2. On redéfinit appartient de façon à ne pas détruire la pile p quand on y recherche un élément e. Une première solution de complexité en espace non constante est de réaliser une copie préalable de l’objet pile ; cette solution est de complexité peut être très coûteuse en temps et en espace (voir exercices 34 et 35). Une seconde solution de complexité en espace constante est d’enrichir le type abstrait pile d’une nouvelle fonctionnalité. Ici la notion de curseur suffirait ; elle sera définie dans la section suivante.

Exercice 34 Expliquer pourquoi l’algorithme

fonction appartientAvecCopie(e:élément ; p : pile) : booléen

p ← copie(p) ;

tantque non(estVide(p))

si e = tete(p) alors

retourner vrai() ;

p ← dépiler(p);

retourner faux()

est de complexité en temps et en espace quadratique (égale à Θ(n2) avec n = |p|).

Exercice 35 Ecrire un algorithme équivalent appartientAvecCopie de façon itérative de complexité en temps et en espace linéaire (égale à Θ(n) avec n = |p|).

46 CHAPITRE 4. IMPLÉMENTATIONS DE TYPES

Syntaxe

Nous pouvons quand nous écrivons un algorithme, “insister” sur le fait que l’objet non primitif fourni en entrée est passée par références : l’utilisation du symbole ES a cette vocation. De toutes façons, que ce symbole soit présent ou non, les définitions algorithmiques sont équivalentes.

Afin de limiter les effets de bord, nous pouvons limiter le nombre de variables référençant un même objet. Pour cela, il suffit de :

1. interdire tout effet de bord non spécifié par le problème. Ainsi, la définition ci-dessus de appartient qui modifie la pile est con- sidérée comme incorrecte et doit être remplacée par l’une des deux solutions évoquées plus haut.

2. imposer par des règles syntaxiques qu’une seule variable ne référence qu’un objet. On peut par exemple interdire qu’un type retourné soit de type non prim- itif, exception faite naturellement des constructeurs. Par exemple la fonc- tion depiler peut être remplacée par une procédure dépilerProc qui ne retourne rien et qui a pour prototype :

procédure dépilerProc(p:pile)

Cette limitation syntaxique doit être privilégiée.

4.2 Tableau infini

Nous verrons ici comment représenter une séquence infinie à l’aide des deux séquences de taille fixée structure et tableau. Un exemple classique est le type abstrait suivant :

Nom tableauInfini Utilise entier, élément Opérations tableauInfini : élément → tableauInfini ième : tableauInfini × entier → élément changer-ième : tableauInfini × entier × élément → tableauInfini

Définir un tableau infini est fait simplement à l’aide d’une structure contenant deux champs :

1. un premier champ de nom tab de type tableau.

2. un second champ de nom val contenant la valeur de type élément appa- raissant une infinité de fois.

Une implémentation du type tableauInfini est fournie par la Figure 4.1. Cette implémentation nécessite une fonction auxiliaire extensionÉventuelle qui

4.2. TABLEAU INFINI 47

fonction tableauInfini(e: élément) : structure

t ← tableau[e](100) ; retourner structure(tab,val)(t,e)

fonction ième(t:tableauInfini , i: entier): élément

si i ≤ taille(t.tab) alors retourner t.tab[i]

sinon

retourner t.val

fonction changerIème(t:tableauInfini,i:entier,e:élément):tableauInfini

t ← extensionÉventuelle(t,i) ; t.tab[i] ← e ; retourner t

Figure 4.1 – Implémentation de tableauInfini

retourne un tableau égal à celui fourni en entrée mais dont le premier champ est un tableau de taille au moins égal à i. Sa définition est fournie par la Figure 4.2.

Dés que l’on souhaite modifier la valeur d’un élément se trouvant au delà du tableau fini tab, on étend ce tableau à une taille au moins égale à cet indice. Trois stratégies définies par la fonction stratégieExtension se présentent alors :

1. on fait du “juste mesure”. On étend le tableau de ce dont on a exactement besoin à l’instant courant :

fonction stratégieExtension(n:entier,i:entier):entier ;

retourner i

2. on fait du “juste mesure” à une constante multiplicative près :

fonction stratégieExtension(n:entier,i:entier):entier ;

retourner ⌈ i 100

⌉ · 100

3. on anticipe les besoins futurs de façon à limiter le nombre de telles exten- sions. Une stratégie efficace double systématiquement la taille du tableau :

fonction stratégieExtension(n:entier,i:entier):entier ;

nt ← n ; faire

nt ← nt · 2 ; jusqu’à nt ≥ i

retourner nt ;

Exemple 21 Ainsi l’algorithme :

48 CHAPITRE 4. IMPLÉMENTATIONS DE TYPES

fonction extensionÉventuelle(t:tableauInfini,i:entier):tableauInfini

si i ≤ taille(t.tab) alors retourner t

sinon

ntaille ← stratégieExtension(taille(t.tab),i) ; u ← tableau(ntaille)(t.val) ;

pour i ← 1 à taille(t.tab) u[i] ← t.tab[i]

t.tab ← u ; libérer(u) ;

retourner t

Figure 4.2 – Définition de extensionÉventuelle

procédure test()

t ← tableauInfini(17) ; pour i de 1 à n faire

t ← changer-ième(t,i,i·2) ;

réalisera

1. n extensions selon la première stratégie. La complexité en temps cumulée est Θ(n2). Ceci est toujours extrêmement coûteux. Cette stratégie doit être abandonnée.

2. n 100

extensions selon la seconde (dans le cas où la constante est 100). La

complexité en temps cumulée est Θ( n 2

100 ). D’un point de vue théorique, cette

stratégie est identique à la première.

3. log2(n) extensions selon la troisième. La complexité en temps cumulée est Θ(2 · n) car égale à Θ(1 + 2 + 22 + . . . + 2log2(n)). Cette stratégie est pour cette raison préférable aux deux précédentes.

4.3 Représentation d’une pile par une zone mémoire

contiguë

Une utilisation immédiate d’un tableau infini est l’implémentation d’une pile.

Nom pile Utilise élément Opérations

4.4. REPRÉSENTATION D’UNE LISTE ITÉRATIVE À L’AIDE D’UN CHAÎNAGE49

pileVide : → pile estVide : pile → booléen empiler : pile × élément → pile depiler : pile → pile tete : pile → élément

Et ce sous la forme d’une structure ayant deux champs, ce tableau ainsi que l’indice de l’élément au sommet de la pile, l’indice étant égal à 0 si la pile est vide.

fonction pileVide():pileRéel

t← tableauInfini(0.0) retourner structure(tab,ind)(t,0)

fonction empiler(p:pileRéel, r:réel):pileRéel

p.ind ← p.ind + 1 ; p.tab ← changerIème(p.tab,p.ind,r) ; retourner p

Exercice 36 Écrire les autres primitives.

Exercice 37 Implémenter le type abstrait pile en utilisant directement le type tableau.

4.4 Représentation d’une liste itérative à l’aide

d’un châınage

Une définition du type abstrait liste est la suivante :

Nom liste Utilise élément, entier Opérations estListeVide: liste → booléen listeVide : → liste ièmeElmt : liste × entier → élément insérer : élément × entier × liste → liste supprimer : entier × liste → liste longueur : liste → entier

50 CHAPITRE 4. IMPLÉMENTATIONS DE TYPES

4.4.1 Encapsulation du type élément dans un noeud

Une première idée qui sera réutilisée dans le cas d’objets plus complexes, tels les arbres, est de définir un nouveau type abstrait, le type noeud, qui encap- sule le type élément. Cette définition complexifie la définition du type liste en nécessitant de définir le nouveau type abstrait noeud. Ce coût initial est large- ment compensée par la simplicité des algorithmes implémentant les différentes primitives comme nous le verrons plus loin.

D’un point de vue théorique, l’ensemble des noeuds peut être vue comme le domaine de définition de la liste. Nous rappelons qu’une liste l est l’incarnation de la structure mathématique “séquence” qui est une fonction l associant à tout élément d’un domaine Doml un élément dans un ensemble Ω.

La signature du type noeud est :

Nom noeud Utilise élément, booléen Opérations constructNoeud : élément × noeud → noeud ?suivant : noeud → booléen suivant : noeud → noeud contenu : noeud → élément changerCont : noeud × élément → noeud changerSuiv : noeud × noeud → noeud

L’implémentation d’un tel objet est réalisé à l’aide d’une structure contenant deux champs :

1. un champ de nom cont de type élément.

2. un champ de nom suiv désignant le noeud suivant.

Voici l’écriture de quelques primitives :

fonction constructNoeud(e:élément,n:noeud):noeud

retourner structure(cont,suiv)(e,n)

fonction changerSuiv(n:noeud,p:noeud):noeud

n.suiv ← p ; retourner n

Exercice 38 Écrire les autres primitives du type noeud.

4.4. REPRÉSENTATION D’UNE LISTE ITÉRATIVE À L’AIDE D’UN CHAÎNAGE51

4.4.2 Sentinelle avant

Si l’on souhaite insérer un élément e en ième position, deux cas apparaissent selon que i est égal à 1 ou non :

– si i = 1, le nouveau premier noeud est celui contenant cet élément e. – si i 6= 1, le premier noeud est inchangé.

Cette singularité i = 1 complexifie cet algorithme. Il est facile de se douter que d’autres algorithmes le seront aussi.

Une seconde idée permet de simplifier l’écriture de nombreux algorithmes. Elle consiste à utiliser une “sentinelle avant” : l’idée étant de représenter en mémoire la liste (3, 5) par une liste composée de trois éléments (#, 3, 5), la valeur du premier élément n’ayant aucune importance quant à la valeur de la liste représenté : cet élément s’appelle la sentinelle avant.

Ainsi, une liste sera représentée par un type structure contenant un unique champ de nom sentinelleAvant et désignant cette sentinelle avant.

Pour des raisons similaires, il est souhaitable de rajouter une sentinelle arrière. Supposons donc enrichi le type noeud de façon à pouvoir produire des noeuds sentinelles et à pouvoir tester si un noeud est une sentinelle. En d’autres termes, supposons les opérations :

sentinelle : → noeud ?sentinelle : noeud → booléen

Ainsi, la fonction listeVide peut se définir ainsi :

fonction listeVide():liste

n ← sentinelle() ; n ← changerSuivant(n,sentinelle()) ; retourner structure(sentinelleAvant)(n) ;

Une fonction très utile de nom ièmeNoeud permet à partir d’une liste et d’un entier i de retourner son ième noeud (le noeud sentinelle si i = 0). La fonction ièmeNoeud peut ainsi être définie :

fonction ièmeNoeud(l:liste;i:entier):noeud

n ← l.sentinelleAvant ;

faire i fois

n ← suivant(n) ;

retourner n

Il en découle les définitions des primitives ième et insérer (Figure 4.3 et 4.4).

52 CHAPITRE 4. IMPLÉMENTATIONS DE TYPES

fonction ième(l:liste;i:entier):élément

retourner contenu(ièmeNoeud(l,i)) ;

Figure 4.3 – Fonction ième

fonction insérer(l:liste,i:entier,e:élément):liste

prec ← ièmeNoeud(l,i-1) ; n ← constructNoeud(cont,suiv)(e,suivant(prec)); prec ← changerSuiv(prec,n) ;

retourner l

Figure 4.4 – Fonction insérer

Exercice 39 Écrire la fonction supprimer.

Exercice 40 Indiquer comment singulariser un noeud comme noeud sentinelle. En d’autres termes, écrire les primitives sentinelle et ?sentinelle.

4.4.3 Autre attribut de la liste : sa longueur

Si l’algorithme le requiert, pour simplifier ou optimiser l’algorithme nous pou- vons définir de nouveaux attributs au type liste. Nous pouvons par exemple implémenter le type liste de façon à ce que le calcul de la longueur se fasse en temps constant. Pour cela :

1. nous enrichissons le type abstrait de la fonction : longueur liste -> entier.

2. nous ajoutons dans la structure implémentant la liste un champ longueur initialisé à 0 et modifier lors de toute modification de la liste.

La fonction listeVide peut se définir ainsi :

fonction listeVide():liste

n ← sentinelle() ; n ← changerSuivant(n,sentinelle()) ;

retourner structure(sentinelleAvant,longueur)(n,0) ;

Exercice 41 Écrire les autres primitives du type liste ainsi enrichi.

4.4. REPRÉSENTATION D’UNE LISTE ITÉRATIVE À L’AIDE D’UN CHAÎNAGE53

4.4.4 Un autre attribut de la liste : le curseur

L’exécution de la procédure test3 de la Figure 4.5 double la valeur de chaque élément entier d’une liste l ; la complexité en temps est Θ(longueur(l)2), puisque l’exécution de iemeNoeud(l,i) est de complexité en temps Θ(i).

fonction test3(l:liste) : liste

n ← taille(l) ;

pour i ← 1 à n faire p ← ièmeNoeud(l,i) ; p ← changerCont(p,2·contenu(p))

Figure 4.5 – Parcours d’une liste

Certes, on aurait pu réécrire l’algorithme en parcourant les noeuds de proche en proche et obtenir un algorithme linéaire. Mais nous allons montrer qu’une meilleure implémentation permet d’obtenir une complexité en temps linéaire sans modifier l’algorithme.

Cette idée consiste à ajouter à l’implémentation de la liste un noeud curseur (nom du champ curseur) ainsi que sa position (nom du champ positionCurseur). Ce curseur est positionné sur le ième noeud à chaque appel de la fonction ièmeNoeud. Ainsi, si le curseur est le noeud de position i (le i-ième noeud) d’une liste l, la complexité en temps de l’exécution de ièmeNoeud sur l’entrée l et i+ 1 (voire i, i+ 2) est constant et non plus θ(i+ 1).

L’écriture de ièmeNoeud est alors :

fonction ièmeNoeud(l:liste;i:entier):noeud

si l.positionCurseur ≤ i n ← n.curseur ; j ← n.positionCurseur ;

sinon

n ← l.sentinelleAvant ; j ← 0 ;

faire (j-i) fois

n ← suivant(n) ;

l.curseur ← n ; l.positionCurseur ← i ;

retourner n

54 CHAPITRE 4. IMPLÉMENTATIONS DE TYPES

En conséquence de quoi, il est facile d’observer que la fonction test de la Figure 4.5 a une complexité en temps égale non pas à θ(longueur(l)2) mais θ(longueur(l)).

Implémentation du type liste

Implémenter le type liste se fait en utilisant un type structure composé des champs :

– sentinelleAvant – curseur – positionCurseur – longueur Les différentes fonctions se définissent ainsi :

fonction estVide(l : liste) : booléen

retourner l.longueur = 0

fonction longueur(l : liste) : entier

retourner l.longueur

fonction listeVide() : liste

n ← sentinelle() ; n ← changerSuivant(n,sentinelle()) ;

retourner structure(sentinelleAvant,curseur,positionCurseur, longueur)

(n, n, 0, 0);

fonction ièmeElmt(l : liste ; i : entier) : élément

retourner contenu(ièmeNoeud(l,i))

fonction supprimer(l : liste ; i : entier) : liste

n ← ièmeNoeud(l,i-1) ; n ← changerSuivant(n,suivant(n)) ;

l.longueur ← l.longueur - 1 ;

retourner l

fonction insérer(e : élément ; i : entier ; l : liste ) : liste

4.4. REPRÉSENTATION D’UNE LISTE ITÉRATIVE À L’AIDE D’UN CHAÎNAGE55

n ← ièmeNoeud(l,i-1) ; ajout ← constructNoeud(e,suivant(n)) ; n ← changerSuiv(n,ajout) ;

l.longueur ← l.longueur + 1 ;

retourner l

Exercice 42 Ecrire une fonction qui réalise la concaténation de deux listes.

Châınage arrière

Une amélioration parfois utile est de pouvoir parcourir la liste dans les deux sens. Cela se fait simplement en considérant un nouveau type noeud possédant pour fonctions celles du type noeud augmentées de :

?prec : noeud → booléen prec : noeud → noeud changerPrec : noeud × noeud → noeud

L’implémentation de nouveau type est réalisé en ajoutant dans la structure une nouveau champ prec.

L’intérêt d’utiliser ce nouveau type noeud est par exemple, dans le cas d’une liste avec Curseur, la définition de ièmeNoeud de complexité en temps Θ(min(i, |i− d|)) où d est la position du curseur.

Exercice 43 Ecrire une implémentation de ièmeNoeud de complexité en temps Θ(min(i, |i− d|)) où d est la position du curseur.

Exercice 44 Ecrire une implémentation de ièmeNoeud de complexité en temps Θ(min(i, |i−d|, n− i)) où d est la position du curseur et n la longueur de la liste.

4.4.5 Implémentation d’un nouveau type liste

Il est possible de repenser totalement la définition du type abstrait, c’est à dire du nombre et du sens de chacune des opérations en fonction des concepts intro- duits. Ceci permet d’écrire des algorithmes en utilisant des routines de bas niveau manipulant ici par exemple des curseurs et permettant d’évaluer très précisément la complexité de ces algorithmes.

Voici, un exemple de nouvelle signature :

Nom nouvelleListe Utilise noeud, entier, booléen Opérations

56 CHAPITRE 4. IMPLÉMENTATIONS DE TYPES

estVide : nouvelleListe → booléen listeVide : → nouvelleListe curseur : nouvelleListe → noeud positionCurseur : nouvelleListe → entier debut : nouvelleListe → nouvelleListe fin : nouvelleListe → nouvelleListe avant : nouvelleListe → nouvelleListe tropADroite : nouvelleListe → booléen

où la signification des opérations est ainsi “vulgairement” défini :

– début (fin) positionne le curseur sur le premier (resp. dernier) noeud. – avant avance le curseur. – tropADroite indique si le curseur a débordé de la dernière position.

Exercice 45 Définir un type noeud permettant de d’implémenter en temps con- stant une opération arrière:liste -> liste qui permet de reculer le curseur.

Remarque

La liste d’opérations définissant le type nouvelleListe est fourni à titre d’ex- emple. On peut la modifier en ajoutant par exemple des opérations du type liste comme ièmeElmt, de nouvelles opérations comme ièmeNoeud, avancerIèmePosition etc... Il est inutile de vouloir définir une type liste universel car le nombre d’opérations serait trop élevé. Le choix de ces opérations est dicté par le problème à résoudre et l’algorithme écrit.

4.5 Implémentation d’un arbre binaire par châınage

Toutes les notions nécessaires à la définition du (ou des) types abstraits “ar- bres” et à leurs implémentations apparaissent dans les chapitres ou sections précédentes. Pour cette raison, différentes questions sont laissées en exercice.

4.5.1 Utilisation du type noeud

De la même façon que pour les listes, la définition du type abstrait arbre peut être défini en utilisant un type abstrait auxiliaire noeud. D’un point de vue théorique, l’ensemble des noeuds peuvent être considérés comme les éléments du domaine de définition.

Les définitions que nous présentons ici ne sont pas universelles, elles peuvent évoluer selon le problème à résoudre ou l’algorithme à écrire. Voici les définitions des deux types abstraits noeud :

4.5. IMPLÉMENTATION D’UN ARBRE BINAIRE PAR CHAÎNAGE 57

Nom arbre Utilise noeud, entier, booléen Opérations

estVide : arbre → booléen arbreVide : → arbre racine : arbre → noeud prendreRacine : noeud → arbre

Figure 4.6 – Définition de arbre

Nom noeud Utilise élément, booléen Opérations

contenu : noeud → élément ?filsGauche : noeud → booléen ?filsDroit : noeud → booléen filsGauche : noeud → noeud filsDroit : noeud → noeud constructNoeud : élément × noeud × noeud → noeud changerFilsGauche : noeud × noeud → noeud changerFilsDroit : noeud × noeud → noeud changerContenu : noeud × élément → noeud

Figure 4.7 – Définition de noeud

4.5.2 Utilisation de sentinelles

De la même façon que pour les listes, l’utilisation de sentinelles facilite l’écriture d’algorithmes. Les fonctions afférentes aux sentinelles à ajouter au type noeud sont :

Opérations estSentinelle : noeud → booléen constructSentinelle : → noeud

Le sens de constructSentinelle est de construire un noeud sentinelle de noeuds associés (fils, fils droit, éventuellement père) lui-même.

4.5.3 Ajout d’un châınage père

De la même façon que pour les listes dans lesquelles on souhaite se déplacer dans les deux sens avant arrière, il est parfois très utiles d’accéder au noeud père. Cela consiste à enrichir le type noeud en ajoutant les fonctions et en redéfinissant la fonction constructNoeud :

58 CHAPITRE 4. IMPLÉMENTATIONS DE TYPES

Opérations ?père : noeud → booléen père : noeud → noeud changerPère : noeud× noeud → noeud

Exercice 46 Définir algorithmiquement les primitives du type abstrait arbre.

Exercice 47 Définir algorithmiquement les primitives du type abstrait noeud.

commentaires (0)
Aucun commentaire n'a été pas fait
Écrire ton premier commentaire
Ceci c'est un aperçu avant impression
Chercher dans l'extrait du document
Docsity n'est pas optimisée pour le navigateur que vous utilisez. Passez à Google Chrome, Firefox, Internet Explorer ou Safari 9+! Téléchargez Google Chrome