• Aucun résultat trouvé

} }

Les objets Lencapsulent une liste d’association. Les m´ethodes getet putemploient toutes les deux la m´ethode AList.getCell pour retrouver l’information associ´ee `a la cl´e key pass´ee en argument. La technique de l’encapsulage est d´esormais famili`ere, nous l’avons d´ej`a exploit´ee pour les ensembles, les piles et les files dans les chapitres pr´ec´edents.

Mais il faut surtout noter une nouveaut´e : la classeLd´eclare impl´ementer l’interface Assoc (mot-cl´eimplements). Cette d´eclaration entraˆıne deux cons´equences importantes.

ˆ Le compilateur Java v´erifie que les objets de la classeLposs`edent bien les deux m´ethodes sp´ecifi´ees par l’interfaceAssoc, avec les signatures conformes. Il y a un d´etail ´etrange : les m´ethodes sp´ecifi´ees par une interface sont obligatoirement public.

ˆ Un objetLpeut prendre le type Assoc, ce qui arrive par exemple dans l’appel suivant : // in est un WordReader

count(in, new L()) ; // Compter les mots de in.

Notez quecountne connaˆıt pas la classeL, seulement l’interface Assoc.

Notre programme Freqfonctionne, mais il n’est pas tr`es efficace. En effet si la liste d’association est de tailleN, une recherche pargetCellpeut prendre de l’ordre deN op´erations, en particulier dans le cas fr´equent o`u la cl´e n’est pas dans la liste. Il en r´esulte que le programme Freq est en O(n2) o`unest le nombre de mots de l’entr´ee. Pour atteindre une efficacit´e bien meilleure, nous allons introduire la nouvelle notion de table de hachage.

2 Table de hachage

La table de hachage est une impl´ementation efficace de la table d’association. Appelons univers des cl´esl’ensembleU de toutes les cl´es possibles. Nous allons d’abord observer qu’il existe un cas particulier simple quand l’univers des cl´es est un petit intervalle entier, puis ramener le cas g´en´eral `a ce cas simple.

2.1 Adressage direct

Cette technique tr`es efficace ne peut malheureusement s’appliquer que dans des cas tr`es particuliers. Il faut que l’univers des cl´es soit de la forme {0, . . . , n−1}, o`u nest un entier pas trop grand, et d’autre part que deux ´el´ements distincts aient des cl´es distinctes (ce que nous avons d’ailleurs d´ej`a suppos´e). Il suffit alors d’utiliser un tableau de taille npour repr´esenter la table d’association.

Ce cas s’applique par exemple `a la base de donn´ee des concurrents d’une ´epreuve sportive qui exclut les ex-æquos. Le rang `a l’arriv´ee d’un concurrent peut servir de cl´e dans la base de donn´ees. Mais, ne nous leurrons pas un cas aussi simple est exceptionnel en pratique. Le fait pertinent est de remarquer que le probl`eme de la recherche d’information se simplifie beaucoup quand les cl´es sont des entiers pris dans un petit intervalle.

2.2 Table de hachage

L’id´ee fondamentale de la table de hachage est de se ramener au cas de l’adressage direct, c’est-`a-dire de cl´es qui sont des indices dans un tableau. Soit m, entier pas trop grand, `a vrai dire entier de l’ordre du nombre d’´el´ements d’informations que l’on compte g´erer. On se donne une fonction

h:U → {0, . . . , m−1}

appel´ee fonction de hachage. L’id´ee est de ranger l’´el´ement de cl´e k non pas dans une case de tableau t[k], comme dans l’adressage direct (cela n’a d’ailleurs aucun sens si k n’est pas un entier) , mais dans t[h(k)]. Nous reviendrons en 3 sur le choix, relativement d´elicat, de la fonction de hachage. Mais nous devons affronter d`es `a pr´esent une difficult´e. En effet, il devient d´eraisonnable d’exclure le cas de cl´es (distinctes) k et k telles que h(k) = h(k). La figure 1 illustre la survenue d’une telle collision entre les cl´es k1 et k3 distinctes qui sont telles que h(k1) =h(k3). Pr´ecisons un peu le probl`eme, supposons que la collision survient lors de l’ajout

Fig.1 – Une collision dans une table de hachage.

0 1

de l’´el´ement d’information v3 de cl´e k3, alors qu’il existe d´ej`a dans la table une cl´e k1 avec h(k1) =h(k3). La question est alors : o`u ranger l’information associ´ee `a la cl´ek3?

2.3 R´esolution des collisions par chaˆınage

La solution la plus simple pour r´esoudre les collisions consiste `a mettre tous les ´el´ements d’in-formation dont les cl´es ont mˆeme valeur de hachage dans une liste. On parle alors de r´esolution des collisions par chaˆınage. Dans le cas de l’exemple de collision de la figure 1, on obtient la situation de la figure 2. On remarque que les ´el´ements de la tabletsont tout bˆetement des listes d’association, la liste t[i]regroupant tous les ´el´ements d’information (k, v) de la table qui sont tels que h(k) vaut l’indicei.

Nous proposons une nouvelle impl´ementation H des tables d’associationsAssoc, en encap-sulant cette fois une table de hachage.

class H implements Assoc {

final static int SIZE=1024 ; // Assez grand ?

private AList [] t ; // Tableau interne de listes d’associations.

H() { t = new AList [SIZE] } ;

private int hash(String key) { return Math.abs(key.hashCode()) % t.length ;}

Fig.2 – R´esolution des collisions par chaˆınage.

public int get(String key) { int h = hash(key) ;

AList r = AList.getCell(key, t[h]) ; i f (r == null) {

public void put(String key, int val) { int h = hash(key) ;

AList r = AList.getCell(key, t[h]) ; i f (r == null) {

Le code de la fonction de hachage hash est en fait assez simple, parce qu’il utilise la m´ethode de hachage des chaˆınes fournie par Java (toute la complexit´e est cach´ee dans cette m´ethode) dont il r´eduit le r´esultat modulo la taille du tableau internet, afin de produire un indice valide.

La valeur absolue Math.abs est malheureusement n´ecessaire, car pour n n´egatif, l’op´erateur ((reste de la division euclidienne)) %renvoie un r´esultat n´egatif (mis`ere).

Il est surtout important de remarquer :

ˆ Le code est en fait presque le mˆeme que celui de la classe L(page 67), en rempla¸cant p part[h].

ˆ La classeH d´eclare impl´ementer l’interface Assoc et le fait effectivement ce que le com-pilateur v´erifie. Un objet de la nouvelle classe H est donc un argument valide pour la m´ethode countde la classe Freq.

Estimons le coˆut deputet degetpour une table qui contient N ´el´ements d’information. On suppose que le coˆut du calcul de la fonction hachage est enO(1), et que hachage est uniforme, c’est-`a-dire que la valeur de hachage d’une cl´e vaut h ∈ [0. . . m[ avec une probabilit´e 1/m.

Ces deux hypoth`eses sont r´ealistes. Pour la premi`ere, en supposant que le coˆut de calcul de la fonction de hachage est proportionnel `a la longueur des mots, nous constatons que la longueur des mots d’un texte ordinaire deN mots est faible et ind´ependante deN.2La seconde hypoth`ese traduit simplement que nous disposons d’une ((bonne )) fonction de hachage, faisons confiance

`

a la m´ethode hashCodedes String.

Sous ces deux hypoth`eses, la recherche d’un ´el´ement se fait en moyenne en temps Θ(1 +α), o`uα=n/mest lefacteur de charge(load factor) de la table (nest le nombre de cl´es `a ranger et m est la taille du tableau). Plus pr´ecis´ement une recherche infructueuse dans la table parcourt en moyenne α cellules de listes, et une recherche fructueuse 1 +α/2−1/(2m) cellules, coˆuts auquels on ajoute le coˆut du calcul de la fonction de hachage. Ce r´esultat est d´emontr´e dans [2, section 12.2], contentons nous de remarquer queαest tout simplement la longueur moyenne des listes d’associations t[h].

Peut-ˆetre faut il remarquer que le coˆut d’une recherche dans le cas le pire est O(n), quand toutes les cl´es entrent en collision. Mais employer les tables de hachage suppose de faire confiance au hasard (hachage uniforme) et donc de consid´erer plus le cas moyen que le cas le pire. Une fa¸con plus concr`ete de voir les choses est de consid´erer que, par exemple lors du comptage des mots d’un texte, on ins`ere et recherche de nombreux mots uniform´ement hach´es, et que donc le coˆut moyen donne une tr`es bonne indication du coˆut rencontr´e en pratique.

Dans un premier temps, pour notre impl´ementation simple deHqui dimensionne le tableaut initialement, nous pouvons interpr´eter le r´esultat de complexit´e en moyenne d’une recherche en Θ(1 +α), en constatant que si la taille du tableau interne est de l’ordre de n, alors nous avons atteint un coˆut (en moyenne) deput etget en temps constant. Il peut sembler que nous nous sommes livr´es `a une suite d’approximations et d’`a-peu-pr`es, et c’est un peu vrai. Il n’en reste pas moins, et c’est le principal, que les tables de hachage sont efficaces en pratique, essentiellement sous r´eserve que pour une ex´ecution donn´ee, les valeurs de hachage des cl´es se r´epartissent uniform´ement parmi les indices du tableau interne correctement dimensionn´e, mais aussi que le coˆut du calcul de la fonction de hachage ne soit pas trop ´elev´e. Dans cet esprit pragmatique, on peut voir la table de hachage comme un moyen simple de diviser le coˆut des listes d’association d’un facteurn, au prix de l’allocation d’un tableau de taille de l’ordre de n.

2.3.1 Compl´ement : redimensionnement dynamique

Dans un deuxi`eme temps, il est plus convenable, et ce sera aussi plus pratique, de redi-mensionner dynamiquement la table de hachage afin de maintenir le facteur de charge dans des limites raisonnables. Pour atteindre un coˆut amorti en temps constant pourput, il suffit de deux conditions (comme pour push dans le cas des piles, voir II.2.1)

ˆ La taille des tableaux internes doit suivre une progression g´eom´etrique au cours du temps.

ˆ Le coˆut du redimensionnement doit ˆetre proportionnel au nombre d’informations stock´ees dans la table au moment de ce redimensionnement.

D´efinissons d’abord une constantealphaqui est notre borne sup´erieure du facteur de charge, et une variable d’instance nbKeysqui compte le nombre d’associations effectivement pr´esentes dans la table.

final static double alpha = 4.0 ; private int nbKeys = 0 ;

final static int SIZE = 16 ;

2Un autre argument est de dire qu’il existe de l’ordre deN =K mots de taille inf´erieure `a , o`uK est le nombre de caract`eres possibles. Dans ce cas le coˆut du calcul de la fonction de hachage est enO(logN), r´eput´e ind´ependant denpournN.

Nous avons aussi chang´e la valeur de la taille par d´efaut de la table, afin de ne pas mobiliser une quantit´e cons´equente de m´emoirea priori. C’est aussi une bonne id´ee de proc´eder ainsi afin que le redimensionnement ait effectivement lieu et que le code correspondant soit test´e.

La m´ethode de redimensionnementresize, `a ajouter `a la classeHdouble la taille du tableau internet.

private void resize() {

int old_sz = t.length ; // Ancienne taille int new_sz = 2*old_sz ; // Nouvelle taille

AList [] oldT = t ; // garder une r´ef´erence sur l’ancien tableau t = new AList [new_sz] ; // Allouer le nouveau tableau

/* Ins´erer toutes les paires cl´e-information de oldT dans le nouveau tableau t */

for (int i = 0 ; i < old_sz ; i++) {

for (AList p = oldT[i] ; p != null ; p = p.next) { int h = hash(p.key) ;

t[h] = new AList (p.key, p.val, t[h]) ; }

} }

Il faut noter que la fonction de hachage hash qui transforme les cl´es en indices du tableau t d´epend de la taille det(de fait son code emploie this.t.length). Pour cette raison, le nouveau tableau est directement rang´e dans le champtde this et une r´ef´erence sur l’ancien tableau est conserv´ee dans la variable localeoldT, le temps de parcourir les paires cl´e-information contenues dans les listes de l’ancien tableau pour les ajouter dans le nouveau tableau t. Le redimension-nement n’est pas gratuit, il est mˆeme assez coˆuteux, mais il reste bien proportionnel au nombre d’informations stock´ees — sous r´eserve d’un calcul en temps constant de la fonction de hachage.

C’est la m´ethode putqui tient `a jour le comptenbKeyet appelle le m´ethoderesizequand le facteur de charge nbKeys/t.length d´epassealpha.

public void put(String key, int val) { int h = hash(key) ;

AList r = AList.getCell(key, t[h]) ; i f (r == null) {

t[h] = new AList(key, val, t[h]) ; nbKeys++ ;

i f (t.length * alpha < nbKeys) { resize() ;

} } else {

r.val = val ; }

}

Notez que le redimensionnement est, le cas ´ech´eant, effectu´eapr`es ajout d’une nouvelle associa-tion. En effet, la valeur de hachagehn’est valide que relativement `a la longueur de tableaut.

2.4 Adressage ouvert

Dans le hachage `a adressage ouvert, les ´el´ements d’informations sont stock´es directement dans le tableau. Plus pr´ecis´ement, la table de hachage est un tableau de paires cl´e-information.

Le facteur de charge α est donc n´ecessairement inf´erieur `a un. ´Etant donn´ee une cl´e k on recherche l’information associ´ee `a k d’abord dans la case d’indice h(k), puis, si cette case est occup´ee par une information de cl´ek diff´erente de k, on continue la recherche en suivant une

s´equence d’indices pr´ed´efinie, jusqu’`a trouver une case contenant une information dont la cl´e vaut k ou une une case libre. Dans le premier cas il existe un ´el´ement de cl´e k dans la table, dans le second il n’en existe pas. La s´equence la plus simple consiste `a examiner successivement les indices h(k), h(k) + 1, h(k) + 2 etc. modulo m taille de la table. C’est le sondage lin´eaire (linear probing).

Pour ajouter une information (k, v), on proc`ede exactement de la mˆeme mani`ere, jusqu’`a trouver une case libre ou une case contenant la paire (k, v). Dans les deux cas, on dispose d’une case o`u ranger (k, v), au besoin en ´ecrasant la valeur v anciennement associ´ee `a k. Selon cette technique, une fois entr´ee dans la table, une cl´e reste `a la mˆeme place dans le tableau et est acc´ed´ee selon la mˆeme s´equence, `a condition de ne pas supprimer d’informations, ce que nous supposons.

Pour coder une nouvelle impl´ementation O de la table d’association Assoc, qui utilise le hachage avec adressage ouvert. Nous d´efinissons d’abord une classe des paires cl´e-information.

class Pair {

String key ; int val ;

Pair(String key, int val) { this.key = key ; this.val = val ; } }

Les objets O poss`edent en propre un tableau d’objets Pair. Le code de la classe O est donn´e par la figure 3. Dans le constructeur, les cases du tableaunew Pair [SIZE]sont implicitement initialis´ees `a null (voir B.3.6.2), qui est justement la valeur qui permet `a getSlot d’identifier les cases (( vides )). La m´ethode getSlot, charg´ee de trouver la case o`u ranger une association en fonction de la cl´e, est appel´ee par les deux m´ethodes put et get. La relative complexit´e de getSlotjustifie cette organisation. La m´ethodegetSlotpeut ´echouer, quand la table est pleine

— notez l’emploi de la boucle do, voir B.3.4, ce qui rend la question du dimensionnement du tableau interne plus critique que dans le cas du chaˆınage.

Exercice 1 Modifier le code de la classeOafin de redimensionner automatiquement le tableau interne, d`es que le facteur de charge d´epasse une valeur critiquealpha.

final static double alpha = 0.5 ;

Solution.Comme dans le cas du chaˆınage, nous allons ´ecrire une m´ethode priv´eeresizecharg´ee d’agrandir la table quand elle devient trop charg´ee. La m´ethode putest modifi´ee pour g´erer le compte nbKeys des informations effectivement pr´esentes dans la table, et appeler resize si besoin est.

private int nbKeys = 0 ;

public void put(String key, int val) { int h = getSlot(key) ;

Pair p = t[h] ; i f (p == null) {

nbKeys++ ;

t[h] = new Pair(key, val) ;

i f (t.length * alpha < nbKeys) resize() ; } else {

p.val = val ; }

}

Fig.3 – Impl´ementation d’une table de hachage `a adressage ouvert class O implements Assoc {

private final static int SIZE = 1024 ; // Assez grand ? private Pair [] t ; // Tableau interne de paires O() { t = new Pair[SIZE] ; }

private int hash(String key) { return Math.abs(key.hashCode()) % t.length ; } /* M´ethode de recherche de la case associ´ee `a key */

private int getSlot(String key) { int h0 = hash(key) ;

int h = h0 ; do {

/* Si t[h] est (( vide )) ou contient la cl´e key, on a trouv´e */

i f (t[h] == null || key.equals(t[h].key)) return h ; /* Sinon, passer `a la case suivante */

h++ ;

i f (h >= t.length) h = 0 ; } while (h != h0) ;

throw new Error ("Table pleine") ; // On a fait le tour complet }

public int get(String key) { Pair p = t[getSlot(key)] ; i f (p == null) {

return 0 ; } else {

return p.val ; }

}

public void put(String key, int val) { int h = getSlot(key) ;

Pair p = t[h] ; i f (p == null) {

t[h] = new Pair(key, val) ; } else {

p.val = val ; }

} }

La m´ethode resize fait appel `a getSlot pour transf´erer les informations de l’ancienne `a la nouvelle table. C’est le meilleur moyen de garantir des ajouts compatibles avec les m´ethodes putetget.

private void resize() { int old_sz = t.length ; int new_sz = 2*old_sz ; Pair [] oldT = t ; t = new Pair[new_sz] ;

for (int k = 0 ; k < old_sz ; k++) { Pair p = oldT[k] ;

i f (p != null) t[getSlot(p.key)] = p ; }

} }

Il faut, comme dans le cas du chaˆınage, prendre la pr´ecaution de ranger le nouveau tableau dans la variable d’instance t avant de commencer `a calculer les valeurs de hachage dans le nouveau tableau. On note aussi queoldT[k] peut valoir null et qu’il faut en tenir compte.

On d´emontre [6, section 6.4] qu’une recherche infructueuse entraˆıne en moyenne l’examen d’environ 1/2·(1 + 1/(1−α)2) cases et une recherche fructueuse d’environ 1/2·(1 + 1/(1−α)), o`u α est le facteur de charge et sous r´eserve de hachage uniforme. Ces r´esultats ne sont stricto sensu plus valables pourα proche de un, mais les formules donnent toujours un majorant. Bref, pour un facteur de charge de 50 % on examine en moyenne pas plus de trois cases.

Le sondage lin´eaire provoque des ph´enom`enes de regroupement (en plus des collisions).

Consid´erons par exemple la table ci-dessous, o`u les cases encore libres sont en blanc :

0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 0

1

0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18

En supposant queh(k) soit distribu´e uniform´ement, la probabilit´e pour que la caseisoit choisie

`

a la prochaine insertion est la suivante

P(0) = 1/19 P(2) = 2/19 P(3) = 1/19 P(8) = 5/19 P(9) = 1/19 P(12) = 3/19 P(13) = 1/19 P(14) = 1/19 P(16) = 2/19 P(18) = 2/19 Comme on le voit, la case 8 a la plus grande probabilit´e d’ˆetre occup´ee, ce qui accentuera le le regroupement des cases 4–7. Ce ph´enom`ene se r´ev`ele rapidement quand des cl´es successives sont hach´ees sur des entiers successifs, un cas qui se pr´esente en pratique avec le hachage modulo (voir 3), quand les cl´es sont par exemple les valeurs successives d’un compteur, ou, dans des applications plus techniques, des adresses d’objets qui se suivent dans la m´emoire.

Plusieurs solutions ont ´et´e propos´ees pour ´eviter ce probl`eme. La meilleure solution consiste

`

a utiliser un double hachage. On se donne deux fonctions de hachageh:U → {0, . . . , m−1} et h :U → {1, . . . , r−1} (r < m). Ensuite le sondage est effectu´e selon la s´equence h(k) +h(k), h(k) + 2h(k), h(k) + 3h(k), etc. Les regroupements ne sont plus `a craindre essentiellement parce que l’incr´ement de la s´equence est lui aussi devenu une fonction uniforme de la cl´e. En particulier en cas de collision selon h, il n’y a aucune raison que les sondages se fassent selon le mˆeme incr´ement. Pour que le sondage puisse parcourir toute la table on prend h(k) >0 et h(k) premier avec m taille de la table. Pour ce faire on peut prendre m ´egal `a une puissance de deux eth(k) toujours impair, ou mpremier eth(k) strictement inf´erieur `a m(par exemple pour des cl´e enti`eresh(k) =kmodmet h(k) = 1 + (kmod (m−2))

Dans [6, section 6.4] D. Knuth montre que, sous des hypoth`eses de distribution uniforme et d’ind´ependance des deux fonctions de hachage, le nombre moyen de sondages pour un hachage

double est environ −ln(1−α)/α en cas de succ`es et `a 1/(1−α) en cas d’´echec.3 Le tableau ci-dessous donne quelques valeurs num´eriques :

Facteur de charge 50 % 80 % 90 % 99 %

Succ`es 1.39 2.01 2.56 4.65

Echec 2.00 5.00 10.00 100.00

Comme on le voitα = 80 % est un excellent compromis. Insistons sur le fait que les valeurs de ce tableau sont ind´ependantes de n. Par cons´equent, avec un facteur charge de 80 %, il suffit en moyenne de deux essais pour retrouver un ´el´ement, mˆeme avec dix milliards de cl´es ! Notons que pour le sondage lin´eaire cet ordre de grandeur est atteint pour des tables `a moiti´e pleines.

Tandis que pour le chaˆınage on peut aller jusqu’`a un facteur de charge d’environ 4.

En fixant ces valeurs de facteur de charge pour les trois techniques, nous ´egalisons plus ou moins les temps de recherche. Examinons alors la m´emoire occup´ee pour n associations.

Nous constatons que le chaˆınage consomme un tableau de taille n/4 plus n cellules de listes `a trois champs, soit 3 +n/4 + 5·n cases de m´emoire en Java, en tenant compte de deux cases suppl´ementaires par objet (voir II.4). Tandis que le sondage lin´eaire consomme 3 + 2·n+ 4·n

Nous constatons que le chaˆınage consomme un tableau de taille n/4 plus n cellules de listes `a trois champs, soit 3 +n/4 + 5·n cases de m´emoire en Java, en tenant compte de deux cases suppl´ementaires par objet (voir II.4). Tandis que le sondage lin´eaire consomme 3 + 2·n+ 4·n