TD 1 - Mise à niveau Caml
Pour ce premier TD, il est suggéré de travailler dans Emacs, en
utilisant le mode interactif d'Ocaml. Pour cela, ouvrez un fichier
portant le suffixe .ml, par exemple en lançant la commande
emacs td1.ml ou, si Emacs est déjà lancé, en ouvrant un
fichier td1.ml avec Ctrl-x Ctrl-f. Vous devriez voir
apparaître Tuareg en bas de la fenêtre, qui signale le mode
Emacs pour Ocaml. Chaque déclaration que vous écrivez dans
td1.ml peut être envoyée à Ocaml avec le raccourci Ctrl-x
Ctrl-e. Si besoin, l'intégralité du fichier peut être envoyée à
Ocaml avec le raccourci Ctrl-c Ctrl-b. (Toutes ces commandes
se retrouvent dans le menu Tuareg.)
Il va sans dire que vous êtes libres d'utiliser un autre éditeur de
texte, à partir du moment où vous le maîtrisez et êtes à même de
développer efficacement en Ocaml avec cet éditeur.
Il y a trois exercices indépendants.
Ceux d'entre vous qui se sentent à l'aise avec Caml mais souhaitent
tout de même faire l'exercice 1 sur les tris peuvent
attaquer directement à la question
tri par tas.
1. Tri
Dans ce premier exercice, nous allons programmer quelques tris
classiques, sous la forme de fonctions opérant sur des listes
d'entiers, c'est-à-dire ayant le type int list -> int list.
Tri par insertion
Écrire une fonction insertion : int -> int list -> int
list qui insère un élément dans une liste d'entiers
supposée triée en ordre croissant.
Écrire une fonction insertion_sort : int list -> int list
qui réalise le tri par insertion d'une liste d'entiers. On rappelle
que cela consiste à extraire le premier élément de la liste,
à trier récursivement le reste de la liste, puis à insérer
l'élément isolé (avec la fonction insertion).
Tester avec les exemples ci-dessous :
insertion_sort [1; 2; 3];;
insertion_sort [3; 2; 1];;
insertion_sort [];;
insertion_sort [1];;
insertion_sort [2; 1; 1;];;
Tri fusion (mergesort)
Écrire une fonction split : int list -> int list * int
list qui prend une liste d'entiers l en argument et la «
coupe en deux » c'est-à-dire renvoie une paire de liste
(l1,l2) telle que les éléments de l sont
exactement ceux de l1 et de l2 et telle que les
listes l1 et l2 aient une longueur ne différant
au plus d'un.
Écrire une fonction fusion : int list -> int list -> int
list prenant en arguments deux listes supposées triées en
ordre croissant et les fusionne en une unique liste triée.
En utilisant les deux fonctions précédentes, écrire une
fonction mergesort : int list -> int list réalisant le tri
fusion. On rappelle que le principe consiste à couper la liste en
deux, à trier récursivement chacune des deux listes obtenues,
puis à fusion les deux listes triées.
Tri rapide (quicksort)
Écrire une fonction partition : int -> int list -> int list *
int list qui prend en argument un entier x et une liste
d'entiers l et renvoie une paire de listes (l1,l2)
telle que l1 (resp. l2) contient les éléments
de l plus petits (resp. plus grands) que x.
Écrire une fonction quicksort : int list -> int list
réalisant le tri rapide (quicksort). On rappelle que le
principe consiste à isoler un élément particulier de la liste
(ici le premier), à partionner les éléments restants en
utilisant la fonction partition ci-dessus, à trier
récursivement les deux listes obtenues, et enfin à recoller
les morceaux. Pour cette dernière étape on pourra utiliser
la fonction de bibliothèque List.append qui effectue la
concaténation de deux listes.
Tri par tas (heapsort)
Structure de tas
Un tas est un arbre binaire contenant des entiers en chacun de
ses noeuds et dont chaque sous-arbre t vérifie la propriété
suivante : l'entier à la racine de t est inférieur ou
égal à tous les entiers contenus dans t.
Voici un exemple de tas contenant les entiers 1, 2, 2, 4, 5 et 6 :
1
/ \
2 5
/ \
4 2
\
6
Le but de cette question est de réaliser les deux opérations élémentaires
sur la structure de tas que sont l'insertion d'un nouvel élément
et l'extraction du plus petit élément (c'est-à-dire la racine).
On se donne le type heap suivant pour représenter les tas :
type heap = Null | Fork of int * heap * heap
On commence par une opération plus complexe,
merge : heap -> heap -> heap, qui réalise la fusion de
deux tas a et b. Le principe est le suivant.
Si a ou b est Null, le résultat est
immédiat. Sinon, on compare la racine de a et celle de
b ; supposons que la racine de a soit la plus petite
(sinon, on échange les rôles de a et b).
Le résultat est alors un tas dont la racine est celle de
a, dont le sous-arbre gauche est le sous-arbre droit de
a, et dont le sous-arbre droit est obtenu en fusionnant
récursivement le sous-arbre gauche de a et l'arbre b.
Écrire la fonction merge.
À l'aide de la fonction merge, écrire une fonction
add : int -> heap -> heap qui
insère un nouvel élément dans un tas. (On pourra se servir du
tas Fork (x, Null, Null) qui contient uniquement x.)
De même, utiliser la fonction merge pour écrire une
fonction extract_min : heap -> int * heap qui
extrait le plus petit élément d'un tas (sa racine) et retourne
le tas constitué par tous les autres éléments. Lorsque le tas
est vide, on pourra lever l'exception Empty_heap ainsi
déclarée :
exception Empty_heap
Note : ces tas s'appellent des skew heaps et sont à
la fois persistants, élégants et très efficaces.
Application au tri
Une fois la structure de tas donnée, il est aisé d'en déduire
un algorithme de tri, appelé tri par tas.
Pour cela, commencer par écrire une fonction heap_of_list : int
list -> heap qui construit un tas avec tous les éléments
d'une liste.
Écrire ensuite la fonction inverse list_of_heap : heap
-> int list qui construit une liste
triée avec tous les éléments d'un tas.
Enfin, combiner les deux fonctions précédentes en une fonction
heapsort : int list -> int list réalisant le tri par tas.
Paramétricité
Les fonctions de tri précédentes utilisent la fonction de comparaison
prédéfinie d'OCaml. On souhaite les généraliser afin qu'elles
s'appliquent à des listes dont les éléments sont munis d'une relation
d'ordre arbitraire.
-
Une première solution consiste à utiliser l'ordre
supérieur, pour passer la fonction de comparaison en
argument. Reprendre le code de l'une des fonctions de tri ci-dessus et
en faire une fonction de type
sort : ('a -> 'a -> bool) -> 'a list -> 'a list
où la fonction passée en argument correspond à la relation
« inférieur ou égal à ».
On pourra tester ainsi :
sort (<=) [1;2;3;2;4;1];;
-
Une autre solution consiste à écrire la fonction de tri à
l'intérieur d'un foncteur, dont l'argument spécifie le
type des éléments de la liste et la fonction de
comparaison. Reprendre le code de l'une des fonctions de tri et en
faire un foncteur dont la signature est
module Sort(X : sig type t val le : t -> t -> bool end) : sig
val sort : X.t list -> X.t list
end
On pourra tester ainsi :
module S = Sort(struct type t = int let le = (<=) end);;
S.sort [1;2;3;2;4;1];;
2. Le mode T9 des téléphones portables
Le mode T9 des téléphones portables facilite la saisie des
textes sur les claviers : au lieu de taper plusieurs fois sur une
touche pour faire défiler les lettres, une seule frappe suffit et le
téléphone propose de lui-même les mots qui correspondent à la séquence
de touches qui vient d'être tapée, à partir d'un dictionnaire qu'il a
en mémoire.
Par exemple, en tapant successivement les touches 2, 6, 6, 5, 6, 8 et
7 vous obtenez le mot bonjour. Il est possible qu'une suite de
touches corresponde à plusieurs mots. Ainsi, la suite 5, 6, 4 et 3
correspond aux mots joie et loge et la suite 2, 5, 3 et
3 à clef et bled.
Le but de cet exercice est de programmer une solution efficace pour la
recherche des mots du dictionnaire qui correspondent à une séquence de
touches.
Pour cela, nous allons représenter le dictionnaire par un arbre
digital (en anglais trie) c'est-à-dire un arbre où
chaque branche est étiquetée par le numéro d'une touche du téléphone
et chaque noeud par les mots du dictionnaire correspondant à la
séquence des touches menant de la racine de l'arbre à ce noeud.
Par exemple, l'arbre que l'on obtient pour le petit dictionnaire
d'informatique composé des mots {hd, if, in, get, to, void, unit}
est le suivant :
{}
/ \
4/ \8
/ \
{} {}
/ \ \
6/ \3 \6
/ \ \
{in} {if,hd} {to}
| |
|8 |4
| |
{get} { }
/ \
3/ \8
/ \
{void} {unit}
Pour représenter un tel arbre digital, on se donne le type Caml
suivant :
type trie = { words : string list ; branches : (int * trie) list }
i.e. chaque noeud de l'arbre est un enregistrement où le champ
words contient la liste des mots correspondant à ce noeud
et où le champ branches est une liste de paires associant
des arbres digitaux à des entiers (on appelle précisément cela une liste
d'association).
- Définir la valeur empty : trie correspondant au
dictionnaire vide.
- Écrire une fonction find : trie -> int list -> string
list renvoyant la liste des mots stockée dans le noeud
désigné par la liste d'entiers passée en argument. (On pourra
se servir de la fonction List.assoc de la
bibliothèque Caml.)
- Écrire une fonction add : trie -> int list -> string ->
trie ajoutant un mot dans le dictionnaire, la liste d'entiers
correspondant au mot étant passée en argument. On pourra
commencer par écrire une fonction
change_assoc : 'a -> 'b -> ('a * 'b) list -> ('a * 'b) list
qui modifie (ou étend) une liste d'association.
- Écrire une fonction key : char -> int qui associe aux
lettres minuscules de l'alphabet le chiffre correspondant sur le
clavier d'un téléphone portable.
En déduire une fonction intlist_of_string : string -> int
list qui convertit un mot en la liste d'entiers correspondant
à sa saisie sur le clavier du téléphone, puis une fonction
add_word : trie -> string -> trie ajoutant un mot dans le
dictionnaire.
- Construire le dictionnaire t correspondant à la liste de mots
suivante :
let dict = [
"lex"; "key"; "void" ; "caml" ; "unix" ; "for" ; "while" ; "done" ;
"let" ; "fold"; "val"; "exn" ; "rec" ; "else" ; "then" ; "type" ;
"try" ; "with" ; "to"; "find" ; "do" ; "in" ; "if" ; "hd" ; "tl" ;
"iter" ; "map" ; "get"; "copy" ; "and"; "as" ; "begin" ; "class" ;
"downto" ; "end" ; "exception" ; "false" ; "fun" ; "function" ;
"match" ; "mod" ; "mutable" ; "open" ; "or" ; "true" ; "when" ;
"load" ; "mem" ; "length" ; "bash" ; "unit" ; "site"; "php"; "sql";
"ssh"; "spam"; "su"; "qt"; "root"; "bsd"; "boot"; "caml"; "bash";
"ocaml"; "kde"; "gtk" ; "gcc"
]
(On pourra utiliser pour cela la fonction de bibliothèque
List.fold_left.)
Tester avec les exemples suivants (la réponse attendue est
indiquée entre parenthèses) :
let _ = find t [2;2;6;5];; (* ["caml"] *)
let _ = find t [4;3];; (* ["hd"; "if"] *)
let _ = find t [4;3;8];; (* ["get"] *)
let _ = find t [8;6;4;3];; (* ["void"] *)
let _ = find t [8;6;4;8];; (* ["unit"] *)
- Écrire une fonction remove_word : trie -> string ->
trie permettant de supprimer un mot du dictionnaire.
3. Compression d'images
Un programme à trous quad.ml est fourni.
Il suffit de le récupérer et de le modifier.
Le programme utilise la bibliothèque Graphics.
Pour le compiler, il faut donc lier cette bibliothèque, à savoir :
% ocamlc -o quad graphics.cma quad.ml
ou encore avec le compilateur natif :
% ocamlopt -o quad graphics.cmxa quad.ml
Ceci produira l'exécutable quad. Cet exécutable dessine
des images, puis s'arrête.
L'affichage des images est contrôlé à
l'aide des touches du clavier. À tout moment la touche « q » (avec le
curseur de la souris dans la fenêtre graphique) permet de
passer à la question suivante.
Les quadtrees
On présente ici une méthode originale de représentation d'images sous forme d'arbres. Cette représentation donne une méthode de compression plus ou
moins efficace et facilite certaines opérations sur les images.
Pour simplifier, on suppose les images carrées, de côté 2n, et en
noir et blanc.
L'idée est la suivante :
une image toute blanche ou toute noire se représente par sa couleur, tandis
qu'une image composite se divise naturellement en quatre
images carrées.
Selon ce schéma, les images sont représentées par des arbres du type
suivant :
type couleur = Blanc | Noir
type arbre = Feuille of couleur | Noeud of arbre * arbre * arbre * arbre
On manipulera aussi des images bitmap, représentées par des
matrices de couleurs que l'on supposera toujours de côté 2n par la
suite.
type image = couleur array array (* matrice de taille 2n *)
Échauffement
Écrire une fonction compte_feuilles qui prend un arbre en
argument et renvoie le nombre de feuilles de cet arbre.
Une fonction compte_feuilles grossièrement fausse est présente
dans le squelette quad.ml ; à vous de la
remplacer par la bonne.
Diverses conversions
Arbre d'une image bitmap
Écrire une fonction image_vers_arbre qui prend un entier k et
une image de côté k, et qui
rend l'arbre représentant cette image.
En Caml, étant donnée une matrice m, on accède à mi,j par
m.(i).(j) (car en Caml une matrice est en fait un tableau de
tableaux, ces derniers étant ici conventionnellement les lignes).
Vous aurez peut-être besoin de consulter la documentation du module
Array.
Dessin de l'image
Nous pourrions inverser la fonction précédente.
Mais il revient quasiment au même de dessiner l'image encodée par un
arbre dans la fenêtre graphique.
Écrire donc une fonction dessine_arbre qui prend un entier k = 2n en argument et
un arbre a, et qui dessine l'image encodée par a dans la
fenêtre graphique opportunément taillée en k × k.
Vous aurez certainement besoin de la fonction
Graphics.fill_rect qui colorie un rectangle dans la
couleur courante (ici le noir).
Les fonctions de test de quad.ml se chargent de la mise en
place de la fenêtre et des appels à image_vers_arbre et à
dessine_arbre.
Normalement, vous deviez obtenir ce dessin :
(Si vous obtenez une isométrie quelconque de ce dessin,
tranquilisez-vous et continuez.)
Transformations de l'arbre
Écrire les fonctions inverse, rotate et
antirotate qui prennent toutes un arbre a représentant une
image i en argument et
renvoient un arbre représentant l'image inversée de i (blanc et noir
échangés), i tournée d'un quart de tour vers la gauche, et i tournée
d'un quart de tour vers la droite.
Le squelette permet de tester ces trois fonctions. Ainsi,
la séquence de touches «n, i, p » devrait normalement donner ces
images successivement :
-- n -->
-- i -->
-- p -->
Une fractale
Écrire une fonction fractale : int -> arbre qui prend un
entier n en argument et construit l'arbre correspondant à n
itérations du processus dont on montre ici les trois premières
applications.
-- n -->
-- n -->
-- n -->
(Il convient ici de bien regarder la première itération pour
comprendre le processus.)
Sauvegarde des images compressées
Dans le but de sauvegarder les images dans des fichiers,
on se pose le problème de transformer le type arbre en une liste de 0 et
de 1, ainsi que le problème réciproque.
On note code(a) la liste de 0 et de 1 représentant un arbre a. On
choisit le codage suivant :
| code(Feuille Blanc) |
= |
00 |
| code(Feuille Noir) |
= |
01 |
| code(Noeud (a1,a2,a3,a4)) |
= |
1 code(a1) code(a2) code(a3) code(a4) |
Pour simplifier on va procéder en deux temps : d'abord le codage (et
décodage) de
l'arbre en liste de bits ; puis l'écriture (et lecture) des bits dans
un fichier.
Des arbres vers les listes, et retour
On se donne d'abord un type pour les bits (zéro ou un).
type bit = Zero | Un
Écrire une fonction arbre_vers_liste de type
arbre -> bit list qui transforme un arbre en une liste de 0 et
de 1 selon le codage ci-dessus. Cette fonction ressemble furieusement à
l'affichage d'un arbre sous forme préfixe.
Écrire la fonction réciproque liste_vers_arbre
de type bit list -> arbre, qui
transforme une liste de bits en l'arbre correspondant.
Cette fonction ressemble à l'analyse syntaxique d'une
expresssion donnée sous forme préfixe, le codage choisi évitant les
ambiguïtés.
Écriture et lecture
Terminer la question, c'est-à-dire écrire les fonctions
ecrire_arbre de type string -> arbre -> unit
et lire_arbre de type string -> arbre, qui
respectivement sauvegarde un arbre dans un fichier dont le nom est
passé en argument, et lit l'arbre contenu dans un fichier.
On cherche bien évidemment à produire les fichiers les plus petits
possible. L'idée est simple et classique, les fichiers étant des
suites de caractères qui sont aussi des entiers sur 8 bits. On va
donc grouper les bits par paquets de 8 dans des entiers normaux,
convertir ces entiers en caractères (voir Char.chr), puis
écrire les caractères dans le fichier.
Les fonctions sur les fichiers sont regroupées dans le
module Pervasives (ouvert par défaut, ainsi on n'est pas obligé
de faire précéder ces fonctions de Pervasives.).
On portera un intérêt tout particulier à l'ouverture
(open_out et open_in) et à la fermeture
(close_out et close_in)
d'un fichier, ainsi qu'à l'écriture et à la lecture d'un caractère
(output_char et input_char).
Le squelette fourni contient un test (sauver et relire une des
fractales de la question précédente), mais vous pouvez aussi lire
cette image.
retour à la page du cours