Previous Next Contents

Chapitre 1    Tableaux

Les tableaux sont une des structures de base de l'informatique. Un tableau représente selon ses dimensions, un vecteur ou une matrice d'éléments d'un même type. Un tableau permet l'accès direct à un élément, et nous allons nous servir grandement de cette propriété dans les algorithmes de tri et de recherche en table que nous allons considérer.

1.1  Le tri

Qu'est-ce qu'un tri? On suppose qu'on se donne une suite de N nombres entiers á ai ñ, et on veut les ranger en ordre croissant au sens large. Ainsi, pour N = 10, la suite

á 18, 3, 10, 25, 9, 3, 11, 13, 23, 8 ñ

devra devenir

á 3, 3, 8, 9, 10, 11, 13, 18, 23, 25 ñ

Ce problème est un classique de l'informatique. Il a été étudié en détail, cf. la moitié du livre de Knuth [30]. En tant qu'algorithme pratique, on le rencontre souvent. Par exemple, il faut établir le classement de certains élèves, mettre en ordre un dictionnaire, trier l'index d'un livre, faire une sortie lisible d'un correcteur d'orthographe, ... Il faudra bien faire la distinction entre le tri d'un grand nombre d'éléments (plusieurs centaines), et le tri de quelques éléments (un paquet de cartes). Dans ce dernier cas, la méthode importe peu. Un algorithme amusant, bogo-tri, consiste à regarder si le paquet de cartes est déjà ordonné. Sinon, on le jette par terre. Et on recommence. Au bout d'un certain temps, on risque d'avoir les cartes ordonnées. Bien sûr, le bogo-tri peut ne pas se terminer. Une autre technique fréquemment utilisée avec un jeu de cartes consiste à regarder s'il n'y a pas une transposition à effectuer. Dès qu'on en voit une à faire, on la fait et on recommence. Cette méthode marche très bien sur une bonne distribution de cartes.

Plus sérieusement, il faudra toujours avoir à l'esprit que le nombre d'objets à trier est important. Ce n'est pas la peine de trouver une méthode sophistiquée pour trier 10 éléments. Pourtant, les exemples traités dans un cours sont toujours de taille limitée, pour des raisons pédagogiques il n'est pas possible de représenter un tri sur plusieurs milliers d'éléments. Le tri, par ses multiples facettes, est un très bon exemple d'école. En général, on exigera que le tri se fasse in situ, c'est-à-dire que le résultat soit au même endroit que la suite initiale. On peut bien sûr trier autre chose que des entiers. Il suffit de disposer d'un domaine de valeurs muni d'une relation d'ordre total. On peut donc trier des caractères, des mots en ordre alphabétique, des enregistrements selon un certain champ. On supposera, pour simplifier, qu'il existe une opération d'échange ou plus simplement d'affectation sur les éléments de la suite à trier. C'est pourquoi nous prendrons le cas de valeurs entières.

1.1.1  Méthodes de tri élémentaires

Dans tout ce qui suit, on suppose que l'on trie des nombres entiers et que ceux-ci se trouvent dans un tableau a. L'algorithme de tri le plus simple est le tri par sélection. Il consiste à trouver l'emplacement de l'élément le plus petit du tableau, c'est-à-dire l'entier m tel que ai ³ am pour tout i. Une fois cet emplacement m trouvé, on échange les éléments a1 et am. Puis on recommence ces opérations sur la suite á a2, a3, ..., aN ñ, ainsi on recherche le plus petit élément de cette nouvelle suite et on l'échange avec a2. Et ainsi de suite ... jusqu'au moment où on n'a plus qu'une suite composée d'un seul élément á aN ñ.

La recherche du plus petit élément d'un tableau est un des premiers exercices de programmation. La détermination de la position de cet élément est très similaire, elle s'effectue à l'aide de la suite d'instructions:
    m = 0;
    for (int j = 1; j < N; ++j)
        if (a[j] < a[m])
            m = i;



Figure 1.1 : Exemple de tri par sélection

L'échange de deux éléments nécessite une variable temporaire t et s'effectue par:
    t = a[m]; a[m] = a[1]; a[1] = t;
Il faut refaire cette suite d'opérations en remplacant 1 par 2, puis par 3 et ainsi de suite jusqu'à N. Ceci se fait par l'introduction d'une nouvelle variable i qui prend toutes les valeurs entre 1 et N. Ces considérations donnent lieu au programme présenté en détail ci-dessous. Pour une fois, nous l' écrivons pour une fois en totalité; les procédures d'acquisition des données et de restitution des résultats sont aussi fournies. Pour les autres algorithmes, nous nous limiterons à la description de la procédure effective de tri.

class TriSelection {

  final static int N = 10;

  static int[] a = new int[N];   // Le tableau à trier 

  static void initialisation() { // On tire au sort des nombres 
                                 // entre 0 et 127, en initialisant 
                                 // le tirage au sort sur l'heure 
      for (int i = 0; i < N; ++i)
          a[i] = (int) (Math.random() * 128);
  }

  static void impression() {
      for (int i = 0; i < N; ++i)
          System.out.print (a[i] + " ");
      System.out.println ("");
  }

  static void triSelection() {
      int   min, t;

      for (int i = 0; i < N - 1; ++i) {
          min = i;
          for (int j = i+1; j < N; ++j)
             if (a[j] < a[min])
                 min = j; 
          t = a[min]; a[min] = a[i]; a[i] = t;
      }
  }

  public static void main (String args[]) {

      initialisation();   // On lit le tableau
      impression();       // et on l'imprime
      triSelection();     // On trie
      impression();       // On imprime le résultat 
  }
}



Figure 1.2 : Exemple de tri bulle

Il est facile de compter le nombre d'opérations nécessaires. A chaque itération, on démarre à l'élément ai et on le compare successivement à ai+1, ai+2, ..., aN. On fait donc N - i comparaisons. On commence avec i = 1 et on finit avec i = N-1. Donc on fait (N-1) + (N-2) + ··· + 2 + 1 = N(N-1)/2 comparaisons, et N-1 échanges. Le tri par sélection fait donc de l'ordre de N2 comparaisons. Si N = 100, il y a 5000 comparaisons, soit 5 ms si on arrive à faire une comparaison et l'itération de la boucle for en 1µs, ce qui est tout à fait possible sur une machine plutôt rapide actuellement. On écrira que le tri par sélection est en O(N2). Son temps est quadratique par rapport aux nombres d'éléments du tableau.

Une variante du tri par sélection est le tri bulle. Son principe est de parcourir la suite á a1, a2, ..., aN ñ en intervertissant toute paire d'éléments consécutifs (aj-1, aj) non ordonnés. Ainsi après un parcours, l'élément maximum se retrouve en aN. On recommence avec le préfixe á a1, a1, ..., aN-1 ñ, ... Le nom de tri bulle vient donc de ce que les plus grands nombres se déplacent vers la droite en poussant des bulles successives de la gauche vers la droite. L'exemple numérique précédent est donné avec le tri bulle dans la figure 1.2.

La procédure correspondante utilise un indice i qui marque la fin du préfixe à trier, et l'indice j qui permet de déplacer la bulle qui monte vers la borne i. On peut compter aussi très facilement le nombre d'opérations et se rendre compte qu'il s'agit d'un tri en O(N2) comparaisons et éventuellement échanges (si par exemple le tableau est donné en ordre strictement décroissant).

static void triBulle() {

  int   t;

  for (int i = N-1; i >= 0; --i) 
      for (int j = 1; j <= i; ++j)
          if (a[j-1] > a[j]) {
              t = a[j-1]; a[j-1] = a[j]; a[j] = t;
          }
}

1.1.2  Analyse en moyenne

Pour analyser un algorithme de tri, c'est à dire déterminer le nombre moyen d'opérations qu'il effectue, on utilise le modèle des permutations. On suppose dans ce modèle que la suite des nombres à trier est la suite des entiers 1,2, ... n et l'on admet que toutes les permutations de ces entiers sont équiprobables. On peut noter que le nombre de comparaisons à effectuer pour un tri ne dépend pas des éléments à trier mais de l'ordre dans lequel ils apparaissent. Les supposer tous compris entre 1 et N n'est donc pas une hypothèse restrictive si on ne s'intéresse qu'à l'analyse et si l'on se place dans un modèle d'algorithme dont l'opération de base est la comparaison.

Pour une permutation a de {1, 2 ... n } dans lui-même, une inversion est un couple (ai, aj) tel que i < j et ai > aj. Ainsi, la permutation

á 8,1,5,10,4,2,6,7,9,3 ñ

qui correspond à l'ordre des éléments de la figure 1.1, comporte 21 inversions. Chaque échange d'éléments de la procédure TriBulle supprime une et une seule inversion et, une fois le tri terminé, il n'y a plus aucune inversion. Ainsi le nombre total d'échanges effectués est égal au nombre d'inversions dans la permutation. Calculer le nombre moyen d'échanges dans la procédure de tri bulle revient donc à compter le nombre moyen d'inversions de l'ensemble des permutations sur N éléments. Un moyen de faire ce calcul consiste à compter le nombre d'inversions dans chaque permutation à faire la somme de tous ces nombres et à diviser par N!. Ceci est plutôt fastidieux, une remarque simple permet d'aller plus vite.

L'image miroir de toute permutation a = á a1, a2 ... aN ñ est la permutation b = á aN, ..., a2, a1 ñ. Il est clair que (ai, aj) est une inversion de a si et seulement si ce n'est pas une inversion de b. La somme du nombre d'inversions de a et de celles de b est N(N-1)/2. On regroupe alors deux par deux les termes de la somme des nombres d'inversions des permutations sur N éléments et on obtient que le nombre moyen d'inversions sur l'ensemble des permutations est donné par:

N(N-1)

4

ce qui est donc le nombre moyen d'échanges dans la procédure TriBulle. On note toutefois que le nombre de comparaisons effectuées par TriBulle est le même que celui de TriSelection soit N(N-1)/2.

1.1.3  Le tri par insertion




Figure 1.3 : Exemple de tri par insertion

Une méthode complètement différente est le tri par insertion. C'est la méthode utilisée pour trier un paquet de cartes. On prend une carte, puis 2 et on les met dans l'ordre si nécessaire, puis 3 et on met la 3ème carte à sa place dans les 2 premières, ... De manière générale on suppose les i-1 premières cartes triées. On prend la i ème carte, et on essaie de la mettre à sa place dans les i-1 cartes déjà triées. Et on continue jusqu'à i = N. Ceci donne le programme suivant

static void triInsertion() {

  int   j, v;
  for (int i = 1; i < N; ++i) {
      v = a[i]; j = i;
      while (j > 0  &&  a[j-1] > v) {
          a[j] = a[j-1]; 
          --j;
      }
      a[j] = v;
  }
}
Pour classer le i ème élément du tableau a, on regarde successivement en marche arrière à partir du i-1ième. On décale les éléments visités vers la droite pour pouvoir mettre a[i] à sa juste place. Le programme précédent contient une légère erreur, si a[i] est le plus petit élément du tableau, car on va sortir du tableau par la gauche. On peut toujours y remédier en supposant qu'on rajoute un élément a[0] valant -maxint. On dit alors que l'on a mis une sentinelle à gauche du tableau a. Ceci n'est pas toujours possible, et il faudra alors rajouter un test sur l'indice j dans la boucle while. Ainsi, pour le tri par insertion, l'exemple numérique précédent est dans la figure 1.3.

Le nombre de comparaisons pour insérer un élément dans la suite triée de ceux qui le précédent est égal au nombre d'inversions qu'il présente avec ceux-ci augmenté d'une unité. Soit ci ce nombre de comparaisons. On a

ci = 1 + card {aj | aj > ai, j < i }

Pour la permutation a correspondant à la suite à trier, dont le nombre d'inversions est inv(a), le nombre total de comparaisons pour le tri par insertion est

C
 
a
=
N
S
i=2
ci = N-1 + inv(a)

D'où le nombre moyen de comparaisons

CN =
1

N!
 
S
a
C
 
a
= N-1 +
N(N-1)

4
=
N(N+3)

4
- 1

Bien que l'ordre de grandeur soit toujours N2, ce tri est plus efficace que le tri par sélection. De plus, il a la bonne propriété que le nombre d'opérations dépend fortement de l'ordre initial du tableau. Dans le cas où le tableau est presque en ordre, il y a peu d'inversions et très peu d'opérations sont nécessaires, contrairement aux deux méthodes de tri précédentes. Le tri par insertion est donc un bon tri si le tableau à trier a de bonnes chances d'être presque ordonné.

Une variante du tri par insertion est un tri dû à D. L. Shell en 1959, c'est une méthode de tri que l'on peut sauter en première lecture. Son principe est d'éviter d'avoir à faire de longues chaînes de déplacements si l'élément à insérer est très petit. Nous laissons le lecteur fanatique en comprendre le sens, et le mentionnons à titre anecdotique. Au lieu de comparer les éléments adjacents pour l'insertion, on les compare tous les ..., 1093, 364, 121, 40, 13, 4, et 1 éléments. (On utilise la suite un+1 = 3 un + 1). Quand on finit par comparer des éléments consécutifs, ils ont de bonnes chances d'être déjà dans l'ordre. On peut montrer que le tri Shell ne fait pas plus que O(N3/2) comparaisons, et se comporte donc bien sur des fichiers de taille raisonnable (5000 éléments). La démonstration est compliquée, et nous la laissons en exercice difficile. On peut prendre tout autre générateur que 3 pour générer les séquences à explorer. Pratt a montré que pour des séquences de la forme 2p 3q, le coût est O(n log2 n) dans le pire cas (mais il est coûteux de mettre cette séquence des 2p 3q dans l'ordre). Dans le cas général, le coût (dans le cas le pire) du tri Shell est toujours un problème ouvert. Le tri Shell est très facile à programmer et très efficace en pratique (c'est le tri utilisé dans le noyau Maple).



static void triShell() {

  int h = 1; do 
     h = 3*h + 1; 
  while ( h <= N );
  do {
      h = h / 3;
      for (int i = h; i < N; ++i)
          if (a[i] < a[i-h]) {
              int   v = a[i], j = i;
              do {
                  a[j] = a[j-h];
                  j = j - h;
              } while (j >= h  &&  a[j-h] > v);
              a[j] = v;
          }
  } while ( h > 1);
}

1.2  Recherche en table

Avec les tableaux, on peut aussi faire des tables. Une table contient des informations sur certaines clés. Par exemple, la table peut être un annuaire téléphonique. Les clés sont les noms des abonnés. L'information à rechercher est le numéro de téléphone. Une table est donc un ensemble de paires á nom, numéro ñ. Il y a plusieurs manières d'organiser cette table: un tableau d'enregistrement, une liste ou un arbre (comme nous le verrons plus tard). Pour l'instant, nous supposons la table décrite par deux tableaux nom et tel, indicés en parallèle, le numéro de téléphone de nom[i] étant tel[i].




Figure 1.4 : Un exemple de table pour la recherche en table

1.2.1  La recherche séquentielle

La première méthode pour rechercher un numéro de téléphone consiste à faire une recherche séquentielle (ou linéaire). On examine successivement tous les éléments de la table et on regarde si on trouve un abonné du nom voulu. Ainsi

static int recherche (String x) {

  for (int i = 0; i < N; ++i)
      if (x.equals(nom[i]))
          return tel[i];
  return -1;
}
qui peut aussi s'écrire

static int recherche (String x) {

  int   i = 0;
  while (i < N  &&  !x.equals(nom[i]))
      ++i;
  if (i < N)
      return tel[i];
  else
      return -1;
}
Si on a la place, une autre possibilité est de mettre une sentinelle au bout de la table.

static int recherche (String x) {

  int  i = 0;
  nom[N] = x; tel[N] = -1;
  while (! x.equals(nom[i]))
      ++i;
  return tel[i];
}
L'écriture de la procédure de recherche dans la table des noms est alors plus efficace, car on peut remarquer que l'on ne fait plus qu'un test là où on en faisait deux. La recherche séquentielle est aussi appelée recherche linéaire, car il est facile de montrer que l'on fait N/2 opérations en moyenne, et N opérations dans le pire cas. Sur une table de 10000 éléments, la recherche prend 5000 opérations en moyenne, soit 5ms.

Voici un programme complet utilisant la recherche linéaire en table.

class Table {

  final static int N = 6;
  static String nom[] = new String[N+1];
  static int tel[] = new int[N+1];

  static void initialisation() {
    nom[0] = "paul";  tel[0] = 2811;
    nom[1] = "roger";  tel[1] = 4501;
    nom[2] = "laure";  tel[2] = 2701;
    nom[3] = "anne";  tel[3] = 2702;
    nom[4] = "pierre";  tel[4] = 2805;
    nom[5] = "yves";  tel[5] = 2806;
  }

  static int recherche (String x) {

    for (int i = 0; i < N; ++i)
        if (x.equals(nom[i]))
            return tel[i];
    return -1;
  }

  public static void main (String args[]) {
      
    Initialisation();
    if (args.length == 1)
        System.out.println (Recherche(args[0]));
  }
}

1.2.2  La recherche dichotomique

Une autre technique de recherche en table est la recherche dichotomique. Supposons que la table des noms soit triée en ordre alphabétique (comme l'annuaire des PTT). Au lieu de rechercher séquentiellement, on compare la clé à chercher au nom qui se trouve au milieu de la table des noms. Si c'est le même, on retourne le numéro de téléphone du milieu, sinon on recommence sur la première moitié (ou la deuxième) si le nom recherché est plus petit (ou plus grand) que le nom rangé au milieu de la table. Ainsi

static void initialisation() {

  nom[0] = "anne"; tel[0] = 2702;
  nom[1] = "laure"; tel[1] = 2701;
  nom[2] = "paul"; tel[2] = 2811;
  nom[3] = "pierre"; tel[3] = 2805;
  nom[4] = "roger"; tel[4] = 4501;
  nom[5] = "yves"; tel[5] = 2806;
}

static int rechercheDichotomique (String x) {

  int   i, g, d, cmp;

  g = 0; d = N-1;
  do {
      i = (g + d) / 2;
      cmp = x.compareTo(nom[i]);
      if (cmp == 0)
          return tel[i];
      if (cmp < 0)
          d = i - 1;
      else 
          g = i + 1;
  } while (g <= d);
  return -1;
}
Le nombre CN de comparaisons pour une table de taille N est tel que CN = 1 + Cë N/2 û et C0 = 1. Donc CN » log2(N). (Dorénavant, log2(N) sera simplement écrit log N.) Si la table a 10000 éléments, on aura CN » 14. C'est donc un gain sensible par rapport aux 5000 opérations nécessaires pour la recherche linéaire. Bien sûr, la recherche linéaire est plus simple à programmer, et sera donc utilisée pour les petites tables. Pour des tables plus importantes, la recherche dichotomique est plus intéressante.

On peut montrer qu'un temps sub-logarithmique est possible si on connaît la distribution des objets. Par exemple, dans l'annuaire du téléphone, ou dans un dictionnaire, on sait a priori qu'un nom commençant par la lettre V se trouvera plutôt vers la fin. En supposant la distribution uniforme, on peut faire une règle de trois pour trouver l'indice de l'élément de référence pour la comparaison, au lieu de choisir le milieu, et on suit le reste de l'algorithme de la recherche dichotomique. Cette méthode est la recherche par interpolation. Alors le temps de recherche est en O(log log N), c'est-à-dire 4 opérations pour une table de 10000 éléments, et 5 opérations jusqu'à 109 entrées dans la table!

1.2.3  Insertion dans une table

Dans la recherche linéaire ou par dichotomie, on ne s'est pas préoccupé de l'insertion dans la table d'éléments nouveaux. C'est par exemple très peu souvent le cas pour un annuaire téléphonique. Mais cela peut être fréquent dans d'autres utilisations, comme la table des usagers d'un système informatique. Essayons de voir comment organiser l'insertion d'éléments nouveaux dans une table, dans le cas des recherches séquentielle et dichotomique.

Pour le cas séquentiel, il suffit de rajouter au bout de la table l'élément nouveau, s'il y a la place. S'il n'y a pas de place, on appelle une procédure erreur qui imprimera le message d'erreur donné en paramètre et arrêtera le programme (cf. page X). Ainsi

void insertion (String x, int val) {

    ++n;
    if (n >= N) then
        erreur ("De'bordement de la table");
    nom[n] = x;
    tel[n] = val;
}
L'insertion se fait donc en temps constant, en O(1). Dans le cas de la recherche par dichotomie, il faut maintenir la table ordonnée. Pour insérer un nouvel élément dans la table, il faut d'abord trouver son emplacement par une recherche dichotomique (ou séquentielle), puis pousser tous les éléments derrière lui pour pouvoir insérer le nouvel élément au bon endroit. Cela peut donc prendre log n + n opérations. L'insertion dans une table ordonnée de n éléments prend donc un temps O(n).

1.2.4  Hachage

Une autre méthode de recherche en table est le hachage. On utilise une fonction h de l'ensemble des clés (souvent des chaînes de caractères) dans un intervalle d'entiers. Pour une clé x, h(x) est l'endroit où l'on trouve x dans la table. Tout se passe parfaitement bien si h est une application injective. Pratiquement, on ne peut arriver à atteindre ce résultat. On tente alors de s'en approcher et on cherche aussi à minimiser le temps de calcul de h(x). Ainsi un exemple de fonction de hachage est

h(x) = (x[1] × Bl-1 + x[2] × Bl-2 + ··· + x[l]) mod N

On prend d'habitude B = 128 ou B = 256 et on suppose que la taille de la table N est un nombre premier. Pourquoi? D'abord, il faut connaître la structure des ordinateurs pour comprendre le choix de B comme une puissance de 2. En effet, les multiplications par des puissances de 2 peuvent se faire très facilement par des décalages, puisque les nombres sont représentés en base 2. En général, dans les machines ``modernes'', cette opération est nettement plus rapide que la multiplication par un nombre arbitraire. Quant à prendre N premier, c'est pour éviter toute interférence entre les multiplications par B et la division par N. En effet, si par exemple B=N=256, alors h(x) = x[l] et la fonction h ne dépendrait que du dernier caractère de x. Le but est donc d'avoir une fonction h de hachage simple à calculer et ayant une bonne distribution sur l'intervalle [0,N-1]. (Attention: il sera techniquement plus simple dans cette section sur le hachage de supposer que les indices des tableaux varient sur [0, N-1] au lieu de [1, N]). Le calcul de la fonction h se fait par la fonction h(x, l), où l est la longueur de la chaîne x,

static int h (String x) {

  int   r = 0;
  for (int i = 0; i < x.length(); ++i)
      r = ((r * B) + x.charAt(i)) % N;
  return r;
}
Donc la fonction h donne pour toute clé x une entrée possible dans la table. On peut alors vérifier si x = nom[h(x)]. Si oui, la recherche est terminée. Si non, cela signifie que la table contient une autre clé x' telle que h(x') = h(x). On dit alors qu'il y a une collision, et la table doit pouvoir gérer les collisions. Une méthode simple est de lister les collisions dans une table col parallèle à la table nom. La table des collisions donnera une autre entrée i dans la table des noms où peut se trouver la clé recherchée. Si on ne trouve pas la valeur x à cette nouvelle entrée i, on continuera avec l'entrée i' donnée par i' = col[i]. Et on continue tant que col[i] ¹ -1. La recherche est donnée par

static int recherche (String x) {

  for (int i = h(x); i != -1; i = col[i]) 
      if (x.equals(nom[i]))
          return tel[i];
  return -1;
}



Figure 1.5 : Hachage par collisions séparées

Ainsi la procédure de recherche prend un temps au plus égal à la longueur moyenne des classes d'équivalence définies sur la table par la valeur de h(x), c'est-à-dire à la longueur moyenne des listes de collisions. Si la fonction de hachage est parfaitement uniforme, il n'y aura pas de collision et on atteindra tout élément en une comparaison. Ce cas est très peu probable. Il y a des algorithmes compliqués pour trouver une fonction de hachage parfaite sur une table donnée et fixe. Mais si le nombre moyen d'éléments ayant même valeur de hachage est k = N / M, où M est grosso modo le nombre de classes d'équivalences définies par h, la recherche prendra un temps N / M. Le hachage ne fait donc que réduire d'un facteur constant le temps pris par la recherche séquentielle. L'intérêt du hachage est qu'il est souvent très efficace, tout en étant simple à programmer.

L'insertion dans une table avec le hachage précédent est plus délicate. En effet, on devrait rapidement fusionner des classes d'équivalences de la fonction de hachage, car il faut bien mettre les objets à insérer à une certaine entrée dans la table qui correspond elle-même à une valeur possible de la fonction de hachage. Une solution simple est de supposer la table de taille n telle que N £ n £ Nmax. Pour insérer un nouvel élément, on regarde si l'entrée calculée par la fonction h est libre, sinon on met le nouvel élément au bout de la table, et on chaîne les collisions entre elles par un nouveau tableau col. (Les tableaux nom, tel et col sont maintenant de taille Nmax). On peut choisir de mettre le nouvel élément en tête ou à la fin de la liste des collisions; ici on le mettra en tête. Remarque: à la page X, tous les outils seront développés pour enchaîner les collisions par des listes; comme nous ne connaissons actuellement que les tableaux comme structure de donnée, nous utilisons le tableau col. L'insertion d'un nouvel élément dans la table s'écrit

static void insertion (String x, int val) {

  int   i = h(x);

  if (nom[i] == null) {
      nom[i] = x;
      tel[i] = val;
  } else
  if (n >= Nmax)
      erreur ("Débordement de la table");
  else {
      nom[n] = x;
      tel[n] = val;
      col[n] = col[i];      // On met la nouvelle entrée en tête
      col[i] = n;           // de la liste des collisions de sa 
      ++n;                  // classe d'équivalence. 
  }
}
Au début, on suppose n = N, nom[i] = "" (chaîne vide) et col[i] = -1 pour 0 £ i < N . La procédure d'insertion est donc très rapide et prend un temps constant O(1).

Une autre technique de hachage est de faire un hachage à adressage ouvert. Le principe en est très simple. Au lieu de gérer des collisions, si on voit une entrée occupée lors de l'insertion, on range la clé à l'entrée suivante (modulo la taille de la table). On suppose une valeur interdite dans les clés, par exemple la chaîne vide '', pour désigner une entrée libre dans la table. Les procédures d'insertion et de recherche s'écrivent très simplement comme suit



Figure 1.6 : Hachage par adressage ouvert

static int recherche (String x) {

  int   i = h(x);

  while (nom[i] != null) { 
      if (x.equals(nom[i]))
          return tel[i];
      i = (i+1) % N;
  }
  return -1;
}

static void insertion (String x, int val) {
  int   i;

  if (n >= N)
      erreur ("De'bordement de la table");
  ++n;
  i = h(x);
  while ((nom[i] != null) && ! x.equals(nom[i]))
      i = (i+1) % N;
  nom[i] = x;
  tel[i] = val;
}
Dans le cas où la clé à insérer se trouverait déjà dans la table, l'ancien numéro de téléphone est écrasé, ce qui est ce que l'on veut dans le cas présent. Il est intéressant de reprendre les méthodes de recherche en table déjà vues et de se poser ce problème. Plus intéressant, on peut se demander si cette méthode simple de hachage linéaire est très efficace, et si on ne risque pas, en fait, d'aboutir à une recherche séquentielle standard. Notre problème est donc de comprendre la contiguïté de la fonction de hachage, et la chance que l'on peut avoir pour une valeur donnée de h(x) d'avoir aussi les entrées h(x) + 1, h(x) + 2, h(x) + 3 ... occupées. On peut démontrer que, si la fonction de hachage est uniforme et si a = n/Nmax est le taux d'occupation de la table, le nombre d'opérations est:

Donc si a=2/3, on fait 2 ou 5 opérations, si a=90%, on en fait 5 ou 50. La conclusion est donc que, si on est prêt à grossir la table de 50%, le temps de recherche est très bon, avec une méthode de programmation très simple.

Une méthode plus subtile, que l'on peut ignorer en première lecture, est d'optimiser le hachage à adressage ouvert précédent en introduisant un deuxième niveau de hachage. Ainsi au lieu de considérer l'élément suivant dans les procédures de recherche et d'insertion, on changera les instructions

        i := (i+1) mod Nmax;
en

        i := (i+u) mod Nmax;
u = h2 (x, l) est une deuxième fonction de hachage. Pour éviter des phénomènes de périodicité, il vaut mieux prendre u et Nmax premiers entre eux. Une méthode simple, comme Nmax est déjà supposé premier, est de faire u < Nmax. Par exemple, h2(x, l) = 8 - (x[l] mod 8) est une fonction rapide à calculer, et qui tient compte des trois derniers bits de x. On peut se mettre toujours dans le cas de distributions uniformes, et de fonctions h et h2 ``indépendantes''. Alors on montre que le nombre d'opérations est en moyenne:

en fonction du taux d'occupation a de la table. Numériquement, pour a = 80%, on fait 3 ou 5 opérations, pour a = 99%, on fait 7 ou 100. Ce qui est tout à fait raisonnable.

Le hachage est très utilisé pour les correcteurs d'orthographe. McIlroy1 a calculé que, dans un article scientifique typique, il y a environ 20 erreurs d'orthographe (c'est-à-dire des mots n'apparaissant pas dans un dictionnaire), et qu'une collision pour 100 papiers est acceptable. Il suffit donc de faire un correcteur probabiliste qui ne risque de se tromper qu'un cas sur 2000. Au lieu de garder tout le dictionnaire en mémoire, et donc consommer beaucoup de place, il a utilisé un tableau de n bits. Il calcule k fonctions hi de hachage indépendantes pour tout mot w, et regarde si les k bits positionnés en hi(w) valent simultanément 1. On est alors sûr qu'un mot n'est pas dans le dictionnaire si la réponse est négative, mais on pense qu'on a de bonnes chances qu'il y soit si la réponse est oui. Un calcul simple montre que la probabilité P pour qu'un mot d'un dictionnaire de d entrées ne positionne pas un bit donné est P = e-dk/n. La probabilité pour qu'une chaîne quelconque soit reconnue comme un mot du dictionnaire est (1-P)k. Si on veut que cette dernière vaille 1/2000, il suffit de prendre P = 1/2 et k = 11. On a alors n/d = k/ln 2 = 15,87. Pour un dictionnaire de 25000 mots, il faut donc 400000 bits (50 kO), et, pour un de 200000 mots, if faut 3200000 bits (400 kO). McIlroy avait un pdp11 et 50 kO était insupportable. Il a compressé la table en ne stockant que les nombres de 0 entre deux 1, ce qui a ramené la taille à 30 kO. Actuellement, la commande spell du système Unix utilise k = 11 et une table de 400000 bits.2

1.3  Programmes en Caml

(* Tri par sélection, page X *)
#open "printf";;

let nMax = 10;;
let a = make_vect nMax 0;;

let initialisation () =
  for i = 0 to nMax - 1 do
    a.(i) <- random__int 100
  done;;

let impression () =
  for i = 0 to nMax - 1 do
    printf "%3d " a.(i)
  done;
  printf "\n";;

let tri_sélection () =
  let min = ref 0 in
  for i = 0 to nMax - 2 do
    min := i;
    for j = i + 1 to nMax - 1 do
      if a.(j) < a.(!min) then min := j
    done;
    let t = a.(!min) in
    a.(!min) <- a.(i); a.(i) <- t
  done;;

let main () =
  (* On initialise le tableau *)
  initialisation ();
  (* On trie *)
  tri_sélection* ();
  (* On imprime le résultat *)
  impression ();
  exit 0;;


(* Style plus Caml *)

let initialisation a =
  for i = 0 to vect_length a - 1 do
    a.(i) <- random__int 100
  done;;

let impression a =
  for i = 0 to vect_length a - 1 do
    print_int a.(i); print_string " ";
  done;
  print_newline();;

let main () =
  let a = make_vect nMax 0 in 
  initialisation (a);
  tri_sélection (a);
  impression (a);
  exit 0;;

(* Tri bulle, voir page X *)
let tri_bulle a = 
  let j = ref 0 in
  let v = ref 0 in
  for i = vect_length a - 1 downto 0 do
    for j = 1 to i do
      if a.(j-1) > a.(j) then let t = ref a.(j-1) in begin
        a.(j-1) <- a.(j); a.(j) <- !t
        end
    done
  done;;

(* Tri par insertion, voir page X *)
let tri_insertion a =
  let j = ref 0 in
  let v = ref 0 in
  for i = 1 to vect_length a - 1 do
    v := a.(i); j := i;
    while !j > 0 && a.(!j - 1) > !v do
      a.(!j) <- a.(!j - 1);
      decr j
    done;
    a.(!j) <- !v;
  done;;

(* Tri Shell, voir page X *)
let tri_shell a =
  let h = ref 1 in
   while !h <= vect_length a - 1 do
    h := 3 * !h + 1
   done;
   while !h > 1 do
     h := !h / 3;
     for i = !h to vect_length a - 1 do
       if a.(i) < a.(i - !h) then begin
         let v = a.(i) in
         let j = ref i in
         while !j >= !h && a.(!j - !h) > v do
           a.(!j) <- a.(!j - !h);
           j := !j - !h
         done;
         a.(!j) <- v 
       end    
     done
   done;;

(* Recherche 1, voir page X *)
exception Found of int;;

let recherche s bottin =
  try 
    for i = 0 to vect_length bottin - 1 do
      let nom, tel = bottin.(i) in
      if nom = s then raise (Found tel)
    done;
    raise Not_found
  with Found tel -> tel;;

(* Recherche 3, voir page X *)
let recherche s bottin =
  nom.(vect_length nom - 1) <- (s, 0);
  let i = ref 0 in
  while fst (bottin.(!i)) <> s do
    incr i
  done;
  if !i = vect_length bottin
  then raise Not_found
  else
    let _, tel = bottin.(!i) in tel;;

exception Found of int;;

let bottin =
 [| "paul",   2811;
    "roger",  4501;
    "laure",  2701;
    "anne",   2702;
    "pierre", 2805;
    "yves",   2806 
 |];;

// Encore une autre manière d'écrire la fonction recherche
let recherche s bottin = begin
  try 
  do_vect
    (function nom, tel -> 
       if nom = s then raise (Found tel))
    bottin;
  raise Not_found
  with Found t -> t
  end;;

(* Lecture des données *)
while true do
  printf "%d\n"
    (recherche (input_line stdin) bottin)
done;;

(* Recherche dichotomique, voir page X *)
let nom =
  [| "anne"; "laure"; "paul";
     "pierre"; "roger"; "yves" |];;
let tel =
  [| 2702;   2701;    2811;
     2805;     4501;    2806 |];;

let recherche_dichotomique x =
  let g = ref 0
  and d = ref (vect_length nom - 1)
  and i = ref 0 in
  while x <> nom.(!i) && !g <= !d do
    i := (!g + !d) / 2;
    if x < nom.(!i) then d := !i - 1
    else g := !i + 1;
  done;
  if x = nom.(!i) then tel.(!i) else (-1);;

(* Insertion 1, voir page X *)
let n = ref 0;;

let insertion x val =
  if !n >= vect_length nom
    then failwith "Débordement de la table";
  nom.(n) <- x;
  tel.(n) <- val;
  incr n;;

(* Fonction de hachage, voir page X *)
let N = vect_length nom - 1
and B = 128;;

let h s =
  let result = ref 0 in
  for i = 0 to string_length s - 1 do
    result :=
      (!result * B + (int_of_char (s.[i]))) mod N
  done;
  !result;;

(* Recherche avec hachage, voir page X *)
let col = make_vect (vect_length nom)  (-1);;

let recherche x =
  let i = ref (h x) in
  while nom.(!i) <> x &&
        col.(!i) <> -1
  do i := col.(!i) done;
  if x = nom.(!i) then tel.(!i) else -1;;

(* Insertion avec hachage, voir page X *)
let nom = make_vect nMax "";;
let tel = make_vect nMax 0;;
let col = make_vect nMax (-1);;

let n = ref 0;;

let insertion x val =
  let i = h x in
  if nom.(i) = "" then begin
    nom.(i) <- x;
    tel.(i) <- val
  end else begin
  if !n >= nMax
    then failwith "Débordement de la table"
    else begin
      nom.(!n) <- x;
      tel.(!n) <- val;
      col.(!n) <- col.(i);  (* On met la nouvelle entrée en tête *)
      col.(i) <- !n;        (* de la liste des collisions *)
      incr n                (* de sa classe d'équivalence *)
    end;
  end;;

(* Hachage avec adressage ouvert, voir page X *)

let recherche x =
  let i = ref (h x) in
  while nom.(!i) <> x && nom.(!i) <> "" do
    i := (!i + 1) mod N
  done;
  if nom.(!i) = x then tel.(!i) else -1;;

let insertion x val =
  if !n > N
    then failwith "Débordement de la table";
  incr n;
  let i = ref (h x) in
  while nom.(!i) <> x && nom.(!i) <> "" do
    i := (!i + 1) mod N
  done;
  nom.(!i) <- x;
  tel.(!i) <- val;;

1
Doug McIlroy était le chef de l'équipe qui a fait le système Unix à Bell laboratories.
2
Paul Zimmermann a remarqué que les dictionnaires français sont plus longs que les dictionnaires anglais à cause des conjugaisons des verbes. Il a reprogrammé la commande spell en français en utilisant des arbres digitaux qui partagent les préfixes et suffixes des mots d'un dictionnaire français de 200000 mots Le dictionnaire tient alors dans une mémoire de 500 kO. Son algorithme est aussi rapide et exact (non probabiliste).

Previous Next Contents