Location via proxy:   [ UP ]  
[Report a bug]   [Manage cookies]                

TP 23

Télécharger au format pdf ou txt
Télécharger au format pdf ou txt
Vous êtes sur la page 1sur 8

MP2I — 2022-2023 Pierre Le Scornet Module Algorithmique

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;

Une entrée du dictionnaire est un couple (code, octet) :

struct dict_entry_t { C
cw_t pointer;
byte_t byte;
};

typedef struct dict_entry_t dict_entry_t;

Le dictionnaire en lui même est une structure contenant :


— le prochain code disponible ;
— la largeur d’un code (en nombre de bits) – pour l’instant, cette taille sera constante ;
— un tableau statique de dict_entry_t. La taille de ce tableau est une constante globale, et vaut 2𝑑 ,
où 𝑑 est la largeur maximale d’un code.

#define CW_MAX_WIDTH 16 C
#define DICTFULL (1u << CW_MAX_WIDTH)

const cw_t NO_ENTRY = DICTFULL;


const cw_t NULL_CW = DICTFULL;

1
MP2I — 2022-2023 Pierre Le Scornet Module Algorithmique

const cw_t FIRST_CW = 0x100;


const int CW_MIN_WIDTH = 16;

struct dict_entry_t {
cw_t pointer;
byte_t byte;
};

typedef struct dict_entry_t dict_entry_t;

struct dict_t {
cw_t next_available_cw;
int cw_width;
dict_entry_t data[DICTFULL];
};

struct dict_t dict;

Remarque 1

— On utilise la directive du pré-processeur #define pour les constantes CW_MAX_WIDTH et


DICTFULL pour pouvoir définir un tableau statique de taille DICTFULL (ce ne serait pas pos-
sible si DICTFULL était défini comme un const int, par exemple). Ce point peut être ignoré.
— dict est une variable globale.
— NO_ENTRY et NULL_CW sont des constantes dont on sait qu’elles ne peuvent correspondre à
un code valide. On a trois constantes différentes pour la même valeur 2𝑑 , mais elles seront
utilisées dans des contextes différents.
— FIRST_CW indique le premier code créé dynamiquement (après les codes pour les motifs de
un octet). On a donné sa valeur en hexadécimal, qui correspond à 256 en décimal.

▶ 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.

1 cw_t lookup(cw_t cw, byte_t byte); C

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.

1 void build_entry(cw_t cw, byte_t byte); C

II. Gestion de fichiers en C


La gestion des fichiers est plus contrainte en OCaml qu’en C (en tout cas, pour ce qui est au programme).
En OCaml, vous disposez des deux fonctions suivantes :

1 val open_in : string -> in_channel OCaml


2 val open_out : string -> out_channel

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 :

1 FILE* fopen(char* filename, char* accessMode); C

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 fclose(FILE* stream); C

qui renvoie 0 si tout se passe bien, EOF sinon.


Pour lire ou écrire dans un fichier, on utilise les fonctions suivantes :

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".

1 void mock_compress(FILE *input_file, FILE *output_file); C

Remarque 3

Pour lire un octet du fichier d’entrée, on utilisera la fonction getc. Son prototype est :

1 int getc(FILE *stream); C

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 :

1 int strcmp (const char* str1, const char* str2); C


2 // Renvoie x avec x = 0 si elles sont égales, x > 0 si str1>str2 pour l'ordre
↪ lexicographique, x < 0 sinon.

▶ Question 8 Écrire un programme ayant le comportement suivant :


— il accepte entre un et trois arguments en ligne de commande ; i
— le premier argument est réduit à un caractère :
— c pour compresser en mode binaire à l’aide de la fonction compress (qui reste à écrire) ;
— C pour compresser en mode texte avec mock_compress ;
— d pour décompresser en mode binaire ;
— D pour décompresser en mode texte ;

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 :

typedef struct stack stack; C

stack *stack_new(int capacity);


void stack_free(stack *s);
int stack_size(stack *s);
byte_t stack_pop(stack *s);
void stack_push(stack *s, byte_t byte);

Pour l’inclure à votre code, on utilisera la directive #include "stack.h".


▶ Question 10 Écrire une fonction decode_cw ayant le prototype suivant :

1 byte_t decode_cw(FILE *fp, cw_t cw, stack *s); C

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 :

1 int putc(int ch, FILE *stream); C

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

1 byte_t get_first_byte(cw_t cw); C

▶ 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.

1 void mock_decompress(FILE *input_file, FILE *output_file); C

On pensera à tester la fonction sur une entrée contenant un cas 𝐾𝑤𝐾 (par exemple "ABABABA\n") !

V. Lecture et écriture binaires


Pour réaliser une véritable compression, il est nécessaire qu’un code de largeur 𝑑 utilise 𝑑 bits sur le fichier
de sortie. Comme 𝑑 n’a aucune raison d’être un multiple de 8, on est ramené à un problème similaire à
celui que l’on a résolu en OCaml pour le code de Huffman dans le TP 22 bonus. Nous allons procéder de
manière très légèrement différente :
— on maintiendra toujours un accumulateur (que nous appellerons buffer) et sa taille (en nombre
de bits significatifs), et l’on écrira toujours un octet sur le fichier de sortie quand le nombre de bits
de l’accumulateur atteindra ou dépassera 8 ;
— cependant, au lieu de recevoir les bits à écrire un par un, nous les recevrons par paquet (un code
complet à chaque appel) ;
— d’autre part, la clôture du fichier sera plus simple : on pourra se contenter de compléter le dernier
octet par des zéros. En effet, lors de la décompression, on saura toujours combien de bits on souhaite
lire, et ce nombre sera toujours supérieur ou égal à 9 (longueur minimale possible d’un code).
On utilise la structure suivante :

const int BUFFER_WIDTH = 64; C


const int BYTE_WIDTH = 8;
const uint64_t BYTE_MASK = (1 << BYTE_WIDTH) - 1;

struct bit_file {
FILE *fp;
uint64_t buffer;
int buffer_length;
};

typedef struct bit_file bit_file;

bit_file *bin_initialize(FILE *fp){


bit_file *bf = malloc(sizeof(bit_file));
bf->fp = fp;
bf->buffer = 0;
bf->buffer_length = 0;
return bf;
}

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 :

1 void output_bits(bit_file *bf, uint64_t data, int width, bool flush); C

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 :

1 uint64_t input_bits(bit_file *bf, int width, bool *eof); C

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 ».

1 void compress(FILE *input_file, FILE *output_file); C


2 void decompress(FILE *input_file, FILE *output_file);

▶ Question 17 Tester le taux de compression obtenu pour :


— le fichier source de votre code C d’aujourd’hui ;
— l’énoncé du TP en format pdf ;
— l’exécutable obtenu en compilant votre code source ;
— le texte intégral de Moby Dick fourni avec le sujet.
On pourra comparer :
— les taux de compression obtenus pour différentes largeurs de code ;
— le taux de compression obtenus en utilisant l’utilitaire zip.

VI. Codes de largeur variable


Pour améliorer le taux de compression, une solution simple est d’utiliser des codes à largeur variable. En
effet, en supposant que l’on fixe la largeur des codes à 14 bits par exemple, on va mettre assez longtemps à
émettre le premier code ne rentrant pas sur 13 bits (i.e. 8192) : jusque-là, le ou les bits les plus significatifs
des codes émis valent tous zéro, et l’on gaspille donc de la place.
Pour éviter cela, il suffit de se mettre d’accord (entre la fonction de compression et celle de décompression)
sur une règle pour l’évolution de la largeur du code. La règle la plus simple est la suivante :
— au départ, la largeur d’un code est 9 bits ;
— on se fixe une largeur maximale (disons 16 bits), et l’on crée les structures dict et inverse_table
avec une taille correspondant à cette largeur ;

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.

1 void build_entry(cw_t cw, byte_t byte, bool compress_mode); C

▶ 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.

Vous aimerez peut-être aussi