Algorithmique des arbres, Ocaml L2, 2ème semestre

publicité
Algorithmique des arbres, Ocaml
L2, 2ème semestre
Frédérique Carrère
Irène Durand
Florian Golemo
Stefka Gueorguieva
Nathan Lothe
Mathieu Raffinot
Marc Zeitoun
Version du 13 février 2017
http ://dept-info.labri.fr/ENSEIGNEMENT/asda/
[email protected]
Table des matières
1 Introduction
1
Les arbres comme structures de données . . . . . . .
1.1
Utilisation des arbres et premières définitions
1.2
Variantes d’arbres . . . . . . . . . . . . . . .
2
Données attachées aux nœuds d’un arbre . . . . . .
3
Quelques tâches importantes . . . . . . . . . . . . .
4
Références . . . . . . . . . . . . . . . . . . . . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
2 Révisions : tableaux et listes
1
Rappels sur les tableaux . . . . . . . . . .
1.1
Quelques exercices sur les tableaux
2
La structure de donnée de liste . . . . . .
2.1
Les listes en OCaml . . . . . . . .
2.2
Quelques exercices sur les listes . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
7
. 7
. 9
. 10
. 11
. 12
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
2
2
2
5
5
5
6
3 Arbres et arbres de recherche
14
1
De la liste à l’arbre . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 14
2
Arbres binaires de recherche . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 16
1
Chapitre 1
Introduction
Ce cours se situe dans l’apprentissage des structures de données. Nous allons aborder et approfondir
la notion d’arbre, en passant par des rappels sur les tableaux et les listes, pour bien comprendre
pourquoi et dans quels cas la notion ou la structure d’arbre permet d’améliorer nos algorithmes, que
ce soit en conceptualisation, en simplicité ou en efficacité.
1
Les arbres comme structures de données
Les arbres sont une structure de donnée très utilisée en informatique. Le mot « arbre > > est en
fait un terme générique, qui désigne plusieurs sortes de structures de données différentes. Nous verrons
quelques unes de ces variantes en Section 1.2. Les arbres permettent d’organiser l’information de façon
plus élaborée que dans le cas de structures de données linéaires (telles les tableaux ou les listes). Cette
plus grande complexité a un coût et un gain :
• Le coût à payer une plus grande difficulté de manipulation des arbres par rapport à celle des
structures linéaires.
• Le gain apporté peut être une facilité de modélisation, lorsqu’on veut représenter des structures
hiérarchiques pour lesquelles les arbres sont très bien adaptés, ainsi qu’une meilleure efficacité
algorithmique.
Ce court chapitre introductif présente quelques utilisations des arbres en informatique et quelques
variations sur la structure de donnée d’arbre. La Section 2 explique la notion de multi-ensemble, que
nous voulons représenter grâce à cette structure de données. Enfin, la Section 3 présente des tâches
fréquemment effectuées sur les arbres, qui seront rencontrées dans le cours.
1.1
Utilisation des arbres et premières définitions
La notion d’arbre est introduite dans cette section avec des applications concrètes. Nous en profitons
pour introduire des termes importants, à retenir. Ils sont écrits avec cette police de caractères.
Vocabulaire et premier exemple : système de fichiers
Les arbres sont particulièrement adaptés à la représentation et à la manipulation d’information
déjà hiérarchique. Par exemple, un système de fichiers a une structure hiérarchique : un répertoire
peut contenir des sous-répertoires ou fichiers, qui eux-mêmes peuvent contenir des sous-répertoires ou
fichiers, etc. On peut représenter des répertoires et fichiers par un arbre, comme indiqué en Figure 1.1.
L’usage en informatique est de dessiner cette structure avec la racine / du système de fichiers en
haut, et en indiquant les répertoires au-dessous de celui qui les contient. On obtient la représentation
de la Figure 1.2. Chaque répertoire correspond à un nœud de l’arbre. Distinguer le nœud racine (ici
/) permet de parler de relations de parenté entre nœuds :
• Chaque nœud, sauf la racine, a un unique père. Dans l’exemple concret du système de fichiers,
le père d’un répertoire (autre que la racine) est le répertoire qui le contient. Ainsi, le père du
seul nœud dont le nom est include est le nœud usr.
2
/
bin
home
nafnaf
idees
plans
nifnif
noufnouf
usr
bin
include
lib
Fig. 1.1 : Vue hiérarchique de répertoires
• Inversement, chaque nœud peut avoir (ou non) des fils. Par exemple, le nœud usr a 3 fils (nommés
bin, include et lib). En revanche, le nœud include n’a aucun fils. Un nœud qui n’a pas de fils
est appelé une feuille.
• Un nœud qui n’est pas une feuille est appelé nœud interne.
• L’arité d’un nœud est son nombre de fils. Les feuilles sont donc les nœuds d’arité 0.
• Enfin, deux nœuds qui ont même père sont appelés frères.
/
bin
nafnaf
idees
home
nifnif
usr
noufnouf
bin
include
lib
plans
Fig. 1.2 : Représentation arborescente du même système de fichiers
Dans la Figure 1.2, on a indiqué chaque lien de parenté par une flèche du père vers le fils. En
général, on omet les flèches, qui sont redondantes avec le choix de placer la racine en haut, et de « faire
croître > > les arbres vers le bas.
Remarquez également que les noms des répertoires sont de l’information attachée aux nœuds, ils ne
désignent pas les nœuds eux-mêmes. En particulier, le même nom peut être utilisé pour 2 nœuds différents. Sur cet exemple, c’est le cas : deux des répertoires s’appellent bin (pour un arbre qui représente
un système de fichiers, deux frères ne peuvent bien sûr pas avoir le même nom).
Étant donné un nœud d’un arbre, il y a un unique chemin reliant ce sommet à la racine et ne
passant pas 2 fois par le même sommet. Une branche est un chemin qui va de la racine à une feuille.
Par exemple, / −
→ home −
→ nafnaf −
→ plans est une branche. Il y a donc autant de branches que de
feuilles. Enfin, un sous-arbre enraciné à un nœud particulier est l’arbre obtenu en ne conservant que
le nœud et ses descendants. Par exemple, le sous-arbre enraciné au seul nœud marqué usr est l’arbre
de la Figure 1.3 suivante.
3
usr
bin
include
lib
Fig. 1.3 : Sous-arbre enraciné au nœud marqué usr
Notez enfin que certains détails sont pour l’instant ignorés. Par exemple, doit-on considérer que
l’ordre des fils a une importance, ou au contraire, que permuter deux fils sur la figure représente le
même arbre ?
Dans ce cours, on utilisera des arbres binaires dans lesquels l’ordre des fils est important. Un arbre
est dit binaire lorsque tout nœud a 0, 1 ou 2 fils. Comme on utilisera des arbres dans lesquels l’ordre
des fils importe, on distingue le fils gauche et le fils droit de tout nœud interne. De la même façon,
on parle de sous-arbre gauche d’un nœud interne le sous-arbre enraciné en son fils gauche (idem
pour le sous-arbre droit).
Des utilisations très diverses
Les arbres ont de multiples utilisations en informatique. Nous en mentionnons quelques-unes dans
cette partie. Une utilisation très courante des arbres est fournie par le format XML (eXtended Markup
Language). Il s’agit d’un format standard de documents, utilisé dans de multiples contextes pour
stocker ou transmettre de l’information. Le code suivant est un exemple de fichier XML. Le format
est utilisé dans de nombreuses applications, comme les bases de données, les données GPS, le dessin
vectoriel, etc. Vous pouvez en visualiser d’autres, par exemple en regardant des traces fournies par un
GPS, ou les fichiers générés par un logiciel de dessin vectoriel (comme Inkscape).
<?xml version=” 1 . 0 ” e n c o d i n g=”UTF−8” ?>
<b o o k s t o r e>
<book i s b n=” 978 −0201896831 ”>
< t i t l e>The Art o f Computer Programming , Fundamental A l g o r i t h m s</ t i t l e>
<a u t h o r>Donald E . Knuth</ a u t h o r>
<y e a r>1997</ y e a r>
<volume>1</ volume>
<p r i c e>65</ p r i c e>
</ book>
<book i s b n=” 978 −2258034310 ”>
< t i t l e>C a l v i n e t Hobbes , tome 1 : Adieu , monde c r u e l !</ t i t l e>
<a u t h o r> B i l l Watterson</ a u t h o r>
<y e a r>1999</ y e a r>
<volume>1</ volume>
<p r i c e>10</ p r i c e>
</ book>
</ b o o k s t o r e>
Ce code se représente naturellement par un arbre, donné en Figure 1.4, où les valeurs concrètes ont
été omises.
bookstore
book
title
author
year
book
volume
price
title
author
year
volume
price
Fig. 1.4 : Une représentation du document XML
De façon générale, les arbres sont utilisés pour représenter et manipuler de l’information naturellement hiérarchique, mais aussi pour rendre plus rapide l’accès à l’information, tout en conservant
un temps d’accès raisonnable aux données. Les domaines d’applications des arbres en informatique
4
ne se limitent pas aux deux exemples déjà rencontrés. Ils sont utilisés dans des domaines aussi variés
que les algorithmes de routage, la compression de données, des algorithmes de fouille de données et
d’apprentissage, des algorithmes de résolution de jeux (comme les échecs), ou des algorithmes de rendu
de jeux 3D, voir ici et là. Ils interviennent aussi dans la représentation de grandes bases de données,
la représentation de certains formats de document (nous avons vu XML, mais le format PDF utilise
aussi les arbres, par exemple). Enfin, ils interviennent pour approcher des problèmes difficiles sur les
graphes (et cette liste d’applications n’est pas exhaustive). La structure d’arbre est donc réellement
fondamentale en informatique.
1.2
Variantes d’arbres
On distingue plusieurs sortes d’arbres : dans ce cours l’ordre des fils aura une importance. Ainsi,
les deux arbres de la Figure 1.5 ne sont pas les mêmes.
Fig. 1.5 : Deux arbres symétriques mais différents
Les arbres les plus simples sont les arbres binaires, dans lesquels chaque nœud interne a 2 fils. On
considèrera aussi des arbres à arité variable d’un nœud à un autre (on rappelle que l’arité d’un nœud
est son nombre de fils). Parfois, on peut considérer l’arbre vide, qui ne contient aucune feuille. On peut
aussi considérer, au contraire, que tout arbre a au moins un nœud. Cette distinction impacte en fait
tous les arbres que l’on considère, car les définitions d’arbres sont récursives.
2
Données attachées aux nœuds d’un arbre
Ensembles et multi-ensembles
Tout au long de ce cours, nous allons manipuler des données mémorisées dans un arbre. La première
idée est de partir d’un ensemble d’éléments. Par exemple, si les données à manipuler sont les entiers
entre 0 et 10, l’ensemble correspondant serait {0 , 1 , 2 , 3 , 4 , 5 , 6 , 7 , 8 , 9 , 10}. Malheureusement, les
ensembles ne suffisent pas, car un ensemble contient un élément ou pas, mais ne peut pas contenir
deux fois le même élément. On veut donc manipuler des objets mathématiques dans lesquels un élément
peut apparaître plusieurs fois, mais où l’ordre n’importe pas. L’objet mathématique adapté est en fait
une fonction d’un ensemble E dans N : pour un élément x ∈ E, l’entier f (x) qui compte le nombre
d’occurrences de x. Cet objet s’appelle un multi-ensemble. En pratique, on écrira un multi-ensemble
fini simplement en donnant ses éléments, chaque élément pouvant être présent plusieurs fois. Au lieu
d’utiliser la notation {. . .}, on utilisera {{. . .}}.
On utilisera souvent des valeurs entières, parce que c’est un type simple. Par exemple, on pourra
considérer le multi-ensemble S = {{12 , 2 , 2 , 3 , 5 , 6 , 7 , 6}}. Comme l’ordre des éléments dans un multiensemble n’est pas importante, on peut aussi noter S = {{7 , 2 , 12 , 3 , 5 , 2 , 6 , 6}}, ou bien encore S =
{{2 , 2 , 3 , 5 , 6 , 6 , 7 , 12}}. Ce multi-ensemble peut être représenté par un tableau, par une liste, par des
arbres, etc. Qu’est-ce qui change ? Nous allons voir cela en détail.
3
Quelques tâches importantes
Après un rappel sur les structures de données linéaires (tableau et listes), les chapitres suivants
présentent des problèmes et algorithmes sur les arbres. Ces algorithmes sont guidés par l’utilisation
qu’on souhaite faire des arbres. Cependant, certaines opérations sont génériques et importantes. Elles
reviennent donc fréquemment. Parmi celles-ci, il est conseillé de réfléchir à des algorithmes pour exécuter les tâches suivantes. Vous pouvez retrouver les algorithmes correspondants, écrits en OCaml, sur la
5
page du cours de Programmation fonctionnelle, https ://www.labri.fr/~zeitoun/enseignement/
16-17/PF/.
Pour simplifier, nous nous limitons volontairement ici aux arbres binaires. À partir d’un arbre
donné en entrée, il s’agit de calculer :
• son nombre de feuilles,
• son nombre de nœuds internes,
• sa taille, c’est-à-dire son nombre de nœuds, en comptant les feuilles comme les nœuds internes,
• sa hauteur, c’est-à-dire la longueur maximale d’une de ses branches,
• la liste de ses nœuds en parcours infixe,
• la liste de ses nœuds en parcours préfixe,
• la liste de ses nœuds en parcours postfixe.
Par ailleurs, comme les arbres seront utilisés pour mémoriser de information, une tâche élémentaire
est, étant donné un arbre t dont les nœuds sont étiquetés par des éléments d’un ensemble E, ainsi
qu’un élément x de E, de calculer
• un booléen, vrai si l’élément x apparaît dans l’arbre, et faux sinon.
• de façon plus précise, on peut demander que la fonction renvoie, en plus de ce booléen, un chemin
de la racine à un sommet qui contient l’élément x. Ce chemin peut être représenté par une liste
de directions (gauche ou droite).
• enfin, les arbres sont fréquemment utilisés comme structure de données pour représenter des
dictionnaires. Un dictionnaire est une liste de couples, chaque couple étant de la forme (k , v),
où k est appelée clé et v est appelée valeur. Chaque clé apparaît au maximum une fois dans
l’arbre. Par contre, une valeur peut apparaître plusieurs fois (associée à des clés différentes). Par
exemple, dans un dictionnaire au sens habituel, chaque clé est un mot et la valeur associée à un
mot contient ses différentes définitions.
Dans ce cadre, plutôt que de retourner simplement un booléen disant si x apparaît dans l’arbre,
il est intéressant d’avoir un algorithme recherchant x comme clé dans l’arbre, et si x est effectivement présent comme clé, retournant l’unique valeur associée à x.
4
Références
Le livre [1] est librement disponible en ligne. Il contient plusieurs algorithmes classiques, en particulier sur les arbres.
Bibliographie
[1]
D. Beauquier, J. Berstel et Ph. Chrétienne. Éléments d’Algorithmique. Masson, 1992. url :
http ://www-igm.univ-mlv.fr/~berstel/Elements/Elements.html.
6
Chapitre 2
Révisions : tableaux et listes
Ce chapitre présente quelques rappels sur les structures linéaires : tableaux et listes. On compare
notamment l’intérêt algorithmique de chacune de ses structures.
1
Rappels sur les tableaux
Un tableau est une suite d’éléments consécutifs en mémoire, tous de même type. Un tableau est
déterminé par :
• sa taille,
• le type des éléments qu’il peut contenir,
• l’adresse de sa première case.
La figure 2.1 représente un tableau contenant les éléments du multi-ensemble S = {{15 , 17 , 42 , 42 , 45 , 84 , 99}}
(dans l’ordre où ils sont écrits).
Premier indice
0
1
2
3
4
5
6
15 17 42 42 45 84 99
Indices
Éléments
Longueur du tableau: 7
Fig. 2.1 : Un tableau contenant 7 entiers
Remarque. 2.1 Même si on veut seulement représenter un multi-ensemble, un tableau contient en
fait plus d’information, car ses éléments sont naturellement ordonnés par leur indice dans le tableau.
Un tableau représente donc en fait, mathématiquement, un vecteur.
Cela n’empêche pas d’utiliser cette structure pour représenter un multi-ensemble, en ignorant
simplement l’ordre des éléments. Autrement dit, si on représente un multi-ensemble par un tableau,
permuter les éléments de ce tableau ne change pas le multi-ensemble représenté. Il peut alors être
avantageux de maintenir une représentation particulière de ce multi-ensemble (par exemple triée),
pour pouvoir effectuer des tâches plus efficacement (par exemple, en utilisant la dichotomie).
Un tableau est implanté en rangeant ses éléments de façon consécutive en mémoire, à partir de
l’adresse du premier élément du tableau. Ce choix a un avantage et deux inconvénients :
1. Accès direct. L’avantage des tableaux est qu’ils permettent l’accès à un élément dont l’indice
est connu en temps O(1) (cette propriété est appelée accès direct). En effet, pour accéder à
l’élément se trouvant en ième case d’un tableau, il suffit d’ajouter à l’adresse de son premier
élément i fois la taille de chaque élément. Le calcul de l’adresse de l’élément p[ i ] nécessite donc
seulement une addition et une multiplication. L’accès à la valeur p[ i ], ou *(p+i), nécessite en
7
plus de dé-référencer l’adresse. On a donc un nombre constant d’opérations « élémentaires » à
effectuer (une addition, une multiplication et un dé-référencement), ce qui nécessite un temps
O(1), c’est-à-dire constant, indépendant de l’indice i et de la taille du tableau.
p
p+3
0
1
2
3
4
5
6
15 17 42 42 45 84 99
Fig. 2.2 : Accès direct
Remarque. 2.2 En langage C, l’arithmétique des pointeurs effectue la multiplication par la
taille des éléments du tableau sans que l’on ait à l’écrire : si on écrit
int p [ 1 0 ] ;
float q [ 2 0 ] ;
alors p + 5 est l’adresse de la 6ème case de p et q + 15 est l’adresse de la 16ème case de q. On
n’a pas besoin de (et il ne faut pas, en fait) multiplier par sizeof(int) dans le cas de p, ou
par sizeof(float) dans le cas de q.
2. Redimensionnement coûteux. Dans certains langages, le nombre d’éléments d’un tableau est
fixe et décidé au moment de l’allocation mémoire. Dans ce cas, il n’est pas possible d’étendre un
tableau dont toutes les cases sont occupées pour y ranger un élément supplémentaire, sans faire
soi-même une recopie à la main.
Il existe cependant des langages permettant d’étendre un tableau. En C, vous avez vu la fonction
de la bibliothèque standard de prototype void * realloc (void *ptr, size_t size ); . Cette fonction
permet de redimensionner un tableau déjà alloué par une des fonctions malloc ou calloc. Par
exemple, l’instruction C :
r e a l l o c ( p , 10* s i z e o f ( int ) ) ;
fait passer de la situation de la Figure 2.3 (a) à celle de la Figure 2.3 (b) (si elle réussit, et en
supposant que p est un tableau contenant des éléments de type int).
p
0
p
(a)
1
2
3
4
5
6
0
(b)
1
2
3
4
5
6
7 8 9
15 17 42 42 45 84 99
15 17 42 42 45 84 99 ? ? ?
Longueur du tableau: 7
Longueur du tableau: 10
Fig. 2.3 : Redimensionnement d’un tableau
Cependant, même s’il y a assez de mémoire disponible, redimensionner un tableau peut conduire
à recopier tout le tableau. C’est le cas s’il n’y a pas assez d’espace contigu en mémoire, par
exemple si d’autres données ont déjà été allouées « de chaque côté du tableau > >, comme dans
la Figure 2.4.
p1
0
p2
1
2
3
4
5
6
0
p3
1
2
3
4
5
6
0
1
2
3
4
5
6
50 12 11 85 42 77 20 15 17 42 42 45 84 99 10 70 99 18 53 79 80
Fig. 2.4 : Une situation où redimensionner p2 est coûteux
8
Remarque. 2.3 Dans l’exemple de la figure 2.4, redimensionner p2 serait coûteux même s’il y
avait de la place disponible « à sa gauche > >, puisque l’extension doit se faire sur la droite.
Remarque. 2.4 Au niveau de la programmation, lorsqu’un gestionnaire de mémoire comme
celui de la bibliothèque standard C a besoin d’étendre un tableau de taille n, dire que le coût
de l’extension est O(n) dans le cas le pire n’est pas tout à fait exact. En effet, pour allouer
le nombre voulu de cases, l’algorithme de gestion mémoire doit trouver l’espace consécutivement en mémoire (car dans un tableau, on range les éléments de façon consécutive). Il est
possible qu’il y ait suffisamment d’espace en mémoire, mais pas consécutivement. Si l’algorithme décale certaines zones occupées mémoire pour créer un espace contigu assez grand, le
coût du redimensionnement peut être très important (et dépend de la taille de la mémoire
disponible, et non plus de celle du tableau).
3. Insertion et suppression coûteuses. Enfin, si on veut insérer un élément dans un tableau à
un indice i (en supprimant son dernier élément), il faut décaler tous les éléments à un indice j > i,
ce qui nécessite à nouveau, dans le cas le pire, O(n) affectations. De même, pour supprimer un
élément d’un tableau, il faut décaler d’une case vers la gauche tous les éléments d’indice supérieur
à l’indice de l’élément à supprimer, ce qui nécessite O(n) affectations dans le cas le pire.
Tableaux : résumé
• L’avantage des tableaux est l’accès en temps constant à n’importe quel élément dont on
connaît la position : le nombre d’opérations pour accéder à un élément ne dépend pas de
la taille du tableau.
• Un inconvénient des tableaux est qu’on ne peut pas toujours les redimensionner, et même si
on le peut, un redimensionnement peut induire une recopie complète de tout le tableau.
Le nombre d’affectations nécessaires est, dans ce cas, proportionnel au nombre d’éléments
du tableau.
• Un second inconvénient est que l’insertion dans un tableau nécessite de décaler tous les
éléments à la droite de celui qu’on veut insérer, ce qui demande à nouveau une recopie.
Dans le cas le pire, si on insère un élément en première place, ce décalage demande un
nombre d’affectations proportionnel au nombre d’éléments du tableau.
1.1
Quelques exercices sur les tableaux
1. Écrire une fonction int_array_imperative_print, qui affiche un tableau de manière itérative.
Exercice 2.1
Exemple : int_array_imperative_print [|5;3;2;17;1;2;12|] doit afficher la séquence suivante :
5 3 2 17 1 2 12 − : unit = ().
2. Quelle est la complexité de la fonction précédente ?
Exercice 2.2
récursive.
1. Écrire une fonction int_array_recursive_print, qui affiche un tableau de manière
Exemple : int_array_recursive_print [|5;3;2;17;1;2;12|] doit afficher la séquence suivante :
5 3 2 17 1 2 12 − : unit = ().
2. Quelle est la complexité de la fonction précédente ?
Exercice 2.3
1. Écrire une fonction array_simple_sort, qui trie un tableau par sélection.
Exemple : L’évaluation au toplevel de l’expression array_simple_sort [|5;3;2;17;1;2;12|] donne
− : int array = [|1; 2; 2; 3; 5; 12; 17|] .
2. Quelle est la complexité de la fonction précédente ?
9
1. Écrire une fonction dichotomic_search, qui recherche un élément x dans un tableau trié par une approche dichotomique et renvoie vrai ou faux suivant si l’élément est là
ou non.
Exercice 2.4
Exemple :
• L’évaluation au toplevel de dichotomic_search [|1;2;7;7;7;12;17;23;42|] 23 donne − : bool = true.
• L’évaluation au toplevel de dichotomic_search [|1;2;7;7;7;12;17;23;42|] 24 donne − : bool = false.
2. Quelle est la complexité de la fonction précédente ?
2
La structure de donnée de liste
Une autre structure de données dite « linéaire » qui permet de représenter en mémoire des multiensembles est la liste. Au lieu de représenter un multi-ensemble sous la forme d’un tableau, c’est-à-dire
par une suite de cases consécutives en mémoire, nous utilisons l’idée suivante : chaque élément est
placé dans une structure de deux champs, le premier contenant l’élément à mémoriser, et le second
contenant l’adresse de la structure suivante. Le dernier élément de la liste sera associé à une valeur
spéciale, notée traditionnellement NULL. La liste elle-même peut être représentée par un pointeur sur
le premier élément de la liste, appelé tête de la liste, ou head (si elle est vide, la liste sera représentée
par la valeur NULL). Du point de vue de la programmation, en langage C par exemple, la valeur
NULL est une adresse invalide, ce qui permet de la différencier des pointeurs de la liste, et ainsi
de détecter la fin de la liste. La Figure 2.5 représente le même multi-ensemble que précédemment,
S = {{15 , 17 , 42 , 42 , 45 , 84 , 99}}, les éléments apparaissant dans cet ordre.
15
17
42
42
45
head
84
99
NULL
Fig. 2.5 : Une liste simplement chaînée d’entiers
Remarque. 2.5 Comme dans le cas des tableaux, une liste fournit implicitement un ordre sur les
éléments (voir Remarque 1).
À nouveau, ce choix d’implantation a des avantages et des inconvénients.
1. Un inconvénient est que contrairement au cas des tableaux, l’accès à un élément n’est pas direct :
accéder à un élément nécessite de « suivre » la chaîne de pointeurs, ce qui, à partir du début de
la liste, a un coût O(n) dans le cas le pire (où n est la taille de la liste).
2. Au contraire des tableaux, les opérations d’insertion en tête et de suppression peuvent être
implantées en O(1). En particulier, le coût de l’ajout de k éléments en tête d’une liste de taille
n a un coût O(k), qui ne dépend pas de n.
La Figure 2.6 montre les O(1) étapes de suppression d’un élément de la liste, en supposant qu’il a déjà
été recherché (rappel : la recherche elle-même a une complexité O(n), où n est la taille de la liste). On
suppose donc l’élément à supprimer pointé par x, et on appelle next le champ de la structure pointant
sur la cellule suivante de la liste.
10
x
15
17
42
42
45
84
99
NULL
42
45
84
99
NULL
42
45
84
99
NULL
45
84
99
NULL
head
y
x
y = x−>next;
15
17
42
head
y
x
x−>next = y−>next;
15
17
42
head
y
x
15
free (y);
17
42
head
Fig. 2.6 : Suppression dans une liste en temps constant
Listes : résumé
• L’inconvénient principal des listes est l’accès d’un élément en temps O(n) dans le cas le
pire, si la liste est de taille n, même si on sait que l’élément est le ième dans la liste.
• Un avantage est qu’on peut ajouter/supprimer un élément en tête, en temps O(1).
• De façon plus générale, si on dispose d’un pointeur sur un élément de la liste, on peut insérer ou supprimer un élément en temps O(1), sans sans recopie des éléments présents.
2.1
Les listes en OCaml
Tout comme dans la plupart des langages, on pourrait définir un type liste en langage OCaml. La
définition peut se faire de la façon suivante, si on veut définir les listes d’entiers.
1
2
3
type i n t _ l i s t =
| Empty
| C e l l of i n t * i n t _ l i s t
C’est une définition de type récursive. Elle exprime qu’une liste est :
• soit la liste vide, qu’on choisit de représenter par la valeur Empty.
• soit composée d’un couple (entier, liste).
Par exemple, la liste de la figure 2.5 s’écrirait
1
2
3
4
5
6
7
Cell (15 ,
Cell (17 ,
Cell (42 ,
Cell (42 ,
Cell (45 ,
( Cell (84 ,
C e l l ( 9 9 , Empty ) ) ) ) ) ) ) )
Remarque. 2.6 Les identificateurs permettant de construire les types, ici Empty et Cell, doivent
commencer par une majuscule (au contraire du nom du type, int_list , qui doit commencer par une
minuscule).
11
Une remarque importante est que définir une liste de flottants, ou une liste de chaînes de caractères,
se ferait de façon analogue. Comme de nombreux algorithmes sur les listes ne dépendent pas du type
des éléments de la liste, il serait intéressant d’avoir une notion de liste dont le type des éléments est
un paramètre. Il est possible d’écrire une telle définition en OCaml. Dans ce cas, il faut utiliser un
paramètre de type, commençant par une apostrophe, par exemple ’a, ou ’foo. Par exemple :
type ’ f o o l i s t =
| Empty
| C e l l of ’ f o o * ’ f o o l i s t
1
2
3
OCaml déterminera le type d’une liste en fonction de celui de ces éléments. Par exemple :
#
−
#
−
#
−
1
2
3
4
5
6
Empty ; ;
: ’ a l i s t = Empty
C e l l ( 1 , Empty ) ; ;
: i n t l i s t = C e l l ( 1 , Empty )
C e l l ( 1 . 5 , Empty ) ; ;
: f l o a t l i s t = C e l l ( 1 . 5 , Empty )
Enfin, il est en fait inutile d’écrire notre propre type, car Ocaml fournit déjà un type ’a list , ainsi
que des fonctions et opérateurs. De ce fait,
• Plutôt que la notation lourde ci-dessus pour écrire la liste de la Figure 2.5, on écrit simplement
[15;17;42;42;45;84;99] . Attention, les éléments sont séparés par des « ; > > et non des « , > >.
• La liste vide se note donc [] .
• On peut ajouter un élément en tête de liste avec l’opérateur :: . Par exemple, 1::[2;3] est la liste
[1;2;3] , qu’on peut aussi obtenir par 1::2::3::[] .
• On peut concaténer deux listes grâce à l’opérateur @. Par exemple, [1;2;3] @ [4;5] est la liste
[1;2;3;4;5] .
• Plusieurs fonctions utiles sont disponibles dans la bibliothèque : voir https ://caml.inria.fr/
pub/docs/manual-ocaml/libref/List.html. Il est possible de visualiser leur code à l’aide de
l’utilitaire ocamlbrowser.
2.2
Quelques exercices sur les listes
Exercice 2.5 Écrire une fonction conc qui concatène deux listes sans utiliser l’opérateur de conca-
ténation @ de Caml.
Exemple : conc [1;2;3] [4,5] s’évalue en − : int list = [1; 2; 3; 4; 5].
Note. Cette fonction existe dans la bibliothèque standard OCaml, elle peut être utilisée sous le
nom List .append. Voir https ://caml.inria.fr/pub/docs/manual-ocaml/libref/List.html.
Exercice 2.6 Écrire une fonction mir qui renverse une liste, sans utiliser d’opérateur d’itération sur
les listes. Exemple : mir [1;2;3;4;5] doit donner − : int list = [5; 4; 3; 2; 1].
Exercice 2.7 Écrire une fonction pref qui renvoie la liste des n premiers éléments d’une liste, et une
erreur si la liste est trop courte (c’est-à-dire a strictement moins de n éléments).
Exemple : pref [1; 2; 3; 4] 2 doit donner − : int list = [1; 2].
Exercice 2.8 De la même façon, écrire une fonction suff qui renvoie la liste des n derniers élé-
ments d’une liste l1 , et une erreur si ce n’est pas possible. Exemple : suff [1;2;3;4] 2 doit donner
− : int list = [3; 4].
Listes triées
12
Exercice 2.9 Écrire une fonction insert qui insère dans une liste triée un élément, à sa place dans
la liste.
Exemple : insert [1;3;4;7;8] 7 doit donner − : int list = [1; 3; 4; 7; 7; 8].
Exercice 2.10 Écrire une fonction tri_insertion , qui trie une liste en utilisant la fonction insert .
Quelle est sa complexité ?
Exercice 2.11 Écrire une fonction split qui coupe une liste l1 en deux deux listes d’éléments de l1
de tailles égales (à un élément près).
Exemple : split [1;2;3;4;5] doit donner − : int list * int list = [1; 3; 5], [2; 4].
Exercice 2.12 Écrire une fonction merge qui fusionne deux listes triées en une nouvelle liste triée.
Exemple : merge([1; 4; 4; 7],[1;2;6]) doit donner − : int list = [1; 1; 2; 4; 4; 6; 7].
Exercice 2.13 Écrire une fonction merge_sort qui utilise récursivement les deux fonctions split et
merge pour trier une liste. Quelle est sa complexité ? Exemple : tri_fusion
donner − : int list = [2; 4; 4; 5; 5; 6; 8; 19].
[8;2;4;4;5;6;19;5] doit
Exercice 2.14 — (Plus difficile). On suppose avoir une variable du type suivant, écrit en langage C :
struct c e l l {
struct c e l l * next ;
}
Sur la figure suivante, deux exemples de telles structures sont pointées chacune par une variable,
x1 pour la première et x2 pour la seconde :
NULL
x1
x2
On dit que la variable x1 représente une liste, alors que la variable x2 représente un lasso, car
aucune cellule dont l’unique champ est NULL n’est accessible depuis x2. Enfin, on appelle longueur
associée à la variable de type struct cell * le nombre de cellules accessibles depuis cette variable.
Ainsi, la longueur associée à x1 est 7, et celle associée à x2 est 10.
1. Écrivez un algorithme de complexité O(n2 ) dans le cas le pire, pour déterminer si une variable
dont la longueur associée est n représente une liste ou un lasso.
2. Même question avec une complexité O(n). Proposer au moins 4 algorithmes différents.
13
Chapitre 3
Arbres et arbres de recherche
Les tableaux et les listes permettent de représenter des multi-ensembles de deux manières différentes, et chacune, du fait de son organisation en mémoire, possède ses propres propriétés algorithmiques. La table suivante suppose que les éléments des multi-ensembles peuvent être ordonnés. Les
éléments d’un multi-ensemble peuvent donc être triés par ordre croissant (d’où les colonnes Tableau
trié et Liste triée). Dans cette table, n désigne le nombre d’éléments du tableau ou de la liste représentant le multi-ensemble. La table récapitule les propriétés de ces deux structures vues dans le cours
d’algorithmique du 1er semestre de L2 et dans le chapitre précédent.
Recherche d’un élément
Recherche du minimum
Insertion d’un élément
Suppression d’un élément
Tableau
Tableau trié
Liste
Liste triée
O(n)
O(n)
O(n)
O(n)
O(log n)
O(1)
O(n)
O(n)
O(n)
O(n)
O(n)
O(n)
O(n)
O(1)
O(n)
O(n)
Tab. 3.1 : Résumé des complexités de certaines tâches sur tableaux et listes
Un avantage du tableau trié est qu’il permet la recherche d’un élément en temps O(log n), en utilisant un algorithme dichotomique, alors que celui des listes est l’insertion ou la suppression d’un élément
en temps constant (voir le Chapitre 2). L’insertion d’un élément dans un tableau non nécessairement
trié peut se faire en O(1) en maintenant l’indice du premier emplacement libre, et en supposant que
les emplacements libres sont maintenus en fin de tableau. En contrepartie, la suppression d’un élément
nécessite un décalage, pour maintenir les emplacements libres en fin de tableau. Enfin, la complexité
O(n) de l’insertion ou de la suppression d’un élément dans une liste provient de la recherche de cet
élément. Une fois cette recherche effectuée, l’insertion et la suppression ne nécessitent plus que O(1)
opérations élémentaires, comme on l’a vu au Chapitre 2.
Remarque. 3.1 En pratique, la différence entre des complexités en O(n) et en O(log n) est significa-
tive. Par exemple, si n = 1015 (un peta-octet, c’est-à-dire un million de giga-octets), alors log(n) vaut
environ seulement 35. Cela signifie qu’un algorithme de complexité O(log n) sera utilisable même
pour de très grandes valeurs de n (en supposant raisonnable la constante multiplicative cachée dans
la notation O(·)).
1
De la liste à l’arbre
On aimerait combiner l’efficacité de la recherche dans les tableaux triés avec la facilité d’insertion/suppression d’un élément déjà trouvé fournie par les listes. La remarque 1 montre qu’à défaut de
parvenir à des complexités en O(1), obtenir des complexités en O(log n) est très intéressant.
Par ailleurs, garder les éléments à stocker contigus en mémoire pose des problèmes lorsqu’on veut
insérer ou supprimer un élément, car cette structure de données force à faire des décalages. On part
donc de l’idée suivante : la complexité O(log n) de recherche d’un élément dans un tableau trié provient
14
de l’algorithme de dichotomie (voir l’exercice 4 du Chapitre 2). Le problème dans une liste est que
l’on n’a pas accès à l’élément central (supposons pour simplifier que cet élément existe, c’est-à-dire
qu’on travaille sur une liste de taille impaire). Pour résoudre ce problème d’accès à l’élément central,
on pourrait représenter une liste triée par :
• sa partie gauche, constituée des éléments à gauche de l’élément central,
• son élément central,
• sa partie droite, constituée des éléments à droite de l’élément central.
Par exemple, on pourrait représenter la liste de la Figure 2.5 de la façon suivante :
15
17
head
42
NULL
45
42
84
head
99
NULL
Fig. 3.1 : Découpage d’une liste triée en partie gauche, élément central, partie droite
Par ailleurs, pour implanter ce triplet, on peut utiliser la représentation suivante (notez que la
nouvelle structure nécessaire, celle qui est pointée par x, utilise deux pointeurs) :
x
42
15
17
45
42
NULL
84
99
NULL
Fig. 3.2 : Structure de données pour découper une liste triée élément central, parties gauche et droite
Cette idée permet bien d’accélérer la recherche dans une liste triée. En effet, on a maintenant accès
à l’élément central, et on peut donc commencer à appliquer la méthode dichotomique : si l’élément
cherché est l’élément central, il est trouvé. Sinon, s’il est inférieur à l’élément central, on le cherche
dans la partie gauche, et sinon, dans la partie droite. On a donc divisé l’espace de recherche par 2.
Il est naturel de continuer cette idée et de subdiviser à nouveau les sous-listes gauche et droite. Sur
le même exemple, on obtiendrait :
x
42
84
17
15
NULL
42
NULL
45
NULL
99
NULL
Fig. 3.3 : Arbre découpant récursivement une liste
15
On obtient un arbre binaire : chaque nœud de l’arbre a au maximum 2 fils. De plus, comme on est
parti d’une liste triée, on peut vérifier que l’arbre a la propriété suivante : pour tout nœud z de l’arbre,
• les étiquettes des nœuds présentes dans le sous-arbre gauche de z sont inférieures ou égales à
l’étiquette de z.
• les étiquettes des nœuds présentes dans le sous-arbre droit de z sont supérieures ou égales à
l’étiquette de z.
Ces arbres binaires, dont les nœuds sont étiquetés par des valeurs ayant cette propriété, s’appellent
arbres binaires de recherche. Cette propriété est importante, car elle guide la navigation vers
un élément que l’on recherche, de la même façon que l’on guide la navigation dans l’algorithme de
recherche dichotomique. La longueur de la navigation est donnée par la longueur de la branche la plus
longue de l’arbre. Si on part d’une liste triée de longueur n = 2k − 1 et que l’on construit l’arbre
comme dans l’exemple ci-dessus, on obtiendrait un arbre dont toutes les branches auraient la même
hauteur, k − 1 (l’exemple est le cas où k = 3, n = 2). Autrement dit, le nombre de choix à effectuer
serait k − 1 = O(log n).
2
Arbres binaires de recherche
Rappelons que nos objectifs sont les suivants. On veut :
• une structure de donnée permettant de représenter des multi-ensembles, et
• qui permet d’effectuer les opérations suivantes de façon efficace :
– Rechercher un élément dans le multi-ensemble,
– Ajouter un élément,
– Supprimer un élément.
On va organiser les éléments d’un multi-ensemble à représenter selon l’idée expliquée en Section 1.
Sur l’exemple présenté dans cette section, nous avons ignoré deux détails :
1. Une valeur peut être présente en plusieurs exemplaires dans le multi-ensemble à représenter. Cela
pose un problème potentiel, puisqu’on voudrait qu’en tout nœud de l’arbre, les valeurs présentes
dans le sous-arbre gauche soient inférieures à la valeur du nœud, et celles présentes dans le sousarbre droit soient supérieures. Pour gérer le cas de valeurs en plusieurs exemplaires et diminuer
le nombre de nœuds des arbres construits, on procède ainsi : au lieu de stocker individuellement
chaque valeur, on stocke des couples (x , n) où x est une valeur présente dans le multi-ensemble,
et n > 1 désigne le nombre de fois où x apparaît dans l’ensemble. Par exemple, on représentera le
multi-ensemble {{15 , 17 , 42, 42 , 45 , 84 , 99}} utilisé dans l’exemple de la Section 1 par l’ensemble
{(15 , 1) , (17 , 1) , (42, 2) , (45 , 1) , (84 , 1) , (99 , 1)}, parce que 42 apparaît 2 fois. Ainsi, il n’y aura
plus de duplication de valeurs. Cet ensemble pourrait être représenté par l’arbre de la Figure 3.4.
(42, 2)
(17, 1)
(15, 1)
(84, 1)
(45, 1)
(99, 1)
Fig. 3.4 : Un arbre représentant un multi-ensemble
On peut noter sur cet arbre que les 1ères coordonnées suivent la règle des arbres binaires de
recherche : la 1ère coordonnée d’un nœud est (strictement) supérieure aux 1ère coordonnées des
nœuds de son sous-arbre gauche et (strictement) inférieure aux 1ère coordonnées des nœuds de
son sous-arbre droit.
Pour alléger les notations, on indiquera les secondes coordonnées, c’est-à-dire le nombre d’occurrences de l’élément, au dessus du nœud plutôt que dans un couple. L’arbre de la Figure 3.4 peut
donc se dessiner comme en Figure 3.5.
16
2
42
1
1
17
84
1
1
1
15
45
99
Fig. 3.5 : Une autre représentation de l’arbre de la Figure 3.4
2. Le second détail ignoré en Section 1 est le suivant : dans l’exemple de la Section 1, tout nœud a 0
ou 2 fils. En général, ce ne sera pas vrai : par exemple le nœud 17 de la Figure 3.5 a un seul fils.
Si on préfère travailler avec des arbres dans lesquels tout nœud a 0 ou 2 fils, qu’on appelle arbres
binaires entiers (au sens de « plein > > : full binary tree en anglais), il suffit d’ajouter
à tout nœud qui n’a pas 2 fils une ou deux feuilles fictives, qui ne portent pas de valeur. On
pourrait voir ces feuilles comme un arbre vide. L’arbre ainsi obtenu est présenté en Figure 3.6.
2
42
1
1
17
84
1
1
1
15
45
99
Fig. 3.6 : Un arbre binaire entier
En résumé, l’arbre de la Figure 3.6 représente le multi-ensemble {{15 , 17 , 42 , 42 , 45 , 84 , 99}}. Les
feuilles, représentées par un carré, peuvent aussi être vues comme l’arbre vide. Chaque nœud
interne contient une clé et un entier (noté au dessus) qui représente le nombre de fois que la clé
apparaît dans le multi-ensemble.
On remarque que :
• cet arbre est entier (au sens ci-dessus),
• aucune de ses feuilles ne porte de valeur.
Dans la suite de ce chapitre, nous travaillerons avec de tels arbres. Nous travaillerons avec des
arbres binaires qui ne sont pas entiers plus tard dans d’autres chapitres. Le lien entre les deux
versions sera abordé à la fin de ce chapitre.
Remarque. 3.2 Le point 1 montre en fait que l’on peut sans plus de travail représenter des dic-
tionnaires en utilisant les arbres binaires de recherche. En effet, on s’apprête à mémoriser dans
les nœuds des couples (x , n) où x n’apparaît pas plus d’une fois. De façon plus générale, on peut
mémoriser des couples (k , v), celui où k est une clé et v la valeur associée. La représentation d’un
multi-ensemble est simplement un cas particulier, où les clés sont les éléments du multi-ensemble
et la valeur associée à une clé est le nombre de fois où l’élément apparaît.
On va maintenant définir formellement le type arbre binaire de recherche. Ce type permet, comme
expliqué en Remarque 2, de représenter un dictionnaire. La définition 1 utilise donc deux ensembles : le
premier, K, correspond aux clés, et le second, V , aux valeurs associées aux clés. Suivant la Remarque 2,
la représentation des multi-ensembles est un simple cas particulier : pour représenter un multi-ensemble
d’éléments de E, on prendra K = E (par exemple les entiers dans l’exemple de la Section 1), et V = N,
pour représenter le nombre d’occurrences de chaque valeur.
17
Definition 3.1 — Arbre binaire de recherche. Soit K un ensemble muni d’une relation d’ordre total
6, et V un ensemble. On écrit k1 < k2 si k1 6 k2 et k1 6= k2 . Un arbre binaire de recherche sur
K , V est un arbre dont chaque nœud est :
• ou bien une feuille. Dans ce cas, le nœud n’est pas étiqueté.
• ou bien un nœud ayant exactement deux fils. Dans ce cas, le nœud est étiqueté par un couple
(k , v), où e ∈ K et v ∈ V . De plus, pour tout nœud de son sous-arbre gauche étiqueté par
(k` , v` ), on a k` < k. Symétriquement, pour tout nœud de son sous-arbre droit est étiqueté
par (kr , vr ), alors k < kr .
À nouveau, on peut penser aux feuilles comme représentant l’arbre vide. Elles seront notées Empty
dans le code OCaml.
Dans le cas de la représentation d’un multi-ensemble, rappelons à nouveau que V = N permet
de compter le nombre d’apparitions d’un élément dans le multi-ensemble : pour chaque nœud interne
étiqueté par (k , v), l’entier v représente le nombre de fois que k apparaît dans l’ensemble représenté
par l’arbre binaire. Il est appelé multiplicité de k. Par exemple, dans l’arbre de la Figure 3.6, la
multiplicité de 42 est 2 et la multiplicité de 17 est 1.
1. Écrire un type OCaml (’ k ,’ v) bst pour définir un type arbre binaire de recherche,
où ’k représentera le type des clés et ’v le type des valeurs.
Exercice 3.1
2. Écrire un type OCaml ’k bst_multiset pour définir un type arbre binaire de recherche représentant un multi-ensemble, où ’k est le type des éléments du multi-ensemble.
Exercice 3.2 En utilisant l’un des types définis dans l’exercice 1, écrire l’arbre de recherche de la
Figure 3.6 en OCaml.
1. Écrire une fonction bst_search qui recherche un élément x dans un arbre binaire
de recherche représentant un multi-ensemble, et qui renvoie 0 s’il est absent, et sa multiplicité
s’il est présent.
Exercice 3.3
2. Quel est le type de cette fonction ?
1. Écrire une fonction list_of_bst qui prend en argument un arbre binaire de recherche qui représente un multi-ensemble, et qui construit une liste triée en ordre croissant
des éléments de l’arbre, en tenant compte de leurs multiplicités. Par exemple, pour l’arbre de
la Figure 3.6, la fonction devra renvoyer la liste [15; 17; 42; 42; 45; 84; 99].
Exercice 3.4
2. Quel est le type de cette fonction ?
Exercice 3.5 — Complexité (plus difficile). Remarquez qu’on ne peut pas écrire une version de la
fonction list_of_bst de l’exerice 4 qui travaillerait en temps linéaire par rapport à la taille de
l’arbre binaire de recherche passé en argument. En effet, si l’arbre contient un seul nœud binaire
dont l’étiquette est de multiplicité n, alors la taille de la liste résultat est n. On va donc s’intéresser
à sa complexité en fonction de la taille de la liste retournée.
1. Votre fonction écrite dans l’exerice 4 est-elle de complexité linéaire par rapport à la taille de
la liste retournée ? Pourquoi ?
2. Écrire une version list_of_bst_efficient de cette fonction de complexité linéaire par rapport
à la taille de la liste résultat. Cette question est plus difficile. Pour la résoudre, vous pouvez :
(a) Dans un premier temps, calculer une fonction qui prend en argument une liste d’arbres
binaires de recherche, et qui renvoie la liste triée des nœuds de la suite de ces arbres,
avec leur multiplicité (la valeur retournée est donc une liste d’arbres ayant chacun un
seul nœud interne).
(b) Dans un second temps, faire une fonction qui calcule le résultat voulu à partir de cette
liste.
18
Exercice 3.6 Écrire une fonction bst_insert qui prend en argument un arbre binaire de recherche
représentant un multi-ensemble d’éléments d’un ensemble E, ainsi qu’un élément e de E qui renvoie
l’arbre binaire de recherche représentant le multi-ensemble d’origine auquel on a ajouté E.
1. En utilisant la fonction écrite en exercice 6, écrire une fonction bst_of_list qui
prend en argument une liste d’éléments d’un ensemble E, et qui retourne un arbre binaire de
recherche représentant le même multi-ensemble que la liste.
Exercice 3.7
2. En utilisant la fonction écrite dans l’exercice 4, écrire une fonction qui prend une liste en
argument et calcule la liste d’éléments triée dans l’ordre croissant. Tester cette fonction sur
des listes d’entiers ou de chaînes de caractères.
3. Quelle est la complexité de la fonction écrite en question 2.
Exercice 3.8 Écrire une fonction bst_min qui identifie l’élément minimum d’un arbre binaire de
recherche, et renvoie un couple :
• la première composante du couple est soit Empty si l’arbre est vide, soit l’arbre ayant un
unique nœud interne dont la valeur est le minimum, avec sa multiplicité,
• la seconde composante du couple est l’arbre obtenu en supprimant le nœud où se trouvait
l’élément minimum.
Exercice 3.9 Symétriquement, écrire une fonction bst_max qui identifie l’élément maximal d’un
arbre binaire de recherche, le renvoie, et le supprime de l’arbre (quelque soit sa multiplicité).
Exercice 3.10 Écrire une fonction bst_remove qui prend comme arguments un arbre binaire de
recherche et un élément e, et renvoie l’arbre binaire de recherche correspondant à l’arbre d’entrée
dans lequel on a supprimé une occurrence de e.
On pourra utiliser une fonction auxiliaire bst_pop_max bst qui prend en paramètre un abr
bst et qui retourne l’abr dans lequel le noeud contenant l’élément maximum a été supprimé et en
seconde valeur le couple (e, m) où e est l’élément maximum supprimé et m sa multiplicité.
La fonction bst_remove e bst parcourt récursivement l’arbre en le reconstruisant jusqu’à rencontrer éventuellement l’élément e à rechercher ; si l’élément a une multiplicité strictement plus
grande que 1, le noeud est conservé dans le nouvel arbre avec une multiplicité décrémentée de 1
sinon s’il n’y a pas de sous-arbre gauche, il suffit de retourner le sous-arbre droit, sinon grâce à la
fonction auxiliaire appliquée au sous-arbre gauche, on récupère l’élément maximum et le sous-arbre
gauche privé de ce maximum qui permettent de reformer un abr ayant ce maximum à la racine.
1. Proposer une représentation des arbres binaires par des mots. Vous pouvez par
exemple soit utiliser des parenthèses, soit « parcourir > > l’arbre et coder les montées et les
descentes de votre parcours.
Exercice 3.11
2. Écrire une fonction qui prend en argument un arbre binaire de recherche, et retourne une
chaîne de caractères qui le représente suivant ce codage.
La lecture d’un arbre affiché en mode texte (sans graphisme) est souvent compliquée : dès que
l’arbre est un peu large, il devient très difficile de lire l’affichage produit. Il existe cependant une
astuce qui permet de visualiser les arbres simplement. L’idée est d’afficher les arbres horizontalement,
en utilisant le retour à la ligne du mode texte. La figure 3.7 montre un exemple de cet affichage.
1. Écrire une fonction tree_display qui affiche l’arbre en mode texte, comme sur
la Figure 3.7b.
Exercice 3.12
2. Écrire une fonction bst_dot qui affiche le code Graphviz permettant d’afficher un arbre binaire
de recherche graphiquement, en mettant le couple (clé, valeur) dans chaque nœud. La syntaxe
Graphviz est très simple, voir http ://graphviz.org/Documentation/dotguide.pdf et pour
les puristes, http ://www.graphviz.org/doc/info/attrs.html.
19
1
12
1
1
8
23
3
2
4
9
2
11
(a) Arbre binaire de recherche
(b) Affichage en mode texte
Fig. 3.7 : Exemple d’affichage simple d’un arbre en mode texte.
Arbres binaires de recherche (ABR) : résumé
Qu’avons nous gagné par rapport aux listes et aux tableaux ? Les opérations de recherche, d’insertion d’un élément et de recherche du minimum se font dans un temps proportionnel à la hauteur de
l’arbre. Dans le meilleur des cas, lorsque les données sont bien réparties, la hauteur est de l’ordre de
log n. Nous obtenons alors le tableau de complexité suivant.
Recherche d’un élément
Recherche du minimum
Insertion d’un élément
Suppression d’un élément
Tableau
Tableau trié
Liste
Liste triée
ABR
O(n)
O(n)
O(n)
O(n)
O(log n)
O(1)
O(n)
O(n)
O(n)
O(n)
O(n)
O(n)
O(n)
O(1)
O(n)
O(n)
O(n),
O(n),
O(n),
O(n),
O(h(t))
O(h(t))
O(h(t))
O(h(t))
C’est bien mais ce serait mieux si ces complexités étaient en O(log n), c’est-à-dire si l’arbre était
de hauteur log n) (relativement équilibré). C’est l’objet du chapitre suivant.
20
Téléchargement