TP 23
TP 23
TP 23
TP 23 : Algorithme LZW
I. Structures de données
Repris d’un TP de Jean-Baptiste Bianquis.
Conceptuellement, on a besoin de deux structures de données pour implémenter l’algorithme LZW :
— une structure (qu’on appellera dictionnaire) donnant les associations code vers motif ;
— une structure (qu’on appellera table inverse) donnant les associations motif vers code (et permettant
de tester si un motif est associé à un code).
On peut imaginer la solution suivante :
— la première structure (le dictionnaire) est réalisée par un tableau, avec le motif associé au code 𝑐
dans la case d’indice 𝑐 ;
— la seconde structure (la table inverse) est réalisée par une table de hachage, avec les motifs comme
clés et les codes comme valeurs.
▶ Question 1 En supposant qu’on choisisse cette solution :
— les deux structures sont-elles utiles dans la phase de compression ?
— et dans la phase de décompression ?
La solution que nous allons utiliser est légèrement différente, et tire parti du fait que la structure des motifs
« connus » (i.e associés à un code) est très contrainte. Un motif présent dans la table est :
— soit réduit à un octet ;
— soit de la forme 𝑚𝑥, avec 𝑚 un autre motif présent dans le dictionnaire et 𝑥 un octet.
On peut donc éviter de manipuler des motifs en tant que tels, et n’utiliser que des couples 𝑐, 𝑥 où :
— 𝑐 est un code déjà existant ;
— 𝑥 est un octet.
On définit les alias de type suivants :
// codeword type C
typedef uint32_t cw_t;
// byte type
typedef uint8_t byte_t;
struct dict_entry_t { C
cw_t pointer;
byte_t byte;
};
#define CW_MAX_WIDTH 16 C
#define DICTFULL (1u << CW_MAX_WIDTH)
1
MP2I — 2022-2023 Pierre Le Scornet Module Algorithmique
struct dict_entry_t {
cw_t pointer;
byte_t byte;
};
struct dict_t {
cw_t next_available_cw;
int cw_width;
dict_entry_t data[DICTFULL];
};
Remarque 1
▶ Question 2 Avec les valeurs données ci-dessus pour les différentes constantes (et pour le type cw_t),
quelle quantité de mémoire le dictionnaire occupe-t-il ?
▶ Question 3 Écrire une fonction initialize_dictionary qui initialise les champs next_available_cw
et cw_width, ainsi que la partie du tableau correspondant aux motifs de un octet.
— Pour cw_width, on initialisera à CW_MIN_WIDTH.
— On mettra le champ pointer à NULL_CW pour les motifs de un octet.
1 void initialize_dictionary(void); C
Pour la table inverse (association motif vers code), on utilise un tableau bidimensionnel de codes :
1 cw_t inverse_table[DICTFULL][256]; C
La case inverse_table[c][x] contiendra le code correspondant au couple (𝑐, 𝑥) s’il existe, une valeur
quelconque sinon.
▶ Question 4 Quelle quantité de mémoire inverse_table consomme-t-elle ?
▶ Question 5 Écrire une fonction lookup qui prend en entrée un couple 𝑐, 𝑥 et renvoie :
— le code correspondant à 𝑐, 𝑥 s’il y en a un ;
— NO_ENTRY sinon.
2
MP2I — 2022-2023 Pierre Le Scornet Module Algorithmique
▶ Question 6 Écrire une fonction build_entry qui prend en entrée un couple 𝑐, 𝑥 et ajoute une entrée
dans le dictionnaire pour ce motif (en mettant également à jour inverse_table). On pourra supposer sans
le vérifier que le motif n’est pas déjà présent dans le dictionnaire.
Remarque 2
Dans le cas où le dictionnaire est déjà plein, cette fonction ne fera rien.
Je vous avais dit que les valeurs de type in_channel et out_channel pouvaient être vues comme des “cur-
seurs” ou des “pointeurs” qui se déplacent dans un fichier de la gauche vers la droite. En C, cela va être
plus explicite : les deux types de fichiers d’OCaml sont réunis en un seul en C : FILE*, défini dans le fichier
d’en-tête stdio.h.
Dans le même fichier d’en-tête, on définit trois valeurs de type FILE* :
— stdin, l’entrée standard, qui représente les entrées (mais pas les arguments) fournies à votre pro-
gramme, que ce soit au clavier ou en utilisent une commande de redirection de la forme ./programme
< fichier.txt ;
— stdout, la sortie standard, qui correspond à ce qui est imprimé dans la console par votre programme
(ou redirigé avec ./programme > sortie.txt) ;
— stderr, la sortie standard d’erreur, habituellement également imprimée dans la console, qui est
faite pour afficher des erreurs.
Pour ouvrir un fichier, que ce soit pour le lire ou pour y écrire, on utilise la fonction de prototype :
Cette fonction prend en entrée le nom ou le chemin vers un fichier filename et une autre chaîne de carac-
tères contenant les droits d’accès que l’on ouvre sur le fichier. Les droits d’accès les plus importants sont :
— "r" pour read, pour lire un fichier qui existe déjà (s’il n’existe pas, le comportement est indéter-
miné) ;
— "w" pour write, pour écrire dans un fichier, qui écrase le contenu précédent du fichier et le crée s’il
n’existe pas.
D’autres droits possibles existent : "a" pour ajouter du contenu à la fin d’un fichier par exemple.
Un fichier ouvert doit être fermé à la fin de son utilisation grâce à la fonction :
1 int fprintf(FILE* stream, char* format, ...); // stream a été ouvert en écriture C
2 int fscanf(FILE* stream, char* format, ...); // stream a été ouvert en lecture
3
MP2I — 2022-2023 Pierre Le Scornet Module Algorithmique
La fonction fprintf supporte la même syntaxe de formats que la fonction printf, d’où les ... qui signifie
les arguments supplémentaires pour remplir le format dans l’argument format. On précise juste en plus
que l’on écrit non pas dans stdout mais dans stream. Autrement dit, printf est fprintf où le flux où l’on
écrit est stdout.
La fonction fscanf lit dans un flux ouvert en écriture stream. Ce qui est lu est donné par le format, qui a
les mêmes spécificateurs de types que printf ou fprintf.
Ainsi :
— fscanf(f, "%c", char_pointer) lit un caractère ;
— fscanf(f, "%s", char_pointer) lit une chaîne de caractères en s’arrêtant juste avant le premier
caractère d’espacement rencontré (espace, tabulation ou retour à la ligne). Son comportement est
indéfini si le tableau char_pointer n’est pas assez grand pour contenir la chaîne lue (y compris le
caractère nul rajouté automatiquement à la fin).
III. Compression
Pour compresser, nous allons lire le fichier d’entrée octet par octet et émettre des codes sur un fichier de
sortie. Pour l’instant, ces codes seront émis en ASCII : si l’on émet 517, on écrira "517" (trois caractères
ASCII) sur le fichier de sortie. Bien sûr, cela ne permet pas de compresser réellement, mais cela nous
permettra de vérifier la correction de notre algorithme de compression.
▶ Question 7 Écrire une fonction mock_compress qui prend en entrée un fichier d’entrée et un fichier
de sortie et écrit dans le fichier de sortie les codes générés, au format ASCII, séparées par une espace. Par
exemple, avec en entrée un fichier réduit à "ABABCABBAB\n", et sachant que le code ASCII de A est 65, on
doit obtenir le résultat suivant : "65 66 256 67 256 257 66 10".
Remarque 3
Pour lire un octet du fichier d’entrée, on utilisera la fonction getc. Son prototype est :
L’entier qu’elle renvoie est soit EOF (une constante prédéfinie, strictement négative, qui signifie qu’on
est arrivé à la fin du fichier), soit une valeur entre 0 et 255 (qui peut donc être transtypée sans
problème en byte_t).
La fonction suivante permet de vérifier que deux chaînes de caractères sont égales :
i. Pour rappel, on peut récupérer les arguments donnés à un programme en donnant le prototype int main(int argc, char*
argv[]) à la fonction main : argc est alors le nombre d’arguments (au moins 1 car on compte le programme lui-même) et argv le
tableau de ces arguments.
4
MP2I — 2022-2023 Pierre Le Scornet Module Algorithmique
— le deuxième argument, s’il est présent, indique le fichier d’entrée (sinon, on prend l’entrée stan-
dard) ;
— le troisième argument, s’il est présent, indique le fichier de sortie (sinon, on prend la sortie stan-
dard).
Remarque 4
On en profitera bien entendu pour tester la fonction mock_compress sur l’exemple ci-dessus, et sur
d’autres exemples de préférence !
▶ Question 9 Quelle est la complexité temporelle totale de votre programme, en fonction de la largeur
𝑑 des codes et du nombre 𝑛 d’octets à compresser ?
IV. Décompression
La décompression est un peu plus délicate que la compression à cause de la manière dont nous avons
choisi de représenter le dictionnaire. Implicitement, le dictionnaire contient les motifs sous forme de listes
chaînées de caractères, avec le dernier caractère du motif en tête de liste. Nous voulons bien sûr émettre
les caractères dans l’ordre, ce qui peut se faire de deux manières :
— soit à l’aide d’une pile ;
— soit à l’aide d’une fonction récursive.
La version utilisant une fonction récursive est légèrement plus facile à écrire, mais peut être problématique
dans les cas pathologiques puisqu’elle utilise un espace proportionnel à la longueur du motif sur la pile
d’appel. On fournit donc une réalisation très simple de la structure de pile via le header stack.h, qui déclare
les fonctions suivantes :
Cette fonction émettra, dans l’ordre, tous les octets du motif dont le code est cw sur le fichier *fp. La pile
est fournie pour éviter de la réallouer à chaque appel : on pourra supposer qu’elle est de taille suffisante
pour contenir tous les octets du motif, et son état tant avant qu’après l’appel n’a pas d’importance. De plus,
elle renverra le dernier octet du motif (qui nous sera utile par la suite). Pour émettre un octet sur le flux de
sortie, on utilisera :
L’argument ch est automatiquement converti en unsigned char avant d’être écrit, on donnera donc direc-
tement un byte_t comme argument. La valeur de retour est égale à ch si tout s’est bien passé, à EOF sinon :
on pourra l’ignorer.
▶ Question 11 Écrire une fonction get_first_byte qui renvoie le premier octet du motif associé à un
code.
5
MP2I — 2022-2023 Pierre Le Scornet Module Algorithmique
▶ Question 12 Écrire une fonction mock_decompress qui lit un fichier compressé au format produit par
mock_compress et écrit le flux décompressé correspondant sur output_file.
— Il est conseillé de commencer par retrouver l’algorithme de décompression par soi-même : il n’est
pas inenvisageable que l’on vous demande cela le jour d’un concours…
— Après avoir fourni cet effort, consulter le cours est quand même une bonne idée.
— La fonction decode_cw suffit pour traiter le cas « usuel » ; la fonction get_first_byte est utile pour
traiter le cas dit 𝐾𝑤𝐾 (celui où l’on lit un code que l’on n’a pas encore ajouté au dictionnaire).
— La table inverse ne sert pas pour la décompression.
On pensera à tester la fonction sur une entrée contenant un cas 𝐾𝑤𝐾 (par exemple "ABABABA\n") !
struct bit_file {
FILE *fp;
uint64_t buffer;
int buffer_length;
};
6
MP2I — 2022-2023 Pierre Le Scornet Module Algorithmique
▶ Question 13 En utilisant un buffer de 64 bits, quelle taille de code peut-on traiter au maximum sans
problème ? La limitation est-elle gênante ?
▶ Question 14 Écrire une fonction output_bits de prototype :
Les données à écrire sont des les width bits de poids faible de data. Le paramètre flush détermine le
comportement sur les bits restants après avoir écrit autant d’octets que possible dans bf :
— s’il vaut false, les bits restants sont laissés dans bf->buffer ;
— s’il vaut true, ils sont écrits dans bf, complétés par des zéros pour obtenir un octet.
On écrira les données bit le moins significatif en premier : en particulier, quand on écrit un octet constitué
du reste du code précédent et du début du code actuel, les bits de poids faible de l’octet correspondront à
l’ancien code et ceux de poids fort au nouveau.
▶ Question 15 Écrire une fonction input_bits ayant le prototype suivant :
Cette fonction lit width bits depuis le flux bit_file (de manière à lire correctement un flux écrit par
output_bits, évidemment). La valeur pointée par eof sera mise à true si la lecture a échoué parce que
l’on est arrivé à la fin du fichier sans parvenir à lire width bits, à false sinon.
Remarque 5
Les width bits lus seront placés dans les bits de poids faibles de la valeur de retour.
▶ Question 16 Écrire les fonctions compress et decompress, ayant les mêmes prototypes que mock_compress
et mock_decompress mais écrivant les codes en version « compacte ».
7
MP2I — 2022-2023 Pierre Le Scornet Module Algorithmique
— dès que l’on souhaite créer une entrée pour un code et que ce code ne tient pas sur le nombre actuel
de bits, on augmente la largeur de 1 si c’est possible (sinon, le dictionnaire est plein et l’entrée n’est
pas créée) ;
— le nouveau code est créé au moment où l’on émet un code déjà existant (systématiquement) : on
convient que le code existant est émis avec l’ancienne largeur, ce qui revient à dire que l’on crée
l’entrée pour le nouveau code après émission de l’ancien ;
— pour la décompression, il faut juste penser que l’on a toujours « un temps de retard » sur la com-
pression (pour l’état du dictionnaire), et qu’il faut donc changer de largeur une étape plus tôt.
▶ Question 18 Modifier la fonction build_entry pour qu’elle mette à jour la largeur des codes. Le com-
portement étant légèrement différent suivant que l’on est en train de compresser ou de décompresser, on
ajoute un paramètre booléen compress_mode pour indiquer le mode de fonctionnement.
▶ Question 19 Après avoir apporté les autres modifications nécessaires à votre code (s’il y a lieu), re-
prendre les mesures de taux de compression et les comparaisons avec zip. On pourra aussi comparer avec
la compression de Huffman que nous avons déjà programmé, et tester si la composée de Huffman et de
LZW, dans un sens ou dans l’autre, présente un intérêt.