Support
Support
Support
François Boulier
8 janvier 2021
Introduction
1
Table des matières
2 Énumération de données 25
2.1 Les piles et les files . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 25
2.1.1 Implantation avec un tableau de taille fixe . . . . . . . . . . . . . . . 26
2.1.2 Implantation avec un tableau redimensionnable . . . . . . . . . . . . 28
2.1.3 Implantation avec listes chaînées . . . . . . . . . . . . . . . . . . . . 30
2.2 Files avec priorité . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 30
2.2.1 Comment munir un tableau d’une structure de tas . . . . . . . . . . . 31
2.2.2 Implantation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 32
2
3.2.2 La recherche dichotomique . . . . . . . . . . . . . . . . . . . . . . . . 42
3.2.3 La suite de Fibonacci . . . . . . . . . . . . . . . . . . . . . . . . . . . 49
3.3 Récurrences linéaires à coefficients constants . . . . . . . . . . . . . . . . . . 51
4 Recherche de données 55
4.1 La complexité étudiée . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 56
4.2 Implantation avec un tableau non ordonné . . . . . . . . . . . . . . . . . . . 56
4.2.1 Implantation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 57
4.2.2 Analyse . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 57
4.3 Implantation avec un tableau ordonné . . . . . . . . . . . . . . . . . . . . . . 59
4.3.1 Implantation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 59
4.3.2 Analyse . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 59
4.4 Implantation par arbres binaires de recherche . . . . . . . . . . . . . . . . . 61
4.4.1 Arbres binaires . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 61
4.4.2 Arbres binaires de recherche . . . . . . . . . . . . . . . . . . . . . . . 62
4.4.3 Implantation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 64
4.4.4 Analyse . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 64
4.5 Implantation par arbres AVL . . . . . . . . . . . . . . . . . . . . . . . . . . 66
4.5.1 Analyse . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 67
4.5.2 Parcours d’arbres binaires . . . . . . . . . . . . . . . . . . . . . . . . 69
4.6 Implantation par tables de hachage . . . . . . . . . . . . . . . . . . . . . . . 69
4.6.1 Implantation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 72
4.6.2 Analyse . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 73
4.7 Conclusion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 75
3
5.4.2 Exemple . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 93
4
Chapitre 1
5
1.2 Programmation modulaire
La programmation modulaire consiste à réaliser un programme par assemblage de mo-
dules, ou composants logiciels. C’est en général une bonne idée de réaliser un module pour
chaque structure de données importante. En C, un module est constitué d’un fichier source
et d’un fichier d’entête. Prenons l’exemple d’un type de nombres rationnels. Le code source
se trouverait dans un fichier rationnel.c. Le fichier d’entête serait rationnel.h (le suffixe
vient du mot « header » en Anglais).
Dans le fichier source, on repère les variables et les fonctions locales au module, au fait
que leur définition est précédée du mot-clef static. D’une façon générale, il vaut mieux
éviter les variables globales, toutes catégories confondues. Les fonctions locales aux modules
sont courantes. Voir section 5.1.1 pour plus de détails.
6
void pgcd (int* g, int a, int b)
Des fonctions plus compliquées demandent des spécifications mieux organisées (entête de
la fonction, explication en une ligne, explication détaillée, description de chaque paramètre,
noms des algorithmes utilisés, exemples, . . .).
De bons exemples se trouvent dans les pages d’aide de la bibliothèque standard du C et
dans la bibliothèque d’algèbre linéaire numérique LAPACK.
#define NB_ELEM_MAX 10
struct tableau_rationnel
{ struct rationnel tab [NB_ELEM_MAX];
int nb_elem;
};
Une difficulté. Le mécanisme décrit ci-dessus pose une difficulté : en raison des directives
d’inclusion en cascade, un même fichier d’entête peut être inclus à plusieurs reprises. C’est
non seulement une perte de temps mais aussi une source d’erreur puisque les types définis
dans ce fichier vont être définis plusieurs fois, ce que le compilateur n’acceptera pas.
Continuons l’exemple et réalisons, en plus du type struct tableau_rationnel, un type
pour les matrices à coefficients rationnels. Le fichier matrice_rationnel.h contiendrait les
lignes suivantes :
2. Les fichiers d’entête contiennent donc à la fois l’implantation et une partie du type abstrait.
3. Ce mot-clef est optionnel pour les prototypes des fonctions, mais il est obligatoire pour les prototypes
des variables globales.
7
#include "rationnel.h"
#define DIM_MAX 10
struct matrice_rationnel
{ struct rationnel tab [DIM_MAX, DIM_MAX];
int nb_lig;
int nb_col;
};
Supposons maintenant qu’un programme principal ait besoin d’utiliser ces deux structures
de données. Il contiendrait les directives d’inclusion suivantes :
#include "tableau_rationnel.h"
#include "matrice_rationnel.h"
À la compilation, le fichier rationnel.h sera inclus à deux reprises, ce qui provoquera
une erreur de compilation. Les inclusions multiples de fichiers d’entête surgissent donc très
naturellement, dès qu’un logiciel commence à se développer.
Résolution. Pour éviter les inclusions multiples, on associe une macro à chaque fichier
d’entête. Par exemple, au fichier rationnel.h, on associe la macro RATIONNEL_H. On dé-
finit chaque macro à la première inclusion du fichier d’entête correspondant, et on annule
l’inclusion d’un fichier d’entête dès que la macro qui lui est associée est définie. Tous les
fichiers d’entête peuvent donc être écrits suivant le même schéma, illustré avec l’exemple des
rationnels :
#if ! defined (RATIONNEL_H)
#define RATIONNEL_H 1
struct rationnel
{ int numer;
int denom;
};
#endif
8
sécurité, on peut même exiger que toute définition de fonction globale soit bien comparée avec
son prototype. Il suffit pour cela de compiler le module avec l’option -Wmissing-prototypes.
Voici un extrait du manuel du compilateur gcc :
-Wmissing-prototypes (C and Objective-C only)
Warn if a global function is defined without a previous prototype
declaration. This warning is issued even if the definition itself
provides a prototype. The aim is to detect global functions that
fail to be declared in header files.
Constructeurs. Les constructeurs d’un type sont des fonctions de la première catégorie,
qui agissent sur des zones brutes. Dans ce cours, leur identificateur commence par « init_ ».
Les fonctions de la seconde catégorie ont des identificateurs commençant par « set_ ».
pour comprendre les déclarations de types et les prototypes des fonctions. D’autres directives peuvent être
nécessaires pour compiler le fichier source (math.h pour utiliser une fonction de la bibliothèque de maths,
ou stdio.h pour utiliser printf). Ces directives-là doivent figurer dans le fichier source, pas dans le fichier
d’entête.
9
La distinction entre les deux types de fonctions est importante parce que certaines struc-
tures peuvent mobiliser des ressources qu’il faut restituer au système avant qu’elles ne de-
viennent inaccessibles. C’est le cas des structures contenant des pointeurs vers des zones
allouées dynamiquement (via les fonctions système malloc ou realloc) ou des structures
contenant des pointeurs vers des descripteurs de fichiers ouverts (via fopen par exemple).
Quand une structure comporte de tels champs, les fonctions de la seconde catégorie (qui
opèrent sur des zones déjà initialisées) doivent libérer les ressources consommées avant de
changer les valeurs des pointeurs (en exécutant free sur les zones allouées dynamiquement
et fclose sur les descripteurs de fichiers ouverts). Par contre les constructeurs, qui opèrent
sur des zones brutes ne doivent surtout pas tenter de libérer les zones référencées par les
pointeurs !
10
On distingue les programmes exécutables des processus. Les programmes exécutables
sont les programmes en langage machine produits par un compilateur (gcc). Les processus
sont des programmes exécutables en cours d’exécution.
Dans un programme exécutable, les données sont réparties en deux zones : la zone text où
sont stockées les instructions en langage machine ; la zone data où sont stockées les données
du programme C (chaînes de caractères écrites en toutes lettres, tableaux initialisés).
Dans un processus, on trouve deux zones supplémentaires, qui sont créées au lancement
de l’exécutable : la pile d’exécution du processus (en Anglais, stack), qui permet de réaliser le
mécanisme des appels de fonction, et où sont stockées, en particulier, les paramètres formels
et les variables locales des fonctions ; le tas (en Anglais, heap), qui est utilisé pour l’allocation
dynamique.
Sans entrer dans les détails, à chaque appel de fonction, des zones mémoire sont réservées
sur la pile d’exécution, pour les paramètres formels et les variables locales de la fonction. À
la fin de l’exécution de la fonction, ces zones sont recyclées par le processus pour les appels
des autres fonctions.
Ce mécanisme explique pourquoi on ne peut pas allouer de la mémoire en retournant
l’adresse d’une variable locale. Le compilateur C accepte de compiler la fonction ci-dessous,
mais prévient qu’elle est plus que douteuse ! En effet, elle retourne bien l’adresse d’une zone
de n octets, mais cette zone sera réutilisée par le processus dès le prochain appel de fonction.
$ cat mauvais_malloc.c
char* mauvais_malloc (int n)
{ char zone [n];
return zone;
}
$ gcc -c mauvais_malloc.c
mauvais_malloc.c: In function ’mauvais_malloc’:
mauvais_malloc.c:3: warning: function returns address of local variable
malloc. L’appel de fonction malloc (n) retourne, en cas de succès, une zone mémoire
d’au moins n octets, allouée dans le tas. En cas d’erreur (par exemple en cas de mémoire
11
insuffisante), malloc retourne zéro. En général, le paramètre n a la forme d’une constante
numérique multipliée par la taille d’un type (voir sizeof, un peu plus bas).
free. Si p est l’adresse d’une zone allouée (dans le tas) par malloc ou par realloc, l’appel
de fonction free (p) restitue cette zone au processus. Si p vaut zéro (pointeur nul), free
(p) ne fait rien.
realloc. Si p est l’adresse d’une zone de n octets allouée par malloc ou par realloc, l’appel
de fonction realloc (p, m) retourne une zone mémoire, allouée dans le tas, d’au moins m
octets. Supposons m > n (le cas le plus fréquent). Alors, le contenu des n premiers octets
de la zone retournée est égal au contenu de la zone pointée par p. Si la nouvelle zone est
distincte de l’ancienne, alors l’ancienne est automatiquement libérée par un appel à free.
Si le pointeur p est nul, alors l’appel realloc (p, m) est équivalent à malloc (m). Si la
nouvelle taille m est nulle, alors l’appel realloc (p, m) est équivalent à free (p).
Erreurs classiques
1. oublier d’allouer de la mémoire à un pointeur (se traduit souvent par une segmentation
fault) ;
2. appliquer free à une adresse non nulle qui n’a pas été obtenue via malloc ou realloc ;
3. consulter le contenu d’une zone après qu’elle a été libérée avec free (voir le destructeur
de listes chaînées, en section 1.6.2) ;
4. appliquer free deux fois de suite sur la même zone ;
5. allouer une zone trop petite (cette erreur se traduit par un débordement de tableau ;
elle survient lorsqu’on oublie de multiplier le nombre d’éléments à allouer par la taille
des éléments du type ou, lorsqu’on se trompe sur la nature du type) ;
6. oublier de libérer des zones allouées (cette erreur se traduit par une fuite de mémoire).
L’opérateur sizeof
L’opérateur sizeof, appliqué à un type du langage C, retourne la taille, en nombre
d’octets, des variables du type 9 . Supposons que n soit un entier supérieur ou égal à 1. Alors
l’instruction suivante :
{ struct box* p;
p = (struct box*)malloc (n * sizeof (struct box));
}
affecte à p l’adresse d’une zone de taille suffisante pour recevoir un tableau de n éléments de
type struct box. Le paramètre passé à malloc devrait toujours être de la forme : un entier
multiplié par l’opérateur sizeof, appliqué à un type.
9. En pratique, c’est un peu plus compliqué que cela en raison d’un problème de gestion des alignements
de zones en mémoire.
12
L’utilitaire valgrind
Sous LINUX, l’utilitaire valgrind aide à repérer un grand nombre d’erreurs liées à la
gestion de la mémoire, et, en particulier, les fuites de mémoire (memory leaks en Anglais).
Dans l’exemple suivant, une zone pouvant recevoir 10 doubles est allouée mais jamais libérée.
/* Programme a.c */
#include <stdlib.h>
int main ()
{ double* T;
T = (double*)malloc (10 * sizeof (double));
return 0;
}
13
1.6.1 Fichier d’entête
Spécifications du type
On devrait normalement les écrire en commentaire dans le fichier d’entête.
Le type struct liste_double permet de manipuler des listes de doubles. Le champ
nbelem contient le nombre d’éléments de la liste. Le champ tete est un pointeur, vers une
liste chaînées de maillons. La liste vide est codée par nbelem = 0 et tete = 0 (c’est-à-dire
le pointeur nul). Le champ nbelem est égal au nombre de maillons.
Le type struct maillon_double permet de représenter les maillons. Le champ value
contient la valeur du maillon (un double). Le champ next est un pointeur vers le maillon
suivant. Dans le cas du dernier maillon, on a next = 0 (c’est-à-dire le pointeur nul). Les
deux phrases suivantes sont essentielles.
— Deux listes distinctes n’ont pas de maillon en commun.
— Les maillons sont alloués dynamiquement (dans le tas).
La figure 1.1 illustre le cas de deux variables locales, ou de deux paramètres formels, nommés
src et dst, de type struct liste_double.
Déclarations
On remarque la mécanisme destiné à éviter les inclusions multiples. On a séparé l’im-
plantation des prototypes des fonctions globales.
14
#if ! defined (LISTE_DOUBLE_H)
#define LISTE_DOUBLE_H 1
/**********************************************************************
* IMPLANTATION
**********************************************************************/
struct maillon_double
{ double value;
struct maillon_double* next;
};
struct liste_double
{ struct maillon_double* tete;
int nbelem;
};
/**********************************************************************
* PROTOTYPES DES FONCTIONS (TYPE ABSTRAIT)
**********************************************************************/
15
La fonction suivante modifie la liste dont l’adresse lui est passée en paramètre : elle
alloue dynamiquement un nouveau maillon, lui donne la valeur d, et le place en tête de la
liste chaînée de maillons. Le message d’erreur éventuel est imprimé sur l’erreur standard
plutôt que la sortie standard. La fonction exit arrête l’exécution du processus.
Supposons qu’au lieu d’une liste de doubles, on implante une liste de listes de doubles.
Dans ce cas, il faudrait faire attention à l’affectation de la valeur, pour éviter que deux listes
différentes aient des maillons en commun. Il faudrait alors impérativement utiliser la fonction
set_liste_double, pour éviter une fuite mémoire.
void ajouter_en_tete_liste_double (struct liste_double* L, double d)
{ struct maillon_double* nouveau;
courant = L->tete;
for (i = 0; i < L->nbelem; i++)
{ suivant = courant->next;
free (courant);
courant = suivant;
}
}
Une question s’est posée lors d’une séance de travaux pratiques : ne faudrait-il pas appeler
la fonction init_liste_double à la fin du destructeur pour remettre les champs à zéro ?
Réponse en deux parties :
16
1. remettre les champs à zéro est une bonne idée, qui peut aider à révéler rapidement
les bugs du programme utilisateur du module, mais ce n’est pas obligatoire (il ne faut
pas confondre le destructeur de listes avec une fonction qui affecterait la liste vide
à L) ;
2. si on choisit de remettre les champs à zéro, il vaut mieux le faire directement dans
la fonction qu’en appelant le constructeur, puisque, en principe, un utilisateur qui
souhaiterait se servir d’une variable après appel au destructeur, est censé appliquer
explicitement un constructeur à cette variable (voir fonction set_liste_double).
La fonction suivante est une fonction locale du module liste_double. Elle est utilisée
par set_liste_double. Elle renverse l’ordre des éléments de la liste dont l’adresse lui est
passée en paramètre (si avant l’appel, L = [a1 , a2 , . . . , an ] alors, après l’appel, on a L =
[an , . . . , a2 , a1 ]).
if (L->nbelem >= 2)
{ courant = L->tete;
suivant = courant->next;
courant->next = (struct maillon_double*)0;
for (i = 1; i < L->nbelem; i++)
{ precedent = courant;
courant = suivant;
suivant = suivant->next;
courant->next = precedent;
}
L->tete = courant;
}
}
La fonction suivante est la plus difficile à écrire. Voir [1, section 6.5]. Elle affecte une
copie de src à dst. On remarque que la fonction ne peut pas se permettre d’affecter tout
simplement src à dst, parce que les deux listes auraient alors des maillons en commun.
La fonction n’est pas un constructeur : la liste dst est supposée initialisée. Elle contient
donc, en général, des maillons. Pour ne pas perdre de mémoire allouée dynamiquement, on
choisit une solution simple : on commence par appliquer le destructeur sur dst puis, on
réinitialise la liste.
On prévoit la possibilité que src et dst soient la même liste (dans ce cas, on ne fait rien).
Pour la recopie, on utilise la fonction d’ajout en tête, ce qui a pour effet d’obtenir une
copie . . . mais à l’envers. On appelle ensuite retourner_liste_double pour remettre les
éléments à l’endroit.
17
void set_liste_double (struct liste_double* dst, struct liste_double* src)
{ struct maillon_double* M;
int i;
if (dst != src)
{ clear_liste_double (dst);
init_liste_double (dst);
M = src->tete;
for (i = 0; i < src->nbelem; i++)
{ ajouter_en_tete_liste_double (dst, M->value);
M = M->next;
}
retourner_liste_double (dst);
}
}
if (L->nbelem == 0)
{ fprintf (stderr, "erreur : liste vide\n");
exit (1);
}
tete = L->tete;
*d = tete->value; /* affectation */
L->tete = tete->next;
L->nbelem -= 1;
free (tete);
}
18
void imprimer_liste_double (struct liste_double* L)
{ struct maillon_double* M;
int i;
printf ("[");
M = L->tete;
for (i = 0; i < L->nbelem; i++)
{ if (i == 0)
printf ("%f", M->value);
else
printf (", %f", M->value);
M = M->next;
}
printf ("]\n");
}
printf ("[");
M = L->tete;
while (M != (struct maillon_double*)0)
{ if (M == L->tete)
printf ("%f", M->value);
else
printf (", %f", M->value);
M = M->next;
}
printf ("]\n");
}
19
#include "liste_double.h"
int main ()
{ struct liste_double A, B;
init_liste_double (&A);
init_liste_double (&B);
clear_liste_double (&A);
clear_liste_double (&B);
return 0;
}
1.7 Itérateurs
Pour bien séparer l’implantation du type abstrait de la structure de données struct
liste_double, il reste à fournir à l’utilisateur de la structure de données, des moyens d’ac-
céder aux doubles stockés dans les listes, sans manipuler directement la structure C. Ce
problème se résout en définissant un itérateur.
D’une façon générale, un itérateur est une structure de données I destinée à parcourir
une autre structure E représentant un ensemble de données. Écrire un itérateur présente
deux avantages :
1. masquer l’implantation de la structure de données parcourue E afin de faciliter son
évolution future ou des variantes d’implantation ;
2. écrire une fois pour toutes certains algorithmes de parcours qui peuvent être particu-
lièrement délicats.
Un exemple bien connu est l’itérateur de fichiers. L’ensemble de fonctions fopen, fgetc et
fclose permet d’énumérer tous les caractères d’un fichier texte indépendemment de l’im-
plantation des fichiers. Les programmes C qui utilisent ces fonctions sont portables sur toutes
les plateformes, alors que les implantations des fichiers peuvent être très différentes ; la re-
présentation des fichiers sous UNIX est fort compliquée. L’algorithme masqué par fgetc est
loin d’être élémentaire.
On détaille ci-dessous un itérateur pour le type struct liste_double. Voici la partie à
ajouter au fichier d’entête liste_double.h. La structure de données associée à l’itérateur
se nomme struct iterateur_liste_double. Elle comporte un pointeur liste vers la liste
à parcourir, un pointeur maillon vers le dernier maillon dont la valeur a été consultée et
un champ indice contenant l’indice de ce maillon dans la liste. Le champ maillon vaut
20
zéro (le pointeur nul) si l’itérateur est positionné « à l’extérieur » de la liste. L’indice du
premier maillon vaut zéro. L’itérateur est lui-même une structure de données. Il comporte
un constructeur et un destructeur.
struct iterateur_liste_double
{ struct liste_double* liste;
struct maillon_double* maillon;
int indice;
};
21
Les deux fonctions suivantes permettent de parcourir la liste dans les deux sens. L’un
des deux parcours est nettement plus efficace que l’autre. C’est dû à l’implantation que nous
avons choisie.
La fonction suivante fait avancer l’itérateur d’un maillon. Si le maillon courant fait bien
partie de la liste, la fonction affecte sa valeur au double dont l’adresse est dans d et retourne
true. Dans l’autre cas, elle retourne false.
bool next_iterateur_liste_double
(struct iterateur_liste_double* iter, double* d)
{
iter->indice += 1;
if (iter->indice == 0)
iter->maillon = iter->liste->tete;
else if (iter->indice < iter->liste->nbelem)
iter->maillon = iter->maillon->next;
*d = iter->maillon->value; /* affectation */
return true;
}
iter->indice -= 1;
if (iter->indice < 0 || iter->indice >= iter->liste->nbelem)
return false;
iter->maillon = iter->liste->tete;
for (i = 0; i < iter->indice; i++)
iter->maillon = iter->maillon->next;
*d = iter->maillon->value; /* affectation */
return true;
}
22
1.8 Gestion des erreurs
Dans les implantations des différents modules, en cas d’erreur, le processus s’arrête bruta-
lement (appel à exit). Cette stratégie simple est admissible si on développe un programme.
Elle est inadmissible si on développe une bibliothèque. Dans ce cas, l’information « une er-
reur s’est produite » doit systématiquement être remontée aux fonctions appelantes, sans
arrêter le processus.
Lorsqu’on traite les erreurs ainsi, il faut aussi prévoir le cas où une erreur se produit
lors de l’exécution d’un constructeur : la construction n’étant pas terminée, les champs de
la variable en cours d’initialisation ne sont peut-être pas tous dans un état cohérent : on ne
peut donc pas appliquer le destructeur sur la variable et libérer les ressources qui avaient été
allouées avec succès, avant que l’erreur ne se produise.
Une solution consiste à programmer les constructeurs pour qu’ils initialisent immédiate-
ment tous les champs des variables avec des valeurs par défaut (typiquement, affecter zéro à
tous les pointeurs) et, seulement après, effectuent les véritables initialisations. Parallèlement,
les destructeurs doivent être programmés pour reconnaître ces valeurs par défaut et ne pas
libérer des ressources qui n’ont pas été initialisées.
La mise en œuvre de la solution décrite ci-dessus peut demander du soin, dans le cas de
structures de données complexes dont les champs doivent eux-mêmes être initialisés par des
constructeurs.
23
Bibliographie
[1] Jacquelin Charbonnel. Langage C++. Les spécifications du standard ANSI/ISO expli-
quées. InterEditions, Paris, 1997. Deuxième édition.
24
Chapitre 2
Énumération de données
De même, il serait possible de réaliser une pile d’element avec le type abstrait minimaliste
suivant. La première fonction permet de vider la pile P . La deuxième permet de tester si P
est vide. La troisième empile e dans P (au sommet de P ). La quatrième dépile l’élément le
plus récent de P (au sommet de P ) et le retourne.
25
void vider (struct pile* P);
bool est_vide (struct pile* P);
void empiler (struct pile* P, element e);
element depiler (struct pile* P);
Pour des raisons de confort, on implante généralement un peu plus de fonctions. Pour per-
mettre l’utilisation simultanée de piles et de files d’éléments de types différents, on peut faire
apparaître le type des éléments dans les identificateurs de fonctions.
Les prototypes suivants permettraient d’implanter un module de files de doubles. On
remarque la présence d’un constructeur et d’un destructeur ainsi que de quelques fonctions
supplémentaires comme longueur_file_double, qui retourne le nombre d’éléments présents
dans la file (cette fonction pourrait se reprogrammer avec les quatre fonctions mentionnées
plus haut) et est_pleine_file_double, qui teste s’il est encore possible d’enfiler un double
(cette fonction est nécessaire pour toute implantation réaliste). Enfin, on a tourné un peu
différemment la fonction qui défile un double.
void init_file_double (struct file_double*);
void vider_file_double (struct file_double*);
void clear_file_double (struct file_double*);
int longueur_file_double (struct file_double*);
bool est_vide_file_double (struct file_double*);
bool est_pleine_file_double (struct file_double*);
void enfiler_double (struct file_double*, double);
void defiler_double (double*, struct file_double*);
void imprimer_file_double (struct file_double*);
26
#define SIZE_PILE_DOUBLE 1000
struct pile_double
{ int sp; /* l’indice du sommet de pile */
double tab [SIZE_PILE_DOUBLE]; /* la zone de stockage */
};
On obtient une implantation assez simple d’une file avec un tableau tab de dimension N
et deux indices r et w. L’indice r (champ read_end ci-dessous) désigne l’extrémité en lecture
de la file (c’est l’indice du prochain élément à être défilé). L’indice w (champ write_end
ci-dessous) désigne l’extrémité en écriture de la file (c’est l’indice du dernier élément à avoir
été enfilé). L’astuce consiste à faire avancer les indices « circulairement » sur le tableau en
les incrémentant modulo N : si un élément e doit être enfilé, on calcule la nouvelle valeur
de w par la formule w = (w + 1) mod N et on range e dans tab à l’indice w ; si un élément
doit être défilé et rangé dans une variable e, on affecte à e l’élément de tab situé à l’indice r
et on calcule la nouvelle valeur de r par r = (r + 1) mod N . On peut initialiser la file avec
r = 1 et w = 0. Attention au fait que la file ainsi décrite a une capacité maximale de N − 1
éléments (et pas N car, sans information supplémentaire, rien ne permet de distinguer la file
vide de la file à N éléments). Dans la structure de données ci-dessous, on a résolu ce dernier
problème en mémorisant le nombre d’éléments enfilés dans un champ de la structure.
#define SIZE_FILE_DOUBLE 10000
struct file_double
{ int n; /* le nombre de doubles dans la file */
int read_end; /* indice du prochain élément défilé */
int write_end; /* indice du dernier élément enfilé */
double tab [SIZE_FILE_DOUBLE]; /* la zone de stockage */
};
Principales fonctions
Le constructeur est très simple. Supposons qu’au lieu d’une pile de doubles, on réalise
une pile de liste de doubles. Il faudrait alors appliquer le constructeur de listes de doubles
sur chaque emplacement du tableau tab.
void init_pile_double (struct pile_double* P)
{
P->sp = -1;
}
Le destructeur est vide. Supposons qu’au lieu d’une pile de doubles, on réalise une pile de
liste de doubles. Il faudrait alors appliquer le destructeur de listes de doubles sur chaque
emplacement du tableau tab.
void clear_pile_double (struct pile_double* P)
{
}
27
La fonction suivante est sans difficulté.
bool est_pleine_pile_double (struct pile_double* P)
{
return P->sp == SIZE_PILE_DOUBLE-1;
}
La fonction suivante empile un double. Les macros __FILE__ et __LINE__ sont remplacés par
le nom du fichier et le numéro de ligne courants, pour mieux localiser les erreurs. Supposons
qu’au lieu d’une pile de doubles, on réalise une pile de liste de doubles. Il faudrait alors
utiliser la fonction set_liste_double pour réaliser l’affectation.
void empiler_double (struct pile_double* P, double d)
{
if (est_pleine_pile_double (P))
{ fprintf (stderr, "%s:%d\n", __FILE__, __LINE__);
exit (1);
}
P->sp += 1;
P->tab [P->sp] = d; /* affectation */
}
Principales fonctions
Le constructeur initialise la pile avec le tableau vide.
void init_pile_double (struct pile_double* P)
{
P->alloc = 0;
P->sp = -1;
P->tab = (double*)0;
}
28
Le destructeur. Supposons qu’au lieu d’une pile de doubles, on réalise une pile de liste de
doubles. Il faudrait alors appliquer le destructeur de listes de doubles sur tous les emplace-
ments du tableau, avant de libérer la zone avec free.
void clear_pile_double (struct pile_double* P)
{
free (P->tab);
}
La spécification de la fonction suivante a légèrement évolué 1 . C’est elle qui est chargée
du redimensionnement du tableau. Elle utilise la fonction realloc. Supposons qu’au lieu
d’une pile de doubles, on réalise une pile de liste de doubles. Il faudrait alors appliquer le
constructeur de listes de doubles sur tous les emplacements de newtab situés entre les indices
P->alloc et newalloc − 1.
bool est_pleine_pile_double (struct pile_double* P)
{ int newalloc;
double* newtab;
bool b;
La fonction suivante n’a pas vraiment changé. La fonction assert teste si la condition qui lui
est passée en paramètre est vraie. Si elle ne l’est pas, elle arrête le processus en imprimant le
nom du fichier et le numéro de ligne où l’erreur s’est produite. Pour l’utiliser, il est nécessaire
d’inclure le fichier assert.h. On peut désactiver cette fonction en passant l’option -DNDEBUG
au compilateur.
Supposons qu’au lieu d’une pile de doubles, on réalise une pile de liste de doubles. Il
faudrait alors utiliser la fonction set_liste_double pour réaliser l’affectation.
1. Ce qu’on ne devrait pas faire si on appliquait dogmatiquement nos théories.
29
void empiler_double (struct pile_double* P, double d)
{
assert (! est_pleine_pile_double (P));
P->sp += 1;
P->tab [P->sp] = d; /* affectation */
}
Comment déterminer si un élément est plus prioritaire qu’un autre ? Pour atteindre un
certain degré de généralité, on paramètre une file avec priorité de struct element avec une
fonction du type suivant :
typedef bool fonction_de_priorite (struct element*, struct element*);
qui retourne true si son premier paramètre est plus prioritaire que le second, false sinon.
Il est possible de réaliser une file avec priorité de struct element avec le type abstrait
minimaliste suivant. Lors des opérations de file, le champ indice des éléments présents dans
la file sera modifié. Pour cette raison, il est préférable de supposer que le tableau contient
les adresses des éléments enfilés et pas une copie de ces éléments.
La fonction initialiser vide la file F et reçoit l’adresse de la fonction de priorité
à utiliser. Les fonctions longueur, enfiler et defiler se passent de commentaires. La
30
fonction changement_priorite permet de mettre à jour la position d’un élément dont on
connaît l’indice dans le tableau, après que son degré de priorité a changé.
void initialiser
(struct file_priorite*, fonction_de_priorite* est_prioritaire);
int longueur (struct file_priorite*);
void enfiler (struct file_priorite*, struct element*);
struct element* defiler (struct file_priorite*);
void changement_priorite (int indice, struct file_priorite*);
La fonction changement_priorite ne semble pas très naturelle mais elle répond à un vrai
besoin, qui apparaît dans plusieurs applications importantes des files avec priorité. C’est le
cas de l’algorithme de Dijkstra, en théorie des graphes [2, section 5.2.3], où un sommet a est
plus prioritaire qu’un sommet b si le potentiel de a est inférieur à celui de b ; les potentiels
des sommets sont régulièrement modifiés au cours de l’algorithme. C’est la gestion de ces
changements de priorité qui motive la gestion du champ indice dans les éléments enfilés.
Ti est plus prioritaire que chacun de ses fils, pour tout 0 ≤ i < n.
On ne maintient aucun lien de priorité entre les deux fils d’un emplacement. Plus précisé-
ment, on s’interdit de maintenir le moindre lien de priorité entre les deux fils. L’efficacité de
la structure est une conséquence de cette interdiction. Un exemple est donné en figure 2.1.
Les deux sous-algorithmes importants sont ceux qui permettent de mettre à jour la posi-
tion d’un élément dans la file, suite à une augmentation ou une diminution de son degré de
31
function augmentation_priorité (i)
La priorité de l’élément Ti a été augmentée. Il faut le faire progresser dans la file
begin
elt = T [i]
fils = i
pere = b(fils − 1)/2c
Invariant : l’emplacement du fils est libre
while fils > 0 et elt est plus prioritaire que T [pere] do
L’emplacement libre remonte au niveau du père
T [fils] = T [pere]
fils = pere
pere = b(fils − 1)/2c
end do
T [fils] = elt
end
priorité. Ils sont donnés figures 2.2, page 32 (pour l’augmentation) et 2.3, page 33 (pour la
diminution).
Pour enfiler un nouvel élément e dans le tas, il suffit d’incrémenter n, de ranger e en
Tn−1 et d’appeler la fonction de la figure 2.2 (augmentation de priorité) avec n − 1 pour
paramètre. L’élément le plus prioritaire est nécessairement en T0 . Lorsque cet élément est
défilé, un emplacement libre se crèe en T0 . Il suffit alors de recopier le dernier élément du
tas, Tn−1 , dans l’emplacement libre T0 , puis d’appeler la fonction de la figure 2.3 (diminution
de priorité) avec 0 pour paramètre. Enfin, lors d’un changement de priorité à l’indice i, il
suffit d’appeler la fonction de la figure 2.2 (on fait le pari qu’il s’agit d’une augmentation de
priorité) avec i pour paramètre. Si l’élément n’a pas été déplacé par la fonction, on appelle
la fonction de la figure 2.3 (au cas où il s’agirait d’une diminution).
2.2.2 Implantation
On précise une implantation d’une file avec priorité d’éléments de type struct element*.
On rappelle que ce sont les adresses des éléments qui sont mis dans la file, pas des copies des
éléments. En effet, pour simplifier la gestion du champ indice des éléments, on veut éviter
que les éléments soient dupliqués. Le champ n contient le nombre d’éléments présents dans
la file. La zone de stockage est implantée sous la forme d’un tableau de taille fixe. L’adresse
de la fonction de priorité à utiliser est stockée dans la structure C.
32
function diminution_priorite (i)
La priorité de l’élément Ti a été diminuée. Il faut le faire reculer dans la file
On note n le nombre d’éléments présents dans la file
begin
elt = T [i]
pere = i
gauche = 2 pere + 1
droit = gauche + 1
Invariant : l’emplacement du père est libre
do
fini = vrai
if gauche < n then
if droit < n then
max = l’indice du plus prioritaire des deux fils
else
max = gauche
end if
if T [max] est plus prioritaire que elt then
Le plus prioritaire des fils remonte au niveau du père et l’emplacement libre descend
T [pere] = T [max]
pere = max
gauche = 2 pere + 1
droit = gauche + 1
fini = faux
end if
end if
while non fini
T [pere] = elt
end
33
Principales fonctions
Les deux sous-algorithmes sont implantés sous la forme de fonctions locales. Ce sont
des variantes proches des versions idéalisées des figures 2.2 et 2.3. On commence par le
constructeur :
void init_file_priorite_element
(struct file_priorite_element* F, fonction_de_priorite* fonction)
{
F->n = 0;
F->est_prioritaire = fonction;
}
Dans la fonction suivante, l’élément X est extérieur à la file. Un emplacement vide a été créé
en tab [pos]. Cet emplacement vide, qui va recevoir X en fin de boucle, est éventuellement
remonté dans la file. La fonction retourne true si l’emplacement a été remonté au moins une
fois. La fonction prioritaire est utilisée pour comparer les degrés de priorité des éléments
avec X. Chaque fois qu’un élément est déplacé dans le tableau, son champ indice est mis-
à-jour.
static bool augmentation_priorite_element
(int pos, struct element* X, struct file_priorite_element* F)
{ int fils, pere;
bool augmentation_effective;
augmentation_effective = false;
fils = pos;
pere = (fils - 1) / 2;
/* Invariant : l’emplacement vide est au niveau du fils */
while (fils > 0 && (*F->est_prioritaire) (X, F->tab [pere]))
{
/* On remonte l’emplacement vide au niveau du père */
F->tab [fils] = F->tab [pere];
F->tab [fils]->indice = fils;
fils = pere;
pere = (fils - 1) / 2;
augmentation_effective = true;
}
F->tab [fils] = X;
F->tab [fils]->indice = fils;
return augmentation_effective;
}
34
static bool diminution_priorite_element
(int pos, struct element* X, struct file_priorite_element* F)
{ int pere, gauche, droit, max;
bool fini, diminution_effective;
diminution_effective = false;
pere = pos;
gauche = 2*pos + 1;
droit = gauche + 1;
/* Invariant : l’emplacement vide est au niveau du père */
do
{ fini = true;
if (gauche < F->n)
{ if (droit < F->n)
{ if ((*F->est_prioritaire) (F->tab [gauche], F->tab [droit]))
max = gauche;
else
max = droit;
} else
max = gauche;
if ((*F->est_prioritaire) (F->tab [max], X))
{
/*
* On descend l’emplacement vide au niveau du fils le plus prioritaire.
* On remonte le fils le plus prioritaire au niveau du père
*/
F->tab [pere] = F->tab [max];
F->tab [pere]->indice = pere;
pere = max;
gauche = 2*pere + 1;
droit = gauche + 1;
diminution_effective = true;
fini = false;
}
}
} while (!fini);
F->tab [pere] =X;
F->tab [pere]->indice = pere;
return diminution_effective;
}
35
void enfiler_priorite_element
(struct file_priorite_element* F, struct element* X)
{
assert (F->n < SIZE_FILE_PRIORITE_ELEMENT);
F->n += 1;
augmentation_priorite_element (F->n - 1, X, F);
}
La priorité de l’élément tab [pos] a été modifiée, mais on ne sait pas s’il s’agit d’une
augmentation ou d’une diminution de priorité. On fait le pari d’une augmentation de priorité
et on se sert du booléen retourné pour vérifier si on a raison ou tort, et appeler, le cas échéant,
la fonction qui gère les diminutions de priorité.
void changement_priorite_element (int pos, struct file_priorite_element* F)
{ struct element* X;
36
Bibliographie
[1] Thomas Cormen, Charles Leiserson, Ronald Rivest, and Clifford Stein. Introduction à
l’algorithmique. Dunod, Paris, 2ème edition, 2002.
[2] Clarisse Dhaenens. Graphes et Combinatoire. Support du cours de IS 3, Polytech Lille,
2010.
37
Chapitre 3
Étudier la complexité d’un algorithme, c’est étudier son efficacité lorsque la taille de sa
donnée tend vers l’infini. Dans le cadre de ce cours, on se concentre sur l’efficacité en temps
de calcul. Dans certains problèmes, il est utile d’étudier l’efficacité en taille mémoire.
Pour pouvoir mener l’étude de la complexité d’un algorithme, on est toujours obligé
d’abstraire le problème, c’est-à-dire d’étudier un problème mathématique un peu arbitraire,
dont on espère qu’il reflète les points essentiels du comportement du vrai algorithme.
Une approximation courante consiste à mesurer la taille de la donnée d’un algorithme
par une unique variable n. Dans le cas d’un algorithme de tri, n pourrait désigner le nombre
d’éléments du tableau à trier ; dans le cas de l’algorithme d’Euclide, n pourrait être le nombre
de bits du plus grand des deux entiers dont on cherche le pgcd. Cette approximation n’est
pas toujours souhaitable : en théorie des graphes, il est courant de mesurer la taille de la
donnée avec deux variables : le nombre de sommets et le nombre d’arcs.
Une autre approximation courante consiste à ne mesurer l’efficacité que selon un seul
critère. Dans le cas d’un algorithme de tri, on peut ne s’intéresser qu’au nombre de com-
paraisons d’éléments ; dans le cas de l’algorithme d’Euclide, on peut ne s’intéresser qu’au
nombre d’opérations arithmétiques. Ici aussi, l’approximation effectuée peut masquer des
phénomènes importants : deux multiplications d’entiers peuvent prendre des temps de calcul
très différents suivant que les entiers comportent un grand nombre de chiffres ou pas.
En résumé, dans ce cours, la complexité d’un algorithme est une fonction f : N → N,
qui à un entier n, représentant la taille de sa donnée, associe un nombre f (n) d’opérations
considéré comme représentatif du comportement de l’algorithme.
Pour une taille de la donnée, n, fixée, l’algorithme à étudier peut se comporter très
différemment suivant la configuration de la donnée. Le cas le plus couramment étudié est le
« pire des cas » (précisé ci-dessous) mais on peut s’intéresser aussi au « meilleur des cas »
ou même à la complexité « en moyenne ».
Le pire des cas s’obtient en considérant, pour chaque valeur de n, la configuration de
la donnée où le nombre d’opérations effectuées est maximal. La complexité du pire des cas
est donc, par définition, la fonction f qui à n, associe ce nombre maximal d’opérations. De
même, le meilleur des cas s’obtient en considérant, pour chaque valeur de n, la configuration
38
de la donnée où le nombre d’opérations effectuées est minimal. La complexité du meilleur des
cas est donc la fonction f qui à n, associe ce nombre minimal d’opérations. Voir la Figure 3.1
en bas à droite pour une illustration graphique.
Prenons l’exemple d’un algorithme, paramétré par un élément et un tableau, qui cherche
si l’élément appartient au tableau, par une recherche exhaustive. L’entier n est égal au nombre
d’éléments du tableau. La fonction f (n) est égale au nombre de comparaisons d’éléments.
Le meilleur des cas est celui où l’élément recherché est trouvé tout de suite (on a f (n) = 1).
Le pire des cas est celui où l’élément n’appartient pas au tableau (on a f (n) = n).
Le cas moyen suppose qu’on fasse quelques hypothèses. Par exemple, on peut supposer
que l’élément appartient au tableau et qu’il a la probabilité 1/n de se trouver dans chacune
des n cases. l’algorithme va trouver l’élément en 1 comparaison avec une probabilité 1/n,
en 2 comparaisons avec une probabilité 1/n, en 3 comparaisons avec une probabilité 1/n,
etc. Le nombre de comparaisons devient alors une variable aléatoire. Le nombre f (n) est
l’espérance de cette variable :
1 n (n + 1) n+1
f (n) = = ·
n 2 2
Dans ce cours, on ne s’intéressera qu’à la complexité dans le pire des cas, sauf lorsqu’on
s’intéressera aux tables de hachage, où la seule complexité vraiment intéressante est une
complexité en moyenne.
Définition 1 On dit que f (n) ∈ O(g(n)) s’il existe deux constantes c > 0 et n0 ≥ 0 telles
que, quel que soit n ≥ n0 on ait f (n) ≤ c g(n). La fonction g(n) est appelée un majorant
asymptotique de f (n).
39
Définition 2 On dit que f (n) ∈ Ω(g(n)) s’il existe deux constantes c > 0 et n0 ≥ 0 telles
que, quel que soit n ≥ n0 on ait f (n) ≥ c g(n). La fonction g(n) est appelée un minorant
asymptotique de f (n).
Le graphique en haut à droite de la Figure 3.1 illustre f (n) ∈ O(log n) (la fonction f (n)
est minorée asymptotiquement par log n). On peut remarquer qu’il est inutile de préciser la
base 1 b du logarithme considéré (la constante ln b est en quelque sorte « incorporée » à la
constante c de la définition).
Définition 3 On dit que f (n) ∈ Θ(g(n)) s’il existe trois constantes c1 , c2 > 0 et n0 ≥ 0
telles que, quel que soit n ≥ n0 on ait c1 g(n) ≤ f (n) ≤ c2 g(n). La fonction g(n) est appelée
un équivalent asymptotique de f (n).
Question 5. Deux algorithmes ont des complexités respectives en Θ(n) et en Θ(n2 ). Peut-
on affirmer que le premier est toujours plus rapide que le second ?
40
Figure 3.1 – Illustration graphique des notations asymptotiques et de la notion de cas.
La fonction f (n) est la complexité d’un certain algorithme. Son graphe devrait être tracé
en pointillé puisqu’il s’agit normalement d’une fonction N → N. On l’a représenté par une
courbe continue pour des raisons de lisibilité. Graphique en bas à droite : on a fixé un
algorithme. Pour chaque valeur de n on a compté le nombre d’opérations effectuées pour
toutes les configurations possibles de la donnée et on a reporté un point par valeur obtenue.
41
On commence par s’intéresser à la boucle intérieure. Dans cette boucle, l’indice i est fixé.
On note s(i) le nombre d’opérations arithmétiques effectuées sur des doubles, dans cette
boucle. On a s(i) = 3 i. On s’intéresse ensuite à la boucle extérieure et on exprime f (n) en
fonction de s(i). On a :
Supposons qu’on ne reconnaisse pas la somme ci-dessus. On peut alors définir f (n) comme
la solution d’une relation de récurrence avec condition initiale :
f (n) = f (n − 1) + 3 n , f (0) = 0 .
Dans les deux cas, on a f (n) ∈ Θ(n2 ), ce qui nous permet d’affirmer que le temps de calcul
de l’algorithme croît avec le carré du degré du polynôme.
42
démarche qui peut servir sur d’autres exemples. On commence par définir f (n) par une rela-
tion de récurrence. On s’aperçoit qu’elle est trop compliquée pour être résolue directement.
On cherche alors une relation de récurrence proche mais plus simple. La solution de cette
relation plus simple nous donne une forme présumée pour f (n) dépendant de constantes. On
ajuste les valeurs des constantes avec l’algorithme d’estimation de paramètres de gnuplot et
un fichier de mesures obtenu en simulant f (n) numériquement. Le résultat est convaincant
graphiquement et il est plus facile de terminer l’analyse.
Le problème initial
On considère la fonction suivante, qui retourne true si elt appartient au tableau T,
entre les indices deb et fin − 1. Le tableau est supposé trié par ordre croissant. La fonction
applique l’algorithme de la recherche dichotomique. Elle est tournée récursivement.
43
bool appartient (double* T, int deb, int fin, double elt)
{ int a, b, m;
bool trouve;
a = deb;
b = fin;
trouve = false;
while (a < b && !trouve)
{ m = (a + b) / 2;
if (elt < T [m])
b = m;
else if (elt > T [m])
a = m+1;
else
trouve = true;
}
return trouve;
}
Simplification du problème
On s’intéresse au nombre f (n) de comparaisons de doubles effectuées par l’algorithme,
dans le cas où elt n’appartient pas au tableau. Ici, n = fin − deb représente le nombre
d’éléments du tableau. À chaque appel récursif, on passe de n éléments à bn/2c (où bn/2c
désigne la partie entière de n/2) ; l’algorithme s’arrête lorsque n = 0. On a donc envie d’écrire
f (n) = f (bn/2c) + 2 , f (0) = 0 .
Malheureusement, MAPLE ne parvient pas à résoudre la récurrence :
> syst := { f(n) = f(floor(n/2)) + 2, f(0) = 0 };
syst := {f(0) = 0, f(n) = f(floor(n/2)) + 2}
> rsolve (syst, f(n));
rsolve({f(0) = 0, f(n) = f(floor(n/2)) + 2}, f(n))
On est alors tenté de simplifier la relation de récurrence en supprimant cavalièrement le
calcul de partie entière. Ce n’est pas suffisant, MAPLE ne parvient toujours pas à résoudre
la récurrence, en raison d’un problème lié à la condition initiale :
> syst := { f(n) = f(n/2) + 2, f(0) = 0 };
syst := {f(0) = 0, f(n) = f(n/2) + 2}
> rsolve (syst, f(n));
Error, (in rsolve/dc) initial conditions are inconsistent with the recurrence
On soupçonne MAPLE de procéder au changement de variable p = log2 (n) et de ren-
contrer un problème avec la fonction logarithme, en n = 0. On modifie donc légèrement la
condition initiale. Cette nouvelle condition initiale apparaîtrait d’ailleurs naturellement si
on changeait très légèrement la condition d’arrêt de la fonction appartient. Cette fois-ci, le
logiciel trouve la solution : f (n) = 2 log2 (n) + 1.
44
> syst := { f(n) = f(n/2) + 2, f(1) = 1 };
syst := {f(1) = 1, f(n) = f(n/2) + 2}
> rsolve (syst, f(n));
ln(2) + 2 ln(n)
---------------
ln(2)
int f (int n)
{
if (n == 0)
return 0;
else
return f (n/2) + 2;
}
int main ()
{ FILE* fichier;
int n;
fichier = fopen ("stats", "w");
if (fichier == (FILE*)0)
{ fprintf (stderr, "erreur fopen\n");
exit (1);
}
for (n = 1; n < 1000; n++)
fprintf (fichier, "%d\t%d\n", n, f(n));
fclose (fichier);
return 0;
}
L’idée consiste alors à chercher une fonction f (n) de la forme suggerée par l’analyse du cas
simplifié, c’est-à-dire une fonction de la forme a log2 (n) + b, où a et b sont deux paramètres
2. Sur cet exemple-ci, le fichier de mesures est donné par une fonction C qui évalue la fonction f (n) à
partir de son équation de récurrence. Sur d’autres exemples, on peut obtenir le fichier de mesures en modifiant
le programme à analyser et en lui faisant compter les opérations de comparaison.
45
qui restent à estimer. On utiliser pour cela la fonction fit 3 de gnuplot. On trouve une
approximation f (n) ' 1.91 log2 (n) + 1.6 assez proche du 2 log2 (n) obtenu en résolvant la
récurrence de la version simplifiée.
Figure 3.2 – La fonction f (n) expérimentale et celle obtenue par estimation de paramètres.
Les graphiques, donnés figure 3.2, suggèrent bien un « comportement logarithmique ». Mais
peut-on affirmer que f (n) ∈ Θ(log(n)) ? Pour s’en assurer, on extrait du fichier "stats", les
mesures qui correspondent aux angles des marches. Pour les angles inférieurs, on trouve les
lignes suivantes
3. La fonction fit implante un algorithme de moindres carrés non linéaires : la méthode de Levenberg-
Marquardt, qui fait partie de la famille des méthodes de Newton. Cela signifie que l’expression vis-à-vis de
laquelle l’estimation est faite pourrait dépendre non linéairement des paramètres à estimer (les paramètres
pourraient figurer en exposant, par exemple). Dans notre cas, l’expression dépend linéairement des paramètres
à estimer et on aurait pu utiliser des moindres carrés linéaires.
46
# n finf(n)
1 2
3 4
7 6
15 8
31 10
63 12
127 14
255 16
511 18
La récurrence se résout facilement 4 et on trouve finf (n) = 2 log2 (n + 1). Pour les angles
supérieurs, on trouve les lignes suivantes
# n fsup(n)
1 2
2 4
4 6
8 8
16 10
32 12
64 14
128 16
256 18
512 20
La récurrence se résout encore plus facilement et on trouve fsup (n) = 2 log2 (n) + 2. Voir les
courbes figure 3.3. Effectuons deux calculs de limites avec MAPLE :
> finf := 2*log[2](n+1);
2 ln(n + 1)
finf := -----------
ln(2)
> fsup := 2*log[2](n) + 2;
2 ln(n)
fsup := ------- + 2
ln(2)
> limit (finf/log[2](n), n = infinity);
2
> limit (fsup/log[2](n), n = infinity);
2
4. Ajouter une colonne imaginaire p commençant à 0, incrémentée de 1 en 1. Exprimer n et finf (n) en
fonction de p. On trouve n(p) = 2p+1 − 1 et finf (p) = 2 p + 2. Il ne reste plus qu’à tirer p en fonction de n
dans la première formule et à reporter le résultat dans la seconde.
47
Les limites calculées nous suggèrent des valeurs pour les constantes. Prenons 0 < cinf < 2.
Par exemple cinf = 1. On a
f (n) finf (n)
≥ > cinf
log2 (n) log2 (n)
quand n tend vers plus l’infini. Par conséquent, f (n) ∈ Ω(log(n)). Similairement, prenons
csup > 2. Par exemple csup = 3. On a
quand n tend vers plus l’infini. Par conséquent, f (n) ∈ O(log(n)) et donc f (n) ∈ Θ(log(n)).
Figure 3.3 – La fonction f (n) est encadrée par les fonctions finf (n) et fsup (n).
Conclusion
On a trouvé que l’algorithme de la recherche dichotomique a une complexité en temps,
dans le pire des cas, en Θ(log(n)). Pourtant, f (n) est en fait une fonction constante par
morceaux et on voit que, dans l’ensemble des fonctions appartenant à Θ(log(n)), on peut
trouver des fonctions aux comportements très différents.
On a obtenu un équivalent asymptotique de la fonction f (n) du pire des cas. On a donc
une borne asymptotique de la fonction f (n) dans tous les cas : l’algorithme de la recherche
dichotomique a toujours une complexité en temps en O(log(n)).
48
3.2.3 La suite de Fibonacci
La suite de Fibonacci est définie par :
F (0) = F (1) = 1 , F (n + 2) = F (n + 1) + F (n) . (3.1)
Cette suite est un exemple de relation de récurrence linéaire à coefficients constants (elle
est linéaire parce que F (n + 2) dépend linéairement de F (n + 1) et de F (n)). La forme
générale des solutions des relations de récurrence linéaires à coefficients constants est bien
connue (voir la section suivante) : il faut s’attendre, dans le cas général, à une combinaison
linéaire d’exponentielles (la variable n figurant en exposant). Le nombre d’exponentielles de
la combinaison linéaire est, en général, égal à l’ordre de la suite. Ici, c’est 2. On devrait donc
avoir
F (n) = a λn + b µn
où les paramètres a, b, λ et µ restent à déterminer. Vérification avec MAPLE :
syst := { F(n) = F(n-1) + F(n-2), F(0) = 1, F(1) = 1};
syst := {F(0) = 1, F(1) = 1, F(n) = F(n - 1) + F(n - 2)}
49
Les conditions initiales ont changé et une constante supplémentaire est apparue dans la
relation de récurrence. En fait, ces différences sont sans importance si on s’intéresse au
comportement asymptotique des fonctions (voir la section suivante). Pour une justification
calculatoire, on peut utiliser MAPLE :
> syst := { f(n) = f(n-1) + f(n-2) + 1, f(0) = 0, f(1) = 0};
syst := {f(0) = 0, f(1) = 0, f(n) = f(n - 1) + f(n - 2) + 1}
On trouve une suite différente, bien sûr, mais le comportement asymptotique n’a pas changé.
Pour conclure cette section, on peut remarquer que, comme on connaît la forme de la fonction,
f (n) ' a λn + b µn
50
gnuplot> fit a*lambda**x+b*mu**x "stats" via a,b,lambda,mu
f (n) + a1 f (n − 1) + a2 f (n − 2) + · · · + at f (n − t) = 0
est une combinaison linéaire de termes de la forme nj β n où β est une racine du polynôme
caractéristique
C(z) = z t + a1 z t+1 + a2 z t+2 + · · · + at
et où l’exposant j est strictement inférieur à la multiplicité de la racine β de C(z). Les
coefficients dépendent des conditions initiales f (0), f (1), . . . , f (t − 1).
Reprenons l’exemple de la suite de Fibonacci (3.1). Son polynôme caractéristique, C(z) =
2
z − z − 1, a deux racines simples √
1± 5
z= ·
2
Comme les racines sont simples, c’est-à-dire de multiplicité 1, les exposants j mentionnés
dans le théorème sont nuls et toute solution est de la forme
√ !n √ !n
1+ 5 1− 5
F (n) = a +b · (3.3)
2 2
Les constantes a et b dépendent des conditions initiales. Rappelons que F (0) = F (1) = 1.
Les constantes sont donc solutions du système d’équations linéaires :
√ √
1+ 5 1− 5
F (0) = a + b = 1 , F (1) = a +b = 1.
2 2
On trouve √ √
5+ 5 5− 5
a= , b= ·
10 10
51
Relations non homogènes. La relation de récurrence considérée dans le théorème 1 est
une relation « homogène », c’est-à-dire que son membre droit est égal à zéro. Pour résoudre
une relation de récurrence linéaire à coefficients constants, non homogène, c’est-à-dire avec
un membre droit non nul,
il suffit de résoudre l’équation homogène associée (en oubliant le membre droit) et d’ajouter
une solution particulière à l’expression obtenue. Reprenons l’exemple de la suite (3.2). La
relation homogène associée est celle de la suite de Fibonacci. Sa solution générale est de
la forme (3.3). Une solution particulière de la relation non homogène est f (n) = −1. La
solution générale de la relation non homogène est donc
√ !n √ !n
1+ 5 1− 5
f (n) = a +b − 1. (3.4)
2 2
n(p) = a 2p − 1 .
52
Récurrence du type « diviser pour régner ». Soit à résoudre une récurrence de la
forme suivante, qui apparaît naturellement dans des méthodes telles que la méthode dicho-
tomique, ou, plus généralement, dans toutes les méthodes du type « diviser pour régner ».
n
f (n) = f + 1 , f (1) = 1 .
2
L’idée consiste à faire l’hypothèse que n est une puissance de deux (mettons n = 2p ) et à
poser g(p) = f (2p ) = f (n). En effet, on a alors g(p − 1) = f (n/2) et, dans les nouvelles
variables, on a affaire à une récurrence classique :
Sur l’exemple, la solution est évidente g(p) = p + 1. Comme n = 2p , on a p = log2 (n) et,
dans les anciennes variables, la solution s’écrit :
53
Bibliographie
[1] Thomas Cormen, Charles Leiserson, Ronald Rivest, and Clifford Stein. Introduction à
l’algorithmique. Dunod, Paris, 2ème edition, 2002.
[2] Donald Erwin Knuth. Big Omicron and big Omega and big Theta. In SIGACT News,
pages 18–24, April-June 1976.
[3] Robert Sedgewick and Philippe Flajolet. An Introduction to the Analysis of Algorithms.
Addison Wesley, 1996.
54
Chapitre 4
Recherche de données
Dans ce chapitre, on étudie des structures de données qui permettent de rechercher une
donnée dans un ensemble (pour retrouver une information qui lui est associée ou tout simple-
ment déterminer si la donnée fait partie de l’ensemble). Une structure de données qui fournit
une telle fonctionnalité (avec l’ajout d’un élément à l’ensemble et, parfois, la suppression
d’un élément de l’ensemble) constitue ce qu’on appelle un dictionnaire [1, Partie 3, page
191].
Les structures de données et les algorithmes de recherche sont illustrées avec le projet
LINKER, dont les détails sont présentés au chapitre 5. Une version abstraite est décrite dans
les figures 4.1 et 4.2. Le dictionnaire maintenu par l’algorithme du projet LINKER s’appelle
une « table des symboles ». Voir la section 5.2.1, page 83 pour une définition des symboles.
function linker_abstrait
reçoit une liste de fichiers objets et de bibliothèques sur sa ligne de commandes
begin
vider la table des symboles T
for chaque fichier listé sur la ligne de commandes do
if ce fichier est un fichier objet O then
enregistrer tous les symboles de O dans T (figure 4.2)
else (c’est donc une bibliothèque B)
do
n’enregistrer les symboles d’un fichier objet O de B dans T , que si O
fournit une définition à un symbole indéfini de T
while au moins un fichier objet de B a été incorporé dans T
end if
end do
end
55
function enregistrer (s, T )
enregistre un symbole s dans la table des symboles T = {s1 , . . . , sm }
begin
if ∃ i tel que si et s ont même identificateur then
if s est un symbole d’un type « défini » then
if si est un symbole de type « indéfini » then
changer le type de si pour celui de s
end if
incrémenter de 1 le nombre de fois où si est défini
end if
else
fixer à 0 ou à 1 le nombre de fois où s est défini,
suivant que s est d’un type « défini » ou pas
T = T ∪ {s}
end if
end
Figure 4.2 – Version abstraite de la fonction qui enregistre un symbole dans la table des
symboles. Remarquer que tout enregistrement commence par une recherche.
56
Figure 4.3 – Implantation avec un tableau désordonné. À gauche, la première phase de l’al-
gorithme avec la courbe expérimentale, celle obtenue par estimation de paramètres et celle du
pire des cas. À droite, la seconde phase avec la courbe expérimentale (fichier "naif.stats")
et celle obtenue par estimation de paramètres.
4.2.1 Implantation
L’implantation repose sur un tableau redimensionnable. Le champ mesures contient
des mesures permettant d’analyser le comportement de la table et de produire le fichier
"linker.stats". Le type struct symbole est défini section 5.2.1, page 83.
struct tableau
{ int alloc; /* le nombre d’emplacements alloués */
int size; /* le nombre d’emplacements utilisés */
struct symbole* tab; /* la zone de stockage */
};
struct symtable
{ struct tableau T;
struct stats mesures; /* mesures */
};
La recherche d’un symbole dans la table est effectuée par un parcours séquentiel des éléments
du tableau. Les nouveaux éléments sont enregistrés en fin de tableau.
4.2.2 Analyse
Dans le meilleur des cas, on a f (n) ' n puisque, à chacune des n itérations (sauf la
première), on effectue exactement 1 comparaison de chaîne.
57
Le pire des cas
Le pire des cas est un peu plus compliqué : à la première itération, il n’y a aucune
comparaison effectuée, à la deuxième, il y en a une, à la troisième, il y en a deux, jusqu’à la
enième, où n − 1 comparaisons sont effectuées. Au total,
n (n − 1) 1 1
f (n) = 0 + 1 + 2 + · · · + (n − 1) = = n2 − n .
2 2 2
Un cas réel
À quoi faut-il s’attendre sur un cas réel ? Pour toutes les implantations, on a choisi de
tester le programme linker sur lui-même, en exécutant la commande (le répertoire comporte
dix fichiers objets) :
$ ./linker *.o /usr/lib/libc.a
On assiste à un comportement en deux phases. Durant la première phase, les symboles consi-
dérés sont ceux présents dans les fichiers objets. Ils vont être systématiquement incorporés
dans la table des symboles mais ils ne vont pas systématiquement faire augmenter la dimen-
sion de cette table. On devrait donc avoir une fonction f (n) ' a n2 + b n + c pour certaines
valeurs de a, b et c. Durant la seconde phase, les symboles considérés sont ceux de la biblio-
thèque standard. Il y a de nombreuses comparaisons (la bibliothèque est parcourue quatre
fois de suite) mais la dimension de T va très peu augmenter. On devrait alors avoir une
fonction f (n) ' a n + b, pour certaines valeurs de a et de b. La constante a devrait être
approximativement égale à la dimension de la table T finale (112 symboles).
Au vu de l’évolution de la troisième colonne du fichier "linker.stats" (qui donne la
dimension de la table T ), la première phase s’arrête vers n = 70. On peut donc estimer les
coefficients a, b et c par la commande suivante. On trouve une fonction qui croît un peu
moins vite que le pire des cas.
$ gnuplot
gnuplot> fit [0:70] a*x**2+b*x+c "linker.stats" via a,b,c
Pour la deuxième phase, on peut estimer les paramètres a et b par la commande suivante. Le
coefficient directeur a ' 108 n’est pas très éloigné de la valeur prédite 112. Graphiquement,
on a bien le sentiment d’observer une croissance linéaire. Voir figure 4.3.
58
gnuplot> fit a*x+b "linker.stats" via a,b
4.3.2 Analyse
Le pire des cas
Le pire des cas est intéressant parce qu’il soulève une question : la fonction f (n) ne
compte que des comparaisons de symboles. Mais ne faudrait-il pas compter aussi le coût de
la translation des éléments du tableau lors de l’ajout des nouveaux éléments ?
Supposons, pour commencer, qu’on ne compte que les comparaisons de symboles. À
l’itération numéro i, on effectue une recherche dichotomique dans un tableau de taille i.
En nous inspirant des résultats établis dans la section 3.2.2, on est tenté de considérer
que chaque recherche coûte log2 (i) comparaisons et donc que la fonction f (n) devrait être,
approximativement, de la forme :
Chaque logarithme de la somme peut être majoré par log2 (n). La somme peut donc être
majorée par n log2 (n). On s’attend donc à ce que f (n) ∈ O(n log(n)). La vraie courbe
du pire des cas est en fait une courbe « continue », obtenue en raccordant des segments
de droites. On peut mener, pour cette courbe, une analyse aussi fine que celle menée pour
la recherche dichotomique seule, dans la section 3.2.2. On peut ainsi montrer (les calculs
59
sont un peu trop longs pour être détaillés ici), non seulement que notre borne supérieure
asymptotique est correcte, mais aussi que
f (n) ∈ Θ(n log(n)) .
Supposons maintenant qu’on intègre le coût des translations dans la fonction f (n). À
l’itération numéro i, on effectue une recherche dichotomique dans un tableau de taille i plus
une translation de i éléments. Dans ce cas, la fonction f (n) devrait être, approximativement,
de la forme :
f (n) ' 1 + 2 + · · · + n + log2 (1) + log2 (2) + · · · + log2 (n) .
La somme des logarithmes peut être négligée et on retrouve la complexité, dans le pire des
cas, des tableaux non ordonnés :
f (n) ∈ Θ(n2 ) .
Un cas réel
Sur le code du projet LINKER, on observe à nouveau un comportement en deux phases.
On a compté chaque translation d’un symbole avec compteur comme une comparaison. Lors
de la première phase, la courbe a une forme difficile à reconnaître. Lors de la seconde, on
observe un comportement linéaire f (n) ' a n + b. La constante a étant très proche du
logarithme en base 2 du nombre de symboles présents dans la table. On est très loin du
pire des cas, en raison du très petit nombre d’ajouts d’éléments par rapport au nombre de
recherches de symboles. Les courbes expérimentales sont données figure 4.4.
gnuplot> fit a*x+b "linker.stats" via a,b
60
Figure 4.4 – Implantation avec un tableau ordonné. À gauche, la courbe expérimentale de
la première phase de l’algorithme. À droite, la seconde phase avec la courbe expérimentale
et celle obtenue par estimation de paramètres.
La « longueur » d’un chemin est le nombre d’arcs qui le composent (n − 1 sur l’exemple). La
« hauteur » d’un nœud a est le maximum des longueurs des chemins qui commencent en a.
La hauteur d’un arbre est la hauteur de sa racine.
2. On utilise le terme « arbre » au lieu de « arborescence » mais « arborescence » serait plus juste. Cet
abus de langage est classique. Voir [1, chapitre 12, page 247, note en bas de page].
61
Dans un arbre binaire, il est souvent utile de distinguer 3 les deux fils d’un nœud a. On
les appelle alors, le « fils gauche » et le « fils droit » du nœud a. Un nœud qui n’a qu’un
seul fils peut donc avoir, soit un fils gauche, soit un fils droit (ce n’est pas équivalent).
Le « sous-arbre gauche » d’un nœud a est l’arbre obtenu en ne gardant que les nœuds de
l’arbre A qui sont accessibles à partir du fils gauche de a ainsi que les arcs où ils apparaissent.
Le fils gauche de a est donc la racine du sous-arbre gauche de a. On définit le « sous-arbre
droit » de a de façon similaire.
Un point d’algorithmique
Un arbre qui n’est pas vide est complètement défini par sa racine. Plutôt que d’écrire des
fonctions paramétrées par des arbres, on écrit donc plutôt des fonctions paramétrées par des
racines, c’est-à-dire des nœuds. Ce choix peut sembler bizarre mais il rend le pseudo-code
plus proche des implantations en langage C. Un problème se pose alors avec l’arbre vide,
qui n’a normalement pas de racine. On le contourne en assimilant l’arbre vide à une racine
conventionnelle, notée NIL [1, section 10.4, page 208].
62
function ajout_dans_un_ABR (racine, valeur)
Cette fonction modifie l’arbre désigné par racine.
On suppose que valeur n’appartient pas à l’arbre.
begin
Créer une nouveau nœud (une feuille), de valeur valeur
if racine = NIL then
résultat = la nouvelle feuille
else
pred = indéfini
succ = racine
On est certain d’entrer au moins une fois dans la boucle
while succ 6= NIL do
pred = succ
if la valeur de succ est inférieure à valeur then
succ = le fils droit de succ
else
succ = le fils gauche de succ
end if
end do
if la valeur de pred est inférieure à valeur then
Modifier le fils droit de pred pour qu’il pointe vers la nouvelle feuille
else
Modifier le fils gauche de pred pour qu’il pointe vers la nouvelle feuille
end if
résultat = racine
end if
return résultat
end
Tout symbole qui apparaît dans le sous-arbre gauche de a est inférieur 4 lexico-
graphiquement 5 au symbole de a. Tout symbole qui apparaît dans le sous-arbre
droit de a est supérieur au symbole de a.
La recherche d’un élément dans un arbre binaire de recherche peut se faire par l’algorithme
de la figure 4.5, qui est un analogue de la méthode dichotomique. L’ajout d’un élément peut
se faire par l’algorithme de la figure 4.6.
L’efficacité de ces deux algorithmes dépend en fait considérablement de la forme de
4. Dans notre cas, tous les symboles sont distincts deux-à-deux : on ne se soucie pas de la différence entre
« strictement inférieur » et « inférieur ou égal ».
5. C’est-à-dire suivant l’ordre du dictionnaire.
63
l’arbre.
Si l’arbre est équilibré en nombre de nœuds, c’est-à-dire si, pour tout nœud a, le nombre
de nœuds du sous-arbre gauche de a est égal au nombre de nœuds du sous-arbre droit de a
(plus ou moins 1) alors les deux algorithmes se comportent vraiment comme une méthode
dichotomique et le nombre de comparaisons de valeurs effectuées lors d’un ajout ou d’une
recherche infructueuse dans un arbre de p nœuds est approximativement égal au logarithme
en base 2 de p.
Par contre, un arbre peut fort bien avoir la forme d’une liste chaînée. Dans ce cas, le
nombre de comparaisons de valeurs effectuées est égal à p. Une telle configuration apparaît
si un arbre est construit avec la fonction de la figure 4.6, en ajoutant une séquence de valeurs
déjà triées (par ordre croissant ou décroissant).
4.4.3 Implantation
On implante un arbre binaire de recherche au moyen de la structure suivante. Un nœud
est un pointeur sur un struct ABR. L’arbre NIL est codé par le pointeur nul.
struct noeud
{ struct noeud* gauche; /* sous-arbre gauche (symboles plus petits) */
struct noeud* droit; /* sous-arbre droit (symboles plus grands)*/
struct symbole value; /* valeur du noeud */
};
#define NIL (struct noeud*)0
La structure struct symtable, qui sert à implanter les tables de symboles, est adaptée
comme suit :
struct symtable
{ struct noeud* arbre; /* la racine de l’ABR */
struct stats stats; /* mesures */
};
4.4.4 Analyse
Le pire des cas
Le pire des cas n’a pas changé par rapport à l’implantation par un tableau désordonné !
En effet, dans le pire des cas, les symboles arrivent déjà triés dans l’ordre lexicographique,
l’arbre a la forme d’une liste et on retrouve :
n (n − 1)
f (n) = ·
2
Un cas réel
Dans un cas réel (le code du projet LINKER), heureusement, le comportement asymp-
totique s’améliore nettement. On observe à nouveau un comportement en deux phases. La
64
première correspond au traitement des fichiers objets. La courbe expérimentale ressemble à
une succession de petites paraboles (une sorte de pire des cas par morceaux). Ce phénomène
surprenant semble dû au fait que les symboles extraits d’un même fichier objet sont donnés
par ordre croissant. La seconde phase correspond au traitement de la bibliothèque standard.
On s’attend à trouver une fonction f (n) ' a n + b. Si de plus, l’arbre est équilibré en nombre
de nœuds, on s’attend à ce que a soit approximativement égal au logarithme en base 2 du
nombre de symboles, qui vaut log2 (112) ' 6.8. On trouve plutôt, par estimation de para-
mètres, une valeur de l’ordre de 18, ce qui montre que l’arbre n’est pas vraiment équilibré.
La hauteur de l’arbre est de 26. Voir figure 4.7.
gnuplot> fit a*x+b "linker.stats" via a,b
Figure 4.7 – Implantation avec arbres binaires de recherche. À gauche, la première phase
de l’algorithme avec la courbe expérimentale et celle du pire des cas. À droite, la seconde
phase. Tout en haut, la courbe expérimentale correspondant au tableau désordonné. Tout
en bas, celle correspondant au tableau ordonné. Au milieu, celle correspondant à un ABR et
celle obtenue par estimation de paramètres (ces deux-là sont quasiment indiscernables).
On peut comparer ce résultat avec la hauteur moyenne d’un arbre binaire de recherche
après n insertions d’éléments aléatoires, qui se comporte, asymptotiquement, comme c ln n
où la constante c vaut approximativement 4.311 [2, Theorem 5.10, page 261]. Dans notre cas
(n = 112), la hauteur devrait être 20 ou 21. On peut penser que la hauteur trouvée, 26, est
moins bonne parce que les symboles des fichiers objets sont donnés par ordre croissant.
L’itérateur de symboles récupère la sortie de la commande nm, qui, par défaut, trie les
symboles par ordre alphabétique. En s’arrangeant pour que l’itérateur exécute la commande
65
nm -p, on évite ce tri et on améliore nettement les performances de la structure, sans pour
autant atteindre les performances des AVL, décrits dans la section suivante.
Ajout d’un élément dans un arbre AVL. L’idée consiste, lorsqu’on ajoute un élément
dans un arbre AVL, à l’ajouter comme dans un arbre ABR, puis à rééquilibrer l’ABR pour
obtenir à nouveau un AVL. Les opérations de rééquilibrage s’appuient sur les opérations
ci-dessous (les lettres minuscules représentent des nœuds, les majuscules des arbres, éventu-
ellement vides). Ces opérations sont appelées « rotation gauche » et « rotation droite » dans
[1, section 13.2].
Premier cas. Le nouvel élément a été inséré dans l’arbre D, qui comporte donc nécessai-
rement une racine d avec un sous-arbre gauche E et un sous-arbre droit F . Il suffit d’une
rotation dans le sens trigonométrique, appliquée à a, pour rééquilibrer l’arbre :
66
Second cas. Le nouvel élément a été inséré dans l’arbre C, qui comporte donc nécessai-
rement une racine c avec un sous-arbre gauche G et un sous-arbre droit H. Il suffit d’enchaîner
une rotation dans le sens trigonométrique inverse, appliquée à b, puis une rotation dans le
sens trigonométrique, appliquée à a, pour rééquilibrer l’arbre :
Il y a bien sûr deux autres cas symétriques, à appliquer si le nouvel élément est inséré
dans A plutôt que dans B.
Mise en œuvre. Pour commencer, on rajoute deux champs à la structure struct noeud
pour mémoriser les hauteurs des sous-arbres gauche et droit de chaque nœud. On modifie la
fonction de la figure 4.6 en lui ajoutant une pile de struct noeud* dans laquelle on mémorise
le chemin suivi dans la boucle. À la fin de l’ajout, on parcourt le chemin en sens inverse,
grâce à la pile et on vérifie que chaque nœud est équilibré en hauteur. S’il ne l’est pas, on
lui applique les rotations décrites plus haut.
4.5.1 Analyse
Le pire des cas
Supposons qu’il y ait n symboles dans la table des symboles. Le nombre de comparaisons
effectuées lors de la recherche infructueuse d’un symbole est égale à 1 plus la hauteur de
l’arbre binaire de recherche associé à la table des symboles. Que peut valoir cette hauteur,
pour un arbre AVL, dans le pire des cas ?
Notons F (h) le nombre de nœuds minimal pour obtenir un arbre AVL de hauteur h − 1,
c’est-à-dire un arbre qui provoque h comparaisons de symboles, dans le pire des cas. On
a F (h) = F (h − 1) + F (h − 2) + 1 avec F (1) = 1 et F (2) = 2, c’est-à-dire une relation de
67
récurrence proche de celle de la suite de Fibonacci. On trouve (voir la section 3.3) :
√ !h √ !h
1+ 5 1− 5
F (h) = a +b − 1,
2 2
√
où a et b désignent deux constantes. Notons ϕ = (1+ 5)/2 le « nombre d’or ». En travaillant
les formules (toujours avec un logiciel de calcul formel), on trouve que F −1 (n) ≤ logϕ n. On
en conclut que le nombre de comparaisons effectuées lors de la recherche infructueuse d’un
symbole dans une table de n symboles est, dans le pire des cas, inférieur ou égal à logϕ n.
Par conséquent,
n
X 1
f (n) ≤ logϕ i ≤ n logϕ n = n log2 n ≤ 1.45 n log2 n .
i=1
log2 ϕ
Expérimentalement, on constate, de plus, que n log2 n ≤ f (n), ce qui tend à montrer que
f (n) ∈ Θ(n log(n)) et que la constante 1.45 est assez précise. On trouve un résultat à peine
moins bon que celui qu’on trouverait si on utilisait des arbres équilibrés en nombre de nœuds
au lieu d’arbres AVL. C’est un très bon résultat.
Figure 4.8 – Implantation avec AVL. À gauche, la première phase de l’algorithme avec la
courbe expérimentale, celle du pire des cas et la courbe théorique qui majore le pire des
cas. À droite, la seconde phase avec la courbe expérimentale correspondant à un tableau
ordonné, celle correspondant à un AVL et la courbe obtenue par estimation de paramètres
(ces deux-là étant quasiment indiscernables). L’implantation avec AVL est meilleure qu’avec
un tableau ordonné, parce que son comportement est meilleur lors de la première phase.
Un cas réel
Dans un cas réel (toujours le code du projet LINKER), il devient difficile de distinguer
les deux phases. Par estimation de paramètres, on trouve une fonction f (n) ' a n + b avec
a ' 6.91, ce qui est proche de log2 (112) ' 6.80. La hauteur du dictionnaire est de 8.
68
gnuplot> fit a*x+b "linker.stats" via a,b
Figure 4.9 – Impression des valeurs d’un arbre binaire de recherche. Implantation récursive
d’un parcours « gauche-racine-droite ».
69
function imprimer_ABR (racine)
Cette fonction imprime les valeurs de l’ABR désigné par racine. Le parcours
« gauche-racine-droite » a pour effet d’imprimer les valeurs par ordre alphabétique.
local variables
Une pile P d’ABR
begin
vider la pile P
a = racine
Boucle d’initialisation
while a 6= NIL do
empiler a dans P
a = le fils gauche de a
end do
Invariant : on a fini de traiter le sous-arbre gauche de l’ABR en sommet de pile
while P n’est pas vide do
dépiler un ABR de P dans a
imprimer la valeur de a
a = le fils droit de a
while a 6= NIL do
empiler a dans P
a = le fils gauche de a
end do
end do
end
Figure 4.10 – Impression des valeurs d’un arbre binaire de recherche. Implantation itérative
d’un parcours « gauche-racine-droite ».
70
Une façon simple de résoudre le problème des collisions consiste à stocker des listes
d’éléments, plutôt que des éléments, dans les alvéoles. D’autres méthodes, dites « d’adressage
ouvert » évitent le recours à des structures de données auxiliaires telles que les listes. On se
concentre ci-dessous sur une méthode particulière d’adressage ouvert : le « double hachage ».
Double hachage. Avec cette méthode, la fonction de hachage retourne non pas une valeur
de hachage mais deux : h(s) = (h1 (s), h2 (s)). Les deux valeurs de hachage sont comprises
entre 0 et N − 1. La valeur h2 (s) doit être non nulle.
Pour tester si un élément e de clef s est présent dans la table, on teste si l’élément se
trouve dans l’alvéole d’indice h1 (s). Si cet alvéole est occupé par un autre élément que e,
on teste l’alvéole d’indice h1 (s) + h2 (s) mod N . Si celui-ci aussi est occupé par un autre
élément que e, on teste l’alvéole d’indice h1 (s) + 2 h2 (s) mod N et ainsi de suite, jusqu’à
trouver, soit e, soit un alvéole libre. De même, pour enregistrer un élément e de clef s dans
la table, il suffit de chercher un emplacement libre en énumerant les alvéoles d’indice
h1 (s) + i h2 (s) mod N , i = 0, 1, 2, . . . (4.1)
Choix de N . Supposons que la table contienne encore un alvéole libre, d’indice k. Est-on
certain que la formule (4.1) le trouve, c’est-à-dire qu’il existe un entier i tel que h1 (s) +
i h2 (s) = k mod N ? Oui, si le plus grand diviseur commun de h2 (s) et de N vaut 1 [1,
chapitre 31.4]. Dans ce cas en effet, h2 (s) est inversible modulo N et i vaut (k −h1 (s)) h2 (s)−1
mod N . Cette difficulté se résout très facilement en choisissant un nombre premier pour N .
Suppression d’un élément. Si on utilise une technique d’adressage ouvert, il faut gérer
avec finesse les suppressions d’éléments. La solution la plus simple consiste à définir trois
états pour les alvéoles, au lieu de deux : l’état libre, l’état occupé et l’état détruit.
Mais attention : dans le cadre d’une application où des suppressions seraient régulièrement
effectuées, le nombre d’alvéoles libres pourrait tendre vers zéro (il n’y aurait plus que des
alvéoles occupés ou détruits). Chaque opération d’ajout ou de recherche provoquerait alors
un parcours de toute la table et les performances de la structure de données deviendraient
très mauvaises. Pour un tel type d’application, la technique du hachage simple est préférable.
Dans tous les cas : attention aux algorithmes !
Saturation d’une table. L’adressage ouvert présente enfin un inconvénient sur la mé-
thode des listes chaînées : la table peut devenir pleine et il n’est pas simple de redimensionner
la table. En pratique, on considère qu’une table de hachage est pleine dès que son taux de
remplissage α (égal au nombre d’alvéoles occupés 7 divisé par le nombre total d’alvéoles) est
supérieur à 1/3. C’est en effet à partir de ce taux que les performances de la structure de
données commencent à se dégrader en moyenne (voir section 4.6.2).
p(n) = p(n − 1) (N − n + 1)/N . Un calcul rapide montre que, pour N = 400, on a p(24) < 1/2. En d’autres
termes, une collision est probable dès que le 24ème élément est enregistré dans la table. Ce phénomène est
très lié au « paradoxe des anniversaires ».
7. Ou non vides, suivant les choix d’implantation.
71
Parcours d’une table. Il est parfois utile d’énumérer tous les éléments présents dans un
dictionnaire. Dans le projet LINKER, la fonction synthese_symtable doit effectuer cette
opération. Sans implantation d’un mécanisme supplémentaire, il est nécessaire de parcourir
les N alvéoles or N peut être beaucoup plus grand que le nombre d’éléments présents dans
la table. Autre inconvénient : les éléments sont alors énumérés dans le désordre.
4.6.1 Implantation
On implante une table de hachage utilisant la méthode du double hachage au moyen
des structures suivantes. Le type struct valeur_double_hachage permet de représenter les
deux valeurs de hachage retournées par les fonctions de hachage. Le type enum etat_alveole
permet de coder les trois états possibles pour un alvéole (bien que dans le projet, l’état
« détruit » ne soit pas utilisé). Le type struct alveole permet de représenter les alvéoles. Le
type fonction_double_hachage fournit le prototype des fonctions de hachage. Le premier
paramètre correspond à la clef ; le second est la table elle-même, dont la dimension est
nécessaire pour calculer les valeurs de hachage.
struct valeur_double_hachage
{ int h1;
int h2;
};
struct alveole
{ struct symbole sym; /* la valeur, si etat = alveole_occupe */
enum etat_alveole etat; /* l’état de l’alvéole */
};
struct table_double_hachage;
struct table_double_hachage
{ fonction_double_hachage* h; /* pointeur vers la fonction de hachage */
struct alveole* tab; /* la zone de stockage */
long N; /* nombre d’alvéoles alloués à tab */
};
72
void init_table_double_hachage
(struct table_double_hachage* T, int N0, fonction_double_hachage* h)
La structure struct symtable, qui sert à implanter les tables de symboles, est modifiée
comme suit.
struct symtable
{ struct table_double_hachage T; /* la table de hachage */
struct stats mesures; /* mesures */
};
4.6.2 Analyse
Le pire des cas
Dans le pire des cas, il y a systématiquement collision et l’algorithme de recherche d’un
élément parcourt systématiquement tous les alvéoles occupés de la table. La fonction f (n) ∈
O(n2 ).
Un cas en moyenne
Il est beaucoup plus intéressant de procéder à une analyse de complexité en moyenne.
On considère pour cela une table de N alvéoles, dont n sont occupés. Le taux de remplissage
de la table est le réel α = n/N < 1. Soit s un symbole n’appartenant pas à T . À combien de
comparaisons de chaînes de caractères faut-il s’attendre pour que la fonction de recherche
prouve que s n’appartient pas à T ? Cette fonction énumère des alvéoles d’indices h1 (s) +
i h2 (s) à partir de i = 0 jusqu’à trouver un alvéole vide. Pour pouvoir mener le calcul de
complexité, on choisit de voir les indices énumérés comme des entiers de l’intervalle [0, N −1]
tous susceptibles d’être tirés avec la même probabilité. Le nombre de comparaisons de chaînes
de caractères effectuées par la fonction de recherche devient donc une variable aléatoire X
et on a :
xi 0 1 2 3 4
p(X = xi ) 1 − α α (1 − α) α2 (1 − α) α3 (1 − α) α4 (1 − α)
73
Un cas réel
On a fait deux simulations avec le code du projet LINKER : une avec N = 521 et une autre
avec N = 16411. On rappelle que la table reçoit 112 symboles. Même avec N = 521 (cas où
la table est relativement bien remplie), les performances sont impressionnantes. Avec N =
16411, la fonction f (n) est quasiment constante, ce qui signifie qu’il n’y a quasiment aucune
collision. La forme un peu répétitive de la courbe est peut-être due au fait que la bibliothèque
est parcourue quatre fois de suite. Un graphique est donné figure 4.11.
74
static struct valeur_double_hachage
fonction_double_hachage_par_defaut
(char* clef, struct table_double_hachage* table)
{ struct valeur_double_hachage hashval;
long p, i;
p = table->N;
hashval.h1 = 0;
for (i = 0; clef [i] != ’\0’; i++)
hashval.h1 = (hashval.h1 + (i + 1) * clef [i] * clef [i]) % p;
hashval.h2 = 1 + hashval.h1 % (p-1);
return hashval;
4.7 Conclusion
Si le dictionnaire tient dans la mémoire vive de l’ordinateur, les tables de hachage four-
nissent une excellente implantation. Les arbres AVL (et d’autres variantes plus sophistiquées
[1, chapitre 13, notes, page 293]) sont une bonne alternative si on est gêné par les défauts
des tables de hachage : crainte du pire des cas, choix de la fonction de hachage, difficulté
d’énumérer tous les éléments du dictionnaire par ordre croissant ou décroissant, difficulté de
redimensionner la table lorsqu’elle devient saturée. Quant aux dictionnaires beaucoup plus
gros, il vaut mieux les implanter avec des bases de données.
75
Bibliographie
[1] Thomas Cormen, Charles Leiserson, Ronald Rivest, and Clifford Stein. Introduction à
l’algorithmique. Dunod, Paris, 2ème edition, 2002.
[2] Robert Sedgewick and Philippe Flajolet. An Introduction to the Analysis of Algorithms.
Addison Wesley, 1996.
76
Chapitre 5
Dans ce chapitre, on présente une version un peu simplifiée de l’algorithme appliqué par
l’éditeur de liens pour gérer les tables de symboles. On détaille son implantation dans le
projet LINKER, qui sert de fil conducteur pour les méthodes du chapitre 4.
77
Les variables
Dans un programme C, il y a trois grands types de variables : les variables globales, les
variables locales aux modules et les variables locales aux fonctions.
Les variables locales aux fonctions ne sont associées à aucun symbole. Leur emplacement
mémoire varie d’un appel de fonction sur l’autre. Il est pris automatiquement sur la pile des
appels de fonctions, lorsque la fonction est appelée. Il est repris automatiquement lorsque
l’appel de fonction est terminé. Ces variables sont en fait locales à des appels de fonctions
plutôt qu’à des fonctions.
Les variables locales à un module sont des variables déclarées dans un module (c’est-à-dire
dans un fichier source) mais précédées du mot-clef static. Elles sont invisibles en dehors de
leur module. Par exemple, la variable primes suivante, est locale au module qui la contient :
static int primes [] = { 2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41 };
Le mot-clef static précède parfois la déclaration d’une variable, à l’intérieur du corps d’une
fonction. La variable est alors une variable locale au module (il lui correspond un symbole)
mais invisible en dehors de la fonction où elle est déclarée. C’est le cas de la variable buffer
de la fonction printf_gnuplot suivante :
void printf_gnuplot (struct gnuplot_window* gnuplot, char* format, ...)
{ static char buffer [1024];
...
}
Les fonctions
Il y a deux types de fonctions : les fonctions locales à un module (leur définition est
précédée du mot-clef static) et les fonctions globales. À chaque fonction correspond un
symbole. La fonction suivante, par exemple, est locale au module qui la contient :
static double max (double a, double b)
{
return a < b ? b : a;
}
78
/* fichier a.c */
extern char func (int);
int main ()
{ int i;
for (i = 0; i < 3; i++)
Tableau [i] = func (i);
return 0;
}
En compilant a.c avec l’option -c, on produit un fichier objet a.o. L’utilitaire nm permet
ensuite de visualiser les symboles globaux définis dans le fichier (leur nom est précédé d’une
majuscule différente de U) et des références vers des symboles indéfinis (leur nom est précédé
d’un U) :
$ gcc -c a.c
$ nm a.o
0000000000000003 C Tableau
U func
0000000000000000 T main
Considérons maintenant le fichier source b.c suivant. Il définit une variable locale au module
b.c et une fonction globale.
/* fichier b.c */
static char constante = ’0’;
En compilant b.c avec l’option -c, on produit un fichier objet b.o. L’utilitaire nm permet
ensuite de visualiser les symboles présents dans le fichier. Le symbole func est global : son
nom est précédé d’une majuscule différente de U ; le symbole constante est défini mais n’est
pas global, puisque sa définition est précédée du mot-clef static : son nom est précédé d’une
minuscule.
$ gcc -c b.c
$ nm b.o
0000000000000000 d constante
0000000000000000 T func
L’édition des liens est maintenant possible. L’exécutable a.out contient les deux fichiers
objets avec tous leurs symboles (plus d’autres, qu’on ne détaille pas). Tous les symboles sont
définis :
79
$ gcc a.o b.o
$ nm a.out
0000000000601030 B Tableau
...
0000000000601018 d constante
...
0000000000400504 T func
00000000004004c4 T main
Si un symbole global est défini plus d’une fois, l’édition des liens échoue aussi 1 . C’est ce qui
arrive si on mentionne deux fois le même fichier objet, comme dans l’exemple ci-dessous :
$ gcc a.o b.o b.o
b.o: In function ‘func’:
b.c:(.text+0x0): multiple definition of ‘func’
b.o:b.c:(.text+0x0): first defined here
collect2: ld returned 1 exit status
Enfin, l’édition des liens échoue aussi si le symbole spécial main n’est pas défini.
80
Compilons-le et tentons l’édition des liens entre les trois fichiers a.o, b.o et c.o. On observe
que, le fichier c.o est incorporé à l’exécutable bien que l’exécutable ne fasse aucun appel
direct ou indirect aux symboles présents dans c.o :
$ gcc -c c.c
$ nm c.o
0000000000000000 T action
$ gcc a.o b.o c.o
$ nm a.out
0000000000601038 B Tableau
...
000000000040051c T action
...
0000000000601018 d constante
...
0000000000400504 T func
00000000004004c4 T main
Fabriquons maintenant une bibliothèque avec les deux fichiers b.o et c.o (commande ar
pour « archive » en Anglais). En utilisant la commande nm, on voit que la bibliothèque
libdemo.a contient les deux fichiers objets :
$ ar cru libdemo.a b.o c.o
$ nm libdemo.a
b.o:
0000000000000000 d constante
0000000000000000 T func
c.o:
0000000000000000 T action
Procédons maintenant à l’édition des liens entre le fichier objet a.o qui contient le programme
principal et la bibliothèque 2 . On observe que l’éditeur des liens n’a incorporé à l’exécutable,
que le contenu du fichier b.o (le symbole action n’apparaît pas). Le fichier c.o n’a pas été
incorporé parce qu’aucun de ses symboles n’est utilisé, directement ou indirectement, par le
programme.
2. Au lieu de : gcc a.o libdemo.a, on aurait pu écrire, plus traditionnellement : gcc -L. a.o -ldemo
(l’option -L. indique à l’éditeur de liens qu’une des bibliothèques qu’il cherche appartient au répertoire
courant).
81
$ gcc a.o libdemo.a
$ nm a.out
0000000000601030 B Tableau
...
0000000000601018 d constante
...
0000000000400504 T func
00000000004004c4 T main
5.1.6 Un piège
L’ordre dans lequel les bibliothèques sont listées est important. En effet, l’éditeur des
liens incorpore les fichiers donnés sur sa ligne de commande, en parcourant cette ligne de
gauche à droite. Lorsqu’il traite une bibliothèque, il n’incorpore un fichier objet de cette
bibliothèque que si ce fichier définit un symbole indéfini, au moment où la bibliothèque est
traitée. Dans notre exemple, si on place la bibliothèque en début de la ligne de commande,
aucun symbole n’est encore défini au moment où la bibliothèque est traitée, et l’édition des
liens échoue :
$ gcc libdemo.a a.o
a.o: In function ‘main’:
a.c:(.text+0x1b): undefined reference to ‘func’
collect2: ld returned 1 exit status
82
que ar pour créer la bibliothèque dynamique. L’édition des liens réussit, même en plaçant
la bibliothèque en début de la ligne de commande.
$ gcc -shared -o libdemo.so b.o c.o
$ gcc a.o ./libdemo.so
$ ./a.out
$ gcc ./libdemo.so a.o
$ ./a.out
5.2.1 Implantation
Symboles
Un symbole est représenté par la structure suivante. Le champ ident contient l’identifi-
cateur du symbole. Le champ type contient le type, tel qu’il est fourni par la commande nm.
Le champ nbdef contient le nombre de fois où le symbole est défini. Ce champ n’acquiert
une signification que lorsque le symbole est enregistré dans la table des symboles.
struct symbole
{ char type;
char* ident;
int nbdef;
};
83
La table des symboles
Elle est implantée sous la forme d’une structure struct symtable dont l’implantation
peut varier (voir chapitre 4). Dans sa version la plus simple, la structure est un tableau
redimensionnable T (voir ci-dessous). Le champ mesures contient une structure, inutile au
fonctionnement de l’algorithme, qui sert à compter les comparaisons de chaînes de caractères,
pour mesurer l’efficacité de la structure de données (voir chapitre 4).
struct symtable
{ struct tableau T; /* tableau redimensionnable de symboles */
struct stats mesures; /* mesures */
};
Le module symtable exporte les cinq fonctions globales ci-dessous. La première est un
constructeur. La deuxième est le destructeur. La troisième enregistre le symbole sym dans la
table table. La quatrième teste si table contient un symbole d’identificateur clef. Si c’est
le cas, elle retourne l’adresse du symbole, sinon, elle retourne le pointeur nul. La cinquième
parcourt la table, teste si l’édition des liens est réussie ou non et imprime une synthèse sur
la sortie standard. Elle retourne zéro en cas de succès.
void init_symtable (struct symtable*)
void clear_symtable (struct symtable*)
void enregistrer_dans_symtable (struct symtable* table, struct symbole* sym)
struct symbole* rechercher_dans_symtable (char* clef, struct symtable* table)
int synthese_symtable (struct symtable*)
Voici le code de la fonction qui enregistre un symbole (supposé global) dans la table. Les
instructions qui mettent à jour le champ mesures ont été supprimées.
84
void enregistrer_dans_symtable (struct symtable* table, struct symbole* sym)
{ struct symbole* symp;
/*
* Si table->T contient un symbole d’identificateur sym->ident,
* l’adresse de ce symbole est affectée à symp, sinon, symp reçoit zéro.
*/
rechercher_symbole_dans_tableau (&symp, sym->ident, &table->T);
if (symp)
{ if (est_defini_symbole (sym))
{ if (est_indefini_symbole (symp))
changer_type_symbole (symp, sym->type);
ajouter_definition_symbole (symp);
}
} else
{
/* Le nb de définitions vaut 1 ou 0 suivant que le symbole est défini ou pas */
if (est_defini_symbole (sym))
changer_nbdef_symbole (sym, 1);
else
changer_nbdef_symbole (sym, 0);
ajouter_symbole_dans_tableau (&table->T, sym);
}
}
Voici le code de la fonction qui recherche un symbole à partir de sa clef, dans la table. Les
instructions qui mettent à jour le champ mesures ont été supprimées.
struct symbole* rechercher_dans_symtable (char* clef, struct symtable* table)
{ struct symbole* symp;
rechercher_symbole_dans_tableau (&symp, clef, &table->T);
return symp;
}
L’itérateur de symboles
Un itérateur de symboles est implanté dans dans le module iterateur_symbole. Il per-
met d’extraire les symboles des fichiers objets et des bibliothèques. Ce module exporte les
cinq fonctions suivantes. La première est un constructeur. Elle positionne l’itérateur iter au
début du fichier fname (qui doit être soit un fichier objet, soit une bibliothèque) et retourne
l’adresse de son premier symbole (le pointeur nul si aucun symbole). La deuxième est un
autre constructeur. Elle s’applique dans le cas où iter1 est un itérateur, en train de parcou-
rir les symboles d’une bibliothèque. Elle positionne iter2 au début du fichier objet courant
de iter1 et retourne l’adresse de son premier symbole (le pointeur nul si aucun symbole).
Après appel à cette fonction, les deux itérateurs iter1 et iter2 sont indépendants l’un de
l’autre : il est possible d’appliquer le destructeur sur l’un sans affecter le fonctionnement de
85
l’autre. La troisième fonction est le destructeur. La quatrième fonction fait avancer l’itérateur
et retourne l’adresse du symbole suivant (le pointeur nul si aucun symbole). La cinquième
fonction s’applique dans le cas où l’itérateur est en train de parcourir une bibliothèque. Elle
fait avancer l’itérateur jusqu’au fichier objet suivant de la bibliothèque et retourne l’adresse
de son premier symbole (le pointeur nul si aucun symbole). Les pointeurs retournés par les
fonctions pointent sur des champs internes des itérateurs 3 . Les ressources consommées par
ces symboles seront automatiquement libérées par l’appel à clear_iterateur_symbole.
struct symbole* first_symbole (struct iterateur_symbole* iter, char* fname)
struct symbole* first_symbole_objet_courant
(struct iterateur_symbole* iter2,
struct iterateur_symbole* iter1)
void clear_iterateur_symbole (struct iterateur_symbole*)
struct symbole* next_symbole (struct iterateur_symbole*)
struct symbole* next_symbole_next_objet (struct iterateur_symbole*)
3. Un choix de conception discutable : il simplifie l’écriture des programmes mais complique les spécifi-
cations et la documentation.
86
Le code source du programme principal
int main (int argc, char** argv)
{ struct symtable table;
struct iterateur_symbole iter, iter2;
struct symbole *sym, *sym2;
int i, status;
bool reloop;
init_symtable (&table);
i = 1;
while (i < argc)
{ commenter_stats (&table.mesures, argv [i]);
reloop = false;
sym = first_symbole (&iter, argv [i]);
if (est_fichier_objet (argv [i]))
{
/* Charge tous les symboles de l’objet */
while (sym != (struct symbole*)0)
{ if (! est_local_symbole (sym))
enregistrer_dans_symtable (&table, sym);
sym = next_symbole (&iter);
}
} else
{
/*
* On parcourt toute la bibliothèque à la recherche d’une définition
* pour un symbole indéfini.
*/
while (sym != (struct symbole*)0)
{ if (est_global_et_defini_symbole (sym))
{ sym2 = rechercher_dans_symtable (sym->ident, &table);
if (sym2 != (struct symbole*)0 &&
est_indefini_symbole (sym2))
{
/* On en a trouvé un : on charge l’objet auquel ce symbole appartient */
sym2 = first_symbole_objet_courant (&iter2, &iter);
while (sym2 != (struct symbole*)0)
{ if (! est_local_symbole (sym2))
enregistrer_dans_symtable (&table, sym2);
sym2 = next_symbole (&iter2);
}
clear_iterateur_symbole (&iter2);
/* On devra parcourir à nouveau la bibliothèque */
reloop = true;
/*
* On saute tous les symboles qui suivent sym dans le fichier objet de sym
* puisqu’on vient juste de les charger dans la table.
*/
sym = next_symbole_next_objet (&iter);
} else
sym = next_symbole (&iter);
} else
sym = next_symbole (&iter);
}
}
clear_iterateur_symbole (&iter);
if (!reloop)
i += 1;
}
/* Indique si l’édition des liens est réussie ou pas */
status = synthese_symtable (&table);
clear_symtable (&table);
return status;
}
87
5.2.2 Exemples
Sur l’exemple donné en début de chapitre, avec l’implantation naïve de la table des
symboles, on a les résultats suivants. Le fichier linker.stats contient trois colonnes de
nombres : la première colonne compte le nombre d’appels à get_symbole_symtable et
put_symbole_symtable, la deuxième compte le nombre de comparaisons de chaînes de ca-
ractères (appels à strcmp) effectuées par des deux fonctions, la troisième compte le nombre
de symboles présents dans la table des symboles de l’exécutable en cours de construction.
$ ./linker a.o libdemo.a
edition des liens reussie
$ ./linker libdemo.a a.o
symboles indefinis
func
$ ./linker a.o libdemo.a a.o
symboles dupliques
Tableau
main
$ cat linker.stats
# nb appels get | nb comp. chaines | nb symboles
...
12 24 3
88
5.3 Le makefile
On en donne ici une présentation très simplifiée, qui suffit pour les séances de travaux
pratiques de ce cours. Un Makefile est un fichier qui contient deux types d’informations :
1. des commandes de compilation séparée et d’édition des liens, qui peuvent être longues,
en raison des options,
2. des dépendances entre fichiers : si on modifie un fichier a.h, quels sont les fichiers à
recompiler ? Probablement a.c mais peut-être, aussi d’autres fichiers.
Avec l’utilitaire make de GNU, la conception d’un makefile est vraiment très simple : les com-
mandes de compilation n’ont pas besoin d’être écrites (elles sont implicites) ; les dépendances
peuvent être fabriquées par gcc lui-même !
Le programme principal main.c inclut chaine.h. Le fichier d’entête chaine.h inclut liste_char.h.
L’ensemble des dépendances s’obtient par la commande suivante. La première ligne signifie :
toute modification à chaine.c, chaine.h ou liste_char.h, doit entraîner la reconstruction
du fichier objet chaine.o.
$ gcc -MM *.c
chaine.o: chaine.c chaine.h liste_char.h
liste_char.o: liste_char.c liste_char.h
main.o: main.c chaine.h liste_char.h
On obtient un makefile en reportant ces trois lignes à la fin du fichier Makefile suivant :
CC=gcc
CFLAGS=-g -Wall -Wmissing-prototypes
LDFLAGS=-g
objects := $(patsubst %.c,%.o,$(wildcard *.c))
all: main
clean:
-rm $(objects)
-rm main
main: $(objects)
chaine.o: chaine.c chaine.h liste_char.h
liste_char.o: liste_char.c liste_char.h
main.o: main.c chaine.h liste_char.h
89
5.3.2 Utilisation
Maintenant, si on lance la commande make, toutes les commandes nécessaires à la pro-
duction de l’exécutable main sont exécutées :
$ make
gcc -g -Wall -Wmissing-prototypes -c -o main.o main.c
gcc -g -Wall -Wmissing-prototypes -c -o chaine.o chaine.c
gcc -g -Wall -Wmissing-prototypes -c -o liste_char.o liste_char.c
gcc -g main.o chaine.o liste_char.o -o main
Par contre, si on modifie chaine.h, certaines compilations (pas toutes) sont exécutés à
nouveau :
$ touch chaine.h
$ make
gcc -g -Wall -Wmissing-prototypes -c -o main.o main.c
gcc -g -Wall -Wmissing-prototypes -c -o chaine.o chaine.c
gcc -g main.o chaine.o liste_char.o -o main
Enfin, si on veut effacer tous les fichiers objets ainsi que l’exécutable, il suffit de lancer la
commande :
$ make clean
rm chaine.o liste_char.o main.o
rm main
5.3.3 Explications
Les trois premières lignes de Makefile donnent des valeurs à des variables reconnues
par l’utilitaire make et utilisées pour compiler des programmes C. La variable CC contient
le nom du compilateur. La variable CFLAGS contient les options à passer lors des étapes de
compilation séparées. La variable LDFLAGS contient les options à passer lors de l’édition des
liens.
La ligne suivante est un peu compliquée. Elle affecte à la variable objects, les noms des
fichiers objets du TP. Ces noms sont obtenus en substituant le suffixe « .o » au suffixe « .c »,
pour tous les fichiers « .c » présents dans le répertoire courant.
La ligne suivante donne la cible par défaut : si on lance make sans paramètre, il faut
construire main.
La ligne suivante donne la cible clean : si on lance make clean, il faut effacer les fichiers
présents dans la variable objects ainsi que main. Le signe moins devant rm indique à make
de ne pas s’arrêter si ces commandes échouent (par exemple, dans le cas où les fichiers à
90
détruire n’existent pas). Attention : les espaces devant les actions sont impérativement des
tabulations.
La ligne suivante indique une première dépendance : la cible main dépend de tous les
fichiers objets. Si un fichier objet est modifié ou reconstruit, alors, il faut reconstruire main.
Cette ligne n’est suivie d’aucune action : il s’agit d’une action implicite.
Les dernières lignes ont été produites par gcc -MM. Elles ne sont suvies d’aucune action.
Les actions associées sont implicites.
91
5.4 Le debugger
Le debugger gdb permet d’exécuter des programmes C pas à pas. Il facilite considérable-
ment la mise au point des programmes. Pour pouvoir l’appliquer à un exécutable, il faut que
l’option -g soit passée à gcc, à l’étape de compilation séparée ainsi qu’à l’étape d’édition
des liens.
92
5.4.2 Exemple
Un programme C implante un algorithme de tirage de boules de loto (voir plus bas). La
fonction principale utilise deux ensembles de boules, un pour les boules qui restent à tirer et
un autre pour les boules déjà tirées. La structure de données utilisée, struct ensemble, est
constituée de deux champs : le tableau elt contient les boules (des entiers, en fait) ; le champ
size donne le nombre de boules réellement présentes dans le tableau. La structure struct
ensemble est compréhensible isolément. Cela a permis d’écrire une fonction print_ensemble
à un seul argument. Par contre, le tableau elt tout seul ne l’est pas. La fonction tirage_loto
effectue des calculs un peu redondants. Si on considère par exemple la boucle qui initialise
l’ensemble boules, on voit qu’on aurait pu écrire les choses beaucoup plus concisément, en
évitant l’appel à init_ensemble, et en ne fixant la valeur du champ size qu’à la fin de
la boucle for. Le programme aurait été non seulement plus court mais aussi plus rapide.
Pourquoi a-t-on préféré la « version longue » ? Parce qu’elle s’efforce de maintenir la struc-
ture dans un état cohérent, ce qui permettrait, si on le souhaitait, d’effectuer des appels
à print_ensemble au début de chaque itération. Des raisonnements similaires ont guidé
l’écriture de la deuxième boucle. Cette possibilité est particulièrement intéressante avec le
debugger, puisqu’il est possible d’appeler une fonction comme print_ensemble depuis n’im-
porte quel point d’arrêt.
93
Démonstration
# L’option -g est nécessaire pour utiliser le debugger
$ gcc -g tirage_loto.c
$ gdb a.out
[...]
Reading symbols from /home/boulier/ENSEIGN/debugger/a.out...done.
# Première exécution : ok
(gdb) run
Starting program: /home/boulier/ENSEIGN/debugger/a.out
{5, 12, 7, 2, 19, 10, 20}
(gdb) list 50
45 init_ensemble (&boules);
46 init_ensemble (&tirage);
47 /* À chaque itération, la structure "boules" est cohérente */
48 for (i = 0; i < SIZE_MAX; i++)
49 { boules.elt [boules.size] = i+1;
50 boules.size += 1;
51 }
52 /* n = le nombre de boules à tirer */
53 srand48 ((long)time (NULL));
54 n = (int)(drand48 () * (SIZE_MAX + 1));
(gdb) break 49 if (i == 7)
Breakpoint 2 at 0x4008a9: file tirage_loto.c, line 49.
(gdb) run
Starting program: /home/boulier/toto/a.out
Breakpoint 2, tirage_loto () at tirage_loto.c:49
49 { boules.elt [boules.size] = i+1;
(gdb) call print_ensemble (&boules)
{1, 2, 3, 4, 5, 6, 7}
95
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <stdbool.h>
#define SIZE_MAX 20
struct ensemble
{ int size;
int elt [SIZE_MAX];
};
int main ()
{
tirage_loto ();
return 0;
}
Table des figures
97
4.7 Implantation avec arbres binaires de recherche. À gauche, la première phase
de l’algorithme avec la courbe expérimentale et celle du pire des cas. À droite,
la seconde phase. Tout en haut, la courbe expérimentale correspondant au
tableau désordonné. Tout en bas, celle correspondant au tableau ordonné.
Au milieu, celle correspondant à un ABR et celle obtenue par estimation de
paramètres (ces deux-là sont quasiment indiscernables). . . . . . . . . . . . . 65
4.8 Implantation avec AVL. À gauche, la première phase de l’algorithme avec la
courbe expérimentale, celle du pire des cas et la courbe théorique qui majore
le pire des cas. À droite, la seconde phase avec la courbe expérimentale cor-
respondant à un tableau ordonné, celle correspondant à un AVL et la courbe
obtenue par estimation de paramètres (ces deux-là étant quasiment indiscer-
nables). L’implantation avec AVL est meilleure qu’avec un tableau ordonné,
parce que son comportement est meilleur lors de la première phase. . . . . . 68
4.9 Impression des valeurs d’un arbre binaire de recherche. Implantation récursive
d’un parcours « gauche-racine-droite ». . . . . . . . . . . . . . . . . . . . . . 69
4.10 Impression des valeurs d’un arbre binaire de recherche. Implantation itérative
d’un parcours « gauche-racine-droite ». . . . . . . . . . . . . . . . . . . . . . 70
4.11 Implantation avec table de hachage. À gauche, la courbe expérimentale pour
N = 16411 et celle obtenue par estimation de paramètres. À droite, les deux
courbes expérimentales (en bas) et celle correspondant aux AVL. . . . . . . . 74
98
Index
99
fonction de priorité, 30 plot, 59
fonction globale, 77 priority queue, 30
fonction locale à un module, 77 processus, 11
fopen, 20 programmation modulaire, 6
free, 10, 11 prototype d’une fonction, 5
fuite de mémoire, 12, 13
racine, 61
-g, 92 realloc, 29
gdb, 92 realloc, 11
gnuplot, 46 rechercher_dans_symtable, 84
récurrence, 51
hauteur, 61 relation de récurrence, 51
heap, 11 relation homogène, 51
implantation d’un type, 5 relation non homogène, 51
inclusions multiples, 7 rotation, 66
itérateur, 20, 86 séparée (compilation), 77
iterateur_symbole, 86 sizeof, 12
ld, 80 sous-arbre, 62
__LINE__, 28 sous-arbre droit, 62
LINKER, 77 sous-arbre gauche, 62
linker.stats, 88 spécification, 6
spécification d’une fonction, 5
make, 89 spécification d’une structure, 5
makefile, 89 static, 77
malloc, 11 structure de données, 5
meilleur des cas, 39, 56 symbole dupliqué, 80
memory leak, 13 symbole indéfini, 77
-MM, 89
module, 6 table de hachage, 69
moindres carrés, 46 table des symboles, 55
tas, 11, 31
NIL, 62 taux de remplissage, 71
nm, 79 text, 11
nœud, 61 type abstrait, 5
nombre d’or, 49
valgrind, 13
objet, 77 variable globale, 77
variable locale à un module, 77
père, 61 variable locale à une fonction, 77
pile, 25 vérification des prototypes, 8
pile d’ABR, 67
pile d’exécution, 11 -Wmissing-prototypes, 9
pire des cas, 39, 56
100