Arbres et application 1 Arbres et graphes

publicité
Arbres et application
1
1.1
Arbres et graphes
Un arbre est un graphe particulier
Définition : on appelle arbre tout graphe non orienté, connexe et sans cycle.
Voici un exemple d’arbre
k
k
k
A
A
A
k
k
k
k
Dans les arbres selon cette définition, aucun sommet n’est privilégié ; il n’y a ainsi pas de notion de
racine, de père ou de fils. Ce n’est donc pas tout à fait la vision qui est celle adoptée dans le programme
et, en particulier, celui de première année. Pour mieux comprendre les liens entre les représentations,
nous commençons par énoncer (et prouver) quelques propriétés des graphes acycliques ou connexes.
Proposition : un graphe (non orienté et simple) connexe d’ordre n possède au moins n − 1 arêtes.
On peut, par exemple, prouver le résultat par récurrence sur l’ordre du graphe (i.e. son nombre de
sommets).
- Le résultat est immédiat si n = 1.
- Supposons le résultat vrai à un rang n ≥ 1. Soit G connexe d’ordre n + 1. Si G possède un
sommet de degré 1 alors le graphe G0 obtenu en retirant ce sommet a au moins n − 1 arêtes
par hypothèse de récurrence (car on a supprimé une unique arête sans perdre la connexité) et
G a donc n arêtes au moins. Sinon, tous les sommets sont de degré ≥ 2 et comme la somme
des degrés des sommets est le double du nombre d’arêtes, il y a encore au moins n arêtes.
Proposition : un graphe (non orienté et simple) d’ordre n et sans cycle possède au plus n − 1 arêtes.
A nouveau, on propose une récurrence sur l’ordre du graphe.
- Le résultat est immédiat si n = 1.
- Supposons le résultat vrai à un rang n ≥ 1. Soit G d’ordre n + 1 et sans cycle. Je prétends qu’il
existe au moins un sommet de degré ≤ 1. En effet, sinon en considérant un chemin v1 , . . . , vk
de longueur maximale (cela existe car l’ensemble des chemins est fini), vk n’a, par maximalité,
aucun voisin différent des autres vi et comme il est de degré ≥ 2 il a au moins un moins un
voisin parmi ces vi ce qui donne un cycle. En retirant ce sommet et l’arête associée (si elle
existe) on se ramène au cas d’un graphe acyclique d’ordre n − 1 et on conclut.
Théorème : soit G un graphe d’ordre n. Les trois propositions suivantes sont équivalentes
i. G est un arbre
ii. G est connexe et a n − 1 arêtes
iii. G est acyclique et a n − 1 arêtes
Les arbres sont donc parmi les graphes connexes ceux avec un minimum d’arêtes ou parmi les graphes
acycliques ceux avec un maximum d’arêtes.
Si G est un arbre d’ordre n il est connexe et acyclique et, avec les propositions, il possède n − 1 arêtes.
Il nous reste à prouver l’équivalence de ii et de iii (puisque si ii et iii sont vraies, i est immédiatement
vrai par définition des arbres).
1
- Soit G un graphe connexe à n − 1 arêtes. Si, par l’absurde, il était cyclique, on obtiendrait un
graphe connexe d’ordre n et à n − 2 arêtes en supprimant une arête d’un cycle et la première
proposition montre que c’est impossible.
- Soit G un graphe acyclique à n − 1 arêtes. Supposons, par l’absurde, que G n’est pas connexe. Il
y a donc au moins deux composantes connexes. En ajoutant une arête entre un sommet d’une
des composantes et un sommet d’une autre, on garde l’absence de cycle et on a n arêtes ce qui
est impossible par la seconde proposition.
Ex 1 Dans cet exercice, on considère des graphes dont l’ensemble des sommets est S = {0, 1, . . . , n−1}.
On suppose de plus que n ≥ 3.
1. Soit G = (S, A) un arbre. Montrer que G possède un sommet de degré 1 et que le graphe obtenu
en supprimant un tel sommet et l’arête associée est encore un arbre.
2. A un arbre G = (S, A) on peut alors associer un n − 2 uplet d’éléments de [|0, n − 1|] par
l’algorithme suivant
On part d’un uplet L vide
Pour k de 1 à n-2, faire
Noter i le plus petit sommet de degré 1
Ajouter au uplet L le voisin de i
Supprimer i de G
Renvoyer L
Donner le 6-uplet associé à l’arbre
4k
6k
0k
A
A
A
2k
5k
7k
1k
3k
3. Justifier l’injectivité de l’application qui à l’arbre associe le n − 2-uplet.
4. Justifier la bijectivité en donnant un algorithme permettant de reconstruire l’arbre à partir du
n − 2-uplet (on ne demande pas de preuve). Appliquer au uplet (1, 1, 0, 2, 2, 0, 3, 3).
5. Implémenter les deux algorithmes précédents en Caml. On représentera les graphes par une
matrice d’adjacence et les uplets par des tableaux.
6. Déterminer le nombre des arbres dont l’ensemble des sommets est S.
1.2
Arbre couvrant d’un graphe connexe
Soit G = (S, A) un graphe supposé connexe. L’ensemble {card(A0 )/ A0 ⊂ A, (S, A0 ) connexe} est
alors non vide et inclus dans N. Il possède donc un minimum. On peut donc trouver un sous graphe
connexe G0 = (S, A0 ) tel que la suppression d’une arête deconnecte G0 . G0 est donc acyclique et c’est
ainsi un arbre.
On appelle arbre couvrant de G tout sous-graphe de G qui est un arbre et qui a les mêmes sommets.
On vient de voir que tout graphe connexe admet au moins un arbre couvrant. Considérons le graphe
suivant :
2
1
4
5
@
@
@ @
@
@
0
3
6
@
@
@
@
@
@ @ @
@
@
2
8
7
Le graphe ci-dessous est un arbre qui couvre ce graphe.
1
4
5
@
@
@ @
@
@
0
3
6
@
@
@
@
@ @
@
2
8
7
Ex 2 Soit G = (S, A) un graphe connexe. Pour construire un arbre couvrant ce graphe, on peut partir
d’un graphe sans arête. On choisit arbitrairement un sommet. On ajoute alors une arête issue de ce
sommet (il en existe une). On a alors deux sommets “couverts”. On ajoute alors une arête entre un
sommet couvert et un autre non couvert (il en existe une par connexité). On poursuit le processus
jusqu’à couvrir tous les sommets. On a alors ajouté n − 1 arêtes et on a un sous-graphe qui est un
arbre.
On choisit de représenter les graphes en Caml grâce au type suivant :
type arete == int*int ;;
type graphe = {N : int ; A : arete list} ;;
Le champ N nous donne le nombre des sommets. Le champ A donne la liste des arêtes, une arête étant
ici un couple (x, y) avec x < y.
Ecrire une fonction arbrecouvrant : graphe → graphe mettant en oeuvre l’algorithme précédent.
1.3
Orientation d’un arbre
Soit G = (S, A) un arbre. Pour tous sommets x, y distincts, il existe un unique chemin d’extrémités x
et y (existence par connexité et unicité par absence de cycle). Si on choisit un sommet r arbitrairement,
on peut alors orienter naturellement toutes les arêtes :
- Une arête rx est orientée de r vers x (x est le fils de r et r est le père de y).
- si xy est une arête avec x, y 6= r on peut trouver une unique chaı̂ne de r vers x. Si y est sur
celle-ci, on oriente l’arête de y vers x (x est le fils de y et y le père de x) et sinon, on l’oriente
de x vers y (y est le fils de x et x est le père de y).
On obtient un arbre dont on dit qu’il est enraciné et orienté (l’enracinement entraı̂nant naturellement
l’orientation des arêtes).
Définitions : soit G = (S, A) un arbre enraciné et orienté.
- Un sommet sans fils s’appelle une feuille (ou noeud terminal) ; les feuilles sont ainsi les noeuds
de degré 1 différents de la racine (sauf si l’arbre est réduit à cette racine) ;
- un sommet qui n’est pas une feuille (et qui possède donc au moins un fils) s’appelle un noeud
intérieur (ou noeud interne).
3
Ex 3 On veut compter le nombre Cn d’arbres différents de sommets 0, . . . , n − 1 (on parle ici d’arbres
non orientés ; le calcul a été effectué d’une autre manière en exercice 1). Pour cela, on va transiter par
des arbres orientés (et donc aussi enracinés). Pour construire un tel arbre orienté, on doit choisir n − 1
arêtes orientées. On appelle construction un n − 1 uplet d’arêtes orientées donnant un arbre. Notez que
deux constructions peuvent donner le même arbre et que tout n − 1 uplet n’est pas une construction
(le choix des arêtes pouvant ne pas mener à un arbre orienté). On note Dn le nombre de constructions
dans le cas de n sommets (on compte donc le nombre de uplets et pas le nombre d’arbres orientés).
1. Dans un premier compte, on choisit l’un des Cn arbres non orientés. Choisir une racine oriente
chaque arête et il reste à choisir l’ordre de ces arêtes dans le uplet. En utilisant ce raisonnement,
montrer que Dn = n!Cn .
2. Dans un second temps, on part du graphe vide et on ajoute une arête orientée puis une seconde
puis une troisième etc. Initialement, on a n composantes connexes formant une forêt de n arbres
réduits à leurs racines. L’ajout de chaque arête orientée fusionne deux composantes. Quel est
le nombre de choix possibles pour la k-ième arête (lors de ce choix, on a n − k composantes
connexes formées d’arbres orientés et on doit en fusionner deux en gardant des arbres orientés) ?
En déduire la valeur de Dn .
3. Conclure que Cn = nn−2 .
1.4
Arbre de parcours d’un graphe
Soit G = (S, A) un graphe connexe. Soit s un sommet donné. Lorsque l’on effectue un parcours
du graphe à partir de s, on peut simultanément construire un arbre enraciné en s. On débute avec
l’arbre ({s}, ∅). Quand on découvre un sommet y à partir d’un sommet x, on ajoute y à l’ensemble
des sommets et l’arête (orientée) (x, y) à l’ensemble des arêtes. On obtient alors un graphe connexe
d’ordre k avec k − 1 arêtes et c’est bien un arbre.
Considérons le graphe exemple suivant.
0 P
P
1
5
PP
@
@
PP
@ PP @ @
@
PP
2
3 P
4
8
PP
PP
PP
PP P
6
7
On trouve ci-dessous l’abre de parcours en largeur (à gauche) et en profondeur (à droite) à partir du
sommet 2 (en privilégiant les sommets de petit numéro en cas de choix possible).
2k
1k
5k
?
k
2 HH
j k
H
0k
3
@
@
R k
@
R k
@
4
6k
7
?
8k
0k
?
1k
?
4k
@
R k
@
3
?
6k
7k
5k
?
4
- 8k
Ex 4 Ecrire une fonction arbreDFS : graphe → int → graphe qui à partir d’un graphe non orienté
et d’un numéro de sommet renvoie l’arbre orienté de parcours en profondeur à partir du sommet argument. On représente ici les graphes par le tableau des listes d’adjacence.
2
Arbres binaires
Pour ce qui concerne les arbres, le programme se limite (si on sort de la représentation précédente sous
forme de graphe orienté ou non) au cas des arbres enracinés (et donc orientés) dont tous les noeud
internes sont exactement de degré 2. On parle alors d’arbre binaire ou encore d’arbre d’arité 2. On a
en réalité deux définitions concurrentes selon que l’on se permet ou non l’utilisation de l’arbre vide.
2.1
Les deux représentations possibles
Si on se limite aux arbres binaires stricts, on ne se permet pas l’utilisation de l’arbre vide et on définit
deux constructeurs Feuille et Noeud selon le schéma suivant (où l’on étiquette les noeuds de l’arbre
par des éléments de type ’a).
type ’a arbre =
| Feuille of ’a
| Noeud of ’a arbre * ’a * ’a arbre ;;
Dans certains cas, on veut travailler avec des arbres dont certains noeuds peuvent n’avoir qu’un seul
fils (le droit OU le gauche). On règle le problème en se permettant l’utilisation d’un arbre vide selon
le type suivant
type ’a arbre =
| Nil
| Noeud of ’a arbre * ’a * ’a arbre ;;
Quand on utilise ce dernier type, ce que l’on fera car c’est le plus général, il faut parfois être attentif
au cas particulier de l’arbre vide pour lequel certaines définitions (comme la hauteur) peuvent ne pas
avoir de sens.
2.2
Définitions et preuves inductives
La structure d’arbre binaire étant définie de façon récurrente, les définitions et preuves naturelles sur
les arbres se feront par induction structurelle.
Définition : on définit la taille d’un arbre de façon inductive par :
— la taille de l’arbre vide est nulle ;
— si g et d sont deux arbres alors la taille de Noeud(g,x,d) vaut la somme des tailles de g et de
d plus un.
De façon plus informelle, la taille d’un arbre est ainsi égale à son nombre de noeuds (internes ou
terminaux).
Quand on vous définit informellement une notion sur les arbres, il convient (dans le but d’implémentation)
de traduire récursivement cette définition.
Définition : on appelle hauteur d’un arbre la taille du plus long chemin dans l’arbre de la racine vers
une feuille.
La traduction récursive n’est pas immédiate car la hauteur de l’arbre vide n’est alors pas définie. On
peut prendre comme comme cas de base celui d’une feuille (deux fils vides) et distinguer trois cas
récurrent (selon qu’il y a un fils à droite ou un fils à gauche ou deux fils). On peut aussi s’interroger
sur l’existence d’une convention naturelle pour la hauteur de l’arbre vide. En adoptant cette deuxième
démarche, on définit récursivement la hauteur par
— la hauteur de Nil vaut −1 ;
5
— la hauteur de Noeud(g,x,d) est égale à 1 plus le maximum des tailles de g et de d.
L’implémentation est souvent immédiate à partir de la définition récurrente.
let rec taille a =
match a with
Nil -> 0
| Noeud (g,_,d) -> 1 + (taille g) + (taille d) ;;
let rec hauteur a =
match a with
Nil -> -1
| Noeud (g,_,d) -> 1 + (max (hauteur g) (hauteur d)) ;;
Ex 5 Ecrire une fonction compteF : arbre → int renvoyant le nombre de feuilles d’un arbre.
Les preuves sur les arbres se feront de même de manière inductive. Soit on utilise une induction
structurelle, soit on utilise une récurrence sur la hauteur soit on utilise une récurrence sur la taille des
arbres.
Ex 6 On choisit ici la définition des arbres binaires sans l’arbre vide. Dans un arbre binaire t,
on note i(t) la somme des profondeurs des noeuds internes et e(t) celle des profondeurs des feuilles.
Démontrer que dans un arbre binaire t à n(t) noeuds internes on a
e(t) = i(t) + 2n(t)
2.3
Calculs de complexité
Dans les algorithmes mettant en oeuvre des arbres, les calculs de complexité reposeront souvent sur
la hauteur et/ou le nombre de noeuds de ceux-ci. On a essentiellement deux cas.
- On fait un unique appel récursif sur le fils droit OU le fils gauche. On parcourt alors une unique
branche de l’arbre et la complexité dépend de la hauteur de l’arbre.
- On fait des appels récursifs à gauche et à droite (dans un parcours en profondeur) et on parcourt
alors tous les noeuds de l’arbre. La complexité dépend alors de la taille (nombre de noeuds) de
l’arbre.
Dans un cas comme dans l’autre, le résultat du calcul dépend alors du temps de traitement d’un
noeud. Si ce traitement se fait en temps constant, on a une complexité linéaire en fonction de la
hauteur (premier cas) ou de la taille (second cas).
ATTENTION : il faut prendre garde aux coût cachés. L’idéal est de faire en sorte que chaque noeud
soit parcouru au plus une fois (ce que les énoncés imposeront souvent). Ceci implique, en particulier,
de ne pas appeler de fonction auxiliaire elle même récurrente.
Exemple : un arbre binaire est dit complet si tous les niveaux de l’arbre contiennent le maximum de
noeuds possibles. Une définition récurrente possible est la suivante :
— l’arbre vide est complet ;
— Noeud(g,x,d) est complet si g et d sont complets de même hauteur.
Une implémentation naı̈ve de cette définition est la suivante :
let rec est_complet a =
match a with
|Nil -> true
|Noeud(g,x,d) -> (est_complet g)&&(est_complet d)&&(hauteur g=hauteur d) ;;
Du fait de l’appel à la fonction récurrente hauteur (qui ne s’exécute pas en temps constant), il n’y
a pas au plus une visite de chaque noeud de l’arbre. Si on veut respecter cette contrainte, il faut
6
profiter du parcours de l’arbre (lors de l’appel récursif à est complet pour calculer cette hauteur.
On écrit donc d’abord une fonction auxiliaire complet : arbre → bool*int. Dans l’appel complet
a on renvoie un couple (b,h) où b est un booléen indiquant si l’arbre est complet et où h donne la
hauteur de l’arbre s’il est complet et a une valeur indifférente (par exemple 0) sinon.
let rec complet a =
match a with
|Nil -> (true,-1)
|Noeud(g,x,d) -> let (bg,hg)=complet g in
if bg then begin
let (bd,hd)=complet d in
if bd && hd=hg then (true,hg+1) else (false,0)
end
else (false,0) ;;
let est_complet a =
let (ba,ha)=complet a in
ba;;
Dans les calculs de complexité, la quantité caractéristique s’imposera souvent d’elle même (comme
expliqué plus haut). Dans certains cas (comme celui de la première version de est complet) on hésitera
entre la taille et la hauteur. Il est aussi possible que l’énoncé impose une quantité caractéristique. Il
faut donc connaı̂tre les liens entre taille et hauteur.
Théorème : si a est un arbre binaire de taille |a| et de hauteur h(a), on a
h(a) + 1 ≤ |a| ≤ 2h(a)+1 − 1
log2 (|a| + 1) − 1 ≤ h(a) ≤ |a| − 1
Le second encadrement se déduit du premier. Pour celui-ci, on raisonne par induction structurelle.
- Comme Nil est de hauteur −1 et de taille nulle, le résultat est vrai dans le cas de base.
- Supposons que a = Noeud(g, x, d) avec l’hypothèse vraie pour g et d alors
|a| = 1 + |g| + |d| ≤ 2 + h(g) + h(d) ≥ 2 + max(h(g), h(d)) = 1 + h(a)
|a| = 1 + |g| + |d| ≤ 2h(g)+1 + 2h(d)+1 − 1 ≤ 2 × 2max(h(g),h(d))+1 − 1 = 2h(a)+1 − 1
On remarque que ces encadrements sont optimaux : le minorant est atteint dans le cas d’un arbre
filiforme (par exemple un peigne) et le majorant correspond au cas d’un P
arbre complet (à profondeur
k, il y a alors exactement 2k éléments et si l’arbre est de hauteur h, il y a hk=0 2k = 2h+1 − 1 noeuds).
Une complexité linéaire en fonction de la hauteur peut ainsi, selon la forme de l’arbre, être linéaire
ou logarithmique en fonction du nombre de noeuds. Il est ainsi souvent important de conserver des
arbres “touffus”.
Définition : un arbre a est dit équilibré si h(a) = O(log(|a|)).
Ex 7 Si a est un arbre non vide, on appelle déséquilibre de a la différence des hauteurs du fils gauche
et du fils droit de a (d(a) = h(g) − h(d)). On définit récursivement les arbres AVL par
- l’arbre vide est un arbre AVL
- a = Noeud(g, c, d) est un arbre AVL si g et d sont des arbres AVL et si d(a) ∈ {−1, 0, +1}.
1. Montrer qu’un arbre AVL est équilibré.
2. Ecrire une fonction testant si un arbre possède la propriété AVL. On ne devra parcourir qu’une
unique fois chaque noeud de l’arbre.
7
Ex 8 On appelle arbre 0-1 un arbre binaire dont les étiquettes valent 0 ou 1 et qui vérifie les conditions
suivantes :
- la racine (si l’arbre est non vide) est d’étiquette 0 ;
- le parent d’un noeud d’étiquette 1 est d’étiquette 0 ;
- pour chaque noeud, tout les chemins de ce noeud à une feuille contiennent le même nombre de
noeuds d’étiquette 0.
1. Montrer que le squelette d’arbre ci-dessous peut être étiqueté afin de devenir un arbre 0-1.
HH
HH
HH
H
@
@
@
A
A
A
A
A A
@
@
@
A
A
A
A
A
A
2. Donner un exemple de squelette d’arbre ne pouvant être étiqueté en un arbre 0-1.
3. Si a est un arbre 0-1, on note z(a) le nombre de noeuds d’étiquette 0 sur le chemin de la racine
à une feuille (ce nombre ne dépend pas de la feuille). Montrer que
z(a) ≤ h(a) + 1 ≤ 2z(a) et |a| ≥ 2z(a) − 1
En déduire que les arbres 0-1 sont équilibrés.
4. Ecrire une fonction testant en temps linéaire en fonction du nombre de noeuds si un arbre
d’étiquettes dans {0, 1} est un arbre 0-1.
2.4
Parcours en profondeur
On veut appliquer un certain traitement à tous les noeuds d’un arbre (comptage, impression des
étiquettes. . .), il convient de parcourir l’arbre ou encore de choisir une numérotation (c’est à dire un
ordre) pour les noeuds. L’une des méthodes consiste à visiter entièrement le sous-arbre gauche d’un
noeud avant de visiter le sous-arbre droit. On privilégie ainsi la visite des noeuds les plus profonds en
premier et on retrouve la notion de parcours en profondeur introduite dans le cas des graphes.
Lors d’un parcours en profondeur, chaque noeud intérieur autre que la racine est visité trois fois :
quand on arrive du père, quand on finit de visiter le sous-arbre gauche et quand on finit de visiter
le sous-arbre droit. C’est en fait encore le cas pour la racine (la première rencontre étant quand on
aborde l’arbre).
Si l’on veut appliquer un traitement aux noeuds (par exemple, les numéroter ou imprimer les étiquettes)
on parle :
- d’un traitement préfixe quand on effectue l’action lors de la première rencontre ;
- d’un traitement infixe quand on effectue l’action lors de la deuxième rencontre ;
- d’un traitement postfixe quand on effectue l’action lors de la troisième rencontre.
A titre d’exemple, voici une fonction Caml permettant l’affichage des étiquettes d’un arbre binaire
dans l’ordre préfixe.
8
let rec affiche a =
match a with
Nil -> ()
| Noeud (g,i,d) -> print_int i ; affiche g ; affiche d ;;
Ex 9 Ecrire une fonction prenant en argument un arbre binaire et renvoyant la liste des étiquettes de
l’arbre dans l’ordre préfixe (par exemple). On essayera d’écrire une fonction n’utilisant que l’opérateur ::
de consage et pas celui de concaténation @.
Ex 10 Implémenter l’algorithme d’affichage des noeuds d’un arbre dans un parcours en largeur (on
supposera les étiquettes entières). On représentera une file à l’aide de deux listes (selon le cours de
première année) ; on écrira donc une fonction auxiliaire récursive qui prendra en argument ces deux
listes.
3
3.1
Le TDA dictionnaire
Définition
Dans un dictionnaire au sens courant du terme, on veut chercher si un mot existe (et dans ce cas
obtenir sa définition), insérer de nouveaux mots (avec leurs définitions) ou en supprimer un (quand il
devient obsolète). Il convient donc de gérer un ensemble de couples mot-définition.
Une table d’associations, encore appelée dictionnaire, est un ensemble de couples (k, e) ∈ K × E où
- K est l’ensemble des clefs : il existe au plus un élément e ∈ E tel que le couple (k, e) est dans
la table
- E est l’ensemble des éléments stockés
On veut disposer des opérations suivantes sur cette structure :
- initialisation d’une table ;
- recherche d’un élément dans une table à partir de sa clef ;
- ajout d’un élément à une table (avec une clef non utilisée) ;
- suppression d’un élément de clef donnée.
3.2
Objectif pratique
Nous laisserons ici de côté l’association entre un mot et sa définition. On veut ainsi une structure nous
permettant une implémentation efficace des opérations suivantes :
- recherche de la présence d’un élément dans un dictionnaire ;
- ajout d’un élément à un dictionnaire ;
- suppression d’un élément dans un dictionnaire.
On se place de plus dans le cas (raisonnable) où l’ensemble des mots est totalement ordonné.
On peut imaginer une implémentation mutable ou immuable. Dans le premier cas, l’ajout ou la suppression d’un élément revient à modifier la structure. Dans le second cas, cela revient à créer une
nouvelle structure avec un élément de plus ou de moins qu’initialement.
4
4.1
Arbres binaires de recherche
Définition
Un arbre binaire non vide étiqueté est appelé arbre binaire de recherche (ABR) si les étiquettes
appartiennent à un ensemble totalement ordonné et que pour tout noeud x d’étiquette e, toutes les
étiquettes présentes dans le sous-arbre gauche de x (s’il existe) sont plus petites que e et toutes celle
présentes dans le sous-arbre droit (s’il existe) sont plus grandes que e.
Exemple : l’arbre suivant (fils vides non représentés) est un arbre binaire de recherche sur N
9
k
6 HH
H k
4k
7
@
@k
2k
5
6k
On peut donner une définition récurrente de l’ensemble A des arbres binaires de recherche sur l’ensemble totalement ordonné (E, ≤) :
- l’arbre vide est dans A ;
- si ag et ad sont des éléments de A et que e est simultanément strictement plus grand que toutes
les étiquettes de la racine de ag et strictement plus petit que toutes celles de ad alors l’arbre
Noeud (ag,e,ad) est dans A.
Il est à noter que pour un ensemble donné d’étiquettes, il existe plusieurs arbres binaires de recherche
contenant ces étiquettes. Il y en deux qui sont “linéaires” et assimilables à des listes ordonnées et
d’autres qui sont mieux équilibrés (comme ci-dessus). Les complexités des fonctions de recherche,
d’ajout, de suppression seront (on le verra) linéaires en fonction de la hauteur de l’arbre. Il est donc a
priori important que ces opérations conservent le caractère “équilibré” (notion à définir proprement)
des abr. Cependant nous ne nous préoccuperons pas de cet aspect du problème.
On définit le type arbre binaire de la façon suivante :
type ’a abr =
Nil
|Noeud of (’a abr)*’a*(’a abr) ;;
Attention : tout élément de type abr ne représente par un arbre binaire de recherche puisque ce type
ne prend pas en compte les contraintes sur les étiquettes. Dans la suite, on supposera que les arbres
manipulés sont des abr et on imposera que les arbres renvoyés le soient.
Ex 11 Comment, à l’aide d’un parcours en profondeur, vérifier la structure d’ABR ? On ne demande
pas d’implémentation (ce sera vu plus loin).
Ex 12 Ecrire une fonction efficace de création d’un abr à partir d’un tableau trié. Justifier les choix
faits (en quoi est-ce efficace ?) et donner la complexité de la fonction.
4.2
Recherche dans un abr.
La fonction recherche : ’a arbre -> ’a -> bool prend en argument un arbre t qui possède la
propriété abr (c’est une précondition sur les arguments), un élément x et indique si x est une des
étiquettes de t. Sa complexité est O(h) où h est la hauteur de l’arbre.
let rec recherche t x =
match t with
Nil -> false
|Noeud (tg,e,td) -> if x=e then true
else if x>e then recherche td x
else recherche tg x;;
4.3
Insertion dans un abr.
La fonction insere : ’a arbre -> ’a -> ’a arbre prend en argument un arbre t qui possède la
propriété abr (précondition), un élément x et renvoie un arbre possédant la propriété abr obtenu en
ajoutant une étiquette de valeur x. Sa complexité est O(h) où h est la hauteur de l’arbre.
10
let rec insere t x =
match t with
Nil -> Noeud (Nil,x,Nil)
|Noeud (tg,e,td) -> if x>=e then Noeud (tg,e,insere td x)
else Noeud (insere tg x,e,td) ;;
Remarques.
- Ici, on choisit d’insérer le nouvel élément x même s’il est déjà présent dans l’arbre initial. On a
donc des abr qui peuvent posséder des “doublons”. On pourrait choisir de refuser ces doublons.
- Il est important de vérifier que le résultat renvoyé est bien un abr (il est assez clair qu’il possède
les bonnes étiquettes).
Ex 13 On peut utiliser une technique d’insertion dans un abr autre que celle d’insertion aux feuilles
vue en cours. La méthode consiste à insérer l’étiquette x à la racine du nouvel arbre. Pour cela, on
découpe l’arbre initial en deux arbres dont les étiquettes sont respectivement plus petites et plus
grandes que x et à rassembler ces arbres sous une nouvelle racine.
a. Ecrire une fonction Caml mettant en oeuvre cette méthode (on convient de ne pas ajouter x
s’il est déjà présent).
b. Quelle est la complexité de la fonction précédente.
4.4
Suppression dans un abr.
La fonction supp max : ’a arbre -> ’a*(’a arbre) prend en argument un arbre non vide t possédant
la propriété abr (on a deux préconditions) et renvoie le couple (e,tr) où e est l’étiquette maximale
de t et tr l’arbre obtenu à partir de t en supprimant un noeud étiqueté par e. Sa complexité est O(h)
où h est la hauteur de l’arbre.
let rec supp_max t =
match t with
Noeud (tg,x,Nil) -> (x,tg)
|Noeud (tg,x,td) -> let (e,dr)=supp_max td in
(e,Noeud (tg,x,dr)) ;;
La fonction supprime : ’a arbre -> ’a -> ’a arbre prend en argument un arbre t qui possède
la propriété abr, un élément x et renvoie un arbre possédant la propriété abr obtenu en supprimant
une étiquette de valeur x s’il en existe une, le même arbre est renvoyé sinon. Sa complexité est O(h)
où h est la hauteur de l’arbre.
let rec supprime t x =
match t with
Nil -> Nil
|Noeud (tg,e,td) -> if x<e then Noeud (supprime tg x,e,td)
else if x>e then Noeud (tg,e,supprime td x)
else if tg=Nil then td
else let (m,tgr)=supp_max tg in Noeud (tgr,m,td) ;;
Remarque : dans le cas d’un abr avec de possibles doublons, cette fonction ne supprime qu’une
occurrence (éventuelle) de l’élément.
4.5
Test de propriété abr
On veut écrire une fonction test abr : ’a arbre -> bool teste si un arbre t possède la propriété des
arbres binaires de recherche. Rappelons tout d’abord la définition récurrente. On note E(t) l’ensemble
des étiquettes d’un arbre t, m(t) et M (t) les minimum et maximum de ces étiquettes quand l’arbre
11
est non vide (auquel cas E(t) est fini non vide et possède bien un maximum et un minimum). On note
enfin abr(t) le booléen valant 1 si et seulement si t est un abr. On a alors
t = N il ∨ t = N oeud(N il, x, N il)
abr(t) ⇐⇒
∨ (t = N oeud(g, x, N il) ∧ abr(g) ∧ M (g) ≤ x)
∨ (t = N oeud(N il, x, d) ∧ abr(d) ∧ x ≤ m(d))
∨ (t = N oeud(g, x, d) ∧ abr(g) ∧ abr(d) ∧ M (g) ≤ x ∧ x ≤ m(d))
Pour mettre en oeuvre cette définition récurrente, on a besoin de savoir obtenir le maximum et le
minimum d’un abr non vide. Si on procède naı̈vement en écrivant des fonctions dédiées et en les
utilisant dans la fonction test abr, on n’obtiendra pas une complexité optimale car on ne fera par un
unique parcours de l’arbre. La solution algorithmique est d’écrire une fonction auxiliaire prenant en
argument un abr non vide t et de renvoyer un triplet ‘a*‘a*bool égal à (m(t), M (t), abr(t)).
let rec test_aux (Noeud(g,x,d)) =
match (g,d) with
(Nil,Nil) -> (x,x,true)
|(g,Nil) -> let (mg,Mg,bg)=test_aux g in
(min mg x,max Mg x,bg & Mg<=x)
|(Nil,d) -> let (md,Md,bd)=test_aux d in
(min md x,max Md x,bd & x<=Md)
| _ -> let (mg,Mg,bg)=test_aux g in
let (md,Md,bd)=test_aux d in
(min mg (min x md),max Mg (max x Md),bg & bd & Mg<=x & x<=Md) ;;
let test_abr t =
if t=Nil then true
else let (_,_,b)=test_aux t in b ;;
Comme chaque noeud est parcouru au plus une fois et qu’on effetue un nombre constant d’opérations
quand on rencontre un noeud, la complexité de la fonction est O(n) où n est la taille de l’arbre (son
nombre de noeuds).
Remarque : on peut aussi utiliser un parcours en profondeur pour tester la propriété ABR. Il est
en effet aisé de voir qu’un arbre binaire a la propriété ABR si et seulement si la liste infixe de ses
étiquettes est triée par ordre croissant. En pratique, on n’a pas besoin de former la liste infixe des
étiquettes mais, lors du parcours, on compare l’élément rencontré à l’élément précédent. On doit bien
sûr lancer le processus avec un élément plus petit que tous les éléments de E (par exemple min int si
les étiquettes sont entières). On écrit donc une fonction auxiliaire de type ’a abr -> ’a -> bool*’a.
Dans l’appel avec t et a, on suppose toutes les étiquettes de t plus grandes que a et on renvoie un
couple (b, M ) avec b = 1 si et seulement si t est un abr et DANS CE CAS M est le maximum de a et
des étiquettes de t.
let rec aux t a =
match t with
Nil -> true,a
|Noeud(g,x,d) -> let (b,c)=aux g a in
if b & c<=x then aux d x
else (false,a) ;; (*valeur de l’élément 2 indifférente*)
let test_abr t = fst (aux t min_int);; (*cas d’étiquettes entières*)
12
5
5.1
Le TDA File de priorité.
Définition
Les ordinateurs semblent multitâches puisque l’on peut lancer plusieurs requêtes qui semblent être
simultanément traitées. En réalité, le processeur ne fait qu’une chose à la fois ! Il doit décider à chaque
instant quelle tâche (ou bout de tâche) en attente il va effectuer et ce n’est que car les processeurs
sont très rapides qu’on a l’illusion de la simultanéité. Quand il reçoit des instructions à effectuer, le
processeur les stocke dans une file d’attente dans laquelle il va se servir quand il a terminé un travail.
Cette file d’attente pourrait suivre le principe FIFO (file) C’est sans compter sur le fait que toutes les
tâches ne sont pas d’importance égale. . .
Une file de priorité est un ensemble de couples priorité-élément. Les priorités sont des éléments d’un
ensemble totalement ordonné et les valeurs sont indifférentes (ce sont les tâches à effectuer de notre
exemple). Les opérations associées au TDA sont les suivantes :
- création d’une file de priorité vide ;
- test de vacuité d’une file de priorité ;
- insertion d’un couple ;
- recherche et suppression d’un élément de priorité maximale.
On voit que la situation est assez semblable à celle des dictionnaires, si ce n’est que l’on n’a pas à savoir
trouver un élément quelconque mais seulement l’un de ceux particuliers de priorité la plus grande.
5.2
Exemple d’utilisation.
A l’aide des files de priorité, on peut écrire un algorithme de tri. Supposons que l’on dispose de valeurs
u1 , . . . , un d’un ensemble ordonné et que l’on veuille les classer par ordre décroissant (par exemple).
On peut agir comme suit :
- Créer une file de priorité f .
- Pour i de 1 à n, insérer ui dans f .
- Pour i de 1 à n, effectuer une suppression dans f et récupérer l’élément.
On récupère alors les éléments dans l’ordre décroissant. Le coût du tri dépendra grandement de
la complexité des fonctions d’insertion. Si on utilise une liste ordonnée par priorités décroissantes,
l’insertion se fait en temps linéaire en fonction du nombre d’éléments de la liste et la suppression se
fait en temps constant. Notre algorithme de tri sera en O(n2 ) (on effectue en fait un tri par insertion).
On veut fair mieux avec une meilleure structure de file de priorité.
6
La structure de tas.
La bonne façon de voir les choses est arborescente. Les APE (arbres de priorité équilibrés) et les tas
sont deux bonnes solutions et nous allons détailler la seconde.
On appelle arbre partiellement ordonné (APO) un arbre binaire étiqueté par des éléments d’un ensemble ordonné et dans lequel l’étiquette d’un nœud est supérieure ou égale à celle de chacun de ses
fils. En particulier, l’étiquette d’un nœud est plus grande que celle de chacun de ses descendants et la
racine est l’un des nœuds avec une étiquette maximale.
On appelle tas tout APO qui est parfait c’est à dire tout APO où tous les niveaux sont complets sauf
le dernier où les feuilles sont le plus à gauche possible. Voici un exemple de tas dont les étiquettes sont
des entiers.
13
18 H
HH
HH
HH
16
18
@
@
@
@
@
@
7
9
A
A
A
1
10
7
5
3
Pour représenter un tas, l’implémentation récursive n’est pas adaptée car nous allons avoir besoin non
seulement de descendre mais aussi de remonter dans les arbres. On représente les tas par des tableaux
de longueur fixée suffisamment grande. Si T est un tas à n noeuds, on lui associe un tableau dont la
case numéro 0 contient n et dont les éléments numérotés 1 à n sont les étiquettes de T dans l’ordre
suivant : par niveau descendant et, sur un niveau donné, de la gauche vers la droite. L’étiquette de
la racine est donc le contenu de la case numéro 1. Dans l’exemple précédent, le tas est un tableau du
type
[|10; 18; 18; 16; 9; 7; 1; 10; 3; 7; 5; . . . |]
Les fils du noeud numéro i sont ceux de numéros 2i et 2i + 1. On a donc le noeud numéro k qui a
pour père celui numéroté bk/2c (valable pour k ≥ 2 puisque la racine n’a pas de père).
type tas == int vect
Ex 14 Proposer une structure permettant de gérer des tas dont les éléments ne sont plus forcément
des entiers.
Ex 15
1. Ecrire une fonction arbre to vect prenant en argument un arbre parfait (pas forcément un
APO) et renvoyant un tableau représentant cet arbre.
2. Ecrire la fonction réciproque vect to arbre.
On peut utiliser la structure de tas pour gérer des structures ordonnées et, par exemple, des files de
priorité. Il nous suffit de donner un algorithme d’insertion et un autre de suppression de la racine (qui
possède le plus grand élément). On suppose bien sûr que l’algorithme prend un tas en argument mais
il faut s’assurer qu’il effectue des modifications de façon à garder la structure de tas.
Algorithme d’insertion d’un élément x.
On place x en bas de l’arbre dans un nouveau nœud en position i
Tant que i n’est pas la racine et que le père de i a une étiquette strictement plus petite que
celle de i, faire
- échanger l’étiquette de i et celle de son père
- changer i en son père
On a les invariants de boucle suivants.
- En notant ik la valeur de i à l’entrée de la boucle numéro k (la première étant celle numéro
0), on a 1 ≤ ik ≤ 2nk . Ceci permet de prouver la terminaison de la fonction (et de majorer le
nombre des itérations par dlog2 (n)e.
- a est un arbre parfait où la propriété APO est vérifiée partout sauf peut-être entre i et son
père. Ceci permet de prouver la validité de la fonction.
Ex 16 Ecrire une fonction d’insertion d’un élément dans un tas (insere : int → tas → unit).
14
Algorithme de suppression.
On remplace l’étiquette de la racine par l’étiquette e du dernier noeud et on supprime ce dernier.
On pose i = 1 (position de l’étiquette e)
Tant que i n’est pas une feuille et que l’un de ses fils est strictement plus grand, faire
- échanger les étiquettes de e et de son fils le plus grand
- Modifier i en conséquence
La preuve de terminaison et de validité de la fonction se fait par des moyens similaires à ceux mis en
oeuvre pour l’insertion.
Ex 17 Ecrire une fonction supprime : tas → int qui prend en argument un tas non vide et en
supprime l’élément à la racine et renvoie l’élément supprimé.
Complexité.
Dans les deux algorithmes, on parcourt une branche de l’arbre uniquement. Or, la longueur d’une
branche est au plus égale au logarithme de la taille du tas. De plus, lors du parcours, on ne fait qu’un
nombre constant d’opérations. On a donc une stratégie d’insertion et de suppression de complexité
logarithmique en fonction de la taille du tas.
Ex 18 Ecrire une fonction tri : int list → int list qui prend en argument une liste et en
renvoie une version triée. On utilisera un tas comme file de priorité et la stratégie de tri décrite dans
le cours. On commencera sans doute par une fonction créant un tas à partir d’une liste. Préciser la
complexité de cette fonction.
15
Téléchargement