• Aucun résultat trouvé

Structures de données récursives

Comme on l’a vu, un constructeur de type de données algébrique peut avoir plusieurs (ou aucun) champs et chaque champ doit avoir un type concret. Avec cela en tête, on peut créer des types dont les constructeurs ont des champs qui sont de ce même type ! Ainsi, on peut créer des types de données récursifs, où une valeur d’un type peut contenir des valeurs de ce même type, qui à leur tour peuvent contenir encore plus de valeurs de ce type, et ainsi de suite.

Pensez à cette liste : [5]. C’est juste un sucre syntaxique pour 5:[]. À gauche de :, il y a une valeur, et à droite, il y a une liste. Dans ce cas, c’est une liste vide. Maintenant, qu’en est-il de [4, 5] ? Eh bien, ça se désucre en 4:(5:[]). En considérant le premier :, on voit qu’il prend aussi un élément à gauche et une liste (ici 5:[]) à droite. Il en va de même pour

3:(4:(5:(6:[]))) , qui peut aussi être écrit 3:4:5:6:[] (parce que : est associatif à droite) ou [3, 4, 5, 6] .

Utilisons un type de données algébrique pour implémenter nos propres listes dans ce cas !

data List a = Empty | Cons a (List a) deriving (Show, Read, Eq, Ord)

On peut lire ça comme la définition des listes donnée dans un paragraphe ci-dessus. Une liste est soit vide, soit une combinaison d’une tête qui a une valeur et d’une autre liste. Si cela vous laisse perplexe, peut-être que vous serez plus à l’aise avec une syntaxe d’enregistrements.

data List a = Empty | Cons { listHead :: a, listTail :: List a} deriving (Show, Read, Eq, Ord)

Vous serez peut-être aussi surpris par le nom du constructeur Cons ici. cons est un autre nom de :. Vous voyez, pour les listes, : est en réalité un constructeur qui prend une valeur et une autre liste, pour retourner une liste. On peut d’ores et déjà utiliser notre nouveau type de listes ! Il a deux champs. Le premier de type

a et le second de type List a .

ghci> Empty Empty

ghci> 5 `Cons` Empty Cons 5 Empty

ghci> 4 `Cons` (5 `Cons` Empty) Cons 4 (Cons 5 Empty)

ghci> 3 `Cons` (4 `Cons` (5 `Cons` Empty)) Cons 3 (Cons 4 (Cons 5 Empty))

On a appelé notre constructeur Cons de façon infixe pour souligner sa similarité avec :. Empty est similaire à [] et 4 `Cons` (5 `Cons` Empty) est similaire à 4:(5:[]).

On peut définir des fonctions comme automatiquement infixes en ne les nommant qu’avec des caractères spéciaux. On peut aussi faire de même avec les constructeurs, puisque ce sont des fonctions qui retournent un type de données. Regardez ça !

infixr 5 :-:

data List a = Empty | a :-: (List a) deriving (Show, Read, Eq, Ord)

Tout d’abord, on remarque une nouvelle construction syntaxique, la déclaration de fixité. Lorsqu’on définit des fonctions comme opérateurs, on peut leur donner une fixité (ce n’est pas nécessaire). Une fixité indique avec quelle force un opérateur lie, et s’il est associatif à droite ou à gauche. Par exemple, la fixité de * est infixl 7 * , et la fixité de + est infixl 6 . Cela signifie qu’ils sont tous deux associatifs à gauche ( 4 * 3 * 2 est équivalent à (4 * 3) * 2) ), mais * lie plus fortement que +, car il a une plus grande fixité, et ainsi 5 * 4 + 3 est équivalent à (5 * 4) + 3.

À part ce détail, on a juste écrit a :-: (List a) à la place de Cons a (List a). Maintenant, on peut écrire des listes qui ont notre type de listes de la sorte :

ghci> 3 :-: 4 :-: 5 :-: Empty (:-:) 3 ((:-:) 4 ((:-:) 5 Empty))

ghci> let a = 3 :-: 4 :-: 5 :-: Empty

ghci> 100 :-: a

(:-:) 100 ((:-:) 3 ((:-:) 4 ((:-:) 5 Empty)))

Lorsqu’on dérive Show pour notre type, Haskell va toujours afficher le constructeur comme une fonction préfixe, d’où le parenthésage autour de l’opérateur (rappelez-vous, 4 + 3 est juste (+) 4 3).

Créons une fonction qui somme deux de nos listes ensemble. ++ est défini ainsi pour des listes normales :

infixr 5 ++

(++) :: [a] -> [a] -> [a] [] ++ ys = ys

(x:xs) ++ ys = x : (xs ++ ys)

On va juste voler cette définition pour nos listes. On nommera la fonction .++.

infixr 5 .++

(.++) :: List a -> List a -> List a Empty .++ ys = ys

(x :-: xs) .++ ys = x :-: (xs .++ ys)

Voyons si ça a marché…

ghci> let b = 6 :-: 7 :-: Empty

ghci> a .++ b

(:-:) 3 ((:-:) 4 ((:-:) 5 ((:-:) 6 ((:-:) 7 Empty))))

Bien. Très bien. Si l’on voulait, on pourrait implémenter toutes les fonctions qui opèrent sur des listes pour notre propre type liste.

Notez comme on a filtré sur le motif (x :-: xs). Cela fonctionne car le filtrage par motif n’est en fait basé que sur la correspondance des constructeurs. On peut filtrer sur :-: parce que c’est un constructeur de notre type de liste, tout comme on pouvait filtrer sur : pour des listes normales car c’était un de leurs

constructeurs. Pareil pour []. Puisque le filtrage par motif marche (uniquement) sur les constructeurs, on peut filtrer sur des choses comme des constructeurs normaux préfixes, ou bien comme 8 ou 'a', qui sont simplement des constructeurs de types numériques ou de caractères respectivement.

À présent, implémentons un arbre binaire de recherche. Si vous ne connaissez pas les arbres binaires de recherche, voici en quoi ils consistent : chaque élément pointe vers deux éléments, son fils gauche et son fils droit. Le fils gauche a une valeur inférieure à celle de l’élément, le fils droit a une valeur supérieure. Chacun des fils peut à son tour pointer vers zéro, un ou deux éléments. Chaque élément a ainsi au plus deux sous-arbres. Et la propriété intéressante de ces arbres est que tous les nœuds du sous-arbre gauche d’un nœud donné, mettons 5, ont une valeur inférieure à 5. Quant aux nœuds du sous-arbre droit, ils sont tous supérieurs à 5. Ainsi, si l’on cherche 8 dans un arbre dont la racine vaut 5, on va chercher seulement dans le sous-arbre droit, puisque 8 est plus grand que 5. Si le nœud suivant est 7, on cherche encore à droite car 8 est plus grand que 7. Et voilà ! On a trouvé notre élément en seulement 3 coups ! Si l’on cherchait dans une liste, ou dans un arbre mal construit, cela pourrait nous prendre jursqu’à 8 coups pour savoir si 8 est là ou pas.

Les ensembles de Data.Set et les maps de Data.Map sont implémentés à l’aide d’arbres binaires de recherche équilibrés, qui sont toujours bien équilibrés. Mais pour l’instant, nous allons simplement implémenter des arbres de recherches binaires standards.

Voilà ce qu’on va dire : un arbre est soit vide, soit un élément contenant une valeur et deux arbres. Ça sent le type de données algébrique à plein nez !

data Tree a = EmptyTree | Node a (Tree a) (Tree a) deriving (Show, Read, Eq)

Ok, bien, c’est très bien. Plutôt que de construire des arbres à la main, on va créer une fonction qui prend un arbre, un élément, et insère cet élément. Cela se fait en comparant la valeur de l’élément que l’on souhaite insérer avec le nœud racine, s’il est plus petit on part à gauche, s’il est plus grand on part à droite. On fait de même avec tous les nœuds suivants jusqu’à ce qu’on arrive à un arbre vide. C’est ici qu’on doit insérer notre nœud qui va simplement remplacer ce vide. Dans des langages comme C, on fait ceci en modifiant des pointeurs et des valeurs dans l’arbre. En Haskell, on ne peut pas vraiment modifier l’arbre, donc on recrée un nouveau sous-arbre chaque fois qu’on décide d’aller à gauche ou à droite et au final, la fonction d’insertion construit un tout nouvel arbre,

puisqu’Haskell n’a pas de concept de pointeurs mais seulement de valeurs. Ainsi, le type de la fonction d’insertion sera de la forme a -> Tree a -> Tree a. Elle prend un élément et un arbre et retourne un nouvel arbre qui contient cet élément. Ça peut paraître inefficace, mais la paresse se charge de ce problème. Voici donc deux fonctions. L’une est une fonction auxiliaire pour créer un arbre singleton (qui ne contient qu’un nœud) et l’autre est une fonction d’insertion.

singleton :: a -> Tree a

singleton x = Node x EmptyTree EmptyTree

treeInsert :: (Ord a) => a -> Tree a -> Tree a

treeInsert x EmptyTree = singleton x

treeInsert x (Node a left right) | x == a = Node x left right

| x < a = Node a (treeInsert x left) right | x > a = Node a left (treeInsert x right)

La fonction singleton est juste un raccourci pour créer un nœud contenant une valeur et deux sous-arbres vides. Dans la fonction d’insertion, on a d’abord notre cas de base sous forme d’un motif. Si l’on atteint un arbre vide et qu’on veut y insérer notre valeur, cela veut dire qu’il faut renvoyer l’arbre singleton qui contient cette valeur. Si l’on insère dans un arbre non vide, il faut vérifier quelques choses. Si l’élément qu’on veut insérer est égal à la racine, alors on retourne le même arbre. S’il est plus petit, on retourne un arbre qui a la même racine, le même sous-arbre droit, mais qui a pour sous-arbre gauche le même qu’avant auquel on a ajouté l’élément à ajouter. Le raisonnement est symétrique si l’élément à ajouter est plus grand que l’élément à la racine.

Ensuite, on va créer une fonction qui vérifie si un élément est dans l’arbre. Définissons d’abord le cas de base. Si on cherche un élément dans l’arbre vide, il n’est certainement pas là. Ok. Voyez comme le cas de base est similaire au cas de base lorsqu’on cherche un élément dans une liste. Si on cherche un élément dans une liste vide, il n’est sûrement pas là. Enfin bon, si l’arbre n’est pas vide, quelques vérifications. Si l’élément à la racine est celui qu’on cherche, trouvé ! Sinon, eh bien ? Puisqu’on sait que les éléments à gauche sont plus petits que lui, si on cherche un élément plus petit, on va le chercher à gauche. Symétriquement, on cherchera à droite un élément plus grand que celui à la racine.

treeElem :: (Ord a) => a -> Tree a -> Bool

treeElem x (Node a left right) | x == a = True

| x < a = treeElem x left | x > a = treeElem x right

Tout ce qu’on a eu à faire, c’est réécrire le paragraphe précédent en code. Amusons-nous avec nos arbres ! Plutôt que d’en créer un à la main (bien que ce soit possible), créons-en un à l’aide d’un pli sur une liste. Souvenez-vous, à peu près tout ce qui traverse une liste et retourne une valeur peut être implémenté comme un pli ! On va commencer avec un arbre vide, puis approcher une liste par la droite en insérant les éléments un à un dans l’arbre accumulateur.

ghci> let nums = [8,6,4,1,7,3,5]

ghci> let numsTree = foldr treeInsert EmptyTree nums

ghci> numsTree

Node 5 (Node 3 (Node 1 EmptyTree EmptyTree) (Node 4 EmptyTree EmptyTree)) (Node 7 (Node 6 EmptyTree EmptyTree) (Node 8 EmptyTree

Dans ce foldr, treeInsert était la fonction de pliage (elle prend un arbre, un élément d’une liste et produit un nouvel arbre) et EmptyTree était l’accumulateur initial. nums, évidemment, était la liste qu’on pliait.

Lorsqu’on affiche notre arbre à la console, il n’est pas très lisible, mais en essayant, on peut voir sa structure. On peut voir que le nœud racine est 5 et qu’il a deux sous-arbres, un qui a pour nœud racine 3, l’autre qui a pour nœud racine 7, etc.

ghci> 8 `treeElem` numsTree True

ghci> 100 `treeElem` numsTree False

ghci> 1 `treeElem` numsTree True

ghci> 10 `treeElem` numsTree False

Tester l’appartenance fonctionne aussi correctement. Cool.

Comme vous le voyez, les structures de données algébriques sont des concepts cool et puissants en Haskell. On peut les utiliser pour tout faire, des booléens et énumérations des jours de la semaine jusqu’aux arbres binaires de recherche, et encore plus !